]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(quickadapter): isolate holdout from continual state (#156)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 29 Jul 2026 20:16:03 +0000 (22:16 +0200)
committerGitHub <noreply@github.com>
Wed, 29 Jul 2026 20:16:03 +0000 (22:16 +0200)
Cold-start HPO trials and the pre-refit selection model whenever `test_size`
enables two-stage selection, so `holdout_rmse` is an independent diagnostic;
only the final XGBoost/LightGBM refit continues from the FreqAI-supplied
previously deployed model. Other regressors ignore any prior model state.

Give the changed HPO objective a stable semantic identity
(`candidate-cold-start-v1`) stored as the Optuna study `user_attr`
`objective_identity` and in the persisted best-params envelope, so legacy
warm-state studies/params are ineligible (reset/rejected once on upgrade).
Unify the hp and label study-lifecycle versioning behind one
`_OptunaStudyMarker` dispatch (stable study names; hp always resets on
mismatch, no new tunable; label keeps `reset_label_study_on_schema_mismatch`).

Make the canonical `continual_learning=false` explicit; document the
`continual_learning` and `test_size` rows (`0` = single-stage, train_test_split
only) and harmonize strategy comment code refs to RST double backticks.

Fixes #132

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 539b42d156f2435b9fa10e3dc4479b027383ffc3..cd2163fa39b83c19547f6f2cabe0ee9fc14c11f6 100644 (file)
--- a/README.md
+++ b/README.md
@@ -58,11 +58,12 @@ docker compose up -d --build
 | reversal_confirmation.max_natr_multiplier_fraction             | 0.0125                        | float [0,1]                                                                                                                                                                                                  | Upper bound fraction (> lower bound) for volatility adjusted reversal threshold.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
 | _Regressor model_                                              |                               |                                                                                                                                                                                                              |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | freqai.regressor                                               | `xgboost`                     | enum {`xgboost`,`lightgbm`,`histgradientboostingregressor`,`ngboost`,`catboost`}                                                                                                                             | Machine learning regressor algorithm.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
+| freqai.continual_learning                                      | false                         | bool                                                                                                                                                                                                         | Continue XGBoost or LightGBM training from the previously deployed model, so its booster grows at every retrain; delete trained models to reset. With `test_size` two-stage selection, HPO and the pre-refit selection model always cold-start (including when causal purging leaves no scorable holdout rows) and only the final refit continues, growing by the selection model's round count. Other regressors ignore any prior model.                                                                                                                                                                                                                                                 |
 | _Model training parameters_                                    |                               |                                                                                                                                                                                                              |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | freqai.model_training_parameters.gpu_vram_gb                   | 80                            | enum {8,10,12,16,24,32,40,48,64,80}                                                                                                                                                                          | Available GPU VRAM (GB) for CatBoost, not total. Constrains `depth`, `border_count`, and `max_ctr_complexity` ranges.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
 | _Data split parameters_                                        |                               |                                                                                                                                                                                                              |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | freqai.data_split_parameters.method                            | `train_test_split`            | enum {`train_test_split`,`timeseries_split`}                                                                                                                                                                 | Data splitting strategy. `train_test_split` for sequential split, `timeseries_split` for chronological split with configurable gap.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
-| freqai.data_split_parameters.test_size                         | 0.1                           | float (0,1) \| int >= 1 \| None                                                                                                                                                                              | Outer holdout size. The same parameter reserves the chronological tail of the remaining training rows as inner validation for HPO and early stopping; a fractional value is relative to those remaining rows, not the original window. The holdout is predicted once and reported as weighted `holdout_rmse` in the original label scale; it therefore measures the pre-refit selection model, not the refitted deployed model. `None` (sklearn dynamic sizing) applies only to `timeseries_split`; `train_test_split` requires a float or int; inner validation then falls back to `0.1`.                                                                                                |
+| freqai.data_split_parameters.test_size                         | 0.1                           | float [0,1) \| int >= 0 \| None                                                                                                                                                                              | Outer holdout size; `0` disables the holdout (single-stage fit, `train_test_split` only). The same parameter reserves the chronological tail of the remaining training rows as inner validation for HPO and early stopping; a fractional value is relative to those remaining rows, not the original window. The holdout is predicted once and reported as weighted `holdout_rmse` in the original label scale; it measures the cold-started pre-refit selection model, not the refitted deployed model. `None` (sklearn dynamic sizing) applies only to `timeseries_split`; `train_test_split` requires a float or int; inner validation then falls back to `0.1`.                       |
 | freqai.data_split_parameters.n_splits                          | 5                             | int >= 2                                                                                                                                                                                                     | Controls train/test proportions for `timeseries_split` (higher = larger train set).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
 | freqai.data_split_parameters.gap                               | 0                             | int >= 0                                                                                                                                                                                                     | Samples to exclude between train/test for `timeseries_split`. When `0` and `causal_mode=true` (default), auto-set from `label_horizon_candles`; when `0` and `causal_mode=false`, auto-set from `label_period_candles`. Under `causal_mode=true`, an explicit `gap<label_horizon_candles` is rejected.                                                                                                                                                                                                                                                                                                                                                                                    |
 | freqai.data_split_parameters.max_train_size                    | None                          | int >= 1 \| None                                                                                                                                                                                             | Maximum training set size for `timeseries_split`. When set, creates a sliding window instead of expanding train set. None = no limit.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
index 0fcbc2b7e96a62ca15c2a98d99655248820748b2..6d211f34a888c8ec1c464b4b6149b5d3721d06d7 100644 (file)
@@ -88,6 +88,7 @@
     "conv_width": 1,
     "purge_old_models": 2,
     "expiration_hours": 24,
+    "continual_learning": false,
     "train_period_days": 21,
     // "live_retrain_hours": 1,
     "backtest_period_days": 2,
index 99d6cf378bc4c5c52d75ddd45e10c5e035fc0f98..513e8b74619c1ad5e0067716c7a6f452c6f09fe2 100644 (file)
@@ -201,6 +201,13 @@ class _OptunaLabelSamplers(NamedTuple):
     nsgaiii: Literal["nsgaiii"] = "nsgaiii"
 
 
+class _OptunaStudyMarker(NamedTuple):
+    user_attr_key: str
+    build_marker: Callable[[], Any]
+    is_compatible: Callable[[Any], bool]
+    reset_on_mismatch: bool
+
+
 class QuickAdapterRegressorV3(BaseRegressionModel):
     """
     The following freqaimodel is released to sponsors of the non-profit FreqAI open-source project.
@@ -233,6 +240,11 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
     _SQRT_2: Final[float] = np.sqrt(2.0)
 
     _OPTUNA_LABEL_N_OBJECTIVES: Final[int] = 7
+    # Bump this identity (e.g. ``-v2``) on any semantic change to the ``hp``
+    # objective (search space, scoring, or fit protocol): it gates warm-state
+    # study reuse and persisted best-params loading; a stale value silently
+    # reuses incompatible trials.
+    _OPTUNA_HP_OBJECTIVE_IDENTITY: Final[str] = "candidate-cold-start-v1"
     _OPTUNA_LABEL_DIRECTIONS: Final[tuple[optuna.study.StudyDirection, ...]] = (
         optuna.study.StudyDirection.MAXIMIZE,
     ) * _OPTUNA_LABEL_N_OBJECTIVES
@@ -378,9 +390,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         _SCIPY_METRICS_SET - _PROBABILITY_DISTANCE_METRICS_SET
     )
 
-    # Absolute tolerance (rtol=0) for constant-column detection in
-    # `_non_constant_objective_indices`; valid on the [0,1]-normalized
-    # output of `_normalize_objective_values`.
+    # Absolute tolerance (``rtol=0``) for constant-column detection in
+    # ``_non_constant_objective_indices``; valid on the [0,1]-normalized
+    # output of ``_normalize_objective_values``.
     _NON_CONSTANT_OBJECTIVE_ATOL: Final[float] = 1e-8
 
     _DENSITY_AGGREGATIONS: Final[tuple[DensityAggregation, ...]] = (
@@ -714,7 +726,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         )
         if label_weights is None:
             # Non-"none" label-weighting strategy with no available label
-            # weights (zigzag produced zero pivots): the support policy
+            # weights (``zigzag`` produced zero pivots): the support policy
             # governs the contract -- ``raise`` raises, ``fallback``
             # warns. A direct return to base weights would bypass the
             # policy silently.
@@ -746,9 +758,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             )
 
         # Support is gated twice on purpose: here on the full split (fail-fast,
-        # esp. under support_policy='raise') and again post-pipeline in
-        # _fit_training_pipelines on the rows fed to model.fit. Outlier removal
-        # is non-monotone on support (pivot_equivalent_count/ESS use a
+        # esp. under ``support_policy='raise'``) and again post-pipeline in
+        # ``_fit_training_pipelines`` on the rows fed to ``model.fit``. Outlier removal
+        # is non-monotone on support (``pivot_equivalent_count``/ESS use a
         # max-relative threshold), so this pre-gate is not a conservative bound:
         # keeping it changes some raise/fallback outcomes, and under 'raise' it
         # can abort a split the post-pipeline gate would have passed.
@@ -1077,8 +1089,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             )
         else:
             # Cluster/density paths route the metric to SciPy/sklearn APIs
-            # (pairwise_distances, KMeans, KMedoids, NearestNeighbors) which
-            # reject aggregate metrics computed by reduction; restrict the
+            # (``pairwise_distances``, ``KMeans``, ``KMedoids``, ``NearestNeighbors``)
+            # which reject aggregate metrics computed by reduction; restrict the
             # valid set to SciPy-compatible non-probability metrics.
             valid_metrics = (
                 QuickAdapterRegressorV3._CLUSTER_DENSITY_DISTANCE_METRICS_SET
@@ -1509,7 +1521,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         default_label_period_candles, default_label_natr_multiplier = (
             self._label_defaults
         )
-        # self.live is unset until IFreqaiModel.start(), so derive trade-mode
+        # ``self.live`` is unset until ``IFreqaiModel.start()``, so derive trade-mode
         # from the configured runmode here.
         trade_mode = self.config.get("runmode") in TRADE_MODES
         for pair in self.pairs:
@@ -2422,8 +2434,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             )
 
         # Recompose weights on the ACTUAL inner-train/validation rows instead of
-        # slicing the outer-train composition: a slice bypasses support_policy and
-        # lets sanitize_and_renormalize silently uniformize a pivot-sparse inner
+        # slicing the outer-train composition: a slice bypasses ``support_policy`` and
+        # lets ``sanitize_and_renormalize`` silently uniformize a pivot-sparse inner
         # split. Realign raw weight components to the split rows by index. The
         # recompose renormalizes each subset to mean 1 (proportional to, not
         # byte-identical with, the old slice), which is scale-invariant for the
@@ -2569,9 +2581,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             # Smuggle base/label weights as extra label columns so datasieve
             # row-filters them in lockstep with the features. Relies on datasieve
             # not altering y VALUES (X-only transforms + row drops) and restoring
-            # them via label_list. _sanitize_pipeline_weights guards row count,
-            # not a silent y-value transform: a future y-transforming step would
-            # corrupt these vectors undetected.
+            # them via ``label_list``. ``_sanitize_pipeline_weights`` guards
+            # row count, not a silent y-value transform: a future y-transforming
+            # step would corrupt these vectors undetected.
             base_weight_column = object()
             label_weight_column = object()
             pipeline_labels = labels.copy()
@@ -2595,9 +2607,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             post_pipeline_label_weights = pipeline_labels.pop(
                 label_weight_column
             ).to_numpy(dtype=float)
-            # Load-bearing: fit_transform captured label_list WITH the smuggled
+            # Load-bearing: ``fit_transform`` captured ``label_list`` WITH the smuggled
             # columns; restore it to the real labels or the next validation/test
-            # transform rebuilds y with the wrong column count (ValueError).
+            # transform rebuilds y with the wrong column count (``ValueError``).
             dk.feature_pipeline.label_list = pipeline_labels.columns
             weights = QuickAdapterRegressorV3._enforce_train_weight_support(
                 post_pipeline_base_weights,
@@ -2925,7 +2937,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         validation_size = self._get_validation_size()
 
         model_training_parameters = copy.deepcopy(self.model_training_parameters)
-        init_model = self.get_init_model(dk.pair)
+        deployment_init_model = self.get_init_model(dk.pair)
+        selection_init_model = None if validation_size != 0 else deployment_init_model
 
         start_time = time.time()
         if self._optuna_hyperopt:
@@ -2947,7 +2960,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                     self._optuna_config["space_reduction"],
                     self._optuna_config["space_fraction"],
                     model_path=dk.data_path,
-                    init_model=init_model,
+                    init_model=selection_init_model,
                     vary_model_seed_by_trial=self._optuna_config[
                         "vary_model_seed_by_trial"
                     ],
@@ -2977,7 +2990,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             eval_set=eval_set,
             eval_weights=eval_weights,
             model_training_parameters=copy.deepcopy(model_training_parameters),
-            init_model=init_model,
+            init_model=selection_init_model,
             model_path=dk.data_path,
         )
         if X_test is not None and not X_test.empty:
@@ -3006,7 +3019,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 self.regressor,
                 model,
                 model_training_parameters,
-                init_model,
+                selection_init_model,
             )
             data_dictionary["train_features"] = data_dictionary.pop("refit_features")
             data_dictionary["train_labels"] = data_dictionary.pop("refit_labels")
@@ -3042,7 +3055,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 eval_set=None,
                 eval_weights=None,
                 model_training_parameters=refit_model_training_parameters,
-                init_model=init_model,
+                init_model=deployment_init_model,
                 model_path=dk.data_path,
             )
         time_spent = time.time() - start_time
@@ -4297,7 +4310,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             )
         non_constant_mask = np.array(
             [
-                # rtol=0: pure absolute tolerance on [0,1]-normalized columns;
+                # ``rtol=0``: pure absolute tolerance on [0,1]-normalized columns;
                 # any finite rtol would leak column magnitude into the threshold.
                 not np.allclose(
                     normalized_matrix[:, column_index],
@@ -4834,7 +4847,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         if storage_backend == QuickAdapterRegressorV3._STORAGE_FILE:
             journal_path = storage_dir / f"{storage_filename}.log"
 
-            # Pre-validate EOF: close the read_logs deferred-raise gap (see helper).
+            # Pre-validate EOF: close the ``read_logs`` deferred-raise gap (see helper).
             if QuickAdapterRegressorV3._optuna_journal_has_corrupt_tail(journal_path):
                 QuickAdapterRegressorV3._optuna_quarantine_journal(
                     journal_path,
@@ -4850,7 +4863,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             try:
                 storage = _build_journal_storage()
             except QuickAdapterRegressorV3._OPTUNA_JOURNAL_RECOVERABLE_ERRORS as exc:
-                # Replay-time corruption: quarantine + retry once. OSError is
+                # Replay-time corruption: quarantine + retry once. ``OSError`` is
                 # excluded from the tuple — FS failures stay operator-actionable.
                 quarantined = QuickAdapterRegressorV3._optuna_quarantine_journal(
                     journal_path, pair, exc
@@ -4938,6 +4951,48 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 f"supported values are {', '.join(_OPTUNA_NAMESPACES)}"
             )
 
+    @staticmethod
+    def _optuna_label_selection_metadata_compatible(existing_marker: Any) -> bool:
+        schema_version = (
+            existing_marker.get("schema_version")
+            if isinstance(existing_marker, dict)
+            else None
+        )
+        return (
+            not isinstance(schema_version, bool)
+            and isinstance(schema_version, (int, np.integer))
+            and schema_version == _OPTUNA_LABEL_SELECTION_SCHEMA_VERSION
+        )
+
+    def _optuna_study_marker(
+        self, namespace: OptunaNamespace
+    ) -> Optional[_OptunaStudyMarker]:
+        if namespace == _OPTUNA_NAMESPACES.hp:
+            identity = QuickAdapterRegressorV3._OPTUNA_HP_OBJECTIVE_IDENTITY
+            # ``hp`` always resets on identity mismatch: a changed objective
+            # makes prior trials incomparable (unlike ``label``'s schema, which
+            # the caller can preserve). A legacy study lacking
+            # ``objective_identity`` therefore mismatches and is reset once on
+            # the first live/dry-run after upgrade.
+            return _OptunaStudyMarker(
+                user_attr_key="objective_identity",
+                build_marker=lambda: identity,
+                is_compatible=lambda existing: existing == identity,
+                reset_on_mismatch=True,
+            )
+        if namespace == _OPTUNA_NAMESPACES.label:
+            return _OptunaStudyMarker(
+                user_attr_key="selection_metadata",
+                build_marker=self._optuna_label_selection_metadata,
+                is_compatible=(
+                    QuickAdapterRegressorV3._optuna_label_selection_metadata_compatible
+                ),
+                reset_on_mismatch=bool(
+                    self._optuna_config["reset_label_study_on_schema_mismatch"]
+                ),
+            )
+        return None
+
     def optuna_create_study(
         self,
         pair: str,
@@ -4959,6 +5014,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
 
         identifier = self.freqai_info.get("identifier")
         study_name = f"{identifier}-{pair}-{namespace}"
+        study_marker = self._optuna_study_marker(namespace)
 
         try:
             storage = self.optuna_create_storage(pair)
@@ -4974,18 +5030,18 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         # cutoff's in-memory best, which stays causal as it predates the current
         # cutoff.
         continuous = self._optuna_config.get("continuous") or not self.live
-        label_schema_mismatch_preserved = False
+        study_marker_mismatch_preserved = False
         if continuous:
             QuickAdapterRegressorV3.optuna_delete_study(
                 pair, namespace, study_name, storage
             )
-        elif namespace == _OPTUNA_NAMESPACES.label:
+        elif study_marker is not None:
             try:
                 existing_study = QuickAdapterRegressorV3.optuna_load_study(
                     study_name, storage
                 )
-                existing_selection_metadata = (
-                    existing_study.user_attrs.get("selection_metadata")
+                existing_marker = (
+                    existing_study.user_attrs.get(study_marker.user_attr_key)
                     if existing_study is not None
                     else None
                 )
@@ -4995,39 +5051,23 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                     exc_info=True,
                 )
                 return None
-            if existing_study is not None:
-                existing_schema_version = (
-                    existing_selection_metadata.get("schema_version")
-                    if isinstance(existing_selection_metadata, dict)
-                    else None
+            if existing_study is not None and not study_marker.is_compatible(
+                existing_marker
+            ):
+                reset_study = study_marker.reset_on_mismatch
+                logger.warning(
+                    f"[{pair}] Optuna {namespace} study {study_name}: "
+                    f"stored {study_marker.user_attr_key} {existing_marker!r} "
+                    f"incompatible with current; "
+                    f"{'resetting' if reset_study else 'preserving'} study"
                 )
-                target_version = _OPTUNA_LABEL_SELECTION_SCHEMA_VERSION
-                if (
-                    isinstance(existing_schema_version, bool)
-                    or not isinstance(existing_schema_version, (int, np.integer))
-                    or existing_schema_version != target_version
-                ):
-                    version_repr = (
-                        "none"
-                        if existing_schema_version is None
-                        else f"v{existing_schema_version!r}"
-                    )
-                    reset_study = self._optuna_config[
-                        "reset_label_study_on_schema_mismatch"
-                    ]
-                    logger.warning(
-                        f"[{pair}] Optuna {namespace} study {study_name}: "
-                        f"selection schema {version_repr} incompatible "
-                        f"with v{target_version}; "
-                        f"{'resetting' if reset_study else 'preserving'} study"
-                    )
-                    if reset_study:
-                        if not QuickAdapterRegressorV3.optuna_delete_study(
-                            pair, namespace, study_name, storage
-                        ):
-                            return None
-                    else:
-                        label_schema_mismatch_preserved = True
+                if reset_study:
+                    if not QuickAdapterRegressorV3.optuna_delete_study(
+                        pair, namespace, study_name, storage
+                    ):
+                        return None
+                else:
+                    study_marker_mismatch_preserved = True
 
         samplers, sampler = self.optuna_samplers_by_namespace(namespace)
         if sampler not in samplers:
@@ -5046,21 +5086,18 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 storage=storage,
                 load_if_exists=not continuous,
             )
-            if (
-                namespace == _OPTUNA_NAMESPACES.label
-                and not label_schema_mismatch_preserved
-            ):
-                new_selection_metadata = self._optuna_label_selection_metadata()
-                existing_selection_metadata = study.user_attrs.get("selection_metadata")
-                if existing_selection_metadata != new_selection_metadata:
-                    if isinstance(existing_selection_metadata, dict):
+            if study_marker is not None and not study_marker_mismatch_preserved:
+                target_marker = study_marker.build_marker()
+                existing_marker = study.user_attrs.get(study_marker.user_attr_key)
+                if existing_marker != target_marker:
+                    if existing_marker is not None:
                         logger.warning(
                             f"[{pair}] Optuna {namespace} study {study_name}: "
-                            f"selection_metadata change detected "
-                            f"(stored: {existing_selection_metadata!r}, "
-                            f"current: {new_selection_metadata!r})"
+                            f"{study_marker.user_attr_key} change detected "
+                            f"(stored: {existing_marker!r}, "
+                            f"current: {target_marker!r})"
                         )
-                    study.set_user_attr("selection_metadata", new_selection_metadata)
+                    study.set_user_attr(study_marker.user_attr_key, target_marker)
             return study
         except Exception as e:
             logger.error(
@@ -5118,6 +5155,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             selection_metadata=self._optuna_label_selection_metadata()
             if namespace == _OPTUNA_NAMESPACES.label
             else None,
+            objective_identity=QuickAdapterRegressorV3._OPTUNA_HP_OBJECTIVE_IDENTITY
+            if namespace == _OPTUNA_NAMESPACES.hp
+            else None,
         )
 
     def optuna_load_best_params(
@@ -5134,6 +5174,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             namespace,
             logger,
             expected_selection_metadata=expected,
+            expected_objective_identity=QuickAdapterRegressorV3._OPTUNA_HP_OBJECTIVE_IDENTITY
+            if namespace == _OPTUNA_NAMESPACES.hp
+            else None,
         )
 
     @staticmethod
@@ -5148,8 +5191,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             return True
         except KeyError as e:
             # A missing study is a benign no-op: non-live runs use a fresh
-            # InMemoryStorage and the first live/dry-run optimization per pair
-            # has none yet. optuna reports it as KeyError; other failures reach
+            # ``InMemoryStorage`` and the first live/dry-run optimization per pair
+            # has none yet. optuna reports it as ``KeyError``; other failures reach
             # the warning branch below.
             logger.debug(
                 f"[{pair}] Optuna {namespace} study {study_name} absent; nothing to delete: {e!r}"
index 45e5be9ce68e7f3a7a8c2c0f7aa4a8d2da1bb306..2b2c30d30703dd14e2bc5a3a923369dbbed42594 100644 (file)
@@ -106,8 +106,8 @@ _TakeProfitHistoryEntry = float | tuple[int, float] | list[int | float]
 
 
 class _TradeHistory(TypedDict):
-    # Key names must mirror the _UNREALIZED_PNL_CANDLE_DATE_KEY /
-    # _UNREALIZED_PNL_TIMEFRAME_KEY constants (a TypedDict field cannot
+    # Key names must mirror the ``_UNREALIZED_PNL_CANDLE_DATE_KEY`` /
+    # ``_UNREALIZED_PNL_TIMEFRAME_KEY`` constants (a ``TypedDict`` field cannot
     # reference a constant).
     unrealized_pnl: list[float]
     take_profit_price: list[_TakeProfitHistoryEntry]
@@ -203,10 +203,10 @@ class QuickAdapterV3(IStrategy):
     _UNREALIZED_PNL_TIMEFRAME_KEY: Final[str] = "unrealized_pnl_timeframe"
 
     # Rounding margin so the sized partial-exit remainder clears freqtrade's
-    # strict `remaining < min_exit_stake` guard.
+    # strict ``remaining < min_exit_stake`` guard.
     _PARTIAL_EXIT_MIN_STAKE_MARGIN: Final[float] = 1e-3
 
-    # FreqAI is crashing if minimal_roi is a property
+    # FreqAI is crashing if ``minimal_roi`` is a property
     minimal_roi = {str(timeframe_minutes * 864): -1}
 
     process_only_new_candles = True
@@ -221,7 +221,7 @@ class QuickAdapterV3(IStrategy):
 
     @cached_property
     def is_trade_runmode(self) -> bool:
-        # True in live and dry-run (runmode in TRADE_MODES), mirroring the
+        # True in live and dry-run (``runmode`` in ``TRADE_MODES``), mirroring the
         # regressor's ``self.live`` gate.
         return self.config.get("runmode") in TRADE_MODES
 
@@ -1079,7 +1079,7 @@ class QuickAdapterV3(IStrategy):
                 dataframe, timeperiod=self.get_label_period_candles(pair)
             )
         else:
-            # Per-candle HPO label_period_candles: NATR is computed once per
+            # Per-candle HPO ``label_period_candles``: NATR is computed once per
             # distinct period, then scattered back to its matching rows (mixing
             # per-row periods within one column is intentional).
             dataframe["natr_label_period_candles"] = np.nan
@@ -1681,12 +1681,12 @@ class QuickAdapterV3(IStrategy):
             trade_partial_stake_amount = trade_stake_percent * trade.stake_amount
             if min_stake is not None and min_stake > 0:
                 current_position_value = trade.amount * current_exit_rate
-                # Live/dry-run passes min_entry_stake, while freqtrade's
+                # Live/dry-run passes ``min_entry_stake``, while freqtrade's
                 # backtesting path already passes the adjusted minimum it guards.
                 min_remaining_position_value = min_stake
                 if self.is_trade_runmode:
-                    # For both the cost- and amount-driven minimum, min_exit_stake
-                    # <= min_stake * max(exit/entry, 1/(1-|sl|)).
+                    # For both the cost- and amount-driven minimum, ``min_exit_stake``
+                    # <= ``min_stake`` * max(exit/entry, 1/(1-|sl|)).
                     min_remaining_position_value *= max(
                         current_exit_rate / current_entry_rate,
                         1.0 / (1.0 - abs(self.stoploss)),
index 9ace98cbe1cef18de9d2616196211fab95301983..29e9388c44651e46faf378dc4d2f895957fc3c12 100644 (file)
@@ -69,7 +69,7 @@ else:
 
 T = TypeVar("T", pd.Series, float)
 
-# lru_cache sizes: SMALL for bounded key spaces (windows, mode strings),
+# ``lru_cache`` sizes: SMALL for bounded key spaces (windows, mode strings),
 # LARGE for open numeric/string keys (formatting, rounding, statistics).
 _CACHE_MAXSIZE_SMALL: Final[int] = 8
 _CACHE_MAXSIZE_LARGE: Final[int] = 128
@@ -316,9 +316,9 @@ def safe_log_ratio(
 
 
 def _is_finite_value(value: Any) -> bool:
-    # np.isfinite raises/returns non-scalar on inputs it cannot reduce to a
-    # single finite float64 (Python ints >= 2**64 -> TypeError/OverflowError;
-    # array-likes -> a ValueError on bool()); treat all of those as non-finite.
+    # ``np.isfinite`` raises/returns non-scalar on inputs it cannot reduce to a
+    # single finite float64 (Python ints >= 2**64 -> ``TypeError``/``OverflowError``;
+    # array-likes -> a ``ValueError`` on ``bool()``); treat all of those as non-finite.
     try:
         return bool(np.isfinite(value))
     except (TypeError, OverflowError, ValueError):
@@ -1569,8 +1569,8 @@ DEFAULTS_REVERSAL_CONFIRMATION: Final[dict[str, Any]] = {
     "max_natr_multiplier_fraction": 0.0125,
 }
 
-# Scalars only: the coupled (min, max) natr pair needs validate_range's
-# cross-field ordering and per-component fallback, which _validate_params
+# Scalars only: the coupled (min, max) natr pair needs ``validate_range``'s
+# cross-field ordering and per-component fallback, which ``_validate_params``
 # cannot express.
 _REVERSAL_CONFIRMATION_SCALAR_SPECS: Final[dict[str, _ParamSpec]] = {
     "lookback_period_candles": _ParamSpec(
@@ -4152,7 +4152,7 @@ def _zigzag(
         # orientation (scan restarts at initial_pivot_pos+1, before the
         # orientation confirmation candle i) must not claim availability earlier
         # than i, since its label depends on that orientation. Fold the latest
-        # confirmation seen so far so known_at never understates it.
+        # confirmation seen so far so ``known_at`` never understates it.
         confirmed_at_pos = max(
             confirmed_at_pos,
             resolve_through_pos,
@@ -4168,9 +4168,9 @@ def _zigzag(
             return
 
         # These swing metrics are backfilled onto the previous pivot from the
-        # adjacent closing pivot, confirmed at this pivot's known_at. The weight
+        # adjacent closing pivot, confirmed at this pivot's ``known_at``. The weight
         # is therefore causally available one pivot later than its label;
-        # compute_label_weight_known_at_lookahead derives that lag so the causal
+        # ``compute_label_weight_known_at_lookahead`` derives that lag so the causal
         # purge masks weights on max(label, weight) availability.
         if (
             pivots_values_log
@@ -4622,10 +4622,11 @@ def get_refit_model_training_parameters(
         )
 
     spec = _REGRESSOR_SPEC_BY_NAME[regressor]
-    # best_iteration is combined-indexed under current xgboost/lightgbm, so
-    # fitted >= initial + 1 always holds; clamp defensively so a degenerate
-    # non-improving continual-learning refit degrades gracefully instead of
-    # raising and killing the whole training window.
+    # The sole caller refits the cold-started selection model
+    # (``init_model=None``), so ``initial_iterations`` is 0 here; the
+    # ``init_model`` branches above remain for warm-start callers. Clamp to
+    # >= 1 so a degenerate fit (``fitted_iterations`` <= ``initial_iterations``)
+    # degrades gracefully instead of raising.
     refit_iterations = max(fitted_iterations - initial_iterations, 1)
     for alias in spec.iteration_aliases:
         refit_parameters.pop(alias, None)
@@ -5126,6 +5127,7 @@ def optuna_load_best_params(
     logger: Logger | None = None,
     *,
     expected_selection_metadata: dict[str, Any] | None = None,
+    expected_objective_identity: str | None = None,
 ) -> dict[str, Any] | None:
     best_params_path = (
         base_path / f"optuna-{namespace}-best-params-{pair.split('/')[0]}.json"
@@ -5140,6 +5142,20 @@ def optuna_load_best_params(
                 logger,
                 expected_selection_metadata=expected_selection_metadata,
             )
+        if expected_objective_identity is not None:
+            if (
+                not isinstance(best_params, dict)
+                or best_params.get("objective_identity") != expected_objective_identity
+                or not isinstance(best_params.get("params"), dict)
+            ):
+                if logger is not None:
+                    logger.warning(
+                        f"[{pair}] Ignoring Optuna {namespace} best params: "
+                        f"objective identity does not match "
+                        f"{expected_objective_identity!r}"
+                    )
+                return None
+            return best_params["params"]
         return best_params
     return None
 
@@ -5151,6 +5167,7 @@ def optuna_save_best_params(
     params: dict[str, Any],
     logger: Logger,
     selection_metadata: dict[str, Any] | None = None,
+    objective_identity: str | None = None,
 ) -> None:
     best_params_path = (
         base_path / f"optuna-{namespace}-best-params-{pair.split('/')[0]}.json"
@@ -5163,6 +5180,11 @@ def optuna_save_best_params(
             }
             if selection_metadata is not None:
                 best_params["selection_metadata"] = selection_metadata
+        elif objective_identity is not None:
+            best_params = {
+                "objective_identity": objective_identity,
+                "params": params,
+            }
         else:
             best_params = params
         with best_params_path.open("w", encoding="utf-8") as write_file: