]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(reforcexy): preserve native duration consumers and coverage provenance
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 3 Oct 2026 19:09:31 +0000 (21:09 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 3 Oct 2026 19:09:31 +0000 (21:09 +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/api/test_api_helpers.py
ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py
ReforceXY/tests/test_coverage_floor.py

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