--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
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
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
### 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`
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
},
{
"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,
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
"""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"
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):
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(
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:
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)
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):
"""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
}
-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):
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]):
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)
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(
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)
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"):
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."""
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)
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()
_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):
from Utils import (
MA_MODES,
PRICE_MODES,
- _fractal_dimension,
alligator,
bottom_log_return,
calculate_min_extrema,
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
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)
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
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()
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:
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])
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],
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)
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
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")
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")
--- /dev/null
+#!/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"