from collections import defaultdict, deque from typing import Protocol from app.bars.models import Bar, Timeframe from app.config import DEFAULT_MAX_BARS_PER_TF class BarStore(Protocol): def put(self, bar: Bar) -> None: ... def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]: ... class InMemoryBarStore: def __init__(self, max_bars_per_tf: int = DEFAULT_MAX_BARS_PER_TF): self._bars: dict[Timeframe, deque[Bar]] = defaultdict( lambda: deque(maxlen=max_bars_per_tf) ) # How far back from the tail a late bar may still land. A closed minute bar # arrives a beat after the ticks that opened the next minute, so it is # rarely more than a bucket or two behind. LATE_BAR_LOOKBACK = 8 def put(self, bar: Bar) -> None: """Store a bar, replacing the bucket it belongs to. Matching only the tail was enough while one closed bar arrived per minute. With tick-built bars a minute's authoritative bar shows up *after* ticks have already opened the next one, so the exchange's own figures were being dropped and the approximation left in place forever. """ bars = self._bars[bar.tf] if not bars or bar.t > bars[-1].t: bars.append(bar) return for index in range(len(bars) - 1, max(-1, len(bars) - self.LATE_BAR_LOOKBACK - 1), -1): if bars[index].t == bar.t: # A provisional bar must never overwrite a settled one: ticks # keep arriving for a minute the exchange has already closed. if bars[index].closed and not bar.closed: return bars[index] = bar return if bars[index].t < bar.t: # Buckets are ordered, so nothing further back can match. return def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]: bars = list(self._bars[tf]) return bars[-limit:] if limit is not None else bars def counts(self) -> dict[str, int]: return {tf.value: len(self._bars[tf]) for tf in Timeframe}