]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(quickadapter): validate coupled/bounded config inputs at the option layer (#141)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 28 Jul 2026 12:11:07 +0000 (14:11 +0200)
committerGitHub <noreply@github.com>
Tue, 28 Jul 2026 12:11:07 +0000 (14:11 +0200)
* 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
quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py
quickadapter/user_data/strategies/LabelTransformer.py
quickadapter/user_data/strategies/QuickAdapterV3.py
quickadapter/user_data/strategies/Utils.py

index cc150920a323354eb573f50c8f124002cb63dc06..fa58f24008c171cbfb4d0986f8738eceb882f15f 100644 (file)
--- 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.                                 |
index 37b64285d0a14d5c48c9cfd6a2e6c25f599df32e..6261d87f4fbe8382430802a0eb309d4f50fdfd2c 100644 (file)
@@ -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(
index ca8f46f0f2fb6d0d147bfd76830d2f5700b27e3d..5de8898875e75beea9f85c1f7453cc4cdb3a9c7d 100644 (file)
@@ -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"
index 805663d0d6813277b03eaa26cd90fb0255e94c4d..aaae7550b5f74cfe6ad845421af22da3e33a5ce8 100644 (file)
@@ -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,
index f9ce4b5a19191746be86de2fbe4acb5c31ae139e..f2c577857409b4a6d790a97fef42e7d497098136 100644 (file)
@@ -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,