]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(quickadapter): address validated test review findings (#281)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 00:03:34 +0000 (02:03 +0200)
committerGitHub <noreply@github.com>
Fri, 2 Oct 2026 00:03:34 +0000 (02:03 +0200)
* fix(quickadapter): address validated test review findings

Replace incidental test assertions with independent consumer oracles and real callback and coverage-runner scenarios. Recover persisted holdout metadata causally and stabilize MMAD constant medians. Refresh the exact type snapshot, use a native drawer fixture, and correct FRAMA and coverage documentation without lowering the coverage floor.

* style(quickadapter): group NATR multiplier bounds

18 files changed:
.github/workflows/quality.yml
README.md
quickadapter/.basedpyright/diagnostics.json
quickadapter/.coveragerc
quickadapter/tests/test_coverage_floor.py
quickadapter/tests/test_label_transformer.py
quickadapter/tests/test_regressor_prediction_dedup.py
quickadapter/tests/test_regressor_prediction_history.py
quickadapter/tests/test_regressor_selection.py
quickadapter/tests/test_regressor_weight_support.py
quickadapter/tests/test_strategy_sizing.py
quickadapter/tests/test_utils_canonical_defaults.py
quickadapter/tests/test_utils_indicators.py
quickadapter/tests/test_utils_zigzag.py
quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py
quickadapter/user_data/strategies/LabelTransformer.py
quickadapter/user_data/strategies/Utils.py
scripts/run-coverage.sh [new file with mode: 0755]

index 51075a6de10ccf312db7e908340c2b38a0b15d85..ec0c869c9b893e9c35cc1529ce29c3260c07879f 100644 (file)
@@ -86,15 +86,7 @@ jobs:
             --env COVERAGE_FILE=/tmp/.coverage \
             --entrypoint sh \
             "${{ matrix.image }}" \
-            -c '
-              set -eu
-              python -m coverage run -m unittest discover -s "${{ matrix.context }}/tests" -v
-              echo "::group::Coverage"
-              status=0
-              python -m coverage report || status=$?
-              echo "::endgroup::"
-              exit $status
-            '
+            /workspace/scripts/run-coverage.sh -s "${{ matrix.context }}/tests" -v
 
   reward-space-analysis:
     name: Reward space analysis
index 76e214fde2f11d7e98aa390a69e4dcd9d6d41515..2ff429d6d3244c54ad41b4d01f811b2ec1544c6b 100644 (file)
--- a/README.md
+++ b/README.md
@@ -177,6 +177,10 @@ 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.
 
+MMAD standardization preserves finite constant columns: with `none` or `maxabs`
+normalization they transform to zero and invert exactly, including near-limit
+and subnormal floats.
+
 ### Continual learning
 
 In backtests, continual training uses only a saved model whose training cutoff
@@ -208,6 +212,12 @@ zero and outlier-rejected predictions count. Predictions align to candle dates;
 candles without predictions have `do_predict=0` and downtime zeros display but
 never calibrate.
 
+After a restart, persisted `holdout_rmse` is recovered from the latest produced
+prediction available at the decision time, independently of the calibration
+window. A newer unavailable score remains unavailable rather than reviving an
+older finite score. Recovery does not warm up adaptive thresholds; a model fitted
+in the current session keeps its in-memory score.
+
 ### Backtest evaluation protocol
 
 Evaluate a proposed change against the current configuration on the same unseen
@@ -502,24 +512,24 @@ without a Freqtrade image.
 
 ### Coverage gate
 
-QuickAdapter is the only project with a coverage gate. Its configuration is
-`quickadapter/.coveragerc`, selected explicitly because coverage.py looks for
-`.coveragerc` in the directory it is run from and does not search parents:
+Within the strategy QA matrix, QuickAdapter is the only strategy whose runtime
+regressions enforce a coverage gate. The standalone reward-space analysis suite
+has its own gate; see [its testing documentation](ReforceXY/reward_space_analysis/tests/README.md).
+QuickAdapter's configuration is `quickadapter/.coveragerc`, selected explicitly
+because coverage.py looks for `.coveragerc` in the directory it is run from and
+does not search parents:
 
 ```shell
 export PYTHONPATH=quickadapter/user_data/strategies
 export COVERAGE_RCFILE=quickadapter/.coveragerc COVERAGE_FILE=/tmp/.coverage
 
-python -m coverage run -m unittest discover -s quickadapter/tests -v
-python -m coverage report
+sh scripts/run-coverage.sh -s quickadapter/tests -v
 ```
 
-Export both variables rather than prefixing a single command: an inline prefix
-binds to that command only, so `python -m coverage report` looks for the data
-file in its default location, finds none, and exits 1 with `No data to report.`
-The mistake is loud rather than silent — it cannot ship a green gate on a
-measurement that never happened — but it does break a run that would otherwise
-have finished.
+The shared runner gives both coverage commands the same environment. When running
+`coverage run` and `coverage report` separately, export both variables for both
+commands: prefixing only the run command does not configure the later report,
+which then looks in its default data path and fails with `No data to report.`
 
 Branch coverage is required. Every guard in this codebase is an early return
 or a raise, and statement coverage marks a guard covered the instant its `if`
@@ -540,9 +550,10 @@ percentage, a `precision` coarse enough to round the total past the floor, a
 disabled branch trace, a disabled namespace walk, a source tree that is not the
 measured one, a `fail_under` that has drifted into the inert `[run]` section, the
 three of coverage's default source exclusions, a coverage pragma in production
-source, a workflow that is not pointed at this `.coveragerc`, a workflow that
-stops propagating the report status, and any `omit`, `include`, `exclude_lines`,
-`exclude_also` or `partial_*` of production code.
+source, and any `omit`, `include`, `exclude_lines`, `exclude_also` or
+`partial_*` of production code. The shared `scripts/run-coverage.sh` runner is
+exercised with real passing and failing coverage reports and a failing test suite;
+the checks assert process exit status, not workflow token spelling.
 
 ### Quality checks
 
index fe96aa38f08ea647dbc42bed10bcee93d67b8b16..972c5c2ff00c885221411963d96e156328ca9626 100644 (file)
     },
     {
       "endCharacter": 62,
-      "endLine": 3666,
+      "endLine": 3675,
       "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": 3666
+      "startLine": 3675
     },
     {
       "endCharacter": 68,
-      "endLine": 4046,
+      "endLine": 4055,
       "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": 4042
+      "startLine": 4051
     },
     {
       "endCharacter": 13,
-      "endLine": 4075,
+      "endLine": 4084,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"cdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 19,
-      "startLine": 4070
+      "startLine": 4079
     },
     {
       "endCharacter": 38,
-      "endLine": 4073,
+      "endLine": 4082,
       "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": 4073
+      "startLine": 4082
     },
     {
       "endCharacter": 9,
-      "endLine": 4178,
+      "endLine": 4187,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"pdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 4176
+      "startLine": 4185
     },
     {
       "endCharacter": 42,
-      "endLine": 4177,
+      "endLine": 4186,
       "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": 4177
+      "startLine": 4186
     },
     {
       "endCharacter": 9,
-      "endLine": 4255,
+      "endLine": 4264,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"cdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 4250
+      "startLine": 4259
     },
     {
       "endCharacter": 34,
-      "endLine": 4253,
+      "endLine": 4262,
       "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": 4253
+      "startLine": 4262
     },
     {
       "endCharacter": 99,
-      "endLine": 4444,
+      "endLine": 4453,
       "file": "quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py",
       "message": "No overloads for \"pdist\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 4444
+      "startLine": 4453
     },
     {
       "endCharacter": 79,
-      "endLine": 4444,
+      "endLine": 4453,
       "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": 4444
+      "startLine": 4453
     },
     {
       "endCharacter": 55,
       "startLine": 2603
     },
     {
-      "endCharacter": 34,
-      "endLine": 846,
+      "endCharacter": 35,
+      "endLine": 850,
       "file": "quickadapter/user_data/strategies/Utils.py",
-      "message": "Argument of type \"list[TrendDirection]\" cannot be assigned to parameter \"value\" of type \"float | ExtensionArray | NDArray[Any] | IndexOpsMixin[float, Any] | None\" in function \"__setitem__\"\n  Type \"list[TrendDirection]\" is not assignable to type \"float | ExtensionArray | NDArray[Any] | IndexOpsMixin[float, Any] | None\"\n    \"list[TrendDirection]\" is not assignable to \"float\"\n    \"list[TrendDirection]\" is not assignable to \"ExtensionArray\"\n    \"list[TrendDirection]\" is not assignable to \"ndarray[_AnyShape, dtype[Any]]\"\n    \"list[TrendDirection]\" is not assignable to \"IndexOpsMixin[float, Any]\"\n    \"list[TrendDirection]\" is not assignable to \"None\"",
+      "message": "Argument of type \"list[TrendDirection]\" cannot be assigned to parameter \"value\" of type \"float | IndexOpsMixin[float, Any] | None\" in function \"__setitem__\"\n  Type \"list[TrendDirection]\" is not assignable to type \"float | IndexOpsMixin[float, Any] | None\"\n    \"list[TrendDirection]\" is not assignable to \"float\"\n    \"list[TrendDirection]\" is not assignable to \"IndexOpsMixin[float, Any]\"\n    \"list[TrendDirection]\" is not assignable to \"None\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 846
+      "startLine": 850
     },
     {
-      "endCharacter": 34,
-      "endLine": 846,
+      "endCharacter": 35,
+      "endLine": 850,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "No overloads for \"__setitem__\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 846
+      "startLine": 850
     },
     {
       "endCharacter": 78,
-      "endLine": 892,
+      "endLine": 896,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Type \"tuple[SmoothingMethod, ...]\" is not assignable to declared type \"tuple[SmoothingKernel, ...]\"\n  \"tuple[SmoothingMethod, ...]\" is not assignable to \"tuple[SmoothingKernel, ...]\"\n    Tuple entry 1 is incorrect type\n      Type \"SmoothingMethod\" is not assignable to type \"SmoothingKernel\"\n        Type \"Literal['gaussian_filter1d']\" is not assignable to type \"SmoothingKernel\"\n          \"Literal['gaussian_filter1d']\" is not assignable to type \"Literal['gaussian']\"\n          \"Literal['gaussian_filter1d']\" is not assignable to type \"Literal['kaiser']\"\n          \"Literal['gaussian_filter1d']\" is not assignable to type \"Literal['kaiser_bessel_derived']\"\n          \"Literal['gaussian_filter1d']\" is not assignable to type \"Literal['triang']\"",
       "rule": "reportAssignmentType",
       "severity": "error",
       "startCharacter": 56,
-      "startLine": 892
+      "startLine": 896
     },
     {
       "endCharacter": 98,
-      "endLine": 1634,
+      "endLine": 1639,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Type \"bool | bool_\" is not assignable to return type \"bool\"\n  Type \"bool | bool_\" is not assignable to type \"bool\"\n    \"numpy.bool[builtins.bool]\" is not assignable to \"bool\"",
       "rule": "reportReturnType",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 1634
+      "startLine": 1639
     },
     {
       "endCharacter": 33,
-      "endLine": 1664,
+      "endLine": 1669,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"Scalar\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Scalar\" is not assignable to type \"ConvertibleToInt\"\n    Type \"complex\" is not assignable to type \"ConvertibleToInt\"\n      \"complex\" is not assignable to \"str\"\n      \"complex\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"complex\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"complex\" is incompatible with protocol \"SupportsIndex\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 1664
+      "startLine": 1669
     },
     {
       "endCharacter": 33,
-      "endLine": 1664,
+      "endLine": 1669,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"Scalar\" cannot be assigned to parameter \"x\" of type \"ConvertibleToInt\" in function \"__new__\"\n  Type \"Scalar\" is not assignable to type \"ConvertibleToInt\"\n    Type \"complex\" is not assignable to type \"ConvertibleToInt\"\n      \"complex\" is not assignable to \"str\"\n      \"complex\" is incompatible with protocol \"Buffer\"\n        \"__buffer__\" is not present\n      \"complex\" is incompatible with protocol \"SupportsInt\"\n        \"__int__\" is not present\n      \"complex\" is incompatible with protocol \"SupportsIndex\"\n  ...",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 1664
+      "startLine": 1669
     },
     {
       "endCharacter": 23,
-      "endLine": 1676,
+      "endLine": 1681,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Type \"float\" is not assignable to return type \"T@get_distance\"\n  Type \"float\" is not assignable to type \"T@get_distance\"",
       "rule": "reportReturnType",
       "severity": "error",
       "startCharacter": 11,
-      "startLine": 1676
+      "startLine": 1681
     },
     {
       "endCharacter": 83,
-      "endLine": 2529,
+      "endLine": 2534,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"str\" cannot be assigned to parameter \"key\" of type \"CombinedMetric\" in function \"__getitem__\"\n  Type \"str\" is not assignable to type \"CombinedMetric\"\n    \"str\" is not assignable to type \"Literal['amplitude']\"\n    \"str\" is not assignable to type \"Literal['amplitude_threshold_ratio']\"\n    \"str\" is not assignable to type \"Literal['volume_rate']\"\n    \"str\" is not assignable to type \"Literal['speed']\"\n    \"str\" is not assignable to type \"Literal['efficiency_ratio']\"\n    \"str\" is not assignable to type \"Literal['volume_weighted_efficiency_ratio']\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 58,
-      "startLine": 2529
+      "startLine": 2534
     },
     {
       "endCharacter": 38,
-      "endLine": 3419,
+      "endLine": 3424,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Cannot access attribute \"__func__\" for class \"FunctionType\"\n  Attribute \"__func__\" is unknown",
       "rule": "reportFunctionMemberAccess",
       "severity": "error",
       "startCharacter": 30,
-      "startLine": 3419
+      "startLine": 3424
     },
     {
       "endCharacter": 34,
-      "endLine": 3421,
+      "endLine": 3426,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Cannot access attribute \"__func__\" for class \"FunctionType\"\n  Attribute \"__func__\" is unknown",
       "rule": "reportFunctionMemberAccess",
       "severity": "error",
       "startCharacter": 26,
-      "startLine": 3421
+      "startLine": 3426
     },
     {
       "endCharacter": 55,
-      "endLine": 3651,
+      "endLine": 3656,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 3651
+      "startLine": 3656
     },
     {
       "endCharacter": 38,
-      "endLine": 3676,
+      "endLine": 3681,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 3676
+      "startLine": 3681
     },
     {
       "endCharacter": 55,
-      "endLine": 3702,
+      "endLine": 3707,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 3702
+      "startLine": 3707
     },
     {
       "endCharacter": 56,
-      "endLine": 3703,
+      "endLine": 3708,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 3703
+      "startLine": 3708
     },
     {
       "endCharacter": 33,
-      "endLine": 3775,
+      "endLine": 3780,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"Series[Any] | NDArray[floating[Any]]\" cannot be assigned to parameter \"series\" of type \"Series[Any]\" in function \"calculate_zero_lag\"\n  Type \"Series[Any] | NDArray[floating[Any]]\" is not assignable to type \"Series[Any]\"\n    \"ndarray[_AnyShape, dtype[floating[Any]]]\" is not assignable to \"Series[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 3775
+      "startLine": 3780
     },
     {
       "endCharacter": 69,
-      "endLine": 3775,
+      "endLine": 3780,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Expected 1 more positional argument",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 59,
-      "startLine": 3775
+      "startLine": 3780
     },
     {
       "endCharacter": 40,
-      "endLine": 3830,
+      "endLine": 3847,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"Series[Any] | None\" cannot be assigned to parameter \"series\" of type \"Series[Any]\" in function \"calculate_zero_lag\"\n  Type \"Series[Any] | None\" is not assignable to type \"Series[Any]\"\n    \"None\" is not assignable to \"Series[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 35,
-      "startLine": 3830
+      "startLine": 3847
     },
     {
       "endCharacter": 38,
-      "endLine": 3831,
+      "endLine": 3848,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"Series[Any] | None\" cannot be assigned to parameter \"series\" of type \"Series[Any]\" in function \"calculate_zero_lag\"\n  Type \"Series[Any] | None\" is not assignable to type \"Series[Any]\"\n    \"None\" is not assignable to \"Series[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 34,
-      "startLine": 3831
+      "startLine": 3848
     },
     {
       "endCharacter": 42,
-      "endLine": 3832,
+      "endLine": 3849,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"Series[Any] | None\" cannot be assigned to parameter \"series\" of type \"Series[Any]\" in function \"calculate_zero_lag\"\n  Type \"Series[Any] | None\" is not assignable to type \"Series[Any]\"\n    \"None\" is not assignable to \"Series[Any]\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 3832
+      "startLine": 3849
     },
     {
       "endCharacter": 45,
-      "endLine": 3834,
+      "endLine": 3851,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"index\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 40,
-      "startLine": 3834
+      "startLine": 3851
     },
     {
       "endCharacter": 33,
-      "endLine": 3836,
+      "endLine": 3853,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 3836
+      "startLine": 3853
     },
     {
       "endCharacter": 31,
-      "endLine": 3837,
+      "endLine": 3854,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 3837
+      "startLine": 3854
     },
     {
       "endCharacter": 48,
-      "endLine": 3842,
+      "endLine": 3859,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"index\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 43,
-      "startLine": 3842
+      "startLine": 3859
     },
     {
       "endCharacter": 40,
-      "endLine": 3843,
+      "endLine": 3860,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 3843
+      "startLine": 3860
     },
     {
       "endCharacter": 59,
-      "endLine": 3845,
+      "endLine": 3862,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Cannot access attribute \"iloc\" for class \"NDArray[Any]\"\n  Attribute \"iloc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 55,
-      "startLine": 3845
+      "startLine": 3862
     },
     {
       "endCharacter": 34,
-      "endLine": 3847,
+      "endLine": 3864,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Cannot access attribute \"iloc\" for class \"NDArray[Any]\"\n  Attribute \"iloc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 30,
-      "startLine": 3847
+      "startLine": 3864
     },
     {
       "endCharacter": 51,
-      "endLine": 3847,
+      "endLine": 3864,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 47,
-      "startLine": 3847
+      "startLine": 3864
     },
     {
       "endCharacter": 72,
-      "endLine": 3847,
+      "endLine": 3864,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Cannot access attribute \"iloc\" for class \"NDArray[Any]\"\n  Attribute \"iloc\" is unknown",
       "rule": "reportAttributeAccessIssue",
       "severity": "error",
       "startCharacter": 68,
-      "startLine": 3847
+      "startLine": 3864
     },
     {
       "endCharacter": 40,
-      "endLine": 3914,
+      "endLine": 3931,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Type \"(Series[Any] | NDArray[floating[Any]], int) -> (Series[Any] | NDArray[floating[Any]])\" is not assignable to declared type \"(series: Unknown, timeperiod: Unknown) -> Series[Any]\"\n  Type \"(Series[Any] | NDArray[floating[Any]], int) -> (Series[Any] | NDArray[floating[Any]])\" is not assignable to type \"(series: Unknown, timeperiod: Unknown) -> Series[Any]\"\n    Missing keyword parameter \"series\"\n    Missing keyword parameter \"timeperiod\"\n      Position-only parameter mismatch; parameter \"series\" is not position-only\n      Position-only parameter mismatch; parameter \"timeperiod\" is not position-only\n      Position-only parameter mismatch; expected 2 but received 0\n      Function return type \"Series[Any] | NDArray[floating[Any]]\" is incompatible with type \"Series[Any]\"\n        Type \"Series[Any] | NDArray[floating[Any]]\" is not assignable to type \"Series[Any]\"\n  ...",
       "rule": "reportAssignmentType",
       "severity": "error",
       "startCharacter": 20,
-      "startLine": 3914
+      "startLine": 3931
     },
     {
       "endCharacter": 33,
-      "endLine": 3916,
+      "endLine": 3933,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Type \"(Series[Any] | NDArray[floating[Any]], int) -> (Series[Any] | NDArray[floating[Any]])\" is not assignable to declared type \"(series: Unknown, timeperiod: Unknown) -> Series[Any]\"\n  Type \"(Series[Any] | NDArray[floating[Any]], int) -> (Series[Any] | NDArray[floating[Any]])\" is not assignable to type \"(series: Unknown, timeperiod: Unknown) -> Series[Any]\"\n    Missing keyword parameter \"series\"\n    Missing keyword parameter \"timeperiod\"\n      Position-only parameter mismatch; parameter \"series\" is not position-only\n      Position-only parameter mismatch; parameter \"timeperiod\" is not position-only\n      Position-only parameter mismatch; expected 2 but received 0\n      Function return type \"Series[Any] | NDArray[floating[Any]]\" is incompatible with type \"Series[Any]\"\n        Type \"Series[Any] | NDArray[floating[Any]]\" is not assignable to type \"Series[Any]\"\n  ...",
       "rule": "reportAssignmentType",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 3916
+      "startLine": 3933
     },
     {
       "endCharacter": 35,
-      "endLine": 3962,
+      "endLine": 3979,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 3962
+      "startLine": 3979
     },
     {
       "endCharacter": 33,
-      "endLine": 3963,
+      "endLine": 3980,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 25,
-      "startLine": 3963
+      "startLine": 3980
     },
     {
       "endCharacter": 37,
-      "endLine": 4075,
+      "endLine": 4097,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 4075
+      "startLine": 4097
     },
     {
       "endCharacter": 35,
-      "endLine": 4076,
+      "endLine": 4098,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4076
+      "startLine": 4098
     },
     {
       "endCharacter": 33,
-      "endLine": 4077,
+      "endLine": 4099,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 25,
-      "startLine": 4077
+      "startLine": 4099
     },
     {
       "endCharacter": 39,
-      "endLine": 4097,
+      "endLine": 4119,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 4097
+      "startLine": 4119
     },
     {
       "endCharacter": 92,
-      "endLine": 4456,
+      "endLine": 4478,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Type \"bool_ | bool\" is not assignable to return type \"bool\"\n  Type \"bool_ | bool\" is not assignable to type \"bool\"\n    \"numpy.bool[builtins.bool]\" is not assignable to \"bool\"",
       "rule": "reportReturnType",
       "severity": "error",
       "startCharacter": 15,
-      "startLine": 4456
+      "startLine": 4478
     },
     {
       "endCharacter": 85,
-      "endLine": 4857,
+      "endLine": 4879,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"XGBoostPruningCallback\" cannot be assigned to parameter \"object\" of type \"RegressorCallback\" in function \"append\"\n  Type \"XGBoostPruningCallback\" is not assignable to type \"RegressorCallback\"\n    Type \"XGBoostPruningCallback\" is not assignable to type \"(...) -> Any\"\n    \"XGBoostPruningCallback\" is not assignable to \"TrainingCallback\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 16,
-      "startLine": 4857
+      "startLine": 4879
     },
     {
       "endCharacter": 29,
-      "endLine": 4900,
+      "endLine": 4922,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"list[tuple[DataFrame, DataFrame]] | None\" cannot be assigned to parameter \"eval_set\" of type \"List[_LGBM_ScikitValidSet] | None\" in function \"fit\"\n  Type \"list[tuple[DataFrame, DataFrame]] | None\" is not assignable to type \"List[_LGBM_ScikitValidSet] | None\"\n    Type \"list[tuple[DataFrame, DataFrame]]\" is not assignable to type \"List[_LGBM_ScikitValidSet] | None\"\n      \"list[tuple[DataFrame, DataFrame]]\" is not assignable to \"List[_LGBM_ScikitValidSet]\"\n        Type parameter \"_T@list\" is invariant, but \"tuple[DataFrame, DataFrame]\" is not the same as \"_LGBM_ScikitValidSet\"\n        Consider switching from \"list\" to \"Sequence\" which is covariant\n      \"list[tuple[DataFrame, DataFrame]]\" is not assignable to \"None\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 21,
-      "startLine": 4900
+      "startLine": 4922
     },
     {
       "endCharacter": 43,
-      "endLine": 4901,
+      "endLine": 4923,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"list[NDArray[floating[Any]]] | None\" cannot be assigned to parameter \"eval_sample_weight\" of type \"List[_LGBM_WeightType] | None\" in function \"fit\"\n  Type \"list[NDArray[floating[Any]]] | None\" is not assignable to type \"List[_LGBM_WeightType] | None\"\n    Type \"list[NDArray[floating[Any]]]\" is not assignable to type \"List[_LGBM_WeightType] | None\"\n      \"list[NDArray[floating[Any]]]\" is not assignable to \"List[_LGBM_WeightType]\"\n        Type parameter \"_T@list\" is invariant, but \"NDArray[floating[Any]]\" is not the same as \"_LGBM_WeightType\"\n        Consider switching from \"list\" to \"Sequence\" which is covariant\n      \"list[NDArray[floating[Any]]]\" is not assignable to \"None\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 4901
+      "startLine": 4923
     },
     {
       "endCharacter": 62,
-      "endLine": 4904,
+      "endLine": 4926,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"list[RegressorCallback] | None\" cannot be assigned to parameter \"callbacks\" of type \"List[(...) -> Unknown] | None\" in function \"fit\"\n  Type \"list[RegressorCallback] | None\" is not assignable to type \"List[(...) -> Unknown] | None\"\n    Type \"list[RegressorCallback]\" is not assignable to type \"List[(...) -> Unknown] | None\"\n      \"list[RegressorCallback]\" is not assignable to \"List[(...) -> Unknown]\"\n        Type parameter \"_T@list\" is invariant, but \"RegressorCallback\" is not the same as \"(...) -> Unknown\"\n        Consider switching from \"list\" to \"Sequence\" which is covariant\n      \"list[RegressorCallback]\" is not assignable to \"None\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 22,
-      "startLine": 4904
+      "startLine": 4926
     },
     {
       "endCharacter": 35,
-      "endLine": 4926,
+      "endLine": 4948,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Object of type \"None\" is not subscriptable",
       "rule": "reportOptionalSubscript",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4926
+      "startLine": 4948
     },
     {
       "endCharacter": 35,
-      "endLine": 4972,
+      "endLine": 4994,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Object of type \"None\" is not subscriptable",
       "rule": "reportOptionalSubscript",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4972
+      "startLine": 4994
     },
     {
       "endCharacter": 49,
-      "endLine": 5042,
+      "endLine": 5064,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Argument of type \"CatBoostPruningCallback\" cannot be assigned to parameter \"object\" of type \"RegressorCallback\" in function \"append\"\n  Type \"CatBoostPruningCallback\" is not assignable to type \"RegressorCallback\"\n    Type \"CatBoostPruningCallback\" is not assignable to type \"(...) -> Any\"\n    \"CatBoostPruningCallback\" is not assignable to \"TrainingCallback\"",
       "rule": "reportArgumentType",
       "severity": "error",
       "startCharacter": 33,
-      "startLine": 5042
+      "startLine": 5064
     },
     {
       "endCharacter": 75,
-      "endLine": 5285,
+      "endLine": 5307,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Operator \"<=\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 54,
-      "startLine": 5285
+      "startLine": 5307
     },
     {
       "endCharacter": 23,
-      "endLine": 5448,
+      "endLine": 5470,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"best_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 5448
+      "startLine": 5470
     },
     {
       "endCharacter": 38,
-      "endLine": 5455,
+      "endLine": 5477,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"best_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 5455
+      "startLine": 5477
     },
     {
       "endCharacter": 22,
-      "endLine": 5467,
+      "endLine": 5489,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"best_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 11,
-      "startLine": 5467
+      "startLine": 5489
     },
     {
       "endCharacter": 22,
-      "endLine": 5625,
+      "endLine": 5647,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"default_ranges\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 5625
+      "startLine": 5647
     },
     {
       "endCharacter": 14,
-      "endLine": 5661,
+      "endLine": 5683,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"params\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 5661
+      "startLine": 5683
     },
     {
       "endCharacter": 22,
-      "endLine": 5745,
+      "endLine": 5767,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"default_ranges\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 5745
+      "startLine": 5767
     },
     {
       "endCharacter": 14,
-      "endLine": 5779,
+      "endLine": 5801,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"params\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 5779
+      "startLine": 5801
     },
     {
       "endCharacter": 22,
-      "endLine": 5850,
+      "endLine": 5872,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"default_ranges\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 5850
+      "startLine": 5872
     },
     {
       "endCharacter": 22,
-      "endLine": 5951,
+      "endLine": 5973,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"default_ranges\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 5951
+      "startLine": 5973
     },
     {
       "endCharacter": 26,
-      "endLine": 6034,
+      "endLine": 6056,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Declaration \"default_ranges\" is obscured by a declaration of the same name",
       "rule": "reportRedeclaration",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 6034
+      "startLine": 6056
     }
   ],
   "filesAnalyzed": 5,
