From b856c8cba54dc6370701aaaf1d1510fc2dc0094a Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 29 Jul 2026 22:16:03 +0200 Subject: [PATCH] fix(quickadapter): isolate holdout from continual state (#156) 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 | 3 +- quickadapter/user_data/config-template.json | 1 + .../freqaimodels/QuickAdapterRegressorV3.py | 193 +++++++++++------- .../user_data/strategies/QuickAdapterV3.py | 18 +- quickadapter/user_data/strategies/Utils.py | 48 +++-- 5 files changed, 165 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 539b42d..cd2163f 100644 --- 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= 1 \| None | Maximum training set size for `timeseries_split`. When set, creates a sliding window instead of expanding train set. None = no limit. | diff --git a/quickadapter/user_data/config-template.json b/quickadapter/user_data/config-template.json index 0fcbc2b..6d211f3 100644 --- a/quickadapter/user_data/config-template.json +++ b/quickadapter/user_data/config-template.json @@ -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, diff --git a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py index 99d6cf3..513e8b7 100644 --- a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py +++ b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py @@ -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}" diff --git a/quickadapter/user_data/strategies/QuickAdapterV3.py b/quickadapter/user_data/strategies/QuickAdapterV3.py index 45e5be9..2b2c30d 100644 --- a/quickadapter/user_data/strategies/QuickAdapterV3.py +++ b/quickadapter/user_data/strategies/QuickAdapterV3.py @@ -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)), diff --git a/quickadapter/user_data/strategies/Utils.py b/quickadapter/user_data/strategies/Utils.py index 9ace98c..29e9388 100644 --- a/quickadapter/user_data/strategies/Utils.py +++ b/quickadapter/user_data/strategies/Utils.py @@ -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: -- 2.53.0