]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
refactor: harmonize errors and warnings messages
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 26 Dec 2025 19:16:22 +0000 (20:16 +0100)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 26 Dec 2025 19:16:22 +0000 (20:16 +0100)
Signed-off-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
ReforceXY/reward_space_analysis/reward_space_analysis.py
ReforceXY/user_data/freqaimodels/ReforceXY.py
ReforceXY/user_data/strategies/RLAgentStrategy.py
quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py
quickadapter/user_data/strategies/QuickAdapterV3.py
quickadapter/user_data/strategies/Utils.py

index 414c99c26eaf32b3345546a212d75051d9799fd0..dd476a962aaa9b6095d1854eaefc9298d58d6649 100644 (file)
@@ -115,6 +115,14 @@ ALLOWED_EXIT_POTENTIAL_MODES = {
     "retain_previous",
 }
 
+# Supported trading modes
+TRADING_MODES: Tuple[str, ...] = ("spot", "margin", "futures")
+
+# Supported p-value adjustment methods
+ADJUST_METHODS: Tuple[str, ...] = ("none", "benjamini_hochberg")
+# Alias without underscore for convenience
+_ADJUST_METHODS_ALIASES: frozenset[str] = frozenset({"benjaminihochberg"})
+
 
 DEFAULT_MODEL_REWARD_PARAMETERS: RewardParams = {
     "invalid_action": -2.0,
@@ -305,7 +313,7 @@ def _to_bool(value: Any) -> bool:
     if text in {"false", "0", "no", "n", "off"}:
         return False
     # Unsupported type
-    raise ValueError(f"Unrecognized boolean literal: {value!r}")
+    raise ValueError(f"Param: unrecognized boolean literal {value!r}")
 
 
 def _get_bool_param(params: RewardParams, key: str, default: Optional[bool] = None) -> bool:
@@ -422,7 +430,7 @@ def _clamp_float_to_bounds(
     if "min" in effective_bounds and adjusted < float(effective_bounds["min"]):
         if strict:
             raise ValueError(
-                f"Parameter '{key}'={adjusted} below min {float(effective_bounds['min'])}"
+                f"Param: '{key}'={adjusted} below min {float(effective_bounds['min'])}"
             )
         adjusted = float(effective_bounds["min"])
         reason_parts.append(f"min={float(effective_bounds['min'])}")
@@ -430,14 +438,14 @@ def _clamp_float_to_bounds(
     if "max" in effective_bounds and adjusted > float(effective_bounds["max"]):
         if strict:
             raise ValueError(
-                f"Parameter '{key}'={adjusted} above max {float(effective_bounds['max'])}"
+                f"Param: '{key}'={adjusted} above max {float(effective_bounds['max'])}"
             )
         adjusted = float(effective_bounds["max"])
         reason_parts.append(f"max={float(effective_bounds['max'])}")
 
     if not np.isfinite(adjusted):
         if strict:
-            raise ValueError(f"Parameter '{key}' is non-finite: {adjusted}")
+            raise ValueError(f"Param: '{key}' is non-finite: {adjusted}")
         adjusted = float(effective_bounds.get("min", 0.0))
         reason_parts.append("non_finite_reset")
 
@@ -521,11 +529,13 @@ def _compute_duration_ratio(trade_duration: int, max_trade_duration_candles: int
 
 def _is_short_allowed(trading_mode: str) -> bool:
     mode = trading_mode.lower()
-    if mode in {"margin", "futures"}:
+    if mode in TRADING_MODES[1:]:  # "margin", "futures"
         return True
-    if mode == "spot":
+    if mode == TRADING_MODES[0]:  # "spot"
         return False
-    raise ValueError("Unsupported trading mode. Expected one of: spot, margin, futures")
+    raise ValueError(
+        f"Config: unsupported trading mode '{mode}'. Expected one of: {list(TRADING_MODES)}"
+    )
 
 
 def _fail_safely(reason: str) -> float:
@@ -619,7 +629,7 @@ def validate_reward_parameters(
                 }
                 continue
             if strict:
-                raise ValueError(f"Parameter '{key}' is non-numeric or invalid: {original_val!r}")
+                raise ValueError(f"Param: '{key}' is non-numeric or invalid: {original_val!r}")
             adjusted = bounds.get("min", 0.0)
             sanitized[key] = adjusted
             adjustments[key] = {
@@ -1451,7 +1461,7 @@ def parse_overrides(overrides: Iterable[str]) -> RewardParams:
     parsed: RewardParams = {}
     for override in overrides:
         if "=" not in override:
-            raise ValueError(f"Invalid override format: '{override}'")
+            raise ValueError(f"CLI: invalid override format '{override}'. Expected 'key=value'")
         key, value = override.split("=", 1)
         try:
             parsed[key] = float(value)
@@ -1807,7 +1817,7 @@ def _binned_stats(
     """Return count/mean/std/min/max of target grouped by clipped bins of column."""
     bins_arr = np.asarray(list(bins), dtype=float)
     if bins_arr.ndim != 1 or bins_arr.size < 2:
-        raise ValueError("bins must contain at least two edges")
+        raise ValueError("Stats: bins must contain at least two edges")
     clipped = df[column].clip(lower=float(bins_arr[0]), upper=float(bins_arr[-1]))
     categories = pd.cut(
         clipped,
@@ -1991,7 +2001,7 @@ def _perform_feature_analysis(
         or permutation_importance is None
         or r2_score is None
     ):
-        raise ImportError("scikit-learn is not available; skipping feature analysis.")
+        raise ImportError("Feature analysis: scikit-learn is not available")
 
     canonical_features = [
         "pnl",
@@ -2172,7 +2182,7 @@ def load_real_episodes(path: Path, *, enforce_columns: bool = True) -> pd.DataFr
         with path.open("rb") as f:
             episodes_data = pickle.load(f)
     except Exception as e:
-        raise ValueError(f"Failed to unpickle '{path}': {e!r}") from e
+        raise ValueError(f"Data: failed to unpickle '{path}': {e!r}") from e
 
     # Top-level dict with 'transitions'
     if isinstance(episodes_data, dict) and "transitions" in episodes_data:
@@ -2184,11 +2194,11 @@ def load_real_episodes(path: Path, *, enforce_columns: bool = True) -> pd.DataFr
                 df = pd.DataFrame(list(candidate))
             except TypeError:
                 raise ValueError(
-                    f"Top-level 'transitions' in '{path}' is not iterable (type {type(candidate)!r})."
+                    f"Data: 'transitions' in '{path}' is not iterable (type {type(candidate)!r})"
                 )
             except Exception as e:
                 raise ValueError(
-                    f"Could not build DataFrame from top-level 'transitions' in '{path}': {e!r}"
+                    f"Data: could not build DataFrame from 'transitions' in '{path}': {e!r}"
                 ) from e
     # List of episodes where some entries have 'transitions'
     elif isinstance(episodes_data, list) and any(
@@ -2206,7 +2216,7 @@ def load_real_episodes(path: Path, *, enforce_columns: bool = True) -> pd.DataFr
                         all_transitions.extend(list(trans))
                     except TypeError:
                         raise ValueError(
-                            f"Episode 'transitions' is not iterable in file '{path}'; found type {type(trans)!r}"
+                            f"Data: episode 'transitions' is not iterable in '{path}' (type {type(trans)!r})"
                         )
             else:
                 skipped += 1
@@ -2220,7 +2230,7 @@ def load_real_episodes(path: Path, *, enforce_columns: bool = True) -> pd.DataFr
             df = pd.DataFrame(all_transitions)
         except Exception as e:
             raise ValueError(
-                f"Could not build DataFrame from flattened transitions in '{path}': {e!r}"
+                f"Data: could not build DataFrame from transitions in '{path}': {e!r}"
             ) from e
     else:
         try:
@@ -2230,7 +2240,7 @@ def load_real_episodes(path: Path, *, enforce_columns: bool = True) -> pd.DataFr
                 df = pd.DataFrame(episodes_data)
         except Exception as e:
             raise ValueError(
-                f"Could not convert pickled object from '{path}' to DataFrame: {e!r}"
+                f"Data: could not convert pickled object from '{path}' to DataFrame: {e!r}"
             ) from e
 
     # Coerce common numeric fields; warn when values are coerced to NaN
@@ -2287,8 +2297,8 @@ def load_real_episodes(path: Path, *, enforce_columns: bool = True) -> pd.DataFr
     if missing_required:
         if enforce_columns:
             raise ValueError(
-                f"Loaded episodes data is missing required columns: {sorted(missing_required)}. "
-                f"Found columns: {sorted(list(df.columns))}."
+                f"Data: missing required columns {sorted(missing_required)}. "
+                f"Found: {sorted(list(df.columns))}"
             )
         warnings.warn(
             f"Missing columns {sorted(missing_required)}; filled with NaN when loading (enforce_columns=False)",
@@ -2416,7 +2426,7 @@ def _validate_distribution_metrics(metrics: Dict[str, float]) -> None:
 
 
 def statistical_hypothesis_tests(
-    df: pd.DataFrame, *, adjust_method: str = "none", seed: int = 42
+    df: pd.DataFrame, *, adjust_method: str = ADJUST_METHODS[0], seed: int = 42
 ) -> Dict[str, Any]:
     """Statistical hypothesis tests (Spearman, Kruskal-Wallis, Mann-Whitney).
 
@@ -2517,11 +2527,13 @@ def statistical_hypothesis_tests(
         }
 
     # Optional multiple testing correction (Benjamini-Hochberg)
-    if adjust_method not in {"none", "benjamini_hochberg", "benjaminihochberg"}:
+    _valid_adjust = set(ADJUST_METHODS) | _ADJUST_METHODS_ALIASES
+    if adjust_method not in _valid_adjust:
         raise ValueError(
-            "Unsupported adjust_method. Use 'none', 'benjamini_hochberg', or 'benjaminihochberg'."
+            f"Stats: unsupported adjust_method '{adjust_method}'. "
+            f"Expected one of: {list(ADJUST_METHODS)}"
         )
-    if adjust_method in {"benjamini_hochberg", "benjaminihochberg"} and results:
+    if adjust_method in _valid_adjust - {ADJUST_METHODS[0]} and results:
         # Collect p-values
         items = list(results.items())
         pvals = np.array([v[1]["p_value"] for v in items])
@@ -3447,8 +3459,8 @@ def build_argument_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "--trading_mode",
         type=str.lower,
-        choices=["spot", "margin", "futures"],
-        default="spot",
+        choices=list(TRADING_MODES),
+        default=TRADING_MODES[0],
         help=("Trading mode to simulate (spot disables shorts). Default: spot."),
     )
     parser.add_argument(
@@ -3482,8 +3494,8 @@ def build_argument_parser() -> argparse.ArgumentParser:
     parser.add_argument(
         "--pvalue_adjust",
         type=str.lower,
-        choices=["none", "benjamini_hochberg"],
-        default="none",
+        choices=list(ADJUST_METHODS),
+        default=ADJUST_METHODS[0],
         help="Multiple testing correction method for hypothesis tests (default: none).",
     )
     parser.add_argument(
@@ -3527,7 +3539,7 @@ def write_complete_statistical_analysis(
     seed: int,
     real_df: Optional[pd.DataFrame] = None,
     *,
-    adjust_method: str = "none",
+    adjust_method: str = ADJUST_METHODS[0],
     stats_seed: Optional[int] = None,
     strict_diagnostics: bool = False,
     bootstrap_resamples: int = 10000,
index 18b66acd246306b4e0c339885e26f08f2a57c29d..83c3ea6e05565ac43cb75c17c0528e3b1de52795 100644 (file)
@@ -267,7 +267,8 @@ class ReforceXY(BaseReinforcementLearningModel):
         self.pairs: List[str] = self.config.get("exchange", {}).get("pair_whitelist")
         if not self.pairs:
             raise ValueError(
-                "FreqAI model requires StaticPairList method defined in pairlists configuration and pair_whitelist defined in exchange section configuration"
+                "Config: missing 'pair_whitelist' in exchange section "
+                "or StaticPairList method not defined in pairlists configuration"
             )
         self.action_masking: bool = (
             self.model_type == ReforceXY._MODEL_TYPES[2]
@@ -813,7 +814,7 @@ class ReforceXY(BaseReinforcementLearningModel):
         train_df = data_dictionary.get("train_features")
         train_timesteps = len(train_df)
         if train_timesteps <= 0:
-            raise ValueError("train_features dataframe has zero length")
+            raise ValueError("Training: train_features dataframe has zero length")
         test_df = data_dictionary.get("test_features")
         eval_timesteps = len(test_df)
         train_cycles = max(1, int(self.rl_config.get("train_cycles", 25)))
@@ -1172,7 +1173,8 @@ class ReforceXY(BaseReinforcementLearningModel):
             )
         else:
             raise ValueError(
-                f"Unsupported storage backend: {storage_backend}. Supported backends are: {', '.join(ReforceXY._STORAGE_BACKENDS)}"
+                f"Hyperopt: unsupported storage backend '{storage_backend}'. "
+                f"Expected one of: {list(ReforceXY._STORAGE_BACKENDS)}"
             )
         return storage
 
@@ -1213,7 +1215,8 @@ class ReforceXY(BaseReinforcementLearningModel):
             )
         else:
             raise ValueError(
-                f"Unsupported sampler: {sampler}. Supported samplers: {', '.join(ReforceXY._SAMPLER_TYPES)}"
+                f"Hyperopt: unsupported sampler '{sampler}'. "
+                f"Expected one of: {list(ReforceXY._SAMPLER_TYPES)}"
             )
 
     @staticmethod
@@ -1547,7 +1550,9 @@ class ReforceXY(BaseReinforcementLearningModel):
         elif ReforceXY._MODEL_TYPES[3] in self.model_type:
             return sample_params_dqn(trial)
         else:
-            raise NotImplementedError(f"{self.model_type} not supported for hyperopt")
+            raise NotImplementedError(
+                f"Hyperopt: model type '{self.model_type}' not supported"
+            )
 
     def objective(
         self, trial: Trial, dk: FreqaiDataKitchen, total_timesteps: int
@@ -4322,7 +4327,7 @@ def convert_optuna_params_to_model_params(
 
     lr = optuna_params.get("learning_rate")
     if lr is None:
-        raise ValueError(f"missing {'learning_rate'} in optuna params for {model_type}")
+        raise ValueError(f"Optuna: missing 'learning_rate' in params for {model_type}")
     lr = get_schedule(
         optuna_params.get("lr_schedule", ReforceXY._SCHEDULE_TYPES[1]), float(lr)
     )  # default: "constant"
@@ -4341,7 +4346,9 @@ def convert_optuna_params_to_model_params(
         ]
         for param in required_ppo_params:
             if optuna_params.get(param) is None:
-                raise ValueError(f"missing '{param}' in optuna params for {model_type}")
+                raise ValueError(
+                    f"Optuna: missing '{param}' in params for {model_type}"
+                )
         cr = optuna_params.get("clip_range")
         cr = get_schedule(
             optuna_params.get("cr_schedule", ReforceXY._SCHEDULE_TYPES[1]),
@@ -4384,7 +4391,9 @@ def convert_optuna_params_to_model_params(
         ]
         for param in required_dqn_params:
             if optuna_params.get(param) is None:
-                raise ValueError(f"missing '{param}' in optuna params for {model_type}")
+                raise ValueError(
+                    f"Optuna: missing '{param}' in params for {model_type}"
+                )
         train_freq = optuna_params.get("train_freq")
         subsample_steps = optuna_params.get("subsample_steps")
         gradient_steps = compute_gradient_steps(train_freq, subsample_steps)
@@ -4418,7 +4427,7 @@ def convert_optuna_params_to_model_params(
         ):  # "QRDQN"
             policy_kwargs["n_quantiles"] = int(optuna_params["n_quantiles"])
     else:
-        raise ValueError(f"Model {model_type} not supported")
+        raise ValueError(f"Optuna: model type '{model_type}' not supported")
 
     if optuna_params.get("net_arch"):
         net_arch_value = str(optuna_params["net_arch"])
index e3bc5ff2ded4771bc659398dc40da0ccda625199..3a0db9fb7285e163183f7c6f5fec048d36bf6409 100644 (file)
@@ -168,4 +168,7 @@ class RLAgentStrategy(IStrategy):
         elif trading_mode == RLAgentStrategy._TRADING_MODES[2]:
             return False
         else:
-            raise ValueError(f"Invalid trading_mode: {trading_mode}")
+            raise ValueError(
+                f"Config: invalid trading_mode '{trading_mode}'. "
+                f"Expected one of: {list(RLAgentStrategy._TRADING_MODES)}"
+            )
index 7591d0e23acad7f768cda39ac1eb2b7a89a52e5f..4d7122db28aaa631c163277c9176d79537ef4898 100644 (file)
@@ -42,6 +42,7 @@ from Utils import (
 
 ExtremaSelectionMethod = Literal["rank_extrema", "rank_peaks", "partition"]
 OptunaNamespace = Literal["hp", "train", "label"]
+ClusterSelectionMethod = Literal["medoid", "min"]
 CustomThresholdMethod = Literal["median", "soft_extremum"]
 SkimageThresholdMethod = Literal[
     "mean", "isodata", "li", "minimum", "otsu", "triangle", "yen"
@@ -101,15 +102,20 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         *_CUSTOM_THRESHOLD_METHODS,
     )
 
-    _OPTUNA_STORAGE_BACKENDS: Final[tuple[str, ...]] = ("file", "sqlite")
-    _OPTUNA_SAMPLERS: Final[tuple[str, ...]] = ("tpe", "auto")
-    _OPTUNA_NAMESPACES: Final[tuple[OptunaNamespace, ...]] = ("hp", "train", "label")
+    _CLUSTER_SELECTION_METHODS: Final[tuple[ClusterSelectionMethod, ...]] = (
+        "medoid",
+        "min",
+    )
 
     _OPTUNA_LABEL_N_OBJECTIVES: Final[int] = 7
     _OPTUNA_LABEL_DIRECTIONS: Final[tuple[optuna.study.StudyDirection, ...]] = (
         optuna.study.StudyDirection.MAXIMIZE,
     ) * _OPTUNA_LABEL_N_OBJECTIVES
 
+    _OPTUNA_STORAGE_BACKENDS: Final[tuple[str, ...]] = ("file", "sqlite")
+    _OPTUNA_SAMPLERS: Final[tuple[str, ...]] = ("tpe", "auto")
+    _OPTUNA_NAMESPACES: Final[tuple[OptunaNamespace, ...]] = ("hp", "train", "label")
+
     _SCIPY_METRICS: Final[tuple[str, ...]] = (
         # "braycurtis",
         # "canberra",
@@ -160,6 +166,12 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         *_CUSTOM_METRICS,
     )
 
+    _UNSUPPORTED_CLUSTER_METRICS: Final[tuple[str, ...]] = (
+        "mahalanobis",
+        "seuclidean",
+        "jensenshannon",
+    )
+
     PREDICTIONS_EXTREMA_THRESHOLD_OUTLIER_DEFAULT: Final[float] = 0.999
     PREDICTIONS_EXTREMA_THRESHOLDS_ALPHA_DEFAULT: Final[float] = 12.0
     PREDICTIONS_EXTREMA_EXTREMA_FRACTION_DEFAULT: Final[float] = 1.0
@@ -205,6 +217,14 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
     def _metrics_set() -> set[str]:
         return set(QuickAdapterRegressorV3._METRICS)
 
+    @staticmethod
+    def _unsupported_cluster_metrics_set() -> set[str]:
+        return set(QuickAdapterRegressorV3._UNSUPPORTED_CLUSTER_METRICS)
+
+    @staticmethod
+    def _cluster_selection_methods_set() -> set[ClusterSelectionMethod]:
+        return set(QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS)
+
     @staticmethod
     def _get_label_p_order_default(metric: str) -> Optional[float]:
         if metric == QuickAdapterRegressorV3._SCIPY_METRICS[5]:  # "minkowski"
@@ -646,7 +666,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 QuickAdapterRegressorV3._CUSTOM_METRICS[10],  # "kmeans2"
             }:
                 logger.info(
-                    f"  label_kmeans_selection: min (default for {label_metric})"
+                    f"  label_kmeans_selection: {QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[1]} (default for {label_metric})"
                 )
             label_kmedoids_metric_config = self.ft_params.get("label_kmedoids_metric")
             if label_kmedoids_metric_config is not None:
@@ -669,7 +689,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 label_metric == QuickAdapterRegressorV3._CUSTOM_METRICS[11]
             ):  # "kmedoids"
                 logger.info(
-                    f"  label_kmedoids_selection: min (default for {label_metric})"
+                    f"  label_kmedoids_selection: {QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[1]} (default for {label_metric})"
                 )
 
             label_knn_metric_config = self.ft_params.get("label_knn_metric")
@@ -779,8 +799,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             params = self._optuna_label_params.get(pair)
         else:
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES)}"
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES)}"
             )
         return params
 
@@ -795,8 +815,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             self._optuna_label_params[pair] = params
         else:
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES)}"
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES)}"
             )
 
     def get_optuna_value(self, pair: str, namespace: str) -> float:
@@ -806,8 +826,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             value = self._optuna_train_value.get(pair)
         else:
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES[:2])}"  # Only hp and train
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES[:2])}"  # Only hp and train
             )
         return value
 
@@ -818,8 +838,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             self._optuna_train_value[pair] = value
         else:
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES[:2])}"  # Only hp and train
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._OPTUNA_NAMESPACES[:2])}"  # Only hp and train
             )
 
     def get_optuna_values(self, pair: str, namespace: str) -> list[float | int]:
@@ -827,8 +847,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             values = self._optuna_label_values.get(pair)
         else:
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
             )
         return values
 
@@ -839,8 +859,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             self._optuna_label_values[pair] = values
         else:
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
             )
 
     def init_optuna_label_candle_pool(self) -> None:
@@ -1036,11 +1056,11 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]
         }:  # Only "label"
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
             )
         if not callable(callback):
-            raise ValueError("callback must be callable")
+            raise ValueError("Invalid callback: must be callable")
         self._optuna_label_candles[pair] += 1
         if pair not in self._optuna_label_incremented_pairs:
             self._optuna_label_incremented_pairs.append(pair)
@@ -1371,8 +1391,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             pred_minima = pred_extrema[pred_extrema < -eps]
         else:
             raise ValueError(
-                f"Unsupported extrema selection method: {extrema_selection}. "
-                f"Supported methods are {', '.join(QuickAdapterRegressorV3._EXTREMA_SELECTION_METHODS)}"
+                f"Invalid extrema_selection '{extrema_selection}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._EXTREMA_SELECTION_METHODS)}"
             )
 
         return pred_minima, pred_maxima
@@ -1413,7 +1433,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         extrema_fraction: float = 1.0,
     ) -> tuple[float, float]:
         if alpha < 0:
-            raise ValueError("alpha must be non-negative")
+            raise ValueError(f"Invalid alpha {alpha}: must be >= 0")
         pred_minima, pred_maxima = QuickAdapterRegressorV3.get_pred_min_max(
             pred_extrema, extrema_selection, extrema_fraction
         )
@@ -1465,7 +1485,10 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         try:
             threshold_func = getattr(skimage.filters, f"threshold_{method}")
         except AttributeError:
-            raise ValueError(f"Unknown skimage threshold function: threshold_{method}")
+            raise ValueError(
+                f"Invalid skimage threshold method '{method}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._SKIMAGE_THRESHOLD_METHODS)}"
+            )
 
         min_func = QuickAdapterRegressorV3.apply_skimage_threshold
         max_func = QuickAdapterRegressorV3.apply_skimage_threshold
@@ -1521,7 +1544,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
           Must contain only finite values (no NaN or inf).
         - metric: distance metric name accepted by scipy.spatial.distance.pdist.
         - weights: optional weight vector per feature (passed as 'w' to pdist).
-                   Not supported by mahalanobis, seuclidean, jensenshannon.
+                   Not supported by metrics in _UNSUPPORTED_CLUSTER_METRICS.
                    Must have size equal to n_features and contain finite non-negative values.
         - p: optional Minkowski order (default 2.0 if metric=='minkowski').
 
@@ -1542,26 +1565,26 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             array([2.        , 2.41421356, 2.41421356])
         """
         if matrix.ndim != 2:
-            raise ValueError("matrix must be 2-dimensional")
+            raise ValueError("Invalid matrix: must be 2-dimensional")
         if matrix.shape[1] == 0:
-            raise ValueError("matrix must have at least one feature")
+            raise ValueError("Invalid matrix: must have at least one feature")
 
         if not np.all(np.isfinite(matrix)):
-            raise ValueError("matrix must contain only finite values (no NaN or inf)")
+            raise ValueError(
+                "Invalid matrix: must contain only finite values (no NaN or inf)"
+            )
 
         if weights is not None:
             if weights.size != matrix.shape[1]:
                 raise ValueError(
-                    f"weights size {weights.size} must match number of features {matrix.shape[1]}"
+                    f"Invalid weights: size {weights.size} must match number of features {matrix.shape[1]}"
                 )
             if not np.all(np.isfinite(weights)) or np.any(weights < 0):
-                raise ValueError("weights must be finite and non-negative")
-            if metric in {
-                QuickAdapterRegressorV3._SCIPY_METRICS[4],  # "mahalanobis"
-                QuickAdapterRegressorV3._SCIPY_METRICS[6],  # "seuclidean"
-                QuickAdapterRegressorV3._SCIPY_METRICS[3],  # "jensenshannon"
-            }:
-                raise ValueError(f"weights not supported for metric '{metric}'")
+                raise ValueError("Invalid weights: must be finite and non-negative")
+            if metric in QuickAdapterRegressorV3._unsupported_cluster_metrics_set():
+                raise ValueError(
+                    f"Invalid weights: not supported for metric '{metric}'"
+                )
 
         matrix = np.asarray(matrix, dtype=np.float64)
         if weights is not None:
@@ -1601,17 +1624,17 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         directions: list[optuna.study.StudyDirection],
     ) -> NDArray[np.floating]:
         if objective_values_matrix.ndim != 2:
-            raise ValueError("objective_values_matrix must be 2-dimensional")
+            raise ValueError("Invalid objective_values_matrix: must be 2-dimensional")
 
         n_samples, n_objectives = objective_values_matrix.shape
         if n_samples == 0 or n_objectives == 0:
             raise ValueError(
-                "objective_values_matrix must have at least one sample and one objective"
+                "Invalid objective_values_matrix: must have at least one sample and one objective"
             )
 
         if len(directions) != n_objectives:
             raise ValueError(
-                f"Number of directions ({len(directions)}) must match number of objectives ({n_objectives})"
+                f"Invalid directions: length ({len(directions)}) must match number of objectives ({n_objectives})"
             )
 
         normalized_matrix = np.zeros_like(objective_values_matrix, dtype=float)
@@ -1699,16 +1722,16 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         metrics: set[str],
     ) -> NDArray[np.floating]:
         if normalized_matrix.ndim != 2:
-            raise ValueError("normalized_matrix must be 2-dimensional")
+            raise ValueError("Invalid normalized_matrix: must be 2-dimensional")
         n_objectives = normalized_matrix.shape[1]
         n_samples = normalized_matrix.shape[0]
         if n_samples == 0 or n_objectives == 0:
             raise ValueError(
-                "normalized_matrix must have at least one sample and one objective"
+                "Invalid normalized_matrix: must have at least one sample and one objective"
             )
         if not np.all(np.isfinite(normalized_matrix)):
             raise ValueError(
-                "normalized_matrix must contain only finite values (no NaN or inf)"
+                "Invalid normalized_matrix: must contain only finite values (no NaN or inf)"
             )
         label_p_order = self.ft_params.get("label_p_order")
         label_weights = self.ft_params.get("label_weights")
@@ -1718,17 +1741,19 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             np_weights = np.array(label_weights, dtype=float)
         else:
             raise ValueError(
-                f"label_weights must be a list, tuple, or array, got {type(label_weights).__name__}"
+                f"Invalid label_weights: must be a list, tuple, or array, got {type(label_weights).__name__}"
             )
         if np_weights.size != n_objectives:
-            raise ValueError("label_weights length must match number of objectives")
+            raise ValueError(
+                "Invalid label_weights: length must match number of objectives"
+            )
         if not np.all(np.isfinite(np_weights)):
-            raise ValueError("label_weights must contain only finite values")
+            raise ValueError("Invalid label_weights: must contain only finite values")
         if np.any(np_weights < 0):
-            raise ValueError("label_weights values must be non-negative")
+            raise ValueError("Invalid label_weights: values must be non-negative")
         label_weights_sum = np.nansum(np.abs(np_weights))
         if np.isclose(label_weights_sum, 0.0):
-            raise ValueError("label_weights sum cannot be zero")
+            raise ValueError("Invalid label_weights: sum cannot be zero")
         np_weights = np_weights / label_weights_sum
 
         ideal_point = np.ones(n_objectives)
@@ -1751,11 +1776,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
 
         if metric in QuickAdapterRegressorV3._scipy_metrics_set():
             cdist_kwargs: dict[str, Any] = {}
-            if metric not in {
-                QuickAdapterRegressorV3._SCIPY_METRICS[4],  # "mahalanobis"
-                QuickAdapterRegressorV3._SCIPY_METRICS[6],  # "seuclidean"
-                QuickAdapterRegressorV3._SCIPY_METRICS[3],  # "jensenshannon"
-            }:
+            if metric not in QuickAdapterRegressorV3._unsupported_cluster_metrics_set():
                 cdist_kwargs["w"] = np_weights
             if metric == QuickAdapterRegressorV3._SCIPY_METRICS[5]:  # "minkowski"
                 cdist_kwargs["p"] = (
@@ -1778,7 +1799,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 variances = np.nanvar(np_sqrt_normalized_matrix, axis=0, ddof=1)
                 if np.any(variances <= 0):
                     raise ValueError(
-                        "shellinger metric requires non-zero variance for all objectives"
+                        "Invalid data for shellinger metric: requires non-zero variance for all objectives"
                     )
                 np_weights = 1 / variances
             return (
@@ -1821,13 +1842,13 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 "label_medoid_metric",
                 QuickAdapterRegressorV3._SCIPY_METRICS[2],  # "euclidean"
             )
-            if label_medoid_metric in {
-                QuickAdapterRegressorV3._SCIPY_METRICS[4],  # "mahalanobis"
-                QuickAdapterRegressorV3._SCIPY_METRICS[6],  # "seuclidean"
-                QuickAdapterRegressorV3._SCIPY_METRICS[3],  # "jensenshannon"
-            }:
+            if (
+                label_medoid_metric
+                in QuickAdapterRegressorV3._unsupported_cluster_metrics_set()
+            ):
                 raise ValueError(
-                    f"Unsupported label_medoid_metric: {label_medoid_metric}. Supported are euclidean/minkowski/cityblock/chebyshev/..."
+                    f"Invalid label_medoid_metric '{label_medoid_metric}'. "
+                    f"Unsupported: {', '.join(QuickAdapterRegressorV3._UNSUPPORTED_CLUSTER_METRICS)}"
                 )
             p = None
             if (
@@ -1864,13 +1885,13 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 "label_kmeans_metric",
                 QuickAdapterRegressorV3._SCIPY_METRICS[2],  # "euclidean"
             )
-            if label_kmeans_metric in {
-                QuickAdapterRegressorV3._SCIPY_METRICS[4],  # "mahalanobis"
-                QuickAdapterRegressorV3._SCIPY_METRICS[6],  # "seuclidean"
-                QuickAdapterRegressorV3._SCIPY_METRICS[3],  # "jensenshannon"
-            }:
+            if (
+                label_kmeans_metric
+                in QuickAdapterRegressorV3._unsupported_cluster_metrics_set()
+            ):
                 raise ValueError(
-                    f"Unsupported label_kmeans_metric: {label_kmeans_metric}. Supported are euclidean/minkowski/cityblock/chebyshev/..."
+                    f"Invalid label_kmeans_metric '{label_kmeans_metric}'. "
+                    f"Unsupported: {', '.join(QuickAdapterRegressorV3._UNSUPPORTED_CLUSTER_METRICS)}"
                 )
             cdist_kwargs: dict[str, Any] = {}
             if (
@@ -1888,7 +1909,10 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 metric=label_kmeans_metric,
                 **cdist_kwargs,
             ).flatten()
-            label_kmeans_selection = self.ft_params.get("label_kmeans_selection", "min")
+            label_kmeans_selection = self.ft_params.get(
+                "label_kmeans_selection",
+                QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[1],  # "min"
+            )
             ordered_cluster_indices = np.argsort(cluster_center_distances_to_ideal)
             best_cluster_indices = None
             for cluster_index in ordered_cluster_indices:
@@ -1900,7 +1924,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             if best_cluster_indices is not None and best_cluster_indices.size > 0:
                 if (
                     label_kmeans_selection
-                    == QuickAdapterRegressorV3._CUSTOM_METRICS[16]  # "medoid"
+                    == QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[0]  # "medoid"
                 ):
                     p = None
                     if (
@@ -1927,7 +1951,10 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                         **cdist_kwargs,
                     ).item()
                     trial_distances[best_trial_index] = best_trial_distance
-                elif label_kmeans_selection == "min":
+                elif (
+                    label_kmeans_selection
+                    == QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[1]  # "min"
+                ):
                     best_cluster_distances = sp.spatial.distance.cdist(
                         normalized_matrix[best_cluster_indices],
                         ideal_point_2d,
@@ -1941,7 +1968,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                     ]
                 else:
                     raise ValueError(
-                        f"Unsupported label_kmeans_selection: {label_kmeans_selection}. Supported are medoid/min"
+                        f"Invalid label_kmeans_selection '{label_kmeans_selection}'. "
+                        f"Supported: {', '.join(QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS)}"
                     )
             return trial_distances
         elif metric == QuickAdapterRegressorV3._CUSTOM_METRICS[11]:  # "kmedoids"
@@ -1950,13 +1978,13 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 "label_kmedoids_metric",
                 QuickAdapterRegressorV3._SCIPY_METRICS[2],  # "euclidean"
             )
-            if label_kmedoids_metric in {
-                QuickAdapterRegressorV3._SCIPY_METRICS[4],  # "mahalanobis"
-                QuickAdapterRegressorV3._SCIPY_METRICS[6],  # "seuclidean"
-                QuickAdapterRegressorV3._SCIPY_METRICS[3],  # "jensenshannon"
-            }:
+            if (
+                label_kmedoids_metric
+                in QuickAdapterRegressorV3._unsupported_cluster_metrics_set()
+            ):
                 raise ValueError(
-                    f"Unsupported label_kmedoids_metric: {label_kmedoids_metric}. Supported are euclidean/minkowski/cityblock/chebyshev/..."
+                    f"Invalid label_kmedoids_metric '{label_kmedoids_metric}'. "
+                    f"Unsupported: {', '.join(QuickAdapterRegressorV3._UNSUPPORTED_CLUSTER_METRICS)}"
                 )
             kmedoids_kwargs: dict[str, Any] = {
                 "metric": label_kmedoids_metric,
@@ -1984,7 +2012,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 **cdist_kwargs,
             ).flatten()
             label_kmedoids_selection = self.ft_params.get(
-                "label_kmedoids_selection", "min"
+                "label_kmedoids_selection",
+                QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[1],  # "min"
             )
             best_medoid_distance_position = np.nanargmin(medoid_distances_to_ideal)
             best_medoid_index = medoid_indices[best_medoid_distance_position]
@@ -1994,12 +2023,15 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             if best_cluster_indices.size > 0:
                 if (
                     label_kmedoids_selection
-                    == QuickAdapterRegressorV3._CUSTOM_METRICS[16]  # "medoid"
+                    == QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[0]  # "medoid"
                 ):
                     trial_distances[best_medoid_index] = medoid_distances_to_ideal[
                         best_medoid_distance_position
                     ]
-                elif label_kmedoids_selection == "min":
+                elif (
+                    label_kmedoids_selection
+                    == QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS[1]  # "min"
+                ):
                     if best_cluster_indices.size == 1:
                         best_trial_index = best_cluster_indices[0]
                         trial_distances[best_trial_index] = medoid_distances_to_ideal[
@@ -2019,7 +2051,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                         ]
                 else:
                     raise ValueError(
-                        f"Unsupported label_kmedoids_selection: {label_kmedoids_selection}. Supported are medoid/min"
+                        f"Invalid label_kmedoids_selection '{label_kmedoids_selection}'. "
+                        f"Supported: {', '.join(QuickAdapterRegressorV3._CLUSTER_SELECTION_METHODS)}"
                     )
             return trial_distances
         elif metric in {
@@ -2087,7 +2120,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 return np.nanmax(neighbor_distances, axis=1)
         else:
             raise ValueError(
-                f"Unsupported label metric: {metric}. Supported metrics are {', '.join(metrics)}"
+                f"Invalid label metric '{metric}'. Supported: {', '.join(metrics)}"
             )
 
     def _get_multi_objective_study_best_trial(
@@ -2097,8 +2130,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]
         }:  # Only "label"
             raise ValueError(
-                f"Invalid namespace: {namespace}. "
-                f"Expected {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
+                f"Invalid namespace '{namespace}'. "
+                f"Supported: {QuickAdapterRegressorV3._OPTUNA_NAMESPACES[2]}"  # Only label
             )
         n_objectives = len(study.directions)
         if n_objectives < 2:
@@ -2114,7 +2147,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         )  # "euclidean"
         if label_metric not in metrics:
             raise ValueError(
-                f"Unsupported label metric: {label_metric}. Supported metrics are {', '.join(metrics)}"
+                f"Invalid label_metric '{label_metric}'. Supported: {', '.join(metrics)}"
             )
 
         best_trials = [
@@ -2284,8 +2317,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             )
         else:
             raise ValueError(
-                f"Unsupported optuna storage backend: {storage_backend}. "
-                f"Supported backends are {', '.join(QuickAdapterRegressorV3._OPTUNA_STORAGE_BACKENDS)}"
+                f"Invalid optuna storage_backend '{storage_backend}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._OPTUNA_STORAGE_BACKENDS)}"
             )
         return storage
 
@@ -2316,8 +2349,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             )
         else:
             raise ValueError(
-                f"Unsupported sampler: {sampler}. "
-                f"Supported samplers are {', '.join(QuickAdapterRegressorV3._OPTUNA_SAMPLERS)}"
+                f"Invalid optuna sampler '{sampler}'. "
+                f"Supported: {', '.join(QuickAdapterRegressorV3._OPTUNA_SAMPLERS)}"
             )
 
     def optuna_create_study(
index b086144d83af813803cf163eb1a69070fe9fc15d..68c73c47939ebeef4cfb1c116e093b82a6fd7266 100644 (file)
@@ -727,7 +727,7 @@ class QuickAdapterV3(IStrategy):
     def get_label_natr_ratio_percent(self, pair: str, percent: float) -> float:
         if not isinstance(percent, float) or not (0.0 <= percent <= 1.0):
             raise ValueError(
-                f"Invalid percent value: {percent}. It should be a float between 0 and 1"
+                f"Invalid percent {percent}: must be a float between 0 and 1"
             )
         return self.get_label_natr_ratio(pair) * percent
 
@@ -1416,7 +1416,8 @@ class QuickAdapterV3(IStrategy):
         trade_price_target_fn = trade_price_target_methods.get(trade_price_target)
         if trade_price_target_fn is None:
             raise ValueError(
-                f"Invalid trade_price_target: {trade_price_target}. Available: {', '.join(sorted(TRADE_PRICE_TARGETS))}"
+                f"Invalid trade_price_target '{trade_price_target}'. "
+                f"Supported: {', '.join(sorted(TRADE_PRICE_TARGETS))}"
             )
         return trade_price_target_fn()
 
@@ -1445,7 +1446,7 @@ class QuickAdapterV3(IStrategy):
     ) -> Optional[float]:
         if not (0.0 <= natr_ratio_percent <= 1.0):
             raise ValueError(
-                f"natr_ratio_percent must be in [0, 1], got {natr_ratio_percent}"
+                f"Invalid natr_ratio_percent {natr_ratio_percent}: must be in [0, 1]"
             )
         trade_duration_candles = self.get_trade_duration_candles(df, trade)
         if not QuickAdapterV3.is_trade_duration_valid(trade_duration_candles):
@@ -1472,7 +1473,7 @@ class QuickAdapterV3(IStrategy):
     ) -> Optional[float]:
         if not (0.0 <= natr_ratio_percent <= 1.0):
             raise ValueError(
-                f"natr_ratio_percent must be in [0, 1], got {natr_ratio_percent}"
+                f"Invalid natr_ratio_percent {natr_ratio_percent}: must be in [0, 1]"
             )
         trade_duration_candles = self.get_trade_duration_candles(df, trade)
         if not QuickAdapterV3.is_trade_duration_valid(trade_duration_candles):
@@ -1494,7 +1495,7 @@ class QuickAdapterV3(IStrategy):
         callback: Callable[[], None],
     ) -> None:
         if not callable(callback):
-            raise ValueError("callback must be callable")
+            raise ValueError("Invalid callback: must be callable")
         timestamp = int(current_time.timestamp())
         candle_duration_secs = max(1, int(self._candle_duration_secs))
         candle_start_secs = (timestamp // candle_duration_secs) * candle_duration_secs
@@ -1818,8 +1819,8 @@ class QuickAdapterV3(IStrategy):
             )
         else:
             raise ValueError(
-                f"Invalid interpolation_direction: {interpolation_direction}. "
-                f"Expected {', '.join(QuickAdapterV3._INTERPOLATION_DIRECTIONS)}"
+                f"Invalid interpolation_direction '{interpolation_direction}'. "
+                f"Supported: {', '.join(QuickAdapterV3._INTERPOLATION_DIRECTIONS)}"
             )
         candle_deviation = (
             candle_label_natr_value / 100.0
@@ -1892,7 +1893,7 @@ class QuickAdapterV3(IStrategy):
             candle_threshold = base_price * (1 - current_deviation)
         else:
             raise ValueError(
-                f"Invalid side: {side}. Expected {', '.join(QuickAdapterV3._TRADE_DIRECTIONS)}"
+                f"Invalid side '{side}'. Supported: {', '.join(QuickAdapterV3._TRADE_DIRECTIONS)}"
             )
         self._candle_threshold_cache[cache_key] = candle_threshold
         return self._candle_threshold_cache[cache_key]
@@ -2475,8 +2476,8 @@ class QuickAdapterV3(IStrategy):
             return False
         else:
             raise ValueError(
-                f"Invalid trading_mode: {trading_mode}. "
-                f"Expected {', '.join(QuickAdapterV3._TRADING_MODES)}"
+                f"Invalid trading_mode '{trading_mode}'. "
+                f"Supported: {', '.join(QuickAdapterV3._TRADING_MODES)}"
             )
 
     def leverage(
index 8e194f9fad58a836aa56402d30308a167551349a..b6b6f7bd6f44ef0b349e0727e0f93ffd186a79be 100644 (file)
@@ -199,7 +199,7 @@ def non_zero_diff(s1: pd.Series, s2: pd.Series) -> pd.Series:
 @lru_cache(maxsize=8)
 def get_odd_window(window: int) -> int:
     if window < 1:
-        raise ValueError("Window size must be greater than 0")
+        raise ValueError(f"Invalid window {window}: must be > 0")
     return window if window % 2 == 1 else window + 1
 
 
@@ -232,7 +232,10 @@ def _calculate_coeffs(
     elif win_type == SMOOTHING_METHODS[2]:  # "triang"
         coeffs = sp.signal.windows.triang(M=window, sym=True)
     else:
-        raise ValueError(f"Unknown window type: {win_type}")
+        raise ValueError(
+            f"Invalid window type '{win_type}'. "
+            f"Supported: {', '.join(SMOOTHING_METHODS[:3])}"
+        )
     return coeffs / np.sum(coeffs)
 
 
@@ -439,7 +442,10 @@ def standardize_weights(
         return _standardize_mmad(weights, scaling_factor=mmad_scaling_factor)
 
     else:
-        raise ValueError(f"Unknown standardization method: {method}")
+        raise ValueError(
+            f"Invalid standardization method '{method}'. "
+            f"Supported: {', '.join(STANDARDIZATION_TYPES)}"
+        )
 
 
 def _normalize_sigmoid(
@@ -625,7 +631,10 @@ def normalize_weights(
     elif normalization == NORMALIZATION_TYPES[5]:  # "rank"
         normalized_weights = _normalize_rank(standardized_weights, method=rank_method)
     else:
-        raise ValueError(f"Unknown normalization method: {normalization}")
+        raise ValueError(
+            f"Invalid normalization method '{normalization}'. "
+            f"Supported: {', '.join(NORMALIZATION_TYPES)}"
+        )
 
     # Phase 3: Post-processing
     if not np.isclose(gamma, 1.0) and np.isfinite(gamma) and gamma > 0:
@@ -778,7 +787,10 @@ def calculate_hybrid_extrema_weights(
             weights=source_weights_array[:, np.newaxis],
         )
     else:
-        raise ValueError(f"Unknown hybrid aggregation method: {aggregation}")
+        raise ValueError(
+            f"Invalid hybrid aggregation method '{aggregation}'. "
+            f"Supported: {', '.join(HYBRID_AGGREGATIONS)}"
+        )
 
     if aggregation_normalization != NORMALIZATION_TYPES[6]:  # "none"
         combined_source_weights_array = normalize_weights(
@@ -956,7 +968,10 @@ def compute_extrema_weights(
             default_weight=np.nanmedian(normalized_weights),
         )
 
-    raise ValueError(f"Unknown extrema weighting strategy: {strategy}")
+    raise ValueError(
+        f"Invalid extrema weighting strategy '{strategy}'. "
+        f"Supported: {', '.join(WEIGHT_STRATEGIES)}"
+    )
 
 
 def _apply_weights(
@@ -1044,7 +1059,7 @@ def get_weighted_extrema(
 
 def get_callable_sha256(fn: Callable[..., Any]) -> str:
     if not callable(fn):
-        raise ValueError("fn must be callable")
+        raise ValueError("Invalid fn: must be callable")
     code = getattr(fn, "__code__", None)
     if code is None and isinstance(fn, functools.partial):
         fn = fn.func
@@ -1056,7 +1071,7 @@ def get_callable_sha256(fn: Callable[..., Any]) -> str:
     if code is None and hasattr(fn, "__call__"):
         code = getattr(fn.__call__, "__code__", None)
     if code is None:
-        raise ValueError("Unable to retrieve code object from fn")
+        raise ValueError("Invalid fn: unable to retrieve code object")
     return hashlib.sha256(code.co_code).hexdigest()
 
 
@@ -1115,7 +1130,7 @@ def top_change_percent(dataframe: pd.DataFrame, period: int) -> pd.Series:
     :return: The top change percentage series
     """
     if period < 1:
-        raise ValueError("period must be greater than or equal to 1")
+        raise ValueError(f"Invalid period {period}: must be >= 1")
 
     previous_close_top = (
         dataframe.get("close").rolling(period, min_periods=period).max().shift(1)
@@ -1133,7 +1148,7 @@ def bottom_change_percent(dataframe: pd.DataFrame, period: int) -> pd.Series:
     :return: The bottom change percentage series
     """
     if period < 1:
-        raise ValueError("period must be greater than or equal to 1")
+        raise ValueError(f"Invalid period {period}: must be >= 1")
 
     previous_close_bottom = (
         dataframe.get("close").rolling(period, min_periods=period).min().shift(1)
@@ -1152,7 +1167,7 @@ def price_retracement_percent(dataframe: pd.DataFrame, period: int) -> pd.Series
     :return: Retracement percentage series
     """
     if period < 1:
-        raise ValueError("period must be greater than or equal to 1")
+        raise ValueError(f"Invalid period {period}: must be >= 1")
 
     previous_close_low = (
         dataframe.get("close").rolling(period, min_periods=period).min().shift(1)
@@ -1234,7 +1249,7 @@ def _fractal_dimension(
 ) -> float:
     """Original fractal dimension computation implementation per Ehlers' paper."""
     if period % 2 != 0:
-        raise ValueError("period must be even")
+        raise ValueError(f"Invalid period {period}: must be even")
 
     half_period = period // 2
 
@@ -1263,7 +1278,7 @@ def frama(df: pd.DataFrame, period: int = 16, zero_lag: bool = False) -> pd.Seri
     Original FRAMA implementation per Ehlers' paper with optional zero lag.
     """
     if period % 2 != 0:
-        raise ValueError("period must be even")
+        raise ValueError(f"Invalid period {period}: must be even")
 
     n = len(df)
 
@@ -1305,7 +1320,7 @@ def smma(series: pd.Series, period: int, zero_lag=False, offset=0) -> pd.Series:
     https://www.sierrachart.com/index.php?page=doc/StudiesReference.php&ID=173&Name=Moving_Average_-_Smoothed
     """
     if period <= 0:
-        raise ValueError("period must be greater than 0")
+        raise ValueError(f"Invalid period {period}: must be > 0")
     n = len(series)
     if n < period:
         return pd.Series(index=series.index, dtype=float)
@@ -1945,7 +1960,7 @@ def get_optuna_callbacks(
         ]
     else:
         raise ValueError(
-            f"Unsupported regressor model: {regressor} (supported: {', '.join(REGRESSORS)})"
+            f"Invalid regressor '{regressor}'. Supported: {', '.join(REGRESSORS)}"
         )
     return callbacks
 
@@ -2016,7 +2031,7 @@ def fit_regressor(
         )
     else:
         raise ValueError(
-            f"Unsupported regressor model: {regressor} (supported: {', '.join(REGRESSORS)})"
+            f"Invalid regressor '{regressor}'. Supported: {', '.join(REGRESSORS)}"
         )
     return model
 
@@ -2030,13 +2045,13 @@ def get_optuna_study_model_parameters(
 ) -> dict[str, Any]:
     if regressor not in set(REGRESSORS):
         raise ValueError(
-            f"Unsupported regressor model: {regressor} (supported: {', '.join(REGRESSORS)})"
+            f"Invalid regressor '{regressor}'. Supported: {', '.join(REGRESSORS)}"
         )
     if not isinstance(expansion_ratio, (int, float)) or not (
         0.0 <= expansion_ratio <= 1.0
     ):
         raise ValueError(
-            f"expansion_ratio must be a float between 0 and 1, got {expansion_ratio}"
+            f"Invalid expansion_ratio {expansion_ratio}: must be a float between 0 and 1"
         )
     default_ranges: dict[str, tuple[float, float]] = {
         "n_estimators": (100, 2000),
@@ -2162,9 +2177,9 @@ def get_optuna_study_model_parameters(
 @lru_cache(maxsize=128)
 def largest_divisor_to_step(integer: int, step: int) -> Optional[int]:
     if not isinstance(integer, int) or integer <= 0:
-        raise ValueError("integer must be a positive integer")
+        raise ValueError(f"Invalid integer {integer!r}: must be a positive integer")
     if not isinstance(step, int) or step <= 0:
-        raise ValueError("step must be a positive integer")
+        raise ValueError(f"Invalid step {step!r}: must be a positive integer")
 
     if step == 1 or integer % step == 0:
         return integer
@@ -2216,7 +2231,8 @@ def get_min_max_label_period_candles(
 ) -> tuple[int, int, int]:
     if min_label_period_candles > max_label_period_candles:
         raise ValueError(
-            "min_label_period_candles must be less than or equal to max_label_period_candles"
+            f"Invalid label_period_candles range: min ({min_label_period_candles}) "
+            f"must be <= max ({max_label_period_candles})"
         )
 
     capped_period_candles = max(1, floor_to_step(max_period_candles, candles_step))
@@ -2268,9 +2284,9 @@ def round_to_step(value: float | int, step: int) -> int:
     :raises ValueError: If step is not a positive integer or value is not finite.
     """
     if not isinstance(value, (int, float)):
-        raise ValueError("value must be an integer or float")
+        raise ValueError(f"Invalid value {value!r}: must be an integer or float")
     if not isinstance(step, int) or step <= 0:
-        raise ValueError("step must be a positive integer")
+        raise ValueError(f"Invalid step {step!r}: must be a positive integer")
     if isinstance(value, (int, np.integer)):
         q, r = divmod(value, step)
         twice_r = r * 2
@@ -2280,33 +2296,33 @@ def round_to_step(value: float | int, step: int) -> int:
             return (q + 1) * step
         return int(round(value / step) * step)
     if not np.isfinite(value):
-        raise ValueError("value must be finite")
+        raise ValueError(f"Invalid value {value!r}: must be finite")
     return int(round(float(value) / step) * step)
 
 
 @lru_cache(maxsize=128)
 def ceil_to_step(value: float | int, step: int) -> int:
     if not isinstance(value, (int, float)):
-        raise ValueError("value must be an integer or float")
+        raise ValueError(f"Invalid value {value!r}: must be an integer or float")
     if not isinstance(step, int) or step <= 0:
-        raise ValueError("step must be a positive integer")
+        raise ValueError(f"Invalid step {step!r}: must be a positive integer")
     if isinstance(value, (int, np.integer)):
         return int(-(-int(value) // step) * step)
     if not np.isfinite(value):
-        raise ValueError("value must be finite")
+        raise ValueError(f"Invalid value {value!r}: must be finite")
     return int(math.ceil(float(value) / step) * step)
 
 
 @lru_cache(maxsize=128)
 def floor_to_step(value: float | int, step: int) -> int:
     if not isinstance(value, (int, float)):
-        raise ValueError("value must be an integer or float")
+        raise ValueError(f"Invalid value {value!r}: must be an integer or float")
     if not isinstance(step, int) or step <= 0:
-        raise ValueError("step must be a positive integer")
+        raise ValueError(f"Invalid step {step!r}: must be a positive integer")
     if isinstance(value, (int, np.integer)):
         return int((int(value) // step) * step)
     if not np.isfinite(value):
-        raise ValueError("value must be finite")
+        raise ValueError(f"Invalid value {value!r}: must be finite")
     return int(math.floor(float(value) / step) * step)