]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix: harden reward analysis and runtime regressions
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 23 Sep 2026 21:44:18 +0000 (23:44 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 23 Sep 2026 21:44:18 +0000 (23:44 +0200)
14 files changed:
.github/workflows/quality.yml
README.md
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/cli/test_cli_params_and_csv.py
ReforceXY/reward_space_analysis/tests/helpers/test_utilities.py
ReforceXY/reward_space_analysis/tests/integration/test_report_formatting.py
ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py
ReforceXY/tests/test_review_contracts.py
ReforceXY/user_data/strategies/RLAgentStrategy.py
quickadapter/tests/test_prediction_history.py [new file with mode: 0644]

index 019114d45633ccc2dcf29af9a935b51a0cfe6747..81f5fe1b1ebc96f45130b812441df90e2213eaa6 100644 (file)
@@ -63,6 +63,16 @@ jobs:
             "${{ matrix.image }}" \
             /workspace/scripts/check_basedpyright.py \
             --project "${{ matrix.project }}"
+      - name: QuickAdapter runtime regressions
+        if: matrix.project == 'quickadapter'
+        run: |
+          docker run --rm \
+            --mount "type=bind,src=${GITHUB_WORKSPACE},dst=/workspace,readonly" \
+            --workdir /workspace \
+            --env PYTHONPATH=/workspace/quickadapter/user_data/strategies \
+            --entrypoint python \
+            "${{ matrix.image }}" \
+            -m unittest discover -s quickadapter/tests -v
 
   reforcexy-tests:
     name: ReforceXY tests
index 9f048e0627e11f1160f7c59a644901a9d2ab30d0..bece30f25bdaafb9422cb5b6e815429db6d56ce2 100644 (file)
--- a/README.md
+++ b/README.md
@@ -465,12 +465,16 @@ and `exit_pnl` remain on the transition history row.
 
 ### Runtime regressions
 
-Run the runtime training, inference and accounting regressions inside the
-ReforceXY QA image, with the repository mounted at `/workspace` and `/workspace`
-as the working directory:
+Run each suite in its matching Freqtrade QA image, with the repository mounted
+at `/workspace` and `/workspace` as the working directory:
 
 ```shell
+# ReforceXY
 python -m unittest discover -s ReforceXY/tests -v
+
+# QuickAdapter
+PYTHONPATH=/workspace/quickadapter/user_data/strategies \
+  python -m unittest discover -s quickadapter/tests -v
 ```
 
 ### Quality checks
index 0a0397460b1d114dc22f4d821b9edd122be5a4bf..61c476f94cbc401e187b6ff64778131d4949f9da 100644 (file)
     },
     {
       "endCharacter": 5,
-      "endLine": 2108,
+      "endLine": 2135,
       "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": 2103
+      "startLine": 2130
     },
     {
       "endCharacter": 21,
-      "endLine": 2105,
+      "endLine": 2132,
       "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": 2105
+      "startLine": 2132
     },
     {
       "endCharacter": 5,
-      "endLine": 2130,
+      "endLine": 2157,
       "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": 2126
+      "startLine": 2153
     },
     {
       "endCharacter": 43,
-      "endLine": 2127,
+      "endLine": 2154,
       "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": 2127
+      "startLine": 2154
     },
     {
       "endCharacter": 42,
-      "endLine": 2127,
+      "endLine": 2154,
       "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": 2127
+      "startLine": 2154
     },
     {
       "endCharacter": 60,
-      "endLine": 2387,
+      "endLine": 2414,
       "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": 2387
+      "startLine": 2414
     },
     {
       "endCharacter": 58,
-      "endLine": 2388,
+      "endLine": 2415,
       "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": 2388
+      "startLine": 2415
     },
     {
       "endCharacter": 88,
-      "endLine": 2407,
+      "endLine": 2434,
       "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": 2407
+      "startLine": 2434
     },
     {
       "endCharacter": 88,
-      "endLine": 2407,
+      "endLine": 2434,
       "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": 2407
+      "startLine": 2434
     },
     {
       "endCharacter": 17,
-      "endLine": 2416,
+      "endLine": 2443,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Object of type \"None\" cannot be called",
       "rule": "reportOptionalCall",
       "severity": "error",
       "startCharacter": 28,
-      "startLine": 2410
-    },
-    {
-      "endCharacter": 40,
-      "endLine": 2645,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg1\" of type \"SupportsRichComparisonT@min\" in function \"min\"\n  Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n    Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n      Type \"object\" is not assignable to type \"SupportsRichComparison\"\n        \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n          \"__lt__\" is not present\n        \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n          \"__gt__\" is not present",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 22,
-      "startLine": 2645
-    },
-    {
-      "endCharacter": 38,
-      "endLine": 2645,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Cannot access attribute \"min\" for class \"ExtensionArray\"\n  Attribute \"min\" is unknown",
-      "rule": "reportAttributeAccessIssue",
-      "severity": "error",
-      "startCharacter": 35,
-      "startLine": 2645
-    },
-    {
-      "endCharacter": 59,
-      "endLine": 2645,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg2\" of type \"SupportsRichComparisonT@min\" in function \"min\"\n  Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n    Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n      Type \"object\" is not assignable to type \"SupportsRichComparison\"\n        \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n          \"__lt__\" is not present\n        \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n          \"__gt__\" is not present",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 42,
-      "startLine": 2645
-    },
-    {
-      "endCharacter": 57,
-      "endLine": 2645,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Cannot access attribute \"min\" for class \"ExtensionArray\"\n  Attribute \"min\" is unknown",
-      "rule": "reportAttributeAccessIssue",
-      "severity": "error",
-      "startCharacter": 54,
-      "startLine": 2645
-    },
-    {
-      "endCharacter": 40,
-      "endLine": 2646,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg1\" of type \"SupportsRichComparisonT@max\" in function \"max\"\n  Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n    Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n      Type \"object\" is not assignable to type \"SupportsRichComparison\"\n        \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n          \"__lt__\" is not present\n        \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n          \"__gt__\" is not present",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 22,
-      "startLine": 2646
-    },
-    {
-      "endCharacter": 38,
-      "endLine": 2646,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Cannot access attribute \"max\" for class \"ExtensionArray\"\n  Attribute \"max\" is unknown",
-      "rule": "reportAttributeAccessIssue",
-      "severity": "error",
-      "startCharacter": 35,
-      "startLine": 2646
-    },
-    {
-      "endCharacter": 59,
-      "endLine": 2646,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg2\" of type \"SupportsRichComparisonT@max\" in function \"max\"\n  Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n    Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n      Type \"object\" is not assignable to type \"SupportsRichComparison\"\n        \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n          \"__lt__\" is not present\n        \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n          \"__gt__\" is not present",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 42,
-      "startLine": 2646
-    },
-    {
-      "endCharacter": 57,
-      "endLine": 2646,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Cannot access attribute \"max\" for class \"ExtensionArray\"\n  Attribute \"max\" is unknown",
-      "rule": "reportAttributeAccessIssue",
-      "severity": "error",
-      "startCharacter": 54,
-      "startLine": 2646
-    },
-    {
-      "endCharacter": 76,
-      "endLine": 2662,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "No overloads for \"histogram\" match the provided arguments",
-      "rule": "reportCallIssue",
-      "severity": "error",
-      "startCharacter": 24,
-      "startLine": 2662
-    },
-    {
-      "endCharacter": 49,
-      "endLine": 2662,
-      "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 \"_ArrayLikeComplex_co\" in function \"histogram\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeComplex_co\"\n    Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeComplex_co\"\n      \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 37,
-      "startLine": 2662
-    },
-    {
-      "endCharacter": 74,
-      "endLine": 2663,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "No overloads for \"histogram\" match the provided arguments",
-      "rule": "reportCallIssue",
-      "severity": "error",
-      "startCharacter": 23,
-      "startLine": 2663
-    },
-    {
-      "endCharacter": 47,
-      "endLine": 2663,
-      "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 \"_ArrayLikeComplex_co\" in function \"histogram\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeComplex_co\"\n    Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeComplex_co\"\n      \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 36,
-      "startLine": 2663
-    },
-    {
-      "endCharacter": 51,
-      "endLine": 2691,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"u_values\" of type \"ToFloatND\" in function \"wasserstein_distance\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n    Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n      \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 39,
-      "startLine": 2691
-    },
-    {
-      "endCharacter": 64,
-      "endLine": 2691,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"v_values\" of type \"ToFloatND\" in function \"wasserstein_distance\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n    Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n      \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 53,
-      "startLine": 2691
-    },
-    {
-      "endCharacter": 68,
-      "endLine": 2694,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "No overloads for \"ks_2samp\" match the provided arguments",
-      "rule": "reportCallIssue",
-      "severity": "error",
-      "startCharacter": 27,
-      "startLine": 2694
-    },
-    {
-      "endCharacter": 54,
-      "endLine": 2694,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"data1\" of type \"ToFloatND\" in function \"ks_2samp\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n    Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n      \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 42,
-      "startLine": 2694
-    },
-    {
-      "endCharacter": 67,
-      "endLine": 2694,
-      "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
-      "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"data2\" of type \"ToFloatND\" in function \"ks_2samp\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n    Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n      \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n  ...",
-      "rule": "reportArgumentType",
-      "severity": "error",
-      "startCharacter": 56,
-      "startLine": 2694
+      "startLine": 2437
     },
     {
       "endCharacter": 29,
-      "endLine": 2994,
+      "endLine": 3038,
       "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": 2994
+      "startLine": 3038
     },
     {
       "endCharacter": 28,
-      "endLine": 2994,
+      "endLine": 3038,
       "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": 2994
+      "startLine": 3038
     },
     {
       "endCharacter": 65,
-      "endLine": 3003,
+      "endLine": 3047,
       "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": 3003
+      "startLine": 3047
     },
     {
       "endCharacter": 65,
-      "endLine": 3003,
+      "endLine": 3047,
       "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": 3003
+      "startLine": 3047
     },
     {
       "endCharacter": 5,
-      "endLine": 3899,
+      "endLine": 3947,
       "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": 3895
+      "startLine": 3943
     },
     {
       "endCharacter": 43,
-      "endLine": 3896,
+      "endLine": 3944,
       "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": 3896
+      "startLine": 3944
     },
     {
       "endCharacter": 42,
-      "endLine": 3896,
+      "endLine": 3944,
       "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": 3896
+      "startLine": 3944
     },
     {
       "endCharacter": 22,
index b4cda5c2bab8df9935193c14d082d7760221d79b..7d55875b555d41c99eae10e6b9660a79808cdca5 100644 (file)
@@ -178,8 +178,10 @@ be overridden via `--params`.
 - **`--profit_aim`** (float, default: 0.03) – Profit target threshold (e.g.
   0.03=3%).
 - **`--risk_reward_ratio`** (float, default: 2.0) – Risk-reward multiplier.
-- **`--action_masking`** (bool, default: true) – Simulate environment action
-  masking. Invalid actions receive penalties only if masking disabled.
+- **`--action_masking`** (bool, default: true) – With masking enabled, sample
+  only valid actions. When disabled, sample an invalid action with 10% probability
+  and apply the configured invalid-action penalty. Invalid actions leave the held
+  position unchanged, except for an independent terminal liquidation.
 
 ### Reward & Shaping
 
@@ -219,6 +221,9 @@ does not expose this option. In that mode, bootstrap percentile intervals retain
 finite ordered bounds, including exact zero-width intervals for constants; the
 interval need not contain the original sample mean.
 
+Bootstrap counts must be positive. The PnL rank-biserial effect is positive when
+the first named group (`pnl+`) has higher rewards than the second (`pnl-`).
+
 ### Overrides
 
 - **`--out_dir`** (path, default: reward_space_outputs) – Output directory
@@ -462,6 +467,10 @@ Flags hierarchy:
 
 Auto-skip if `num_samples < 4`.
 
+Reusing an output directory removes only stale analyzer-owned
+`feature_importance.csv` and the three `partial_dependence_{trade_duration,idle_duration,pnl}.csv`
+files before writing the new report; unrelated files are retained.
+
 ### Reproducibility
 
 | Component                             | Controlled By | Notes                                      |
@@ -577,6 +586,11 @@ Within the same analyzer revision, identical `params_hash` values mean the resol
 Implementation: up to 50 evenly spaced histogram edges (normally 49 bins) with
 ε=1e-10; constants have zero divergence.
 
+Non-finite numeric values in real episodes are marked missing. Each feature is
+compared only when both synthetic and real data contain at least 10 finite
+observations; otherwise it is omitted. If none qualify, the report distinguishes
+this from not supplying real episodes.
+
 ---
 
 ## Advanced Usage
index eb8736b58779d4f7976abd8998705de7156b8848..f056d4d69dc43f0e1b068c3b383844d8457f120b 100644 (file)
@@ -1545,6 +1545,7 @@ _SAMPLE_DURATION_HAZARD_OVERTIME_MULTIPLIER = 4.0
 _SAMPLE_DURATION_HAZARD_MAX_PROBABILITY = 0.9
 _SAMPLE_EXIT_PROBABILITY_MIN = 0.002
 _SAMPLE_EXIT_PROBABILITY_MAX = 0.2
+_SAMPLE_INVALID_ACTION_PROBABILITY = 0.1
 
 
 def _sampling_probabilities(
@@ -1595,6 +1596,7 @@ def _sample_action(
     max_trade_duration_candles: int,
     idle_duration: int,
     max_idle_duration_candles: int,
+    action_masking: bool = True,
 ) -> tuple[Actions, float, float, float]:
     entry_prob, exit_prob, neutral_prob = _sampling_probabilities(
         position,
@@ -1612,15 +1614,26 @@ def _sample_action(
         else:
             choices = [Actions.Neutral, Actions.Long_enter]
             weights = [neutral_prob, entry_prob]
-        action = rng.choices(choices, weights=weights, k=1)[0]
-        return action, entry_prob, exit_prob, neutral_prob
-
-    if position == Positions.Long:
+    elif position == Positions.Long:
         choices = [Actions.Neutral, Actions.Long_exit]
+        weights = [1.0 - exit_prob, exit_prob]
     else:  # Positions.Short
         choices = [Actions.Neutral, Actions.Short_exit]
+        weights = [1.0 - exit_prob, exit_prob]
+
+    if not action_masking:
+        invalid_choices = [
+            candidate
+            for candidate in Actions
+            if not _is_valid_action(position, candidate, short_allowed=short_allowed)
+        ]
+        valid_mass = 1.0 - _SAMPLE_INVALID_ACTION_PROBABILITY
+        weights = [weight * valid_mass for weight in weights]
+        choices.extend(invalid_choices)
+        weights.extend(
+            [_SAMPLE_INVALID_ACTION_PROBABILITY / len(invalid_choices)] * len(invalid_choices)
+        )
 
-    weights = [1.0 - exit_prob, exit_prob]
     action = rng.choices(choices, weights=weights, k=1)[0]
     return action, entry_prob, exit_prob, neutral_prob
 
@@ -1751,6 +1764,7 @@ def simulate_samples(
             max_trade_duration_candles=max_trade_duration_candles,
             idle_duration=idle_duration,
             max_idle_duration_candles=max_idle_duration_candles,
+            action_masking=action_masking,
         )
 
         context = RewardContext(
@@ -1763,18 +1777,13 @@ def simulate_samples(
             action=action,
         )
 
+        next_position = _get_next_position(position, action, short_allowed=short_allowed)
         if position == Positions.Neutral:
-            if action == Actions.Long_enter:
-                position = Positions.Long
-                trade_duration = 0
-                idle_duration = 0
-                entry_open = current_open
-            elif action == Actions.Short_enter and short_allowed:
-                position = Positions.Short
+            if next_position != Positions.Neutral:
+                position = next_position
                 trade_duration = 0
                 idle_duration = 0
                 entry_open = current_open
-            if position in (Positions.Long, Positions.Short):
                 entry_pnl = _compute_unrealized_pnl_estimate(
                     position, entry_open=entry_open, current_open=entry_open, params=params
                 )
@@ -1783,7 +1792,7 @@ def simulate_samples(
                 pnl_floor = min(-0.15, entry_pnl)
         else:
             idle_duration = 0
-            if action in (Actions.Long_exit, Actions.Short_exit):
+            if next_position == Positions.Neutral:
                 position = Positions.Neutral
                 trade_duration = 0
                 idle_duration = 0
@@ -1946,7 +1955,11 @@ def simulate_samples(
                 "reward_base": breakdown.base_reward,
                 "reward_pbrs_delta": breakdown.pbrs_delta,
                 "reward_invariance_correction": breakdown.invariance_correction,
-                "is_invalid": float(breakdown.invalid_penalty != 0.0),
+                "is_invalid": float(
+                    not _is_valid_action(
+                        context.position, context.action, short_allowed=short_allowed
+                    )
+                ),
                 "pbrs_invariant": bool(pbrs_invariant),
             }
         )
@@ -1971,24 +1984,38 @@ def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> N
         (1.0 + entry_fee_rate) * (1.0 + exit_fee_rate) - 1.0,
     )
 
-    # INVARIANT 1: Action-position compatibility
-    long_exits = df[(df["action"] == 2.0) & (df["position"] != 1.0)]
-    short_exits = df[(df["action"] == 4.0) & (df["position"] != 0.0)]
-    if len(long_exits) > 0:
-        raise AssertionError(f"Sim: {len(long_exits)} Long_exit actions without Long position")
-    if len(short_exits) > 0:
-        raise AssertionError(f"Sim: {len(short_exits)} Short_exit actions without Short position")
-
-    long_entries = df[(df["action"] == 1.0) & (df["position"] != 0.5)]
-    short_entries = df[(df["action"] == 3.0) & (df["position"] != 0.5)]
-    if len(long_entries) > 0:
-        raise AssertionError(
-            f"Sim: {len(long_entries)} Long_enter actions without Neutral position"
-        )
-    if len(short_entries) > 0:
-        raise AssertionError(
-            f"Sim: {len(short_entries)} Short_enter actions without Neutral position"
-        )
+    # INVARIANT 1: masked sampling must only emit legal actions; unmasked
+    # actions may be invalid but cannot alter an open position.
+    if _get_bool_param(params, "action_masking", True):
+        long_exits = df[(df["action"] == 2.0) & (df["position"] != 1.0)]
+        short_exits = df[(df["action"] == 4.0) & (df["position"] != 0.0)]
+        if len(long_exits) > 0:
+            raise AssertionError(f"Sim: {len(long_exits)} Long_exit actions without Long position")
+        if len(short_exits) > 0:
+            raise AssertionError(
+                f"Sim: {len(short_exits)} Short_exit actions without Short position"
+            )
+
+        long_entries = df[(df["action"] == 1.0) & (df["position"] != 0.5)]
+        short_entries = df[(df["action"] == 3.0) & (df["position"] != 0.5)]
+        if len(long_entries) > 0:
+            raise AssertionError(
+                f"Sim: {len(long_entries)} Long_enter actions without Neutral position"
+            )
+        if len(short_entries) > 0:
+            raise AssertionError(
+                f"Sim: {len(short_entries)} Short_enter actions without Neutral position"
+            )
+    else:
+        changed_on_invalid = df[
+            df["is_invalid"].eq(1.0)
+            & ~df["terminated"].eq(True)
+            & df["next_position"].ne(df["position"])
+        ]
+        if len(changed_on_invalid) > 0:
+            raise AssertionError(
+                f"Sim: {len(changed_on_invalid)} invalid actions changed open position"
+            )
 
     # INVARIANT 2: Duration logic
     neutral_with_trade = df[(df["position"] == 0.5) & (df["trade_duration"] > 0)]
@@ -2009,15 +2036,15 @@ def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> N
         raise AssertionError(f"Sim: {len(neutral_with_pnl)} Neutral positions with non-zero pnl")
 
     # Economic exits belong to voluntary exits or a proven terminal liquidation.
+    voluntary_exit = (
+        df["position"].eq(Positions.Long.value) & df["action"].eq(Actions.Long_exit.value)
+    ) | (df["position"].eq(Positions.Short.value) & df["action"].eq(Actions.Short_exit.value))
     liquidation = df.get("terminal_liquidation", pd.Series(False, index=df.index)).eq(True)
     valid_liquidation = (
         df.get("terminated", pd.Series(False, index=df.index)).eq(True)
         & df.get("next_position", pd.Series(np.nan, index=df.index)).eq(Positions.Neutral.value)
         & (
-            (
-                df["position"].isin([Positions.Long.value, Positions.Short.value])
-                & ~df["action"].isin([Actions.Long_exit.value, Actions.Short_exit.value])
-            )
+            (df["position"].isin([Positions.Long.value, Positions.Short.value]) & ~voluntary_exit)
             | (
                 df["position"].eq(Positions.Neutral.value)
                 & df["action"].isin([Actions.Long_enter.value, Actions.Short_enter.value])
@@ -2028,7 +2055,7 @@ def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> N
     if (liquidation & ~valid_liquidation).any():
         raise AssertionError("Sim: terminal liquidation lacks a terminal open-position transition")
     non_exit_with_exit_reward = df[
-        (~df["action"].isin([2.0, 4.0])) & ~liquidation & (df["reward_exit"].abs() > eps_reward)
+        ~voluntary_exit & ~liquidation & (df["reward_exit"].abs() > eps_reward)
     ]
     if len(non_exit_with_exit_reward) > 0:
         raise AssertionError(
@@ -2576,6 +2603,15 @@ def load_real_episodes(
                     RewardDiagnosticsWarning,
                     stacklevel=2,
                 )
+            infinite = df[col].isin((np.inf, -np.inf))
+            count_infinite = int(infinite.sum())
+            if count_infinite:
+                df.loc[infinite, col] = np.nan
+                warnings.warn(
+                    f"Data: replaced {count_infinite} non-finite value(s) in column '{col}' with NaN when loading '{path}'",
+                    RewardDiagnosticsWarning,
+                    stacklevel=2,
+                )
 
     # Ensure required columns exist (or fill with NaN if allowed)
     required = {
@@ -2637,8 +2673,14 @@ def compute_distribution_shift_metrics(
     continuous_features = ["pnl", "trade_duration", "idle_duration"]
 
     for feature in continuous_features:
-        synth_values = synthetic_df[feature].dropna().values
-        real_values = real_df[feature].dropna().values
+        synth_values = synthetic_df[feature].to_numpy(dtype=float, na_value=np.nan)
+        real_values = real_df[feature].to_numpy(dtype=float, na_value=np.nan)
+        synth_finite = np.isfinite(synth_values)
+        real_finite = np.isfinite(real_values)
+        if not synth_finite.all():
+            synth_values = synth_values[synth_finite]
+        if not real_finite.all():
+            real_values = real_values[real_finite]
 
         if len(synth_values) < 10 or len(real_values) < 10:
             continue
@@ -2830,7 +2872,7 @@ def statistical_hypothesis_tests(
         u_stat, p_val = stats.mannwhitneyu(pnl_positive, pnl_negative)
 
         n1, n2 = len(pnl_positive), len(pnl_negative)
-        rb = 1.0 - 2.0 * (float(u_stat) / float(n1 * n2)) if n1 > 0 and n2 > 0 else np.nan
+        rb = 2.0 * (float(u_stat) / float(n1 * n2)) - 1.0 if n1 > 0 and n2 > 0 else np.nan
         if np.isfinite(rb):
             rb = float(np.clip(rb, -1.0, 1.0))
 
@@ -2959,6 +3001,8 @@ def bootstrap_confidence_intervals(
     """
     if independent_observations is not True:
         raise ValueError("Stats: bootstrap intervals require independent_observations=True")
+    if n_bootstrap < 1:
+        raise ValueError("Stats: n_bootstrap must be positive")
     alpha = 1 - confidence_level
     lower_percentile = 100 * alpha / 2
     upper_percentile = 100 * (1 - alpha / 2)
@@ -3726,8 +3770,8 @@ def build_argument_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "--base_factor",
         type=float,
-        default=100.0,
-        help="Base reward scaling factor (default: 100).",
+        default=None,
+        help=f"Base reward scaling factor (default: {DEFAULT_MODEL_REWARD_PARAMETERS['base_factor']:g}).",
     )
     parser.add_argument(
         "--profit_aim",
@@ -3891,6 +3935,10 @@ def write_complete_statistical_analysis(
 ) -> None:
     """Generate a single comprehensive statistical analysis report."""
     output_dir.mkdir(parents=True, exist_ok=True)
+    # These names belong to this report; leave all other user files untouched.
+    (output_dir / "feature_importance.csv").unlink(missing_ok=True)
+    for feature in ("trade_duration", "idle_duration", "pnl"):
+        (output_dir / f"partial_dependence_{feature}.csv").unlink(missing_ok=True)
     report_path = output_dir / "statistical_analysis.md"
 
     reward_params: RewardParams = (
@@ -4052,6 +4100,9 @@ def write_complete_statistical_analysis(
         distribution_shift = compute_distribution_shift_metrics(
             df, real_df, independent_observations=independent_observations
         )
+    distribution_shift_unavailable = (
+        "no real episodes provided" if real_df is None else "no comparable finite observations"
+    )
 
     # Write comprehensive report
     with report_path.open("w", encoding="utf-8") as f:
@@ -4619,7 +4670,7 @@ def write_complete_statistical_analysis(
             else:
                 # Placeholder keeps numbering stable and explicit
                 f.write("### 5.4 Distribution Shift Analysis\n\n")
-                f.write("_Not performed (no real episodes provided)._\n\n")
+                f.write(f"_Not performed ({distribution_shift_unavailable})._\n\n")
 
         # Footer
         f.write("---\n\n")
@@ -4649,7 +4700,9 @@ def write_complete_statistical_analysis(
         if distribution_shift:
             f.write("6. **Distribution Shift** - Comparison with real trading data\n")
         else:
-            f.write("6. **Distribution Shift** - Not performed (no real episodes provided)\n")
+            f.write(
+                f"6. **Distribution Shift** - Not performed ({distribution_shift_unavailable})\n"
+            )
         if invariance_status is not None:
             f.write("7. **PBRS Invariance** - " + invariance_status + "\n")
         f.write("\n")
@@ -4685,7 +4738,7 @@ def main() -> None:
         print("CLI: Parameter adjustments applied\n" + "\n".join(adj_lines))
 
     # Effective values: defaults < explicit flags < --params, resolved once.
-    base_factor = _get_float_param(params, "base_factor", float(args.base_factor))
+    base_factor = _get_float_param(params, "base_factor")
     profit_aim = _get_float_param(params, "profit_aim", float(args.profit_aim))
     risk_reward_ratio = _get_float_param(params, "risk_reward_ratio", float(args.risk_reward_ratio))
     effective_params = {
index 9bbd1901f9727a1d279fd448d17c67d6dcab7ddc..48f04536f8f61b83cc6b9c2fe5b68345a8fb34c5 100644 (file)
@@ -181,44 +181,52 @@ Columns:
   stable across unrelated line insertions.
 - Notes: Sub-modes, non-owning references and multi-path coverage.
 
-| ID                                            | Category    | Description                                                                          | Owning test                                                                                        | Notes                                                                                                                                                                          |
-| --------------------------------------------- | ----------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| report-additives-deterministic-092            | components  | Additives deterministic report section                                               | components/test_additives.py::test_additive_activation_deterministic_contribution                  | Integration/PBRS may reference outcome non-owning                                                                                                                              |
-| robustness-decomposition-integrity-101        | robustness  | Single active core component equals total reward under mutually exclusive scenarios  | robustness/test_robustness.py::test_decomposition_integrity                                        | Scenarios: idle, hold, exit, invalid; non-owning refs integration/test_reward_calculation.py                                                                                   |
-| robustness-exit-mode-fallback-102             | robustness  | Unknown exit_attenuation_mode falls back to linear w/ warning                        | robustness/test_robustness.py::test_robustness_102_unknown_exit_mode_fallback_linear               |                                                                                                                                                                                |
-| robustness-negative-grace-clamp-103           | robustness  | Negative exit_plateau_grace clamps to 0.0 w/ warning                                 | robustness/test_robustness.py::test_robustness_103_negative_plateau_grace_clamped                  |                                                                                                                                                                                |
-| robustness-invalid-power-tau-104              | robustness  | Invalid power tau falls back alpha=1.0 w/ warning                                    | robustness/test_robustness.py::test_robustness_104_invalid_power_tau_fallback_alpha_one            |                                                                                                                                                                                |
-| robustness-near-zero-half-life-105            | robustness  | Near-zero half life yields no attenuation (factor≈base)                              | robustness/test_robustness.py::test_robustness_105_half_life_near_zero_fallback                    |                                                                                                                                                                                |
-| pbrs-canonical-exit-semantic-106              | pbrs        | Canonical exit uses shaping=-prev_potential and next_potential=0.0                   | pbrs/test_pbrs.py::test_exit_step_shaping_matches_exit_step_rules                                  | Uses stored potential across steps; no drift correction applied                                                                                                                |
-| statistics-partial-deps-skip-107              | statistics  | skip_partial_dependence => empty PD structures                                       | statistics/test_statistics.py::test_statistics_feature_analysis_skip_partial_dependence            |                                                                                                                                                                                |
-| helpers-transitions-preserve-multiplicity-108 | helpers     | Repeated transitions retain their empirical multiplicity                             | helpers/test_utilities.py::test_repeated_transitions_preserve_multiplicity                         |                                                                                                                                                                                |
-| helpers-missing-cols-fill-109                 | helpers     | Missing required columns filled with NaN + single warning                            | helpers/test_utilities.py::test_missing_multiple_required_columns_single_warning                   |                                                                                                                                                                                |
-| statistics-binned-stats-min-edges-110         | statistics  | <2 bin edges raises ValueError                                                       | statistics/test_statistics.py::test_statistics_binned_stats_invalid_bins_raises                    | Docstring line                                                                                                                                                                 |
-| statistics-constant-cols-exclusion-111        | statistics  | Constant columns excluded & listed                                                   | statistics/test_statistics.py::test_statistics_correlation_dropped_constant_columns                | Docstring line                                                                                                                                                                 |
-| statistics-degenerate-distribution-shift-112  | statistics  | Constants: zero distances; KS p only with declared independent observations          | statistics/test_statistics.py::test_statistics_distribution_shift_metrics_degenerate_zero          | Docstring line                                                                                                                                                                 |
-| statistics-constant-dist-exact-ci-113a        | statistics  | Both modes retain exact constant CI bounds                                           | statistics/test_statistics.py::test_stats_bootstrap_constant_distribution_exact_bounds             |                                                                                                                                                                                |
-| statistics-percentile-outside-mean-113b       | statistics  | Percentile bounds need not contain the sample mean                                   | statistics/test_statistics.py::test_stats_bootstrap_percentiles_need_not_contain_mean              |                                                                                                                                                                                |
-| statistics-constant-diagnostics-115           | statistics  | Constants have N/A higher moments, normality tests and Q-Q fits in both modes        | statistics/test_statistics.py::test_statistics_distribution_constant_diagnostics                   |                                                                                                                                                                                |
-| pbrs-canonical-near-zero-report-116           | pbrs        | Canonical trajectories with valid evidence are classified as verified                | pbrs/test_pbrs.py::test_pbrs_canonical_near_zero_report                                            | Requires local identity, continuity, discounted terminal boundary, and zero observed additives; the non-owning boundary test also covers a complete singleton terminal episode |
-| robustness-exit-pnl-only-117                  | robustness  | Only exit actions have non-zero PnL                                                  | robustness/test_robustness.py::test_pnl_invariant_exit_only                                        |                                                                                                                                                                                |
-| pbrs-absence-shift-placeholder-118            | pbrs        | Placeholder shift line present when shaping shift is absent                          | pbrs/test_pbrs.py::test_pbrs_absence_and_distribution_shift_placeholder                            |                                                                                                                                                                                |
-| components-pbrs-breakdown-fields-119          | components  | PBRS breakdown fields finite and mathematically aligned                              | components/test_reward_components.py::test_pbrs_breakdown_fields_finite_and_aligned                | Tests base_reward, pbrs_delta and invariance_correction alignment                                                                                                              |
-| integration-pbrs-metrics-section-120          | integration | PBRS Metrics section present in report with tracing metrics                          | integration/test_report_formatting.py::test_report_includes_pbrs_metrics_section                   |                                                                                                                                                                                |
-| cli-pbrs-csv-columns-121                      | cli         | PBRS columns in reward_samples.csv when shaping enabled                              | cli/test_cli_params_and_csv.py::test_csv_contains_pbrs_columns_when_shaping_present                | Verifies finite reward_base, reward_pbrs_delta and reward_invariance_correction values                                                                                         |
-| statistics-bh-finite-family-122               | statistics  | Undefined tests excluded from finite-only BH family; marked non-applicable           | statistics/test_statistics.py::test_bh_excludes_undefined_tests_from_finite_family                 |                                                                                                                                                                                |
-| statistics-independence-contract-123          | statistics  | Inferential helpers require independent_observations=True                            | statistics/test_statistics.py::test_inference_helpers_require_independent_observations             | Covers hypothesis tests and bootstrap intervals                                                                                                                                |
-| report-independent-sections-124               | integration | CI, diagnostics and shift sections do not depend on hypothesis-test output           | integration/test_report_formatting.py::test_statistical_sections_do_not_depend_on_hypothesis_tests | Also verifies the reported bootstrap resample count                                                                                                                            |
-| pbrs-discounted-evidence-125                  | pbrs        | Verification requires local identity, continuity and discounted terminal boundary    | pbrs/test_pbrs.py::test_pbrs_canonical_discontinuous_potentials_report                             | Discontinuous potentials are not verified                                                                                                                                      |
-| statistics-proportional-histograms-126        | statistics  | Proportional histograms ignore sample count; moved mass yields positive KL/JS        | statistics/test_statistics.py::test_distribution_shift_proportional_histograms_ignore_sample_count | KL and JS remain finite and non-negative                                                                                                                                       |
-| pbrs-near-bound-clamp-127                     | pbrs        | Relaxed near-bound clamps apply exact endpoints and retain all reasons               | pbrs/test_pbrs.py::test_validate_reward_parameters_records_near_bound_clamps_exactly               | Includes numeric-string coercion                                                                                                                                               |
-| pbrs-exit-mode-validation-128                 | pbrs        | Exit-potential choices are strict or canonicalized; direct calls fail safe           | pbrs/test_pbrs.py::test_invalid_exit_mode_warns_at_direct_and_simulation_boundaries                | Direct calculation and simulation boundaries warn before fallback; PBRS calls suppress additives                                                                               |
-| cli-invalid-exit-mode-129                     | cli         | Invalid `--params exit_potential_mode` fails before artifacts                        | cli/test_cli_params_and_csv.py::test_invalid_exit_potential_mode_params_fails_before_artifacts     | Strict CLI validation                                                                                                                                                          |
-| cli-warning-header-recognition-130            | cli         | Warning counts accept only anchored Python warning header formats                    | cli/test_cli_params_and_csv.py::test_warning_header_positive_and_negative_formats                  | Covers POSIX, relative, synthetic and Windows source locations                                                                                                                 |
-| pbrs-invalid-mode-provenance-131              | pbrs        | Invalid imported exit-mode metadata cannot certify canonical invariance              | pbrs/test_pbrs.py::test_report_rejects_invalid_exit_mode_provenance                                | Preserves the invalid raw value and reports effective additive settings as unknown                                                                                             |
-| pbrs-synthetic-fee-floor-132                  | pbrs        | High-fee entry loss remains in long/short synthetic PnL and exit rewards             | pbrs/test_pbrs.py::test_synthetic_fee_loss_extrema_match_retained_pnl                              | Direct and transformed trajectories                                                                                                                                            |
-| pbrs-synthetic-profitable-mark-133            | pbrs        | Favorable long/short marks remain profitable after high-fee synthetic transformation | pbrs/test_pbrs.py::test_synthetic_high_fee_winner_retains_profitable_mark                          | Covers immediate gains and recovery after an initial loss on the independent sampled market path                                                                               |
-| pbrs-synthetic-fee-boundary-134               | pbrs        | Fee-boundary rounding is accepted while materially extreme PnL is rejected           | pbrs/test_pbrs.py::test_synthetic_high_fee_short_boundary_rejects_real_excess                      | Non-unit entry price exposes floating-point roundoff                                                                                                                           |
-| pbrs-synthetic-latent-price-135               | pbrs        | Clipped candidate PnL does not erase the sampled market path for later holds         | pbrs/test_pbrs.py::test_unrealized_pnl_retains_sampled_market_path_after_candidate_cap             | Equal first retained marks from distinct market prices diverge after the same adverse return                                                                                   |
+| ID                                            | Category    | Description                                                                          | Owning test                                                                                                       | Notes                                                                                                                                                                          |
+| --------------------------------------------- | ----------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| report-additives-deterministic-092            | components  | Additives deterministic report section                                               | components/test_additives.py::test_additive_activation_deterministic_contribution                                 | Integration/PBRS may reference outcome non-owning                                                                                                                              |
+| robustness-decomposition-integrity-101        | robustness  | Single active core component equals total reward under mutually exclusive scenarios  | robustness/test_robustness.py::test_decomposition_integrity                                                       | Scenarios: idle, hold, exit, invalid; non-owning refs integration/test_reward_calculation.py                                                                                   |
+| robustness-exit-mode-fallback-102             | robustness  | Unknown exit_attenuation_mode falls back to linear w/ warning                        | robustness/test_robustness.py::test_robustness_102_unknown_exit_mode_fallback_linear                              |                                                                                                                                                                                |
+| robustness-negative-grace-clamp-103           | robustness  | Negative exit_plateau_grace clamps to 0.0 w/ warning                                 | robustness/test_robustness.py::test_robustness_103_negative_plateau_grace_clamped                                 |                                                                                                                                                                                |
+| robustness-invalid-power-tau-104              | robustness  | Invalid power tau falls back alpha=1.0 w/ warning                                    | robustness/test_robustness.py::test_robustness_104_invalid_power_tau_fallback_alpha_one                           |                                                                                                                                                                                |
+| robustness-near-zero-half-life-105            | robustness  | Near-zero half life yields no attenuation (factor≈base)                              | robustness/test_robustness.py::test_robustness_105_half_life_near_zero_fallback                                   |                                                                                                                                                                                |
+| pbrs-canonical-exit-semantic-106              | pbrs        | Canonical exit uses shaping=-prev_potential and next_potential=0.0                   | pbrs/test_pbrs.py::test_exit_step_shaping_matches_exit_step_rules                                                 | Uses stored potential across steps; no drift correction applied                                                                                                                |
+| statistics-partial-deps-skip-107              | statistics  | skip_partial_dependence => empty PD structures                                       | statistics/test_statistics.py::test_statistics_feature_analysis_skip_partial_dependence                           |                                                                                                                                                                                |
+| helpers-transitions-preserve-multiplicity-108 | helpers     | Repeated transitions retain their empirical multiplicity                             | helpers/test_utilities.py::test_repeated_transitions_preserve_multiplicity                                        |                                                                                                                                                                                |
+| helpers-missing-cols-fill-109                 | helpers     | Missing required columns filled with NaN + single warning                            | helpers/test_utilities.py::test_missing_multiple_required_columns_single_warning                                  |                                                                                                                                                                                |
+| statistics-binned-stats-min-edges-110         | statistics  | <2 bin edges raises ValueError                                                       | statistics/test_statistics.py::test_statistics_binned_stats_invalid_bins_raises                                   | Docstring line                                                                                                                                                                 |
+| statistics-constant-cols-exclusion-111        | statistics  | Constant columns excluded & listed                                                   | statistics/test_statistics.py::test_statistics_correlation_dropped_constant_columns                               | Docstring line                                                                                                                                                                 |
+| statistics-degenerate-distribution-shift-112  | statistics  | Constants: zero distances; KS p only with declared independent observations          | statistics/test_statistics.py::test_statistics_distribution_shift_metrics_degenerate_zero                         | Docstring line                                                                                                                                                                 |
+| statistics-constant-dist-exact-ci-113a        | statistics  | Both modes retain exact constant CI bounds                                           | statistics/test_statistics.py::test_stats_bootstrap_constant_distribution_exact_bounds                            |                                                                                                                                                                                |
+| statistics-percentile-outside-mean-113b       | statistics  | Percentile bounds need not contain the sample mean                                   | statistics/test_statistics.py::test_stats_bootstrap_percentiles_need_not_contain_mean                             |                                                                                                                                                                                |
+| statistics-constant-diagnostics-115           | statistics  | Constants have N/A higher moments, normality tests and Q-Q fits in both modes        | statistics/test_statistics.py::test_statistics_distribution_constant_diagnostics                                  |                                                                                                                                                                                |
+| pbrs-canonical-near-zero-report-116           | pbrs        | Canonical trajectories with valid evidence are classified as verified                | pbrs/test_pbrs.py::test_pbrs_canonical_near_zero_report                                                           | Requires local identity, continuity, discounted terminal boundary, and zero observed additives; the non-owning boundary test also covers a complete singleton terminal episode |
+| robustness-exit-pnl-only-117                  | robustness  | Only exit actions have non-zero PnL                                                  | robustness/test_robustness.py::test_pnl_invariant_exit_only                                                       |                                                                                                                                                                                |
+| pbrs-absence-shift-placeholder-118            | pbrs        | Placeholder shift line present when shaping shift is absent                          | pbrs/test_pbrs.py::test_pbrs_absence_and_distribution_shift_placeholder                                           |                                                                                                                                                                                |
+| components-pbrs-breakdown-fields-119          | components  | PBRS breakdown fields finite and mathematically aligned                              | components/test_reward_components.py::test_pbrs_breakdown_fields_finite_and_aligned                               | Tests base_reward, pbrs_delta and invariance_correction alignment                                                                                                              |
+| integration-pbrs-metrics-section-120          | integration | PBRS Metrics section present in report with tracing metrics                          | integration/test_report_formatting.py::test_report_includes_pbrs_metrics_section                                  |                                                                                                                                                                                |
+| cli-pbrs-csv-columns-121                      | cli         | PBRS columns in reward_samples.csv when shaping enabled                              | cli/test_cli_params_and_csv.py::test_csv_contains_pbrs_columns_when_shaping_present                               | Verifies finite reward_base, reward_pbrs_delta and reward_invariance_correction values                                                                                         |
+| statistics-bh-finite-family-122               | statistics  | Undefined tests excluded from finite-only BH family; marked non-applicable           | statistics/test_statistics.py::test_bh_excludes_undefined_tests_from_finite_family                                |                                                                                                                                                                                |
+| statistics-independence-contract-123          | statistics  | Inferential helpers require independent_observations=True                            | statistics/test_statistics.py::test_inference_helpers_require_independent_observations                            | Covers hypothesis tests and bootstrap intervals                                                                                                                                |
+| report-independent-sections-124               | integration | CI, diagnostics and shift sections do not depend on hypothesis-test output           | integration/test_report_formatting.py::test_statistical_sections_do_not_depend_on_hypothesis_tests                | Also verifies the reported bootstrap resample count                                                                                                                            |
+| pbrs-discounted-evidence-125                  | pbrs        | Verification requires local identity, continuity and discounted terminal boundary    | pbrs/test_pbrs.py::test_pbrs_canonical_discontinuous_potentials_report                                            | Discontinuous potentials are not verified                                                                                                                                      |
+| statistics-proportional-histograms-126        | statistics  | Proportional histograms ignore sample count; moved mass yields positive KL/JS        | statistics/test_statistics.py::test_distribution_shift_proportional_histograms_ignore_sample_count                | KL and JS remain finite and non-negative                                                                                                                                       |
+| pbrs-near-bound-clamp-127                     | pbrs        | Relaxed near-bound clamps apply exact endpoints and retain all reasons               | pbrs/test_pbrs.py::test_validate_reward_parameters_records_near_bound_clamps_exactly                              | Includes numeric-string coercion                                                                                                                                               |
+| pbrs-exit-mode-validation-128                 | pbrs        | Exit-potential choices are strict or canonicalized; direct calls fail safe           | pbrs/test_pbrs.py::test_invalid_exit_mode_warns_at_direct_and_simulation_boundaries                               | Direct calculation and simulation boundaries warn before fallback; PBRS calls suppress additives                                                                               |
+| cli-invalid-exit-mode-129                     | cli         | Invalid `--params exit_potential_mode` fails before artifacts                        | cli/test_cli_params_and_csv.py::test_invalid_exit_potential_mode_params_fails_before_artifacts                    | Strict CLI validation                                                                                                                                                          |
+| cli-warning-header-recognition-130            | cli         | Warning counts accept only anchored Python warning header formats                    | cli/test_cli_params_and_csv.py::test_warning_header_positive_and_negative_formats                                 | Covers POSIX, relative, synthetic and Windows source locations                                                                                                                 |
+| pbrs-invalid-mode-provenance-131              | pbrs        | Invalid imported exit-mode metadata cannot certify canonical invariance              | pbrs/test_pbrs.py::test_report_rejects_invalid_exit_mode_provenance                                               | Preserves the invalid raw value and reports effective additive settings as unknown                                                                                             |
+| pbrs-synthetic-fee-floor-132                  | pbrs        | High-fee entry loss remains in long/short synthetic PnL and exit rewards             | pbrs/test_pbrs.py::test_synthetic_fee_loss_extrema_match_retained_pnl                                             | Direct and transformed trajectories                                                                                                                                            |
+| pbrs-synthetic-profitable-mark-133            | pbrs        | Favorable long/short marks remain profitable after high-fee synthetic transformation | pbrs/test_pbrs.py::test_synthetic_high_fee_winner_retains_profitable_mark                                         | Covers immediate gains and recovery after an initial loss on the independent sampled market path                                                                               |
+| pbrs-synthetic-fee-boundary-134               | pbrs        | Fee-boundary rounding is accepted while materially extreme PnL is rejected           | pbrs/test_pbrs.py::test_synthetic_high_fee_short_boundary_rejects_real_excess                                     | Non-unit entry price exposes floating-point roundoff                                                                                                                           |
+| pbrs-synthetic-latent-price-135               | pbrs        | Clipped candidate PnL does not erase the sampled market path for later holds         | pbrs/test_pbrs.py::test_unrealized_pnl_retains_sampled_market_path_after_candidate_cap                            | Equal first retained marks from distinct market prices diverge after the same adverse return                                                                                   |
+| api-unmasked-invalid-actions-136              | api         | Unmasked invalid actions keep positions and receive penalties                        | api/test_api_helpers.py::test_unmasked_simulation_samples_invalid_actions_without_changing_position               | Includes spot, futures and zero-penalty invalid actions                                                                                                                        |
+| api-terminal-invalid-exit-137                 | api         | Wrong-side terminal exits liquidate the held side                                    | api/test_api_helpers.py::test_invalid_terminal_exit_liquidates_the_held_position                                  | Prevents dropped terminal trades                                                                                                                                               |
+| cli-stale-generated-artifacts-138             | cli         | Skipped analyses remove only reserved outputs from an earlier run                    | cli/test_cli_params_and_csv.py::test_skipped_analysis_removes_only_stale_generated_artifacts                      | Preserves unrelated files                                                                                                                                                      |
+| helpers-nonfinite-episodes-139                | helpers     | Infinite real observations become missing without dropping rows                      | helpers/test_utilities.py::test_nonfinite_numeric_episodes_are_marked_missing                                     | Retains transition multiplicity                                                                                                                                                |
+| statistics-finite-shift-140                   | statistics  | Finite observations remain comparable despite an infinite value                      | statistics/test_statistics.py::test_distribution_shift_uses_finite_observations                                   | Rejects all-nonfinite features                                                                                                                                                 |
+| 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                                                                                                                                                      |
+| integration-finite-report-143                 | integration | Reports distinguish unusable real values from missing real episodes                  | integration/test_report_formatting.py::test_report_distinguishes_missing_real_episodes_from_unusable_observations | Section and summary agree                                                                                                                                                      |
 
 ### Non-Owning Smoke / Reference Checks
 
index f13a68344ffd87eece9a225b9603ddfd2487761c..e1e976e2089b4ec94b6332fbd79c4f43ac30716d 100644 (file)
@@ -7,6 +7,7 @@ import tempfile
 import unittest
 from pathlib import Path
 from typing import Any, cast
+from unittest import mock
 
 import numpy as np
 import pandas as pd
@@ -191,24 +192,88 @@ class TestAPIAndHelpers(RewardSpaceTestBase):
         prob_upper_bound = SCENARIOS.API_PROBABILITY_UPPER_BOUND
         self.assertTrue(((values >= 0.0) & (values <= prob_upper_bound)).all())
 
-    def test_simulate_samples_interprets_bool_string_params(self):
-        """Test simulate_samples correctly interprets string boolean params like action_masking."""
-        df1 = simulate_samples_with_defaults(
-            self.base_params(
-                action_masking="true", max_trade_duration_candles=PARAMS.TRADE_DURATION_SHORT
-            ),
-            num_samples=SCENARIOS.SAMPLE_SIZE_REPORT_MINIMAL,
-            trading_mode="spot",
+    def test_unmasked_simulation_samples_invalid_actions_without_changing_position(self):
+        """Unmasked actions expose penalties without inventing trades or wrong-side exits."""
+        params = self.base_params(
+            action_masking="false", max_trade_duration_candles=PARAMS.TRADE_DURATION_SHORT
         )
-        self.assertIsInstance(df1, pd.DataFrame)
-        df2 = simulate_samples_with_defaults(
-            self.base_params(
-                action_masking="false", max_trade_duration_candles=PARAMS.TRADE_DURATION_SHORT
-            ),
-            num_samples=SCENARIOS.SAMPLE_SIZE_REPORT_MINIMAL,
+        for mode in ("spot", "futures"):
+            with self.subTest(mode=mode):
+                df = simulate_samples_with_defaults(
+                    params,
+                    num_samples=SCENARIOS.SAMPLE_SIZE_LARGE,
+                    seed=SEEDS.BASE,
+                    trading_mode=mode,
+                )
+                invalid = df[df["is_invalid"] == 1.0]
+                self.assertGreater(len(invalid), 0)
+                self.assertLess(len(invalid), len(df))
+                self.assertTrue((invalid["reward_invalid"] == params["invalid_action"]).all())
+                self.assertTrue((invalid["reward_base"] == params["invalid_action"]).all())
+                wrong_exit = invalid.loc[
+                    (
+                        (
+                            (invalid["position"] == Positions.Long.value)
+                            & (invalid["action"] == Actions.Short_exit.value)
+                        )
+                        | (
+                            (invalid["position"] == Positions.Short.value)
+                            & (invalid["action"] == Actions.Long_exit.value)
+                        )
+                    )
+                    & ~invalid["terminated"]
+                ]
+                self.assertGreater(len(wrong_exit), 0)
+                self.assertTrue((wrong_exit["next_position"] == wrong_exit["position"]).all())
+                if mode == "spot":
+                    forbidden_entry = invalid.loc[
+                        (invalid["position"] == Positions.Neutral.value)
+                        & (invalid["action"] == Actions.Short_enter.value)
+                    ]
+                    self.assertGreater(len(forbidden_entry), 0)
+                    self.assertTrue(
+                        (forbidden_entry["next_position"] == Positions.Neutral.value).all()
+                    )
+                masked = simulate_samples_with_defaults(
+                    self.base_params(
+                        action_masking="true",
+                        max_trade_duration_candles=PARAMS.TRADE_DURATION_SHORT,
+                    ),
+                    num_samples=SCENARIOS.SAMPLE_SIZE_LARGE,
+                    seed=SEEDS.BASE,
+                    trading_mode=mode,
+                )
+                self.assertEqual(int(masked["is_invalid"].sum()), 0)
+
+        zero_penalty = simulate_samples_with_defaults(
+            self.base_params(action_masking="false", invalid_action=0.0),
+            num_samples=SCENARIOS.SAMPLE_SIZE_LARGE,
+            seed=SEEDS.BASE,
             trading_mode="spot",
         )
-        self.assertIsInstance(df2, pd.DataFrame)
+        self.assertGreater(int(zero_penalty["is_invalid"].sum()), 0)
+        self.assertTrue((zero_penalty["reward_invalid"] == 0.0).all())
+
+    def test_invalid_terminal_exit_liquidates_the_held_position(self):
+        """A wrong-side terminal exit keeps the held trade until its forced liquidation."""
+        sampled_actions = [
+            (Actions.Long_enter, 0.3, float("nan"), 0.7),
+            (Actions.Short_exit, float("nan"), 0.2, float("nan")),
+        ]
+        with mock.patch("reward_space_analysis._sample_action", side_effect=sampled_actions):
+            df = simulate_samples_with_defaults(
+                self.base_params(action_masking="false"),
+                num_samples=2,
+                seed=SEEDS.BASE,
+                trading_mode="spot",
+            )
+        terminal = df.iloc[-1]
+        self.assertEqual(terminal["position"], Positions.Long.value)
+        self.assertEqual(terminal["action"], Actions.Short_exit.value)
+        self.assertEqual(terminal["is_invalid"], 1.0)
+        self.assertTrue(terminal["terminal_liquidation"])
+        self.assertEqual(terminal["next_position"], Positions.Neutral.value)
+        self.assertTrue(np.isfinite(terminal["exit_pnl"]))
 
     def test_short_allowed_via_simulation(self):
         """Test _is_short_allowed via different trading modes."""
index f5a07f2592faf4ebddac4195a5f8d3a0f1fdd0f0..78c34944c3cff2b493508a132bc4f0aef74ccff1 100644 (file)
@@ -167,6 +167,45 @@ class TestParamsPropagation(RewardSpaceTestBase):
         fi_path = out_dir / "feature_importance.csv"
         self.assertFalse(fi_path.exists(), "feature_importance.csv should be absent when skipped")
 
+    def test_skipped_analysis_removes_only_stale_generated_artifacts(self):
+        """A second run cannot expose the prior run's feature or PD conclusions."""
+        out_dir = self.output_path / "reused_analysis"
+        args = [
+            "--num_samples",
+            str(SCENARIOS.CLI_NUM_SAMPLES_FAST),
+            "--seed",
+            str(SEEDS.BASE),
+            "--rf_n_jobs",
+            "1",
+            "--perm_n_jobs",
+            "1",
+        ]
+        _assert_cli_success(self, _run_cli(out_dir=out_dir, args=args))
+        self.assertTrue((out_dir / "feature_importance.csv").exists())
+        generated_pd = list(out_dir.glob("partial_dependence_*.csv"))
+        self.assertGreater(len(generated_pd), 0)
+        user_file = out_dir / "notes.txt"
+        user_file.write_text("keep", encoding="utf-8")
+        custom_pd = out_dir / "partial_dependence_custom.csv"
+        custom_pd.write_text("keep", encoding="utf-8")
+
+        _assert_cli_success(
+            self,
+            _run_cli(
+                out_dir=out_dir,
+                args=[*args, "--skip_feature_analysis", "--skip_partial_dependence"],
+            ),
+        )
+        for artifact in (out_dir / "feature_importance.csv", *generated_pd):
+            self.assertFalse(artifact.exists(), artifact.name)
+        self.assertEqual(user_file.read_text(encoding="utf-8"), "keep")
+        self.assertEqual(custom_pd.read_text(encoding="utf-8"), "keep")
+        self.assertIn(
+            "Feature Importance - (skipped)", (out_dir / "statistical_analysis.md").read_text()
+        )
+        self.assertTrue((out_dir / "reward_samples.csv").exists())
+        self.assertTrue((out_dir / "manifest.json").exists())
+
     def test_manifest_records_resolved_simulation_inputs(self):
         """The manifest records and hashes resolved simulation inputs."""
         out_dir = self.output_path / "manifest_hash"
index 76cb90e0c3320e971dc5d9ead2a3fa333b4233d1..f3fefd05a5e30fc35346f16c34a70476e184ffce 100644 (file)
@@ -170,6 +170,29 @@ class TestLoadRealEpisodes(RewardSpaceTestBase):
         self.assertEqual(len(loaded_data), 3)
         self.assertIn("pnl", loaded_data.columns)
 
+    def test_nonfinite_numeric_episodes_are_marked_missing(self):
+        """Keep transition multiplicity but mark infinite numeric observations missing."""
+        episodes = pd.DataFrame(
+            {
+                "pnl": [0.01, float("inf"), -0.02],
+                "trade_duration": [1, 2, 3],
+                "idle_duration": [0, 0, 0],
+                "position": [1.0, 1.0, 1.0],
+                "action": [0.0, 0.0, 0.0],
+                "reward": [1.0, 1.0, 1.0],
+                "reward_exit": [0.0, float("-inf"), 0.0],
+            }
+        )
+        path = Path(self.temp_dir) / "nonfinite.pkl"
+        self.write_pickle(episodes, path)
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            loaded = load_real_episodes(path)
+        self.assertEqual(len(loaded), len(episodes))
+        self.assertTrue(pd.isna(loaded.loc[1, "pnl"]))
+        self.assertTrue(pd.isna(loaded.loc[1, "reward_exit"]))
+        self.assertTrue(any("non-finite" in str(w.message) for w in caught))
+
 
 if __name__ == "__main__":
     unittest.main()
index 600807bfc633971ca32c9aeb7bf20aa9bc085fd0..3dde5ee9b5cabaa794681ec7408b51ed4c682d1a 100644 (file)
@@ -91,6 +91,18 @@ class TestReportFormatting(RewardSpaceTestBase):
         report_path = out_dir / "statistical_analysis.md"
         return report_path.read_text(encoding="utf-8")
 
+    def test_report_distinguishes_missing_real_episodes_from_unusable_observations(self):
+        synth_df = self.make_stats_df(n=SCENARIOS.SAMPLE_SIZE_TINY, seed=SEEDS.REPORT_FORMAT_1)
+        real_df = synth_df.copy()
+        real_df[["pnl", "trade_duration", "idle_duration"]] = np.inf
+        content = self._write_report(synth_df, real_df=real_df, skip_feature_analysis=True)
+        self.assertIn("_Not performed (no comparable finite observations)._", content)
+        self.assertIn(
+            "6. **Distribution Shift** - Not performed (no comparable finite observations)",
+            content,
+        )
+        self.assertNotIn("no real episodes provided", content)
+
     def test_distribution_shift_section_present_with_real_episodes(self):
         """Distribution Shift section renders metrics table when real episodes provided."""
         # Synthetic df (ensure >=10 non-NaN per feature)
index e32f445c406f36c91a013a64510a37512727ec54..b5bdc479ec9f4ad4d7bd5012ee7bc1377da61ac8 100644 (file)
@@ -715,6 +715,63 @@ class TestStatistics(RewardSpaceTestBase):
                         strict_diagnostics=strict,
                     )
 
+    def test_distribution_shift_uses_finite_observations(self):
+        """One infinite PnL cannot erase a distribution with enough finite samples."""
+        synthetic = pd.DataFrame(
+            {
+                "pnl": np.linspace(-0.1, 0.1, 20),
+                "trade_duration": np.arange(20),
+                "idle_duration": np.arange(20),
+            }
+        )
+        real = synthetic.copy()
+        real.loc[0, "pnl"] = np.inf
+        observed = compute_distribution_shift_metrics(synthetic, real)
+        finite_only = real.copy()
+        finite_only.loc[0, "pnl"] = np.nan
+        expected = compute_distribution_shift_metrics(synthetic, finite_only)
+        for key in ("pnl_kl_divergence", "pnl_js_distance", "pnl_wasserstein", "pnl_ks_statistic"):
+            self.assertIn(key, observed)
+            self.assertAlmostEqual(observed[key], expected[key])
+
+    def test_pnl_rank_biserial_direction_matches_named_first_group(self):
+        """A larger reward for pnl+ has a positive effect; reversing groups reverses it."""
+        df = pd.DataFrame(
+            {
+                "pnl": [1.0] * 30 + [-1.0] * 30,
+                "reward": [10.0] * 30 + [0.0] * 30,
+                "reward_idle": [0.0] * 60,
+                "position": [1.0] * 60,
+            }
+        )
+        for positive_reward, negative_reward, expected_u, expected_effect in (
+            (10.0, 0.0, 900.0, 1.0),
+            (0.0, 10.0, 0.0, -1.0),
+        ):
+            with self.subTest(positive_reward=positive_reward):
+                df.loc[:29, "reward"] = positive_reward
+                df.loc[30:, "reward"] = negative_reward
+                result = statistical_hypothesis_tests(df, independent_observations=True)[
+                    "pnl_sign_reward_difference"
+                ]
+                self.assertEqual(result["statistic"], expected_u)
+                self.assertEqual(result["effect_size_rank_biserial"], expected_effect)
+
+    def test_bootstrap_rejects_nonpositive_resample_count(self):
+        """Reject an absent bootstrap even when constant data take the fast path."""
+        for rewards in (np.arange(10, dtype=float), np.ones(10)):
+            for count in (0, -1):
+                with (
+                    self.subTest(constant=bool(rewards.min() == rewards.max()), count=count),
+                    self.assertRaisesRegex(ValueError, "n_bootstrap"),
+                ):
+                    bootstrap_confidence_intervals(
+                        pd.DataFrame({"reward": rewards}),
+                        ["reward"],
+                        n_bootstrap=count,
+                        independent_observations=True,
+                    )
+
 
 if __name__ == "__main__":
     unittest.main()
index 2dc7cdb0e5360ed237b44cc3680fa06f4e2a2bde..abdf3d4b695b355a93fee5dfe60a9e0081c773e6 100644 (file)
@@ -24,6 +24,7 @@ from ReforceXY.user_data.freqaimodels.ReforceXY import (
     convert_optuna_params_to_model_params,
     deepmerge,
 )
+from ReforceXY.user_data.strategies.RLAgentStrategy import RLAgentStrategy
 
 
 class RecordingPolicy:
@@ -101,6 +102,34 @@ class ReviewContractsTest(unittest.TestCase):
         self.addCleanup(model.close_envs)
         return model
 
+    def test_strategy_leverage_respects_pair_bounds_and_invalid_config(self):
+        """A strategy callback always returns a finite leverage inside pair limits."""
+        strategy = RLAgentStrategy.__new__(RLAgentStrategy)
+        arguments = {
+            "pair": "BTC/USDT",
+            "current_time": dt(2026, 1, 1, tzinfo=timezone.utc),
+            "current_rate": 100.0,
+            "proposed_leverage": 2.0,
+            "max_leverage": 5.0,
+            "entry_tag": None,
+            "side": "long",
+        }
+        for configured, expected in (
+            (None, 2.0),
+            (0.5, 1.0),
+            (10.0, 5.0),
+            (3.0, 3.0),
+            (float("nan"), 2.0),
+            (float("inf"), 2.0),
+            (10**500, 2.0),
+            (-5.0, 1.0),
+            ("invalid", 2.0),
+            (True, 2.0),
+        ):
+            with self.subTest(configured=configured):
+                strategy.config = {} if configured is None else {"leverage": configured}
+                self.assertEqual(strategy.leverage(**arguments), expected)
+
     def test_training_preserves_raw_prices_and_returns_best_checkpoint(self):
         for drop in (False, True):
             with self.subTest(drop_ohlc_from_features=drop), tempfile.TemporaryDirectory() as temp:
index fe1212a9f492c27d6ec6996808052d210cddcf17..a1ab0d224803fe841c1df4490d5d3142d245ac6b 100644 (file)
@@ -188,7 +188,18 @@ class RLAgentStrategy(IStrategy):
         :param side: 'long' or 'short' - indicating the direction of the proposed trade
         :return: A leverage amount, which will be between 1.0 and max_leverage.
         """
-        return min(self.config.get("leverage", proposed_leverage), max_leverage)
+        configured = self.config.get("leverage")
+        if configured is None:
+            requested = proposed_leverage
+        else:
+            try:
+                requested = float(configured)
+            except (TypeError, ValueError, OverflowError):
+                requested = float("nan")
+            if isinstance(configured, bool) or not np.isfinite(requested):
+                logger.warning("Invalid leverage value %r; using proposed leverage", configured)
+                requested = proposed_leverage
+        return float(max(1.0, min(requested, max_leverage)))
 
     def is_short_allowed(self) -> bool:
         trading_mode = self.config.get("trading_mode")
diff --git a/quickadapter/tests/test_prediction_history.py b/quickadapter/tests/test_prediction_history.py
new file mode 100644 (file)
index 0000000..2965d8e
--- /dev/null
@@ -0,0 +1,216 @@
+"""Runtime contracts for FreqAI's native QuickAdapter prediction history."""
+
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest import mock
+
+import numpy as np
+import pandas as pd
+from freqtrade.enums import RunMode
+from freqtrade.exchange import timeframe_to_seconds
+from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
+
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import QuickAdapterRegressorV3
+
+PAIR = "BTC/USDT"
+MARKER = "_freqai_strategies_produced"
+
+
+def model_config(path: str) -> dict:
+    return {
+        "user_data_dir": Path(path),
+        "timeframe": "5m",
+        "stake_amount": "unlimited",
+        "runmode": RunMode.DRY_RUN,
+        "exchange": {"pair_whitelist": [PAIR]},
+        "pairlists": [{"method": "StaticPairList"}],
+        "freqai": {
+            "enabled": True,
+            "identifier": "quickadapter-runtime-regression",
+            "continual_learning": True,
+            "train_period_days": 1,
+            "backtest_period_days": 1,
+            "conv_width": 1,
+            "fit_live_predictions_candles": 2,
+            "feature_parameters": {
+                "include_timeframes": ["5m"],
+                "include_corr_pairlist": [],
+                "label_period_candles": 1,
+                "shuffle_after_split": False,
+            },
+            "data_split_parameters": {"test_size": 0, "shuffle": False},
+            "model_training_parameters": {"n_estimators": 2, "n_jobs": 1},
+            "label_prediction": {"method": "none"},
+        },
+    }
+
+
+class PredictionHistoryTest(unittest.TestCase):
+    def test_native_append_restart_and_causal_calibration(self):
+        with tempfile.TemporaryDirectory() as temp:
+            config = model_config(temp)
+            model = QuickAdapterRegressorV3(config=config)
+            model.live = True
+            dates = pd.date_range("2026-01-01", periods=4, freq="5min", tz="UTC")
+            strat_df = pd.DataFrame(
+                {"date": dates, "high": [101.0] * 4, "low": [99.0] * 4, "close": [100.0] * 4}
+            )
+            dk = SimpleNamespace(
+                data={
+                    "extra_returns_per_train": {
+                        "label_period_candles": 1,
+                        "label_natr_multiplier": 1.0,
+                        "holdout_rmse": np.inf,
+                    }
+                },
+                label_list=["&s-extrema"],
+                unique_class_list=[],
+                full_df=strat_df,
+            )
+            bootstrap = pd.DataFrame({"&s-extrema": [21.0, 22.0, 23.0, 24.0]})
+            model.set_initial_historic_predictions(bootstrap, dk, PAIR, strat_df)
+            model.fit_live_predictions(dk, PAIR)
+            model.dd.set_initial_return_values(PAIR, bootstrap, strat_df)
+            self.assertEqual(model.dd.historic_predictions[PAIR][MARKER].tolist(), [False] * 4)
+            self.assertEqual(dk.data["labels_mean"]["&s-extrema"], 0.0)
+            model.dk = SimpleNamespace(check_if_model_expired=lambda _: False)
+
+            def produce(dataframe: pd.DataFrame, value: float, status: int) -> None:
+                with mock.patch.object(
+                    model,
+                    "predict",
+                    return_value=(pd.DataFrame({"&s-extrema": [value]}), np.array([status])),
+                ):
+                    model.build_strategy_return_arrays(dataframe, dk, PAIR, 0)
+
+            produce(strat_df, 1.0, 0)
+            extended = pd.concat(
+                [
+                    strat_df,
+                    pd.DataFrame(
+                        {
+                            "date": [dates[-1] + pd.Timedelta(minutes=5)],
+                            "high": [101.0],
+                            "low": [99.0],
+                            "close": [100.0],
+                        }
+                    ),
+                ],
+                ignore_index=True,
+            )
+            dk.full_df = extended
+            produce(extended, 3.0, 1)
+            model.fit_live_predictions(dk, PAIR)
+            self.assertEqual(dk.data["labels_mean"]["&s-extrema"], 2.0)
+            self.assertEqual(dk.data["labels_std"]["&s-extrema"], 1.0)
+            self.assertEqual(len(model.dd.historic_predictions[PAIR]), len(extended))
+
+            future = pd.concat(
+                [
+                    extended,
+                    pd.DataFrame(
+                        {
+                            "date": [dates[-1] + pd.Timedelta(minutes=10)],
+                            "high": [101.0],
+                            "low": [99.0],
+                            "close": [100.0],
+                        }
+                    ),
+                ],
+                ignore_index=True,
+            )
+            dk.full_df = future
+            produce(future, 999.0, 1)
+            self.assertNotIn(MARKER, dk.return_dataframe)
+            dk.full_df = extended
+            model.fit_live_predictions(dk, PAIR)
+            self.assertEqual(dk.data["labels_mean"]["&s-extrema"], 2.0)
+            model.dd.save_historic_predictions_to_disk()
+
+            restored = QuickAdapterRegressorV3(config=config)
+            restored.live = True
+            self.assertTrue(restored.dd.load_historic_predictions_from_disk())
+            restored.fit_live_predictions(dk, PAIR)
+            self.assertEqual(dk.data["labels_mean"]["&s-extrema"], 2.0)
+            self.assertEqual(dk.data["labels_std"]["&s-extrema"], 1.0)
+            self.assertEqual(len(restored.dd.historic_predictions[PAIR]), len(future))
+            self.assertEqual(
+                restored.dd.historic_predictions[PAIR][MARKER].tolist(), [False] * 3 + [True] * 3
+            )
+
+    def test_backtest_does_not_use_future_model_but_resumes_from_earlier_artifact(self):
+        with tempfile.TemporaryDirectory() as temp:
+            config = model_config(temp)
+            pair = PAIR
+
+            def frame(day: str, offset: float) -> pd.DataFrame:
+                values = np.arange(48)
+                return pd.DataFrame(
+                    {
+                        "date": pd.date_range(day, periods=48, freq="5min", tz="UTC"),
+                        "%-feature": np.sin(values / 4) + offset,
+                        "&s-extrema": np.cos(values / 5),
+                    }
+                )
+
+            def kitchen(settings: dict, data: pd.DataFrame, *, live: bool):
+                dk = FreqaiDataKitchen(settings, live=live, pair=pair)
+                timestamp = int(
+                    (
+                        data["date"].iloc[-1] + pd.Timedelta(seconds=timeframe_to_seconds("5m"))
+                    ).timestamp()
+                )
+                dk.set_paths(pair, timestamp)
+                dk.set_new_model_names(pair, timestamp)
+                dk.data_path.mkdir(parents=True, exist_ok=True)
+                dk.label_list = ["&s-extrema"]
+                dk.training_features_list = ["%-feature"]
+                return dk, timestamp
+
+            source = QuickAdapterRegressorV3(config=config)
+            source.live = True
+            future = frame("2026-02-01", 1000.0)
+            future_dk, future_ts = kitchen(config, future, live=True)
+            deployed = source.train(future, pair, future_dk)
+            source.dd.get_pair_dict_info(pair)
+            source.dd.pair_dict[pair]["trained_timestamp"] = future_ts
+            source.dd.save_data(deployed, pair, future_dk)
+
+            backtest_config = dict(config)
+            backtest_config["runmode"] = RunMode.BACKTEST
+            backtest_config["timerange"] = "20260101-20260105"
+            backtest_config["config_files"] = [
+                "/workspace/quickadapter/user_data/config-template.json"
+            ]
+            backtest = QuickAdapterRegressorV3(config=backtest_config)
+            backtest.live = False
+            for day, offset, save_model in (
+                ("2026-01-01", 0.0, False),
+                ("2026-01-02", 10.0, True),
+                ("2026-01-03", 20.0, False),
+            ):
+                training = frame(day, offset)
+                dk, timestamp = kitchen(backtest_config, training, live=False)
+                self.assertLess(timestamp, future_ts)
+                trained = backtest.train(training, pair, dk)
+                transformed = dk.data_dictionary["train_features"]["%-feature"]
+                if offset < 20.0:
+                    self.assertAlmostEqual(float(transformed.min()), -1.0)
+                    self.assertAlmostEqual(float(transformed.max()), 1.0)
+                else:
+                    self.assertGreater(float(transformed.min()), 2.0)
+                if save_model:
+                    backtest.dd.pair_dict[pair]["trained_timestamp"] = future_ts
+                    backtest.dd.save_data(trained, pair, dk)
+                    backtest = QuickAdapterRegressorV3(config=backtest_config)
+                    backtest.live = False
+                    backtest.dd.get_pair_dict_info(pair)
+                    self.assertEqual(backtest.dd.pair_dict[pair]["trained_timestamp"], future_ts)
+                else:
+                    backtest.dd.save_metadata(dk)
+
+
+if __name__ == "__main__":
+    unittest.main()