From: Jérôme Benoit Date: Sat, 3 Oct 2026 19:09:31 +0000 (+0200) Subject: fix(reforcexy): preserve native duration consumers and coverage provenance X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=9a943b11b25be39a4774c0293ccde42ba9ad6e4d;p=freqai-strategies.git fix(reforcexy): preserve native duration consumers and coverage provenance --- diff --git a/ReforceXY/.basedpyright/diagnostics.json b/ReforceXY/.basedpyright/diagnostics.json index 4309409..3025216 100644 --- a/ReforceXY/.basedpyright/diagnostics.json +++ b/ReforceXY/.basedpyright/diagnostics.json @@ -3,203 +3,203 @@ "diagnostics": [ { "endCharacter": 58, - "endLine": 323, + "endLine": 324, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Type \"RewardParamValue\" is not assignable to declared type \"bool | None\"\n  Type \"RewardParamValue\" is not assignable to type \"bool | None\"\n    Type \"float\" is not assignable to type \"bool | None\"\n      \"float\" is not assignable to \"bool\"\n      \"float\" is not assignable to \"None\"", "rule": "reportAssignmentType", "severity": "error", "startCharacter": 18, - "startLine": 323 + "startLine": 324 }, { "endCharacter": 92, - "endLine": 581, + "endLine": 597, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"RewardParamValue\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"RewardParamValue\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"", "rule": "reportArgumentType", "severity": "error", "startCharacter": 22, - "startLine": 581 + "startLine": 597 }, { "endCharacter": 92, - "endLine": 581, + "endLine": 597, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"RewardParamValue\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"RewardParamValue\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n ...", "rule": "reportArgumentType", "severity": "error", "startCharacter": 22, - "startLine": 581 + "startLine": 597 }, { "endCharacter": 5, - "endLine": 2194, + "endLine": 2210, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"cut\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 17, - "startLine": 2189 + "startLine": 2205 }, { "endCharacter": 21, - "endLine": 2191, + "endLine": 2207, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"_Array1D[Any]\" cannot be assigned to parameter \"bins\" of type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\" in function \"cut\"\n  Type \"_Array1D[Any]\" is not assignable to type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"int\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Sequence[float]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[int]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[float]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"IntervalIndex[Interval[Any]]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Series[Any]\"", "rule": "reportArgumentType", "severity": "error", "startCharacter": 13, - "startLine": 2191 + "startLine": 2207 }, { "endCharacter": 5, - "endLine": 2216, + "endLine": 2273, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n  Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n    \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n      Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n      Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"", "rule": "reportAssignmentType", "severity": "error", "startCharacter": 34, - "startLine": 2212 + "startLine": 2269 }, { "endCharacter": 43, - "endLine": 2213, + "endLine": 2270, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"__init__\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 8, - "startLine": 2213 + "startLine": 2270 }, { "endCharacter": 42, - "endLine": 2213, + "endLine": 2270, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n  Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n    \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n      \"__iter__\" is not present", "rule": "reportArgumentType", "severity": "error", "startCharacter": 13, - "startLine": 2213 + "startLine": 2270 }, { "endCharacter": 60, - "endLine": 2473, + "endLine": 2532, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"importances_mean\" for class \"dict[Unknown, Bunch]\"\n  Attribute \"importances_mean\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 44, - "startLine": 2473 + "startLine": 2532 }, { "endCharacter": 58, - "endLine": 2474, + "endLine": 2533, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"importances_std\" for class \"dict[Unknown, Bunch]\"\n  Attribute \"importances_std\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 43, - "startLine": 2474 + "startLine": 2533 }, { "endCharacter": 88, - "endLine": 2493, + "endLine": 2552, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"columns\" for class \"NDArray[Unknown]\"\n  Attribute \"columns\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 81, - "startLine": 2493 + "startLine": 2552 }, { "endCharacter": 88, - "endLine": 2493, + "endLine": 2552, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"columns\" for class \"list[Unknown]\"\n  Attribute \"columns\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 81, - "startLine": 2493 + "startLine": 2552 }, { "endCharacter": 17, - "endLine": 2502, + "endLine": 2561, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Object of type \"None\" cannot be called", "rule": "reportOptionalCall", "severity": "error", "startCharacter": 28, - "startLine": 2496 + "startLine": 2555 }, { "endCharacter": 29, - "endLine": 3101, + "endLine": 3160, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"ptp\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 11, - "startLine": 3101 + "startLine": 3160 }, { "endCharacter": 28, - "endLine": 3101, + "endLine": 3160, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"a\" of type \"_ArrayLikeNumeric_co\" in function \"ptp\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeNumeric_co\"\n    Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeNumeric_co\"\n      \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n ...", "rule": "reportArgumentType", "severity": "error", "startCharacter": 18, - "startLine": 3101 + "startLine": 3160 }, { "endCharacter": 65, - "endLine": 3110, + "endLine": 3169, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"mean\" for class \"Categorical[object]\"\n  Attribute \"mean\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 61, - "startLine": 3110 + "startLine": 3169 }, { "endCharacter": 65, - "endLine": 3110, + "endLine": 3169, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"mean\" for class \"ExtensionArray\"\n  Attribute \"mean\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 61, - "startLine": 3110 + "startLine": 3169 }, { "endCharacter": 5, - "endLine": 4022, + "endLine": 4081, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n  Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n    \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n      Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n      Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"", "rule": "reportAssignmentType", "severity": "error", "startCharacter": 34, - "startLine": 4018 + "startLine": 4077 }, { "endCharacter": 43, - "endLine": 4019, + "endLine": 4078, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"__init__\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 8, - "startLine": 4019 + "startLine": 4078 }, { "endCharacter": 42, - "endLine": 4019, + "endLine": 4078, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n  Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n    \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n      \"__iter__\" is not present", "rule": "reportArgumentType", "severity": "error", "startCharacter": 13, - "startLine": 4019 + "startLine": 4078 }, { "endCharacter": 22, diff --git a/ReforceXY/reward_space_analysis/README.md b/ReforceXY/reward_space_analysis/README.md index 6f63013..a2530ad 100644 --- a/ReforceXY/reward_space_analysis/README.md +++ b/ReforceXY/reward_space_analysis/README.md @@ -324,6 +324,17 @@ conversion to Python integers, without binary64 narrowing or a fixed-width cap. Adjustment metadata for finite native durations records the final normalized integer. Strict validation checks a numeric gamma in its native precision before normalizing it. +Simulation consumes extended integer durations without a binary64 cap. Ordinary +fractional duration caps retain float-product truncation; extended caps use the +supplied ratio's exact rational value. Duration relationship tables keep candle +units when their float bounds and labels are safe. At larger scales, the same +12 right-closed uniform bins use exact assignment and explicitly declared +`duration / max_trade_duration_candles` coordinates; the first bin includes zero. +Report configuration retains exact integer durations without changing Python's +integer-string digit limit. Non-finite explicit Python/NumPy trade-duration +overrides in the idle-cap helper use the configured duration, then the canonical +default; an explicit idle threshold retains precedence. + Analysis-specific numeric string/bool coercion is retained; it does not replace the stricter runtime configuration contract. Direct gamma calculation falls back to 0.95 for invalid/non-finite input and clamps finite out-of-range values before diff --git a/ReforceXY/reward_space_analysis/reward_space_analysis.py b/ReforceXY/reward_space_analysis/reward_space_analysis.py index 7dbc343..355efa7 100644 --- a/ReforceXY/reward_space_analysis/reward_space_analysis.py +++ b/ReforceXY/reward_space_analysis/reward_space_analysis.py @@ -16,6 +16,7 @@ import numbers import pickle import random import warnings +from decimal import Decimal from enum import Enum, IntEnum from pathlib import Path from typing import TYPE_CHECKING, Any, Final, Literal @@ -543,6 +544,17 @@ def _compute_duration_ratio(trade_duration: int, max_trade_duration_candles: int return trade_duration / max(1, max_trade_duration_candles) +def _scale_duration(duration: int, ratio: float) -> int: + """Keep ordinary float truncation; scale extended integers without narrowing.""" + if duration.bit_length() <= 53: + scaled = duration * ratio + if math.isfinite(scaled): + return int(scaled) + numerator, denominator = ratio.as_integer_ratio() + product = duration * numerator + return product // denominator if product >= 0 else -(-product // denominator) + + def _resolve_pnl_target(profit_aim: float, risk_reward_ratio: float) -> float: """Resolve the effective target used by runtime rewards and analysis diagnostics.""" target = float(profit_aim * risk_reward_ratio) @@ -569,14 +581,18 @@ def _fail_safely(reason: str) -> float: def get_max_idle_duration_candles( params: RewardParams, *, - max_trade_duration_candles: int | None = None, + max_trade_duration_candles: float | np.integer[Any] | np.floating[Any] | None = None, ) -> int: mtd = ( - int(max_trade_duration_candles) + _get_int_param( + {"max_trade_duration_candles": max_trade_duration_candles}, + "max_trade_duration_candles", + 0, + ) if isinstance(max_trade_duration_candles, (int, float, np.integer, np.floating)) - else None + else 0 ) - if mtd is None or mtd <= 0: + if mtd <= 0: mtd = _get_int_param(params, "max_trade_duration_candles") if mtd <= 0: mtd = int(DEFAULT_MODEL_REWARD_PARAMETERS.get("max_trade_duration_candles", 128)) @@ -1629,7 +1645,7 @@ def _sampling_probabilities( duration_ratio = _compute_duration_ratio(trade_duration, max_trade_duration_candles) - base_exit_prob = 1.0 / max(1, int(max_trade_duration_candles)) + base_exit_prob = 1 / max(1, int(max_trade_duration_candles)) base_exit_prob = float( np.clip(base_exit_prob, _SAMPLE_EXIT_PROBABILITY_MIN, _SAMPLE_EXIT_PROBABILITY_MAX) ) @@ -1797,7 +1813,7 @@ def simulate_samples( max_idle_duration_candles = get_max_idle_duration_candles( params, max_trade_duration_candles=max_trade_duration_candles ) - max_trade_duration_cap = int(max_trade_duration_candles * max_duration_ratio) + max_trade_duration_cap = _scale_duration(max_trade_duration_candles, max_duration_ratio) samples: list[dict[str, float | None]] = [] prev_potential: float = 0.0 @@ -2193,6 +2209,13 @@ def _binned_stats( include_lowest=True, duplicates="drop", ) + return _aggregate_binned_stats(df, target, categories) + + +def _aggregate_binned_stats( + df: pd.DataFrame, target: str, categories: pd.Series[Any] | pd.Categorical[str] +) -> pd.DataFrame: + """Aggregate every bin, including empty bins, without changing reward observations.""" aggregated = ( pd.DataFrame({"bin": categories, target: df[target]}) .dropna(subset=["bin"]) @@ -2203,6 +2226,40 @@ def _binned_stats( return aggregated +# Three-duration endpoints, pandas' precision=3 rounding, and a factor-two margin. +_DURATION_BIN_FLOAT_MAX: Final[int] = int(np.finfo(float).max) // 6000 + + +def _duration_binned_stats( + df: pd.DataFrame, column: str, target: str, max_duration: int +) -> pd.DataFrame: + """Use exact category assignment when candle-valued float bins cannot be rendered.""" + if max_duration <= _DURATION_BIN_FLOAT_MAX: + return _binned_stats(df, column, target, np.linspace(0, max_duration * 3.0, 13)) + + def bin_code(value: Any) -> int: + if pd.isna(value): + return -1 + if isinstance(value, (int, np.integer)): + numerator, denominator = int(value), 1 + elif not np.isfinite(value): + return 11 if value > 0 else 0 + else: + numerator, denominator = value.as_integer_ratio() + # ceil(4 * value / max_duration) - 1 implements right-closed bins exactly. + return min(11, max(0, (4 * numerator - 1) // (max_duration * denominator))) + + labels = [f"{'[' if i == 0 else '('}{i / 4:g}, {(i + 1) / 4:g}]" for i in range(12)] + categories = pd.Categorical.from_codes( + np.fromiter((bin_code(value) for value in df[column]), dtype=np.int8, count=len(df)), + categories=pd.Index(labels), + ordered=True, + ) + aggregated = _aggregate_binned_stats(df, target, categories) + aggregated.index.name = "duration / max_trade_duration_candles" + return aggregated + + def _compute_relationship_stats(df: pd.DataFrame) -> dict[str, Any]: """Return binned stats dict for idle, trade duration and pnl (uniform bins). @@ -2216,8 +2273,6 @@ def _compute_relationship_stats(df: pd.DataFrame) -> dict[str, Any]: else {} ) max_trade_duration_candles = _get_int_param(reward_params, "max_trade_duration_candles") - idle_bins = np.linspace(0, max_trade_duration_candles * 3.0, 13) - trade_bins = np.linspace(0, max_trade_duration_candles * 3.0, 13) exit_pnl = df.get("exit_pnl", df["pnl"]).fillna(df["pnl"]) pnl_min = float(exit_pnl.min()) pnl_max = float(exit_pnl.max()) @@ -2225,8 +2280,12 @@ def _compute_relationship_stats(df: pd.DataFrame) -> dict[str, Any]: pnl_max = pnl_min + 1e-6 pnl_bins = np.linspace(pnl_min, pnl_max, 13) - idle_stats = _binned_stats(df, "idle_duration", "reward_idle", idle_bins) - hold_stats = _binned_stats(df, "trade_duration", "reward_hold", trade_bins) + idle_stats = _duration_binned_stats( + df, "idle_duration", "reward_idle", max_trade_duration_candles + ) + hold_stats = _duration_binned_stats( + df, "trade_duration", "reward_hold", max_trade_duration_candles + ) exit_stats = _binned_stats(df.assign(exit_pnl=exit_pnl), "exit_pnl", "reward_exit", pnl_bins) idle_stats = idle_stats.round(6) @@ -4041,7 +4100,7 @@ def write_complete_statistical_analysis( def _fmt_val(v: Any, ndigits: int = 6) -> str: try: if isinstance(v, numbers.Integral): - return f"{int(v)}" + return str(Decimal(int(v))) elif isinstance(v, numbers.Real): fv = float(v) if math.isnan(fv): @@ -4222,11 +4281,11 @@ def write_complete_statistical_analysis( f.write(f"| exit_potential_mode | {exit_mode} |\n") f.write(f"| potential_gamma | {potential_gamma} |\n") # Additional configuration details - f.write(f"| max_trade_duration_candles | {max_trade_duration_candles} |\n") + f.write(f"| max_trade_duration_candles | {Decimal(max_trade_duration_candles)} |\n") max_idle_duration_candles = get_max_idle_duration_candles( reward_params, max_trade_duration_candles=max_trade_duration_candles ) - f.write(f"| max_idle_duration_candles | {max_idle_duration_candles} |\n") + f.write(f"| max_idle_duration_candles | {Decimal(max_idle_duration_candles)} |\n") f.write(f"| strict_diagnostics | {strict_diagnostics} |\n") f.write(f"| skip_feature_analysis | {skip_feature_analysis} |\n") f.write(f"| skip_partial_dependence | {skip_partial_dependence} |\n") @@ -4244,7 +4303,13 @@ def write_complete_statistical_analysis( continue # already printed explicitly try: if k in reward_params and reward_params[k] != default_v: - overrides_pairs.append(f"{k}={reward_params[k]}") + value = reward_params[k] + rendered = ( + str(Decimal(int(value))) + if isinstance(value, numbers.Integral) and not isinstance(value, bool) + else str(value) + ) + overrides_pairs.append(f"{k}={rendered}") except Exception: continue if overrides_pairs: diff --git a/ReforceXY/reward_space_analysis/tests/README.md b/ReforceXY/reward_space_analysis/tests/README.md index 67e6dea..040bc2d 100644 --- a/ReforceXY/reward_space_analysis/tests/README.md +++ b/ReforceXY/reward_space_analysis/tests/README.md @@ -233,6 +233,15 @@ Columns: | pbrs-native-duration-bounds-151 | pbrs | Floating duration bounds are checked before truncation without narrowing | pbrs/test_pbrs.py::test_native_duration_bounds_precede_truncation | Extended-precision cases run when supported by the platform | | pbrs-native-gamma-reward-148 | pbrs | Finite out-of-range native gamma clamps reach the public reward | pbrs/test_pbrs.py::test_native_finite_gamma_clamps_the_public_reward | Extended-range cases run when supported by the platform | | pbrs-relaxed-native-gamma-149 | pbrs | Relaxed native gamma clamps reach rewards while strict validation rejects | pbrs/test_pbrs.py::test_relaxed_native_gamma_clamps_before_reward_calculation | Preserves direct, strict and relaxed boundary contracts | +| api-native-duration-consumers-152 | api | Validated extended native durations reach real trajectories and finite rewards | api/test_api_helpers.py::test_simulation_consumes_extended_native_duration_without_narrowing | Includes the actual exit hazard and terminal state | +| api-fractional-duration-cap-153 | api | Ordinary fractional caps retain truncation and rounded boundaries | api/test_api_helpers.py::test_simulation_preserves_small_fractional_duration_caps | Includes zero cap and one-third / one-seventh ratios | +| api-nonfinite-duration-fallback-154 | api | Explicit non-finite native durations preserve configured/default idle-cap precedence | api/test_api_helpers.py::test_nonfinite_explicit_duration_uses_configured_then_default_idle_cap | Python and NumPy NaN and both infinities | +| api-unbounded-duration-report-155 | api | Public reports retain exact duration configuration beyond integer-string digit limits | api/test_api_helpers.py::test_public_report_preserves_unbounded_duration_configuration | Leaves interpreter globals and boolean override semantics unchanged | +| statistics-extended-duration-mass-156 | statistics | Large-duration bins retain all observations, reward means and declared ratio units | statistics/test_statistics.py::test_extended_duration_relationship_bins_preserve_mass_and_reward_means | Actual simulator output | +| statistics-exact-duration-boundary-157 | statistics | Adjacent integer observations at extended right-closed boundaries stay distinguishable | statistics/test_statistics.py::test_extended_duration_bins_distinguish_exact_right_closed_boundaries | Includes outside-range clipping | +| statistics-candle-duration-units-158 | statistics | Ordinary duration relationship tables retain candle-valued boundaries | statistics/test_statistics.py::test_ordinary_duration_relationship_bins_retain_candle_units | Unchanged ordinary output scale | +| statistics-nullable-duration-bins-159 | statistics | Nullable numeric duration observations remain excluded from aggregates at extended scales | statistics/test_statistics.py::test_extended_duration_bins_preserve_nullable_observation_semantics | Counts and reward means use only nonmissing observations | + ### Non-Owning Smoke / Reference Checks diff --git a/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py b/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py index 2b76b19..2318d34 100644 --- a/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py +++ b/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Tests for public API and helper functions.""" +import ast import math import random +import sys import tempfile import unittest +from decimal import Decimal from pathlib import Path from typing import Any, cast from unittest import mock @@ -23,7 +26,9 @@ from reward_space_analysis import ( _get_str_param, _sample_action, build_argument_parser, + get_max_idle_duration_candles, parse_overrides, + validate_reward_parameters, write_complete_statistical_analysis, ) @@ -37,6 +42,111 @@ pytestmark = pytest.mark.api class TestAPIAndHelpers(RewardSpaceTestBase): """Public API + helper utility tests.""" + def test_nonfinite_explicit_duration_uses_configured_then_default_idle_cap(self): + """Non-finite native overrides must not bypass configured/default precedence.""" + for scalar in (float, np.float32, np.float64, np.longdouble): + for raw in ("nan", "inf", "-inf"): + value = scalar(raw) + for params, expected in ( + ({"max_trade_duration_candles": 7}, 28), + ({}, 512), + ({"max_trade_duration_candles": 0}, 512), + ({"max_trade_duration_candles": 7, "max_idle_duration_candles": 13}, 13), + ): + with self.subTest(scalar=scalar, raw=raw, params=params): + self.assertEqual( + get_max_idle_duration_candles(params, max_trade_duration_candles=value), + expected, + ) + self.assertEqual( + get_max_idle_duration_candles( + {"max_trade_duration_candles": 7}, max_trade_duration_candles=np.float32(3.75) + ), + 12, + ) + self.assertEqual( + get_max_idle_duration_candles({}, max_trade_duration_candles=10**400), + 4 * 10**400, + ) + + def test_simulation_consumes_extended_native_duration_without_narrowing(self): + """Validated large durations remain usable by trajectory and hazard consumers.""" + values = [10**308, 10**400] + if np.finfo(np.longdouble).maxexp > 1024: + values.append(np.longdouble("1e400")) + for value in values: + with self.subTest(value_type=type(value), magnitude=int(value).bit_length()): + params, _ = validate_reward_parameters( + self.base_params(max_trade_duration_candles=value), + strict=True, + ) + df = simulate_samples_with_defaults( + params, + num_samples=32, + seed=42, + trading_mode="spot", + ) + self.assertEqual( + df.attrs["reward_params"]["max_trade_duration_candles"], int(value) + ) + self.assertTrue( + np.isfinite(df[["reward", "duration_ratio", "idle_ratio"]]).all().all() + ) + held = df[df["position"] == Positions.Long.value] + self.assertGreater(held["trade_duration"].max(), 0) + self.assertTrue(held["sample_exit_prob"].eq(0.002).all()) + self.assertTrue(df.iloc[-1]["terminated"]) + self.assertEqual(df.iloc[-1]["next_position"], Positions.Neutral.value) + + def test_simulation_preserves_small_fractional_duration_caps(self): + """Keep ordinary float-product truncation, including zero and rounded boundaries.""" + for duration, ratio, expected in ((1, 0.5, 0), (3, 1 / 3, 1), (7, 1 / 7, 1), (10, 0.3, 3)): + with self.subTest(duration=duration, ratio=ratio): + df = simulate_samples_with_defaults( + self.base_params(max_trade_duration_candles=duration), + num_samples=8, + seed=42, + trading_mode="spot", + max_duration_ratio=ratio, + ) + self.assertEqual(df["next_trade_duration"].max(), expected) + + def test_public_report_preserves_unbounded_duration_configuration(self): + """The real report retains exact integer configuration beyond Python digit limits.""" + duration = 10**4900 + digit_limit = sys.get_int_max_str_digits() + df = simulate_samples_with_defaults( + self.base_params(max_trade_duration_candles=duration, check_invariants=False), + num_samples=32, + seed=42, + trading_mode="spot", + ) + write_complete_statistical_analysis( + df, + self.output_path, + PARAMS.PROFIT_AIM, + PARAMS.RISK_REWARD_RATIO, + 42, + skip_feature_analysis=True, + ) + report = (self.output_path / "statistical_analysis.md").read_text() + configuration = { + fields[1].strip(): fields[2].strip() + for line in report.splitlines() + if line.startswith("| max_") + for fields in [line.split("|")] + } + self.assertEqual(Decimal(configuration["max_trade_duration_candles"]), Decimal(duration)) + self.assertEqual(Decimal(configuration["max_idle_duration_candles"]), Decimal(4 * duration)) + self.assertEqual(sys.get_int_max_str_digits(), digit_limit) + override_row = next( + line for line in report.splitlines() if line.startswith("| Overrides |") + ) + overrides = dict( + pair.split("=", 1) for pair in override_row.split("|")[2].strip().split(", ") + ) + self.assertIs(ast.literal_eval(overrides["check_invariants"]), False) + def test_sample_action_idle_hazard_increases_entry_rate(self): """_sample_action() increases entry probability past idle cap. diff --git a/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py b/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py index fcc559f..f90c9b7 100644 --- a/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py +++ b/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py @@ -12,6 +12,7 @@ from reward_space_analysis import ( RewardDiagnosticsWarning, _binned_stats, _compute_relationship_stats, + _duration_binned_stats, bootstrap_confidence_intervals, compute_distribution_shift_metrics, distribution_diagnostics, @@ -28,6 +29,7 @@ from ..constants import ( STATISTICAL, TOLERANCE, ) +from ..helpers import simulate_samples_with_defaults from ..test_base import RewardSpaceTestBase _perform_feature_analysis = getattr(reward_space_analysis, "_perform_feature_analysis", None) @@ -38,6 +40,83 @@ pytestmark = pytest.mark.statistics class TestStatistics(RewardSpaceTestBase): """Statistical tests: metrics, diagnostics, bootstrap, correlations.""" + def test_extended_duration_relationship_bins_preserve_mass_and_reward_means(self): + """Large configured durations retain all observations in truthful ratio bins.""" + for duration in (10**306, 10**400): + with self.subTest(magnitude=duration.bit_length()): + df = simulate_samples_with_defaults( + self.base_params(max_trade_duration_candles=duration), + num_samples=32, + seed=42, + trading_mode="spot", + ) + result = _compute_relationship_stats(df) + for name, target in (("idle_stats", "reward_idle"), ("hold_stats", "reward_hold")): + stats = result[name] + self.assertEqual(stats["count"].sum(), len(df)) + self.assertEqual(stats.iloc[0]["count"], len(df)) + self.assertAlmostEqual(stats.iloc[0]["mean"], df[target].mean(), places=6) + self.assertEqual(stats.index.name, "duration / max_trade_duration_candles") + self.assertEqual(stats.index[0], "[0, 0.25]") + self.assertEqual(stats.index[-1], "(2.75, 3]") + + def test_extended_duration_bins_preserve_nullable_observation_semantics(self): + """Missing nullable duration observations stay excluded from reward aggregates.""" + for dtype in ("Int64", "Float64"): + with self.subTest(dtype=dtype): + df = simulate_samples_with_defaults( + self.base_params(max_trade_duration_candles=10**400), + num_samples=32, + seed=42, + trading_mode="spot", + ) + df["idle_duration"] = df["idle_duration"].astype(dtype) + df.loc[0, "idle_duration"] = pd.NA + stats = _compute_relationship_stats(df)["idle_stats"] + present = df["idle_duration"].notna() + self.assertEqual(stats["count"].sum(), int(present.sum())) + self.assertAlmostEqual( + stats.iloc[0]["mean"], + df.loc[present, "reward_idle"].mean(), + places=6, + ) + + def test_extended_duration_bins_distinguish_exact_right_closed_boundaries(self): + """Adjacent integer observations at huge boundaries must not round into one bin.""" + duration = 10**400 + values = [ + 0, + 1, + duration // 4, + duration // 4 + 1, + duration // 2, + duration // 2 + 1, + 3 * duration, + 3 * duration + 1, + ] + df = pd.DataFrame({"duration": pd.Series(values, dtype=object), "reward": np.arange(8.0)}) + result = _duration_binned_stats(df, "duration", "reward", duration) + self.assertEqual(result["count"].tolist(), [3, 2, 1] + [0] * 8 + [2]) + self.assertEqual(result.iloc[0]["mean"], 1.0) + self.assertEqual(result.iloc[1]["mean"], 3.5) + self.assertEqual(result.iloc[2]["mean"], 5.0) + self.assertEqual(result.iloc[-1]["mean"], 6.5) + + def test_ordinary_duration_relationship_bins_retain_candle_units(self): + """The normal configured scale keeps the existing candle-valued intervals.""" + df = simulate_samples_with_defaults( + self.base_params(max_trade_duration_candles=128), + num_samples=32, + seed=42, + ) + stats = _compute_relationship_stats(df)["idle_stats"] + self.assertEqual(stats.index.name, "bin") + first_upper = float(stats.index[0].split(",")[1].rstrip("]")) + last_upper = float(stats.index[-1].split(",")[1].rstrip("]")) + self.assertEqual(first_upper, 128 / 4) + self.assertEqual(last_upper, 3 * 128) + self.assertEqual(stats["count"].sum(), len(df)) + def test_statistics_feature_analysis_skip_partial_dependence(self): """Invariant 107: skip_partial_dependence=True yields empty partial_deps.""" if _perform_feature_analysis is None: diff --git a/ReforceXY/tests/test_coverage_floor.py b/ReforceXY/tests/test_coverage_floor.py index ab82b28..6e3ab61 100644 --- a/ReforceXY/tests/test_coverage_floor.py +++ b/ReforceXY/tests/test_coverage_floor.py @@ -7,6 +7,7 @@ import subprocess import sys import unittest from pathlib import Path +from unittest import mock from coverage.config import DEFAULT_EXCLUDE, DEFAULT_PARTIAL, DEFAULT_PARTIAL_ALWAYS, CoverageConfig from coverage.files import find_python_files @@ -101,6 +102,18 @@ class CoverageFloorTest(QaTestCase): self.assertGreater(value, 0.0) self.assertLessEqual(value, 100.0) + def test_the_floor_carries_its_measurement_provenance(self): + """A recorded measurement must be dated, final, and sufficient for its floor.""" + lines = [line.strip() for line in self.text.splitlines()] + index = next(i for i, line in enumerate(lines) if line.partition("=")[0].strip() == FLOOR) + annotation = " ".join(lines[max(0, index - 2) : index]) + self.assertIn("measured", annotation) + self.assertNotIn("PROVISIONAL", annotation) + self.assertRegex(annotation, r"\d{4}-\d{2}-\d{2}") + found = re.search(r"measured[^%]*?([0-9]+(?:\.[0-9]+)?)%", annotation) + self.assertIsNotNone(found, "the floor must record the measurement it relies on") + self.assertGreaterEqual(float(found.group(1)), float(self._raw_floor())) + def test_branch_tracing_is_enabled(self): # Without it, every guard — an early return, a raise — counts as covered the # moment its `if` is evaluated, and the gate measures nothing useful. @@ -280,5 +293,38 @@ class CoverageFloorTest(QaTestCase): ) +class CoverageProvenanceRegressionTest(QaTestCase): + def test_actual_guard_rejects_invalid_and_accepts_final_dated_measurements(self): + """Run the real guard against fixture files, including stale but valid provenance.""" + baseline = COVERAGERC.read_text() + cases = ( + ("", False), + ("# measured 76.0%", False), + ("# measured 2026-10-02", False), + ("# measured 2026-10-02 at 69.9%", False), + ("# measured 2026-10-02 at 76.0% PROVISIONAL", False), + ("# measured 2026-10-02 at 70.0%", True), + ("# measured 2026-10-02 at 76.0%", True), + ("# measured 2000-01-01 at 76.0%", True), + ) + for annotation, valid in cases: + with self.subTest(annotation=annotation), temporary_directory() as directory: + fixture = directory / ".coveragerc" + fixture.write_text( + "\n".join( + annotation if line.startswith("# measured") else line + for line in baseline.splitlines() + ) + ) + result = unittest.TestResult() + with mock.patch.dict(globals(), COVERAGERC=fixture): + CoverageFloorTest("test_the_floor_carries_its_measurement_provenance").run( + result + ) + self.assertEqual(result.testsRun, 1) + self.assertEqual(result.errors, []) + self.assertEqual(len(result.failures), 0 if valid else 1) + + if __name__ == "__main__": unittest.main()