index 23088bb4701a4ad66d408632096ffe9d2461fb38..13e8a0c30447ef8a4af72e80cfe7a61bce243f25 100644 (file)
@@ -8,5 +8,5 @@ relative_files = True
 show_missing = True
 precision = 1
 include_namespace_packages = True
-# measured 2026-10-01 at 70.5% (23.9% before this suite, same configuration) — raise only, never lower
+# measured 2026-10-01 at 71.5% — raise only, never lower
 fail_under = 67
index 5ac4c5de775b79db86a0d1e33ad6600eb0b201ae..527696204ee8f8ab4e196ac40552a3fcc784af57 100644 (file)
@@ -1,11 +1,12 @@
 """Guards the coverage gate itself; requires the Freqtrade QA image."""
 
 import configparser
+import os
 import re
+import subprocess
 import unittest
 
-import yaml
-from qa_support import COVERAGERC, REPO_ROOT, QaTestCase
+from qa_support import COVERAGERC, REPO_ROOT, QaTestCase, temporary_directory
 
 FLOOR = "fail_under"
 PLACES = "precision"
@@ -21,7 +22,7 @@ ELLIPSIS_BODY = re.compile(r"^\s*(((async )?def .*?)?[\])]+(\s*->.*?)?:\s*)?\.\.
 TYPE_CHECKING = re.compile(r"^\s*if (typing\.)?TYPE_CHECKING:")
 INCLUDE = "include"
 MEASURED_TREE = REPO_ROOT / "quickadapter" / "user_data"
-WORKFLOW = REPO_ROOT / ".github" / "workflows" / "quality.yml"
+COVERAGE_RUNNER = REPO_ROOT / "scripts" / "run-coverage.sh"
 
 
 class CoverageFloorTest(QaTestCase):
@@ -178,65 +179,52 @@ class CoverageFloorTest(QaTestCase):
                     found["if TYPE_CHECKING:"] += 1
         self.assertEqual(counts, found, "these shrink the denominator without any config change")
 
-    def test_the_workflow_runs_the_gate_with_this_configuration_and_propagates_it(self):
-        # Everything above constrains the CONFIGURATION to be meaningful; these two constrain
-        # something to actually read it and to act on the result.
-        #
-        # The rcfile binding: coverage looks for `.coveragerc` in the directory it runs from
-        # and does not search parents, and the QA image has no `.coveragerc`, `setup.cfg`,
-        # `tox.ini` or `pyproject.toml` at the workspace root. Deleting the one
-        # `--env COVERAGE_RCFILE=` line therefore leaves coverage with NO configuration: branch
-        # tracing off, no `source`, `fail_under = 0`, measuring the standard library and
-        # exiting 0. Nothing else in this class is sensitive to that line.
-        workflow = WORKFLOW.read_text()
-        self.assertRegex(
-            workflow,
-            r"--env COVERAGE_RCFILE=\S*\$\{\{ matrix\.context \}\}/\.coveragerc",
-            "the coverage step must be pointed at the context's .coveragerc",
-        )
-        # The propagation: `exit $status` is the only thing that turns a failing
-        # `coverage report` into a failing job. It is scoped to the COVERAGE STEP, matched on
-        # its own indent: every `- name:` in the file is indented, so searching for an
-        # unindented one silently runs off the end of the file and a decoy token anywhere
-        # later in the workflow satisfies the assertion.
-        step = re.search(
-            r"^      - name: Run runtime regressions with coverage$.*?(?=^      - name:|\n\S)",
-            workflow,
-            re.DOTALL | re.MULTILINE,
-        )
-        self.assertIsNotNone(step, "the coverage step is gone from the workflow")
-        self.assertIn("python -m coverage report", step.group(0))
-        self.assertIn("exit $status", step.group(0), "the step must exit with the report status")
-
-    def test_the_workflow_actually_dispatches_the_coverage_step(self):
-        # The previous guards constrain the CONFIGURATION and the body of the coverage step.
-        # This constrains whether the step runs AT ALL, which is the same lever one level up:
-        # flipping the QuickAdapter matrix entry to `coverage: false`, or the step's own `if:`
-        # to a constant, leaves every other test green while CI stops running the gate
-        # entirely. A job that skips the coverage step is indistinguishable from a green one.
-        #
-        # Parsed rather than grepped: a matrix entry that loses its `coverage` key, or an `if:`
-        # that stops referencing the matrix, must fail here rather than pass a substring test.
-        workflow = yaml.safe_load(WORKFLOW.read_text())
-        entries = workflow["jobs"]["strategy-qa"]["strategy"]["matrix"]["include"]
-        by_name = {entry["name"]: entry for entry in entries}
-
-        self.assertIn("QuickAdapter", by_name)
-        self.assertIs(
-            by_name["QuickAdapter"].get("coverage"),
-            True,
-            "the QuickAdapter matrix entry must request the coverage run",
-        )
-        steps = workflow["jobs"]["strategy-qa"]["steps"]
-        coverage_steps = [
-            step for step in steps if step.get("name") == "Run runtime regressions with coverage"
-        ]
-        self.assertEqual(1, len(coverage_steps), "the coverage step must exist exactly once")
-        self.assertEqual(
-            "matrix.coverage",
-            coverage_steps[0].get("if"),
-            "the coverage step must be dispatched by the matrix flag, not a constant",
-        )
+    def _run_coverage(self, floor, *, tests_pass=True):
+        with temporary_directory() as directory:
+            source = directory / "source"
+            tests = directory / "tests"
+            source.mkdir()
+            tests.mkdir()
+            (source / "sample.py").write_text(
+                "def choose(flag):\n    if flag:\n        return 1\n    return 2\n"
+            )
+            expected = 1 if tests_pass else 2
+            (tests / "test_sample.py").write_text(
+                "import unittest\nfrom sample import choose\n"
+                "class SampleTest(unittest.TestCase):\n"
+                "    def test_one_branch(self):\n"
+                f"        self.assertEqual(choose(True), {expected})\n"
+            )
+            rcfile = directory / ".coveragerc"
+            rcfile.write_text(
+                f"[run]\nbranch = True\nsource = {source}\n[report]\nfail_under = {floor}\n"
+            )
+            env = {
+                **os.environ,
+                "PYTHONPATH": str(source),
+                "COVERAGE_RCFILE": str(rcfile),
+                "COVERAGE_FILE": str(directory / ".coverage"),
+            }
+            return subprocess.run(
+                ["sh", str(COVERAGE_RUNNER), "-s", str(tests), "-v"],
+                cwd=directory,
+                env=env,
+                text=True,
+                capture_output=True,
+                check=False,
+                timeout=60,
+            )
+
+    def test_the_runner_propagates_the_actual_coverage_report_status(self):
+        # The same passing suite covers only one branch: 100 fails, 50 passes.
+        for floor, expected_status in ((100, 2), (50, 0)):
+            with self.subTest(floor=floor):
+                result = self._run_coverage(floor)
+                self.assertEqual(result.returncode, expected_status, result.stdout + result.stderr)
+
+    def test_the_runner_preserves_a_test_failure_even_above_the_coverage_floor(self):
+        result = self._run_coverage(50, tests_pass=False)
+        self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
 
     def test_the_source_is_the_measured_tree(self):
         self.assertEqual(
index ce9e57526b429d2ecbf8bf3a0594766a4ce3a619..b9dd5fa986b404de23ab702fe16f7629d7b565b0 100644 (file)
@@ -75,6 +75,14 @@ def transformer(standardization: str = "none", normalization: str = "none", **ov
 
 
 class LabelTransformerTest(QaTestCase):
+    def test_robust_scaling_centers_on_the_median_and_divides_by_the_iqr(self):
+        series = np.array([0.0, 1.0, 2.0, 3.0, 100.0])
+        fitted = transformer("robust", "none")
+        fitted.fit(series)
+        scaled, *_ = fitted.transform(series)
+        # Median 2, quartiles 1 and 3: the outlier does not set the scale.
+        np.testing.assert_allclose(scaled, [-1.0, -0.5, 0.0, 0.5, 49.0], rtol=1e-12)
+
     def test_inverse_transform_recovers_the_input_for_every_scaler_pair(self):
         matrix = label_matrix()
         for standardization in STANDARDIZATION_TYPES:
@@ -217,14 +225,20 @@ class LabelTransformerTest(QaTestCase):
         self.assertLess(float(np.max(np.abs(scaled[:, 1]))), np.max(np.abs(matrix[:, 1])))
         np.testing.assert_allclose(scaled[:, 2], matrix[:, 2], rtol=0.0, atol=0.0)
 
-    def test_a_constant_column_scales_to_zero_because_the_mad_collapses_to_one(self):
-        column = np.full((8, 1), 2.5)
-        fitted = transformer("mmad", "none")
-        fitted.fit(column)
-        scaled, *_ = fitted.transform(column)
-        np.testing.assert_allclose(scaled, 0.0, rtol=0.0, atol=0.0)
-        recovered, *_ = fitted.inverse_transform(scaled)
-        np.testing.assert_allclose(recovered, column, rtol=0.0, atol=0.0)
+    def test_constant_columns_scale_to_zero_and_round_trip_exactly(self):
+        smallest = np.nextafter(0.0, 1.0)
+        for value in (2.5, 1.7e308, -1.7e308, smallest, -smallest):
+            for rows in (1, 2, 3):
+                for normalization in ("none", "maxabs"):
+                    with self.subTest(value=value, rows=rows, normalization=normalization):
+                        column = np.full((rows, 1), value)
+                        fitted = transformer("mmad", normalization)
+                        with np.errstate(over="raise", invalid="raise"):
+                            fitted.fit(column)
+                            scaled, *_ = fitted.transform(column)
+                            recovered, *_ = fitted.inverse_transform(scaled)
+                        np.testing.assert_array_equal(scaled, np.zeros_like(column))
+                        np.testing.assert_array_equal(recovered, column)
 
     def test_a_mad_below_the_isclose_tolerance_is_treated_as_collapsed(self):
         rng = np.random.default_rng(3)
@@ -233,23 +247,12 @@ class LabelTransformerTest(QaTestCase):
         fitted.fit(column)
         scaled, *_ = fitted.transform(column)
         self.assertTrue(np.all(np.isfinite(scaled)))
-        # The true MAD is 5.45e-11, which would scale the residual to O(1e10); the collapse to
-        # 1.0 keeps the output at the residual's own scale, so this distinguishes the guard.
+        # Dividing by the tiny MAD would produce order-one standardized residuals;
+        # the collapsed scale keeps them near their original magnitude.
         self.assertLess(float(np.max(np.abs(scaled))), 1e-6)
         recovered, *_ = fitted.inverse_transform(scaled)
         np.testing.assert_allclose(recovered, column, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL)
 
-    def test_a_non_finite_mad_is_replaced_instead_of_dividing_by_it(self):
-        # np.median of two equal near-maximal floats overflows to inf, so the median absolute
-        # deviation is non-finite too. Dividing by the replacement leaves -inf, whereas dividing
-        # by the inf MAD itself would emit NaN.
-        column = np.array([[1.7e308], [1.7e308]])
-        fitted = transformer("mmad", "none")
-        fitted.fit(column)
-        scaled, *_ = fitted.transform(column)
-        self.assertFalse(np.any(np.isnan(scaled)))
-        self.assertTrue(np.all(np.isneginf(scaled)))
-
     def test_sigmoid_is_the_identity_when_its_scale_is_zero_or_not_finite(self):
         series = label_series()
         for scale in (0.0, 1e-12, np.inf, np.nan):
index 1e251099ce59ee47fbe6b5822f3d0346d1671641..e99c76210042877826867b99143533fd051d7553 100644 (file)
@@ -1,7 +1,6 @@
 """Provability ranking and per-candle deduplication of historic FreqAI predictions.; requires the Freqtrade QA image."""
 
 import unittest
-from types import SimpleNamespace
 from typing import Any
 
 import numpy as np
@@ -33,11 +32,12 @@ _EXPIRED_FORMS: dict[str, dict[str, Any]] = {
 }
 
 
-def drawer_stub(history: pd.DataFrame) -> SimpleNamespace:
-    """A drawer exposing exactly what the patched attach method reads and writes."""
-    stub = SimpleNamespace(historic_predictions={PAIR: history}, model_return_values={})
-    stub._ensure_date_pred_dtype = FreqaiDataDrawer._ensure_date_pred_dtype
-    return stub
+def drawer_stub(history: pd.DataFrame) -> FreqaiDataDrawer:
+    """Use native drawer methods without its disk and model initialization."""
+    drawer = object.__new__(FreqaiDataDrawer)
+    drawer.historic_predictions = {PAIR: history}
+    drawer.model_return_values = {}
+    return drawer
 
 
 class RegressorPredictionDedupTest(QaTestCase):
index 6c58b4fb0507fcbcd407081ddbf166307db6996b..57142626855a4accfde59709b0a227c02f046d10 100644 (file)
@@ -644,26 +644,21 @@ class RegressorPredictionHistoryTest(QaTestCase):
         self.assertFalse(pd.isna(extra["DI_cutoff"]))
 
     def test_a_healthy_di_sample_keeps_its_fitted_cutoff(self):
-        model, dk, _ = calibrated(candles=4, di=[0.5, 1.25, 1.75, 2.5])
+        di = [0.5, 1.25, 1.75, 2.5]
+        expected_params = sp.stats.weibull_min.fit(di, floc=0)
+        expected_cutoff = sp.stats.weibull_min.ppf(0.999, *expected_params)
+        model, dk, _ = calibrated(candles=4, di=di)
         extra = dk.data["extra_returns_per_train"]
 
         model.fit_live_predictions(dk, PAIR)
 
-        # The fallback is not taken: the published cutoff is the Weibull quantile of the
-        # parameters the same call recorded, so the substitution is the only divergence.
-        self.assertNotEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
-        self.assertTrue(np.isfinite(extra["DI_cutoff"]))
         np.testing.assert_allclose(
-            extra["DI_cutoff"],
-            sp.stats.weibull_min.ppf(
-                0.999,
-                extra["DI_value_param1"],
-                extra["DI_value_param2"],
-                extra["DI_value_param3"],
-            ),
+            [extra[f"DI_value_param{i}"] for i in (1, 2, 3)],
+            expected_params,
             rtol=1e-12,
             atol=0.0,
         )
+        np.testing.assert_allclose(extra["DI_cutoff"], expected_cutoff, rtol=1e-12, atol=0.0)
 
     def test_missing_and_non_positive_di_values_never_yield_a_nan_cutoff(self):
         for di in ([np.nan] * 4, [0.0, 0.0, 0.0, 0.0], [-1.0, -2.0, -3.0, -4.0]):
@@ -727,15 +722,45 @@ class RegressorPredictionHistoryTest(QaTestCase):
                     QuickAdapterRegressorV3.optuna_validate_value(extra["holdout_rmse"])
                 )
 
-    def test_a_replayed_produced_holdout_score_is_used_when_present(self):
-        model, dk, _ = calibrated(candles=2, holdout=[np.nan, 1.75])
-        model._holdout_rmse = {PAIR: float("inf")}
-        model._session_fitted_pairs = set()
-        extra = dk.data["extra_returns_per_train"]
-
-        model.fit_live_predictions(dk, PAIR)
-
-        self.assertEqual(extra["holdout_rmse"], 1.75)
+    def test_replayed_holdout_is_as_of_the_decision_and_independent_of_calibration(self):
+        persisted = history(
+            [
+                "2026-01-01 00:00",
+                "2026-01-01 00:00",
+                "2026-01-01 00:05",
+                "2026-01-01 00:04",
+                "2026-01-01 00:10",
+                "2026-01-01 00:15",
+            ],
+            [True, True, True, False, True, True],
+            values=[-0.5, 0.5, 0.0, 5.0, 9.0, 7.0],
+            holdout=[0.25, 0.5, np.nan, 888.0, 999.0, 777.0],
+        )
+        persisted.loc[5, "date_pred"] = pd.NaT
+        for order in (list(range(6)), [4, 2, 0, 3, 1, 5]):
+            with self.subTest(order=order):
+                model, dk, _ = calibrated(candles=2)
+                model.dd.historic_predictions[PAIR] = persisted.iloc[order].reset_index(drop=True)
+                model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY] = (
+                    "2026-01-01T00:05:00+00:00"
+                )
+                model._calibration_current_candles[PAIR] = pd.Timestamp(
+                    "2026-01-01 00:05", tz="UTC"
+                )
+                model._holdout_rmse = {PAIR: float("inf")}
+                model._session_fitted_pairs = set()
+                model.fit_live_predictions(dk, PAIR)
+                self.assertEqual(dk.data["extra_returns_per_train"]["holdout_rmse"], 0.5)
+                self.assertEqual(dk.data["labels_mean"][LABEL], 0.0)
+
+    def test_unavailable_replayed_holdout_does_not_resurrect_an_older_score(self):
+        for latest, expected in ((np.inf, np.inf), (np.nan, 0.5)):
+            with self.subTest(latest=latest):
+                model, dk, _ = calibrated(candles=2, holdout=[0.5, latest])
+                model._holdout_rmse = {PAIR: np.inf}
+                model._session_fitted_pairs = set()
+                model.fit_live_predictions(dk, PAIR)
+                self.assertEqual(dk.data["extra_returns_per_train"]["holdout_rmse"], expected)
 
     def test_an_unfitted_pair_with_no_replay_column_reports_infinite(self):
         model, dk, _ = calibrated(candles=2)
index 77c8a5101b3f2975619d6e78ca95486e4f27ef56..e476cb42659412571cae9b6d959ca490c37896f3 100644 (file)
@@ -1425,25 +1425,38 @@ class RegressorSelectionTest(QaTestCase):
                 self.assertTrue(-1.0 <= float(minimum) <= float(maximum) <= 1.0)
 
     def test_each_requested_threshold_method_yields_its_own_numbers(self):
-        # A range check cannot tell delegation from substitution: every method lands inside
-        # [-1, 1] whatever it computes. Forcing `threshold_func` to Otsu inside the consumer
-        # left the whole suite green while `mean` silently became Otsu's -0.9485 instead of
-        # -0.55, so an entry gate used a method the config never asked for.
-        #
-        # The full tuple is pinned per method, not just the differing one. On this fixture
-        # `yen` happens to coincide with `otsu`, and `li` with `mean`; that is why the whole
-        # table is asserted — rerouting any single method to any other breaks it.
-        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        # Eight negative minima and seven positive maxima give independently sorted
+        # tails. Their histograms distinguish every requested threshold method.
+        series = pd.Series(
+            [
+                0.1,
+                -0.95,
+                0.15,
+                -0.92,
+                0.2,
+                -0.9,
+                0.25,
+                -0.6,
+                0.3,
+                -0.55,
+                0.6,
+                -0.5,
+                0.8,
+                -0.1,
+                0.9,
+                -0.08,
+                0.1,
+            ]
+        )
         expected = {
-            "mean": (-0.55, 0.8),
-            "isodata": (-0.64970703125, 0.8),
-            "li": (-0.55, 0.8),
-            "minimum": (-0.94267578125, 0.8),
-            "otsu": (-0.94853515625, 0.8),
-            "triangle": (-0.94560546875, 0.8),
-            "yen": (-0.94853515625, 0.8),
+            "mean": (-0.575, 0.45714285714285713),
+            "isodata": (-0.64583984375, 0.49423828125),
+            "li": (-0.7694279130567987, 0.4070990364894884),
+            "minimum": (-0.75119140625, 0.45029296875),
+            "otsu": (-0.49970703125, 0.30087890625),
+            "triangle": (-0.94490234375, 0.15439453125),
+            "yen": (-0.60166015625, 0.25107421875),
         }
-        self.assertEqual(sorted(expected), sorted(SKIMAGE_THRESHOLD_METHODS))
         for method, bounds in expected.items():
             with self.subTest(method=method):
                 np.testing.assert_allclose(
@@ -2037,15 +2050,6 @@ class RegressorSelectionTest(QaTestCase):
                     mode=RAISE,
                     reference_matrix=unique_rows(4),
                 )
-                with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
-                    kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
-                        metric,
-                        weights=np.array([0.5, 0.5]),
-                        mode=WARN,
-                        reference_matrix=unique_rows(4),
-                    )
-                self.assertNotIn("w", kwargs)
-                self.assertIn("using uniform weights", captured.output[0])
 
     def test_a_minkowski_p_reaches_cdist_only_when_it_validates(self):
         reference = unique_rows(4)
