]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(quickadapter): normalize FRAMA and cover exit contracts (#284)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 19:11:25 +0000 (21:11 +0200)
committerGitHub <noreply@github.com>
Fri, 2 Oct 2026 19:11:25 +0000 (21:11 +0200)
Normalize FRAMA window ranges while preserving close, seed, historical window and zero-lag preprocessing. Add native custom_exit consumer contracts and fix the remaining index commentary. Refresh documentation, measured coverage and snapshot line positions without changing accepted diagnostics.

README.md
quickadapter/.basedpyright/diagnostics.json
quickadapter/.coveragerc
quickadapter/tests/test_strategy_sizing.py
quickadapter/tests/test_strategy_targets.py
quickadapter/tests/test_utils_indicators.py
quickadapter/user_data/strategies/Utils.py

index 6e2c31ae19c2198707c28133b9983eaab9003b76..4cb56247c5f83d9ac03130239e7775fc9048d4cf 100644 (file)
--- a/README.md
+++ b/README.md
@@ -491,6 +491,11 @@ PYTHONPATH=/workspace/quickadapter/user_data/strategies \
   python -m unittest discover -s quickadapter/tests -v
 ```
 
+QuickAdapter regressions cover normalized FRAMA with an independent numerical
+oracle and `custom_exit` with native Freqtrade trades and filled orders. Callback
+cases include model expiration, outliers, reversal confirmation and persisted
+final take-profit trails for long and short positions, including restart behavior.
+
 Both commands must be run from the repository root, which is what the
 container's `--workdir /workspace` provides. To select one concern, pass a
 pattern that matches the whole `module.Class.method` name; a bare substring
index 972c5c2ff00c885221411963d96e156328ca9626..f0977eaa061c7548c870e3a04e2cc1fc6b66476e 100644 (file)
     },
     {
       "endCharacter": 35,
-      "endLine": 850,
+      "endLine": 849,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "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": 850
+      "startLine": 849
     },
     {
       "endCharacter": 35,
-      "endLine": 850,
+      "endLine": 849,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "No overloads for \"__setitem__\" match the provided arguments",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 8,
-      "startLine": 850
+      "startLine": 849
     },
     {
       "endCharacter": 78,
-      "endLine": 896,
+      "endLine": 895,
       "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": 896
+      "startLine": 895
     },
     {
       "endCharacter": 98,
-      "endLine": 1639,
+      "endLine": 1638,
       "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": 1639
+      "startLine": 1638
     },
     {
       "endCharacter": 33,
-      "endLine": 1669,
+      "endLine": 1668,
       "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": 1669
+      "startLine": 1668
     },
     {
       "endCharacter": 33,
-      "endLine": 1669,
+      "endLine": 1668,
       "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": 1669
+      "startLine": 1668
     },
     {
       "endCharacter": 23,
-      "endLine": 1681,
+      "endLine": 1680,
       "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": 1681
+      "startLine": 1680
     },
     {
       "endCharacter": 83,
-      "endLine": 2534,
+      "endLine": 2533,
       "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": 2534
+      "startLine": 2533
     },
     {
       "endCharacter": 38,
-      "endLine": 3424,
+      "endLine": 3423,
       "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": 3424
+      "startLine": 3423
     },
     {
       "endCharacter": 34,
-      "endLine": 3426,
+      "endLine": 3425,
       "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": 3426
+      "startLine": 3425
     },
     {
       "endCharacter": 55,
-      "endLine": 3656,
+      "endLine": 3655,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 3656
+      "startLine": 3655
     },
     {
       "endCharacter": 38,
-      "endLine": 3681,
+      "endLine": 3680,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 3681
+      "startLine": 3680
     },
     {
       "endCharacter": 55,
-      "endLine": 3707,
+      "endLine": 3706,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 48,
-      "startLine": 3707
+      "startLine": 3706
     },
     {
       "endCharacter": 56,
-      "endLine": 3708,
+      "endLine": 3707,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"rolling\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 49,
-      "startLine": 3708
+      "startLine": 3707
     },
     {
       "endCharacter": 33,
-      "endLine": 3780,
+      "endLine": 3779,
       "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": 3780
+      "startLine": 3779
     },
     {
       "endCharacter": 69,
-      "endLine": 3780,
+      "endLine": 3779,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Expected 1 more positional argument",
       "rule": "reportCallIssue",
       "severity": "error",
       "startCharacter": 59,
-      "startLine": 3780
+      "startLine": 3779
     },
     {
       "endCharacter": 40,
-      "endLine": 3847,
+      "endLine": 3846,
       "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": 3847
+      "startLine": 3846
     },
     {
       "endCharacter": 38,
-      "endLine": 3848,
+      "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": 34,
-      "startLine": 3848
+      "startLine": 3847
     },
     {
       "endCharacter": 42,
-      "endLine": 3849,
+      "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": 36,
-      "startLine": 3849
+      "startLine": 3848
     },
     {
       "endCharacter": 45,
-      "endLine": 3851,
+      "endLine": 3850,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"index\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 40,
-      "startLine": 3851
+      "startLine": 3850
     },
     {
       "endCharacter": 33,
-      "endLine": 3853,
+      "endLine": 3852,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 3853
+      "startLine": 3852
     },
     {
       "endCharacter": 31,
-      "endLine": 3854,
+      "endLine": 3853,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 3854
+      "startLine": 3853
     },
     {
       "endCharacter": 48,
-      "endLine": 3859,
+      "endLine": 3858,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"index\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 43,
-      "startLine": 3859
+      "startLine": 3858
     },
     {
       "endCharacter": 40,
-      "endLine": 3860,
+      "endLine": 3859,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 36,
-      "startLine": 3860
+      "startLine": 3859
     },
     {
       "endCharacter": 59,
-      "endLine": 3862,
+      "endLine": 3861,
       "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": 3862
+      "startLine": 3861
     },
     {
       "endCharacter": 34,
-      "endLine": 3864,
+      "endLine": 3863,
       "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": 3864
+      "startLine": 3863
     },
     {
       "endCharacter": 51,
-      "endLine": 3864,
+      "endLine": 3863,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"iloc\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 47,
-      "startLine": 3864
+      "startLine": 3863
     },
     {
       "endCharacter": 72,
-      "endLine": 3864,
+      "endLine": 3863,
       "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": 3864
+      "startLine": 3863
     },
     {
       "endCharacter": 40,
-      "endLine": 3931,
+      "endLine": 3930,
       "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": 3931
+      "startLine": 3930
     },
     {
       "endCharacter": 33,
-      "endLine": 3933,
+      "endLine": 3932,
       "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": 3933
+      "startLine": 3932
     },
     {
       "endCharacter": 35,
-      "endLine": 3979,
+      "endLine": 3978,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 3979
+      "startLine": 3978
     },
     {
       "endCharacter": 33,
-      "endLine": 3980,
+      "endLine": 3979,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 25,
-      "startLine": 3980
+      "startLine": 3979
     },
     {
       "endCharacter": 37,
-      "endLine": 4097,
+      "endLine": 4096,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 29,
-      "startLine": 4097
+      "startLine": 4096
     },
     {
       "endCharacter": 35,
-      "endLine": 4098,
+      "endLine": 4097,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4098
+      "startLine": 4097
     },
     {
       "endCharacter": 33,
-      "endLine": 4099,
+      "endLine": 4098,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 25,
-      "startLine": 4099
+      "startLine": 4098
     },
     {
       "endCharacter": 39,
-      "endLine": 4119,
+      "endLine": 4118,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"to_numpy\" is not a known attribute of \"None\"",
       "rule": "reportOptionalMemberAccess",
       "severity": "error",
       "startCharacter": 31,
-      "startLine": 4119
+      "startLine": 4118
     },
     {
       "endCharacter": 92,
-      "endLine": 4478,
+      "endLine": 4477,
       "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": 4478
+      "startLine": 4477
     },
     {
       "endCharacter": 85,
-      "endLine": 4879,
+      "endLine": 4878,
       "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": 4879
+      "startLine": 4878
     },
     {
       "endCharacter": 29,
-      "endLine": 4922,
+      "endLine": 4921,
       "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": 4922
+      "startLine": 4921
     },
     {
       "endCharacter": 43,
-      "endLine": 4923,
+      "endLine": 4922,
       "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": 4923
+      "startLine": 4922
     },
     {
       "endCharacter": 62,
-      "endLine": 4926,
+      "endLine": 4925,
       "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": 4926
+      "startLine": 4925
     },
     {
       "endCharacter": 35,
-      "endLine": 4948,
+      "endLine": 4947,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Object of type \"None\" is not subscriptable",
       "rule": "reportOptionalSubscript",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4948
+      "startLine": 4947
     },
     {
       "endCharacter": 35,
-      "endLine": 4994,
+      "endLine": 4993,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Object of type \"None\" is not subscriptable",
       "rule": "reportOptionalSubscript",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 4994
+      "startLine": 4993
     },
     {
       "endCharacter": 49,
-      "endLine": 5064,
+      "endLine": 5063,
       "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": 5064
+      "startLine": 5063
     },
     {
       "endCharacter": 75,
-      "endLine": 5307,
+      "endLine": 5306,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "Operator \"<=\" not supported for \"None\"",
       "rule": "reportOptionalOperand",
       "severity": "error",
       "startCharacter": 54,
-      "startLine": 5307
+      "startLine": 5306
     },
     {
       "endCharacter": 23,
-      "endLine": 5470,
+      "endLine": 5469,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"best_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 12,
-      "startLine": 5470
+      "startLine": 5469
     },
     {
       "endCharacter": 38,
-      "endLine": 5477,
+      "endLine": 5476,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"best_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 27,
-      "startLine": 5477
+      "startLine": 5476
     },
     {
       "endCharacter": 22,
-      "endLine": 5489,
+      "endLine": 5488,
       "file": "quickadapter/user_data/strategies/Utils.py",
       "message": "\"best_params\" is possibly unbound",
       "rule": "reportPossiblyUnboundVariable",
       "severity": "error",
       "startCharacter": 11,
-      "startLine": 5489
+      "startLine": 5488
     },
     {
       "endCharacter": 22,
-      "endLine": 5647,
+      "endLine": 5646,
       "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": 5647
+      "startLine": 5646
     },
     {
       "endCharacter": 14,
-      "endLine": 5683,
+      "endLine": 5682,
       "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": 5683
+      "startLine": 5682
     },
     {
       "endCharacter": 22,
-      "endLine": 5767,
+      "endLine": 5766,
       "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": 5767
+      "startLine": 5766
     },
     {
       "endCharacter": 14,
-      "endLine": 5801,
+      "endLine": 5800,
       "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": 5801
+      "startLine": 5800
     },
     {
       "endCharacter": 22,
-      "endLine": 5872,
+      "endLine": 5871,
       "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": 5872
+      "startLine": 5871
     },
     {
       "endCharacter": 22,
-      "endLine": 5973,
+      "endLine": 5972,
       "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": 5973
+      "startLine": 5972
     },
     {
       "endCharacter": 26,
-      "endLine": 6056,
+      "endLine": 6055,
       "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": 6056
+      "startLine": 6055
     }
   ],
   "filesAnalyzed": 5,
index dbdf96e4062d346bc971220c0d56a035687f49bc..eb357fd6af087b17419204cf71eeef242dd695a6 100644 (file)
@@ -8,5 +8,5 @@ relative_files = True
 show_missing = True
 precision = 1
 include_namespace_packages = True
-# measured 2026-10-02 at 71.6% — raise only, never lower
+# measured 2026-10-02 at 73.1% — raise only, never lower
 fail_under = 68
index 08de002c49ddc053a56ac23911dbe4616cd45bb6..0df66a1cfcbf799b16f8700530a76bf71aacdb34 100644 (file)
@@ -1,5 +1,6 @@
-"""Position sizing and throttle contracts; requires the Freqtrade QA image."""
+"""Trade callbacks, position sizing and throttle contracts; requires the Freqtrade QA image."""
 
+import copy
 import datetime
 import math
 import unittest
@@ -9,9 +10,10 @@ from unittest import mock
 import numpy as np
 import pandas as pd
 from freqtrade.enums import RunMode
-from freqtrade.persistence import Trade
+from freqtrade.persistence import Order, Trade
 from qa_support import PAIR, QaTestCase, model_config, temporary_directory
 from QuickAdapterV3 import QuickAdapterV3
+from Utils import EXTREMA_COLUMN
 
 
 def strategy(*, duration=10, natr=2.0, fraction=0.5, candle_secs=300):
@@ -188,7 +190,10 @@ class RuntimeSizingTest(QaTestCase):
         config = model_config(
             directory,
             runmode=RunMode.BACKTEST,
-            exit_pricing={"trade_natr_method": "quantile_interpolation"},
+            exit_pricing={
+                "trade_natr_method": "quantile_interpolation",
+                "final_take_profit_retracement_fraction": 0.25,
+            },
         )
         self.model = QuickAdapterV3(config)
         self.model.freqai_info = config["freqai"]
@@ -203,6 +208,11 @@ class RuntimeSizingTest(QaTestCase):
                 "close": [100.0] * 3,
                 "natr_label_period_candles": [1.0, 4.0, 8.0],
                 "label_natr_multiplier": [3.0] * 3,
+                "do_predict": [1] * 3,
+                "DI_catch": [1] * 3,
+                EXTREMA_COLUMN: [0.0] * 3,
+                "minima_threshold": [-0.5] * 3,
+                "maxima_threshold": [0.5] * 3,
             }
         )
         self.model.dp = SimpleNamespace(
@@ -213,8 +223,8 @@ class RuntimeSizingTest(QaTestCase):
             ),
         )
 
-    def position(self, short=False):
-        return Trade(
+    def position(self, short=False, exit_stage=0):
+        position = Trade(
             pair=PAIR,
             open_rate=100.0,
             open_date=self.now - datetime.timedelta(minutes=5),
@@ -227,6 +237,39 @@ class RuntimeSizingTest(QaTestCase):
             fee_close=0.0,
             orders=[],
         )
+        position.orders = [
+            Order(
+                ft_order_side=position.exit_side,
+                ft_is_open=False,
+                status="closed",
+                filled=0.1,
+                ft_order_tag=f"take_profit_{position.trade_direction}_{stage}",
+            )
+            for stage in range(exit_stage)
+        ]
+        return position
+
+    def custom_data(self, position, store=None):
+        # Isolate database persistence only; all trade/exit calculations remain native.
+        store = {} if store is None else store
+        self.enterContext(
+            mock.patch.object(
+                position,
+                "get_custom_data",
+                side_effect=lambda key, default=None: store.get(key, default),
+            )
+        )
+        self.enterContext(
+            mock.patch.object(
+                position,
+                "set_custom_data",
+                side_effect=lambda key, value: store.update({key: value}),
+            )
+        )
+        return store
+
+    def exit(self, position, rate=100.0):
+        return self.model.custom_exit(PAIR, position, self.now, rate, 0.0)
 
     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.
@@ -298,6 +341,157 @@ class RuntimeSizingTest(QaTestCase):
                     direction = "short" if short else "long"
                     self.assertEqual(after, (stake_reduction, f"take_profit_{direction}_0"))
 
+    def test_expired_models_exit_before_order_stage_and_date_guards(self):
+        self.frame.loc[self.frame.index[-1], "date"] = pd.NaT
+        self.frame["do_predict"] = 2
+        self.frame["DI_catch"] = 0
+        for short in (False, True):
+            with self.subTest(short=short):
+                position = self.position(short)
+                position.orders.append(
+                    Order(
+                        ft_order_side=position.exit_side, ft_is_open=True, status="open", filled=0.0
+                    )
+                )
+                store = self.custom_data(position, {"n_outliers": 17})
+                self.assertEqual(self.exit(position), "model_expired")
+                self.assertEqual(store, {"n_outliers": 17})
+
+    def test_outliers_count_once_per_valid_candle(self):
+        position = self.position()
+        store = self.custom_data(position, {"last_outlier_date": "not-a-date"})
+        self.frame["DI_catch"] = 0
+        self.assertIsNone(self.exit(position))
+        self.assertIsNone(self.exit(position))
+        self.assertEqual(store["n_outliers"], 1)
+        self.assertEqual(store["last_outlier_date"], self.now.isoformat())
+        self.now += datetime.timedelta(minutes=5)
+        self.frame.loc[self.frame.index[-1], "date"] = self.now
+        self.assertIsNone(self.exit(position))
+        self.assertEqual(store["n_outliers"], 2)
+        self.assertEqual(store["last_outlier_date"], self.now.isoformat())
+        self.frame.loc[self.frame.index[-1], "date"] = pd.NaT
+        self.assertIsNone(self.exit(position))
+        self.assertEqual(store["n_outliers"], 2)
+
+    def test_reversal_exits_require_strict_scores_and_real_price_confirmation(self):
+        self.model.reversal_confirmation.update(
+            {
+                "lookback_period_candles": 0,
+                "decay_fraction": 1.0,
+                "min_natr_multiplier_fraction": 0.5,
+                "max_natr_multiplier_fraction": 0.5,
+            }
+        )
+        for short, boundary, score, rate, tag in (
+            (False, 0.5, 0.6, 80.0, "maxima_detected_long"),
+            (True, -0.5, -0.6, 120.0, "minima_detected_short"),
+        ):
+            with self.subTest(short=short):
+                position = self.position(short)
+                self.custom_data(position)
+                self.frame[EXTREMA_COLUMN] = boundary
+                self.assertIsNone(self.exit(position, rate))
+                self.frame[EXTREMA_COLUMN] = score
+                self.assertIsNone(self.exit(position, 100.0))
+                for prediction, inlier in ((0, 1), (1, 0)):
+                    self.frame["do_predict"] = prediction
+                    self.frame["DI_catch"] = inlier
+                    self.assertIsNone(self.exit(position, rate))
+                self.frame["do_predict"] = 1
+                self.frame["DI_catch"] = 1
+                self.assertEqual(self.exit(position, rate), tag)
+
+    def test_final_exit_guards_preserve_unarmed_state(self):
+        original = self.frame.copy()
+        for guard in ("empty", "open_order", "partial_stage", "invalid_date"):
+            with self.subTest(guard=guard):
+                self.frame = original.copy()
+                position = self.position(exit_stage=0 if guard == "partial_stage" else 3)
+                store = self.custom_data(position)
+                if guard == "empty":
+                    self.frame = self.frame.iloc[:0]
+                elif guard == "open_order":
+                    position.orders.append(
+                        Order(
+                            ft_order_side=position.exit_side,
+                            ft_is_open=True,
+                            status="open",
+                            filled=0.0,
+                        )
+                    )
+                elif guard == "invalid_date":
+                    self.frame.loc[self.frame.index[-1], "date"] = pd.NaT
+                self.assertIsNone(self.exit(position, 150.0))
+                self.assertEqual(store, {})
+
+    def test_final_exit_trails_survive_restart_and_confirm_on_a_later_candle(self):
+        key = QuickAdapterV3._FINAL_TAKE_PROFIT_STATE_KEY
+        for short, target, arm, best, retrace, tag in (
+            (False, 121.0, 130.0, 135.0, 110.0, "take_profit_long_final"),
+            (True, 79.0, 70.0, 65.0, 90.0, "take_profit_short_final"),
+        ):
+            with self.subTest(short=short):
+                self.now = datetime.datetime(2026, 1, 1, 0, 10, tzinfo=datetime.UTC)
+                self.frame.loc[self.frame.index[-1], "date"] = self.now
+                position = self.position(short, exit_stage=3)
+                store = self.custom_data(position)
+                # Entry NATR rank gives 7%; final fraction 1 and one-candle factor 1.
+                np.testing.assert_allclose(
+                    self.model.get_take_profit_target(self.frame, position, 3),
+                    (target, 21.0),
+                    rtol=1e-12,
+                    atol=0.0,
+                )
+                self.assertIsNone(self.exit(position, 100.0))
+                self.assertNotIn(key, store)
+                self.assertEqual(store["history"]["take_profit_price"], [(3, target)])
+                self.assertIsNone(self.exit(position, arm))
+                self.assertEqual(store[key]["best_rate"], arm)
+                self.assertAlmostEqual(store[key]["retracement_distance"], 5.25, places=12)
+                self.now += datetime.timedelta(minutes=5)
+                self.frame.loc[self.frame.index[-1], "date"] = self.now
+                self.assertIsNone(self.exit(position, best))
+                self.assertEqual(store[key]["best_rate"], best)
+                provider = self.model.dp
+                config = copy.deepcopy(self.model.config)
+                self.model = QuickAdapterV3(config)
+                self.model.freqai_info = config["freqai"]
+                self.model.bot_start()
+                self.model.dp = provider
+                position = self.position(short, exit_stage=3)
+                self.custom_data(position, store)
+                self.assertIsNone(self.exit(position, retrace))
+                self.assertEqual(store[key]["best_rate"], best)
+                self.now += datetime.timedelta(minutes=5)
+                self.frame.loc[self.frame.index[-1], "date"] = self.now
+                self.assertEqual(self.exit(position, retrace), tag)
+
+    def test_invalid_final_exit_state_is_cleared_then_rearmed(self):
+        key = QuickAdapterV3._FINAL_TAKE_PROFIT_STATE_KEY
+        position = self.position(exit_stage=3)
+        store = self.custom_data(position, {key: {"best_rate": "corrupt"}})
+        self.assertIsNone(self.exit(position, 100.0))
+        self.assertIsNone(store[key])
+        self.assertIsNone(self.exit(position, 130.0))
+        self.assertEqual(store[key]["best_rate"], 130.0)
+        self.assertAlmostEqual(store[key]["retracement_distance"], 5.25, places=12)
+
+    def test_an_unmeasurable_final_trail_does_not_arm_or_exit(self):
+        position = self.position(short=True, exit_stage=3)
+        store = self.custom_data(position)
+        # Target 79 is crossed, but a zero current rate cannot seed a positive trail.
+        self.assertIsNone(self.exit(position, 0.0))
+        self.assertNotIn(QuickAdapterV3._FINAL_TAKE_PROFIT_STATE_KEY, store)
+        self.assertEqual(store["history"]["take_profit_price"], [(3, 79.0)])
+
+    def test_a_missing_final_take_profit_target_leaves_state_unarmed(self):
+        position = self.position(exit_stage=3)
+        store = self.custom_data(position)
+        self.frame["natr_label_period_candles"] = np.nan
+        self.assertIsNone(self.exit(position, 150.0))
+        self.assertEqual(store, {})
+
 
 class ThrottleCallbackTest(QaTestCase):
     def test_a_non_callable_is_refused(self):
index 423c5a66909d976b96ba9a6797f4c6d898f04370..04fceff19746e0e845ba962629da18842455593e 100644 (file)
@@ -667,14 +667,11 @@ class StrategyTargetsTest(QaTestCase):
             self.assertTrue(np.isfinite(smoothed.to_numpy()).all())
 
     def test_the_labels_and_weights_do_not_depend_on_the_frame_index(self):
-        # The strongest form of the positional-contract guard, because it runs the real
-        # strategy path. Every other fixture in this suite builds `date` as a COLUMN and
-        # leaves a zero-based RangeIndex in place, where a pivot position and a pivot index
-        # label are the same number — so returning `df.index` instead of positions passed
-        # everywhere. FreqAI hands `set_freqai_targets` a DatetimeIndex frame, where the
-        # labels are Timestamps: the weighting path raised TypeError on the first candle of
-        # every pair, and a shifted integer index dropped every pivot and returned no weight
-        # at all, with only a warning.
+        # Exercise the real strategy path with datetime and shifted integer indexes.
+        # The zero-based fixtures cannot distinguish pivot positions from index labels.
+        # This offset-500 fixture puts every pivot label outside the positional range;
+        # smaller shifts can instead retain weights on incorrect rows. No particular
+        # index type is assumed for native FreqAI inputs.
         baseline = None
         for name, index in (
             ("datetime", "date"),
index 9c77e7ceb6790c7f8672ee6ea581f9d02fab8de9..2453cea2a23e2622ea62879e093dda721d93fd1c 100644 (file)
@@ -4,6 +4,7 @@ import functools
 import hashlib
 import itertools
 import unittest
+from decimal import Decimal, localcontext
 
 import numpy as np
 import pandas as pd
@@ -502,15 +503,56 @@ class UtilsIndicatorsTest(QaTestCase):
         )
         self.assertAlmostEqual(float(raw[3]), float(closes.iloc[:4].mean()), places=9)
 
-    def test_a_constant_window_gives_a_finite_fractal_dimension_instead_of_a_log_of_zero(self):
-        # _fractal_dimension short-circuits to 1.0 when either half-range is zero, and a
-        # constant frame is the only shape that reaches that branch. The case was previously
-        # named for a gap and a window edge that its fixture cannot contain, which read as if
-        # the early return were covered from a second direction. What is actually distinct
-        # here is the guard itself: without it the ratio is log(0) and alpha is -inf.
-        result = frama(ohlcv_frame([7.0] * 12), 4).to_numpy()
-        self.assertFalse(np.isinf(result).any())
-        self.assertFalse(np.isnan(result[3:]).any())
+    def test_frama_adapts_to_normalized_window_ranges(self):
+        # Independent high-precision slopes from Ehlers, with this API's close price,
+        # historical window and mean seed. Flat ranges also exercise the zero guard.
+        fixtures = (
+            ("oscillation", [1.0, 3.0] * 12, 0.25),
+            ("trend", list(range(1, 25)), 0.25),
+            ("mixed", [1, 2, 1, 4, 3, 7, 2, 5, 1, 2, 8, 3] * 2, 0.25),
+            ("flat", [7.0] * 24, 0.0),
+            ("flat_halves", [7, 7, 9, 9, 11, 11, 10, 10] * 3, 0.0),
+        )
+        for name, prices, spread in fixtures:
+            frame = pd.DataFrame(
+                {
+                    "high": np.asarray(prices) + spread,
+                    "low": np.asarray(prices) - spread,
+                    "close": prices,
+                },
+                index=pd.Index([f"bar-{i}" for i in range(len(prices))]),
+            )
+            for period in (4, 6, 8):
+                with self.subTest(shape=name, period=period), localcontext() as context:
+                    context.prec = 60
+                    highs = [Decimal(str(value)) for value in frame["high"]]
+                    lows = [Decimal(str(value)) for value in frame["low"]]
+                    closes = [Decimal(str(value)) for value in frame["close"]]
+                    half = period // 2
+                    expected = np.full(len(frame), np.nan)
+                    previous = sum(closes[:period]) / Decimal(period)
+                    expected[period - 1] = float(previous)
+                    for i in range(period, len(frame)):
+                        start = i - period
+                        split = start + half
+                        n1 = (max(highs[start:split]) - min(lows[start:split])) / half
+                        n2 = (max(highs[split:i]) - min(lows[split:i])) / half
+                        n3 = (max(highs[start:i]) - min(lows[start:i])) / period
+                        dimension = (
+                            ((n1 + n2).ln() - n3.ln()) / Decimal(2).ln()
+                            if n1 + n2 > 0 and n3 > 0
+                            else Decimal(1)
+                        )
+                        dimension = min(Decimal(2), max(Decimal(1), dimension))
+                        alpha = (Decimal("-4.6") * (dimension - 1)).exp()
+                        alpha = min(Decimal(1), max(Decimal("0.01"), alpha))
+                        previous = alpha * closes[i] + (1 - alpha) * previous
+                        expected[i] = float(previous)
+                    result = frama(frame, period)
+                    self.assertTrue(result.index.equals(frame.index))
+                    assert_allclose(
+                        result.to_numpy(), expected, rtol=1e-12, atol=0.0, equal_nan=True
+                    )
 
     def test_the_smma_follows_its_own_recursion(self):
         # Seeded with the mean of the first `period` bars, then alpha = 1/period per bar.
index 60f965cbdc4f8d56419720447b114910f0af062b..82965d7a865b2bbb438376f845e7cddcb920f58f 100644 (file)
@@ -844,10 +844,9 @@ def _generate_extrema_label(
 
     series = pd.Series(0.0, index=dataframe.index)
     if result.indices:
-        # `indices` are POSITIONS and `series` carries the caller's index, so this assignment
-        # must be positional. `.loc` would read the positions as labels, which raises KeyError
-        # on the DatetimeIndex FreqAI passes and on any shifted integer index, and on a
-        # PERMUTED integer index writes the directions onto the wrong rows without raising.
+        # Pivot indices are row positions, independently of the caller's index.
+        # Label-based assignment can fail when labels are absent or place directions
+        # on the wrong rows for a reordered or shifted integer index.
         series.iloc[result.indices] = result.directions
 
     metrics: dict[str, list[float]] = {
@@ -3793,15 +3792,12 @@ 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 using unnormalized half-window ranges.
+    """Fractal dimension from Ehlers' normalized 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.
+    Each half-window range is divided by ``period / 2`` and the full-window
+    range by ``period``. Their lengths cancel to a factor two in the range
+    ratio. Degenerate zero ranges use dimension one; finite dimensions are
+    clipped to ``[1, 2]``.
     Reference: https://www.mesasoftware.com/papers/FRAMA.pdf
     """
     if period % 2 != 0 or period < 2:
@@ -3825,15 +3821,18 @@ def _fractal_dimension(
     if (HL1 + HL2) == 0 or HL3 == 0:
         return 1.0
 
-    D = (np.log(HL1 + HL2) - np.log(HL3)) / np.log(2)
+    # Normalizing the half/full window lengths adds log2(2) to the log ratio.
+    D = 1.0 + (np.log(HL1 + HL2) - np.log(HL3)) / np.log(2)
     return np.clip(D, 1.0, 2.0)
 
 
 def frama(df: pd.DataFrame, period: int = 16, zero_lag: bool = False) -> pd.Series:
-    """
-    FRAMA-style filter using unnormalized ranges, with optional zero lag.
+    """Fractal adaptive moving average using normalized ranges.
 
-    See :func:`_fractal_dimension` for the distinction from Ehlers' formulation.
+    Filter closes, seed with their first-period mean at ``period - 1``, and
+    estimate each dimension from the preceding ``period`` candles. Optional
+    zero lag preprocesses highs, lows and closes before these operations.
+    See :func:`_fractal_dimension` for the normalized dimension.
     """
     if period % 2 != 0:
         raise ValueError(f"Invalid period value {period!r}: must be even")