From d81031d2754e62f6af392c52b19e10fcf8bc7399 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 23 Jul 2026 19:39:11 +0200 Subject: [PATCH] fix(quickadapter): make HPO state causal in backtests (#111) * fix(quickadapter): make label HPO causal in backtests * fix(quickadapter): harden causal label HPO bounds, docs and harmonization - guard empty prediction history and use NaT/order-safe max()/min() when bounding label HPO OHLCV to the current FreqAI prediction time - gate strategy label-param loading on freqtrade TRADE_MODES to mirror the regressor self.live gate - document the point-in-time study reset (supersedes explicit continuous=false) and causal warm-start seeding - make the optuna_hyperopt.enabled README entry terse and add a dedicated causal label HPO note; unify terminology on current FreqAI prediction time * docs(quickadapter): drop causal label HPO note from README * docs(quickadapter): note label-namespace continuous override and refine HPO bound docstring * fix(quickadapter): isolate HPO state in non-live runs * fix(quickadapter): isolate non-live Optuna storage * fix(quickadapter): silence benign Optuna study deletion on fresh storage - treat a missing study on delete as a debug no-op: non-live runs use a fresh InMemoryStorage and the first live/dry-run optimization per pair has no persisted study yet, so optuna.delete_study raises KeyError; keep warning+traceback for genuine deletion failures - drop redundant point-in-time frame copies: DataProvider.get_pair_dataframe already returns a caller-owned frame and it is only read downstream - lower the non-live 'Label HPO skipped' bounds logs to debug (expected backtest warmup states, consistent with the throttle debug log) - refine the point-in-time docstring (dk.full_df is the full feature frame) and note that self.live is unset at __init__ * docs(quickadapter): tighten HPO comments and de-parenthesize README - make the __init__ trade-mode, non-live point-in-time HPO, and delete_study KeyError comments more precise and concise without dropping semantics - reword the delete_study comment to point at the warning branch instead of the inaccurate 'real failures raise otherwise' - rephrase the optuna_hyperopt.continuous README entry without a parenthetical precision, consistent with the surrounding prose * chore(serena): migrate project config to the language_servers schema Serena renamed the deprecated `languages` key to `language_servers` and refreshed the accompanying comments; regenerate the tracked project config to match. --- .serena/project.yml | 77 +++++----- README.md | 6 +- .../freqaimodels/QuickAdapterRegressorV3.py | 133 ++++++++++++++---- .../user_data/strategies/QuickAdapterV3.py | 10 +- 4 files changed, 160 insertions(+), 66 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 95bc49c..e65f884 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -8,6 +8,13 @@ ignore_all_files_in_gitignore: true # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" # Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] @@ -57,41 +64,6 @@ included_optional_tools: [] # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] -# list of languages for which language servers are started (LSP backend only); choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_jedi python_pyrefly python_ty r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# yaml zig -# (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- python - # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. # This overrides the corresponding setting in the global configuration; see the documentation there. @@ -168,3 +140,38 @@ ls_additional_workspace_folders: [] # - "./subproject2" ls_workspace_folders: - . + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- python diff --git a/README.md b/README.md index e8a5d0a..e8c83c4 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,12 @@ docker compose up -d --build | freqai.label_prediction.outlier_quantile | 0.999 | float (0,1) | Quantile threshold for predictions outlier filtering. | | freqai.label_prediction.keep_fraction | 0.0075 | float (0,1] | Fraction of extrema used for thresholds. 1 uses all, lower values keep only most significant. Applies to `rank_extrema` and `rank_peaks`; ignored for `partition`. | | _Optuna / HPO_ | | | | -| freqai.optuna_hyperopt.enabled | false | bool | Enables HPO. | +| freqai.optuna_hyperopt.enabled | false | bool | Enables regressor and dynamic label HPO. | | freqai.optuna_hyperopt.sampler | `tpe` | enum {`tpe`,`auto`} | HPO sampler algorithm for `hp` namespace. `tpe` uses [TPESampler](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.TPESampler.html) with multivariate, group, and constant_liar (when multiple workers), `auto` uses [AutoSampler](https://hub.optuna.org/samplers/auto_sampler). | | freqai.optuna_hyperopt.label_sampler | `auto` | enum {`auto`,`tpe`,`nsgaii`,`nsgaiii`} | HPO sampler algorithm for multi-objective `label` namespace. `nsgaii` uses [NSGAIISampler](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.NSGAIISampler.html), `nsgaiii` uses [NSGAIIISampler](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.NSGAIIISampler.html). | | freqai.optuna_hyperopt.storage | `file` | enum {`file`,`sqlite`} | HPO storage backend. | -| freqai.optuna_hyperopt.continuous | true | bool | Continuous HPO. | -| freqai.optuna_hyperopt.warm_start | true | bool | Warm start HPO with previous best value(s). | +| freqai.optuna_hyperopt.continuous | true | bool | Continuous HPO. Forced for both namespaces in backtest and hyperopt, resetting the study on each optimization. | +| freqai.optuna_hyperopt.warm_start | true | bool | Warm start HPO with previous best value(s). Persisted values are loaded and saved only in live and dry-run modes; non-live runs reuse only values produced earlier in the same run. | | freqai.optuna_hyperopt.n_startup_trials | 15 | int >= 0 | HPO startup trials. | | freqai.optuna_hyperopt.n_trials | 50 | int >= 1 | Maximum HPO trials. | | freqai.optuna_hyperopt.n_jobs | CPU threads / 4 | int >= 1 | Parallel HPO workers. | diff --git a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py index d448922..dfe3d08 100644 --- a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py +++ b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py @@ -32,6 +32,7 @@ import skimage import sklearn from datasieve.pipeline import Pipeline from datasieve.transforms import SKLearnWrapper +from freqtrade.enums import TRADE_MODES from freqtrade.exceptions import DependencyException from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel from freqtrade.freqai.data_kitchen import FreqaiDataKitchen @@ -1433,17 +1434,20 @@ 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 + # from the configured runmode here. + trade_mode = self.config.get("runmode") in TRADE_MODES for pair in self.pairs: self._optuna_hp_value[pair] = -1 self._optuna_label_values[pair] = [ -1 ] * QuickAdapterRegressorV3._OPTUNA_LABEL_N_OBJECTIVES self._optuna_hp_params[pair] = ( - self.optuna_load_best_params(pair, _OPTUNA_NAMESPACES.hp) or {} - ) - self._optuna_label_params[pair] = self.optuna_load_best_params( - pair, _OPTUNA_NAMESPACES.label - ) or { + self.optuna_load_best_params(pair, _OPTUNA_NAMESPACES.hp) + if trade_mode + else None + ) or {} + configured_label_params = { "label_period_candles": self.ft_params.get( "label_period_candles", default_label_period_candles, @@ -1455,6 +1459,11 @@ class QuickAdapterRegressorV3(BaseRegressionModel): ) ), } + self._optuna_label_params[pair] = ( + self.optuna_load_best_params(pair, _OPTUNA_NAMESPACES.label) + if trade_mode + else None + ) or configured_label_params self.set_optuna_label_candle(pair) self._optuna_label_candles[pair] = 0 @@ -2578,25 +2587,10 @@ class QuickAdapterRegressorV3(BaseRegressionModel): self.optuna_throttle_callback( pair=pair, namespace=_OPTUNA_NAMESPACES.label, - callback=lambda: self.optuna_optimize( - pair=pair, - namespace=_OPTUNA_NAMESPACES.label, - objective=lambda trial: label_objective( - trial, - self.data_provider.get_pair_dataframe( - pair=pair, timeframe=self.config.get("timeframe") - ), - fit_live_predictions_candles, - self._optuna_config.get( - "label_candles_step", - QuickAdapterRegressorV3.OPTUNA_LABEL_CANDLES_STEP_DEFAULT, - ), - min_label_period_candles=self._min_label_period_candles, - max_label_period_candles=self._max_label_period_candles, - min_label_natr_multiplier=self._min_label_natr_multiplier, - max_label_natr_multiplier=self._max_label_natr_multiplier, - ), - directions=list(QuickAdapterRegressorV3._OPTUNA_LABEL_DIRECTIONS), + callback=lambda: self._optimize_labels_as_of_prediction_time( + dk, + pair, + fit_live_predictions_candles, ), ) @@ -2746,6 +2740,77 @@ class QuickAdapterRegressorV3(BaseRegressionModel): hp_rmse if hp_rmse is not None else np.inf ) + def _label_hpo_dataframe_as_of_prediction_time( + self, + dk: FreqaiDataKitchen, + pair: str, + ) -> pd.DataFrame: + """Return pair OHLCV bounded to the current FreqAI prediction time. + + In backtests, ``historic_predictions`` holds the preceding prediction + window and ``dk.full_df`` is the full feature frame; the current + prediction time is the first ``dk.full_df`` timestamp strictly after the + last recorded prediction. Live and dry-run modes rely on the already + point-in-time DataProvider frame. This explicit backtest bound is + required because DataProvider otherwise exposes the complete historical + timerange. + """ + pair_dataframe = self.data_provider.get_pair_dataframe( + pair=pair, timeframe=self.config.get("timeframe") + ) + if self.live or pair_dataframe.empty: + return pair_dataframe + + history_dates = ensure_datetime_series( + self.dd.historic_predictions[pair]["date"] + ) + if history_dates.empty: + logger.debug( + "[%s] Label HPO skipped: no prior predictions to bound the current FreqAI prediction time", + pair, + ) + return pair_dataframe.iloc[:0] + + pair_dates = ensure_datetime_series(pair_dataframe["date"]) + full_dates = ensure_datetime_series(dk.full_df["date"]) + current_dates = full_dates.loc[full_dates > history_dates.max()] + if current_dates.empty: + logger.debug( + "[%s] Label HPO skipped: current FreqAI prediction time is unavailable", + pair, + ) + return pair_dataframe.iloc[:0] + + return pair_dataframe.loc[pair_dates <= current_dates.min()] + + def _optimize_labels_as_of_prediction_time( + self, + dk: FreqaiDataKitchen, + pair: str, + fit_live_predictions_candles: int, + ) -> Optional[optuna.study.Study]: + label_dataframe = self._label_hpo_dataframe_as_of_prediction_time(dk, pair) + if label_dataframe.empty: + return None + return self.optuna_optimize( + pair=pair, + namespace=_OPTUNA_NAMESPACES.label, + objective=lambda trial: label_objective( + trial, + label_dataframe, + fit_live_predictions_candles, + self._optuna_config.get( + "label_candles_step", + QuickAdapterRegressorV3.OPTUNA_LABEL_CANDLES_STEP_DEFAULT, + ), + min_label_period_candles=self._min_label_period_candles, + max_label_period_candles=self._max_label_period_candles, + min_label_natr_multiplier=self._min_label_natr_multiplier, + max_label_natr_multiplier=self._max_label_natr_multiplier, + ), + directions=list(QuickAdapterRegressorV3._OPTUNA_LABEL_DIRECTIONS), + ) + @staticmethod def optuna_validate_value(value: Any) -> Optional[float]: return value if isinstance(value, (int, float)) and np.isfinite(value) else None @@ -4166,7 +4231,8 @@ class QuickAdapterRegressorV3(BaseRegressionModel): logger.warning( f"[{pair}] Optuna {namespace} {objective_type} objective hyperopt best params found has invalid optimization target value(s)" ) - self.optuna_save_best_params(pair, namespace) + if self.live: + self.optuna_save_best_params(pair, namespace) return study @staticmethod @@ -4270,6 +4336,9 @@ class QuickAdapterRegressorV3(BaseRegressionModel): ) def optuna_create_storage(self, pair: str) -> optuna.storages.BaseStorage: + if not self.live: + return optuna.storages.InMemoryStorage() + storage_dir = self.full_path storage_filename = f"optuna-{pair.split('/')[0]}" storage_backend = self._optuna_config.get("storage") @@ -4436,7 +4505,11 @@ class QuickAdapterRegressorV3(BaseRegressionModel): ) return None - continuous = self._optuna_config.get("continuous") + # Non-live HPO is point-in-time: reset the study each optimization and + # never persist best params. Warm start still seeds it from the previous + # cutoff's in-memory best, which stays causal as it predates the current + # cutoff. + continuous = self._optuna_config.get("continuous") or not self.live if continuous: QuickAdapterRegressorV3.optuna_delete_study( pair, namespace, study_name, storage @@ -4587,6 +4660,14 @@ class QuickAdapterRegressorV3(BaseRegressionModel): ) -> None: try: optuna.delete_study(study_name=study_name, storage=storage) + 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 + # the warning branch below. + logger.debug( + f"[{pair}] Optuna {namespace} study {study_name} absent; nothing to delete: {e!r}" + ) except Exception as e: logger.warning( f"[{pair}] Optuna {namespace} study {study_name} deletion failed: {e!r}", diff --git a/quickadapter/user_data/strategies/QuickAdapterV3.py b/quickadapter/user_data/strategies/QuickAdapterV3.py index b1e860b..967d53f 100644 --- a/quickadapter/user_data/strategies/QuickAdapterV3.py +++ b/quickadapter/user_data/strategies/QuickAdapterV3.py @@ -17,6 +17,7 @@ from typing import ( import numpy as np import pandas_ta as pta import talib.abstract as ta +from freqtrade.enums import TRADE_MODES from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date from freqtrade.persistence import Trade from freqtrade.strategy import AnnotationType, stoploss_from_absolute @@ -454,9 +455,14 @@ class QuickAdapterV3(IStrategy): self._label_defaults ) self._label_params: dict[str, dict[str, Any]] = {} + # Mirror the regressor's ``self.live`` gate (runmode in TRADE_MODES): + # persisted label params are reused only in live and dry-run. + load_persisted_label_params = self.config.get("runmode") in TRADE_MODES for pair in self.pairs: - label_best_params = self.optuna_load_best_params( - pair, _OPTUNA_NAMESPACES.label + label_best_params = ( + self.optuna_load_best_params(pair, _OPTUNA_NAMESPACES.label) + if load_persisted_label_params + else None ) self._label_params[pair] = ( label_best_params -- 2.53.0