index 35522474e42bbd79658070ae8ba8987fe0247efc..afa1b51cf68c823abacebd0901728f1bb580161e 100644 (file)
@@ -199,9 +199,12 @@ class ComposeTrainWeightsTest(QaTestCase):
 
     def test_a_composed_set_that_passes_support_is_returned(self):
         result = self._compose(
-            np.ones(4), np.array([1.0, 1.0, 1.0, 1.0]), min_effective_sample_size=4.0
+            np.array([1.0, 2.0, 3.0, 4.0, 5.0]),
+            np.array([3.0, 1.0, 2.0, 1.0, 0.0]),
+            min_effective_sample_size=3.0,
         )
-        self.assertEqual(result.size, 4)
+        # Surviving products [3, 2, 6, 4] have mean 3.75; the dropped row stays zero.
+        np.testing.assert_allclose(result, [1.0, 2.0 / 3.0, 2.0, 4.0 / 3.0, 0.0], rtol=1e-12)
 
     def test_a_composed_set_that_breaches_support_routes_through_the_policy(self):
         with self.assertRaisesRegex(ValueError, "effective_sample_size"):
index 0e0181aebd4b860315bf25c8e9098a40dae63daf..08de002c49ddc053a56ac23911dbe4616cd45bb6 100644 (file)
@@ -3,14 +3,16 @@
 import datetime
 import math
 import unittest
