]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(reforcexy): preserve native duration precision and finite gamma bounds
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 22:34:30 +0000 (00:34 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 22:34:30 +0000 (00:34 +0200)
ReforceXY/.basedpyright/diagnostics.json
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/pbrs/test_pbrs.py

index 37c57dea33885189a61a01b78354dd9c332de26c..4309409b4293a859a9a6abce7fa828497275001e 100644 (file)
     },
     {
       "endCharacter": 5,
-      "endLine": 2156,
+      "endLine": 2194,
       "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": 2151
+      "startLine": 2189
     },
     {
       "endCharacter": 21,
-      "endLine": 2153,
+      "endLine": 2191,
       "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": 2153
+      "startLine": 2191
     },
     {
       "endCharacter": 5,
-      "endLine": 2178,
+      "endLine": 2216,
       "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": 2174
+      "startLine": 2212
     },
     {
       "endCharacter": 43,
-      "endLine": 2175,
+      "endLine": 2213,
       "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": 2175
+      "startLine": 2213
     },
     {
       "endCharacter": 42,
-      "endLine": 2175,
+      "endLine": 2213,
       "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": 2175
+      "startLine": 2213
     },
     {
       "endCharacter": 60,
-      "endLine": 2435,
+      "endLine": 2473,
       "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": 2435
+      "startLine": 2473
     },
     {
       "endCharacter": 58,
-      "endLine": 2436,
+      "endLine": 2474,
       "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": 2436
+      "startLine": 2474
     },
     {
       "endCharacter": 88,
-      "endLine": 2455,
+      "endLine": 2493,
       "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": 2455
+      "startLine": 2493
     },
     {
       "endCharacter": 88,
-      "endLine": 2455,
+      "endLine": 2493,
       "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": 2455
+      "startLine": 2493
     },
     {
       "endCharacter": 17,
-      "endLine": 2464,
+      "endLine": 2502,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Object of type \"None\" cannot be called",
       "rule": "reportOptionalCall",
       "severity": "error",
       "startCharacter": 28,
-      "startLine": 2458
+      "startLine": 2496
     },
     {
       "endCharacter": 29,
-      "endLine": 3063,
+      "endLine": 3101,
       "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": 3063
+      "startLine": 3101
     },
     {
       "endCharacter": 28,
-      "endLine": 3063,
+      "endLine": 3101,
       "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": 3063
+      "startLine": 3101
     },
     {
       "endCharacter": 65,
-      "endLine": 3072,
+      "endLine": 3110,
       "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": 3072
+      "startLine": 3110
     },
     {
       "endCharacter": 65,
-      "endLine": 3072,
+      "endLine": 3110,
       "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": 3072
+      "startLine": 3110
     },
     {
       "endCharacter": 5,
-      "endLine": 3972,
+      "endLine": 4022,
       "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": 3968
+      "startLine": 4018
     },
     {
       "endCharacter": 43,
-      "endLine": 3969,
+      "endLine": 4019,
       "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": 3969
+      "startLine": 4019
     },
     {
       "endCharacter": 42,
-      "endLine": 3969,
+      "endLine": 4019,
       "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": 3969
+      "startLine": 4019
     },
     {
       "endCharacter": 22,
index 0f6155bc80c992e34a5882724660c948df6eb870..8c7f00863c37a55b9c9dc69fd6e937aecfa29b44 100644 (file)
@@ -215,11 +215,17 @@ be overridden via `--params`.
   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.
+scalars. Native duration scalars are checked against their bounds before direct
+conversion to Python integers, without binary64 narrowing or a fixed-width cap.
+Adjustment metadata for finite native durations records the final normalized
+integer. Strict validation checks a numeric gamma in its native precision before
+normalizing it.
+Analysis-specific numeric string/bool coercion is retained; it does not replace
+the stricter runtime configuration contract. Direct gamma calculation falls back
+to 0.95 for invalid/non-finite input and clamps finite out-of-range values before
+narrowing, including extended-precision scalars. Relaxed parameter validation
+records its resets/clamps and also checks finite native gamma bounds before
+narrowing.
 For a trained or resumed learner, supply its effective discount as
 `potential_gamma`, not a stale reward/config gamma.
 
index 456aba085ff10e01c1e08904c5305b7226ec62e2..8245fd1ddf7d1107c5bf3f5dae255472fc3556c5 100644 (file)
@@ -595,6 +595,8 @@ def validate_reward_parameters(
     Returns a sanitized copy plus adjustments mapping (param -> original/adjusted/reason).
     Behavior:
     - Boolean-like keys are coerced to bool.
+    - Native duration bounds are checked before conversion directly to Python int.
+    - Finite out-of-range gamma is clamped before float conversion in relaxed mode.
     - Numeric-bounded keys are coerced to float when provided as str/bool/None.
       * In strict mode: raise on non-numeric or out-of-bounds.
       * In relaxed mode: fallback to min bound or 0.0 with adjustment reason.
@@ -654,6 +656,42 @@ def validate_reward_parameters(
             and not bounds["min"] <= original_val <= bounds["max"]
         ):
             raise ValueError(f"Param: '{key}'={original_val!r} outside [0, 1]")
+        # Duration conversion and finite gamma clamps must precede binary64 narrowing.
+        native_duration = key in ("max_trade_duration_candles", "max_idle_duration_candles")
+        if (native_duration or key == "potential_gamma") and isinstance(
+            original_val, (int, float, np.integer, np.floating)
+        ):
+            native_finite = isinstance(original_val, (int, np.integer)) or np.isfinite(original_val)
+            if native_finite and (
+                native_duration or not bounds["min"] <= original_val <= bounds["max"]
+            ):
+                adjusted_native = original_val
+                reason_parts = []
+                if "min" in bounds and adjusted_native < bounds["min"]:
+                    if strict:
+                        raise ValueError(f"Param: '{key}'={original_val} below min {bounds['min']}")
+                    adjusted_native = bounds["min"]
+                    reason_parts.append(f"min={bounds['min']}")
+                if "max" in bounds and adjusted_native > bounds["max"]:
+                    if strict:
+                        raise ValueError(f"Param: '{key}'={original_val} above max {bounds['max']}")
+                    adjusted_native = bounds["max"]
+                    reason_parts.append(f"max={bounds['max']}")
+                adjusted_native = (
+                    int(adjusted_native) if native_duration else float(adjusted_native)
+                )
+                if not isinstance(original_val, (int, float)):
+                    reason_parts.insert(0, "numeric_coerce")
+                sanitized[key] = adjusted_native
+                if reason_parts:
+                    adjustments[key] = {
+                        "original": original_val,
+                        "adjusted": adjusted_native,
+                        "reason": ",".join(reason_parts),
+                        "validation_mode": "strict" if strict else "relaxed",
+                    }
+                continue
+
         # Robust coercion to float using helper (handles None/str/bool/non-finite)
         coerced_val = _get_float_param({key: original_val}, key, np.nan)
 
@@ -750,7 +788,7 @@ def validate_reward_parameters(
             }
     for key in ("max_trade_duration_candles", "max_idle_duration_candles"):
         if key in sanitized:
-            sanitized[key] = int(_get_float_param(sanitized, key))
+            sanitized[key] = _get_int_param(sanitized, key)
 
     return sanitized, adjustments
 
@@ -3263,6 +3301,18 @@ def _get_potential_gamma(params: RewardParams) -> float:
     - Guarantee returned float ∈ [0,1].
     """
     raw_gamma = params.get("potential_gamma")
+    bounds = _PARAMETER_BOUNDS["potential_gamma"]
+    if isinstance(raw_gamma, (int, float, np.integer, np.floating)):
+        native_finite = isinstance(raw_gamma, (int, np.integer)) or np.isfinite(raw_gamma)
+        if native_finite and not bounds["min"] <= raw_gamma <= bounds["max"]:
+            gamma = float(bounds["min"] if raw_gamma < bounds["min"] else bounds["max"])
+            warnings.warn(
+                f"PBRS: potential_gamma={raw_gamma} outside [0,1]; clamped to {gamma}",
+                RewardDiagnosticsWarning,
+                stacklevel=2,
+            )
+            return gamma
+
     gamma = _get_float_param(params, "potential_gamma", np.nan)
     if not np.isfinite(gamma):
         if "potential_gamma" in params:
index 875caba5eda873374df2b7dad5853e1517e7a888..58e0a70911dd2b9371377cd401a77577877c734a 100644 (file)
@@ -228,6 +228,10 @@ Columns:
 | 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 |
+| pbrs-native-integer-durations-146 | pbrs | Native integer durations and adjustment metadata retain exact values | pbrs/test_pbrs.py::test_native_integer_durations_preserve_exact_values | Python, signed NumPy and unsigned NumPy integers; strict and relaxed |
+| pbrs-native-duration-bounds-147 | pbrs | Floating duration bounds are checked before truncation without narrowing | pbrs/test_pbrs.py::test_native_duration_bounds_precede_truncation | Extended-precision cases run when supported by the platform |
+| pbrs-native-gamma-reward-148 | pbrs | Finite out-of-range native gamma clamps reach the public reward | pbrs/test_pbrs.py::test_native_finite_gamma_clamps_the_public_reward | Extended-range cases run when supported by the platform |
+| pbrs-relaxed-native-gamma-149 | pbrs | Relaxed native gamma clamps reach rewards while strict validation rejects | pbrs/test_pbrs.py::test_relaxed_native_gamma_clamps_before_reward_calculation | Preserves direct, strict and relaxed boundary contracts |
 
 ### Non-Owning Smoke / Reference Checks
 
index 835ad980e80971e8e3d61475110e83b06880b70e..0366119780547fa8d65eb3ec86f686868d1ca795 100644 (file)
@@ -1556,6 +1556,113 @@ class TestPBRS(RewardSpaceTestBase):
             },
         )
 
+    def test_native_integer_durations_preserve_exact_values(self):
+        """Validation preserves exact duration values and adjustment metadata."""
+        values = (
+            2**53 + 1,
+            np.int64(2**53 + 1),
+            np.int64(2**63 - 1),
+            np.uint64(2**64 - 1),
+            2**200 + 1,
+        )
+        for key in ("max_trade_duration_candles", "max_idle_duration_candles"):
+            for strict in (True, False):
+                for value in values:
+                    with self.subTest(key=key, strict=strict, value=value):
+                        normalized, adjustments = validate_reward_parameters(
+                            {key: value}, strict=strict
+                        )
+                        self.assertEqual(normalized[key], int(value))
+                        if isinstance(value, np.integer):
+                            self.assertEqual(adjustments[key]["adjusted"], int(value))
+
+    def test_native_duration_bounds_precede_truncation(self):
+        """Native floating durations truncate only after their bounds are checked."""
+        values = [np.float32(3.75), np.float64(3.75)]
+        if np.finfo(np.longdouble).nmant > np.finfo(np.float64).nmant:
+            values.extend(
+                [np.longdouble("9007199254740993.0"), np.longdouble("9007199254740993.5")]
+            )
+        for key, below_minimum, minimum in (
+            ("max_trade_duration_candles", np.longdouble("0.5"), 1),
+            ("max_idle_duration_candles", np.longdouble("-0.5"), 0),
+        ):
+            for value in values:
+                for strict in (True, False):
+                    with self.subTest(key=key, strict=strict, value=value):
+                        normalized, _ = validate_reward_parameters({key: value}, strict=strict)
+                        self.assertEqual(normalized[key], int(value))
+            with self.subTest(key=key, below_minimum=below_minimum):
+                with self.assertRaises(ValueError):
+                    validate_reward_parameters({key: below_minimum}, strict=True)
+                normalized, _ = validate_reward_parameters({key: below_minimum}, strict=False)
+                self.assertEqual(normalized[key], minimum)
+
+    def test_native_finite_gamma_clamps_the_public_reward(self):
+        """Finite native discounts outside the domain use endpoints, not a fallback."""
+        values = [(np.float64(2.0), 0.0), (np.float64(-1.0), -0.025)]
+        if np.finfo(np.longdouble).maxexp > np.finfo(np.float64).maxexp:
+            values.extend([(np.longdouble("1e400"), 0.0), (np.longdouble("-1e400"), -0.025)])
+        context = reward_space_analysis.RewardContext(
+            0.0, 0, 1, 0.0, 0.0, Positions.Neutral, Actions.Neutral
+        )
+        for gamma, expected_shaping in values:
+            with self.subTest(gamma=gamma):
+                params = self.base_params(
+                    potential_gamma=gamma,
+                    hold_potential_enabled=True,
+                    exit_potential_mode="retain_previous",
+                    idle_penalty_ratio=0.0,
+                )
+                with self.assertWarns(reward_space_analysis.RewardDiagnosticsWarning):
+                    reward = reward_space_analysis.calculate_reward(
+                        context,
+                        params,
+                        100.0,
+                        0.03,
+                        2.0,
+                        short_allowed=True,
+                        action_masking=True,
+                        prev_potential=0.025,
+                    )
+                self.assertEqual(reward.reward_shaping, expected_shaping)
+                self.assertEqual(reward.total, expected_shaping)
+
+    def test_relaxed_native_gamma_clamps_before_reward_calculation(self):
+        """Relaxed finite-native clamps reach the reward; strict validation still rejects."""
+        values = [(np.float64(2.0), 1.0), (np.float64(-1.0), 0.0)]
+        if np.finfo(np.longdouble).maxexp > np.finfo(np.float64).maxexp:
+            values.extend([(np.longdouble("1e400"), 1.0), (np.longdouble("-1e400"), 0.0)])
+        context = reward_space_analysis.RewardContext(
+            0.0, 0, 1, 0.0, 0.0, Positions.Neutral, Actions.Neutral
+        )
+        for gamma, expected_gamma in values:
+            with self.subTest(gamma=gamma):
+                with self.assertRaises(ValueError):
+                    validate_reward_parameters({"potential_gamma": gamma}, strict=True)
+                normalized, adjustments = validate_reward_parameters(
+                    {"potential_gamma": gamma}, strict=False
+                )
+                self.assertEqual(normalized["potential_gamma"], expected_gamma)
+                self.assertEqual(adjustments["potential_gamma"]["adjusted"], expected_gamma)
+                params = self.base_params(
+                    **normalized,
+                    hold_potential_enabled=True,
+                    exit_potential_mode="retain_previous",
+                    idle_penalty_ratio=0.0,
+                )
+                reward = reward_space_analysis.calculate_reward(
+                    context,
+                    params,
+                    100.0,
+                    0.03,
+                    2.0,
+                    short_allowed=True,
+                    action_masking=True,
+                    prev_potential=0.025,
+                )
+                self.assertEqual(reward.reward_shaping, expected_gamma * 0.025 - 0.025)
+
     def test_validate_reward_parameters_records_near_bound_clamps_exactly(self):
         """Relaxed validation applies near-bound clamps without approximate-equality suppression."""
         cases = (