]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(reforcexy): synchronize analytical reward boundary contracts
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 20:13:57 +0000 (22:13 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 20:13:57 +0000 (22:13 +0200)
ReforceXY/.basedpyright/diagnostics.json
ReforceXY/.coveragerc
ReforceXY/reward_space_analysis/README.md
ReforceXY/reward_space_analysis/reward_space_analysis.py
ReforceXY/reward_space_analysis/tests/README.md
ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py
ReforceXY/reward_space_analysis/tests/constants.py
ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py
ReforceXY/tests/test_coverage_floor.py
ReforceXY/tests/test_model_pbrs_transitions.py

index e69d425cf99bc25e01b5589d17ca38db3a14f31b..37c57dea33885189a61a01b78354dd9c332de26c 100644 (file)
     },
     {
       "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,
index 4aa29b011ef1c9af3b4d0fe841019b2a521c55d8..b01fefbfe2f549a1c17852d443f4ef2c0b5f8f6b 100644 (file)
@@ -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
index 2220b58efed4562a8e7a2599590ab1fd986d407b..ee9499d3cd24a78365a358c55556e4bcec04b342 100644 (file)
@@ -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                                                                        |
index 563570e069a8cd275b530ace740a3de42f8480b5..456aba085ff10e01c1e08904c5305b7226ec62e2 100644 (file)
@@ -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,
index 4b0bc00e42739643f51c78d752cc85619572c65f..15e8859c3e09b2c53f328c8dd1e41241f2f07c7e 100644 (file)
@@ -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
index 78c34944c3cff2b493508a132bc4f0aef74ccff1..09d698a9fee554b26afef293d1dda3e396face35 100644 (file)
@@ -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"
index ca583ca3c6513742c57b68d4d09ef3b60a7f8b41..ddc6d228ef291c76f783023b58ee17de8fef676c 100644 (file)
@@ -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
index 05af8aa48d50907ef487cbcdf405948cf83d1912..835ad980e80971e8e3d61475110e83b06880b70e 100644 (file)
@@ -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()
index 431d4ba30b1e1cad4b5b4825d7b0e23f76b110bf..ab82b282776c0769b8856f8a1251b38042e71701 100644 (file)
@@ -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
index d49877bd16d1ae0d066293a7bc939bf2e6c62de6..59f54047c2cad1dd359a8dba91b1fd57a1119c37 100644 (file)
@@ -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):