+from types import SimpleNamespace
+from unittest import mock
 
 import numpy as np
 import pandas as pd
-from qa_support import QaTestCase
+from freqtrade.enums import RunMode
+from freqtrade.persistence import Trade
+from qa_support import PAIR, QaTestCase, model_config, temporary_directory
 from QuickAdapterV3 import QuickAdapterV3
 
-PAIR = "BTC/USDT"
-
 
 def strategy(*, duration=10, natr=2.0, fraction=0.5, candle_secs=300):
     """A strategy whose sizing collaborators return fixed, stated values."""
@@ -115,15 +117,6 @@ class DistanceTest(QaTestCase):
     def _take_profit(self, model, **kwargs):
         return model.get_take_profit_distance(frame(), trade(**kwargs.pop("trade", {})), **kwargs)
 
-    def test_the_stoploss_distance_matches_its_formula(self):
-        model = strategy(duration=10, natr=2.0)
-        expected = (
-            100.0 * (2.0 / 100.0) * 0.5 * QuickAdapterV3.get_stoploss_factor(10 + round(0**1.5))
-        )
-        self.assertAlmostEqual(
-            model.get_stoploss_distance(frame(), trade(), 100.0, 0.5), expected, places=10
-        )
-
     def test_the_take_profit_distance_uses_the_open_rate_not_the_current_rate(self):
         model = strategy(duration=10, natr=2.0)
         expected = 100.0 * (2.0 / 100.0) * 0.5 * QuickAdapterV3.get_take_profit_factor(10)
