]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
feat(qav3): add more tunables for reservals labeling HPO
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 31 Oct 2025 15:11:50 +0000 (16:11 +0100)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 31 Oct 2025 15:11:50 +0000 (16:11 +0100)
Signed-off-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
README.md
quickadapter/user_data/config-template.json
quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py
quickadapter/user_data/strategies/QuickAdapterV3.py
quickadapter/user_data/strategies/Utils.py

index 37525834ea45bf0982fc762eb0688537542e4980..f907d49df66d0384945fee5160b9760439dbbbd4 100644 (file)
--- 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.             |
index c82b6fc014d10abae38d6c26854be8a3799d93ee..b1b24d87ed5e98bd88e77b43c1dcda37dbf66409 100644 (file)
       "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
     },
         "4h",
         // "1d"
       ],
-      "label_period_candles": 24,
+      "label_period_candles": 18,
       "label_metric": "euclidean",
       "label_weights": [
         0.5,
index d6550e23b0f76501a757e734a541ce816fab3701..88f46706f2c6e66bc42959d0a2f3e7ddb5b1b27a 100644 (file)
@@ -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(
index 8437cd247c4174d668a569e1d10b43db5e8c98b9..80d8150cc1e025cd625c9a105cff5414ebbcc931 100644 (file)
@@ -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
index c40118be495d1a08eac32cdfb85f371acf7b427e..be700bfdc9eb858466ef4d6ec72bf18c929aedb3 100644 (file)
@@ -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