From: Jérôme Benoit Date: Tue, 28 Jul 2026 14:44:40 +0000 (+0200) Subject: fix(quickadapter): correct and harmonize builtin caching (#165) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=ba4ba4c6308bf2bc2be1dc8c0f759df412158db3;p=freqai-strategies.git fix(quickadapter): correct and harmonize builtin caching (#165) Remove @lru_cache from the instance method QuickAdapterRegressorV3.optuna_samplers_by_namespace: decorating a bound method kept self in the process-lifetime cache, pinning regressor instances (models, studies, dataframes) and leaking memory across re-instantiations. The value is trivially recomputable. Harmonize the rest of the builtin caching: - Centralize lru_cache sizes into _CACHE_MAXSIZE_SMALL/_LARGE constants, replacing scattered 8/64/128 literals (right-sizes the oversized 64 on _label_aux_column_name). - Drop ineffective @lru_cache on the float-keyed static helpers _t_statistic/_effective_df/_t_critical: continuous per-call float keys give a near-zero hit rate while _effective_df pays O(n) tuple hashing. - Return a read-only ndarray from _calculate_coeffs so callers cannot corrupt the shared cached kernel. - Document the _df_signature process_only_new_candles coupling. --- diff --git a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py index 6261d87..61f0f71 100644 --- a/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py +++ b/quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py @@ -7,7 +7,7 @@ import time import warnings from dataclasses import dataclass from datetime import datetime, timezone -from functools import cached_property, lru_cache +from functools import cached_property from pathlib import Path from typing import ( AbstractSet, @@ -4783,7 +4783,6 @@ class QuickAdapterRegressorV3(BaseRegressionModel): case _: assert_never(sampler) - @lru_cache(maxsize=8) def optuna_samplers_by_namespace( self, namespace: OptunaNamespace ) -> tuple[frozenset[OptunaSampler], OptunaSampler]: diff --git a/quickadapter/user_data/strategies/QuickAdapterV3.py b/quickadapter/user_data/strategies/QuickAdapterV3.py index c3f9813..4ef5055 100644 --- a/quickadapter/user_data/strategies/QuickAdapterV3.py +++ b/quickadapter/user_data/strategies/QuickAdapterV3.py @@ -43,6 +43,7 @@ from scipy.stats import pearsonr, t from technical.pivots_points import pivots_points from Utils import ( DEFAULTS_EXIT_THRESHOLDS_CALIBRATION, + _CACHE_MAXSIZE_LARGE, _OPTUNA_NAMESPACES, EXTREMA_COLUMN, EXTREMA_DIRECTION_COLUMN, @@ -657,6 +658,9 @@ class QuickAdapterV3(IStrategy): @staticmethod def _df_signature(df: DataFrame) -> DfSignature: + """Candle-cache key ``(row_count, last_date)``; assumes existing rows + stay immutable (holds under ``process_only_new_candles = True``). + """ n = len(df) if n == 0: return (0, None) @@ -963,7 +967,7 @@ class QuickAdapterV3(IStrategy): return {} @staticmethod - @lru_cache(maxsize=128) + @lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def _td_format( delta: datetime.timedelta, pattern: str = "{sign}{d}:{h:02d}:{m:02d}:{s:02d}" ) -> str: @@ -1206,7 +1210,7 @@ class QuickAdapterV3(IStrategy): return trade.open_date_utc - offset_timedelta @staticmethod - @lru_cache(maxsize=128) + @lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def is_trade_duration_valid(trade_duration: Optional[int | float]) -> bool: return isinstance(trade_duration, (int, float)) and not ( isna(trade_duration) or trade_duration <= 0 @@ -1372,7 +1376,7 @@ class QuickAdapterV3(IStrategy): ) @staticmethod - @lru_cache(maxsize=128) + @lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def get_stoploss_factor(trade_duration_candles: int) -> float: return 2.75 / (1.2675 + math.atan(0.25 * trade_duration_candles)) @@ -1405,7 +1409,7 @@ class QuickAdapterV3(IStrategy): ) @staticmethod - @lru_cache(maxsize=128) + @lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def get_take_profit_factor(trade_duration_candles: int) -> float: return math.log10(9.75 + 0.25 * trade_duration_candles) @@ -2092,7 +2096,6 @@ class QuickAdapterV3(IStrategy): ) @staticmethod - @lru_cache(maxsize=128) def _t_statistic(mean: float, std: float, n: int) -> float: """Compute t-statistic for H0: mu = 0 as ``mean * sqrt(n) / std``. @@ -2108,7 +2111,7 @@ class QuickAdapterV3(IStrategy): return mean * math.sqrt(n) / std @staticmethod - @lru_cache(maxsize=128) + @lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def is_isoformat(string: str) -> bool: if not isinstance(string, str): return False @@ -2119,7 +2122,6 @@ class QuickAdapterV3(IStrategy): return True @staticmethod - @lru_cache(maxsize=128) def _effective_df(x: tuple[float, ...]) -> float: """Effective degrees of freedom with Bartlett's autocorrelation correction. @@ -2156,7 +2158,6 @@ class QuickAdapterV3(IStrategy): return df_eff @staticmethod - @lru_cache(maxsize=128) def _t_critical(q: float, df: float, default_t: float) -> float: """Critical t-value from Student's t-distribution at quantile ``q``. diff --git a/quickadapter/user_data/strategies/Utils.py b/quickadapter/user_data/strategies/Utils.py index 90a530b..dc04501 100644 --- a/quickadapter/user_data/strategies/Utils.py +++ b/quickadapter/user_data/strategies/Utils.py @@ -68,6 +68,11 @@ else: T = TypeVar("T", pd.Series, float) +# 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 + @dataclass(frozen=True, slots=True) class FiniteSample: @@ -664,7 +669,7 @@ LABEL_COLUMNS: Final[tuple[str, ...]] = (EXTREMA_COLUMN,) _FREQAI_LABEL_SIGIL_PATTERN: Final[re.Pattern[str]] = re.compile(r"^&-?") -@lru_cache(maxsize=64) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def _label_aux_column_name(label_col: str, suffix: str) -> str: """Derive a freqtrade-safe auxiliary column name from a label column. @@ -1983,26 +1988,26 @@ def non_zero_diff(s1: pd.Series, s2: pd.Series) -> pd.Series: return diff.where(diff != 0, np.finfo(float).eps) -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_odd_window(window: int) -> int: if window < 1: raise ValueError(f"Invalid window value {window!r}: must be > 0") return window if window % 2 == 1 else window + 1 -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_even_window(window: int) -> int: if window < 1: raise ValueError(f"Invalid window value {window!r}: must be > 0") return window if window % 2 == 0 else window + 1 -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_gaussian_std(window: int) -> float: return (window - 1) / 6.0 if window > 1 else 0.5 -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_savgol_params( window: int, polyorder: int, mode: SmoothingMode ) -> tuple[int, int, str]: @@ -2012,7 +2017,7 @@ def get_savgol_params( return window, polyorder, mode -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def _calculate_coeffs( window: int, win_type: SmoothingKernel, @@ -2036,7 +2041,9 @@ def _calculate_coeffs( f"Invalid window type value {win_type!r}: " f"supported values are {', '.join(SMOOTHING_KERNELS)}" ) - return coeffs / np.sum(coeffs) + normalized_coeffs = coeffs / np.sum(coeffs) + normalized_coeffs.setflags(write=False) + return normalized_coeffs def zero_phase_filter( @@ -2761,7 +2768,7 @@ _SCIENTIFIC_THRESHOLD_HIGH = 1e12 _SCIENTIFIC_THRESHOLD_LOW = 1e-6 -@lru_cache(maxsize=128) +@lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def format_number(value: int | float, significant_digits: int = 5) -> str: if not isinstance(value, (int, float, np.integer, np.floating)): return str(value) @@ -2953,7 +2960,7 @@ def format_dict( return f"{{{joined}}}" if style == "dict" else joined -@lru_cache(maxsize=128) +@lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def calculate_min_extrema( length: int, fit_live_predictions_candles: int, min_extrema: int = 2 ) -> int: @@ -3078,7 +3085,7 @@ def calculate_zero_lag(series: pd.Series, period: int) -> pd.Series: return 2 * series - series.shift(int(lag)) -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_ma_fn( mamode: str, ) -> Callable[ @@ -3102,7 +3109,7 @@ def get_ma_fn( return mamodes.get(mamode, mamodes["sma"]) -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_zl_ma_fn( mamode: str, ) -> Callable[ @@ -3219,7 +3226,7 @@ def smma(series: pd.Series, period: int, zero_lag=False, offset=0) -> pd.Series: return smma -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_price_fn(pricemode: str) -> Callable[[pd.DataFrame], pd.Series]: pricemodes = { "average": ta.AVGPRICE, @@ -5320,7 +5327,7 @@ def get_optuna_study_model_parameters( raise ValueError(enum_error_message("regressor", regressor, REGRESSORS)) -@lru_cache(maxsize=128) +@lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def largest_divisor_to_step(integer: int, step: int) -> int | None: if not isinstance(integer, int) or integer <= 0: raise ValueError( @@ -5366,7 +5373,7 @@ def soft_extremum(series: pd.Series, alpha: float) -> float: return nan_average(values, weights=shifted_exponentials) -@lru_cache(maxsize=8) +@lru_cache(maxsize=_CACHE_MAXSIZE_SMALL) def get_min_max_label_period_candles( fit_live_predictions_candles: int, candles_step: int, @@ -5429,7 +5436,7 @@ def _validate_step_args(value: float | int, step: int) -> None: raise ValueError(f"Invalid step value {step!r}: must be a positive integer") -@lru_cache(maxsize=128) +@lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def round_to_step(value: float | int, step: int) -> int: """ Round a value to the nearest multiple of a given step. @@ -5452,7 +5459,7 @@ def round_to_step(value: float | int, step: int) -> int: return int(round(float(value) / step) * step) -@lru_cache(maxsize=128) +@lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def ceil_to_step(value: float | int, step: int) -> int: _validate_step_args(value, step) if isinstance(value, (int, np.integer)): @@ -5462,7 +5469,7 @@ def ceil_to_step(value: float | int, step: int) -> int: return int(math.ceil(float(value) / step) * step) -@lru_cache(maxsize=128) +@lru_cache(maxsize=_CACHE_MAXSIZE_LARGE) def floor_to_step(value: float | int, step: int) -> int: _validate_step_args(value, step) if isinstance(value, (int, np.integer)):