@@ -188,6 +181,124 @@ class DistanceTest(QaTestCase):
         self.assertEqual(model.get_take_profit_distance(frame(), trade(), 0.5), 0.0)
 
 
+class RuntimeSizingTest(QaTestCase):
+    def setUp(self):
+        super().setUp()
+        directory = self.enterContext(temporary_directory())
+        config = model_config(
+            directory,
+            runmode=RunMode.BACKTEST,
+            exit_pricing={"trade_natr_method": "quantile_interpolation"},
+        )
+        self.model = QuickAdapterV3(config)
+        self.model.freqai_info = config["freqai"]
+        self.model.bot_start()
+        self.now = datetime.datetime(2026, 1, 1, 0, 10, tzinfo=datetime.UTC)
+        self.frame = pd.DataFrame(
+            {
+                "date": pd.date_range("2026-01-01", periods=3, freq="5min", tz="UTC"),
+                "open": [100.0] * 3,
+                "high": [101.0] * 3,
+                "low": [99.0] * 3,
+                "close": [100.0] * 3,
+                "natr_label_period_candles": [1.0, 4.0, 8.0],
+                "label_natr_multiplier": [3.0] * 3,
+            }
+        )
+        self.model.dp = SimpleNamespace(
+            get_analyzed_dataframe=lambda **kwargs: (self.frame, None),
+            _exchange=SimpleNamespace(
+                get_min_pair_stake_amount=lambda *args: 1.0,
+                amount_to_contract_precision=lambda pair, amount: amount,
+            ),
+        )
+
+    def position(self, short=False):
+        return Trade(
+            pair=PAIR,
+            open_rate=100.0,
+            open_date=self.now - datetime.timedelta(minutes=5),
+            is_short=short,
+            stake_amount=100.0,
+            amount=1.0,
+            leverage=2.0,
+            exchange="binance",
+            fee_open=0.0,
+            fee_close=0.0,
+            orders=[],
+        )
+
+    def test_stoploss_distance_uses_current_not_entry_price(self):
+        # Entry NATR 4 has mean rank 0.25 in [4, 8], giving interpolated NATR 7.
+        # One candle has elapsed since entry; the price has moved from 100 to 120.
+        expected = 120.0 * 0.07 * 3.0 * 0.5 * 2.75 / (1.2675 + math.atan(0.25))
+        self.assertAlmostEqual(
+            self.model.get_stoploss_distance(self.frame, self.position(), 120.0, 0.5),
+            expected,
+            places=11,
+        )
+
+    def test_stoploss_callback_converts_distance_relative_to_current_rate(self):
+        fraction = QuickAdapterV3._CUSTOM_STOPLOSS_NATR_MULTIPLIER_FRACTION
+        expected = 0.07 * 3.0 * fraction * 2.75 / (1.2675 + math.atan(0.25)) * 2.0
+        for short in (False, True):
+            with self.subTest(short=short):
+                self.assertAlmostEqual(
+                    self.model.custom_stoploss(
+                        PAIR, self.position(short), self.now, 120.0, 0.2, False
+                    ),
+                    expected,
+                    places=11,
+                )
+
+    def test_partial_exit_reduces_stake_after_the_real_target_is_crossed(self):
+        for short, favorable, unfavorable in ((False, 120.0, 105.0), (True, 80.0, 95.0)):
+            with self.subTest(short=short):
+                position = self.position(short)
+                store = {}
+                # Isolate only database persistence, not target or sizing calculations.
+                with (
+                    mock.patch.object(
+                        position,
+                        "get_custom_data",
+                        side_effect=lambda key, default=None, store=store: store.get(key, default),
+                    ),
+                    mock.patch.object(
+                        position,
+                        "set_custom_data",
+                        side_effect=lambda key, value, store=store: store.update({key: value}),
+                    ),
+                ):
+                    before = self.model.adjust_trade_position(
+                        position,
+                        self.now,
+                        favorable,
+                        0.2,
+                        1.0,
+                        1000.0,
+                        favorable,
+                        unfavorable,
+                        0.2,
+                        0.2,
+                    )
+                    self.assertIsNone(before)
+                    after = self.model.adjust_trade_position(
+                        position,
+                        self.now,
+                        unfavorable,
+                        0.2,
+                        1.0,
+                        1000.0,
+                        unfavorable,
+                        favorable,
+                        0.2,
+                        0.2,
+                    )
+                    stake_reduction = -100.0 * QuickAdapterV3.partial_exit_stages[0][1]
+                    direction = "short" if short else "long"
+                    self.assertEqual(after, (stake_reduction, f"take_profit_{direction}_0"))
+
+
 class ThrottleCallbackTest(QaTestCase):
     def test_a_non_callable_is_refused(self):
         model = strategy()
