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.
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
},
{
"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,
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
-"""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
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):
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"]
"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(
),
)
- 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),
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.
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):
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"),
import hashlib
import itertools
import unittest
+from decimal import Decimal, localcontext
import numpy as np
import pandas as pd
)
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.
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]] = {
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:
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")