From: Jérôme Benoit Date: Tue, 28 Jul 2026 13:56:27 +0000 (+0200) Subject: fix(quickadapter): preserve calendar PnL continuity (#142) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=864ed7e69e62a5cd788a1a9d3868ccdef86916d0;p=freqai-strategies.git fix(quickadapter): preserve calendar PnL continuity (#142) * fix(quickadapter): preserve calendar PnL continuity * fix(quickadapter): drop epoch-anchored phase guard in PnL continuity The (month-1) % n phase guard on the MS path assumed a fixed January epoch, but pandas resample anchors calendar bins on the data origin, not a fixed epoch, so it wrongly reset valid non-January-anchored multi-month series (e.g. a Feb/May/Aug 3M grid). is_on_offset already rejects a non-boundary stored date and stored + offset is the exact next candle for any anchor phase, so the membership test alone is correct and symmetric for MS and YS (resolving the MS/YS asymmetry). Also complete the _TradeHistory key-mirror comment with the legacy timeframe-minutes key. * style(quickadapter): tighten PnL continuity phase-guard rationale comment --- diff --git a/quickadapter/user_data/strategies/QuickAdapterV3.py b/quickadapter/user_data/strategies/QuickAdapterV3.py index 1182fda..c3f9813 100644 --- a/quickadapter/user_data/strategies/QuickAdapterV3.py +++ b/quickadapter/user_data/strategies/QuickAdapterV3.py @@ -18,9 +18,14 @@ from typing import ( import numpy as np import pandas_ta as pta +from pandas.tseries.frequencies import to_offset import talib.abstract as ta from freqtrade.enums import TRADE_MODES -from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date +from freqtrade.exchange import ( + timeframe_to_minutes, + timeframe_to_prev_date, + timeframe_to_resample_freq, +) from freqtrade.persistence import Trade from freqtrade.strategy import AnnotationType, stoploss_from_absolute from freqtrade.strategy.interface import IStrategy @@ -103,11 +108,12 @@ CandleThresholdCacheKey = tuple[str, DfSignature, str, int, float, float] class _TradeHistory(TypedDict): # Key names must mirror the _UNREALIZED_PNL_CANDLE_DATE_KEY / - # _UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY constants (a TypedDict field - # cannot reference a constant). + # _UNREALIZED_PNL_TIMEFRAME_KEY / _LEGACY_UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY + # constants (a TypedDict field cannot reference a constant). unrealized_pnl: list[float] take_profit_price: list[float | tuple[int, float]] unrealized_pnl_candle_date: NotRequired[str] + unrealized_pnl_timeframe: NotRequired[str] unrealized_pnl_timeframe_minutes: NotRequired[int] @@ -205,7 +211,8 @@ class QuickAdapterV3(IStrategy): _TAKE_PROFIT_ORDER_TAG_PREFIX: Final[str] = "take_profit_" _UNREALIZED_PNL_CANDLE_DATE_KEY: Final[str] = "unrealized_pnl_candle_date" - _UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY: Final[str] = ( + _UNREALIZED_PNL_TIMEFRAME_KEY: Final[str] = "unrealized_pnl_timeframe" + _LEGACY_UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY: Final[str] = ( "unrealized_pnl_timeframe_minutes" ) @@ -1545,9 +1552,8 @@ class QuickAdapterV3(IStrategy): history[QuickAdapterV3._UNREALIZED_PNL_CANDLE_DATE_KEY] = ( candle_date.isoformat() ) - history[QuickAdapterV3._UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY] = ( - self.timeframe_minutes - ) + history[QuickAdapterV3._UNREALIZED_PNL_TIMEFRAME_KEY] = self.timeframe + history.pop(QuickAdapterV3._LEGACY_UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY, None) trade.set_custom_data("history", history) return pnl_history @@ -1555,21 +1561,29 @@ class QuickAdapterV3(IStrategy): def _is_pnl_history_discontinuous( stored_candle_date_isoformat: Optional[str], candle_date: datetime.datetime, - timeframe_minutes: int, + timeframe: str, ) -> bool: if not QuickAdapterV3.is_isoformat(stored_candle_date_isoformat): - return False + return True stored_candle_date = datetime.datetime.fromisoformat( stored_candle_date_isoformat ) - elapsed_minutes = (candle_date - stored_candle_date).total_seconds() / 60.0 - # get_pnl_momentum() differences the series assuming one timeframe - # between consecutive samples; any non-adjacent step (forward gap or - # backward/non-monotonic date) breaks that spacing and forces a reset. - # elapsed == 0 is a same-candle re-evaluation, not a discontinuity. - return not math.isclose( - elapsed_minutes, timeframe_minutes, rel_tol=1e-9, abs_tol=1e-9 - ) and not math.isclose(elapsed_minutes, 0.0, abs_tol=1e-9) + resample_frequency = timeframe_to_resample_freq(timeframe) + if resample_frequency.endswith(("MS", "YS")): + calendar_offset = to_offset(resample_frequency) + # No multiplier phase check: pandas anchors the resample grid on the data + # origin, not a fixed epoch, so stored + offset is the next candle for any phase. + if not calendar_offset.is_on_offset(stored_candle_date): + return True + expected_candle_date = stored_candle_date + calendar_offset + else: + expected_candle_date = stored_candle_date + datetime.timedelta( + minutes=timeframe_to_minutes(timeframe) + ) + # The PnL momentum horizon assumes one timeframe between consecutive + # samples; any non-adjacent step (forward gap or backward/non-monotonic + # date) breaks that spacing and forces a reset. + return candle_date not in (stored_candle_date, expected_candle_date) def safe_append_trade_unrealized_pnl( self, trade: Trade, pnl: float, candle_date: datetime.datetime @@ -1578,19 +1592,19 @@ class QuickAdapterV3(IStrategy): trade_unrealized_pnl_history = history.get("unrealized_pnl", []) if trade_unrealized_pnl_history and ( QuickAdapterV3._UNREALIZED_PNL_CANDLE_DATE_KEY not in history - or history.get(QuickAdapterV3._UNREALIZED_PNL_TIMEFRAME_MINUTES_KEY) - != self.timeframe_minutes + or history.get(QuickAdapterV3._UNREALIZED_PNL_TIMEFRAME_KEY) + != self.timeframe or QuickAdapterV3._is_pnl_history_discontinuous( history.get(QuickAdapterV3._UNREALIZED_PNL_CANDLE_DATE_KEY), candle_date, - self.timeframe_minutes, + self.timeframe, ) ): trade_unrealized_pnl_history = [] history["unrealized_pnl"] = trade_unrealized_pnl_history history.pop(QuickAdapterV3._UNREALIZED_PNL_CANDLE_DATE_KEY, None) trade.set_custom_data("history", history) - if ( + if not trade_unrealized_pnl_history or ( history.get(QuickAdapterV3._UNREALIZED_PNL_CANDLE_DATE_KEY) != candle_date.isoformat() ):