index 419c6f407a2d8124af95a96d0fda9879e45c8521..d82ab7637e1f733ad2bc6dc27c85eb5496e62ba4 100644 (file)
@@ -184,18 +184,6 @@ class UtilsCanonicalDefaultsTest(QaTestCase):
                 _validate_params({}, LOGGER, "freqai", specs, inline_defaults), inline_defaults
             )
 
-    def test_the_label_kind_registry_wires_each_kind_to_its_own_pair(self):
-        self.assertEqual(
-            sorted(Utils._LABEL_KIND_REGISTRY),
-            sorted(kind for kind, _, _ in LABEL_KINDS),
-        )
-        for kind, _, defaults in LABEL_KINDS:
-            specs, registry_defaults, cross_field = Utils._LABEL_KIND_REGISTRY[kind]
-            with self.subTest(kind=kind):
-                self.assertIs(registry_defaults, defaults)
-                self.assertEqual(sorted(specs), sorted(defaults))
-                self.assertIs(cross_field is not None, kind == "label_smoothing")
-
     def test_each_accessor_returns_the_defaults_for_an_empty_config(self):
         for kind, accessor, defaults in LABEL_KINDS:
             with self.subTest(kind=kind):
index a0237549ff71bcaa8ff7b03c4c8820e2c7cccbc0..692aeeba45aec045fa890f663eeadd22b3f5b175 100644 (file)
@@ -12,7 +12,6 @@ from qa_support import QaTestCase, ohlcv_frame
 from Utils import (
     MA_MODES,
     PRICE_MODES,
-    _fractal_dimension,
     alligator,
     bottom_log_return,
     calculate_min_extrema,
@@ -479,53 +478,6 @@ class UtilsIndicatorsTest(QaTestCase):
         self.assertTrue(np.isnan(result[:5]).all())
         assert_allclose(result[5:], np.full(15, 7.0), rtol=0.0, atol=0.0)
 
-    def test_frama_reproduces_close_because_the_dimension_is_one_up_to_rounding(self):
-        # The real contract, asserted rather than implied. HL1 and HL2 each span a HALF of the
-        # window while HL3 spans all of it, so HL1 + HL2 <= 2 * HL3 and D <= 1, which makes
-        # alpha exp(-4.6 * (D - 1)) indistinguishable from 1 at this tolerance. This pins the
-        # DEGENERACY as a fact: a change that made the dimension vary, or the -4.6 scale
-        # observable, would have to update this test rather than pass unnoticed. It does NOT
-        # establish that alpha is a literal 1.0 — this fixture is monotone and happens to land
-        # on exactly 1.0, where
-        # test_the_fractal_dimension_can_exceed_one_by_rounding does not.
-        closes = np.array([100.0 + i for i in range(30)])
-        result = frama(ohlcv_frame(closes.tolist()), 6).to_numpy()
-        self.assertTrue(np.isnan(result[:5]).all())
-        assert_allclose(result[5], closes[:6].mean(), rtol=0.0, atol=1e-9)
-        assert_allclose(result[6:], closes[6:], rtol=0.0, atol=1e-9)
-
-    def test_the_fractal_dimension_is_one_within_rounding_on_every_window(self):
-        # Asserted directly on the helper. The name says "within rounding" on purpose: the
-        # clip's lower bound is not a floor in practice, because `np.clip` is a no-op for
-        # values already at or above it, and the ratio lands on 1.0 + O(1e-16) for some
-        # windows. Asserting `== 1.0` here would be pinning a rounding accident of these
-        # three fixtures rather than the contract.
-        trending_high = 100.0 + np.arange(16) * 0.5
-        trending_low = trending_high - 0.8
-        noisy = np.array([100.0 + i for i in range(16)])
-        plateaus = np.array(
-            [1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 2.0, 2.0, 2.0, 9.0, 9.0, 9.0, 3.0, 3.0, 3.0, 4.0]
-        )
-        for highs, lows in (
-            (trending_high, trending_low),
-            (noisy, noisy - 0.3),
-            (plateaus, plateaus - 1.0),
-        ):
-            with self.subTest(highs=highs[:3].tolist()):
-                self.assertAlmostEqual(_fractal_dimension(highs, lows, 16), 1.0, places=12)
-
-    def test_the_fractal_dimension_can_exceed_one_by_rounding(self):
-        # The counterexample that makes the case above honest. This window is not synthetic:
-        # the ratio computes to 1.0000000000000002, the clip leaves it untouched because it is
-        # already above the lower bound, and alpha becomes 0.999999999999999. Anything that
-        # branches on `alpha == 1.0` or promises bit-exact `close` is wrong on this input.
-        highs = np.array([100.2, 101.2, 102.7, 101.6, 102.8, 102.5, 102.5, 102.8, 100.9, 100.3])
-        lows = np.array([99.9, 100.9, 101.6, 101.1, 102.4, 102.3, 101.6, 101.7, 100.6, 99.9])
-        dimension = _fractal_dimension(highs, lows, 10)
-        self.assertGreater(dimension, 1.0)
-        self.assertLessEqual(dimension, 1.0 + 1e-15)
-        self.assertLess(np.exp(-4.6 * (dimension - 1.0)), 1.0)
-
     def test_the_zero_lag_frama_de_lags_before_the_fractal_dimension(self):
         # zero_lag replaces high, low and close with calculate_zero_lag first, which makes the
         # seed the mean of the de-lagged closes rather than the raw ones. Ignoring the flag
index 93ebead3b7ecf466a0ae70456f0291f2185a3b3d..fccac55bb6032b244fa51a001f20992a1af38ded 100644 (file)
@@ -245,17 +245,8 @@ class UtilsZigzagTest(QaTestCase):
         self.assertNotEqual(result.volume_rates[0], 1.0)
 
     def test_the_volume_weighted_efficiency_ratio_weights_each_step_by_its_destination(self):
-        # The sixth COMBINED_METRIC, and the only one that had no numeric assertion: every
-        # existing check either mirrored `efficiency_ratio` or expected 1.0, which holds for
-        # ANY positive weighting. The W_LEGS fixture is monotone, so its ratio is 1.0 under
-        # every convention and cannot discriminate. Shifting the volume window by one candle
-        # — same length, different slice — left all 968 tests green.
-        #
-        # This fixture is a slow uptrend carrying a fast oscillation: the legs are short and
-        # genuinely non-monotone, so the ratio drops well below 1.0 and the window offset
-        # changes the answer. The oracle below restates the documented convention: every
-        # close-to-close step is weighted by the volume of the candle it lands ON, and the
-        # pivot bar at the start of the leg is excluded from the total.
+        # Weight each close-to-close log step by its destination candle's volume,
+        # excluding the starting pivot. Oscillating legs distinguish window offsets.
         steps = np.arange(120)
         closes = 100.0 + 0.02 * steps + 0.25 * np.sin(steps / 2.0)
         volumes = 10.0 + 5.0 * np.sin(steps / 4.0)
@@ -263,7 +254,7 @@ class UtilsZigzagTest(QaTestCase):
         result = _zigzag(ohlcv_frame(closes, volumes), **PARAMS)
 
         log_closes = np.log(closes)
-        checked = 0
+
         for index in range(len(result.indices) - 1):
             start, end = sorted((result.indices[index], result.indices[index + 1]))
             end += 1
@@ -276,18 +267,6 @@ class UtilsZigzagTest(QaTestCase):
                     float(expected),
                     places=12,
                 )
