From: Jérôme Benoit Date: Wed, 23 Sep 2026 21:44:18 +0000 (+0200) Subject: fix: harden reward analysis and runtime regressions X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=54fc33fda583d963e19cca0eca1c508809bfbdbe;p=freqai-strategies.git fix: harden reward analysis and runtime regressions --- diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 019114d..81f5fe1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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 diff --git a/README.md b/README.md index 9f048e0..bece30f 100644 --- 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 diff --git a/ReforceXY/.basedpyright/diagnostics.json b/ReforceXY/.basedpyright/diagnostics.json index 0a03974..61c476f 100644 --- a/ReforceXY/.basedpyright/diagnostics.json +++ b/ReforceXY/.basedpyright/diagnostics.json @@ -33,343 +33,173 @@ }, { "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, diff --git a/ReforceXY/reward_space_analysis/README.md b/ReforceXY/reward_space_analysis/README.md index b4cda5c..7d55875 100644 --- a/ReforceXY/reward_space_analysis/README.md +++ b/ReforceXY/reward_space_analysis/README.md @@ -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 diff --git a/ReforceXY/reward_space_analysis/reward_space_analysis.py b/ReforceXY/reward_space_analysis/reward_space_analysis.py index eb8736b..f056d4d 100644 --- a/ReforceXY/reward_space_analysis/reward_space_analysis.py +++ b/ReforceXY/reward_space_analysis/reward_space_analysis.py @@ -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 = { diff --git a/ReforceXY/reward_space_analysis/tests/README.md b/ReforceXY/reward_space_analysis/tests/README.md index 9bbd190..48f0453 100644 --- a/ReforceXY/reward_space_analysis/tests/README.md +++ b/ReforceXY/reward_space_analysis/tests/README.md @@ -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 diff --git a/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py b/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py index f13a683..e1e976e 100644 --- a/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py +++ b/ReforceXY/reward_space_analysis/tests/api/test_api_helpers.py @@ -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.""" diff --git a/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py b/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py index f5a07f2..78c3494 100644 --- a/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py +++ b/ReforceXY/reward_space_analysis/tests/cli/test_cli_params_and_csv.py @@ -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" diff --git a/ReforceXY/reward_space_analysis/tests/helpers/test_utilities.py b/ReforceXY/reward_space_analysis/tests/helpers/test_utilities.py index 76cb90e..f3fefd0 100644 --- a/ReforceXY/reward_space_analysis/tests/helpers/test_utilities.py +++ b/ReforceXY/reward_space_analysis/tests/helpers/test_utilities.py @@ -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() diff --git a/ReforceXY/reward_space_analysis/tests/integration/test_report_formatting.py b/ReforceXY/reward_space_analysis/tests/integration/test_report_formatting.py index 600807b..3dde5ee 100644 --- a/ReforceXY/reward_space_analysis/tests/integration/test_report_formatting.py +++ b/ReforceXY/reward_space_analysis/tests/integration/test_report_formatting.py @@ -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) diff --git a/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py b/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py index e32f445..b5bdc47 100644 --- a/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py +++ b/ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py @@ -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() diff --git a/ReforceXY/tests/test_review_contracts.py b/ReforceXY/tests/test_review_contracts.py index 2dc7cdb..abdf3d4 100644 --- a/ReforceXY/tests/test_review_contracts.py +++ b/ReforceXY/tests/test_review_contracts.py @@ -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: diff --git a/ReforceXY/user_data/strategies/RLAgentStrategy.py b/ReforceXY/user_data/strategies/RLAgentStrategy.py index fe1212a..a1ab0d2 100644 --- a/ReforceXY/user_data/strategies/RLAgentStrategy.py +++ b/ReforceXY/user_data/strategies/RLAgentStrategy.py @@ -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 index 0000000..2965d8e --- /dev/null +++ b/quickadapter/tests/test_prediction_history.py @@ -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()