From 1297cf46952da526431ecefc12a98f582703bc23 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 2 Oct 2026 22:13:57 +0200 Subject: [PATCH] fix(reforcexy): synchronize analytical reward boundary contracts --- ReforceXY/.basedpyright/diagnostics.json | 76 +++---- ReforceXY/.coveragerc | 2 +- ReforceXY/reward_space_analysis/README.md | 39 +++- .../reward_space_analysis.py | 58 ++++-- .../reward_space_analysis/tests/README.md | 11 ++ .../tests/cli/test_cli_params_and_csv.py | 46 ++++- .../reward_space_analysis/tests/constants.py | 6 +- .../tests/pbrs/test_pbrs.py | 146 +------------- ReforceXY/tests/test_coverage_floor.py | 30 +-- .../tests/test_model_pbrs_transitions.py | 185 +++++++++++++++++- 10 files changed, 357 insertions(+), 242 deletions(-) diff --git a/ReforceXY/.basedpyright/diagnostics.json b/ReforceXY/.basedpyright/diagnostics.json index e69d425..37c57de 100644 --- a/ReforceXY/.basedpyright/diagnostics.json +++ b/ReforceXY/.basedpyright/diagnostics.json @@ -13,193 +13,193 @@ }, { "endCharacter": 92, - "endLine": 571, + "endLine": 581, "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": 571 + "startLine": 581 }, { "endCharacter": 92, - "endLine": 571, + "endLine": 581, "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": 571 + "startLine": 581 }, { "endCharacter": 5, - "endLine": 2140, + "endLine": 2156, "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": 2135 + "startLine": 2151 }, { "endCharacter": 21, - "endLine": 2137, + "endLine": 2153, "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": 2137 + "startLine": 2153 }, { "endCharacter": 5, - "endLine": 2162, + "endLine": 2178, "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": 2158 + "startLine": 2174 }, { "endCharacter": 43, - "endLine": 2159, + "endLine": 2175, "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": 2159 + "startLine": 2175 }, { "endCharacter": 42, - "endLine": 2159, + "endLine": 2175, "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": 2159 + "startLine": 2175 }, { "endCharacter": 60, - "endLine": 2419, + "endLine": 2435, "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": 2419 + "startLine": 2435 }, { "endCharacter": 58, - "endLine": 2420, + "endLine": 2436, "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": 2420 + "startLine": 2436 }, { "endCharacter": 88, - "endLine": 2439, + "endLine": 2455, "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": 2439 + "startLine": 2455 }, { "endCharacter": 88, - "endLine": 2439, + "endLine": 2455, "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": 2439 + "startLine": 2455 }, { "endCharacter": 17, - "endLine": 2448, + "endLine": 2464, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Object of type \"None\" cannot be called", "rule": "reportOptionalCall", "severity": "error", "startCharacter": 28, - "startLine": 2442 + "startLine": 2458 }, { "endCharacter": 29, - "endLine": 3047, + "endLine": 3063, "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": 3047 + "startLine": 3063 }, { "endCharacter": 28, - "endLine": 3047, + "endLine": 3063, "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": 3047 + "startLine": 3063 }, { "endCharacter": 65, - "endLine": 3056, + "endLine": 3072, "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": 3056 + "startLine": 3072 }, { "endCharacter": 65, - "endLine": 3056, + "endLine": 3072, "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": 3056 + "startLine": 3072 }, { "endCharacter": 5, - "endLine": 3956, + "endLine": 3972, "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": 3952 + "startLine": 3968 }, { "endCharacter": 43, - "endLine": 3953, + "endLine": 3969, "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": 3953 + "startLine": 3969 }, { "endCharacter": 42, - "endLine": 3953, + "endLine": 3969, "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": 3953 + "startLine": 3969 }, { "endCharacter": 22, diff --git a/ReforceXY/.coveragerc b/ReforceXY/.coveragerc index 4aa29b0..b01fefb 100644 --- a/ReforceXY/.coveragerc +++ b/ReforceXY/.coveragerc @@ -8,5 +8,5 @@ relative_files = True show_missing = True precision = 1 include_namespace_packages = True -# measured 2026-10-02 at 75.6% — raise only, never lower +# measured 2026-10-02 at 76.0% — raise only, never lower fail_under = 70 \ No newline at end of file diff --git a/ReforceXY/reward_space_analysis/README.md b/ReforceXY/reward_space_analysis/README.md index 2220b58..ee9499d 100644 --- a/ReforceXY/reward_space_analysis/README.md +++ b/ReforceXY/reward_space_analysis/README.md @@ -214,6 +214,15 @@ be overridden via `--params`. - **`--rf_n_jobs`** / **`--perm_n_jobs`** (int, default: -1) – Parallel worker counts for RandomForest and permutation importance (-1 = all cores). +Programmatic reward parameters accept Python and NumPy real integer/floating +scalars. Strict validation checks a numeric gamma in its native precision before +normalizing it. 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; relaxed parameter validation records its resets/clamps. +For a trained or resumed learner, supply its effective discount as +`potential_gamma`, not a stale reward/config gamma. + Inferential helpers are available through the programmatic API only and require `independent_observations=True`. Programmatic callers may pass `stats_seed` to isolate bootstrap resampling from the simulation seed; the descriptive CLI @@ -259,12 +268,24 @@ The exit factor is computed as: **Note:** In ReforceXY, `risk_reward_ratio` maps to `rr`. +The CLI does not import the runtime config template. To reproduce its reward +settings, use `--profit_aim 0.025 --max_trade_duration_candles 96 +--idle_penalty_ratio 0` with the same effective gamma and entry/exit fees. These +are explicit scenario overrides, not different canonical reward defaults. + **Formula:** -Let `pnl_target = profit_aim · risk_reward_ratio` and -`pnl_ratio = pnl / pnl_target` when `pnl_target > 0`. For losses, let -`effective_rr = risk_reward_ratio` if positive, or `2.0` otherwise (the runtime -fallback). Then `loss_threshold = pnl_target / effective_rr` and +For complete reward calculations, let `pnl_target = profit_aim · risk_reward_ratio`; +replace it by `0.01` when the product is nonpositive, as in the runtime. This +effective target is also used for representativity statistics and the manifest. +The supplied profit aim and risk/reward ratio remain unchanged in idle/hold +penalties. Low-level helpers receiving an explicit nonpositive target retain +their neutral coefficient/zero-signal guards. + +Let `pnl_ratio = pnl / pnl_target` when `pnl_target > 0`. The low-level loss +coefficient uses `effective_rr = risk_reward_ratio` if positive, or `2.0` otherwise. +That helper fallback does not make a zero ratio valid for the complete reward +path. Then `loss_threshold = pnl_target / effective_rr` and `loss_ratio = |pnl| / loss_threshold = |pnl_ratio| · effective_rr`. - If `pnl_target ≤ 0`: `pnl_target_coefficient = 1.0` @@ -295,6 +316,14 @@ Let `max_u = max_unrealized_profit`, `min_u = min_unrealized_profit`, `efficiency_coefficient = 1 + efficiency_weight · (efficiency_center - ratio)` - Else: `efficiency_coefficient = 1` +The calculated coefficient is clamped to a finite nonnegative value. Validated +analysis parameters additionally require +`efficiency_weight · max(efficiency_center, 1 − efficiency_center) ≤ 1`: strict +validation rejects combinations that could need a clamp at either ratio endpoint; +relaxed validation records an adjustment and disables weighting (`weight = 0`). +This accepted-domain restriction is stronger than the runtime's per-state clamp; +direct calculation preserves the runtime formula without applying that validator. + In synthetic `unrealized_pnl` mode, sampled market prices accumulate each candle's return independently of the transformed, retained price. The fee-aware sampled PnL is bounded, scaled by the duration-dependent tanh @@ -328,7 +357,7 @@ where `kernel_function` depends on `exit_attenuation_mode`. See | Parameter | Default | Description | | ---------------------------- | ------- | ----------------------------------------------------------------------------------------- | | `max_trade_duration_candles` | 128 | Trade duration cap | -| `max_idle_duration_candles` | None | Idle hazard threshold (4× trade duration fallback); the idle clock keeps counting past it | +| `max_idle_duration_candles` | None | Missing/None derives 4× trade cap; explicit zero uses denominator 1 and does not disable the penalty | | `idle_penalty_ratio` | 1.0 | Idle penalty ratio | | `idle_penalty_power` | 1.025 | Idle penalty exponent | | `hold_penalty_ratio` | 1.0 | Hold penalty ratio | diff --git a/ReforceXY/reward_space_analysis/reward_space_analysis.py b/ReforceXY/reward_space_analysis/reward_space_analysis.py index 563570e..456aba0 100644 --- a/ReforceXY/reward_space_analysis/reward_space_analysis.py +++ b/ReforceXY/reward_space_analysis/reward_space_analysis.py @@ -254,7 +254,7 @@ _PARAMETER_BOUNDS: dict[str, dict[str, float]] = { "exit_additive_gain": {"min": 0.0}, } -RewardParamValue = float | str | bool | None +RewardParamValue = float | np.integer[Any] | np.floating[Any] | str | bool | None RewardParams = dict[str, RewardParamValue] @@ -399,7 +399,7 @@ def _get_float_param( if isinstance(value, bool): return float(int(value)) # Numeric - if isinstance(value, (int, float)): + if isinstance(value, (int, float, np.integer, np.floating)): try: fval = float(value) except (ValueError, TypeError): @@ -475,43 +475,47 @@ def _get_int_param(params: RewardParams, key: str, default: RewardParamValue | N default: Fallback value. If None, looks up from DEFAULT_MODEL_REWARD_PARAMETERS. Behavior: - - Accept bool/int/float/str numeric representations. + - Accept Python/NumPy real scalars and bool/str numeric representations. - Non-finite floats -> fallback to default coerced to int (or 0). - Strings: strip then parse float/int; on failure fallback. - None -> fallback. - - Final value is clamped to a signed 64-bit range implicitly by int(). + - Return a Python int without an implicit fixed-width clamp. """ if default is None: default = DEFAULT_MODEL_REWARD_PARAMETERS.get(key) value = params.get(key, default) if value is None: - return int(default) if isinstance(default, (int, float)) else 0 + return int(default) if isinstance(default, (int, float, np.integer, np.floating)) else 0 if isinstance(value, bool): return int(value) - if isinstance(value, int): - return value - if isinstance(value, float): + if isinstance(value, (int, np.integer)): + return int(value) + if isinstance(value, (float, np.floating)): if not np.isfinite(value): - return int(default) if isinstance(default, (int, float)) else 0 + return int(default) if isinstance(default, (int, float, np.integer, np.floating)) else 0 try: return int(value) except (OverflowError, ValueError): - return int(default) if isinstance(default, (int, float)) else 0 + return int(default) if isinstance(default, (int, float, np.integer, np.floating)) else 0 if isinstance(value, str): stripped = value.strip() if stripped == "": - return int(default) if isinstance(default, (int, float)) else 0 + return int(default) if isinstance(default, (int, float, np.integer, np.floating)) else 0 try: if any(ch in stripped for ch in (".", "e", "E")): fval = float(stripped) if not np.isfinite(fval): - return int(default) if isinstance(default, (int, float)) else 0 + return ( + int(default) + if isinstance(default, (int, float, np.integer, np.floating)) + else 0 + ) return int(fval) return int(stripped) except (ValueError, OverflowError): - return int(default) if isinstance(default, (int, float)) else 0 + return int(default) if isinstance(default, (int, float, np.integer, np.floating)) else 0 # Unsupported type - return int(default) if isinstance(default, (int, float)) else 0 + return int(default) if isinstance(default, (int, float, np.integer, np.floating)) else 0 def _get_str_param(params: RewardParams, key: str, default: str | None = None) -> str: @@ -539,6 +543,12 @@ def _compute_duration_ratio(trade_duration: int, max_trade_duration_candles: int return trade_duration / max(1, max_trade_duration_candles) +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) + return 0.01 if target <= 0.0 else target + + def _is_short_allowed(trading_mode: str) -> bool: mode = trading_mode.lower() if mode in TRADING_MODES[1:]: # "margin", "futures" @@ -563,7 +573,7 @@ def get_max_idle_duration_candles( ) -> int: mtd = ( int(max_trade_duration_candles) - if isinstance(max_trade_duration_candles, (int, float)) + if isinstance(max_trade_duration_candles, (int, float, np.integer, np.floating)) else None ) if mtd is None or mtd <= 0: @@ -573,8 +583,6 @@ def get_max_idle_duration_candles( default_mid = int(DEFAULT_IDLE_DURATION_MULTIPLIER * int(mtd)) mid = _get_int_param(params, "max_idle_duration_candles", default_mid) - if mid <= 0: - mid = default_mid return int(mid) @@ -638,6 +646,14 @@ def validate_reward_parameters( continue original_val = sanitized[key] + # Check the native discount before normalization can round it into [0, 1]. + if ( + key == "potential_gamma" + and strict + and isinstance(original_val, (int, float, np.integer, np.floating)) + and not bounds["min"] <= original_val <= bounds["max"] + ): + raise ValueError(f"Param: '{key}'={original_val!r} outside [0, 1]") # Robust coercion to float using helper (handles None/str/bool/non-finite) coerced_val = _get_float_param({key: original_val}, key, np.nan) @@ -819,7 +835,7 @@ class RewardContext: Attributes ---------- current_pnl : float - Unrealized PnL at the current tick (state s'). + Unrealized PnL of the represented execution or marked next state. """ current_pnl: float @@ -1316,7 +1332,7 @@ def calculate_reward( elif "rr" in params: risk_reward_ratio = _get_float_param(params, "rr", float(risk_reward_ratio)) - pnl_target = float(profit_aim * risk_reward_ratio) + pnl_target = _resolve_pnl_target(profit_aim, risk_reward_ratio) idle_factor = base_factor * (profit_aim / risk_reward_ratio) hold_factor = idle_factor @@ -2214,7 +2230,7 @@ def _compute_representativity_stats( risk_reward_ratio: float, ) -> dict[str, Any]: """Compute representativity statistics for the reward space.""" - pnl_target = float(profit_aim * risk_reward_ratio) + pnl_target = _resolve_pnl_target(profit_aim, risk_reward_ratio) total = len(df) # Map numeric position codes to readable labels to avoid casting Neutral (0.5) to 0 pos_label_map = {0.0: "Short", 0.5: "Neutral", 1.0: "Long"} @@ -4866,7 +4882,7 @@ def main() -> None: "generated_at": pd.Timestamp.now().isoformat(), "num_samples": len(df), "seed": int(args.seed), - "pnl_target": float(profit_aim * risk_reward_ratio), + "pnl_target": _resolve_pnl_target(profit_aim, risk_reward_ratio), "parameter_adjustments": adjustments, "reward_params": resolved_reward_params, "effective": effective_params, diff --git a/ReforceXY/reward_space_analysis/tests/README.md b/ReforceXY/reward_space_analysis/tests/README.md index 4b0bc00..15e8859 100644 --- a/ReforceXY/reward_space_analysis/tests/README.md +++ b/ReforceXY/reward_space_analysis/tests/README.md @@ -227,6 +227,7 @@ Columns: | statistics-rank-direction-141 | statistics | Rank-biserial effect follows the named first-group advantage | statistics/test_statistics.py::test_pnl_rank_biserial_direction_matches_named_first_group | Checks both directions | | statistics-bootstrap-count-142 | statistics | Zero and negative resample counts fail for variable and constant data | statistics/test_statistics.py::test_bootstrap_rejects_nonpositive_resample_count | Rejects missing bootstrap | | api-unmasked-sample-probabilities-144 | api | Unmasked sample probabilities match marginal valid-action frequencies | api/test_api_helpers.py::test_unmasked_sampling_probabilities_match_action_frequencies | Spot/futures entries, long/short exits, and neutral probability | +| cli-effective-reward-boundaries-145 | cli | Effective profit-target fallback and explicit zero idle denominator reach artifacts | cli/test_cli_params_and_csv.py::test_zero_target_and_idle_cap_reach_rewards_and_manifest | Metadata and near-target market coverage agree with runtime boundaries | ### Non-Owning Smoke / Reference Checks @@ -327,6 +328,16 @@ Detailed assertions reside in targeted directories (components, robustness) while integration tests focus on report formatting. The mapping above defines current ownership; multi-path and non-owning references are called out explicitly. +Real runtime/reference parity is owned by +`ReforceXY/tests/test_model_pbrs_transitions.py::PbrsTransitionsTest::test_analysis_matches_real_rewards_across_modes_and_parameter_boundaries`. +Run it in the Freqtrade RL QA image through the canonical runtime suite; the +standalone analysis environment deliberately does not require Freqtrade/SB3. +The comparator exercises direct/strict/relaxed parameter paths, real fills and +terminal liquidation, carries reference potential independently, and checks tiny +shaping components more tightly than the report-verification tolerance. +Raw random shaping sums are not discounted invariance oracles. Complete-episode +discounted identities and report continuity checks retain their existing owners. + ## When to Run Tests Run after changes to: reward component logic, PBRS mechanics, CLI diff --git a/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py b/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py index 78c3494..09d698a 100644 --- a/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py +++ b/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py @@ -15,11 +15,12 @@ import pytest from reward_space_analysis import ( DEFAULT_MODEL_REWARD_PARAMETERS, Actions, + _compute_representativity_stats, get_max_idle_duration_candles, ) from test_reward_space_analysis_cli import _is_warning_header, run_scenario -from ..constants import SCENARIOS, SEEDS, TOLERANCE +from ..constants import PARAMS, SCENARIOS, SEEDS, TOLERANCE from ..test_base import RewardSpaceTestBase # Pytest marker for taxonomy classification @@ -146,6 +147,49 @@ class TestParamsPropagation(RewardSpaceTestBase): - PBRS invariance summary section when reward_shaping present """ + def test_zero_target_and_idle_cap_reach_rewards_and_manifest(self): + """Keep configured idle cap zero and expose the effective runtime profit target. + + **Invariant:** cli-effective-reward-boundaries-145 + **Setup:** fixed seed, zero profit aim and explicit idle cap zero. + **Assertions:** target metadata agrees with near-target market coverage; + idle ratios use a one-candle denominator rather than the derived cap. + """ + out_dir = self.output_path / "zero_reward_boundaries" + result = _run_cli( + out_dir=out_dir, + args=[ + "--num_samples", + str(SCENARIOS.CLI_NUM_SAMPLES_STANDARD), + "--seed", + str(SEEDS.BASE), + "--profit_aim", + "0", + "--max_idle_duration_candles", + "0", + "--hold_potential_enabled", + "1", + "--skip_feature_analysis", + "--skip_partial_dependence", + ], + ) + _assert_cli_success(self, result) + manifest = json.loads((out_dir / "manifest.json").read_text()) + samples = pd.read_csv(out_dir / "reward_samples.csv") + self.assertEqual(manifest["pnl_target"], PARAMS.PNL_TARGET_FALLBACK) + self.assertEqual(manifest["reward_params"]["max_idle_duration_candles"], 0) + pd.testing.assert_series_equal( + samples["idle_ratio"], samples["idle_duration"].astype(float), check_names=False + ) + expected_near_target = ( + (samples["pnl"] >= 0.8 * PARAMS.PNL_TARGET_FALLBACK) + & (samples["pnl"] <= 1.2 * PARAMS.PNL_TARGET_FALLBACK) + ).mean() + stats = _compute_representativity_stats(samples, 0.0, PARAMS.RISK_REWARD_RATIO) + self.assertAlmostEqualFloat( + stats["pnl_near_target"], expected_near_target, tolerance=TOLERANCE.GENERIC_EQ + ) + def test_skip_feature_analysis_summary_branch(self): """CLI run with --skip_feature_analysis should mark feature importance skipped in summary and omit feature_importance.csv.""" out_dir = self.output_path / "skip_feature_analysis" diff --git a/ReforceXY/reward_space_analysis/tests/constants.py b/ReforceXY/reward_space_analysis/tests/constants.py index ca583ca..ddc6d22 100644 --- a/ReforceXY/reward_space_analysis/tests/constants.py +++ b/ReforceXY/reward_space_analysis/tests/constants.py @@ -161,12 +161,10 @@ class StatisticalConfig: Attributes: BH_FP_RATE_THRESHOLD: Benjamini-Hochberg false positive rate threshold (0.15) BOOTSTRAP_DEFAULT_ITERATIONS: Default bootstrap resampling count (100) - EXIT_PROBABILITY_THRESHOLD: Probability threshold for exit events (0.15) """ BH_FP_RATE_THRESHOLD: float = 0.15 BOOTSTRAP_DEFAULT_ITERATIONS: int = 100 - EXIT_PROBABILITY_THRESHOLD: float = 0.15 @dataclass(frozen=True) @@ -256,6 +254,7 @@ class TestParameters: Attributes: BASE_FACTOR: Default base factor for reward scaling (90.0) PROFIT_AIM: Target profit threshold (0.06) + PNL_TARGET_FALLBACK: Effective target for nonpositive profit aim products (0.01) RISK_REWARD_RATIO: Standard risk/reward ratio (2.0) RISK_REWARD_RATIO_HIGH: High risk/reward ratio for stress tests (4.0) PNL_STD: Standard deviation for PnL generation (0.02) @@ -283,6 +282,7 @@ class TestParameters: BASE_FACTOR: float = 90.0 PROFIT_AIM: float = 0.06 + PNL_TARGET_FALLBACK: float = 0.01 RISK_REWARD_RATIO: float = 2.0 RISK_REWARD_RATIO_HIGH: float = 4.0 PNL_STD: float = 0.02 @@ -326,7 +326,6 @@ class TestScenarios: SAMPLE_SIZE_LARGE: Large sample size for statistical power (800) SAMPLE_SIZE_CONST_DF: Sample size for constant dataframes (64) SAMPLE_SIZE_SHIFT_SCALE: Sample size for shift/scale tests (256) - PBRS_SIMULATION_STEPS: Number of steps for PBRS simulation tests (500) MONTE_CARLO_ITERATIONS: Monte Carlo simulation iterations (160) PBRS_SWEEP_ITERATIONS: Number of iterations for PBRS sweep tests (120) BOOTSTRAP_MINIMAL_ITERATIONS: Minimal bootstrap iterations for quick tests (25) @@ -369,7 +368,6 @@ class TestScenarios: SAMPLE_SIZE_SHIFT_SCALE: int = 256 # Specialized test scenario sizes - PBRS_SIMULATION_STEPS: int = 500 MONTE_CARLO_ITERATIONS: int = 160 PBRS_SWEEP_ITERATIONS: int = 120 BOOTSTRAP_MINIMAL_ITERATIONS: int = 25 diff --git a/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py b/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py index 05af8aa..835ad98 100644 --- a/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py +++ b/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py @@ -11,7 +11,6 @@ import pytest import reward_space_analysis from reward_space_analysis import ( - DEFAULT_IDLE_DURATION_MULTIPLIER, DEFAULT_MODEL_REWARD_PARAMETERS, INTERNAL_GUARDS, PBRS_INVARIANCE_TOL, @@ -24,7 +23,6 @@ from reward_space_analysis import ( _compute_unrealized_pnl_estimate, _get_potential_gamma, apply_potential_shaping, - get_max_idle_duration_candles, simulate_samples, validate_reward_parameters, write_complete_statistical_analysis, @@ -35,7 +33,6 @@ from ..constants import ( PBRS, SCENARIOS, SEEDS, - STATISTICAL, TOLERANCE, ) from ..helpers import ( @@ -770,9 +767,9 @@ class TestPBRS(RewardSpaceTestBase): # ---------------- Potential transform mechanics ---------------- # def test_pbrs_progressive_release_decay_clamped(self): - """Verifies progressive_release mode decay clamps at terminal. + """Verify progressive_release decay clamps on a nonterminal exit. - Tolerance rationale: IDENTITY_RELAXED used for PBRS terminal state checks + Tolerance rationale: IDENTITY_RELAXED used for discounted exit-potential checks due to accumulated errors from gamma discounting and potential calculations. """ params = self.DEFAULT_PARAMS.copy() @@ -824,8 +821,8 @@ class TestPBRS(RewardSpaceTestBase): reward_shaping, -prev_potential, tolerance=TOLERANCE.IDENTITY_RELAXED ) - def test_pbrs_spike_cancel_invariance(self): - """Verifies spike_cancel mode produces near-zero terminal shaping.""" + def test_spike_cancel_cancels_voluntary_exit_shaping(self): + """Cancel shaping on a nonterminal exit, without claiming terminal invariance.""" params = self.DEFAULT_PARAMS.copy() params.update( { @@ -909,8 +906,8 @@ class TestPBRS(RewardSpaceTestBase): ) self.assertTrue(evidence["verified"], evidence["reason"]) - def test_non_canonical_flag_false_and_sum_nonzero(self): - """Non-canonical mode -> invariant flags False and Σ shaping non-zero.""" + def test_progressive_release_is_not_classified_as_zero_exit_mode(self): + """Residual-potential modes do not receive the zero-exit configuration flag.""" params = self.base_params( exit_potential_mode="progressive_release", @@ -933,12 +930,6 @@ class TestPBRS(RewardSpaceTestBase): ) unique_flags = set(df["pbrs_invariant"].unique().tolist()) self.assertEqual(unique_flags, {False}, f"Unexpected invariant flags: {unique_flags}") - abs_sum = float(df["reward_shaping"].abs().sum()) - self.assertGreater( - abs_sum, - PBRS_INVARIANCE_TOL * 2, - f"Expected non-trivial shaping magnitude (got {abs_sum})", - ) # ---------------- Additives and canonical path mechanics ---------------- # @@ -1689,23 +1680,6 @@ class TestPBRS(RewardSpaceTestBase): "Canonical shaping magnitude should exceed spike_cancel", ) - def test_pbrs_retain_previous_cumulative_drift(self): - """retain_previous mode accumulates negative shaping drift (non-invariant).""" - params = self.base_params( - exit_potential_mode="retain_previous", - hold_potential_enabled=True, - entry_additive_enabled=False, - exit_additive_enabled=False, - potential_gamma=0.9, - ) - gamma = _get_potential_gamma(params) - rng = np.random.default_rng(SEEDS.ALTERNATE_1) - potentials = rng.uniform(0.05, 0.85, size=220) - deltas = [gamma * p - p for p in potentials] - cumulative = float(np.sum(deltas)) - self.assertLess(cumulative, -TOLERANCE.NEGLIGIBLE) - self.assertGreater(abs(cumulative), 10 * TOLERANCE.IDENTITY_RELAXED) - def test_exit_step_shaping_matches_exit_step_rules(self): """Exit step: shaping uses stored prev_potential. @@ -1907,103 +1881,6 @@ class TestPBRS(RewardSpaceTestBase): # |Φ(s)| <= base_factor and |Δ| <= (1+γ)*base_factor self.assertLessEqual(abs(float(shap)), (1.0 + gamma) * PARAMS.BASE_FACTOR) - def test_report_cumulative_invariance_aggregation(self): - """Canonical telescoping term: small per-step mean drift, bounded increments.""" - - params = self.base_params( - hold_potential_enabled=True, - entry_additive_enabled=False, - exit_additive_enabled=False, - exit_potential_mode="canonical", - ) - gamma = _get_potential_gamma(params) - rng = np.random.default_rng(SEEDS.REPORT_FORMAT_2) - prev_potential = 0.0 - telescoping_sum = 0.0 - max_abs_step = 0.0 - steps = 0 - for _ in range(SCENARIOS.PBRS_SIMULATION_STEPS): - is_exit = rng.uniform() < 0.1 - current_pnl = float(rng.normal(0, 0.05)) - current_dur = float(rng.uniform(0, 1)) - next_pnl = 0.0 if is_exit else float(rng.normal(0, 0.05)) - next_dur = 0.0 if is_exit else float(rng.uniform(0, 1)) - _tot, _shap, next_potential, _pbrs_delta, _entry_additive, _exit_additive = ( - apply_potential_shaping( - base_reward=0.0, - current_pnl=current_pnl, - pnl_target=PARAMS.PROFIT_AIM * PARAMS.RISK_REWARD_RATIO, - current_duration_ratio=current_dur, - next_pnl=next_pnl, - entry_pnl=next_pnl, - next_duration_ratio=next_dur, - risk_reward_ratio=PARAMS.RISK_REWARD_RATIO, - base_factor=PARAMS.BASE_FACTOR, - is_exit=is_exit, - prev_potential=prev_potential, - params=params, - ) - ) - inc = gamma * next_potential - prev_potential - telescoping_sum += inc - if abs(inc) > max_abs_step: - max_abs_step = abs(inc) - steps += 1 - prev_potential = 0.0 if is_exit else next_potential - mean_drift = telescoping_sum / max(1, steps) - self.assertLess( - abs(mean_drift), - 0.02, - f"Per-step telescoping drift too large (mean={mean_drift}, steps={steps})", - ) - self.assertLessEqual( - max_abs_step, - PBRS.MAX_ABS_SHAPING, - f"Unexpected large telescoping increment (max={max_abs_step})", - ) - - def test_report_explicit_non_invariance_progressive_release(self): - """progressive_release cumulative shaping non-zero (release leak).""" - - params = self.base_params( - hold_potential_enabled=True, - entry_additive_enabled=False, - exit_additive_enabled=False, - exit_potential_mode="progressive_release", - exit_potential_decay=0.25, - ) - rng = np.random.default_rng(SEEDS.REPORT_FORMAT_2) - prev_potential = 0.0 - shaping_sum = 0.0 - - for _ in range(SCENARIOS.MONTE_CARLO_ITERATIONS): - is_exit = rng.uniform() < STATISTICAL.EXIT_PROBABILITY_THRESHOLD - next_pnl = 0.0 if is_exit else float(rng.normal(0, 0.07)) - next_dur = 0.0 if is_exit else float(rng.uniform(0, 1)) - _tot, shap, next_pot, _pbrs_delta, _entry_additive, _exit_additive = ( - apply_potential_shaping( - base_reward=0.0, - current_pnl=float(rng.normal(0, 0.07)), - pnl_target=PARAMS.PROFIT_AIM * PARAMS.RISK_REWARD_RATIO, - current_duration_ratio=float(rng.uniform(0, 1)), - next_pnl=next_pnl, - entry_pnl=next_pnl, - next_duration_ratio=next_dur, - risk_reward_ratio=PARAMS.RISK_REWARD_RATIO, - base_factor=PARAMS.BASE_FACTOR, - is_exit=is_exit, - prev_potential=prev_potential, - params=params, - ) - ) - shaping_sum += shap - prev_potential = 0.0 if is_exit else next_pot - self.assertGreater( - abs(shaping_sum), - PBRS_INVARIANCE_TOL * 50, - f"Expected non-zero shaping (got {shaping_sum})", - ) - # Non-owning smoke; ownership: robustness/test_robustness.py:43 (robustness-decomposition-integrity-101) # Owns invariant: pbrs-canonical-near-zero-report-116 @pytest.mark.smoke @@ -2501,17 +2378,6 @@ class TestPBRS(RewardSpaceTestBase): self.assertIn("_PBRS components not present in this analysis._", content) self.assertIn("_Not performed (no real episodes provided)._", content) - def test_get_max_idle_duration_candles_negative_or_zero_fallback(self): - """Explicit mid<=0 fallback path returns derived default multiplier.""" - base = DEFAULT_MODEL_REWARD_PARAMETERS.copy() - base["max_trade_duration_candles"] = 64 - base["max_idle_duration_candles"] = 0 - result = get_max_idle_duration_candles(base) - expected = DEFAULT_IDLE_DURATION_MULTIPLIER * 64 - self.assertEqual( - result, expected, f"Expected fallback {expected} for mid<=0 (got {result})" - ) - if __name__ == "__main__": unittest.main() diff --git a/ReforceXY/tests/test_coverage_floor.py b/ReforceXY/tests/test_coverage_floor.py index 431d4ba..ab82b28 100644 --- a/ReforceXY/tests/test_coverage_floor.py +++ b/ReforceXY/tests/test_coverage_floor.py @@ -101,15 +101,6 @@ class CoverageFloorTest(QaTestCase): self.assertGreater(value, 0.0) self.assertLessEqual(value, 100.0) - def test_the_floor_carries_its_provenance(self): - # The measured date is what tells a reader whether the number is current, and - # it is the only thing distinguishing a real floor from a provisional one. - lines = [line.strip() for line in self.text.splitlines()] - index = next(i for i, line in enumerate(lines) if line.startswith(f"{FLOOR} =")) - annotation = " ".join(lines[max(0, index - 2) : index]) - self.assertIn("measured", annotation, f"the line above {FLOOR} must record the measurement") - self.assertNotIn("PROVISIONAL", annotation) - 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. @@ -128,7 +119,7 @@ class CoverageFloorTest(QaTestCase): def test_the_floor_is_bound_in_the_section_coverage_reads_it_from(self): # coverage binds fail_under as `report:fail_under` ONLY. The same key under [run] # is silently inert, which would leave the gate dead while the value, the - # provenance and the placeholder checks all still passed. + # numeric floor and range checks still passed. self.assertIn(FLOOR, self.parser["report"]) self.assertNotIn(FLOOR, self.parser["run"]) @@ -163,25 +154,6 @@ class CoverageFloorTest(QaTestCase): # policy allows. self.assertGreaterEqual(float(self._raw_floor()), MINIMUM_FLOOR) - def test_the_provenance_records_a_date_and_a_measurement_that_clears_the_floor(self): - # Recency is deliberately NOT asserted: refusing a date older than N months would - # make this suite fail on a repository nobody has touched. What is enforceable is - # that the annotation records a real measurement and that the measurement clears - # the floor it justifies. - lines = [line.strip() for line in self.text.splitlines()] - index = next(i for i, line in enumerate(lines) if line.startswith(f"{FLOOR} =")) - annotation = " ".join(lines[max(0, index - 2) : index]) - with self.subTest(requirement="a well-formed measurement date"): - self.assertRegex(annotation, r"\d{4}-\d{2}-\d{2}") - with self.subTest(requirement="a recorded measurement"): - found = re.search(r"measured[^%]*?([0-9]+(?:\.[0-9]+)?)%", annotation) - self.assertIsNotNone(found, f"no percentage recorded above {FLOOR}: {annotation!r}") - self.assertGreaterEqual( - float(found.group(1)), - float(self._raw_floor()), - "the annotated measurement must clear the floor it justifies", - ) - def test_no_production_source_widens_coverage_s_default_exclusions(self): # The SOURCE side of the same lever. coverage applies DEFAULT_EXCLUDE by # regex-searching the raw file text, so a pragma inside a string literal removes a diff --git a/ReforceXY/tests/test_model_pbrs_transitions.py b/ReforceXY/tests/test_model_pbrs_transitions.py index d49877b..59f5404 100644 --- a/ReforceXY/tests/test_model_pbrs_transitions.py +++ b/ReforceXY/tests/test_model_pbrs_transitions.py @@ -13,18 +13,21 @@ from qa_support import QaTestCase, model_config from sb3_contrib import MaskablePPO from sb3_contrib.common.maskable.evaluation import evaluate_policy +from ReforceXY.reward_space_analysis import reward_space_analysis as analysis from ReforceXY.user_data.freqaimodels.ReforceXY import Actions, MyRLEnv, Positions, ReforceXY class PbrsTransitionsTest(QaTestCase): - def env(self, *, short=False, fee=0.0, **parameters): - prices = pd.DataFrame({"open": [100.0, 100.0] + [90.0 if short else 110.0] * 10}) + def env(self, *, short=False, fee=0.0, profit_aim=0.03, open_prices=None, **parameters): + if open_prices is None: + open_prices = [100.0, 100.0] + [90.0 if short else 110.0] * 10 + prices = pd.DataFrame({"open": open_prices}) env = MyRLEnv( df=prices.copy(), prices=prices, df_raw=prices.copy(), window_size=1, - reward_kwargs={"rr": 2.0, "profit_aim": 0.03}, + reward_kwargs={"rr": 2.0, "profit_aim": profit_aim}, fee=fee, can_short=True, config={ @@ -49,6 +52,182 @@ class PbrsTransitionsTest(QaTestCase): env.reset() return env + def test_analysis_matches_real_rewards_across_modes_and_parameter_boundaries(self): + """Compare independent rewards on real fills, marks and terminal liquidation. + + The reference carries its own stored potential. Only the physical next state + is observed before liquidation; neither reward implementation is replaced. + Tight component tolerances also detect the tiny-terminal correction regression. + """ + # short, terminal loss, explicit exit, gamma, ratio, additives, masking, aim, idle cap + scenarios = ( + (False, False, False, 0.8, 0.4, True, False, 0.03, 12), + (True, True, True, 0.8, 1e-10, False, True, 0.03, 12), + (False, False, True, 0.0, 0.4, True, False, 0.03, 12), + (True, False, False, 1.0, 1e-10, True, True, 0.03, 12), + (False, True, False, 1e-12, 1e-10, True, True, 0.03, 12), + (True, True, True, 1e-9, 0.4, False, False, 0.03, 12), + (False, False, True, np.float32(0.8), np.float32(0.4), True, False, 0.03, 0), + (True, False, False, np.int64(1), 0.4, False, True, 0.0, 12), + (False, False, True, np.longdouble(0.8), 0.4, True, False, -0.03, 12), + ) + components = ( + ("invalid_penalty", "_last_invalid_penalty"), + ("idle_penalty", "_last_idle_penalty"), + ("hold_penalty", "_last_hold_penalty"), + ("exit_component", "_last_exit_reward"), + ("entry_additive", "_last_entry_additive"), + ("exit_additive", "_last_exit_additive"), + ("reward_shaping", "_last_reward_shaping"), + ("prev_potential", "_last_prev_potential"), + ("next_potential", "_last_next_potential"), + ) + for mode in ReforceXY._EXIT_POTENTIAL_MODES: + for scenario in scenarios: + short, terminal_loss, explicit, gamma, ratio, additives, masking, aim, idle_cap = ( + scenario + ) + with self.subTest(mode=mode, scenario=scenario): + opens = [ + 100.0, + 100.0, + 100.0, + 104.0, + 110.0, + 90.0, + 108.0, + 105.0, + 100.0, + 98.0, + 112.0, + 107.0, + 96.0, + 115.0, + 103.0, + 108.0, + ] + if short != terminal_loss: + opens = [200.0 - price for price in opens] + parameters = { + "exit_potential_mode": mode, + "potential_gamma": gamma, + "hold_potential_enabled": True, + "hold_potential_ratio": ratio, + "hold_potential_gain": np.float32(1.0), + "efficiency_weight": np.float32(1.0), + "entry_additive_enabled": additives, + "exit_additive_enabled": additives, + "max_trade_duration_candles": np.int64(3), + "max_idle_duration_candles": np.int64(idle_cap), + } + env = self.env(open_prices=opens, profit_aim=aim, fee=0.0015, **parameters) + env.action_masking = masking + params = { + **analysis.DEFAULT_MODEL_REWARD_PARAMETERS, + **parameters, + "entry_fee_rate": env.fee, + "exit_fee_rate": env.fee, + } + references = { + "direct": params, + "strict": analysis.validate_reward_parameters(params, strict=True)[0], + "relaxed": analysis.validate_reward_parameters(params, strict=False)[0], + } + potentials = dict.fromkeys(references, 0.0) + for tick in range(len(opens)): + action = Actions.Neutral + if tick == 2: + action = Actions.Short_enter if short else Actions.Long_enter + elif explicit and tick == 4: + action = Actions.Short_exit if short else Actions.Long_exit + elif tick == 6: + action = Actions.Long_exit if short else Actions.Short_exit + context = analysis.RewardContext( + env.get_unrealized_profit(), + env.get_trade_duration(), + env.get_idle_duration(), + env.get_max_unrealized_profit(), + env.get_min_unrealized_profit(), + analysis.Positions(env._position.value), + analysis.Actions(action.value), + ) + next_context = None + compute_components = env._compute_pbrs_components + + def observe_next_state( + *, observed_env=env, compute=compute_components, **transition + ): + nonlocal next_context + pnl = transition["next_pnl"] + next_context = analysis.RewardContext( + pnl, + transition["next_trade_duration"], + observed_env.get_idle_duration(), + max(observed_env.get_max_unrealized_profit(), pnl), + min(observed_env.get_min_unrealized_profit(), pnl), + analysis.Positions(transition["next_position"].value), + analysis.Actions.Neutral, + ) + return compute(**transition) + + with mock.patch.object( + env, "_compute_pbrs_components", side_effect=observe_next_state + ): + _, reward, done, truncated, info = env.step(action.value) + self.assertIsNotNone(next_context) + for validation, reference_params in references.items(): + with self.subTest(validation=validation, tick=tick): + expected = analysis.calculate_reward( + context, + reference_params, + 100.0, + aim, + 2.0, + short_allowed=True, + action_masking=masking, + prev_potential=potentials[validation], + next_context=next_context, + terminated=done or truncated, + ) + self.assertTrue( + math.isclose( + reward, expected.total, rel_tol=1e-12, abs_tol=1e-12 + ), + (reward, expected.total), + ) + for field, attribute in components: + actual = getattr(env, attribute) + predicted = getattr(expected, field) + # Relative error for composed arithmetic; absolute floor + # below tiny shaping signals, unlike report tolerance. + self.assertTrue( + math.isclose( + actual, predicted, rel_tol=1e-12, abs_tol=1e-20 + ), + (field, actual, predicted), + ) + self.assertEqual( + info["terminal_liquidation"], expected.terminal_liquidation + ) + if expected.exit_pnl is not None: + self.assertAlmostEqual( + info["exit_pnl"], expected.exit_pnl, places=12 + ) + potentials[validation] = expected.next_potential + if done or truncated: + break + self.assertTrue(done or truncated) + self.assertEqual(set(potentials.values()), {0.0}) + + def test_analysis_rejects_gamma_outside_bounds_before_float_rounding(self): + """Extended-precision out-of-range discounts cannot become valid by rounding.""" + for gamma in ( + np.nextafter(np.longdouble(1), np.longdouble(math.inf)), + np.nextafter(np.longdouble(0), np.longdouble(-1)), + ): + with self.subTest(gamma=gamma), self.assertRaises(ValueError): + analysis.validate_reward_parameters({"potential_gamma": gamma}, strict=True) + def test_retained_potential_shapes_every_nonterminal_neutral_step(self): for mode in ("retain_previous", "progressive_release", "spike_cancel"): with self.subTest(mode=mode): -- 2.53.0