-            checked += 1
-        self.assertGreater(checked, 5, "the fixture must produce enough legs to be meaningful")
-        # And the convention is load-bearing here, not an identity: the one-candle shift a
-        # mutation introduces, and the unweighted ratio, both differ from the pinned value.
-        start, end = sorted((result.indices[5], result.indices[6]))
-        steps_slice = np.diff(log_closes[start : end + 1])
-        shifted = np.abs(steps_slice * (volumes[start:end] / volumes[start:end].sum())).sum()
-        self.assertNotAlmostEqual(
-            float(result.volume_weighted_efficiency_ratios[5]),
-            abs(steps_slice.sum()) / shifted,
-            places=9,
-        )
 
     def test_the_efficiency_ratio_measures_directness_of_the_leg(self):
         closes = wiggly_closes()
index 5df822ab9bf0a1748f75236a4aab6682ef257756..217d62fc26271fc3a6ddefbe14a433bfeb428696 100644 (file)
@@ -3493,14 +3493,18 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         history = self.dd.historic_predictions[pair]
         if self.live:
             history = _dedupe_historic_predictions_on_date_pred(history, pair)
-            history = self._calibration_history(dk, pair, history, fit_live_predictions_candles)
-            remaining = fit_live_predictions_candles - len(history)
+            calibration_history = self._calibration_history(
+                dk, pair, history, fit_live_predictions_candles
+            )
+            remaining = fit_live_predictions_candles - len(calibration_history)
             warmed_up = remaining <= 0
             if not warmed_up:
                 logger.warning(
                     f"[{pair}] Fit live predictions not warmed up: {remaining} produced observations until warmup completion"
                 )
