1111import pandas as pd
1212
1313from app .data_sources import DataSourceFactory
14- from app .data_sources .errors import MarketDataUnavailableError
14+ from app .data_sources .errors import (
15+ MarketDataUnavailableError ,
16+ classify_market_data_failure ,
17+ )
1518from app .services .backtest_cache import KlineCache
1619from app .utils .logger import get_logger
1720
@@ -30,6 +33,8 @@ class _SharedFrameEntry:
3033_shared_frame_locks : dict [str , threading .RLock ] = {}
3134_shared_frames_lock = threading .RLock ()
3235_SHARED_FRAME_CACHE_MAX_SIZE = 256
36+ _LIVE_CACHE_GRACE_BARS = 2
37+ _INCOMPLETE_WARMUP_RETRIES = 1
3338
3439TIMEFRAME_SECONDS = {
3540 "1m" : 60 ,
@@ -58,6 +63,25 @@ def _normalize_utc_datetime(value: datetime) -> datetime:
5863 return value .astimezone (timezone .utc )
5964
6065
66+ def _last_completed_bar_open (
67+ timeframe_seconds : int ,
68+ * ,
69+ now : Optional [datetime ] = None ,
70+ ) -> pd .Timestamp :
71+ """Return the UTC open time of the most recently completed candle.
72+
73+ Using a bar-aligned cutoff avoids treating the seconds inside the current
74+ minute as an uncovered cache tail and refetching the same 1m candle on
75+ every runtime heartbeat.
76+ """
77+ seconds = max (1 , int (timeframe_seconds or 1 ))
78+ current = _normalize_utc_datetime (now or datetime .now (timezone .utc ))
79+ completed_open = ((int (current .timestamp ()) // seconds ) - 1 ) * seconds
80+ return pd .Timestamp (
81+ datetime .fromtimestamp (completed_open , tz = timezone .utc )
82+ ).tz_localize (None )
83+
84+
6185def _covers_crypto_window (
6286 frame : pd .DataFrame ,
6387 requested_start : pd .Timestamp ,
@@ -110,8 +134,8 @@ def _load_strategy_frame_uncached(
110134 ))
111135 requested_start = pd .Timestamp (start_utc ).tz_localize (None )
112136 requested_end = pd .Timestamp (end_utc ).tz_localize (None )
113- closed_bar_cutoff = datetime . now ( timezone . utc ). replace ( tzinfo = None ) - timedelta ( seconds = timeframe_seconds )
114- coverage_end = min (requested_end , pd . Timestamp ( closed_bar_cutoff ) )
137+ closed_bar_cutoff = _last_completed_bar_open ( timeframe_seconds )
138+ coverage_end = min (requested_end , closed_bar_cutoff )
115139 cached = _cache .get (cache_key )
116140 if cached is not None and not cached .empty :
117141 if str (market or "" ).strip ().lower () != "crypto" or _covers_crypto_window (
@@ -187,7 +211,7 @@ def _load_strategy_frame_uncached(
187211 subset = ["open" , "high" , "low" , "close" ]
188212 )
189213 if requested_end >= closed_bar_cutoff :
190- frame = frame [frame .index <= pd . Timestamp ( closed_bar_cutoff ) ]
214+ frame = frame [frame .index <= closed_bar_cutoff ]
191215 if (
192216 str (market or "" ).strip ().lower () == "crypto"
193217 and not _covers_crypto_window (frame , requested_start , coverage_end , timeframe_seconds )
@@ -202,7 +226,15 @@ def _load_strategy_frame_uncached(
202226 frame .index .min (),
203227 frame .index .max (),
204228 )
205- return pd .DataFrame ()
229+ raise MarketDataUnavailableError (
230+ classify_market_data_failure (
231+ "Incomplete K-line coverage after strategy window normalization" ,
232+ exchange_id = exchange_id or "" ,
233+ market_type = market_type or "" ,
234+ symbol = symbol ,
235+ timeframe = timeframe ,
236+ )
237+ )
206238 if not frame .empty :
207239 _cache .put (cache_key , frame , timeframe )
208240 return frame .copy ()
@@ -253,6 +285,38 @@ def _evict_shared_frame_if_needed() -> None:
253285 _shared_frames .pop (oldest_key , None )
254286
255287
288+ def _is_live_request (
289+ requested_end : pd .Timestamp ,
290+ closed_cutoff : pd .Timestamp ,
291+ timeframe_seconds : int ,
292+ ) -> bool :
293+ tolerance = pd .Timedelta (seconds = max (1 , timeframe_seconds ) * 2 )
294+ return bool (requested_end >= closed_cutoff - tolerance )
295+
296+
297+ def _cached_crypto_frame_is_usable (
298+ frame : pd .DataFrame ,
299+ requested_start : pd .Timestamp ,
300+ coverage_end : pd .Timestamp ,
301+ timeframe_seconds : int ,
302+ * ,
303+ live_request : bool ,
304+ ) -> bool :
305+ if not _covers_crypto_window (
306+ frame ,
307+ requested_start ,
308+ coverage_end ,
309+ timeframe_seconds ,
310+ ):
311+ return False
312+ if not live_request :
313+ return True
314+ max_lag = pd .Timedelta (
315+ seconds = max (1 , timeframe_seconds ) * _LIVE_CACHE_GRACE_BARS
316+ )
317+ return bool (frame .index .max () >= coverage_end - max_lag )
318+
319+
256320def clear_shared_strategy_frame_cache () -> None :
257321 """Clear process-local candle state. Intended for tests and controlled reloads."""
258322 with _shared_frames_lock :
@@ -283,11 +347,14 @@ def load_strategy_frame(
283347 timeframe_seconds = TIMEFRAME_SECONDS .get (normalized_timeframe , 86400 )
284348 requested_start = pd .Timestamp (start_utc ).tz_localize (None )
285349 requested_end = pd .Timestamp (end_utc ).tz_localize (None )
286- closed_cutoff = pd .Timestamp (
287- datetime .now (timezone .utc ).replace (tzinfo = None )
288- - timedelta (seconds = timeframe_seconds )
289- )
350+ closed_cutoff = _last_completed_bar_open (timeframe_seconds )
290351 coverage_end = min (requested_end , closed_cutoff )
352+ live_request = _is_live_request (
353+ requested_end ,
354+ closed_cutoff ,
355+ timeframe_seconds ,
356+ )
357+ crypto_market = str (market or "" ).strip ().lower () == "crypto"
291358 key = _shared_frame_key (market , symbol , timeframe , market_type , exchange_id )
292359
293360 with _lock_for_shared_frame (key ):
@@ -316,39 +383,98 @@ def load_strategy_frame(
316383 ))
317384
318385 merged = entry .frame .copy () if entry is not None else pd .DataFrame ()
319- successful_windows : list [ tuple [ pd . Timestamp , pd . Timestamp ]] = []
386+ last_failure : Optional [ MarketDataUnavailableError ] = None
320387 for window_start , window_end in fetch_windows :
321- incoming = _load_strategy_frame_uncached (
322- market ,
323- symbol ,
324- timeframe ,
325- window_start .to_pydatetime ().replace (tzinfo = timezone .utc ),
326- window_end .to_pydatetime ().replace (tzinfo = timezone .utc ),
327- market_type = market_type ,
328- exchange_id = exchange_id ,
388+ incoming = pd .DataFrame ()
389+ attempts = (
390+ 1 + _INCOMPLETE_WARMUP_RETRIES
391+ if normalized_timeframe == "1m" and entry is None
392+ else 1
329393 )
394+ for attempt in range (attempts ):
395+ try :
396+ incoming = _load_strategy_frame_uncached (
397+ market ,
398+ symbol ,
399+ timeframe ,
400+ window_start .to_pydatetime ().replace (tzinfo = timezone .utc ),
401+ window_end .to_pydatetime ().replace (tzinfo = timezone .utc ),
402+ market_type = market_type ,
403+ exchange_id = exchange_id ,
404+ )
405+ last_failure = None
406+ break
407+ except MarketDataUnavailableError as exc :
408+ last_failure = exc
409+ should_retry = bool (
410+ exc .failure .code == "incomplete_market_data"
411+ and attempt + 1 < attempts
412+ )
413+ if should_retry :
414+ logger .warning (
415+ "Retrying incomplete %s %s warmup (%s/%s)" ,
416+ symbol ,
417+ timeframe ,
418+ attempt + 2 ,
419+ attempts ,
420+ )
421+ continue
422+ break
330423 if incoming is not None and not incoming .empty :
331424 merged = _merge_frames (merged , incoming )
332- successful_windows .append ((window_start , min (window_end , coverage_end )))
333425
334426 if merged .empty :
427+ if last_failure is not None :
428+ raise last_failure
335429 return pd .DataFrame ()
336430
337- new_start = entry .coverage_start if entry is not None else requested_start
338- new_end = entry .coverage_end if entry is not None else coverage_end
339- for window_start , window_end in successful_windows :
340- new_start = min (new_start , window_start )
341- new_end = max (new_end , window_end )
431+ # Keep the cache bounded around the active warmup window. If a future
432+ # caller requests older history, the missing prefix is fetched again.
433+ overlap = pd .Timedelta (seconds = timeframe_seconds * 2 )
434+ merged = merged [merged .index >= requested_start - overlap ]
435+ actual_start = merged .index .min ()
436+ actual_end = merged .index .max ()
342437 _shared_frames [key ] = _SharedFrameEntry (
343438 frame = merged ,
344- coverage_start = new_start ,
345- coverage_end = new_end ,
439+ coverage_start = actual_start ,
440+ coverage_end = actual_end ,
346441 )
347442 _evict_shared_frame_if_needed ()
348- return merged [
443+ result = merged [
349444 (merged .index >= requested_start )
350445 & (merged .index <= coverage_end )
351446 ].copy ()
447+ if crypto_market and not _cached_crypto_frame_is_usable (
448+ result ,
449+ requested_start ,
450+ coverage_end ,
451+ timeframe_seconds ,
452+ live_request = live_request ,
453+ ):
454+ logger .warning (
455+ "Refused stale/incomplete cached crypto frame for %s %s: "
456+ "requested=%s~%s, actual=%s~%s" ,
457+ symbol ,
458+ timeframe ,
459+ requested_start ,
460+ coverage_end ,
461+ result .index .min () if not result .empty else "empty" ,
462+ result .index .max () if not result .empty else "empty" ,
463+ )
464+ if last_failure is not None :
465+ raise last_failure
466+ return pd .DataFrame ()
467+ if last_failure is not None :
468+ logger .warning (
469+ "Using recent cached %s %s candles after transient %s failure; "
470+ "latest=%s, required=%s" ,
471+ symbol ,
472+ timeframe ,
473+ last_failure .failure .code ,
474+ result .index .max (),
475+ coverage_end ,
476+ )
477+ return result
352478
353479
354480__all__ = [
0 commit comments