]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(freqai): prevent future continuations and align RL trade accounting (#275)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 23 Sep 2026 23:57:08 +0000 (01:57 +0200)
committerGitHub <noreply@github.com>
Wed, 23 Sep 2026 23:57:08 +0000 (01:57 +0200)
* fix(freqai): bound continuation and correct RL accounting

* fix(reforcexy): preserve second-level continuation and fee extrema

* docs: reconcile strategy guides and invariant ownership

* docs: complete development table of contents

* fix(reward): preserve profitable synthetic paths at high fees

* style(docs): format Markdown with Prettier

* fix: harden reward analysis and runtime regressions

* docs: describe ReforceXY leverage bounds

* refactor(ci): consolidate strategy QA jobs

* docs: explain strategy PYTHONPATH in QA

* fix(reforcexy): report marginal unmasked action probabilities

* style(docs): apply prettier to coverage mapping

* fix(reforcexy): clarify diagnostics and deduplicate leverage warnings

* fix(ci): restore Ruff import grouping

* chore(quickadapter): bump version to 3.13.0-rc.12

20 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/pbrs/test_pbrs.py
ReforceXY/reward_space_analysis/tests/statistics/test_statistics.py
ReforceXY/tests/test_portfolio_returns.py
ReforceXY/tests/test_review_contracts.py
ReforceXY/tests/test_training_observations.py
ReforceXY/user_data/freqaimodels/ReforceXY.py
ReforceXY/user_data/strategies/RLAgentStrategy.py
quickadapter/.basedpyright/diagnostics.json
quickadapter/tests/test_prediction_history.py [new file with mode: 0644]
quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py
quickadapter/user_data/strategies/QuickAdapterV3.py

index 019114d45633ccc2dcf29af9a935b51a0cfe6747..1f6fdefd8e79eea2cce46e04b36a8c8548551af2 100644 (file)
@@ -31,8 +31,8 @@ jobs:
       - name: Check repository formatting
         run: uvx ruff@latest format --check .
 
-  type-check:
-    name: BasedPyright (${{ matrix.name }})
+  strategy-qa:
+    name: QA (${{ matrix.name }})
     runs-on: ubuntu-latest
     timeout-minutes: 30
     strategy:
@@ -63,29 +63,23 @@ jobs:
             "${{ matrix.image }}" \
             /workspace/scripts/check_basedpyright.py \
             --project "${{ matrix.project }}"
-
-  reforcexy-tests:
-    name: ReforceXY tests
-    runs-on: ubuntu-latest
-    timeout-minutes: 30
-    env:
-      REFORCEXY_QA_IMAGE: freqai-strategies-reforcexy-qa:ci
-    steps:
-      - name: Check out repository
-        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-      - name: Build QA image
-        run: >-
-          docker build --pull --target qa
-          --tag "${REFORCEXY_QA_IMAGE}"
-          ReforceXY
       - name: Run runtime regressions
         run: |
           docker run --rm \
             --mount "type=bind,src=${GITHUB_WORKSPACE},dst=/workspace,readonly" \
             --workdir /workspace \
+            --env PYTHONPATH=/workspace/${{ matrix.context }}/user_data/strategies \
             --entrypoint python \
-            "${REFORCEXY_QA_IMAGE}" \
-            -m unittest discover -s ReforceXY/tests -v
+            "${{ matrix.image }}" \
+            -m unittest discover -s "${{ matrix.context }}/tests" -v
+
+  reward-space-analysis:
+    name: Reward space analysis
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
       - name: Set up uv
         uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
         with:
index 35d248fb7f69cbb4a0876e4fd693e1df450525ca..b194c3cbb3103658a885a07af07fe4a5b15f47f8 100644 (file)
--- a/README.md
+++ b/README.md
@@ -5,12 +5,20 @@
 - [QuickAdapter](#quickadapter)
   - [Quick start](#quick-start)
   - [Configuration tunables](#configuration-tunables)
+  - [Continual learning](#continual-learning)
+  - [Live inference](#live-inference)
   - [Backtest evaluation protocol](#backtest-evaluation-protocol)
 - [ReforceXY](#reforcexy)
   - [Quick start](#quick-start-1)
   - [Supported models](#supported-models)
   - [Configuration tunables](#configuration-tunables-1)
+  - [Continual learning](#continual-learning-1)
+  - [Live inference](#live-inference-1)
+  - [Training and HPO](#training-and-hpo)
+  - [Reward and portfolio accounting](#reward-and-portfolio-accounting)
 - [Development](#development)
+  - [Runtime regressions](#runtime-regressions)
+  - [Quality checks](#quality-checks)
 - [Common workflows](#common-workflows)
 - [Note](#note)
 
@@ -169,6 +177,16 @@ ignored with a warning. Matching column patterns are applied from least to most
 specific; equally specific patterns follow declaration order, so the later one
 wins.
 
+### Continual learning
+
+In backtests, continual training uses only a saved model whose training cutoff
+precedes the current window's end and its last available candle boundary. A
+later model left under the same identifier is not reused when a backtest is
+extended into the past. Live and dry-run restarts still restore compatible
+deployed models.
+
+### Live inference
+
 In live and dry-run modes, each pair requires
 `freqai.fit_live_predictions_candles` real model predictions before adaptive
 thresholds become available. The Nth observation first affects the next
@@ -358,6 +376,10 @@ PPO, MaskablePPO, RecurrentPPO, DQN, QRDQN
 The documented list of model tunables is at the top of the
 [ReforceXY.py](./ReforceXY/user_data/freqaimodels/ReforceXY.py) file.
 
+`RLAgentStrategy` clamps configured leverage to `[1, max_leverage]`. An omitted,
+non-finite, non-representable, Boolean or non-numeric value falls back to
+Freqtrade's `proposed_leverage` before clamping.
+
 ### Continual learning
 
 Continual learning trains an independent copy of the deployed policy with its
@@ -369,6 +391,12 @@ deployments without the chronological training marker. Training disables
 `shuffle_after_split`. HPO studies and saved best parameters are reused only
 when their objective identity matches.
 
+Backtests continue only from a saved policy whose training cutoff precedes
+the current window's end and its last available candle boundary. A later
+deployment under the same identifier is not reused for an earlier window,
+even when FreqAI has saved only metadata for intervening windows. Live and
+dry-run restarts still restore compatible deployed policies.
+
 ### Live inference
 
 Optional `fit_live_predictions_candles` statistics use the latest persisted real
@@ -430,23 +458,36 @@ delta over the returned next observation. Termination liquidates any remaining
 position once and clears the terminal potential. `get_env_history()` returns one
 metrics/price row per transition. Its `execution_tick` is the transition/action/fill
 key before the tick increment; its `tick` is the returned post-increment price and
-observation row (normally `execution_tick + 1`). Ordered trade events remain
-separate in `trade_history`, where each event's `tick` equals the history row's
-`execution_tick`; multiple events may share that key. `terminal_liquidation` and
-`exit_pnl` remain on the transition history row.
+observation row (normally `execution_tick + 1`). Exit-efficiency extrema include
+the fee-adjusted PnL at entry and subsequent retained market marks. Ordered
+trade events remain separate in `trade_history`: their tick identifies the
+candle whose price filled the event. Action fills use `execution_tick`;
+terminal liquidations use the returned post-increment tick. `terminal_liquidation`
+and `exit_pnl` remain on the transition history row.
 
 ## Development
 
 ### 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
 ```
 
+CI runs type checks and runtime regressions in one QA matrix entry per strategy.
+The shared runtime step sets each strategy's `PYTHONPATH`. QuickAdapter needs
+this for direct `unittest` discovery because its model imports `LabelTransformer`
+and `Utils` by bare names; ReforceXY resolves its imports without it, so the
+setting is optional there. The reward-space analysis suite runs separately
+with `uv`, without a Freqtrade image.
+
 ### Quality checks
 
 Run repository quality checks from the repository root:
index a305e4bd45e05e65efc8bc3c76206da72082a28f..1bacced3e8b041e2cc17492ee4243672f721bca4 100644 (file)
     },
     {
       "endCharacter": 5,
-      "endLine": 2095,
+      "endLine": 2136,
       "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": 2090
+      "startLine": 2131
     },
     {
       "endCharacter": 21,
-      "endLine": 2092,
+      "endLine": 2133,
       "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": 2092
+      "startLine": 2133
     },
     {
       "endCharacter": 5,
-      "endLine": 2117,
+      "endLine": 2158,
       "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": 2113
+      "startLine": 2154
     },
     {
       "endCharacter": 43,
-      "endLine": 2114,
+      "endLine": 2155,
       "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": 2114
+      "startLine": 2155
     },
     {
       "endCharacter": 42,
-      "endLine": 2114,
+      "endLine": 2155,
       "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": 2114
+      "startLine": 2155
     },
     {
       "endCharacter": 60,
-      "endLine": 2374,
+      "endLine": 2415,
       "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": 2374
+      "startLine": 2415
     },
     {
       "endCharacter": 58,
-      "endLine": 2375,
+      "endLine": 2416,
       "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": 2375
+      "startLine": 2416
     },
     {
       "endCharacter": 88,
-      "endLine": 2394,
+      "endLine": 2435,
       "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": 2394
+      "startLine": 2435
     },
     {
       "endCharacter": 88,
-      "endLine": 2394,
+      "endLine": 2435,
       "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": 2394
+      "startLine": 2435
     },
     {
       "endCharacter": 17,
-      "endLine": 2403,
+      "endLine": 2444,
       "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
       "message": "Object of type \"None\" cannot be called",
       "rule": "reportOptionalCall",
       "severity": "error",
       "startCharacter": 28,
-      "startLine": 2397
-    },
-    {
-      "endCharacter": 40,
-      "endLine": 2632,
-      "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": 2632
-    },
-    {
-      "endCharacter": 38,
-      "endLine": 2632,
-      "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": 2632
-    },
-    {
-      "endCharacter": 59,
-      "endLine": 2632,
-      "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": 2632
-    },
-    {
-      "endCharacter": 57,
-      "endLine": 2632,
-      "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": 2632
-    },
-    {
-      "endCharacter": 40,
-      "endLine": 2633,
-      "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": 2633
-    },
-    {
-      "endCharacter": 38,
-      "endLine": 2633,
-      "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": 2633
-    },
-    {
-      "endCharacter": 59,
-      "endLine": 2633,
-      "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": 2633
-    },
-    {
-      "endCharacter": 57,
-      "endLine": 2633,
-      "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": 2633
-    },
-    {
-      "endCharacter": 76,
-      "endLine": 2649,
-      "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": 2649
-    },
-    {
-      "endCharacter": 49,
-      "endLine": 2649,
-      "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": 2649
-    },
-    {
-      "endCharacter": 74,
-      "endLine": 2650,
-      "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": 2650
-    },
-    {
-      "endCharacter": 47,
-      "endLine": 2650,
-      "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": 2650
-    },
-    {
-      "endCharacter": 51,
-      "endLine": 2678,
-      "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": 2678
-    },
-    {
-      "endCharacter": 64,
-      "endLine": 2678,
-      "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": 2678
-    },
-    {
-      "endCharacter": 68,
-      "endLine": 2681,
-      "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": 2681
-    },
-    {
-      "endCharacter": 54,
-      "endLine": 2681,
-      "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": 2681
-    },
-    {
-      "endCharacter": 67,
-      "endLine": 2681,
-      "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": 2681
+      "startLine": 2438
     },
     {
       "endCharacter": 29,
-      "endLine": 2981,
+      "endLine": 3043,
       "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": 2981
+      "startLine": 3043
     },
     {
       "endCharacter": 28,
-      "endLine": 2981,
+      "endLine": 3043,
       "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": 2981
+      "startLine": 3043
     },
     {
       "endCharacter": 65,
-      "endLine": 2990,
+      "endLine": 3052,
       "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": 2990
+      "startLine": 3052
     },
     {
       "endCharacter": 65,
-      "endLine": 2990,
+      "endLine": 3052,
       "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": 2990
+      "startLine": 3052
     },
     {
       "endCharacter": 5,
-      "endLine": 3886,
+      "endLine": 3952,
       "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": 3882
+      "startLine": 3948
     },
     {
       "endCharacter": 43,
-      "endLine": 3883,
+      "endLine": 3949,
       "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": 3883
+      "startLine": 3949
     },
     {
       "endCharacter": 42,
-      "endLine": 3883,
+      "endLine": 3949,
       "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": 3883
+      "startLine": 3949
     },
     {
       "endCharacter": 22,
-      "endLine": 1010,
+      "endLine": 1011,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"train_env\" for class \"ReforceXY*\"\n  Expression of type \"VecEnv\" cannot be assigned to attribute \"train_env\" of class \"ReforceXY\"\n    Type \"VecEnv\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n      \"VecEnv\" is not assignable to \"VecMonitor\"\n      \"VecEnv\" is not assignable to \"SubprocVecEnv\"\n      \"VecEnv\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 1010
+      "startLine": 1011
     },
     {
       "endCharacter": 37,
-      "endLine": 1010,
+      "endLine": 1011,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"eval_env\" for class \"ReforceXY*\"\n  Expression of type \"VecEnv | None\" cannot be assigned to attribute \"eval_env\" of class \"ReforceXY\"\n    Type \"VecEnv | None\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n      Type \"VecEnv\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n        \"VecEnv\" is not assignable to \"VecMonitor\"\n        \"VecEnv\" is not assignable to \"SubprocVecEnv\"\n        \"VecEnv\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 1010
+      "startLine": 1011
     },
     {
       "endCharacter": 24,
-      "endLine": 1269,
+      "endLine": 1270,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"VecEnv\" cannot be assigned to parameter \"eval_env\" of type \"BaseEnvironment\" in function \"__init__\"\n  \"VecEnv\" is not assignable to \"BaseEnvironment\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 1269
+      "startLine": 1270
     },
     {
       "endCharacter": 38,
-      "endLine": 1477,
+      "endLine": 1498,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"obj\" of type \"Sized\" in function \"len\"\n  Type \"Any | None\" is not assignable to type \"Sized\"\n    \"None\" is incompatible with protocol \"Sized\"\n      \"__len__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 30,
-      "startLine": 1477
+      "startLine": 1498
     },
     {
       "endCharacter": 36,
-      "endLine": 1481,
+      "endLine": 1502,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"obj\" of type \"Sized\" in function \"len\"\n  Type \"Any | None\" is not assignable to type \"Sized\"\n    \"None\" is incompatible with protocol \"Sized\"\n      \"__len__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 1481
+      "startLine": 1502
     },
     {
       "endCharacter": 80,
-      "endLine": 1484,
+      "endLine": 1505,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"timeframe\" of type \"str\" in function \"steps_to_days\"\n  Type \"Any | None\" is not assignable to type \"str\"\n    \"None\" is not assignable to \"str\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 1484
+      "startLine": 1505
     },
     {
       "endCharacter": 78,
-      "endLine": 1485,
+      "endLine": 1506,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"timeframe\" of type \"str\" in function \"steps_to_days\"\n  Type \"Any | None\" is not assignable to type \"str\"\n    \"None\" is not assignable to \"str\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 50,
-      "startLine": 1485
+      "startLine": 1506
     },
     {
       "endCharacter": 80,
-      "endLine": 1486,
+      "endLine": 1507,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"timeframe\" of type \"str\" in function \"steps_to_days\"\n  Type \"Any | None\" is not assignable to type \"str\"\n    \"None\" is not assignable to \"str\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 1486
+      "startLine": 1507
     },
     {
       "endCharacter": 56,
-      "endLine": 1571,
+      "endLine": 1592,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\" cannot be assigned to parameter \"eval_env\" of type \"VecEnv | None\" in function \"get_callbacks\"\n  Type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\" is not assignable to type \"VecEnv | None\"\n    Type \"Env[Unknown, Unknown]\" is not assignable to type \"VecEnv | None\"\n      \"Env[Unknown, Unknown]\" is not assignable to \"VecEnv\"\n      \"Env[Unknown, Unknown]\" is not assignable to \"None\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 43,
-      "startLine": 1571
+      "startLine": 1592
     },
     {
       "endCharacter": 84,
-      "endLine": 1591,
+      "endLine": 1612,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 1591
+      "startLine": 1612
     },
     {
       "endCharacter": 84,
-      "endLine": 1591,
+      "endLine": 1612,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 1591
+      "startLine": 1612
     },
     {
       "endCharacter": 44,
-      "endLine": 2665,
+      "endLine": 2686,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"best_trial_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 2665
+      "startLine": 2686
     },
     {
       "endCharacter": 12,
-      "endLine": 2685,
+      "endLine": 2706,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Parameter declaration \"seed\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 2685
+      "startLine": 2706
     },
     {
       "endCharacter": 16,
-      "endLine": 2686,
+      "endLine": 2707,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Parameter declaration \"env_info\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 2686
+      "startLine": 2707
     },
     {
       "endCharacter": 51,
-      "endLine": 2729,
+      "endLine": 2750,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"list[() -> BaseEnvironment]\" cannot be assigned to parameter \"env_fns\" of type \"list[() -> Env[Unknown, Unknown]]\" in function \"__init__\"\n  \"list[() -> BaseEnvironment]\" is not assignable to \"list[() -> Env[Unknown, Unknown]]\"\n    Type parameter \"_T@list\" is invariant, but \"() -> BaseEnvironment\" is not the same as \"() -> Env[Unknown, Unknown]\"\n    Consider switching from \"list\" to \"Sequence\" which is covariant",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 42,
-      "startLine": 2729
+      "startLine": 2750
     },
     {
       "endCharacter": 53,
-      "endLine": 2735,
+      "endLine": 2756,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"list[() -> BaseEnvironment]\" cannot be assigned to parameter \"env_fns\" of type \"list[() -> Env[Unknown, Unknown]]\" in function \"__init__\"\n  \"list[() -> BaseEnvironment]\" is not assignable to \"list[() -> Env[Unknown, Unknown]]\"\n    Type parameter \"_T@list\" is invariant, but \"() -> BaseEnvironment\" is not the same as \"() -> Env[Unknown, Unknown]\"\n    Consider switching from \"list\" to \"Sequence\" which is covariant",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 2735
+      "startLine": 2756
     },
     {
       "endCharacter": 22,
-      "endLine": 2798,
+      "endLine": 2819,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 2798
+      "startLine": 2819
     },
     {
       "endCharacter": 96,
-      "endLine": 2800,
+      "endLine": 2821,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 89,
-      "startLine": 2800
+      "startLine": 2821
     },
     {
       "endCharacter": 23,
-      "endLine": 2803,
+      "endLine": 2824,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 2803
+      "startLine": 2824
     },
     {
       "endCharacter": 98,
-      "endLine": 2805,
+      "endLine": 2826,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 91,
-      "startLine": 2805
+      "startLine": 2826
     },
     {
       "endCharacter": 44,
-      "endLine": 2817,
+      "endLine": 2838,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for types \"Any | None\" and \"Any | int | None\"\n  Operator \"*\" not supported for types \"None\" and \"None\"\n  Operator \"*\" not supported for types \"None\" and \"int\"",
       "rule": "reportOperatorIssue",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 2817
+      "startLine": 2838
     },
     {
       "endCharacter": 133,
-      "endLine": 2819,
+      "endLine": 2840,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \"*\" not supported for types \"Any | None\" and \"Any | int | None\"\n  Operator \"*\" not supported for types \"None\" and \"None\"\n  Operator \"*\" not supported for types \"None\" and \"int\"",
       "rule": "reportOperatorIssue",
       "severity": "error",
       "startCharacter": 106,
-      "startLine": 2819
+      "startLine": 2840
     },
     {
       "endCharacter": 30,
-      "endLine": 2822,
+      "endLine": 2843,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Operator \">\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 2822
+      "startLine": 2843
     },
     {
       "endCharacter": 54,
-      "endLine": 2887,
+      "endLine": 2908,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"is_pruned\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 2887
+      "startLine": 2908
     },
     {
       "endCharacter": 67,
-      "endLine": 2890,
+      "endLine": 2911,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"use_masking\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 2890
+      "startLine": 2911
     },
     {
       "endCharacter": 60,
-      "endLine": 2898,
+      "endLine": 2919,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"update_best_reward\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 42,
-      "startLine": 2898
+      "startLine": 2919
     },
     {
       "endCharacter": 84,
-      "endLine": 2898,
+      "endLine": 2919,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 2898
+      "startLine": 2919
     },
     {
       "endCharacter": 84,
-      "endLine": 2898,
+      "endLine": 2919,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | list[float]\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | list[float]\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"list[float]\" is not assignable to type \"ConvertibleToFloat\"\n      \"list[float]\" is not assignable to \"str\"\n      \"list[float]\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"list[float]\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 67,
-      "startLine": 2898
+      "startLine": 2919
     },
     {
       "endCharacter": 46,
-      "endLine": 2952,
+      "endLine": 2973,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"is_pruned\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 37,
-      "startLine": 2952
+      "startLine": 2973
     },
     {
       "endCharacter": 57,
-      "endLine": 2955,
+      "endLine": 2976,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"best_mean_reward\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2955
+      "startLine": 2976
     },
     {
       "endCharacter": 37,
-      "endLine": 2965,
+      "endLine": 2986,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"train_env\" for class \"ReforceXY*\"\n  Type \"None\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n    \"None\" is not assignable to \"VecMonitor\"\n    \"None\" is not assignable to \"SubprocVecEnv\"\n    \"None\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 2965
+      "startLine": 2986
     },
     {
       "endCharacter": 36,
-      "endLine": 2970,
+      "endLine": 2991,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Cannot assign to attribute \"eval_env\" for class \"ReforceXY*\"\n  Type \"None\" is not assignable to type \"VecMonitor | SubprocVecEnv | Env[Unknown, Unknown]\"\n    \"None\" is not assignable to \"VecMonitor\"\n    \"None\" is not assignable to \"SubprocVecEnv\"\n    \"None\" is not assignable to \"Env[Unknown, Unknown]\"",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 32,
-      "startLine": 2970
+      "startLine": 2991
     },
     {
       "endCharacter": 7,
-      "endLine": 3015,
+      "endLine": 3036,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Declaration \"MyRLEnv\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 0,
-      "startLine": 3015
+      "startLine": 3036
     },
     {
       "endCharacter": 36,
-      "endLine": 3027,
+      "endLine": 3048,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Declaration \"_last_closed_trade_tick\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 13,
-      "startLine": 3027
+      "startLine": 3048
     },
     {
       "endCharacter": 13,
-      "endLine": 3619,
+      "endLine": 3640,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"reset\" overrides class \"BaseEnvironment\" in an incompatible manner\n  Return type mismatch: base method returns type \"tuple[DataFrame, dict[Unknown, Unknown]]\", override returns type \"tuple[NDArray[float32], dict[str, Any]]\"\n    \"tuple[NDArray[float32], dict[str, Any]]\" is not assignable to \"tuple[DataFrame, dict[Unknown, Unknown]]\"\n      Tuple entry 1 is incorrect type\n        \"ndarray[_AnyShape, dtype[float32]]\" is not assignable to \"DataFrame\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 3619
+      "startLine": 3640
     },
     {
       "endCharacter": 26,
-      "endLine": 3640,
+      "endLine": 3661,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Type \"tuple[DataFrame, dict[Unknown, Unknown]]\" is not assignable to return type \"tuple[NDArray[float32], dict[str, Any]]\"\n  \"DataFrame\" is not assignable to \"ndarray[_AnyShape, dtype[float32]]\"",
       "rule": "reportReturnType",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 3640
+      "startLine": 3661
     },
     {
       "endCharacter": 55,
-      "endLine": 3948,
+      "endLine": 3969,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 3948
+      "startLine": 3969
     },
     {
       "endCharacter": 55,
-      "endLine": 3948,
+      "endLine": 3969,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 3948
+      "startLine": 3969
     },
     {
       "endCharacter": 59,
-      "endLine": 3972,
+      "endLine": 3993,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 3972
+      "startLine": 3993
     },
     {
       "endCharacter": 59,
-      "endLine": 3972,
+      "endLine": 3993,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 3972
+      "startLine": 3993
     },
     {
       "endCharacter": 24,
-      "endLine": 4001,
+      "endLine": 4022,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"_get_observation\" overrides class \"BaseEnvironment\" in an incompatible manner\n  Return type mismatch: base method returns type \"DataFrame\", override returns type \"NDArray[float32]\"\n    \"ndarray[_AnyShape, dtype[float32]]\" is not assignable to \"DataFrame\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4001
+      "startLine": 4022
     },
     {
       "endCharacter": 12,
-      "endLine": 4103,
+      "endLine": 4124,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"step\" overrides class \"Base5ActionRLEnv\" in an incompatible manner\n  Return type mismatch: base method returns type \"tuple[DataFrame, float, bool, Literal[False], dict[str, Unknown]]\", override returns type \"tuple[NDArray[float32], float, bool, bool, dict[str, Any]]\"\n    \"tuple[NDArray[float32], float, bool, bool, dict[str, Any]]\" is not assignable to \"tuple[DataFrame, float, bool, Literal[False], dict[str, Unknown]]\"\n      Tuple entry 1 is incorrect type\n        \"ndarray[_AnyShape, dtype[float32]]\" is not assignable to \"DataFrame\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4103
+      "startLine": 4124
     },
     {
       "endCharacter": 20,
-      "endLine": 4290,
+      "endLine": 4314,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Method \"action_masks\" overrides class \"BaseEnvironment\" in an incompatible manner\n  Return type mismatch: base method returns type \"list[bool]\", override returns type \"NDArray[bool_]\"\n    \"ndarray[_AnyShape, dtype[bool_]]\" is not assignable to \"list[bool]\"",
       "rule": "reportIncompatibleMethodOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 4290
+      "startLine": 4314
     },
     {
       "endCharacter": 40,
-      "endLine": 4439,
+      "endLine": 4463,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "\"Figure\" is not exported from module \"matplotlib.pyplot\"",
       "rule": "reportPrivateImportUsage",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 4439
+      "startLine": 4463
     },
     {
       "endCharacter": 54,
-      "endLine": 4942,
+      "endLine": 4966,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 4942
+      "startLine": 4966
     },
     {
       "endCharacter": 54,
-      "endLine": 4942,
+      "endLine": 4966,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 45,
-      "startLine": 4942
+      "startLine": 4966
     },
     {
       "endCharacter": 50,
-      "endLine": 4954,
+      "endLine": 4978,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"object\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"object\" is not assignable to type \"ConvertibleToFloat\"\n    \"object\" is not assignable to \"str\"\n    \"object\" is incompatible with protocol \"Buffer\"\n      \"__buffer__\" is not present\n    \"object\" is incompatible with protocol \"SupportsFloat\"\n      \"__float__\" is not present\n    \"object\" is incompatible with protocol \"SupportsIndex\"\n      \"__index__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 37,
-      "startLine": 4954
+      "startLine": 4978
     },
     {
       "endCharacter": 72,
-      "endLine": 4965,
+      "endLine": 4989,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 70,
-      "startLine": 4965
+      "startLine": 4989
     },
     {
       "endCharacter": 72,
-      "endLine": 4965,
+      "endLine": 4989,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 70,
-      "startLine": 4965
+      "startLine": 4989
     },
     {
       "endCharacter": 73,
-      "endLine": 4974,
+      "endLine": 4998,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 71,
-      "startLine": 4974
+      "startLine": 4998
     },
     {
       "endCharacter": 73,
-      "endLine": 4974,
+      "endLine": 4998,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"float | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"float | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 71,
-      "startLine": 4974
+      "startLine": 4998
     },
     {
       "endCharacter": 58,
-      "endLine": 4983,
+      "endLine": 5007,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 4983
+      "startLine": 5007
     },
     {
       "endCharacter": 58,
-      "endLine": 4983,
+      "endLine": 5007,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 4983
+      "startLine": 5007
     },
     {
       "endCharacter": 50,
-      "endLine": 5214,
+      "endLine": 5238,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Mapping[Unknown, Unknown]\" cannot be assigned to parameter \"src\" of type \"dict[str, Any]\" in function \"deepmerge\"\n  \"Mapping[Unknown, Unknown]\" is not assignable to \"dict[str, Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5214
+      "startLine": 5238
     },
     {
       "endCharacter": 20,
-      "endLine": 5391,
+      "endLine": 5415,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 18,
-      "startLine": 5391
+      "startLine": 5415
     },
     {
       "endCharacter": 20,
-      "endLine": 5391,
+      "endLine": 5415,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 18,
-      "startLine": 5391
+      "startLine": 5415
     },
     {
       "endCharacter": 59,
-      "endLine": 5396,
+      "endLine": 5420,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5396
+      "startLine": 5420
     },
     {
       "endCharacter": 59,
-      "endLine": 5396,
+      "endLine": 5420,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5396
+      "startLine": 5420
     },
     {
       "endCharacter": 65,
-      "endLine": 5397,
+      "endLine": 5421,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5397
+      "startLine": 5421
     },
     {
       "endCharacter": 65,
-      "endLine": 5397,
+      "endLine": 5421,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5397
+      "startLine": 5421
     },
     {
       "endCharacter": 57,
-      "endLine": 5398,
+      "endLine": 5422,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5398
+      "startLine": 5422
     },
     {
       "endCharacter": 57,
-      "endLine": 5398,
+      "endLine": 5422,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5398
+      "startLine": 5422
     },
     {
       "endCharacter": 63,
-      "endLine": 5400,
+      "endLine": 5424,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5400
+      "startLine": 5424
     },
     {
       "endCharacter": 63,
-      "endLine": 5400,
+      "endLine": 5424,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5400
+      "startLine": 5424
     },
     {
       "endCharacter": 61,
-      "endLine": 5402,
+      "endLine": 5426,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 32,
-      "startLine": 5402
+      "startLine": 5426
     },
     {
       "endCharacter": 61,
-      "endLine": 5402,
+      "endLine": 5426,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 32,
-      "startLine": 5402
+      "startLine": 5426
     },
     {
       "endCharacter": 67,
-      "endLine": 5403,
+      "endLine": 5427,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 5403
+      "startLine": 5427
     },
     {
       "endCharacter": 67,
-      "endLine": 5403,
+      "endLine": 5427,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 5403
+      "startLine": 5427
     },
     {
       "endCharacter": 73,
-      "endLine": 5404,
+      "endLine": 5428,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5404
+      "startLine": 5428
     },
     {
       "endCharacter": 73,
-      "endLine": 5404,
+      "endLine": 5428,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5404
+      "startLine": 5428
     },
     {
       "endCharacter": 61,
-      "endLine": 5405,
+      "endLine": 5429,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 5405
+      "startLine": 5429
     },
     {
       "endCharacter": 61,
-      "endLine": 5405,
+      "endLine": 5429,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 5405
+      "startLine": 5429
     },
     {
       "endCharacter": 89,
-      "endLine": 5412,
+      "endLine": 5436,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 5412
+      "startLine": 5436
     },
     {
       "endCharacter": 89,
-      "endLine": 5412,
+      "endLine": 5436,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 52,
-      "startLine": 5412
+      "startLine": 5436
     },
     {
       "endCharacter": 83,
-      "endLine": 5413,
+      "endLine": 5437,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5413
+      "startLine": 5437
     },
     {
       "endCharacter": 83,
-      "endLine": 5413,
+      "endLine": 5437,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5413
+      "startLine": 5437
     },
     {
       "endCharacter": 57,
-      "endLine": 5438,
+      "endLine": 5462,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5438
+      "startLine": 5462
     },
     {
       "endCharacter": 57,
-      "endLine": 5438,
+      "endLine": 5462,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 5438
+      "startLine": 5462
     },
     {
       "endCharacter": 65,
-      "endLine": 5439,
+      "endLine": 5463,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5439
+      "startLine": 5463
     },
     {
       "endCharacter": 65,
-      "endLine": 5439,
+      "endLine": 5463,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 5439
+      "startLine": 5463
     },
     {
       "endCharacter": 67,
-      "endLine": 5441,
+      "endLine": 5465,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 35,
-      "startLine": 5441
+      "startLine": 5465
     },
     {
       "endCharacter": 67,
-      "endLine": 5441,
+      "endLine": 5465,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 35,
-      "startLine": 5441
+      "startLine": 5465
     },
     {
       "endCharacter": 87,
-      "endLine": 5444,
+      "endLine": 5468,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5444
+      "startLine": 5468
     },
     {
       "endCharacter": 87,
-      "endLine": 5444,
+      "endLine": 5468,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5444
+      "startLine": 5468
     },
     {
       "endCharacter": 93,
-      "endLine": 5445,
+      "endLine": 5469,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5445
+      "startLine": 5469
     },
     {
       "endCharacter": 93,
-      "endLine": 5445,
+      "endLine": 5469,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 5445
+      "startLine": 5469
     },
     {
       "endCharacter": 89,
-      "endLine": 5446,
+      "endLine": 5470,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 47,
-      "startLine": 5446
+      "startLine": 5470
     },
     {
       "endCharacter": 89,
-      "endLine": 5446,
+      "endLine": 5470,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToFloat\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToFloat\"\n    Type \"None\" is not assignable to type \"ConvertibleToFloat\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsFloat\"\n        \"__float__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 47,
-      "startLine": 5446
+      "startLine": 5470
     },
     {
       "endCharacter": 89,
-      "endLine": 5447,
+      "endLine": 5471,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5447
+      "startLine": 5471
     },
     {
       "endCharacter": 89,
-      "endLine": 5447,
+      "endLine": 5471,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 46,
-      "startLine": 5447
+      "startLine": 5471
     },
     {
       "endCharacter": 75,
-      "endLine": 5448,
+      "endLine": 5472,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5448
+      "startLine": 5472
     },
     {
       "endCharacter": 75,
-      "endLine": 5448,
+      "endLine": 5472,
       "file": "ReforceXY/user_data/freqaimodels/ReforceXY.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Any | None\" is not assignable to type \"ConvertibleToInt\"\n    Type \"None\" is not assignable to type \"ConvertibleToInt\"\n      \"None\" is not assignable to \"str\"\n      \"None\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"None\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"None\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 39,
-      "startLine": 5448
+      "startLine": 5472
     },
     {
       "endCharacter": 33,
     },
     {
       "endCharacter": 17,
-      "endLine": 62,
+      "endLine": 63,
       "file": "ReforceXY/user_data/strategies/RLAgentStrategy.py",
       "message": "\"can_short\" overrides symbol of same name in class \"IStrategy\"\n  \"property\" is not assignable to \"bool\"",
       "rule": "reportIncompatibleVariableOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 62
+      "startLine": 63
     },
     {
       "endCharacter": 19,
-      "endLine": 66,
+      "endLine": 67,
       "file": "ReforceXY/user_data/strategies/RLAgentStrategy.py",
       "message": "\"protections\" overrides symbol of same name in class \"IStrategy\"\n  \"property\" is not assignable to \"list[Unknown]\"",
       "rule": "reportIncompatibleVariableOverride",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 66
+      "startLine": 67
     },
     {
       "endCharacter": 72,
-      "endLine": 88,
+      "endLine": 89,
       "file": "ReforceXY/user_data/strategies/RLAgentStrategy.py",
       "message": "No overloads for \"__call__\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 42,
-      "startLine": 88
+      "startLine": 89
     },
     {
       "endCharacter": 71,
-      "endLine": 88,
+      "endLine": 89,
       "file": "ReforceXY/user_data/strategies/RLAgentStrategy.py",
       "message": "Argument of type \"Series[Any] | None\" cannot be assigned to parameter \"x1\" of type \"_SupportsArrayUFunc\" in function \"__call__\"\n  Type \"Series[Any] | None\" is not assignable to type \"_SupportsArrayUFunc\"\n    \"None\" is incompatible with protocol \"_SupportsArrayUFunc\"\n      \"__array_ufunc__\" is not present",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 88
+      "startLine": 89
     }
   ],
   "filesAnalyzed": 3,
index 28ccd426752933a87da08ceef66fb96262684139..5000b4e919cff96a5f65110ac318af0f10dfb16a 100644 (file)
@@ -57,7 +57,7 @@ Full test documentation: [tests/README.md](./tests/README.md).
   - [Transform Functions](#transform-functions)
   - [Skipping Feature Analysis](#skipping-feature-analysis)
   - [Reproducibility](#reproducibility)
-  - [Overrides vs --params](#overrides-vs--params)
+  - [Overrides vs --params](#overrides-vs---params)
 - [Examples](#examples)
 - [Outputs](#outputs)
   - [Main Report (`statistical_analysis.md`)](#main-report-statistical_analysismd)
@@ -165,10 +165,10 @@ Generates shift metrics for comparison (see Outputs section).
 - **`--real_episodes`** (path, optional) – Episodes pickle for real vs synthetic
   distribution shift metrics. (Simulation-only; triggers additional outputs when
   provided).
-- **`--unrealized_pnl`** (flag, default: false) – Transform the retained
-  in-position synthetic price/PnL trajectory using fee-aware unrealized PnL.
-  This affects extrema and all enabled base, PBRS, and additive reward terms
-  that depend on PnL. (Simulation-only.)
+- **`--unrealized_pnl`** (flag, default: false) – Track a sampled market
+  price separately from the retained price within each trade; map its fee-aware
+  PnL through duration-based tanh scaling before retaining the mark. Retained
+  marks affect exit-efficiency extrema and PnL-dependent rewards. (Simulation-only.)
 
 ### Hybrid Simulation Scalars
 
@@ -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
@@ -257,15 +262,16 @@ The exit factor is computed as:
 **Formula:**
 
 Let `pnl_target = profit_aim · risk_reward_ratio` and
-`pnl_ratio = pnl / pnl_target`. On the loss branch,
-`loss_threshold = pnl_target / risk_reward_ratio` and
-`loss_ratio = |pnl| / loss_threshold = |pnl_ratio| · risk_reward_ratio`.
+`pnl_ratio = pnl / pnl_target` when `pnl_target > 0`. For losses, let
+`effective_rr = risk_reward_ratio` if positive, or `2.0` otherwise (the runtime
+fallback). Then `loss_threshold = pnl_target / effective_rr` and
+`loss_ratio = |pnl| / loss_threshold = |pnl_ratio| · effective_rr`.
 
 - If `pnl_target ≤ 0`: `pnl_target_coefficient = 1.0`
 - If `pnl_ratio > 1.0`:
   `pnl_target_coefficient = 1.0 + win_reward_factor · tanh(pnl_amplification_sensitivity · (pnl_ratio - 1.0))`
 - If `pnl < -loss_threshold`:
-  `pnl_target_coefficient = 1.0 + (win_reward_factor · risk_reward_ratio) · tanh(pnl_amplification_sensitivity · (loss_ratio - 1.0))`
+  `pnl_target_coefficient = 1.0 + (win_reward_factor · effective_rr) · tanh(pnl_amplification_sensitivity · (loss_ratio - 1.0))`
 - Else: `pnl_target_coefficient = 1.0`
 
 ##### Efficiency
@@ -289,6 +295,16 @@ Let `max_u = max_unrealized_profit`, `min_u = min_unrealized_profit`,
   `efficiency_coefficient = 1 + efficiency_weight · (efficiency_center - ratio)`
 - Else: `efficiency_coefficient = 1`
 
+In synthetic `unrealized_pnl` mode, sampled market prices accumulate each
+candle's return independently of the transformed, retained price. The
+fee-aware sampled PnL is bounded, scaled by the duration-dependent tanh
+factor, and converted to the retained price. Exit-efficiency extrema start
+with the fee-adjusted PnL at the entry fill and then include each retained
+mark; a sampled candidate is never retained as an extremum. Synthetic marks
+are capped at +0.15; their lower bound is the lesser of -0.15 and the
+fee-adjusted entry PnL. The extreme-PnL check permits that fee loss and
+floating-point roundoff at its boundary, but rejects larger excursions.
+
 ##### Exit Attenuation
 
 | Parameter               | Default | Description                    |
@@ -451,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                                      |
@@ -535,6 +555,11 @@ descriptive.
 | `partial_dependence_*.csv` | Partial dependence data                              |
 | `manifest.json`            | Runtime manifest (simulation + reward params + hash) |
 
+The `sample_entry_prob`, `sample_exit_prob`, and `sample_neutral_prob` columns in
+`reward_samples.csv` report marginal probabilities of valid actions when applicable.
+With masking disabled, these probabilities include the 90% valid-action mass;
+they are not conditional on drawing a valid action.
+
 ### Manifest (`manifest.json`)
 
 | Field                   | Type              | Description                                                                                             |
@@ -563,8 +588,14 @@ Within the same analyzer revision, identical `params_hash` values mean the resol
 | `*_ks_statistic`  | KS two-sample statistic               | [0,1]; higher ⇒ divergence                                                    |
 | `*_ks_pvalue`     | KS test p-value                       | API-only with `independent_observations=True`; omitted by the descriptive CLI |
 
-Implementation: 50-bin histograms with ε=1e-10; constants have zero divergence.
-Inferential KS p-values are available only under the programmatic independence contract.
+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. When none qualify, the report distinguishes
+missing episodes, no comparable finite data, and fewer than 10 finite observations
+per dataset and comparable feature.
 
 ---
 
@@ -603,8 +634,8 @@ uv run python reward_space_analysis.py \
   --out_dir real_vs_synthetic
 ```
 
-Shift metrics: lower divergence preferred (except p-value: higher ⇒ cannot
-reject equality).
+For the CLI's descriptive shift metrics, lower values indicate closer
+synthetic and real distributions.
 
 ### Batch Analysis
 
index 960d072ea90131fd523034b277ddd0994877234c..c4f6854ba276d9316088393c1bd61e1d0f79eeb0 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,29 @@ 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)
+        )
+        entry_prob *= valid_mass
+        exit_prob *= valid_mass
+        neutral_prob *= valid_mass
 
-    weights = [1.0 - exit_prob, exit_prob]
     action = rng.choices(choices, weights=weights, k=1)[0]
     return action, entry_prob, exit_prob, neutral_prob
 
@@ -1735,9 +1751,11 @@ def simulate_samples(
     pnl = 0.0
     max_unrealized_profit = 0.0
     min_unrealized_profit = 0.0
+    pnl_floor = -0.15
 
     # Synthetic market state
     current_open = 1.0
+    sampled_open = current_open
     entry_open = current_open
 
     for _ in range(num_samples):
@@ -1749,6 +1767,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(
@@ -1761,24 +1780,22 @@ 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
+            if next_position != Positions.Neutral:
+                position = next_position
                 trade_duration = 0
                 idle_duration = 0
                 entry_open = current_open
-                max_unrealized_profit = -np.inf
-                min_unrealized_profit = np.inf
-            elif action == Actions.Short_enter and short_allowed:
-                position = Positions.Short
-                trade_duration = 0
-                idle_duration = 0
-                entry_open = current_open
-                max_unrealized_profit = -np.inf
-                min_unrealized_profit = np.inf
+                entry_pnl = _compute_unrealized_pnl_estimate(
+                    position, entry_open=entry_open, current_open=entry_open, params=params
+                )
+                max_unrealized_profit = min_unrealized_profit = entry_pnl
+                sampled_open = entry_open
+                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
@@ -1807,33 +1824,36 @@ def simulate_samples(
             step_return = 0.0
         step_return = float(np.clip(step_return, -0.95, 0.95))
 
-        current_open = float(max(1e-6, current_open * (1.0 + step_return)))
-        # Always sample the random-walk price so both modes consume the same RNG stream.
-        # Unrealized-PnL mode replaces it below with the fee-aware price implied by
-        # the target PnL before reward calculation.
+        # Sample once in both modes; keep the raw market path separate from the
+        # transformed price while a position is open.
+        transform_unrealized_pnl = position in (
+            Positions.Long,
+            Positions.Short,
+        ) and _get_bool_param(params, "unrealized_pnl", False)
+        if transform_unrealized_pnl:
+            sampled_open = float(max(1e-6, sampled_open * (1.0 + step_return)))
+            candidate_open = sampled_open
+        else:
+            current_open = float(max(1e-6, current_open * (1.0 + step_return)))
+            candidate_open = current_open
         if position in (Positions.Long, Positions.Short):
             candidate_pnl = float(
                 np.clip(
                     _compute_unrealized_pnl_estimate(
                         position,
                         entry_open=entry_open,
-                        current_open=current_open,
+                        current_open=candidate_open,
                         params=params,
                     ),
-                    -0.15,
+                    pnl_floor,
                     0.15,
                 )
             )
-            if _get_bool_param(params, "unrealized_pnl", False):
-                # Let the sampled market move shape the next retained PnL without
-                # storing the discarded candidate in exit-efficiency extrema.
-                prospective_max = max(max_unrealized_profit, candidate_pnl)
-                prospective_min = min(min_unrealized_profit, candidate_pnl)
-                center_unrealized = 0.5 * (prospective_max + prospective_min)
+            if transform_unrealized_pnl:
                 beta = _get_float_param(params, "pnl_amplification_sensitivity")
                 hold_ratio = _compute_duration_ratio(trade_duration, max_trade_duration_candles)
                 target_pnl = float(
-                    np.clip(center_unrealized * math.tanh(beta * hold_ratio), -0.15, 0.15)
+                    np.clip(candidate_pnl * math.tanh(beta * hold_ratio), pnl_floor, 0.15)
                 )
                 entry_fee_rate, exit_fee_rate = _get_fee_rates(params)
                 if position == Positions.Long:
@@ -1853,7 +1873,7 @@ def simulate_samples(
                             current_open=current_open,
                             params=params,
                         ),
-                        -0.15,
+                        pnl_floor,
                         0.15,
                     )
                 )
@@ -1938,7 +1958,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),
             }
         )
@@ -1947,36 +1971,52 @@ def simulate_samples(
     df.attrs["reward_params"] = source_params
 
     # Validate critical algorithmic invariants
-    _validate_simulation_invariants(df)
+    _validate_simulation_invariants(df, params)
 
     return df
 
 
-def _validate_simulation_invariants(df: pd.DataFrame) -> None:
+def _validate_simulation_invariants(df: pd.DataFrame, params: RewardParams) -> None:
     """Fail fast if simulation violates action/state invariants."""
 
     eps_pnl = float(INTERNAL_GUARDS["sim_zero_pnl_epsilon"])
     eps_reward = float(INTERNAL_GUARDS["sim_zero_reward_epsilon"])
-    thr_extreme = float(INTERNAL_GUARDS["sim_extreme_pnl_threshold"])
-
-    # 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"
-        )
+    entry_fee_rate, exit_fee_rate = _get_fee_rates(params)
+    thr_extreme = max(
+        float(INTERNAL_GUARDS["sim_extreme_pnl_threshold"]),
+        (1.0 + entry_fee_rate) * (1.0 + exit_fee_rate) - 1.0,
+    )
+
+    # INVARIANT 1: masked sampling must only emit legal actions; unmasked
+    # actions may be invalid but cannot change position before terminal liquidation.
+    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 position")
 
     # INVARIANT 2: Duration logic
     neutral_with_trade = df[(df["position"] == 0.5) & (df["trade_duration"] > 0)]
@@ -1997,15 +2037,15 @@ def _validate_simulation_invariants(df: pd.DataFrame) -> None:
         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])
@@ -2016,7 +2056,7 @@ def _validate_simulation_invariants(df: pd.DataFrame) -> None:
     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(
@@ -2024,7 +2064,8 @@ def _validate_simulation_invariants(df: pd.DataFrame) -> None:
         )
 
     # INVARIANT 5: Bounded values
-    extreme_pnl = df[(df["pnl"].abs() > thr_extreme)]
+    # Fee-adjusted prices can differ from the nominal fee product by a few ULPs.
+    extreme_pnl = df[df["pnl"].abs() > thr_extreme + eps_pnl]
     if len(extreme_pnl) > 0:
         max_abs_pnl = float(df["pnl"].abs().max())
         raise AssertionError(
@@ -2563,6 +2604,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 = {
@@ -2606,6 +2656,10 @@ def _stabilize_divergence(value: float, *, bin_count: int, metric_name: str) ->
     return value
 
 
+_DISTRIBUTION_SHIFT_FEATURES: Final[tuple[str, ...]] = ("pnl", "trade_duration", "idle_duration")
+_MIN_DISTRIBUTION_SHIFT_OBSERVATIONS: Final[int] = 10
+
+
 def compute_distribution_shift_metrics(
     synthetic_df: pd.DataFrame,
     real_df: pd.DataFrame,
@@ -2621,13 +2675,19 @@ def compute_distribution_shift_metrics(
     reported. Constants yield exact zero distances and, inferentially, p=1.0.
     """
     metrics = {}
-    continuous_features = ["pnl", "trade_duration", "idle_duration"]
+    continuous_features = _DISTRIBUTION_SHIFT_FEATURES
 
     for feature in continuous_features:
-        synth_values = synthetic_df[feature].dropna().values
-        real_values = real_df[feature].dropna().values
-
-        if len(synth_values) < 10 or len(real_values) < 10:
+        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 min(len(synth_values), len(real_values)) < _MIN_DISTRIBUTION_SHIFT_OBSERVATIONS:
             continue
 
         min_val = min(synth_values.min(), real_values.min())
@@ -2817,7 +2877,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))
 
@@ -2946,6 +3006,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)
@@ -3713,8 +3775,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",
@@ -3878,6 +3940,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 = (
@@ -4039,6 +4105,23 @@ 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"
+    )
+
+    if (
+        real_df is not None
+        and not distribution_shift
+        and any(
+            np.isfinite(df[feature].to_numpy(dtype=float, na_value=np.nan)).any()
+            and np.isfinite(real_df[feature].to_numpy(dtype=float, na_value=np.nan)).any()
+            for feature in _DISTRIBUTION_SHIFT_FEATURES
+        )
+    ):
+        distribution_shift_unavailable = (
+            "insufficient finite observations; "
+            f"at least {_MIN_DISTRIBUTION_SHIFT_OBSERVATIONS} per dataset and feature required"
+        )
 
     # Write comprehensive report
     with report_path.open("w", encoding="utf-8") as f:
@@ -4606,7 +4689,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")
@@ -4636,7 +4719,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")
@@ -4672,7 +4757,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 82733cf846618d331ebdf399ca8da634ee8f83bb..4b0bc00e42739643f51c78d752cc85619572c65f 100644 (file)
@@ -35,8 +35,9 @@ Markers are declared in `pyproject.toml` and enforced with `--strict-markers`.
 
 ## Test Framework
 
-The test suite uses **pytest as the runner** with **unittest.TestCase as the
-base class** (via `RewardSpaceTestBase`).
+The suite runs under **pytest**. Most class-based tests inherit from
+`RewardSpaceTestBase` (a `unittest.TestCase` subclass); others are standalone
+pytest functions.
 
 ### Hybrid Approach Rationale
 
@@ -51,7 +52,7 @@ This design provides:
 
 ### Base Class
 
-All test classes inherit from `RewardSpaceTestBase` (defined in `test_base.py`):
+Class-based tests normally inherit from `RewardSpaceTestBase` (in `test_base.py`):
 
 ```python
 from ..test_base import RewardSpaceTestBase
@@ -172,68 +173,80 @@ uv run --locked --extra dev pytest -m "statistics and slow" -q
 
 Columns:
 
-- ID: Stable identifier (`<category>-<shortname>-NNN`).
+- ID: Stable identifier (`<category>-<shortname>-NNN`; optional letter for
+  split invariants).
 - Category: Taxonomy directory marker.
 - Description: Concise invariant statement.
-- Owning File: Path:line of primary declaration (prefer comment line
-  `# Owns invariant:` when present; otherwise docstring line).
-- Notes: Clarifications (sub-modes, extensions, non-owning references elsewhere,
-  line clusters for multi-path coverage).
-
-| ID                                            | Category    | Description                                                                         | Owning File                               | Notes                                                                                                                                                                          |
-| --------------------------------------------- | ----------- | ----------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| report-additives-deterministic-092            | components  | Additives deterministic report section                                              | components/test_additives.py:4            | 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:43          | 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:654         | Comment line (function at :655)                                                                                                                                                |
-| robustness-negative-grace-clamp-103           | robustness  | Negative exit_plateau_grace clamps to 0.0 w/ warning                                | robustness/test_robustness.py:696         |                                                                                                                                                                                |
-| robustness-invalid-power-tau-104              | robustness  | Invalid power tau falls back alpha=1.0 w/ warning                                   | robustness/test_robustness.py:747         |                                                                                                                                                                                |
-| robustness-near-zero-half-life-105            | robustness  | Near-zero half life yields no attenuation (factor≈base)                             | robustness/test_robustness.py:792         |                                                                                                                                                                                |
-| pbrs-canonical-exit-semantic-106              | pbrs        | Canonical exit uses shaping=-prev_potential and next_potential=0.0                  | pbrs/test_pbrs.py:374                     | 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:42          | Docstring line                                                                                                                                                                 |
-| helpers-transitions-preserve-multiplicity-108 | helpers     | Repeated transitions retain their empirical multiplicity                            | helpers/test_utilities.py:26              |                                                                                                                                                                                |
-| helpers-missing-cols-fill-109                 | helpers     | Missing required columns filled with NaN + single warning                           | helpers/test_utilities.py:51              | Docstring line                                                                                                                                                                 |
-| statistics-binned-stats-min-edges-110         | statistics  | <2 bin edges raises ValueError                                                      | statistics/test_statistics.py:60          | Docstring line                                                                                                                                                                 |
-| statistics-constant-cols-exclusion-111        | statistics  | Constant columns excluded & listed                                                  | statistics/test_statistics.py:71          | Docstring line                                                                                                                                                                 |
-| statistics-degenerate-distribution-shift-112  | statistics  | Constants: zero distances; KS p only with declared independent observations         | statistics/test_statistics.py:87          | Docstring line                                                                                                                                                                 |
-| statistics-constant-dist-exact-ci-113a        | statistics  | Both modes retain exact constant CI bounds                                          | statistics/test_statistics.py:606         |                                                                                                                                                                                |
-| statistics-percentile-outside-mean-113b       | statistics  | Percentile bounds need not contain the sample mean                                  | statistics/test_statistics.py:620         |                                                                                                                                                                                |
-| statistics-constant-diagnostics-115           | statistics  | Constants have N/A higher moments, normality tests and Q-Q fits in both modes       | statistics/test_statistics.py:194         |                                                                                                                                                                                |
-| pbrs-canonical-near-zero-report-116           | pbrs        | Canonical trajectories with valid evidence are classified as verified               | pbrs/test_pbrs.py:1509                    | 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:127         | Comment line                                                                                                                                                                   |
-| pbrs-absence-shift-placeholder-118            | pbrs        | Placeholder shift line present when shaping shift is absent                         | pbrs/test_pbrs.py:1877                    |                                                                                                                                                                                |
-| components-pbrs-breakdown-fields-119          | components  | PBRS breakdown fields finite and mathematically aligned                             | components/test_reward_components.py:830  | 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:137 |                                                                                                                                                                                |
-| cli-pbrs-csv-columns-121                      | cli         | PBRS columns in reward_samples.csv when shaping enabled                             | cli/test_cli_params_and_csv.py:347        | 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:499         |                                                                                                                                                                                |
-| statistics-independence-contract-123          | statistics  | Inferential helpers require independent_observations=True                           | statistics/test_statistics.py:636         | 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:28  | 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:1699                    | 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:119         | 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:1276                    | 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:1296                    | 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:322        | 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:49         | 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:475                     | Preserves the invalid raw value and reports effective additive settings as unknown                                                                                             |
+- Owning test: Relative source path and function name (`path.py::test_name`),
+  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                                                                                   |
+| 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                                                                                                                                                      |
+| api-unmasked-sample-probabilities-144         | api         | Unmasked sample probabilities match marginal valid-action frequencies                | api/test_api_helpers.py::test_unmasked_sampling_probabilities_match_action_frequencies              | Spot/futures entries, long/short exits, and neutral probability                                                                                                                |
 
 ### Non-Owning Smoke / Reference Checks
 
-Files that reference invariant outcomes (formatting, aggregation) without owning
-the invariant must include a leading comment:
+Tests that check an invariant owned elsewhere identify its owner in a comment
+or docstring, for example:
 
 ```python
 # Non-owning smoke; ownership: <owning file>
 ```
 
-Table tracks approximate line ranges and source ownership:
+The following tests also check outcomes owned elsewhere:
 
-| File                                   | Lines (approx) | References                                           | Ownership Source                                                    |
-| -------------------------------------- | -------------- | ---------------------------------------------------- | ------------------------------------------------------------------- |
-| integration/test_reward_calculation.py | 44             | Decomposition identity (sum components)              | robustness/test_robustness.py:43                                    |
-| components/test_reward_components.py   | 551            | Exit factor finiteness & plateau behavior            | robustness/test_robustness.py:43+                                   |
-| pbrs/test_pbrs.py                      | 1053           | Canonical vs non-canonical classification formatting | robustness/test_robustness.py:43, robustness/test_robustness.py:127 |
-| pbrs/test_pbrs.py                      | 1415           | Non-canonical full report reason aggregation         | robustness/test_robustness.py:43                                    |
-| pbrs/test_pbrs.py                      | 1469           | Non-canonical mode-only reason (additives disabled)  | robustness/test_robustness.py:43                                    |
-| statistics/test_statistics.py          | 292            | Mean decomposition consistency                       | robustness/test_robustness.py:43                                    |
+| Non-owning test                                                                | Reference check                             | Owning test                                                                                              |
+| ------------------------------------------------------------------------------ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
+| integration/test_reward_calculation.py::test_reward_component_activation_smoke | Core component activation and decomposition | robustness/test_robustness.py::test_decomposition_integrity                                              |
+| components/test_reward_components.py::test_exit_factor_calculation             | Exit factor modes and plateau behavior      | robustness/test_robustness.py::test_exit_factor_comprehensive; test_plateau_continuity_at_grace_boundary |
+| pbrs/test_pbrs.py::test_pbrs_canonical_near_zero_report                        | Canonical classification and decomposition  | robustness/test_robustness.py::test_decomposition_integrity                                              |
+| pbrs/test_pbrs.py::test_pbrs_non_canonical_full_report_reason_aggregation      | Non-canonical report reasons                | robustness/test_robustness.py::test_decomposition_integrity                                              |
+| pbrs/test_pbrs.py::test_pbrs_non_canonical_mode_only_reason                    | Non-canonical exit mode without additives   | robustness/test_robustness.py::test_decomposition_integrity                                              |
+| statistics/test_statistics.py::test_stats_mean_decomposition_consistency       | Mean decomposition consistency              | robustness/test_robustness.py::test_decomposition_integrity                                              |
 
 ### Deprecated / Reserved IDs
 
index f13a68344ffd87eece9a225b9603ddfd2487761c..2b76b1911ac40d79da01d1999a2dfd63ebab26be 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
@@ -82,6 +83,41 @@ class TestAPIAndHelpers(RewardSpaceTestBase):
         )
         self.assertGreater(high_idle_rate_spot, low_idle_rate_spot)
 
+    def test_unmasked_sampling_probabilities_match_action_frequencies(self):
+        """Reported probabilities describe valid actions, not their conditional hazards."""
+        draws = SCENARIOS.API_ENTRY_RATE_DRAWS
+        # Four standard errors using the maximum Bernoulli variance.
+        tolerance = 4 * math.sqrt(0.25 / draws)
+        cases = (
+            (Positions.Neutral, False, (Actions.Long_enter,), 1),
+            (Positions.Neutral, True, (Actions.Long_enter, Actions.Short_enter), 1),
+            (Positions.Long, True, (Actions.Long_exit,), 2),
+            (Positions.Short, True, (Actions.Short_exit,), 2),
+        )
+        for position, short_allowed, actions, probability_index in cases:
+            with self.subTest(position=position, short_allowed=short_allowed):
+                rng = random.Random(SEEDS.REPRODUCIBILITY)
+                samples = [
+                    _sample_action(
+                        position,
+                        rng,
+                        short_allowed=short_allowed,
+                        trade_duration=SCENARIOS.API_IDLE_DURATION_HIGH,
+                        max_trade_duration_candles=SCENARIOS.API_MAX_IDLE_DURATION_CANDLES,
+                        idle_duration=SCENARIOS.API_IDLE_DURATION_HIGH,
+                        max_idle_duration_candles=SCENARIOS.API_MAX_IDLE_DURATION_CANDLES,
+                        action_masking=False,
+                    )
+                    for _ in range(draws)
+                ]
+                observed = sum(sample[0] in actions for sample in samples) / draws
+                self.assertAlmostEqual(samples[0][probability_index], observed, delta=tolerance)
+                if position == Positions.Neutral:
+                    observed_neutral = (
+                        sum(sample[0] == Actions.Neutral for sample in samples) / draws
+                    )
+                    self.assertAlmostEqual(samples[0][3], observed_neutral, delta=tolerance)
+
     def test_parse_overrides(self):
         """Overrides accept canonical keys and reject unsupported keys."""
         result = parse_overrides(
@@ -191,24 +227,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 183a306a246246bed7d73e869f97356b6817afef..7f2eb92e0967f7b0aadf29bc2f8d271dd129ecb4 100644 (file)
@@ -169,32 +169,32 @@ class TestSimulationParity(RewardSpaceTestBase):
                 expected_duration = 0
             self.assertEqual(row.trade_duration, expected_duration)
 
-    def test_first_retained_pnl_is_only_exit_extremum(self):
-        """A first-candle exit excludes the fill-time PnL from its extrema."""
+    def test_synthetic_exit_reward_includes_fee_adjusted_entry_pnl(self):
         params = self.base_params(
             unrealized_pnl=False,
             max_trade_duration_candles=100,
-            entry_fee_rate=0.0,
-            exit_fee_rate=0.0,
+            entry_fee_rate=0.0015,
+            exit_fee_rate=0.0015,
+            exit_attenuation_mode="linear",
+            exit_plateau=False,
             hold_potential_enabled=False,
             entry_additive_enabled=False,
             exit_additive_enabled=False,
         )
         actions = [
             (Actions.Long_enter, 1.0, 0.0, 0.0),
+            (Actions.Neutral, 0.0, 0.0, 1.0),
             (Actions.Long_exit, 0.0, 1.0, 0.0),
         ]
         with (
             patch.object(reward_space_analysis, "_sample_action", side_effect=actions),
             patch.object(
-                reward_space_analysis.random.Random,
-                "gauss",
-                side_effect=[0.02, 0.0],
+                reward_space_analysis.random.Random, "gauss", side_effect=[0.02, -0.01, 0.0]
             ),
         ):
             df = simulate_samples(
                 params=params,
-                num_samples=2,
+                num_samples=3,
                 seed=SEEDS.BASE,
                 base_factor=PARAMS.BASE_FACTOR,
                 profit_aim=PARAMS.PROFIT_AIM,
@@ -205,34 +205,239 @@ class TestSimulationParity(RewardSpaceTestBase):
                 pnl_duration_vol_scale=PARAMS.PNL_DUR_VOL_SCALE,
             )
 
-        exit_row = df.iloc[1]
+        entry_pnl = 1.0 / ((1.0 + params["entry_fee_rate"]) * (1.0 + params["exit_fee_rate"])) - 1.0
+        peak_pnl = float(df.iloc[0]["next_pnl"])
+        exit_row = df.iloc[-1]
         pnl = float(exit_row["pnl"])
+        self.assertLess(entry_pnl, 0.0)
+        self.assertGreater(peak_pnl, pnl)
         self.assertGreater(pnl, 0.0)
-        runtime_context = reward_space_analysis.RewardContext(
-            current_pnl=pnl,
-            trade_duration=int(exit_row["trade_duration"]),
-            idle_duration=0,
-            max_unrealized_profit=pnl,
-            min_unrealized_profit=pnl,
-            position=Positions.Long,
-            action=Actions.Long_exit,
+        efficiency = 1.0 + params["efficiency_weight"] * (
+            (pnl - entry_pnl) / (peak_pnl - entry_pnl) - params["efficiency_center"]
+        )
+        duration_ratio = float(exit_row["trade_duration"]) / params["max_trade_duration_candles"]
+        expected = (
+            pnl
+            * params["base_factor"]
+            / (1.0 + params["exit_linear_slope"] * duration_ratio)
+            * efficiency
         )
-        expected_exit = reward_space_analysis.calculate_reward(
-            runtime_context,
-            params,
-            PARAMS.BASE_FACTOR,
-            PARAMS.PROFIT_AIM,
-            PARAMS.RISK_REWARD_RATIO,
-            short_allowed=True,
-            action_masking=True,
-        ).exit_component
         self.assertAlmostEqualFloat(
             float(exit_row["reward_exit"]),
-            expected_exit,
+            expected,
             tolerance=TOLERANCE.IDENTITY_RELAXED,
             rtol=TOLERANCE.RELATIVE,
         )
 
+    def test_synthetic_fee_loss_extrema_match_retained_pnl(self):
+        for direction in ("long", "short"):
+            for transformed in (False, True):
+                with self.subTest(direction=direction, transformed=transformed):
+                    params = self.base_params(
+                        entry_fee_rate=0.1,
+                        exit_fee_rate=0.1,
+                        unrealized_pnl=transformed,
+                        max_trade_duration_candles=1,
+                        win_reward_factor=0.0,
+                        exit_plateau=False,
+                        hold_potential_enabled=False,
+                        exit_linear_slope=0.0,
+                        entry_additive_enabled=False,
+                        exit_additive_enabled=False,
+                    )
+                    enter = Actions.Long_enter if direction == "long" else Actions.Short_enter
+                    exit_action = Actions.Long_exit if direction == "long" else Actions.Short_exit
+                    # Cancel the simulator's directional drift to keep both marks flat.
+                    innovation = -0.001 if direction == "long" else 0.001
+                    with (
+                        patch.object(
+                            reward_space_analysis,
+                            "_sample_action",
+                            side_effect=[(enter, 1.0, 0.0, 0.0), (exit_action, 0.0, 1.0, 0.0)],
+                        ),
+                        patch.object(
+                            reward_space_analysis.random.Random,
+                            "gauss",
+                            side_effect=[innovation, 0.0],
+                        ),
+                    ):
+                        samples = simulate_samples(
+                            num_samples=2,
+                            seed=SEEDS.BASE,
+                            params=params,
+                            base_factor=PARAMS.BASE_FACTOR,
+                            profit_aim=PARAMS.PROFIT_AIM,
+                            risk_reward_ratio=PARAMS.RISK_REWARD_RATIO,
+                            max_duration_ratio=2.0,
+                            trading_mode="futures",
+                            pnl_base_std=PARAMS.PNL_STD,
+                            pnl_duration_vol_scale=PARAMS.PNL_DUR_VOL_SCALE,
+                        )
+
+                    fees = (1.0 + params["entry_fee_rate"]) * (1.0 + params["exit_fee_rate"])
+                    entry_pnl = 1.0 / fees - 1.0 if direction == "long" else 1.0 - fees
+                    expected_pnl = (
+                        entry_pnl * math.tanh(params["pnl_amplification_sensitivity"])
+                        if transformed
+                        else entry_pnl
+                    )
+                    self.assertLess(expected_pnl, -0.15)
+                    first, exit_row = samples.iloc[0], samples.iloc[1]
+                    self.assertAlmostEqual(float(first["next_pnl"]), expected_pnl, places=9)
+                    self.assertAlmostEqual(float(exit_row["exit_pnl"]), expected_pnl, places=9)
+                    efficiency = (
+                        1.0 + params["efficiency_weight"] * (params["efficiency_center"] - 1.0)
+                        if transformed
+                        else 1.0
+                    )
+                    self.assertAlmostEqual(
+                        float(exit_row["reward_exit"]),
+                        expected_pnl * params["base_factor"] * efficiency,
+                        places=8,
+                    )
+
+    def test_synthetic_high_fee_winner_retains_profitable_mark(self):
+        """Keep favorable retained PnL and exit reward positive with high fees.
+
+        **Invariant:** pbrs-synthetic-profitable-mark-133
+        """
+        for direction, innovation in (("long", 0.299), ("short", -0.299)):
+            for flat_first in (False, True):
+                with self.subTest(direction=direction, flat_first=flat_first):
+                    params = self.base_params(
+                        unrealized_pnl=True,
+                        entry_fee_rate=0.1,
+                        exit_fee_rate=0.1,
+                        max_trade_duration_candles=1,
+                    )
+                    enter = Actions.Long_enter if direction == "long" else Actions.Short_enter
+                    exit_action = Actions.Long_exit if direction == "long" else Actions.Short_exit
+                    actions = [(enter, 1.0, 0.0, 0.0)]
+                    # The directional drift completes a 30% favorable market move.
+                    innovations = [innovation]
+                    if flat_first:
+                        actions.append((Actions.Neutral, 0.0, 0.0, 1.0))
+                        innovations = [-0.001 if direction == "long" else 0.001, innovation]
+                    actions.append((exit_action, 0.0, 1.0, 0.0))
+                    innovations.append(0.0)
+                    with (
+                        patch.object(reward_space_analysis, "_sample_action", side_effect=actions),
+                        patch.object(
+                            reward_space_analysis.random.Random,
+                            "gauss",
+                            side_effect=innovations,
+                        ),
+                    ):
+                        samples = simulate_samples(
+                            num_samples=len(actions),
+                            seed=SEEDS.BASE,
+                            params=params,
+                            base_factor=PARAMS.BASE_FACTOR,
+                            profit_aim=PARAMS.PROFIT_AIM,
+                            risk_reward_ratio=PARAMS.RISK_REWARD_RATIO,
+                            max_duration_ratio=2.0,
+                            trading_mode="futures",
+                            pnl_base_std=PARAMS.PNL_STD,
+                            pnl_duration_vol_scale=PARAMS.PNL_DUR_VOL_SCALE,
+                        )
+                    if flat_first:
+                        self.assertLess(float(samples.iloc[0]["next_pnl"]), 0.0)
+                    self.assertGreater(float(samples.iloc[-2]["next_pnl"]), 0.0)
+                    self.assertGreater(float(samples.iloc[-1]["exit_pnl"]), 0.0)
+                    self.assertGreater(float(samples.iloc[-1]["reward_exit"]), 0.0)
+
+    def test_synthetic_high_fee_short_boundary_rejects_real_excess(self):
+        """Accept rounding at the fee bound but reject a material loss beyond it.
+
+        **Invariant:** pbrs-synthetic-fee-boundary-134
+        """
+        params = self.base_params(
+            entry_fee_rate=0.1,
+            exit_fee_rate=0.1,
+            max_trade_duration_candles=1,
+        )
+        with (
+            patch.object(
+                reward_space_analysis,
+                "_sample_action",
+                side_effect=[
+                    (Actions.Neutral, 0.0, 0.0, 1.0),
+                    (Actions.Short_enter, 1.0, 0.0, 0.0),
+                    (Actions.Short_exit, 0.0, 1.0, 0.0),
+                ],
+            ),
+            patch.object(
+                reward_space_analysis.random.Random,
+                "gauss",
+                # Neutral move sets entry price to 1.03; next move cancels short drift.
+                side_effect=[0.03, 0.001, 0.0],
+            ),
+        ):
+            samples = simulate_samples(
+                num_samples=3,
+                seed=SEEDS.BASE,
+                params=params,
+                base_factor=PARAMS.BASE_FACTOR,
+                profit_aim=PARAMS.PROFIT_AIM,
+                risk_reward_ratio=PARAMS.RISK_REWARD_RATIO,
+                max_duration_ratio=2.0,
+                trading_mode="futures",
+                pnl_base_std=PARAMS.PNL_STD,
+                pnl_duration_vol_scale=PARAMS.PNL_DUR_VOL_SCALE,
+            )
+        fee_factor = (1.0 + params["entry_fee_rate"]) * (1.0 + params["exit_fee_rate"])
+        self.assertAlmostEqual(float(samples.iloc[-1]["pnl"]), 1.0 - fee_factor, places=12)
+        beyond_bound = samples.copy()
+        beyond_bound.loc[beyond_bound.index[-1], "pnl"] = 1.0 - fee_factor - 0.001
+        with self.assertRaisesRegex(AssertionError, "extreme PnL"):
+            reward_space_analysis._validate_simulation_invariants(beyond_bound, params)
+
+    def test_unrealized_pnl_retains_sampled_market_path_after_candidate_cap(self):
+        """Keep distinct market paths after their first retained PnL is capped.
+
+        **Invariant:** pbrs-synthetic-latent-price-135
+        """
+        params = self.base_params(unrealized_pnl=True, max_trade_duration_candles=1)
+        actions = [
+            (Actions.Long_enter, 1.0, 0.0, 0.0),
+            (Actions.Neutral, 0.0, 0.0, 1.0),
+            (Actions.Long_exit, 0.0, 1.0, 0.0),
+        ]
+
+        def sample(first_return: float) -> pd.DataFrame:
+            with (
+                patch.object(reward_space_analysis, "_sample_action", side_effect=actions),
+                patch.object(
+                    reward_space_analysis.random.Random,
+                    "gauss",
+                    # Both first candidates clip at +0.15; the following move is -30%.
+                    side_effect=[first_return, -0.301, 0.0],
+                ),
+            ):
+                return simulate_samples(
+                    num_samples=len(actions),
+                    seed=SEEDS.BASE,
+                    params=params,
+                    base_factor=PARAMS.BASE_FACTOR,
+                    profit_aim=PARAMS.PROFIT_AIM,
+                    risk_reward_ratio=PARAMS.RISK_REWARD_RATIO,
+                    max_duration_ratio=2.0,
+                    trading_mode="futures",
+                    pnl_base_std=PARAMS.PNL_STD,
+                    pnl_duration_vol_scale=PARAMS.PNL_DUR_VOL_SCALE,
+                )
+
+        lower_price = sample(0.599)
+        higher_price = sample(0.949)
+        self.assertAlmostEqual(
+            float(lower_price.iloc[0]["next_pnl"]), float(higher_price.iloc[0]["next_pnl"])
+        )
+        self.assertGreater(float(lower_price.iloc[1]["next_pnl"]), 0.0)
+        self.assertGreater(
+            float(higher_price.iloc[1]["next_pnl"]),
+            float(lower_price.iloc[1]["next_pnl"]),
+        )
+
     def test_unrealized_pnl_uses_each_sampled_market_move(self):
         """Later Gaussian innovations affect later retained PnL without becoming extrema."""
         params = self.base_params(
@@ -334,8 +539,8 @@ class TestSimulationParity(RewardSpaceTestBase):
         )
         self.assertTrue(replay["next_pnl"].equals(df["next_pnl"]))
 
-    def test_unrealized_pnl_exit_rewards_use_only_retained_extrema(self):
-        """Exit rewards use extrema reconstructed from the retained PnL trajectory."""
+    def test_unrealized_pnl_exit_rewards_include_fill_and_retained_extrema(self):
+        """The fee-adjusted fill and retained prices determine exit efficiency."""
         params = self.base_params(
             unrealized_pnl=True,
             max_trade_duration_candles=100,
@@ -378,8 +583,11 @@ class TestSimulationParity(RewardSpaceTestBase):
                 Actions.Long_enter,
                 Actions.Short_enter,
             ):
-                max_unrealized = -np.inf
-                min_unrealized = np.inf
+                fee_factor = (1.0 + params["entry_fee_rate"]) * (1.0 + params["exit_fee_rate"])
+                entry_pnl = (
+                    1.0 / fee_factor - 1.0 if action == Actions.Long_enter else 1.0 - fee_factor
+                )
+                max_unrealized = min_unrealized = entry_pnl
 
             if position in (Positions.Long, Positions.Short) and action in (
                 Actions.Long_exit,
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 b545b733610cd1d56722c12de53707c6ce10dcf3..1fc5f210c7d991ce51d509e066a2a2f5680127c8 100644 (file)
@@ -6,7 +6,7 @@ import unittest
 import numpy as np
 import pandas as pd
 
-from ReforceXY.user_data.freqaimodels.ReforceXY import Actions, MyRLEnv
+from ReforceXY.user_data.freqaimodels.ReforceXY import Actions, MyRLEnv, ReforceXY
 
 
 class PortfolioReturnsTest(unittest.TestCase):
@@ -46,6 +46,46 @@ class PortfolioReturnsTest(unittest.TestCase):
             self.assertEqual(info["most_recent_profit"], 0.0)
         self.assertAlmostEqual(np.expm1(env.portfolio_log_returns.sum()), env._total_profit - 1)
 
+    def test_exit_reward_includes_fee_adjusted_entry_extremum(self):
+        for short, mark in ((False, 110.0), (True, 90.0)):
+            with self.subTest(short=short):
+                env = self.make_env([100.0, 100.0, mark, mark, mark])
+                entry = Actions.Short_enter if short else Actions.Long_enter
+                exit_action = Actions.Short_exit if short else Actions.Long_exit
+                fee_factor = (1.0 + env.fee) ** 2
+                entry_pnl = 1.0 - fee_factor if short else 1.0 / fee_factor - 1.0
+                exit_pnl = (
+                    1.0 - mark / 100.0 * fee_factor if short else mark / 100.0 / fee_factor - 1.0
+                )
+
+                env.step(entry.value)
+                self.assertAlmostEqual(env.get_min_unrealized_profit(), entry_pnl)
+                self.assertAlmostEqual(env.get_max_unrealized_profit(), exit_pnl)
+                _, reward, _, _, _ = env.step(exit_action.value)
+
+                duration = 1.0 / env.max_trade_duration_candles
+                if ReforceXY.DEFAULT_EXIT_PLATEAU:
+                    duration = max(0.0, duration - ReforceXY.DEFAULT_EXIT_PLATEAU_GRACE)
+                attenuation = 1.0 / (1.0 + ReforceXY.DEFAULT_EXIT_LINEAR_SLOPE * duration)
+                target_coefficient = 1.0
+                if exit_pnl > env._pnl_target:
+                    target_coefficient += ReforceXY.DEFAULT_WIN_REWARD_FACTOR * math.tanh(
+                        ReforceXY.DEFAULT_PNL_AMPLIFICATION_SENSITIVITY
+                        * (exit_pnl / env._pnl_target - 1.0)
+                    )
+                efficiency = 1.0 + ReforceXY.DEFAULT_EFFICIENCY_WEIGHT * (
+                    1.0 - ReforceXY.DEFAULT_EFFICIENCY_CENTER
+                )
+                self.assertAlmostEqual(
+                    reward,
+                    exit_pnl
+                    * ReforceXY.DEFAULT_BASE_FACTOR
+                    * attenuation
+                    * target_coefficient
+                    * efficiency,
+                    places=9,
+                )
+
     def test_transitions_match_equity_for_both_staking_modes_and_directions(self):
         prices = [100.0, 100.0, 110.0, 105.0, 115.0, 120.0, 118.0, 117.0, 117.0]
         for short in (False, True):
@@ -138,7 +178,6 @@ class PortfolioReturnsTest(unittest.TestCase):
                             env.trade_history[-1]["type"],
                             "short_exit" if short else "long_exit",
                         )
-                        self.assertEqual(env.trade_history[-1]["tick"], info["execution_tick"])
                         self.assertEqual(env.trade_history[-1]["price"], terminal_price)
                         self.assertAlmostEqual(env.trade_history[-1]["profit"], expected_pnl)
                         self.assertAlmostEqual(
@@ -212,7 +251,7 @@ class PortfolioReturnsTest(unittest.TestCase):
                 else:
                     pd.testing.assert_frame_equal(history, baseline)
 
-    def test_terminal_entry_preserves_two_events_and_plot_markers(self):
+    def test_terminal_fill_events_and_plot_match_their_price_candles(self):
         env = self.make_env([100.0, 100.0, 90.0])
         _, _, terminated, truncated, info = env.step(Actions.Long_enter.value)
         expected_pnl = 90.0 / 100.0 / (1.0 + env.fee) ** 2 - 1.0
@@ -225,26 +264,29 @@ class PortfolioReturnsTest(unittest.TestCase):
         self.assertEqual(history.iloc[0]["execution_tick"], info["execution_tick"])
         self.assertEqual(history.iloc[0]["open"], 90.0)
 
-        self.assertEqual(len(env.trade_history), 2)
-        self.assertEqual(
-            [(event["tick"], event["type"]) for event in env.trade_history],
-            [(info["execution_tick"], "long_enter"), (info["execution_tick"], "long_exit")],
-        )
-        self.assertEqual(
-            [event["price"] for event in env.trade_history],
-            [100.0, 90.0],
-        )
-        self.assertEqual(env.trade_history[0]["profit"], 0.0)
-        self.assertAlmostEqual(env.trade_history[1]["profit"], expected_pnl)
+        events = env.trade_history
+        self.assertEqual([event["type"] for event in events], ["long_enter", "long_exit"])
+        for event in events:
+            self.assertAlmostEqual(event["price"], env.prices.iloc[event["tick"]]["open"])
+        self.assertEqual(events[0]["profit"], 0.0)
+        self.assertAlmostEqual(events[1]["profit"], expected_pnl)
 
         figure = env.get_env_plot()
-        marker_lines = [
+        curve = figure.axes[0].lines[0]
+        markers = [
             line
             for line in figure.axes[0].lines
             if line.get_linestyle() == "None" and len(line.get_xdata()) == 1
         ]
-        self.assertEqual([line.get_marker() for line in marker_lines], ["^", "."])
-        self.assertEqual([line.get_xdata()[0] for line in marker_lines], [1, 1])
+        self.assertEqual([line.get_marker() for line in markers], ["^", "."])
+        self.assertEqual(
+            [(line.get_xdata()[0], line.get_ydata()[0]) for line in markers],
+            [(event["tick"], event["price"]) for event in events],
+        )
+        self.assertEqual(
+            (markers[-1].get_xdata()[0], markers[-1].get_ydata()[0]),
+            (curve.get_xdata()[-1], curve.get_ydata()[-1]),
+        )
 
     def test_nonpositive_equity_is_not_reported_as_zero_return(self):
         env = self.make_env([100.0, 100.0, 100.0, 300.0, 300.0], fee=0.0)
index 09c00b8cee84b692ca662f76f06a09432d95911d..59ad196d3942f63d64cbd6f7f1693728928964e5 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,64 @@ 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_strategy_leverage_warnings_follow_invalid_config_transitions(self):
+        """Warn once per invalid setting, including values below the leverage floor."""
+        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",
+        }
+        strategy.config = {"leverage": float("nan")}
+        with self.assertLogs(RLAgentStrategy.__module__, level="WARNING") as logs:
+            for _ in range(3):
+                self.assertEqual(strategy.leverage(**arguments), 2.0)
+            self.assertEqual(len(logs.records), 1)
+            strategy.config["leverage"] = float("inf")
+            self.assertEqual(strategy.leverage(**arguments), 2.0)
+            self.assertEqual(len(logs.records), 2)
+            strategy.config["leverage"] = 0.5
+            for _ in range(2):
+                self.assertEqual(strategy.leverage(**arguments), 1.0)
+            self.assertEqual(len(logs.records), 3)
+            strategy.config["leverage"] = 2.5
+            self.assertEqual(strategy.leverage(**arguments), 2.5)
+            strategy.config["leverage"] = float("nan")
+            self.assertEqual(strategy.leverage(**arguments), 2.0)
+            self.assertEqual(len(logs.records), 4)
+
     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:
@@ -667,16 +726,11 @@ class ReviewContractsTest(unittest.TestCase):
         )
         self.assertAlmostEqual(entry_reward, env._potential_gamma * expected_potential)
 
-        _, exit_reward, exit_done, exit_truncated, _ = env.step(2)
+        _, _, exit_done, exit_truncated, _ = env.step(2)
         self.assertFalse(exit_done)
         self.assertFalse(exit_truncated)
         self.assertEqual(env.trade_history[-1]["tick"], 3)
         self.assertEqual(env.trade_history[-1]["price"], 110.0)
-        pnl_coefficient = 1.0 + 2.0 * math.tanh(2.0 * (observed_pnl / env._pnl_target - 1.0))
-        # The entry transition retains one PnL observation, so max equals min
-        # and the independent efficiency formula remains at its neutral value 1.0.
-        expected_base_exit = observed_pnl * ReforceXY.DEFAULT_BASE_FACTOR * pnl_coefficient
-        self.assertAlmostEqual(exit_reward, expected_base_exit - expected_potential, places=5)
 
         env.step(1)
         _, _, done, truncated, info = env.step(0)
index 6fa931a56ecc3a467866f2bff520fbec2b116e86..df0e0327e77ac3913ae7cd35c17fbe2a70937216 100644 (file)
@@ -8,7 +8,9 @@ from unittest import mock
 
 import numpy as np
 import pandas as pd
+from freqtrade.enums import RunMode
 from freqtrade.exceptions import DependencyException
+from freqtrade.exchange import timeframe_to_seconds
 from freqtrade.freqai.data_drawer import FreqaiDataDrawer
 from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
 from optuna import TrialPruned, create_study
@@ -120,6 +122,116 @@ class TrainingObservationsTest(unittest.TestCase):
                     )
                 self.assertTrue(np.isfinite(score))
 
+    def test_backtest_rejects_future_archive_but_continues_from_earlier_saved_window(self):
+        with tempfile.TemporaryDirectory() as temp:
+            config = model_config(temp)
+            info = config["freqai"]
+            info["continual_learning"] = True
+            info["rl_config"]["model_type"] = "DQN"
+            info["model_training_parameters"] = {
+                "learning_starts": 0,
+                "buffer_size": 128,
+                "batch_size": 8,
+                "train_freq": 4,
+                "gradient_steps": 1,
+                "device": "cpu",
+                "policy_kwargs": {"net_arch": [8]},
+            }
+            pair = "BTC/USDT"
+
+            def frame(day, feature_offset):
+                values = np.arange(64)
+                data = pd.DataFrame(
+                    {
+                        "date": pd.date_range(day, periods=64, freq="5min", tz="UTC"),
+                        "%-feature": np.sin(values) + feature_offset,
+                        "&-action": np.zeros(64),
+                    }
+                )
+                for column in ("open", "high", "low", "close"):
+                    data[f"%-raw_{column}"] = 100.0 + values * 0.1
+                return data
+
+            def kitchen(settings, data, *, live):
+                dk = FreqaiDataKitchen(settings, live=live, pair=pair)
+                timestamp = int(
+                    (
+                        data["date"].iloc[-1]
+                        + pd.Timedelta(seconds=timeframe_to_seconds(settings["timeframe"]))
+                    ).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 = ["&-action"]
+                dk.training_features_list = [column for column in data if column.startswith("%")]
+                return dk, timestamp
+
+            source = ReforceXY(config=config)
+            source.live = True
+            source.can_short = False
+            self.addCleanup(source.close_envs)
+            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/ReforceXY/user_data/config-template.json"
+            ]
+            backtest = ReforceXY(config=backtest_config)
+            backtest.live = False
+            backtest.can_short = False
+            self.addCleanup(backtest.close_envs)
+            saved_timestamp = 0
+            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 = frame(day, offset)
+                dk, timestamp = kitchen(backtest_config, training_frame, live=False)
+                self.assertLess(timestamp, future_ts)
+                self.assertFalse(backtest.model_exists(dk))
+                trained = backtest.train(training_frame, pair, dk)
+                transformed = dk.data_dictionary["train_features"]["%-feature"]
+                if offset < 20.0:
+                    self.assertAlmostEqual(transformed.min(), -1.0)
+                    self.assertAlmostEqual(transformed.max(), 1.0)
+                else:
+                    self.assertGreater(transformed.min(), 2.0)
+                backtest.dd.pair_dict[pair]["trained_timestamp"] = timestamp
+                if save_model:
+                    backtest.dd.save_data(trained, pair, dk)
+                    saved_timestamp = timestamp
+                else:
+                    backtest.dd.save_metadata(dk)
+
+            self.assertGreater(saved_timestamp, 0)
+            seconds_config = dict(backtest_config)
+            seconds_config["timeframe"] = "10s"
+            seconds = ReforceXY(config=seconds_config)
+            seconds.live = False
+            seconds.can_short = False
+            self.addCleanup(seconds.close_envs)
+            seconds.training_timerange = SimpleNamespace(stopts=saved_timestamp + 10)
+            boundary = frame("2026-01-02", 20.0)
+            boundary["date"] = pd.date_range(
+                end=pd.Timestamp(saved_timestamp, unit="s", tz="UTC"),
+                periods=len(boundary),
+                freq="10s",
+            )
+            boundary_dk, boundary_timestamp = kitchen(seconds_config, boundary, live=False)
+            self.assertEqual(boundary_timestamp, saved_timestamp + 10)
+            seconds.train(boundary, pair, boundary_dk)
+            transformed = boundary_dk.data_dictionary["train_features"]["%-feature"]
+            self.assertGreater(transformed.min(), 2.0)
+
     def test_provenance_does_not_change_other_drawers(self):
         with tempfile.TemporaryDirectory() as temp:
             config = model_config(temp)
index f12d205a1b4a374ad94c186ede66e93628d0d716..200135a60719da46d5007d08afacb5145b057486 100644 (file)
@@ -36,6 +36,7 @@ import rapidjson
 import torch as th
 from datasieve.pipeline import Pipeline
 from freqtrade.exceptions import DependencyException
+from freqtrade.exchange import timeframe_to_seconds
 from freqtrade.freqai.data_drawer import (
     FEATURE_PIPELINE,
     METADATA,
@@ -1281,7 +1282,7 @@ class ReforceXY(BaseReinforcementLearningModel):
         return callbacks
 
     def _resolve_deployment_state(
-        self, dk: FreqaiDataKitchen, pair: str
+        self, dk: FreqaiDataKitchen, pair: str, *, as_of_ts: int | None = None
     ) -> tuple[Any, Pipeline] | None:
         """Restore independent training copies of the deployed policy and feature pipeline."""
         if not self.continual_learning:
@@ -1291,6 +1292,19 @@ class ReforceXY(BaseReinforcementLearningModel):
         previous = self.dd.pair_dict.get(pair, {})
         if model is None and not previous.get("model_filename"):
             return None
+        if not self.live:
+            # The drawer timestamp may advance without saving a new policy.
+            # Bound the actual artifact before loading policy, pipeline or replay.
+            name, separator, timestamp = previous.get("model_filename", "").rpartition("_")
+            if (
+                as_of_ts is None
+                or not previous.get("trained_timestamp")
+                or not separator
+                or name != f"cb_{pair.split('/')[0].lower()}"
+                or not timestamp.isdecimal()
+                or int(timestamp) >= as_of_ts
+            ):
+                return None
         try:
             cached = self.dd.meta_data_dictionary.get(pair, {})
             metadata = cached.get(METADATA)
@@ -1432,7 +1446,14 @@ class ReforceXY(BaseReinforcementLearningModel):
         dk.fit_labels()
         # Capture prices once, before normalization and optional raw-OHLC removal.
         prices_train, prices_test = self.build_ohlc_price_dataframes(raw_data, pair, dk)
-        deployment_state = self._resolve_deployment_state(dk, pair)
+        as_of_ts = None
+        if self.continual_learning and not self.live:
+            latest_date = pd.to_datetime(unfiltered_df["date"], utc=True).max()
+            as_of_ts = int(latest_date.timestamp()) + timeframe_to_seconds(self.config["timeframe"])
+            training_timerange = getattr(self, "training_timerange", None)
+            if training_timerange is not None:
+                as_of_ts = min(as_of_ts, int(training_timerange.stopts))
+        deployment_state = self._resolve_deployment_state(dk, pair, as_of_ts=as_of_ts)
         dd = self._apply_training_pipeline(
             raw_data, dk, deployment_state=None if self.hyperopt else deployment_state
         )
@@ -4114,6 +4135,9 @@ class MyRLEnv(Base5ActionRLEnv):
         reward = self.calculate_reward(action)
         trade_type = self.execute_trade(action)
         entry_pnl = self.get_unrealized_profit() if previous_position == Positions.Neutral else 0.0
+        if trade_type is not None and self._position in (Positions.Long, Positions.Short):
+            self._update_max_unrealized_profit(entry_pnl)
+            self._update_min_unrealized_profit(entry_pnl)
         if trade_type is not None:
             self.append_trade_history(
                 trade_type, self.current_price(), pre_pnl, execution_tick=execution_tick
@@ -4189,7 +4213,7 @@ class MyRLEnv(Base5ActionRLEnv):
                 f"{closed_position.name}_exit",
                 self.current_price(),
                 terminal_pnl,
-                execution_tick=execution_tick,
+                execution_tick=self._current_tick,
             )
         if terminated:
             reward = self._apply_terminal_pbrs_correction(reward)
index fe1212a9f492c27d6ec6996808052d210cddcf17..759e05cef2d51bdf25027efa516a356ee099a20d 100644 (file)
@@ -58,6 +58,7 @@ class RLAgentStrategy(IStrategy):
     _ACTION_EXIT_LONG: Final[int] = 2
     _ACTION_ENTER_SHORT: Final[int] = 3
     _ACTION_EXIT_SHORT: Final[int] = 4
+    _leverage_warning_state: tuple[str, str] | None = None
 
     @property
     def can_short(self) -> bool:
@@ -188,7 +189,31 @@ 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")
+        warning_kind: str | None = None
+        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):
+                warning_kind = "invalid"
+                requested = proposed_leverage
+            elif requested < 1.0:
+                warning_kind = "below_minimum"
+
+        warning_state = (warning_kind, repr(configured)) if warning_kind else None
+        if warning_state is not None and warning_state != self._leverage_warning_state:
+            if warning_kind == "invalid":
+                logger.warning("Invalid leverage value %r; using proposed leverage", configured)
+            else:
+                logger.warning(
+                    "Invalid leverage value %r; must be >= 1.0, clamping to 1.0", configured
+                )
+        self._leverage_warning_state = warning_state
+        return float(max(1.0, min(requested, max_leverage)))
 
     def is_short_allowed(self) -> bool:
         trading_mode = self.config.get("trading_mode")
index 0684d8aca17222c16e68b0865d3e9b873af434b3..fe96aa38f08ea647dbc42bed10bcee93d67b8b16 100644 (file)
     },
     {
       "endCharacter": 87,
-      "endLine": 2602,
+      "endLine": 2621,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"index\" for class \"NDArray[Unknown]\"\n  Attribute \"index\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 82,
-      "startLine": 2602
+      "startLine": 2621
     },
     {
       "endCharacter": 68,
-      "endLine": 2603,
+      "endLine": 2622,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"index\" for class \"NDArray[Unknown]\"\n  Attribute \"index\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 63,
-      "startLine": 2603
+      "startLine": 2622
     },
     {
       "endCharacter": 63,
-      "endLine": 2614,
+      "endLine": 2633,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"index\" for class \"NDArray[Unknown]\"\n  Attribute \"index\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 58,
-      "startLine": 2614
+      "startLine": 2633
     },
     {
       "endCharacter": 47,
-      "endLine": 2616,
+      "endLine": 2635,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"loc\" for class \"NDArray[Unknown]\"\n  Attribute \"loc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 2616
+      "startLine": 2635
     },
     {
       "endCharacter": 47,
-      "endLine": 2616,
+      "endLine": 2635,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"loc\" for class \"list[Unknown]\"\n  Attribute \"loc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 2616
+      "startLine": 2635
     },
     {
       "endCharacter": 43,
-      "endLine": 2617,
+      "endLine": 2636,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"loc\" for class \"NDArray[Unknown]\"\n  Attribute \"loc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 40,
-      "startLine": 2617
+      "startLine": 2636
     },
     {
       "endCharacter": 43,
-      "endLine": 2617,
+      "endLine": 2636,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"loc\" for class \"list[Unknown]\"\n  Attribute \"loc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 40,
-      "startLine": 2617
+      "startLine": 2636
     },
     {
       "endCharacter": 31,
-      "endLine": 2638,
+      "endLine": 2657,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"NDArray[Unknown]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 26,
-      "startLine": 2638
+      "startLine": 2657
     },
     {
       "endCharacter": 31,
-      "endLine": 2638,
+      "endLine": 2657,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"list[Unknown]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 26,
-      "startLine": 2638
+      "startLine": 2657
     },
     {
       "endCharacter": 60,
-      "endLine": 2638,
+      "endLine": 2657,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"NDArray[Unknown]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 55,
-      "startLine": 2638
+      "startLine": 2657
     },
     {
       "endCharacter": 60,
-      "endLine": 2638,
+      "endLine": 2657,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"list[Unknown]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 55,
-      "startLine": 2638
+      "startLine": 2657
     },
     {
       "endCharacter": 73,
-      "endLine": 2650,
+      "endLine": 2669,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Unknown | Any | ((value: Unknown, start: SupportsIndex = 0, stop: SupportsIndex = sys.maxsize, /) -> int)\" cannot be assigned to parameter \"target\" of type \"Index[Any]\" in function \"get_indexer\"\n  Type \"Unknown | Any | ((value: Unknown, start: SupportsIndex = 0, stop: SupportsIndex = sys.maxsize, /) -> int)\" is not assignable to type \"Index[Any]\"\n    \"MethodType\" is not assignable to \"Index[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 53,
-      "startLine": 2650
+      "startLine": 2669
     },
     {
       "endCharacter": 73,
-      "endLine": 2650,
+      "endLine": 2669,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"index\" for class \"NDArray[Unknown]\"\n  Attribute \"index\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 68,
-      "startLine": 2650
+      "startLine": 2669
     },
     {
       "endCharacter": 83,
-      "endLine": 2651,
+      "endLine": 2670,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Unknown | Any | ((value: Unknown, start: SupportsIndex = 0, stop: SupportsIndex = sys.maxsize, /) -> int)\" cannot be assigned to parameter \"target\" of type \"Index[Any]\" in function \"get_indexer\"\n  Type \"Unknown | Any | ((value: Unknown, start: SupportsIndex = 0, stop: SupportsIndex = sys.maxsize, /) -> int)\" is not assignable to type \"Index[Any]\"\n    \"MethodType\" is not assignable to \"Index[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 58,
-      "startLine": 2651
+      "startLine": 2670
     },
     {
       "endCharacter": 83,
-      "endLine": 2651,
+      "endLine": 2670,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"index\" for class \"NDArray[Unknown]\"\n  Attribute \"index\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 78,
-      "startLine": 2651
+      "startLine": 2670
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"Buffer\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"_NestedSequence[_SupportsArray[dtype[Any]]]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"_NestedSequence[complex | bytes | str]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"_SupportsArray[dtype[Any]]\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"bytes\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"complex\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 46,
-      "endLine": 2886,
+      "endLine": 2905,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Cannot access attribute \"empty\" for class \"str\"\n  Attribute \"empty\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 41,
-      "startLine": 2886
+      "startLine": 2905
     },
     {
       "endCharacter": 41,
-      "endLine": 2893,
+      "endLine": 2912,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"ArrayLike\" cannot be assigned to parameter \"features\" of type \"DataFrame\" in function \"_sanitize_pipeline_weights\"\n  Type \"ArrayLike\" is not assignable to type \"DataFrame\"\n    \"Buffer\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 2893
+      "startLine": 2912
     },
     {
       "endCharacter": 39,
-      "endLine": 2942,
+      "endLine": 2961,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"ArrayLike\" cannot be assigned to parameter \"features\" of type \"DataFrame\" in function \"_sanitize_pipeline_weights\"\n  Type \"ArrayLike\" is not assignable to type \"DataFrame\"\n    \"Buffer\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 2942
+      "startLine": 2961
     },
     {
       "endCharacter": 19,
-      "endLine": 3052,
+      "endLine": 3071,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"int | Any | None\" cannot be assigned to parameter \"gap\" of type \"int\" in function \"__init__\"\n  Type \"int | Any | None\" is not assignable to type \"int\"\n    \"None\" is not assignable to \"int\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 3052
+      "startLine": 3071
     },
     {
       "endCharacter": 21,
-      "endLine": 3164,
+      "endLine": 3183,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"X\" of type \"DataFrame\" in function \"hp_objective\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3164
+      "startLine": 3183
     },
     {
       "endCharacter": 21,
-      "endLine": 3165,
+      "endLine": 3184,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"y\" of type \"DataFrame\" in function \"hp_objective\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3165
+      "startLine": 3184
     },
     {
       "endCharacter": 33,
-      "endLine": 3166,
+      "endLine": 3185,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"train_weights\" of type \"NDArray[floating[Any]]\" in function \"hp_objective\"\n  Type \"Any | None\" is not assignable to type \"NDArray[floating[Any]]\"\n    \"None\" is not assignable to \"ndarray[_AnyShape, dtype[floating[Any]]]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3166
+      "startLine": 3185
     },
     {
       "endCharacter": 32,
-      "endLine": 3167,
+      "endLine": 3186,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"X_validation\" of type \"DataFrame\" in function \"hp_objective\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3167
+      "startLine": 3186
     },
     {
       "endCharacter": 32,
-      "endLine": 3168,
+      "endLine": 3187,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"y_validation\" of type \"DataFrame\" in function \"hp_objective\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3168
+      "startLine": 3187
     },
     {
       "endCharacter": 38,
-      "endLine": 3169,
+      "endLine": 3188,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"validation_weights\" of type \"NDArray[floating[Any]]\" in function \"hp_objective\"\n  Type \"Any | None\" is not assignable to type \"NDArray[floating[Any]]\"\n    \"None\" is not assignable to \"ndarray[_AnyShape, dtype[floating[Any]]]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3169
+      "startLine": 3188
     },
     {
       "endCharacter": 24,
-      "endLine": 3190,
+      "endLine": 3209,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"X_test\" of type \"DataFrame\" in function \"make_test_set_and_weights\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 3190
+      "startLine": 3209
     },
     {
       "endCharacter": 24,
-      "endLine": 3191,
+      "endLine": 3210,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"y_test\" of type \"DataFrame\" in function \"make_test_set_and_weights\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 3191
+      "startLine": 3210
     },
     {
       "endCharacter": 30,
-      "endLine": 3192,
+      "endLine": 3211,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"test_weights\" of type \"NDArray[floating[Any]]\" in function \"make_test_set_and_weights\"\n  Type \"Any | None\" is not assignable to type \"NDArray[floating[Any]]\"\n    \"None\" is not assignable to \"ndarray[_AnyShape, dtype[floating[Any]]]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 3192
+      "startLine": 3211
     },
     {
       "endCharacter": 15,
-      "endLine": 3198,
+      "endLine": 3217,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"X\" of type \"DataFrame\" in function \"fit_regressor\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 14,
-      "startLine": 3198
+      "startLine": 3217
     },
     {
       "endCharacter": 15,
-      "endLine": 3199,
+      "endLine": 3218,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"y\" of type \"DataFrame\" in function \"fit_regressor\"\n  Type \"Any | None\" is not assignable to type \"DataFrame\"\n    \"None\" is not assignable to \"DataFrame\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 14,
-      "startLine": 3199
+      "startLine": 3218
     },
     {
       "endCharacter": 39,
-      "endLine": 3200,
+      "endLine": 3219,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"train_weights\" of type \"NDArray[floating[Any]]\" in function \"fit_regressor\"\n  Type \"Any | None\" is not assignable to type \"NDArray[floating[Any]]\"\n    \"None\" is not assignable to \"ndarray[_AnyShape, dtype[floating[Any]]]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 26,
-      "startLine": 3200
+      "startLine": 3219
     },
     {
       "endCharacter": 38,
-      "endLine": 3210,
+      "endLine": 3229,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "\"columns\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 3210
+      "startLine": 3229
     },
     {
       "endCharacter": 34,
-      "endLine": 3211,
+      "endLine": 3230,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "\"index\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 3211
+      "startLine": 3230
     },
     {
       "endCharacter": 82,
-      "endLine": 3213,
+      "endLine": 3232,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "\"copy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 78,
-      "startLine": 3213
+      "startLine": 3232
     },
     {
       "endCharacter": 62,
-      "endLine": 3647,
+      "endLine": 3666,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "\"get_pair_dataframe\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 44,
-      "startLine": 3647
+      "startLine": 3666
     },
     {
       "endCharacter": 68,
-      "endLine": 4027,
+      "endLine": 4046,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Type \"floating[Any] | NDArray[float64]\" is not assignable to return type \"NDArray[floating[Any]]\"\n  Type \"floating[Any] | NDArray[float64]\" is not assignable to type \"NDArray[floating[Any]]\"\n    \"floating[Any]\" is not assignable to \"ndarray[_AnyShape, dtype[floating[Any]]]\"",
       "rule": "reportReturnType",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 4023
+      "startLine": 4042
     },
     {
       "endCharacter": 13,
-      "endLine": 4056,
+      "endLine": 4075,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"cdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 19,
-      "startLine": 4051
+      "startLine": 4070
     },
     {
       "endCharacter": 38,
-      "endLine": 4054,
+      "endLine": 4073,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"str\" cannot be assigned to parameter \"metric\" of type \"_MetricFunc\" in function \"cdist\"\n  Type \"str\" is not assignable to type \"_MetricFunc\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 23,
-      "startLine": 4054
+      "startLine": 4073
     },
     {
       "endCharacter": 9,
-      "endLine": 4159,
+      "endLine": 4178,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"pdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 4157
+      "startLine": 4176
     },
     {
       "endCharacter": 42,
-      "endLine": 4158,
+      "endLine": 4177,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"str\" cannot be assigned to parameter \"metric\" of type \"_MetricFunc\" in function \"pdist\"\n  Type \"str\" is not assignable to type \"_MetricFunc\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4158
+      "startLine": 4177
     },
     {
       "endCharacter": 9,
-      "endLine": 4236,
+      "endLine": 4255,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"cdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 4231
+      "startLine": 4250
     },
     {
       "endCharacter": 34,
-      "endLine": 4234,
+      "endLine": 4253,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"str\" cannot be assigned to parameter \"metric\" of type \"_MetricFunc\" in function \"cdist\"\n  Type \"str\" is not assignable to type \"_MetricFunc\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 19,
-      "startLine": 4234
+      "startLine": 4253
     },
     {
       "endCharacter": 99,
-      "endLine": 4425,
+      "endLine": 4444,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"pdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 4425
+      "startLine": 4444
     },
     {
       "endCharacter": 79,
-      "endLine": 4425,
+      "endLine": 4444,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "Argument of type \"str\" cannot be assigned to parameter \"metric\" of type \"_MetricFunc\" in function \"pdist\"\n  Type \"str\" is not assignable to type \"_MetricFunc\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 64,
-      "startLine": 4425
+      "startLine": 4444
     },
     {
       "endCharacter": 55,
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()
index 623a22bfceb52da8299052efaa92d5ff9c22d530..5df822ab9bf0a1748f75236a4aab6682ef257756 100644 (file)
@@ -450,7 +450,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
     https://github.com/sponsors/robcaulk
     """
 
-    version = "3.13.0-rc.11"
+    version = "3.13.0-rc.12"
 
     _CALIBRATION_START_KEY: Final[str] = "quickadapter_calibration_start"
     _DEPLOYMENT_COORDINATE_MARKER_KEY: Final[str] = "quickadapter_deployment_coordinates"
@@ -2372,7 +2372,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         )
 
     def _resolve_deployment_state(
-        self, dk: FreqaiDataKitchen, pair: str
+        self, dk: FreqaiDataKitchen, pair: str, *, as_of_ts: int | None = None
     ) -> tuple[Any, Pipeline, Pipeline] | None:
         """Restore the deployed model with independent copies of its fitted pipelines."""
         if not self.continual_learning:
@@ -2401,6 +2401,19 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         previous = self.dd.pair_dict.get(pair, {})
         if model is None and not previous.get("model_filename"):
             return None
+        if not self.live:
+            # The drawer timestamp can advance without saving a model. Bound the
+            # actual artifact instead, before reading cached or persisted state.
+            name, separator, timestamp = previous.get("model_filename", "").rpartition("_")
+            if (
+                as_of_ts is None
+                or not previous.get("trained_timestamp")
+                or not separator
+                or name != f"cb_{pair.split('/')[0].lower()}"
+                or not timestamp.isdecimal()
+                or int(timestamp) >= as_of_ts
+            ):
+                return None
         try:
             cached = self.dd.meta_data_dictionary.get(pair)
             if model is not None and cached is not None:
@@ -2499,7 +2512,13 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         )
         if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live:
             dk.fit_labels()
-        deployment_state = self._resolve_deployment_state(dk, pair)
+        as_of_ts = None
+        if self.continual_learning and not self.live:
+            as_of_ts = int(dates.max().timestamp()) + timeframe_to_seconds(self.config["timeframe"])
+            training_timerange = getattr(self, "training_timerange", None)
+            if training_timerange is not None:
+                as_of_ts = min(as_of_ts, int(training_timerange.stopts))
+        deployment_state = self._resolve_deployment_state(dk, pair, as_of_ts=as_of_ts)
         dd = self._apply_pipelines(
             dd,
             train_weight_inputs,
index 6ad115346696155a906577f5781a44729c63375d..2a59befef36f147d62ba42d5252b8bf33f7b9681 100644 (file)
@@ -218,7 +218,7 @@ class QuickAdapterV3(IStrategy):
     _ANNOTATION_LINE_OFFSET_CANDLES: Final[int] = 10
 
     def version(self) -> str:
-        return "3.13.0-rc.11"
+        return "3.13.0-rc.12"
 
     timeframe = "5m"
     timeframe_minutes = timeframe_to_minutes(timeframe)