From 3a0acb8bc7c4b04c0d43c3461da766704b68e181 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 28 Jul 2026 14:11:07 +0200 Subject: [PATCH] fix(quickadapter): validate coupled/bounded config inputs at the option layer (#141) * fix(quickadapter): validate smoothing modes per method * refactor(quickadapter): consolidate smoothing cross-field validation Route the label_smoothing method x mode check through the shared label-kind machinery instead of a bespoke loop in the smoothing getter: - fold the per-kind coupled-field validator into _LABEL_KIND_REGISTRY as a third tuple element (single source of truth), with a named CrossFieldValidatorFn type alias matching the existing ValidateParamsFn - get_label_kind_config runs the registered validator on each resolved per-column config; all four label-kind getters are now symmetric one-liners - derive the bot_start wrap/causal-mode check's mode-aware method set from SMOOTHING_METHOD_MODES, dropping the duplicate _SMOOTHING_GAUSSIAN_FILTER1D - README: keep the per-method mode matrix in the type column, drop the code-behavior narration from the description * fix(quickadapter): validate exit calibration and leverage bounds at the option layer Two config inputs bypassed the option-layer validation their siblings use: - exit_pricing.thresholds_calibration.decline_quantile was merged raw: documented float (0,1) but enforced nowhere (only a consumer-side guard that raised TypeError on non-numeric input). Route it through a validated get_exit_thresholds_calibration_config using the shared _validate_params machinery, with DEFAULTS_EXIT_THRESHOLDS_CALIBRATION as single source of truth (drops the duplicate class-var default). Invalid values warn and fall back to 0.5. - leverage() applied only the upper bound; the documented lower bound 1.0 (README: float [1.0, max_leverage]) and non-numeric guarding were missing. Clamp to [1.0, max_leverage], falling back to proposed_leverage on non-numeric input. * fix(quickadapter): validate custom_protections config at the option layer custom_protections was the last config section read ad-hoc with hard int()/ float() casts that crashed on non-numeric input, inconsistent with the warn-and-fall-back contract every other section uses. Add get_custom_protections_config on the shared _validate_params machinery (new _BoolValidator for the enabled flags; nested cooldown/drawdown/stoploss sub-dicts validated per section) with single-source DEFAULTS_*; the protections property consumes the validated, typed config. Invalid or non-numeric values now warn and fall back to their documented defaults instead of raising. * fix(quickadapter): validate fit_live_predictions_candles at the option layer The last strategy-side config value read with a raw int() cast (protections and startup_candle_count) crashed on non-numeric input. Route it through a validated get_fit_live_predictions_candles (positive int, warn and fall back to the default) on the shared _validate_params machinery; drop the now-unused DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES import. * refactor(quickadapter): address PR review nits - harmonize get_exit_thresholds_calibration_config to accept the parent exit_pricing dict and deref thresholds_calibration internally, removing the double as_dict at the call site (mirrors get_custom_protections_config) - reject bool in _NumericValidator: bool is not a valid numeric input, consistent with is_finite_number / leverage() / _BoolValidator - drop the dead commented minimal_roi block referencing the removed DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES import - align comment terminology on 'cross-field' (matches CrossFieldValidatorFn) * refactor(quickadapter): address second-round review nits - warn instead of silently resetting when a config section is present but not a mapping: new as_config_section helper applied to custom_protections (+ cooldown/drawdown/stoploss) and exit_pricing.thresholds_calibration - re-add default_exit_thresholds_calibration ClassVar as a compat alias to the canonical DEFAULTS_EXIT_THRESHOLDS_CALIBRATION (public API stability) - move the minimal_roi rationale note directly above its assignment * refactor(quickadapter): address third-round review nits - Warn on non-mapping config sections by routing the section getters (label kinds, exit_pricing, reversal_confirmation, fit_live) through as_config_section, matching the custom_protections pattern (N-1). - Honor the default_exit_thresholds_calibration override via an optional overrides argument merged over the canonical defaults; user config still wins in _validate_params (N-2). - Resolve fit_live_predictions_candles once through the canonical validator in the regressor so an explicit 0 floors to 100, fixing the .iloc[-0:] whole-frame slice (N-3). * fix(quickadapter): warn on invalid leverage before fallback Route the configured leverage through a cached validator that logs a harmonized warning when the value is non-numeric or a boolean before falling back to proposed_leverage, instead of silently discarding the user setting. The warning fires once (cached) to avoid per-call spam. * fix(quickadapter): warn on sub-minimum leverage; cache protections - Warn once (via the _configured_leverage cached_property) when a numeric leverage is below the 1.0 floor before the leverage() hook clamps it; the per-pair max_leverage ceiling is only known at entry time, so above-ceiling values stay clamped silently. - Promote protections to a cached_property, aligning it with the sibling config-derived accessors and collapsing duplicate warnings on a malformed custom_protections/freqai section to one per strategy instance (reload re-instantiates the strategy, so the cache is fresh). * refactor(quickadapter): drop dead FIT_LIVE_PREDICTIONS_CANDLES_DEFAULT Both consumers were rewired to the resolved self._fit_live_predictions_candles, leaving the ClassVar and its DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES import unused. Remove both (not a public-library API surface). * style(quickadapter): drop redundant comments in exit-calibration getter The as_dict coercion and defaults merge are self-explanatory; keep comments only where the code is not clear on its own. * fix(quickadapter): guard leverage finiteness; warn-once on fit-live warmup - Reject non-finite leverage (NaN/Inf) via the shared is_finite_number guard before falling back to proposed_leverage, instead of letting it reach the clamp silently. - Back startup_candle_count and protections with a cached _fit_live_predictions_candles helper so an invalid fit_live_predictions_candles warns once instead of on every access. startup_candle_count stays a plain property so the StrategyResolver keeps protecting it from config override (cached_property is not a property subclass). * style(quickadapter): drop redundant _LABEL_KIND_REGISTRY comment The tuple type (CrossFieldValidatorFn | None) and the named unpacking (cross_field_validator) already document the third element. * fix(quickadapter): validate exit-calibration override before use Route the override (e.g. a subclass default_exit_thresholds_calibration) through _validate_params against the canonical defaults so an invalid subclass value falls back to the canonical default instead of being trusted blindly (previously it could be returned as-is with a misleading warning, or crash on output_type coercion). User config still wins over the override, which still wins over the canonical default. Also restore a concise note on the intentional silent parent coercion. * style(quickadapter): tighten exit-calibration rationale comments --- README.md | 2 +- .../freqaimodels/QuickAdapterRegressorV3.py | 24 +- .../user_data/strategies/LabelTransformer.py | 4 + .../user_data/strategies/QuickAdapterV3.py | 124 ++++----- quickadapter/user_data/strategies/Utils.py | 240 +++++++++++++++++- 5 files changed, 305 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index cc15092..fa58f24 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ docker compose up -d --build | freqai.label_smoothing.window_candles | 5 | int >= 3 | Smoothing window length (candles). | | freqai.label_smoothing.beta | 8.0 | float > 0 | Shape parameter for `kaiser` and `kaiser_bessel_derived` kernels. | | freqai.label_smoothing.polyorder | 3 | int >= 0 | Polynomial order for `savgol` smoothing. | -| freqai.label_smoothing.mode | `mirror` | enum {`mirror`,`constant`,`nearest`,`wrap`,`interp`} | Boundary mode for `savgol` and `gaussian_filter1d`. | +| freqai.label_smoothing.mode | `mirror` | `savgol`: enum {`mirror`,`constant`,`nearest`,`wrap`,`interp`}; `gaussian_filter1d`: enum {`mirror`,`constant`,`nearest`,`wrap`}; ignored otherwise | Boundary mode for `savgol` and `gaussian_filter1d`. | | freqai.label_smoothing.sigma | 1.0 | float > 0 | Gaussian `sigma` for `gaussian_filter1d` smoothing. | | _Label weighting_ | | | | | freqai.label_weighting.strategy | `none` | enum {`none`,`uniform`,`amplitude`,`amplitude_threshold_ratio`,`volume_rate`,`speed`,`efficiency_ratio`,`volume_weighted_efficiency_ratio`,`combined`} | Label weighting metric: none (`none`), uniform unit weight on every detected pivot (`uniform`), swing amplitude (`amplitude`), swing amplitude / median volatility-threshold ratio (`amplitude_threshold_ratio`), swing volume per candle (`volume_rate`), swing speed (`speed`), swing efficiency ratio (`efficiency_ratio`), swing volume-weighted efficiency ratio (`volume_weighted_efficiency_ratio`), or combined metrics aggregation (`combined`). Switching between `none` and any other strategy requires deleting trained models to realign training emphasis. | diff --git a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py index 37b6428..6261d87 100644 --- a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py +++ b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py @@ -67,9 +67,7 @@ from LabelTransformer import ( ) from Utils import ( - as_dict, enum_error_message, - DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES, DEFAULT_MAX_LABEL_NATR_MULTIPLIER, DEFAULT_MAX_LABEL_PERIOD_CANDLES, DEFAULT_MIN_LABEL_NATR_MULTIPLIER, @@ -92,6 +90,7 @@ from Utils import ( format_dict, format_number, get_causal_mode, + get_fit_live_predictions_candles, get_label_defaults, get_label_horizon_candles, get_label_pipeline_config, @@ -407,9 +406,6 @@ class QuickAdapterRegressorV3(BaseRegressionModel): } _POWER_MEAN_METRICS_SET: Final[frozenset[str]] = frozenset(_POWER_MEAN_MAP) - FIT_LIVE_PREDICTIONS_CANDLES_DEFAULT: Final[int] = ( - DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES - ) MIN_LABEL_PERIOD_CANDLES_DEFAULT: Final[int] = DEFAULT_MIN_LABEL_PERIOD_CANDLES MAX_LABEL_PERIOD_CANDLES_DEFAULT: Final[int] = DEFAULT_MAX_LABEL_PERIOD_CANDLES MIN_LABEL_NATR_MULTIPLIER_DEFAULT: Final[float] = DEFAULT_MIN_LABEL_NATR_MULTIPLIER @@ -1389,19 +1385,17 @@ class QuickAdapterRegressorV3(BaseRegressionModel): @cached_property def label_weighting(self) -> dict[str, Any]: return get_label_weighting_config( - as_dict(self.freqai_info.get("label_weighting")), logger + self.freqai_info.get("label_weighting"), logger ) @cached_property def label_pipeline(self) -> dict[str, Any]: - return get_label_pipeline_config( - as_dict(self.freqai_info.get("label_pipeline")), logger - ) + return get_label_pipeline_config(self.freqai_info.get("label_pipeline"), logger) @cached_property def label_prediction(self) -> dict[str, Any]: return get_label_prediction_config( - as_dict(self.freqai_info.get("label_prediction")), logger + self.freqai_info.get("label_prediction"), logger ) @cached_property @@ -1435,6 +1429,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) migrate_config(self.config, logger) + self._fit_live_predictions_candles: int = get_fit_live_predictions_candles( + self.freqai_info, logger + ) self.pairs: list[str] = self.config.get("exchange", {}).get("pair_whitelist") if not self.pairs: raise ValueError( @@ -1641,7 +1638,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel): ) logger.info("Label Hyperparameters:") logger.info( - f" fit_live_predictions_candles: {self.freqai_info.get('fit_live_predictions_candles', QuickAdapterRegressorV3.FIT_LIVE_PREDICTIONS_CANDLES_DEFAULT)}" + f" fit_live_predictions_candles: {self._fit_live_predictions_candles}" ) if self._optuna_hyperopt: logger.info( @@ -2969,10 +2966,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel): def fit_live_predictions(self, dk: FreqaiDataKitchen, pair: str) -> None: warmed_up = True - fit_live_predictions_candles = self.freqai_info.get( - "fit_live_predictions_candles", - QuickAdapterRegressorV3.FIT_LIVE_PREDICTIONS_CANDLES_DEFAULT, - ) + fit_live_predictions_candles = self._fit_live_predictions_candles if self._optuna_hyperopt: self.optuna_throttle_callback( diff --git a/quickadapter/user_data/strategies/LabelTransformer.py b/quickadapter/user_data/strategies/LabelTransformer.py index ca8f46f..5de8898 100644 --- a/quickadapter/user_data/strategies/LabelTransformer.py +++ b/quickadapter/user_data/strategies/LabelTransformer.py @@ -180,6 +180,10 @@ SMOOTHING_MODES: Final[tuple[SmoothingMode, ...]] = ( "wrap", "interp", ) +SMOOTHING_METHOD_MODES: Final[dict[SmoothingMethod, tuple[SmoothingMode, ...]]] = { + SMOOTHING_METHODS[7]: SMOOTHING_MODES, # "savgol" + SMOOTHING_METHODS[8]: SMOOTHING_MODES[:-1], # "gaussian_filter1d" +} DEFAULTS_LABEL_SMOOTHING: Final[dict[str, Any]] = { "method": SMOOTHING_METHODS[1], # "gaussian" diff --git a/quickadapter/user_data/strategies/QuickAdapterV3.py b/quickadapter/user_data/strategies/QuickAdapterV3.py index 805663d..aaae755 100644 --- a/quickadapter/user_data/strategies/QuickAdapterV3.py +++ b/quickadapter/user_data/strategies/QuickAdapterV3.py @@ -28,6 +28,7 @@ from LabelTransformer import ( COMBINED_AGGREGATIONS, FILL_METHODS, SMOOTHING_METHODS, + SMOOTHING_METHOD_MODES, SMOOTHING_MODES, WEIGHT_STRATEGIES, get_label_column_config, @@ -36,9 +37,8 @@ from pandas import DataFrame, Series, isna, to_numeric from scipy.stats import pearsonr, t from technical.pivots_points import pivots_points from Utils import ( - as_dict, + DEFAULTS_EXIT_THRESHOLDS_CALIBRATION, _OPTUNA_NAMESPACES, - DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES, EXTREMA_COLUMN, EXTREMA_DIRECTION_COLUMN, EXTREMA_DIRECTION_SMOOTHED_COLUMN, @@ -61,7 +61,10 @@ from Utils import ( get_callable_sha256, get_causal_mode, get_distance, + get_custom_protections_config, get_exit_pricing_config, + get_exit_thresholds_calibration_config, + get_fit_live_predictions_candles, get_label_defaults, get_label_horizon_candles, get_label_smoothing_config, @@ -152,7 +155,6 @@ class QuickAdapterV3(IStrategy): _TRADING_MODE_FUTURES: Final[str] = _TRADING_MODES[2] _SMOOTHING_SMM: Final[str] = SMOOTHING_METHODS[5] _SMOOTHING_SAVGOL: Final[str] = SMOOTHING_METHODS[7] - _SMOOTHING_GAUSSIAN_FILTER1D: Final[str] = SMOOTHING_METHODS[8] _FILL_EPSILON: Final[str] = FILL_METHODS[1] _FILL_GAUSSIAN: Final[str] = FILL_METHODS[2] _FILL_EPSILON_GAUSSIAN: Final[str] = FILL_METHODS[3] @@ -176,9 +178,9 @@ class QuickAdapterV3(IStrategy): "t_decl_a": 0.675, } - default_exit_thresholds_calibration: ClassVar[dict[str, float]] = { - "decline_quantile": 0.5, - } + default_exit_thresholds_calibration: ClassVar[dict[str, float]] = ( + DEFAULTS_EXIT_THRESHOLDS_CALIBRATION + ) position_adjustment_enable = True @@ -217,22 +219,8 @@ class QuickAdapterV3(IStrategy): # strict `remaining < min_exit_stake` guard. _PARTIAL_EXIT_MIN_STAKE_MARGIN: Final[float] = 1e-3 - minimal_roi = {str(timeframe_minutes * 864): -1} - # FreqAI is crashing if minimal_roi is a property - # @property - # def minimal_roi(self) -> dict[str, Any]: - # timeframe_minutes = self.timeframe_minutes - # fit_live_predictions_candles = int( - # self.config.get("freqai", {}).get( - # "fit_live_predictions_candles", DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES - # ) - # ) - # return {str(timeframe_minutes * fit_live_predictions_candles): -1} - - # @minimal_roi.setter - # def minimal_roi(self, value: dict[str, Any]) -> None: - # pass + minimal_roi = {str(timeframe_minutes * 864): -1} process_only_new_candles = True @@ -290,25 +278,25 @@ class QuickAdapterV3(IStrategy): }, } - @property + @cached_property + def _fit_live_predictions_candles(self) -> int: + return get_fit_live_predictions_candles(self.config.get("freqai"), logger) + + @cached_property def protections(self) -> list[dict[str, Any]]: - fit_live_predictions_candles = int( - self.config.get("freqai", {}).get( - "fit_live_predictions_candles", DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES - ) - ) - protections = self.config.get("custom_protections", {}) - trade_duration_candles = int(protections.get("trade_duration_candles", 72)) - lookback_period_fraction = float( - protections.get("lookback_period_fraction", 0.5) + fit_live_predictions_candles = self._fit_live_predictions_candles + protections = get_custom_protections_config( + self.config.get("custom_protections"), logger ) + trade_duration_candles = protections["trade_duration_candles"] + lookback_period_fraction = protections["lookback_period_fraction"] lookback_period_candles = max( 1, int(round(fit_live_predictions_candles * lookback_period_fraction)) ) - cooldown = protections.get("cooldown", {}) - cooldown_stop_duration_candles = int(cooldown.get("stop_duration_candles", 4)) + cooldown = protections["cooldown"] + cooldown_stop_duration_candles = cooldown["stop_duration_candles"] stoploss_stop_duration_candles = max( cooldown_stop_duration_candles, trade_duration_candles ) @@ -327,7 +315,7 @@ class QuickAdapterV3(IStrategy): protections_list = [] - if cooldown.get("enabled", True): + if cooldown["enabled"]: protections_list.append( { "method": "CooldownPeriod", @@ -335,22 +323,20 @@ class QuickAdapterV3(IStrategy): } ) - drawdown = protections.get("drawdown", {}) - if drawdown.get("enabled", True): + drawdown = protections["drawdown"] + if drawdown["enabled"]: protections_list.append( { "method": "MaxDrawdown", "lookback_period_candles": lookback_period_candles, "trade_limit": 2 * max_open_trades, "stop_duration_candles": drawdown_stop_duration_candles, - "max_allowed_drawdown": float( - drawdown.get("max_allowed_drawdown", 0.2) - ), + "max_allowed_drawdown": drawdown["max_allowed_drawdown"], } ) - stoploss = protections.get("stoploss", {}) - if stoploss.get("enabled", True): + stoploss = protections["stoploss"] + if stoploss["enabled"]: protections_list.append( { "method": "StoplossGuard", @@ -368,9 +354,7 @@ class QuickAdapterV3(IStrategy): @property def startup_candle_count(self) -> int: # Match the predictions warmup period - return self.config.get("freqai", {}).get( - "fit_live_predictions_candles", DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES - ) + return self._fit_live_predictions_candles @property def max_open_trades_per_side(self) -> int: @@ -387,25 +371,25 @@ class QuickAdapterV3(IStrategy): @cached_property def label_weighting(self) -> dict[str, Any]: return get_label_weighting_config( - as_dict(self.freqai_info.get("label_weighting")), logger + self.freqai_info.get("label_weighting"), logger ) @cached_property def label_smoothing(self) -> dict[str, Any]: return get_label_smoothing_config( - as_dict(self.freqai_info.get("label_smoothing")), logger + self.freqai_info.get("label_smoothing"), logger ) @cached_property def trade_price_target_method(self) -> str: - return get_exit_pricing_config( - as_dict(self.config.get("exit_pricing")), logger - )["trade_price_target_method"] + return get_exit_pricing_config(self.config.get("exit_pricing"), logger)[ + "trade_price_target_method" + ] @cached_property def reversal_confirmation(self) -> dict[str, int | float]: return get_reversal_confirmation_config( - as_dict(self.config.get("reversal_confirmation")), logger + self.config.get("reversal_confirmation"), logger ) @cached_property @@ -439,11 +423,7 @@ class QuickAdapterV3(IStrategy): label_col, label_smoothing["default"], label_smoothing["columns"] ) if ( - col_smoothing_config["method"] - in ( - QuickAdapterV3._SMOOTHING_SAVGOL, - QuickAdapterV3._SMOOTHING_GAUSSIAN_FILTER1D, - ) + col_smoothing_config["method"] in SMOOTHING_METHOD_MODES and col_smoothing_config["mode"] == SMOOTHING_MODES[3] ): # "wrap" raise ValueError( @@ -506,10 +486,13 @@ class QuickAdapterV3(IStrategy): f"{self._pnl_momentum_window_size} candles " f"(~{velocity_span_minutes} min velocity span)." ) - self._exit_thresholds_calibration: dict[str, float] = { - **QuickAdapterV3.default_exit_thresholds_calibration, - **self.config.get("exit_pricing", {}).get("thresholds_calibration", {}), - } + self._exit_thresholds_calibration: dict[str, float] = ( + get_exit_thresholds_calibration_config( + self.config.get("exit_pricing"), + logger, + self.default_exit_thresholds_calibration, + ) + ) self._candle_deviation_cache: dict[CandleDeviationCacheKey, float] = {} self._candle_threshold_cache: dict[CandleThresholdCacheKey, float] = {} self._cached_df_signature: dict[str, DfSignature] = {} @@ -2462,6 +2445,24 @@ class QuickAdapterV3(IStrategy): f"supported values are {', '.join(QuickAdapterV3._TRADING_MODES)}" ) + @cached_property + def _configured_leverage(self) -> Optional[float]: + leverage = self.config.get("leverage") + if leverage is None: + return None + if not is_finite_number(leverage): + logger.warning( + f"Invalid leverage value {leverage!r}: must be a finite number, " + "using proposed_leverage" + ) + return None + leverage = float(leverage) + if leverage < 1.0: + logger.warning( + f"Invalid leverage value {leverage}: must be >= 1.0, clamping to 1.0" + ) + return leverage + def leverage( self, pair: str, @@ -2473,7 +2474,10 @@ class QuickAdapterV3(IStrategy): side: str, **kwargs: Any, ) -> float: - return min(self.config.get("leverage", proposed_leverage), max_leverage) + configured_leverage = self._configured_leverage + if configured_leverage is None: + configured_leverage = proposed_leverage + return float(max(1.0, min(configured_leverage, max_leverage))) def plot_annotations( self, diff --git a/quickadapter/user_data/strategies/Utils.py b/quickadapter/user_data/strategies/Utils.py index f9ce4b5..f2c5778 100644 --- a/quickadapter/user_data/strategies/Utils.py +++ b/quickadapter/user_data/strategies/Utils.py @@ -43,6 +43,7 @@ from LabelTransformer import ( LABEL_WEIGHT_SUPPORT_POLICIES, NORMALIZATION_TYPES, PREDICTION_METHODS, + SMOOTHING_METHOD_MODES, SMOOTHING_METHODS, SMOOTHING_MODES, STANDARDIZATION_TYPES, @@ -53,6 +54,7 @@ from LabelTransformer import ( FillEpsilonBaseline, SmoothingMethod, SmoothingMode, + get_label_column_config, ) from numpy.typing import NDArray from scipy.ndimage import gaussian_filter1d @@ -347,6 +349,8 @@ class _NumericValidator: require_int: bool = False def __call__(self, value: Any) -> bool: + if isinstance(value, bool): + return False if self.require_int and not isinstance(value, int): return False if not isinstance(value, (int, float)) or not _is_finite_value(value): @@ -413,7 +417,22 @@ class _DictValidator: return "must be a mapping" -_Validator = _EnumValidator | _NumericValidator | _RangeValidator | _DictValidator +@dataclass(frozen=True, slots=True) +class _BoolValidator: + def __call__(self, value: Any) -> bool: + return isinstance(value, bool) + + def message(self, param: str) -> str: + return "must be a boolean" + + +_Validator = ( + _EnumValidator + | _NumericValidator + | _RangeValidator + | _DictValidator + | _BoolValidator +) @dataclass(frozen=True, slots=True) @@ -941,11 +960,20 @@ def as_dict(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} +def as_config_section(value: Any, name: str, logger: Logger) -> dict[str, Any]: + if value is not None and not isinstance(value, dict): + logger.warning( + f"Invalid {name} value {value!r}: must be a mapping, using defaults" + ) + return as_dict(value) + + def enum_error_message(ctx: str, value: Any, options: Sequence[str]) -> str: return f"Invalid {ctx} value {value!r}: supported values are {', '.join(options)}" ValidateParamsFn = Callable[[dict[str, Any], Logger, str], dict[str, Any]] +CrossFieldValidatorFn = Callable[[dict[str, Any], str], None] _MISSING: Final = object() @@ -1148,16 +1176,38 @@ def _get_label_config( return {"default": validated_default, "columns": {}} -_LABEL_KIND_REGISTRY: Final[dict[str, tuple[dict[str, _ParamSpec], dict[str, Any]]]] = { - "label_weighting": (_WEIGHTING_SPECS, DEFAULTS_LABEL_WEIGHTING), - "label_pipeline": (_PIPELINE_SPECS, DEFAULTS_LABEL_PIPELINE), - "label_smoothing": (_SMOOTHING_SPECS, DEFAULTS_LABEL_SMOOTHING), - "label_prediction": (_PREDICTION_SPECS, DEFAULTS_LABEL_PREDICTION), +def _validate_smoothing_method_mode( + config: dict[str, Any], + config_name: str, +) -> None: + method = config["method"] + valid_modes = SMOOTHING_METHOD_MODES.get(method) + if valid_modes is not None and config["mode"] not in valid_modes: + raise ValueError( + f"Invalid {config_name} mode value {config['mode']!r} for " + f"method {method!r}: supported values are {', '.join(valid_modes)}" + ) + + +_LABEL_KIND_REGISTRY: Final[ + dict[ + str, + tuple[dict[str, _ParamSpec], dict[str, Any], CrossFieldValidatorFn | None], + ] +] = { + "label_weighting": (_WEIGHTING_SPECS, DEFAULTS_LABEL_WEIGHTING, None), + "label_pipeline": (_PIPELINE_SPECS, DEFAULTS_LABEL_PIPELINE, None), + "label_smoothing": ( + _SMOOTHING_SPECS, + DEFAULTS_LABEL_SMOOTHING, + _validate_smoothing_method_mode, + ), + "label_prediction": (_PREDICTION_SPECS, DEFAULTS_LABEL_PREDICTION, None), } def _label_kind_validator(kind: str) -> ValidateParamsFn: - specs, defaults = _LABEL_KIND_REGISTRY[kind] + specs, defaults, _ = _LABEL_KIND_REGISTRY[kind] def validate( config: dict[str, Any], @@ -1171,7 +1221,7 @@ def _label_kind_validator(kind: str) -> ValidateParamsFn: def get_label_kind_config( kind: str, - config: dict[str, Any], + config: Any, logger: Logger, ) -> dict[str, Any]: if kind not in _LABEL_KIND_REGISTRY: @@ -1179,10 +1229,20 @@ def get_label_kind_config( f"Unknown label kind {kind!r}: supported values are " f"{', '.join(_LABEL_KIND_REGISTRY)}" ) - _, defaults = _LABEL_KIND_REGISTRY[kind] - return _get_label_config( + config = as_config_section(config, kind, logger) + _, defaults, cross_field_validator = _LABEL_KIND_REGISTRY[kind] + validated = _get_label_config( config, logger, kind, _label_kind_validator(kind), defaults ) + if cross_field_validator is not None: + for label_col in LABEL_COLUMNS: + cross_field_validator( + get_label_column_config( + label_col, validated["default"], validated["columns"] + ), + f"{kind} for label {label_col!r}", + ) + return validated def get_label_weighting_config( @@ -1224,12 +1284,165 @@ _EXIT_PRICING_SPECS: Final[dict[str, _ParamSpec]] = { } -def get_exit_pricing_config(config: dict[str, Any], logger: Logger) -> dict[str, str]: +def get_exit_pricing_config(config: Any, logger: Logger) -> dict[str, str]: return _validate_params( - config, logger, "exit_pricing", _EXIT_PRICING_SPECS, DEFAULTS_EXIT_PRICING + as_config_section(config, "exit_pricing", logger), + logger, + "exit_pricing", + _EXIT_PRICING_SPECS, + DEFAULTS_EXIT_PRICING, ) +DEFAULTS_EXIT_THRESHOLDS_CALIBRATION: Final[dict[str, Any]] = { + "decline_quantile": 0.5, +} + +_EXIT_THRESHOLDS_CALIBRATION_SPECS: Final[dict[str, _ParamSpec]] = { + "decline_quantile": _ParamSpec( + _NumericValidator( + min_value=0, max_value=1, min_exclusive=True, max_exclusive=True + ), + output_type=float, + ), +} + + +def get_exit_thresholds_calibration_config( + config: Any, + logger: Logger, + overrides: dict[str, Any] | None = None, +) -> dict[str, float]: + # exit_pricing mapping warning owned by get_exit_pricing_config (avoid double-warn) + config = as_dict(config) + # validate the override so an invalid subclass default falls back to the canonical default + defaults = _validate_params( + overrides or {}, + logger, + "exit_pricing.thresholds_calibration", + _EXIT_THRESHOLDS_CALIBRATION_SPECS, + DEFAULTS_EXIT_THRESHOLDS_CALIBRATION, + ) + return _validate_params( + as_config_section( + config.get("thresholds_calibration"), + "exit_pricing.thresholds_calibration", + logger, + ), + logger, + "exit_pricing.thresholds_calibration", + _EXIT_THRESHOLDS_CALIBRATION_SPECS, + defaults, + ) + + +DEFAULTS_CUSTOM_PROTECTIONS: Final[dict[str, Any]] = { + "trade_duration_candles": 72, + "lookback_period_fraction": 0.5, +} + +DEFAULTS_COOLDOWN_PROTECTION: Final[dict[str, Any]] = { + "enabled": True, + "stop_duration_candles": 4, +} + +DEFAULTS_DRAWDOWN_PROTECTION: Final[dict[str, Any]] = { + "enabled": True, + "max_allowed_drawdown": 0.2, +} + +DEFAULTS_STOPLOSS_PROTECTION: Final[dict[str, Any]] = { + "enabled": True, +} + +_CUSTOM_PROTECTIONS_SPECS: Final[dict[str, _ParamSpec]] = { + "trade_duration_candles": _ParamSpec( + _NumericValidator(min_value=1, require_int=True), output_type=int + ), + "lookback_period_fraction": _ParamSpec( + _NumericValidator(min_value=0, max_value=1, min_exclusive=True), + output_type=float, + ), +} + +_COOLDOWN_PROTECTION_SPECS: Final[dict[str, _ParamSpec]] = { + "enabled": _ParamSpec(_BoolValidator()), + "stop_duration_candles": _ParamSpec( + _NumericValidator(min_value=1, require_int=True), output_type=int + ), +} + +_DRAWDOWN_PROTECTION_SPECS: Final[dict[str, _ParamSpec]] = { + "enabled": _ParamSpec(_BoolValidator()), + "max_allowed_drawdown": _ParamSpec( + _NumericValidator( + min_value=0, max_value=1, min_exclusive=True, max_exclusive=True + ), + output_type=float, + ), +} + +_STOPLOSS_PROTECTION_SPECS: Final[dict[str, _ParamSpec]] = { + "enabled": _ParamSpec(_BoolValidator()), +} + + +def get_custom_protections_config(config: Any, logger: Logger) -> dict[str, Any]: + config = as_config_section(config, "custom_protections", logger) + validated = _validate_params( + config, + logger, + "custom_protections", + _CUSTOM_PROTECTIONS_SPECS, + DEFAULTS_CUSTOM_PROTECTIONS, + ) + validated["cooldown"] = _validate_params( + as_config_section( + config.get("cooldown"), "custom_protections.cooldown", logger + ), + logger, + "custom_protections.cooldown", + _COOLDOWN_PROTECTION_SPECS, + DEFAULTS_COOLDOWN_PROTECTION, + ) + validated["drawdown"] = _validate_params( + as_config_section( + config.get("drawdown"), "custom_protections.drawdown", logger + ), + logger, + "custom_protections.drawdown", + _DRAWDOWN_PROTECTION_SPECS, + DEFAULTS_DRAWDOWN_PROTECTION, + ) + validated["stoploss"] = _validate_params( + as_config_section( + config.get("stoploss"), "custom_protections.stoploss", logger + ), + logger, + "custom_protections.stoploss", + _STOPLOSS_PROTECTION_SPECS, + DEFAULTS_STOPLOSS_PROTECTION, + ) + return validated + + +_FIT_LIVE_PREDICTIONS_SPECS: Final[dict[str, _ParamSpec]] = { + "fit_live_predictions_candles": _ParamSpec( + _NumericValidator(min_value=1, require_int=True), output_type=int + ), +} + + +def get_fit_live_predictions_candles(config: Any, logger: Logger) -> int: + return _validate_params( + as_config_section(config, "freqai", logger), + logger, + "freqai", + _FIT_LIVE_PREDICTIONS_SPECS, + {"fit_live_predictions_candles": DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES}, + )["fit_live_predictions_candles"] + + DEFAULTS_REVERSAL_CONFIRMATION: Final[dict[str, Any]] = { "lookback_period_candles": 0, "decay_fraction": 0.5, @@ -1252,8 +1465,9 @@ _REVERSAL_CONFIRMATION_SCALAR_SPECS: Final[dict[str, _ParamSpec]] = { def get_reversal_confirmation_config( - config: dict[str, Any], logger: Logger + config: Any, logger: Logger ) -> dict[str, int | float]: + config = as_config_section(config, "reversal_confirmation", logger) validated = _validate_params( config, logger, -- 2.53.0