-        pred_df = history.tail(fit_live_predictions_candles).reset_index(drop=True)
+        else:
+            calibration_history = history
+        pred_df = calibration_history.tail(fit_live_predictions_candles).reset_index(drop=True)
 
         di_values = pred_df.get("DI_values")
         if di_values is not None:
@@ -3636,11 +3640,16 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                         f"{current_dates.index[0]}; defaulting to inf"
                     )
         elif pair not in self._session_fitted_pairs:
-            historic = self.dd.historic_predictions.get(pair)
-            if historic is not None and "holdout_rmse" in historic:
+            decision_time = self._calibration_decision_time(dk, pair, history)
+            if decision_time is not None and "holdout_rmse" in history:
+                # Model metadata can predate the current calibration period, but not
+                # come from a future or unproduced prediction.
+                produced = (
+                    _produced_prediction_mask(history)
+                    & history["date_pred"].le(decision_time).to_numpy()
+                )
                 holdout_values = pd.to_numeric(
-                    historic.loc[_produced_prediction_mask(historic), "holdout_rmse"],
-                    errors="coerce",
+                    history.loc[produced, "holdout_rmse"], errors="coerce"
                 ).dropna()
                 if not holdout_values.empty:
                     current_holdout_rmse = float(holdout_values.iloc[-1])
index 853642396beb5adb78542b9a23c444ac46495960..b5101426ff79805e422c792e71a9262e65522b09 100644 (file)
@@ -329,6 +329,18 @@ class LabelTransformer(BaseTransform):
         self._fitted_columns: list[str] = []
         self._fitted = False
 
+    @staticmethod
+    def _median(values: NDArray[np.floating]) -> float:
+        """Median of nonempty fitting values without overflowing the central sum."""
+        middle = values.size // 2
+        if values.size % 2:
+            return float(np.partition(values, middle)[middle])
+        partitioned = np.partition(values, (middle - 1, middle))
+        lower, upper = float(partitioned[middle - 1]), float(partitioned[middle])
+        total = lower + upper
+        # Halve only an overflowing sum; subnormal midpoints need the original sum.
+        return total / 2.0 if np.isfinite(total) else lower / 2.0 + upper / 2.0
+
     @staticmethod
     def _apply_scaler(
         values: NDArray[np.floating],
@@ -486,9 +498,9 @@ class LabelTransformer(BaseTransform):
             state.robust_scaler.fit(values.reshape(-1, 1))
             return
         if method == STANDARDIZATION_TYPES[3]:  # mmad
-            state.median = float(np.median(values))
-            mad = np.median(np.abs(values - state.median))
-            state.mad = float(mad) if np.isfinite(mad) and not np.isclose(mad, 0.0) else 1.0
+            state.median = self._median(values)
+            mad = self._median(np.abs(values - state.median))
+            state.mad = mad if np.isfinite(mad) and not np.isclose(mad, 0.0) else 1.0
             return
         if method == STANDARDIZATION_TYPES[4]:  # power_yj
             state.power_transformer = PowerTransformer(method="yeo-johnson", standardize=True)
index 4c37cc09de369346d490a12d75aac5a8fd61e5c9..2e3884e23ac306d965bbd4698778ea5114f76e0c 100644 (file)
@@ -1048,8 +1048,8 @@ DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES: Final[int] = 100
 
 DEFAULT_MIN_LABEL_PERIOD_CANDLES: Final[int] = 12
 DEFAULT_MAX_LABEL_PERIOD_CANDLES: Final[int] = 24
-DEFAULT_MIN_LABEL_NATR_MULTIPLIER: Final[float] = 9.0
 DEFAULT_LABEL_NATR_PERIOD: Final[int] = 14
+DEFAULT_MIN_LABEL_NATR_MULTIPLIER: Final[float] = 9.0
 DEFAULT_MAX_LABEL_NATR_MULTIPLIER: Final[float] = 12.0
 
 
@@ -3793,27 +3793,16 @@ def zlema(series: pd.Series, period: int) -> pd.Series:
 def _fractal_dimension(
     highs: NDArray[np.floating], lows: NDArray[np.floating], period: int
 ) -> float:
-    """Fractal dimension over the two half-window ranges of a ``period`` window.
-
-    ``HL3`` spans the whole window while ``HL1`` and ``HL2`` each span a half, so
-    ``HL1 <= HL3`` and ``HL2 <= HL3``, hence ``HL1 + HL2 <= 2 * HL3`` and
-    ``D <= 1`` for every reachable input. In exact arithmetic the clip would then
-    return ``1.0`` on every path. In floating point it does not: the ratio lands
-    on ``1.0 + O(1e-16)`` for some windows, and ``np.clip(D, 1.0, 2.0)`` is a
-    no-op above the lower bound, so it passes that value through. ``alpha`` in
-    :func:`frama` is therefore ``exp(-4.6 * (D - 1))``, which is ``1.0`` to within
-    a few ULP rather than exactly ``1.0``, and ``frama`` reproduces ``close``
-    after its seed up to that same rounding. Measured on one deterministic
-    10-bar window, ``D`` is ``1.0000000000000002`` and ``alpha`` is
-    ``0.999999999999999``.
-
-    Nothing downstream may treat ``alpha`` as a literal ``1.0`` or ``frama`` as
-    bit-exact ``close``; a short-circuit on ``alpha == 1`` would not fire.
-
-    This is the current, asserted behaviour, not the formulation in Ehlers' paper:
-    that one takes the range over ``period // 2`` consecutive sub-ranges, whose sum
-    can exceed twice the full range. Adopting it is an algorithmic change and is
-    deliberately not made here.
+    """Fractal dimension using unnormalized half-window ranges.
+
+    In exact arithmetic, finite half-window ranges satisfy
+    ``HL1 + HL2 <= 2 * HL3``, so clipping gives dimension one. Floating-point
+    rounding can leave the dimension slightly above that bound.
+
+    Ehlers' normalized formulation divides each half-window range by
+    ``period / 2`` and the full-window range by ``period`` before computing
+    the dimension. This implementation does not use that normalization.
+    Reference: https://www.mesasoftware.com/papers/FRAMA.pdf
     """
     if period % 2 != 0 or period < 2:
         raise ValueError(f"Invalid period value {period!r}: must be an even integer >= 2")
@@ -3842,7 +3831,9 @@ def _fractal_dimension(
 
 def frama(df: pd.DataFrame, period: int = 16, zero_lag: bool = False) -> pd.Series:
     """
-    Original FRAMA implementation per Ehlers' paper with optional zero lag.
+    FRAMA-style filter using unnormalized ranges, with optional zero lag.
+
+    See :func:`_fractal_dimension` for the distinction from Ehlers' formulation.
     """
     if period % 2 != 0:
         raise ValueError(f"Invalid period value {period!r}: must be even")
diff --git a/scripts/run-coverage.sh b/scripts/run-coverage.sh
new file mode 100755 (executable)
index 0000000..ef3c098
--- /dev/null
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Run unittest discovery and propagate both test and coverage-report failures.
+set -eu
+
+python -m coverage run -m unittest discover "$@"
+echo "::group::Coverage"
+status=0
+python -m coverage report || status=$?
+echo "::endgroup::"
+exit "$status"