From: Jérôme Benoit Date: Fri, 31 Oct 2025 15:11:50 +0000 (+0100) Subject: feat(qav3): add more tunables for reservals labeling HPO X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=e6b984721d194fad53eb53fc48076a15d14ce207;p=freqai-strategies.git feat(qav3): add more tunables for reservals labeling HPO Signed-off-by: Jérôme Benoit --- diff --git a/README.md b/README.md index 3752583..f907d49 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ docker compose up -d --build | reversal_confirmation.lookback_period | 0 | int >= 0 | Prior confirming candles; 0 = none. | | reversal_confirmation.decay_ratio | 0.5 | float (0,1] | Geometric per-candle relaxation factor. | | reversal_confirmation.min_natr_ratio_percent | 0.0099 | float [0,1] | Lower bound fraction for volatility adjusted reversal threshold. | -| reversal_confirmation.max_natr_ratio_percent | 0.035 | float [0,1] | Upper bound fraction (>= lower bound) for volatility adjusted reversal threshold. | +| reversal_confirmation.max_natr_ratio_percent | 0.4 | float [0,1] | Upper bound fraction (>= lower bound) for volatility adjusted reversal threshold. | | _Regressor model_ | | | | | freqai.regressor | `xgboost` | enum {`xgboost`,`lightgbm`} | Machine learning regressor algorithm. | | _Extrema smoothing_ | | | | @@ -54,10 +54,12 @@ docker compose up -d --build | freqai.extrema_smoothing_window | 5 | int >= 1 | Window size for extrema smoothing. | | freqai.extrema_smoothing_beta | 8.0 | float > 0 | Kaiser kernel shape parameter. | | _Feature parameters_ | | | | -| freqai.feature_parameters.label_period_candles | 24 | int >= 1 | Zigzag NATR horizon. | -| freqai.feature_parameters.label_natr_ratio | 9.0 | float > 0 | Zigzag NATR ratio. | -| freqai.feature_parameters.min_label_natr_ratio | 9.0 | float > 0 | Minimum NATR ratio bound used by label HPO. | -| freqai.feature_parameters.max_label_natr_ratio | 12.0 | float > 0 | Maximum NATR ratio bound used by label HPO. | +| freqai.feature_parameters.label_period_candles | min/max midpoint | int >= 1 | Zigzag labeling NATR horizon. | +| freqai.feature_parameters.min_label_period_candles | 12 | int >= 1 | Minimum labeling NATR horizon used for reversals labeling HPO. | +| freqai.feature_parameters.max_label_period_candles | 24 | int >= 1 | Maximum labeling NATR horizon used for reversals labeling HPO. | +| freqai.feature_parameters.label_natr_ratio | min/max midpoint | float > 0 | Zigzag labeling NATR ratio. | +| freqai.feature_parameters.min_label_natr_ratio | 9.0 | float > 0 | Minimum labeling NATR ratio used for reversals labeling HPO. | +| freqai.feature_parameters.max_label_natr_ratio | 12.0 | float > 0 | Maximum labeling NATR ratio used for reversals labeling HPO. | | freqai.feature_parameters.label_frequency_candles | `auto` | int >= 2 \| `auto` | Reversals labeling frequency. `auto` = max(2, 2 * number of whitelisted pairs). | | freqai.feature_parameters.label_metric | `euclidean` | string (supported: `euclidean`,`minkowski`,`cityblock`,`chebyshev`,`mahalanobis`,`seuclidean`,`jensenshannon`,`sqeuclidean`,...) | Metric used in distance calculations to ideal point. | | freqai.feature_parameters.label_weights | [0.5,0.5] | list[float] | Per-objective weights used in distance calculations to ideal point. | diff --git a/quickadapter/user_data/config-template.json b/quickadapter/user_data/config-template.json index c82b6fc..b1b24d8 100644 --- a/quickadapter/user_data/config-template.json +++ b/quickadapter/user_data/config-template.json @@ -138,8 +138,8 @@ "DI_cutoff": 2, "&s-minima_threshold": -2, "&s-maxima_threshold": 2, - "label_period_candles": 24, - "label_natr_ratio": 9.0, + "label_period_candles": 18, + "label_natr_ratio": 10.5, "hp_rmse": -1, "train_rmse": -1 }, @@ -155,7 +155,7 @@ "4h", // "1d" ], - "label_period_candles": 24, + "label_period_candles": 18, "label_metric": "euclidean", "label_weights": [ 0.5, diff --git a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py index d6550e2..88f4670 100644 --- a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py +++ b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py @@ -27,7 +27,9 @@ from Utils import ( get_min_max_label_period_candles, get_optuna_callbacks, get_optuna_study_model_parameters, + midpoint, soft_extremum, + validate_range, zigzag, ) @@ -61,7 +63,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel): https://github.com/sponsors/robcaulk """ - version = "3.7.116" + version = "3.7.117" @cached_property def _optuna_config(self) -> dict[str, Any]: @@ -192,6 +194,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel): self._optuna_label_candle: dict[str, int] = {} self._optuna_label_candles: dict[str, int] = {} self._optuna_label_incremented_pairs: list[str] = [] + self._init_label_defaults() for pair in self.pairs: self._optuna_hp_value[pair] = -1 self._optuna_train_value[pair] = -1 @@ -211,10 +214,14 @@ class QuickAdapterRegressorV3(BaseRegressionModel): if self.optuna_load_best_params(pair, "label") else { "label_period_candles": self.ft_params.get( - "label_period_candles", 24 + "label_period_candles", + self._default_label_period_candles, ), "label_natr_ratio": float( - self.ft_params.get("label_natr_ratio", 9.0) + self.ft_params.get( + "label_natr_ratio", + self._default_label_natr_ratio, + ) ), } ) @@ -225,6 +232,53 @@ class QuickAdapterRegressorV3(BaseRegressionModel): f"Initialized {self.__class__.__name__} {self.freqai_info.get('regressor', 'xgboost')} regressor model version {self.version}" ) + def _init_label_defaults(self) -> None: + default_min_label_natr_ratio = 9.0 + default_max_label_natr_ratio = 12.0 + min_label_natr_ratio = self.ft_params.get( + "min_label_natr_ratio", default_min_label_natr_ratio + ) + max_label_natr_ratio = self.ft_params.get( + "max_label_natr_ratio", default_max_label_natr_ratio + ) + min_label_natr_ratio, max_label_natr_ratio = validate_range( + min_label_natr_ratio, + max_label_natr_ratio, + logger, + name="label_natr_ratio", + default_min=default_min_label_natr_ratio, + default_max=default_max_label_natr_ratio, + allow_equal=False, + non_negative=True, + finite_only=True, + ) + self._default_label_natr_ratio = float( + midpoint(min_label_natr_ratio, max_label_natr_ratio) + ) + + default_min_label_period_candles = 12 + default_max_label_period_candles = 24 + min_label_period_candles = self.ft_params.get( + "min_label_period_candles", default_min_label_period_candles + ) + max_label_period_candles = self.ft_params.get( + "max_label_period_candles", default_max_label_period_candles + ) + min_label_period_candles, max_label_period_candles = validate_range( + min_label_period_candles, + max_label_period_candles, + logger, + name="label_period_candles", + default_min=default_min_label_period_candles, + default_max=default_max_label_period_candles, + allow_equal=True, + non_negative=True, + finite_only=True, + ) + self._default_label_period_candles = int( + round(midpoint(min_label_period_candles, max_label_period_candles)) + ) + def get_optuna_params(self, pair: str, namespace: str) -> dict[str, Any]: if namespace == "hp": params = self._optuna_hp_params.get(pair) @@ -497,6 +551,12 @@ class QuickAdapterRegressorV3(BaseRegressionModel): ), fit_live_predictions_candles, self._optuna_config.get("label_candles_step"), + min_label_period_candles=self.ft_params.get( + "min_label_period_candles", 12 + ), + max_label_period_candles=self.ft_params.get( + "max_label_period_candles", 24 + ), min_label_natr_ratio=self.ft_params.get( "min_label_natr_ratio", 9.0 ), @@ -1817,11 +1877,20 @@ def label_objective( df: pd.DataFrame, fit_live_predictions_candles: int, candles_step: int, + min_label_period_candles: int = 12, + max_label_period_candles: int = 24, min_label_natr_ratio: float = 9.0, max_label_natr_ratio: float = 12.0, ) -> tuple[float, int]: min_label_period_candles, max_label_period_candles, candles_step = ( - get_min_max_label_period_candles(fit_live_predictions_candles, candles_step) + get_min_max_label_period_candles( + fit_live_predictions_candles, + candles_step, + min_label_period_candles=min_label_period_candles, + max_label_period_candles=max_label_period_candles, + min_label_period_candles_fallback=min_label_period_candles, + max_label_period_candles_fallback=max_label_period_candles, + ) ) label_period_candles = trial.suggest_int( diff --git a/quickadapter/user_data/strategies/QuickAdapterV3.py b/quickadapter/user_data/strategies/QuickAdapterV3.py index 8437cd2..80d8150 100644 --- a/quickadapter/user_data/strategies/QuickAdapterV3.py +++ b/quickadapter/user_data/strategies/QuickAdapterV3.py @@ -29,10 +29,12 @@ from Utils import ( get_callable_sha256, get_distance, get_zl_ma_fn, + midpoint, non_zero_diff, price_retracement_percent, smooth_extrema, top_change_percent, + validate_range, vwapb, zigzag, zlema, @@ -67,7 +69,7 @@ class QuickAdapterV3(IStrategy): INTERFACE_VERSION = 3 def version(self) -> str: - return "3.3.160" + return "3.3.163" timeframe = "5m" @@ -99,7 +101,7 @@ class QuickAdapterV3(IStrategy): "lookback_period": 0, "decay_ratio": 0.5, "min_natr_ratio_percent": 0.0099, - "max_natr_ratio_percent": 0.035, + "max_natr_ratio_percent": 0.4, } position_adjustment_enable = True @@ -230,22 +232,28 @@ class QuickAdapterV3(IStrategy): / "models" / self.freqai_info.get("identifier") ) + self._init_label_defaults() self._label_params: dict[str, dict[str, Any]] = {} for pair in self.pairs: self._label_params[pair] = ( self.optuna_load_best_params(pair, "label") if self.optuna_load_best_params(pair, "label") else { - "label_period_candles": self.freqai_info["feature_parameters"].get( - "label_period_candles", 24 + "label_period_candles": self.freqai_info.get( + "feature_parameters", {} + ).get( + "label_period_candles", + self._default_label_period_candles, ), "label_natr_ratio": float( - self.freqai_info["feature_parameters"].get( - "label_natr_ratio", 9.0 + self.freqai_info.get("feature_parameters", {}).get( + "label_natr_ratio", + self._default_label_natr_ratio, ) ), } ) + self._init_reversal_confirmation_defaults() self._candle_duration_secs = int( timeframe_to_minutes(self.config.get("timeframe")) * 60 ) @@ -260,6 +268,120 @@ class QuickAdapterV3(IStrategy): **self.config.get("exit_pricing", {}).get("thresholds_calibration", {}), } + def _init_reversal_confirmation_defaults(self) -> None: + reversal_confirmation = self.config.get("reversal_confirmation", {}) + lookback_period = reversal_confirmation.get( + "lookback_period", + QuickAdapterV3.default_reversal_confirmation["lookback_period"], + ) + decay_ratio = reversal_confirmation.get( + "decay_ratio", QuickAdapterV3.default_reversal_confirmation["decay_ratio"] + ) + min_natr_ratio_percent = reversal_confirmation.get( + "min_natr_ratio_percent", + QuickAdapterV3.default_reversal_confirmation["min_natr_ratio_percent"], + ) + max_natr_ratio_percent = reversal_confirmation.get( + "max_natr_ratio_percent", + QuickAdapterV3.default_reversal_confirmation["max_natr_ratio_percent"], + ) + + if not isinstance(lookback_period, int) or lookback_period < 0: + logger.warning( + f"reversal_confirmation: invalid lookback_period {lookback_period!r}, using default {QuickAdapterV3.default_reversal_confirmation['lookback_period']}" + ) + lookback_period = QuickAdapterV3.default_reversal_confirmation[ + "lookback_period" + ] + + if not isinstance(decay_ratio, (int, float)) or not ( + 0.0 < float(decay_ratio) <= 1.0 + ): + logger.warning( + f"reversal_confirmation: invalid decay_ratio {decay_ratio!r}, using default {QuickAdapterV3.default_reversal_confirmation['decay_ratio']}" + ) + decay_ratio = QuickAdapterV3.default_reversal_confirmation["decay_ratio"] + else: + decay_ratio = float(decay_ratio) + + min_natr_ratio_percent, max_natr_ratio_percent = validate_range( + min_natr_ratio_percent, + max_natr_ratio_percent, + logger, + name="natr_ratio_percent", + default_min=QuickAdapterV3.default_reversal_confirmation[ + "min_natr_ratio_percent" + ], + default_max=QuickAdapterV3.default_reversal_confirmation[ + "max_natr_ratio_percent" + ], + allow_equal=False, + non_negative=True, + finite_only=True, + ) + + self._reversal_lookback_period = int(lookback_period) + self._reversal_decay_ratio = float(decay_ratio) + self._reversal_min_natr_ratio_percent = float(min_natr_ratio_percent) + self._reversal_max_natr_ratio_percent = float(max_natr_ratio_percent) + + logger.debug( + "reversal_confirmation: lookback_period=%s, decay_ratio=%s, natr_ratio_percent_range=(%s, %s)", + self._reversal_lookback_period, + format_number(self._reversal_decay_ratio), + format_number(self._reversal_min_natr_ratio_percent), + format_number(self._reversal_max_natr_ratio_percent), + ) + + def _init_label_defaults(self) -> None: + feature_parameters = self.freqai_info.get("feature_parameters", {}) + + default_min_label_natr_ratio = 9.0 + default_max_label_natr_ratio = 12.0 + min_label_natr_ratio = feature_parameters.get( + "min_label_natr_ratio", default_min_label_natr_ratio + ) + max_label_natr_ratio = feature_parameters.get( + "max_label_natr_ratio", default_max_label_natr_ratio + ) + min_label_natr_ratio, max_label_natr_ratio = validate_range( + min_label_natr_ratio, + max_label_natr_ratio, + logger, + name="label_natr_ratio", + default_min=default_min_label_natr_ratio, + default_max=default_max_label_natr_ratio, + allow_equal=False, + non_negative=True, + finite_only=True, + ) + self._default_label_natr_ratio = float( + midpoint(min_label_natr_ratio, max_label_natr_ratio) + ) + + default_min_label_period_candles = 12 + default_max_label_period_candles = 24 + min_label_period_candles = feature_parameters.get( + "min_label_period_candles", default_min_label_period_candles + ) + max_label_period_candles = feature_parameters.get( + "max_label_period_candles", default_max_label_period_candles + ) + min_label_period_candles, max_label_period_candles = validate_range( + min_label_period_candles, + max_label_period_candles, + logger, + name="label_period_candles", + default_min=default_min_label_period_candles, + default_max=default_max_label_period_candles, + allow_equal=True, + non_negative=True, + finite_only=True, + ) + self._default_label_period_candles = int( + round(midpoint(min_label_period_candles, max_label_period_candles)) + ) + def feature_engineering_expand_all( self, dataframe: DataFrame, period: int, metadata: dict[str, Any], **kwargs ) -> DataFrame: @@ -432,7 +554,10 @@ class QuickAdapterV3(IStrategy): ) if label_period_candles and isinstance(label_period_candles, int): return label_period_candles - return self.freqai_info["feature_parameters"].get("label_period_candles", 24) + return self.freqai_info.get("feature_parameters", {}).get( + "label_period_candles", + self._default_label_period_candles, + ) def set_label_period_candles(self, pair: str, label_period_candles: int) -> None: if isinstance(label_period_candles, int): @@ -443,7 +568,10 @@ class QuickAdapterV3(IStrategy): if label_natr_ratio and isinstance(label_natr_ratio, float): return label_natr_ratio return float( - self.freqai_info["feature_parameters"].get("label_natr_ratio", 9.0) + self.freqai_info.get("feature_parameters", {}).get( + "label_natr_ratio", + self._default_label_natr_ratio, + ) ) def set_label_natr_ratio(self, pair: str, label_natr_ratio: float) -> None: @@ -1233,27 +1361,9 @@ class QuickAdapterV3(IStrategy): return False if order not in {"entry", "exit"}: return False + trade_direction = side - if ( - min_natr_ratio_percent < 0.0 - or max_natr_ratio_percent < min_natr_ratio_percent - ): - logger.warning( - f"User denied {trade_direction} {order} for {pair}: invalid natr_ratio_percent range " - f"min={format_number(min_natr_ratio_percent)}, max={format_number(max_natr_ratio_percent)}" - ) - return False - if not isinstance(lookback_period, int): - logger.info( - f"User denied {trade_direction} {order} for {pair}: invalid lookback_period type" - ) - return False - if lookback_period < 0: - logger.info( - f"User denied {trade_direction} {order} for {pair}: negative lookback_period={lookback_period}" - ) - return False max_lookback_period = max(0, len(df) - 1) if lookback_period > max_lookback_period: lookback_period = max_lookback_period @@ -1549,25 +1659,6 @@ class QuickAdapterV3(IStrategy): trade.set_custom_data("n_outliers", n_outliers) trade.set_custom_data("last_outlier_date", last_candle_date.isoformat()) - lookback_period: int = self.config.get("reversal_confirmation", {}).get( - "lookback_period", - QuickAdapterV3.default_reversal_confirmation["lookback_period"], - ) - decay_ratio: float = self.config.get("reversal_confirmation", {}).get( - "decay_ratio", QuickAdapterV3.default_reversal_confirmation["decay_ratio"] - ) - min_natr_ratio_percent: float = self.config.get( - "reversal_confirmation", {} - ).get( - "min_natr_ratio_percent", - QuickAdapterV3.default_reversal_confirmation["min_natr_ratio_percent"], - ) - max_natr_ratio_percent: float = self.config.get( - "reversal_confirmation", {} - ).get( - "max_natr_ratio_percent", - QuickAdapterV3.default_reversal_confirmation["max_natr_ratio_percent"], - ) if ( trade.trade_direction == "short" and last_candle.get("do_predict") == 1 @@ -1579,10 +1670,10 @@ class QuickAdapterV3(IStrategy): "long", "exit", current_rate, - lookback_period, - decay_ratio, - min_natr_ratio_percent, - max_natr_ratio_percent, + self._reversal_lookback_period, + self._reversal_decay_ratio, + self._reversal_min_natr_ratio_percent, + self._reversal_max_natr_ratio_percent, ) ): return "minima_detected_short" @@ -1597,10 +1688,10 @@ class QuickAdapterV3(IStrategy): "short", "exit", current_rate, - lookback_period, - decay_ratio, - min_natr_ratio_percent, - max_natr_ratio_percent, + self._reversal_lookback_period, + self._reversal_decay_ratio, + self._reversal_min_natr_ratio_percent, + self._reversal_max_natr_ratio_percent, ) ): return "maxima_detected_long" @@ -1732,22 +1823,10 @@ class QuickAdapterV3(IStrategy): side, "entry", rate, - self.config.get("reversal_confirmation", {}).get( - "lookback_period", - QuickAdapterV3.default_reversal_confirmation["lookback_period"], - ), - self.config.get("reversal_confirmation", {}).get( - "decay_ratio", - QuickAdapterV3.default_reversal_confirmation["decay_ratio"], - ), - self.config.get("reversal_confirmation", {}).get( - "min_natr_ratio_percent", - QuickAdapterV3.default_reversal_confirmation["min_natr_ratio_percent"], - ), - self.config.get("reversal_confirmation", {}).get( - "max_natr_ratio_percent", - QuickAdapterV3.default_reversal_confirmation["max_natr_ratio_percent"], - ), + self._reversal_lookback_period, + self._reversal_decay_ratio, + self._reversal_min_natr_ratio_percent, + self._reversal_max_natr_ratio_percent, ): return True return False diff --git a/quickadapter/user_data/strategies/Utils.py b/quickadapter/user_data/strategies/Utils.py index c40118b..be700bf 100644 --- a/quickadapter/user_data/strategies/Utils.py +++ b/quickadapter/user_data/strategies/Utils.py @@ -4,7 +4,7 @@ import hashlib import math from enum import IntEnum from functools import lru_cache -from statistics import median +from logging import Logger from typing import Any, Callable, Literal, Optional, TypeVar import numpy as np @@ -22,6 +22,11 @@ def get_distance(p1: T, p2: T) -> T: return abs(p1 - p2) +def midpoint(value1: T, value2: T) -> T: + """Calculate the midpoint (geometric center) between two values.""" + return (value1 + value2) / 2 + + def non_zero_diff(s1: pd.Series, s2: pd.Series) -> pd.Series: """Returns the difference of two series and replaces zeros with epsilon.""" diff = s1 - s2 @@ -567,7 +572,7 @@ def zigzag( ) -> float: volatility_quantile = calculate_volatility_quantile(pos) if np.isnan(volatility_quantile): - return median([min_threshold, max_threshold]) + return midpoint(min_threshold, max_threshold) return max_threshold - (max_threshold - min_threshold) * volatility_quantile @@ -869,10 +874,10 @@ def get_optuna_study_model_parameters( for param, (default_min, default_max) in default_ranges.items(): center_value = model_training_best_parameters.get(param) - if ( - center_value is None - or not isinstance(center_value, (int, float)) - or not np.isfinite(center_value) + if center_value is None: + center_value = midpoint(default_min, default_max) + elif not isinstance(center_value, (int, float)) or not np.isfinite( + center_value ): continue @@ -1007,11 +1012,11 @@ def get_min_max_label_period_candles( fit_live_predictions_candles: int, candles_step: int, min_label_period_candles: int = 12, - max_label_period_candles: int = 36, + max_label_period_candles: int = 24, max_period_candles: int = 36, max_horizon_fraction: float = 1.0 / 3.0, min_label_period_candles_fallback: int = 12, - max_label_period_candles_fallback: int = 36, + max_label_period_candles_fallback: int = 24, ) -> tuple[int, int, int]: if min_label_period_candles > max_label_period_candles: raise ValueError( @@ -1107,3 +1112,68 @@ def floor_to_step(value: float | int, step: int) -> int: if not np.isfinite(value): raise ValueError("value must be finite") return int(math.floor(float(value) / step) * step) + + +def validate_range( + min_val: float | int, + max_val: float | int, + logger: Logger, + *, + name: str, + default_min: float | int, + default_max: float | int, + allow_equal: bool = False, + non_negative: bool = True, + finite_only: bool = True, +) -> tuple[float | int, float | int]: + min_name = f"min_{name}" + max_name = f"max_{name}" + + if not isinstance(default_min, (int, float)) or not isinstance( + default_max, (int, float) + ): + raise ValueError(f"{name}: defaults must be numeric") + if default_min > default_max or (not allow_equal and default_min == default_max): + raise ValueError( + f"{name}: invalid defaults ordering {default_min} >= {default_max}" + ) + + def _validate_component( + value: float | int | None, name: str, default_value: float | int + ) -> float | int: + ok = True + if not isinstance(value, (int, float)): + ok = False + elif isinstance(value, bool): + ok = False + elif finite_only and not np.isfinite(value): + ok = False + elif non_negative and value < 0: + ok = False + if not ok: + logger.warning( + f"{name}: invalid value {value!r}, using default {default_value}" + ) + return default_value + return value + + sanitized_min = _validate_component(min_val, min_name, default_min) + sanitized_max = _validate_component(max_val, max_name, default_max) + + ordering_ok = ( + (sanitized_min < sanitized_max) + if not allow_equal + else (sanitized_min <= sanitized_max) + ) + if not ordering_ok: + logger.warning( + f"{name}: invalid ordering ({min_name}={sanitized_min}, {max_name}={sanitized_max}); using defaults ({default_min}, {default_max})" + ) + sanitized_min, sanitized_max = default_min, default_max + + if sanitized_min != min_val or sanitized_max != max_val: + logger.warning( + f"{name}: sanitized {min_name}={sanitized_min}, {max_name}={sanitized_max} (defaults=({default_min}, {default_max}))" + ) + + return sanitized_min, sanitized_max