"${{ matrix.image }}" \
/workspace/scripts/check_basedpyright.py \
--project "${{ matrix.project }}"
+ - name: QuickAdapter runtime regressions
+ if: matrix.project == 'quickadapter'
+ run: |
+ docker run --rm \
+ --mount "type=bind,src=${GITHUB_WORKSPACE},dst=/workspace,readonly" \
+ --workdir /workspace \
+ --env PYTHONPATH=/workspace/quickadapter/user_data/strategies \
+ --entrypoint python \
+ "${{ matrix.image }}" \
+ -m unittest discover -s quickadapter/tests -v
reforcexy-tests:
name: ReforceXY tests
### Runtime regressions
-Run the runtime training, inference and accounting regressions inside the
-ReforceXY QA image, with the repository mounted at `/workspace` and `/workspace`
-as the working directory:
+Run each suite in its matching Freqtrade QA image, with the repository mounted
+at `/workspace` and `/workspace` as the working directory:
```shell
+# ReforceXY
python -m unittest discover -s ReforceXY/tests -v
+
+# QuickAdapter
+PYTHONPATH=/workspace/quickadapter/user_data/strategies \
+ python -m unittest discover -s quickadapter/tests -v
```
### Quality checks
},
{
"endCharacter": 5,
- "endLine": 2108,
+ "endLine": 2135,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "No overloads for \"cut\" match the provided arguments",
"rule": "reportCallIssue",
"severity": "error",
"startCharacter": 17,
- "startLine": 2103
+ "startLine": 2130
},
{
"endCharacter": 21,
- "endLine": 2105,
+ "endLine": 2132,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Argument of type \"_Array1D[Any]\" cannot be assigned to parameter \"bins\" of type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\" in function \"cut\"\n Type \"_Array1D[Any]\" is not assignable to type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\"\n \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"int\"\n \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Sequence[float]\"\n \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[int]\"\n \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[float]\"\n \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"IntervalIndex[Interval[Any]]\"\n \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Series[Any]\"",
"rule": "reportArgumentType",
"severity": "error",
"startCharacter": 13,
- "startLine": 2105
+ "startLine": 2132
},
{
"endCharacter": 5,
- "endLine": 2130,
+ "endLine": 2157,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"",
"rule": "reportAssignmentType",
"severity": "error",
"startCharacter": 34,
- "startLine": 2126
+ "startLine": 2153
},
{
"endCharacter": 43,
- "endLine": 2127,
+ "endLine": 2154,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "No overloads for \"__init__\" match the provided arguments",
"rule": "reportCallIssue",
"severity": "error",
"startCharacter": 8,
- "startLine": 2127
+ "startLine": 2154
},
{
"endCharacter": 42,
- "endLine": 2127,
+ "endLine": 2154,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n \"__iter__\" is not present",
"rule": "reportArgumentType",
"severity": "error",
"startCharacter": 13,
- "startLine": 2127
+ "startLine": 2154
},
{
"endCharacter": 60,
- "endLine": 2387,
+ "endLine": 2414,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Cannot access attribute \"importances_mean\" for class \"dict[Unknown, Bunch]\"\n Attribute \"importances_mean\" is unknown",
"rule": "reportAttributeAccessIssue",
"severity": "error",
"startCharacter": 44,
- "startLine": 2387
+ "startLine": 2414
},
{
"endCharacter": 58,
- "endLine": 2388,
+ "endLine": 2415,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Cannot access attribute \"importances_std\" for class \"dict[Unknown, Bunch]\"\n Attribute \"importances_std\" is unknown",
"rule": "reportAttributeAccessIssue",
"severity": "error",
"startCharacter": 43,
- "startLine": 2388
+ "startLine": 2415
},
{
"endCharacter": 88,
- "endLine": 2407,
+ "endLine": 2434,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Cannot access attribute \"columns\" for class \"NDArray[Unknown]\"\n Attribute \"columns\" is unknown",
"rule": "reportAttributeAccessIssue",
"severity": "error",
"startCharacter": 81,
- "startLine": 2407
+ "startLine": 2434
},
{
"endCharacter": 88,
- "endLine": 2407,
+ "endLine": 2434,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Cannot access attribute \"columns\" for class \"list[Unknown]\"\n Attribute \"columns\" is unknown",
"rule": "reportAttributeAccessIssue",
"severity": "error",
"startCharacter": 81,
- "startLine": 2407
+ "startLine": 2434
},
{
"endCharacter": 17,
- "endLine": 2416,
+ "endLine": 2443,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Object of type \"None\" cannot be called",
"rule": "reportOptionalCall",
"severity": "error",
"startCharacter": 28,
- "startLine": 2410
- },
- {
- "endCharacter": 40,
- "endLine": 2645,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg1\" of type \"SupportsRichComparisonT@min\" in function \"min\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"object\" is not assignable to type \"SupportsRichComparison\"\n \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n \"__lt__\" is not present\n \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n \"__gt__\" is not present",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 22,
- "startLine": 2645
- },
- {
- "endCharacter": 38,
- "endLine": 2645,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Cannot access attribute \"min\" for class \"ExtensionArray\"\n Attribute \"min\" is unknown",
- "rule": "reportAttributeAccessIssue",
- "severity": "error",
- "startCharacter": 35,
- "startLine": 2645
- },
- {
- "endCharacter": 59,
- "endLine": 2645,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg2\" of type \"SupportsRichComparisonT@min\" in function \"min\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"object\" is not assignable to type \"SupportsRichComparison\"\n \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n \"__lt__\" is not present\n \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n \"__gt__\" is not present",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 42,
- "startLine": 2645
- },
- {
- "endCharacter": 57,
- "endLine": 2645,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Cannot access attribute \"min\" for class \"ExtensionArray\"\n Attribute \"min\" is unknown",
- "rule": "reportAttributeAccessIssue",
- "severity": "error",
- "startCharacter": 54,
- "startLine": 2645
- },
- {
- "endCharacter": 40,
- "endLine": 2646,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg1\" of type \"SupportsRichComparisonT@max\" in function \"max\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"object\" is not assignable to type \"SupportsRichComparison\"\n \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n \"__lt__\" is not present\n \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n \"__gt__\" is not present",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 22,
- "startLine": 2646
- },
- {
- "endCharacter": 38,
- "endLine": 2646,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Cannot access attribute \"max\" for class \"ExtensionArray\"\n Attribute \"max\" is unknown",
- "rule": "reportAttributeAccessIssue",
- "severity": "error",
- "startCharacter": 35,
- "startLine": 2646
- },
- {
- "endCharacter": 59,
- "endLine": 2646,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"Any | object | NAType | Unknown\" cannot be assigned to parameter \"arg2\" of type \"SupportsRichComparisonT@max\" in function \"max\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"Any | object | NAType | Unknown\" is not assignable to type \"SupportsRichComparison\"\n Type \"object\" is not assignable to type \"SupportsRichComparison\"\n \"object\" is incompatible with protocol \"SupportsDunderLT[Any]\"\n \"__lt__\" is not present\n \"object\" is incompatible with protocol \"SupportsDunderGT[Any]\"\n \"__gt__\" is not present",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 42,
- "startLine": 2646
- },
- {
- "endCharacter": 57,
- "endLine": 2646,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Cannot access attribute \"max\" for class \"ExtensionArray\"\n Attribute \"max\" is unknown",
- "rule": "reportAttributeAccessIssue",
- "severity": "error",
- "startCharacter": 54,
- "startLine": 2646
- },
- {
- "endCharacter": 76,
- "endLine": 2662,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "No overloads for \"histogram\" match the provided arguments",
- "rule": "reportCallIssue",
- "severity": "error",
- "startCharacter": 24,
- "startLine": 2662
- },
- {
- "endCharacter": 49,
- "endLine": 2662,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"a\" of type \"_ArrayLikeComplex_co\" in function \"histogram\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeComplex_co\"\n Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeComplex_co\"\n \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 37,
- "startLine": 2662
- },
- {
- "endCharacter": 74,
- "endLine": 2663,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "No overloads for \"histogram\" match the provided arguments",
- "rule": "reportCallIssue",
- "severity": "error",
- "startCharacter": 23,
- "startLine": 2663
- },
- {
- "endCharacter": 47,
- "endLine": 2663,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"a\" of type \"_ArrayLikeComplex_co\" in function \"histogram\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeComplex_co\"\n Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeComplex_co\"\n \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[numpy.bool[builtins.bool] | number[Any, Any]]]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 36,
- "startLine": 2663
- },
- {
- "endCharacter": 51,
- "endLine": 2691,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"u_values\" of type \"ToFloatND\" in function \"wasserstein_distance\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 39,
- "startLine": 2691
- },
- {
- "endCharacter": 64,
- "endLine": 2691,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"v_values\" of type \"ToFloatND\" in function \"wasserstein_distance\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 53,
- "startLine": 2691
- },
- {
- "endCharacter": 68,
- "endLine": 2694,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "No overloads for \"ks_2samp\" match the provided arguments",
- "rule": "reportCallIssue",
- "severity": "error",
- "startCharacter": 27,
- "startLine": 2694
- },
- {
- "endCharacter": 54,
- "endLine": 2694,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"data1\" of type \"ToFloatND\" in function \"ks_2samp\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 42,
- "startLine": 2694
- },
- {
- "endCharacter": 67,
- "endLine": 2694,
- "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
- "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"data2\" of type \"ToFloatND\" in function \"ks_2samp\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"ToFloatND\"\n Type \"ExtensionArray\" is not assignable to type \"ToFloatND\"\n \"ExtensionArray\" is incompatible with protocol \"_CanArrayND[floating_co]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"SequenceND[py_float | _CanArray[floating_co]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
- "rule": "reportArgumentType",
- "severity": "error",
- "startCharacter": 56,
- "startLine": 2694
+ "startLine": 2437
},
{
"endCharacter": 29,
- "endLine": 2994,
+ "endLine": 3038,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "No overloads for \"ptp\" match the provided arguments",
"rule": "reportCallIssue",
"severity": "error",
"startCharacter": 11,
- "startLine": 2994
+ "startLine": 3038
},
{
"endCharacter": 28,
- "endLine": 2994,
+ "endLine": 3038,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"a\" of type \"_ArrayLikeNumeric_co\" in function \"ptp\"\n Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeNumeric_co\"\n Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeNumeric_co\"\n \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]\"\n \"__array__\" is not present\n \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]]\"\n \"__reversed__\" is not present\n \"count\" is not present\n \"index\" is not present\n ...",
"rule": "reportArgumentType",
"severity": "error",
"startCharacter": 18,
- "startLine": 2994
+ "startLine": 3038
},
{
"endCharacter": 65,
- "endLine": 3003,
+ "endLine": 3047,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Cannot access attribute \"mean\" for class \"Categorical[object]\"\n Attribute \"mean\" is unknown",
"rule": "reportAttributeAccessIssue",
"severity": "error",
"startCharacter": 61,
- "startLine": 3003
+ "startLine": 3047
},
{
"endCharacter": 65,
- "endLine": 3003,
+ "endLine": 3047,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Cannot access attribute \"mean\" for class \"ExtensionArray\"\n Attribute \"mean\" is unknown",
"rule": "reportAttributeAccessIssue",
"severity": "error",
"startCharacter": 61,
- "startLine": 3003
+ "startLine": 3047
},
{
"endCharacter": 5,
- "endLine": 3899,
+ "endLine": 3947,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"",
"rule": "reportAssignmentType",
"severity": "error",
"startCharacter": 34,
- "startLine": 3895
+ "startLine": 3943
},
{
"endCharacter": 43,
- "endLine": 3896,
+ "endLine": 3944,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "No overloads for \"__init__\" match the provided arguments",
"rule": "reportCallIssue",
"severity": "error",
"startCharacter": 8,
- "startLine": 3896
+ "startLine": 3944
},
{
"endCharacter": 42,
- "endLine": 3896,
+ "endLine": 3944,
"file": "ReforceXY/reward_space_analysis/reward_space_analysis.py",
"message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n \"__iter__\" is not present",
"rule": "reportArgumentType",
"severity": "error",
"startCharacter": 13,
- "startLine": 3896
+ "startLine": 3944
},
{
"endCharacter": 22,
- **`--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
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
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 |
Implementation: up to 50 evenly spaced histogram edges (normally 49 bins) with
ε=1e-10; constants have zero divergence.
+Non-finite numeric values in real episodes are marked missing. Each feature is
+compared only when both synthetic and real data contain at least 10 finite
+observations; otherwise it is omitted. If none qualify, the report distinguishes
+this from not supplying real episodes.
+
---
## Advanced Usage
_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(
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,
else:
choices = [Actions.Neutral, Actions.Long_enter]
weights = [neutral_prob, entry_prob]
- action = rng.choices(choices, weights=weights, k=1)[0]
- return action, entry_prob, exit_prob, neutral_prob
-
- if position == Positions.Long:
+ elif position == Positions.Long:
choices = [Actions.Neutral, Actions.Long_exit]
+ weights = [1.0 - exit_prob, exit_prob]
else: # Positions.Short
choices = [Actions.Neutral, Actions.Short_exit]
+ weights = [1.0 - exit_prob, exit_prob]
+
+ if not action_masking:
+ invalid_choices = [
+ candidate
+ for candidate in Actions
+ if not _is_valid_action(position, candidate, short_allowed=short_allowed)
+ ]
+ valid_mass = 1.0 - _SAMPLE_INVALID_ACTION_PROBABILITY
+ weights = [weight * valid_mass for weight in weights]
+ choices.extend(invalid_choices)
+ weights.extend(
+ [_SAMPLE_INVALID_ACTION_PROBABILITY / len(invalid_choices)] * len(invalid_choices)
+ )
- weights = [1.0 - exit_prob, exit_prob]
action = rng.choices(choices, weights=weights, k=1)[0]
return action, entry_prob, exit_prob, neutral_prob
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(
action=action,
)
+ next_position = _get_next_position(position, action, short_allowed=short_allowed)
if position == Positions.Neutral:
- if action == Actions.Long_enter:
- position = Positions.Long
- trade_duration = 0
- idle_duration = 0
- entry_open = current_open
- elif action == Actions.Short_enter and short_allowed:
- position = Positions.Short
+ if next_position != Positions.Neutral:
+ position = next_position
trade_duration = 0
idle_duration = 0
entry_open = current_open
- if position in (Positions.Long, Positions.Short):
entry_pnl = _compute_unrealized_pnl_estimate(
position, entry_open=entry_open, current_open=entry_open, params=params
)
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
"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),
}
)
(1.0 + entry_fee_rate) * (1.0 + exit_fee_rate) - 1.0,
)
- # INVARIANT 1: Action-position compatibility
- long_exits = df[(df["action"] == 2.0) & (df["position"] != 1.0)]
- short_exits = df[(df["action"] == 4.0) & (df["position"] != 0.0)]
- if len(long_exits) > 0:
- raise AssertionError(f"Sim: {len(long_exits)} Long_exit actions without Long position")
- if len(short_exits) > 0:
- raise AssertionError(f"Sim: {len(short_exits)} Short_exit actions without Short position")
-
- long_entries = df[(df["action"] == 1.0) & (df["position"] != 0.5)]
- short_entries = df[(df["action"] == 3.0) & (df["position"] != 0.5)]
- if len(long_entries) > 0:
- raise AssertionError(
- f"Sim: {len(long_entries)} Long_enter actions without Neutral position"
- )
- if len(short_entries) > 0:
- raise AssertionError(
- f"Sim: {len(short_entries)} Short_enter actions without Neutral position"
- )
+ # INVARIANT 1: masked sampling must only emit legal actions; unmasked
+ # actions may be invalid but cannot alter an open position.
+ if _get_bool_param(params, "action_masking", True):
+ long_exits = df[(df["action"] == 2.0) & (df["position"] != 1.0)]
+ short_exits = df[(df["action"] == 4.0) & (df["position"] != 0.0)]
+ if len(long_exits) > 0:
+ raise AssertionError(f"Sim: {len(long_exits)} Long_exit actions without Long position")
+ if len(short_exits) > 0:
+ raise AssertionError(
+ f"Sim: {len(short_exits)} Short_exit actions without Short position"
+ )
+
+ long_entries = df[(df["action"] == 1.0) & (df["position"] != 0.5)]
+ short_entries = df[(df["action"] == 3.0) & (df["position"] != 0.5)]
+ if len(long_entries) > 0:
+ raise AssertionError(
+ f"Sim: {len(long_entries)} Long_enter actions without Neutral position"
+ )
+ if len(short_entries) > 0:
+ raise AssertionError(
+ f"Sim: {len(short_entries)} Short_enter actions without Neutral position"
+ )
+ else:
+ changed_on_invalid = df[
+ df["is_invalid"].eq(1.0)
+ & ~df["terminated"].eq(True)
+ & df["next_position"].ne(df["position"])
+ ]
+ if len(changed_on_invalid) > 0:
+ raise AssertionError(
+ f"Sim: {len(changed_on_invalid)} invalid actions changed open position"
+ )
# INVARIANT 2: Duration logic
neutral_with_trade = df[(df["position"] == 0.5) & (df["trade_duration"] > 0)]
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])
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(
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 = {
continuous_features = ["pnl", "trade_duration", "idle_duration"]
for feature in continuous_features:
- synth_values = synthetic_df[feature].dropna().values
- real_values = real_df[feature].dropna().values
+ synth_values = synthetic_df[feature].to_numpy(dtype=float, na_value=np.nan)
+ real_values = real_df[feature].to_numpy(dtype=float, na_value=np.nan)
+ synth_finite = np.isfinite(synth_values)
+ real_finite = np.isfinite(real_values)
+ if not synth_finite.all():
+ synth_values = synth_values[synth_finite]
+ if not real_finite.all():
+ real_values = real_values[real_finite]
if len(synth_values) < 10 or len(real_values) < 10:
continue
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))
"""
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)
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",
) -> 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 = (
distribution_shift = compute_distribution_shift_metrics(
df, real_df, independent_observations=independent_observations
)
+ distribution_shift_unavailable = (
+ "no real episodes provided" if real_df is None else "no comparable finite observations"
+ )
# Write comprehensive report
with report_path.open("w", encoding="utf-8") as f:
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")
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")
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 = {
stable across unrelated line insertions.
- Notes: Sub-modes, non-owning references and multi-path coverage.
-| ID | Category | Description | Owning test | Notes |
-| --------------------------------------------- | ----------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| report-additives-deterministic-092 | components | Additives deterministic report section | components/test_additives.py::test_additive_activation_deterministic_contribution | Integration/PBRS may reference outcome non-owning |
-| robustness-decomposition-integrity-101 | robustness | Single active core component equals total reward under mutually exclusive scenarios | robustness/test_robustness.py::test_decomposition_integrity | Scenarios: idle, hold, exit, invalid; non-owning refs integration/test_reward_calculation.py |
-| robustness-exit-mode-fallback-102 | robustness | Unknown exit_attenuation_mode falls back to linear w/ warning | robustness/test_robustness.py::test_robustness_102_unknown_exit_mode_fallback_linear | |
-| robustness-negative-grace-clamp-103 | robustness | Negative exit_plateau_grace clamps to 0.0 w/ warning | robustness/test_robustness.py::test_robustness_103_negative_plateau_grace_clamped | |
-| robustness-invalid-power-tau-104 | robustness | Invalid power tau falls back alpha=1.0 w/ warning | robustness/test_robustness.py::test_robustness_104_invalid_power_tau_fallback_alpha_one | |
-| robustness-near-zero-half-life-105 | robustness | Near-zero half life yields no attenuation (factor≈base) | robustness/test_robustness.py::test_robustness_105_half_life_near_zero_fallback | |
-| pbrs-canonical-exit-semantic-106 | pbrs | Canonical exit uses shaping=-prev_potential and next_potential=0.0 | pbrs/test_pbrs.py::test_exit_step_shaping_matches_exit_step_rules | Uses stored potential across steps; no drift correction applied |
-| statistics-partial-deps-skip-107 | statistics | skip_partial_dependence => empty PD structures | statistics/test_statistics.py::test_statistics_feature_analysis_skip_partial_dependence | |
-| helpers-transitions-preserve-multiplicity-108 | helpers | Repeated transitions retain their empirical multiplicity | helpers/test_utilities.py::test_repeated_transitions_preserve_multiplicity | |
-| helpers-missing-cols-fill-109 | helpers | Missing required columns filled with NaN + single warning | helpers/test_utilities.py::test_missing_multiple_required_columns_single_warning | |
-| statistics-binned-stats-min-edges-110 | statistics | <2 bin edges raises ValueError | statistics/test_statistics.py::test_statistics_binned_stats_invalid_bins_raises | Docstring line |
-| statistics-constant-cols-exclusion-111 | statistics | Constant columns excluded & listed | statistics/test_statistics.py::test_statistics_correlation_dropped_constant_columns | Docstring line |
-| statistics-degenerate-distribution-shift-112 | statistics | Constants: zero distances; KS p only with declared independent observations | statistics/test_statistics.py::test_statistics_distribution_shift_metrics_degenerate_zero | Docstring line |
-| statistics-constant-dist-exact-ci-113a | statistics | Both modes retain exact constant CI bounds | statistics/test_statistics.py::test_stats_bootstrap_constant_distribution_exact_bounds | |
-| statistics-percentile-outside-mean-113b | statistics | Percentile bounds need not contain the sample mean | statistics/test_statistics.py::test_stats_bootstrap_percentiles_need_not_contain_mean | |
-| statistics-constant-diagnostics-115 | statistics | Constants have N/A higher moments, normality tests and Q-Q fits in both modes | statistics/test_statistics.py::test_statistics_distribution_constant_diagnostics | |
-| pbrs-canonical-near-zero-report-116 | pbrs | Canonical trajectories with valid evidence are classified as verified | pbrs/test_pbrs.py::test_pbrs_canonical_near_zero_report | Requires local identity, continuity, discounted terminal boundary, and zero observed additives; the non-owning boundary test also covers a complete singleton terminal episode |
-| robustness-exit-pnl-only-117 | robustness | Only exit actions have non-zero PnL | robustness/test_robustness.py::test_pnl_invariant_exit_only | |
-| pbrs-absence-shift-placeholder-118 | pbrs | Placeholder shift line present when shaping shift is absent | pbrs/test_pbrs.py::test_pbrs_absence_and_distribution_shift_placeholder | |
-| components-pbrs-breakdown-fields-119 | components | PBRS breakdown fields finite and mathematically aligned | components/test_reward_components.py::test_pbrs_breakdown_fields_finite_and_aligned | Tests base_reward, pbrs_delta and invariance_correction alignment |
-| integration-pbrs-metrics-section-120 | integration | PBRS Metrics section present in report with tracing metrics | integration/test_report_formatting.py::test_report_includes_pbrs_metrics_section | |
-| cli-pbrs-csv-columns-121 | cli | PBRS columns in reward_samples.csv when shaping enabled | cli/test_cli_params_and_csv.py::test_csv_contains_pbrs_columns_when_shaping_present | Verifies finite reward_base, reward_pbrs_delta and reward_invariance_correction values |
-| statistics-bh-finite-family-122 | statistics | Undefined tests excluded from finite-only BH family; marked non-applicable | statistics/test_statistics.py::test_bh_excludes_undefined_tests_from_finite_family | |
-| statistics-independence-contract-123 | statistics | Inferential helpers require independent_observations=True | statistics/test_statistics.py::test_inference_helpers_require_independent_observations | Covers hypothesis tests and bootstrap intervals |
-| report-independent-sections-124 | integration | CI, diagnostics and shift sections do not depend on hypothesis-test output | integration/test_report_formatting.py::test_statistical_sections_do_not_depend_on_hypothesis_tests | Also verifies the reported bootstrap resample count |
-| pbrs-discounted-evidence-125 | pbrs | Verification requires local identity, continuity and discounted terminal boundary | pbrs/test_pbrs.py::test_pbrs_canonical_discontinuous_potentials_report | Discontinuous potentials are not verified |
-| statistics-proportional-histograms-126 | statistics | Proportional histograms ignore sample count; moved mass yields positive KL/JS | statistics/test_statistics.py::test_distribution_shift_proportional_histograms_ignore_sample_count | KL and JS remain finite and non-negative |
-| pbrs-near-bound-clamp-127 | pbrs | Relaxed near-bound clamps apply exact endpoints and retain all reasons | pbrs/test_pbrs.py::test_validate_reward_parameters_records_near_bound_clamps_exactly | Includes numeric-string coercion |
-| pbrs-exit-mode-validation-128 | pbrs | Exit-potential choices are strict or canonicalized; direct calls fail safe | pbrs/test_pbrs.py::test_invalid_exit_mode_warns_at_direct_and_simulation_boundaries | Direct calculation and simulation boundaries warn before fallback; PBRS calls suppress additives |
-| cli-invalid-exit-mode-129 | cli | Invalid `--params exit_potential_mode` fails before artifacts | cli/test_cli_params_and_csv.py::test_invalid_exit_potential_mode_params_fails_before_artifacts | Strict CLI validation |
-| cli-warning-header-recognition-130 | cli | Warning counts accept only anchored Python warning header formats | cli/test_cli_params_and_csv.py::test_warning_header_positive_and_negative_formats | Covers POSIX, relative, synthetic and Windows source locations |
-| pbrs-invalid-mode-provenance-131 | pbrs | Invalid imported exit-mode metadata cannot certify canonical invariance | pbrs/test_pbrs.py::test_report_rejects_invalid_exit_mode_provenance | Preserves the invalid raw value and reports effective additive settings as unknown |
-| pbrs-synthetic-fee-floor-132 | pbrs | High-fee entry loss remains in long/short synthetic PnL and exit rewards | pbrs/test_pbrs.py::test_synthetic_fee_loss_extrema_match_retained_pnl | Direct and transformed trajectories |
-| pbrs-synthetic-profitable-mark-133 | pbrs | Favorable long/short marks remain profitable after high-fee synthetic transformation | pbrs/test_pbrs.py::test_synthetic_high_fee_winner_retains_profitable_mark | Covers immediate gains and recovery after an initial loss on the independent sampled market path |
-| pbrs-synthetic-fee-boundary-134 | pbrs | Fee-boundary rounding is accepted while materially extreme PnL is rejected | pbrs/test_pbrs.py::test_synthetic_high_fee_short_boundary_rejects_real_excess | Non-unit entry price exposes floating-point roundoff |
-| pbrs-synthetic-latent-price-135 | pbrs | Clipped candidate PnL does not erase the sampled market path for later holds | pbrs/test_pbrs.py::test_unrealized_pnl_retains_sampled_market_path_after_candidate_cap | Equal first retained marks from distinct market prices diverge after the same adverse return |
+| ID | Category | Description | Owning test | Notes |
+| --------------------------------------------- | ----------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| report-additives-deterministic-092 | components | Additives deterministic report section | components/test_additives.py::test_additive_activation_deterministic_contribution | Integration/PBRS may reference outcome non-owning |
+| robustness-decomposition-integrity-101 | robustness | Single active core component equals total reward under mutually exclusive scenarios | robustness/test_robustness.py::test_decomposition_integrity | Scenarios: idle, hold, exit, invalid; non-owning refs integration/test_reward_calculation.py |
+| robustness-exit-mode-fallback-102 | robustness | Unknown exit_attenuation_mode falls back to linear w/ warning | robustness/test_robustness.py::test_robustness_102_unknown_exit_mode_fallback_linear | |
+| robustness-negative-grace-clamp-103 | robustness | Negative exit_plateau_grace clamps to 0.0 w/ warning | robustness/test_robustness.py::test_robustness_103_negative_plateau_grace_clamped | |
+| robustness-invalid-power-tau-104 | robustness | Invalid power tau falls back alpha=1.0 w/ warning | robustness/test_robustness.py::test_robustness_104_invalid_power_tau_fallback_alpha_one | |
+| robustness-near-zero-half-life-105 | robustness | Near-zero half life yields no attenuation (factor≈base) | robustness/test_robustness.py::test_robustness_105_half_life_near_zero_fallback | |
+| pbrs-canonical-exit-semantic-106 | pbrs | Canonical exit uses shaping=-prev_potential and next_potential=0.0 | pbrs/test_pbrs.py::test_exit_step_shaping_matches_exit_step_rules | Uses stored potential across steps; no drift correction applied |
+| statistics-partial-deps-skip-107 | statistics | skip_partial_dependence => empty PD structures | statistics/test_statistics.py::test_statistics_feature_analysis_skip_partial_dependence | |
+| helpers-transitions-preserve-multiplicity-108 | helpers | Repeated transitions retain their empirical multiplicity | helpers/test_utilities.py::test_repeated_transitions_preserve_multiplicity | |
+| helpers-missing-cols-fill-109 | helpers | Missing required columns filled with NaN + single warning | helpers/test_utilities.py::test_missing_multiple_required_columns_single_warning | |
+| statistics-binned-stats-min-edges-110 | statistics | <2 bin edges raises ValueError | statistics/test_statistics.py::test_statistics_binned_stats_invalid_bins_raises | Docstring line |
+| statistics-constant-cols-exclusion-111 | statistics | Constant columns excluded & listed | statistics/test_statistics.py::test_statistics_correlation_dropped_constant_columns | Docstring line |
+| statistics-degenerate-distribution-shift-112 | statistics | Constants: zero distances; KS p only with declared independent observations | statistics/test_statistics.py::test_statistics_distribution_shift_metrics_degenerate_zero | Docstring line |
+| statistics-constant-dist-exact-ci-113a | statistics | Both modes retain exact constant CI bounds | statistics/test_statistics.py::test_stats_bootstrap_constant_distribution_exact_bounds | |
+| statistics-percentile-outside-mean-113b | statistics | Percentile bounds need not contain the sample mean | statistics/test_statistics.py::test_stats_bootstrap_percentiles_need_not_contain_mean | |
+| statistics-constant-diagnostics-115 | statistics | Constants have N/A higher moments, normality tests and Q-Q fits in both modes | statistics/test_statistics.py::test_statistics_distribution_constant_diagnostics | |
+| pbrs-canonical-near-zero-report-116 | pbrs | Canonical trajectories with valid evidence are classified as verified | pbrs/test_pbrs.py::test_pbrs_canonical_near_zero_report | Requires local identity, continuity, discounted terminal boundary, and zero observed additives; the non-owning boundary test also covers a complete singleton terminal episode |
+| robustness-exit-pnl-only-117 | robustness | Only exit actions have non-zero PnL | robustness/test_robustness.py::test_pnl_invariant_exit_only | |
+| pbrs-absence-shift-placeholder-118 | pbrs | Placeholder shift line present when shaping shift is absent | pbrs/test_pbrs.py::test_pbrs_absence_and_distribution_shift_placeholder | |
+| components-pbrs-breakdown-fields-119 | components | PBRS breakdown fields finite and mathematically aligned | components/test_reward_components.py::test_pbrs_breakdown_fields_finite_and_aligned | Tests base_reward, pbrs_delta and invariance_correction alignment |
+| integration-pbrs-metrics-section-120 | integration | PBRS Metrics section present in report with tracing metrics | integration/test_report_formatting.py::test_report_includes_pbrs_metrics_section | |
+| cli-pbrs-csv-columns-121 | cli | PBRS columns in reward_samples.csv when shaping enabled | cli/test_cli_params_and_csv.py::test_csv_contains_pbrs_columns_when_shaping_present | Verifies finite reward_base, reward_pbrs_delta and reward_invariance_correction values |
+| statistics-bh-finite-family-122 | statistics | Undefined tests excluded from finite-only BH family; marked non-applicable | statistics/test_statistics.py::test_bh_excludes_undefined_tests_from_finite_family | |
+| statistics-independence-contract-123 | statistics | Inferential helpers require independent_observations=True | statistics/test_statistics.py::test_inference_helpers_require_independent_observations | Covers hypothesis tests and bootstrap intervals |
+| report-independent-sections-124 | integration | CI, diagnostics and shift sections do not depend on hypothesis-test output | integration/test_report_formatting.py::test_statistical_sections_do_not_depend_on_hypothesis_tests | Also verifies the reported bootstrap resample count |
+| pbrs-discounted-evidence-125 | pbrs | Verification requires local identity, continuity and discounted terminal boundary | pbrs/test_pbrs.py::test_pbrs_canonical_discontinuous_potentials_report | Discontinuous potentials are not verified |
+| statistics-proportional-histograms-126 | statistics | Proportional histograms ignore sample count; moved mass yields positive KL/JS | statistics/test_statistics.py::test_distribution_shift_proportional_histograms_ignore_sample_count | KL and JS remain finite and non-negative |
+| pbrs-near-bound-clamp-127 | pbrs | Relaxed near-bound clamps apply exact endpoints and retain all reasons | pbrs/test_pbrs.py::test_validate_reward_parameters_records_near_bound_clamps_exactly | Includes numeric-string coercion |
+| pbrs-exit-mode-validation-128 | pbrs | Exit-potential choices are strict or canonicalized; direct calls fail safe | pbrs/test_pbrs.py::test_invalid_exit_mode_warns_at_direct_and_simulation_boundaries | Direct calculation and simulation boundaries warn before fallback; PBRS calls suppress additives |
+| cli-invalid-exit-mode-129 | cli | Invalid `--params exit_potential_mode` fails before artifacts | cli/test_cli_params_and_csv.py::test_invalid_exit_potential_mode_params_fails_before_artifacts | Strict CLI validation |
+| cli-warning-header-recognition-130 | cli | Warning counts accept only anchored Python warning header formats | cli/test_cli_params_and_csv.py::test_warning_header_positive_and_negative_formats | Covers POSIX, relative, synthetic and Windows source locations |
+| pbrs-invalid-mode-provenance-131 | pbrs | Invalid imported exit-mode metadata cannot certify canonical invariance | pbrs/test_pbrs.py::test_report_rejects_invalid_exit_mode_provenance | Preserves the invalid raw value and reports effective additive settings as unknown |
+| pbrs-synthetic-fee-floor-132 | pbrs | High-fee entry loss remains in long/short synthetic PnL and exit rewards | pbrs/test_pbrs.py::test_synthetic_fee_loss_extrema_match_retained_pnl | Direct and transformed trajectories |
+| pbrs-synthetic-profitable-mark-133 | pbrs | Favorable long/short marks remain profitable after high-fee synthetic transformation | pbrs/test_pbrs.py::test_synthetic_high_fee_winner_retains_profitable_mark | Covers immediate gains and recovery after an initial loss on the independent sampled market path |
+| pbrs-synthetic-fee-boundary-134 | pbrs | Fee-boundary rounding is accepted while materially extreme PnL is rejected | pbrs/test_pbrs.py::test_synthetic_high_fee_short_boundary_rejects_real_excess | Non-unit entry price exposes floating-point roundoff |
+| pbrs-synthetic-latent-price-135 | pbrs | Clipped candidate PnL does not erase the sampled market path for later holds | pbrs/test_pbrs.py::test_unrealized_pnl_retains_sampled_market_path_after_candidate_cap | Equal first retained marks from distinct market prices diverge after the same adverse return |
+| api-unmasked-invalid-actions-136 | api | Unmasked invalid actions keep positions and receive penalties | api/test_api_helpers.py::test_unmasked_simulation_samples_invalid_actions_without_changing_position | Includes spot, futures and zero-penalty invalid actions |
+| api-terminal-invalid-exit-137 | api | Wrong-side terminal exits liquidate the held side | api/test_api_helpers.py::test_invalid_terminal_exit_liquidates_the_held_position | Prevents dropped terminal trades |
+| cli-stale-generated-artifacts-138 | cli | Skipped analyses remove only reserved outputs from an earlier run | cli/test_cli_params_and_csv.py::test_skipped_analysis_removes_only_stale_generated_artifacts | Preserves unrelated files |
+| helpers-nonfinite-episodes-139 | helpers | Infinite real observations become missing without dropping rows | helpers/test_utilities.py::test_nonfinite_numeric_episodes_are_marked_missing | Retains transition multiplicity |
+| statistics-finite-shift-140 | statistics | Finite observations remain comparable despite an infinite value | statistics/test_statistics.py::test_distribution_shift_uses_finite_observations | Rejects all-nonfinite features |
+| statistics-rank-direction-141 | statistics | Rank-biserial effect follows the named first-group advantage | statistics/test_statistics.py::test_pnl_rank_biserial_direction_matches_named_first_group | Checks both directions |
+| statistics-bootstrap-count-142 | statistics | Zero and negative resample counts fail for variable and constant data | statistics/test_statistics.py::test_bootstrap_rejects_nonpositive_resample_count | Rejects missing bootstrap |
+| integration-finite-report-143 | integration | Reports distinguish unusable real values from missing real episodes | integration/test_report_formatting.py::test_report_distinguishes_missing_real_episodes_from_unusable_observations | Section and summary agree |
### Non-Owning Smoke / Reference Checks
import unittest
from pathlib import Path
from typing import Any, cast
+from unittest import mock
import numpy as np
import pandas as pd
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."""
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"
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()
report_path = out_dir / "statistical_analysis.md"
return report_path.read_text(encoding="utf-8")
+ def test_report_distinguishes_missing_real_episodes_from_unusable_observations(self):
+ synth_df = self.make_stats_df(n=SCENARIOS.SAMPLE_SIZE_TINY, seed=SEEDS.REPORT_FORMAT_1)
+ real_df = synth_df.copy()
+ real_df[["pnl", "trade_duration", "idle_duration"]] = np.inf
+ content = self._write_report(synth_df, real_df=real_df, skip_feature_analysis=True)
+ self.assertIn("_Not performed (no comparable finite observations)._", content)
+ self.assertIn(
+ "6. **Distribution Shift** - Not performed (no comparable finite observations)",
+ content,
+ )
+ self.assertNotIn("no real episodes provided", content)
+
def test_distribution_shift_section_present_with_real_episodes(self):
"""Distribution Shift section renders metrics table when real episodes provided."""
# Synthetic df (ensure >=10 non-NaN per feature)
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()
convert_optuna_params_to_model_params,
deepmerge,
)
+from ReforceXY.user_data.strategies.RLAgentStrategy import RLAgentStrategy
class RecordingPolicy:
self.addCleanup(model.close_envs)
return model
+ def test_strategy_leverage_respects_pair_bounds_and_invalid_config(self):
+ """A strategy callback always returns a finite leverage inside pair limits."""
+ strategy = RLAgentStrategy.__new__(RLAgentStrategy)
+ arguments = {
+ "pair": "BTC/USDT",
+ "current_time": dt(2026, 1, 1, tzinfo=timezone.utc),
+ "current_rate": 100.0,
+ "proposed_leverage": 2.0,
+ "max_leverage": 5.0,
+ "entry_tag": None,
+ "side": "long",
+ }
+ for configured, expected in (
+ (None, 2.0),
+ (0.5, 1.0),
+ (10.0, 5.0),
+ (3.0, 3.0),
+ (float("nan"), 2.0),
+ (float("inf"), 2.0),
+ (10**500, 2.0),
+ (-5.0, 1.0),
+ ("invalid", 2.0),
+ (True, 2.0),
+ ):
+ with self.subTest(configured=configured):
+ strategy.config = {} if configured is None else {"leverage": configured}
+ self.assertEqual(strategy.leverage(**arguments), expected)
+
def test_training_preserves_raw_prices_and_returns_best_checkpoint(self):
for drop in (False, True):
with self.subTest(drop_ohlc_from_features=drop), tempfile.TemporaryDirectory() as temp:
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:return: A leverage amount, which will be between 1.0 and max_leverage.
"""
- return min(self.config.get("leverage", proposed_leverage), max_leverage)
+ configured = self.config.get("leverage")
+ if configured is None:
+ requested = proposed_leverage
+ else:
+ try:
+ requested = float(configured)
+ except (TypeError, ValueError, OverflowError):
+ requested = float("nan")
+ if isinstance(configured, bool) or not np.isfinite(requested):
+ logger.warning("Invalid leverage value %r; using proposed leverage", configured)
+ requested = proposed_leverage
+ return float(max(1.0, min(requested, max_leverage)))
def is_short_allowed(self) -> bool:
trading_mode = self.config.get("trading_mode")
--- /dev/null
+"""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()