Two faults, both introduced by the tick feed, both visible as a huge bar that flattened the price scale. A LEVEL_ONE_FUTURES update arrived carrying LAST_PRICE: 0. The parser rejected None, but 0 is not None, so a minute opened at zero — o=0.0 h=7777.25 l=0.0 — and provisional_higher carried that low into 5m, 15m, 30m, 1h and the daily bar. Non-positive prices are treated as absent now, so the last real price carries forward and the update still counts as the trade it is. Separately, store.put replaced a bar only when it matched the tail. That was sufficient while one closed bar arrived per minute, but ticks open the next minute before CHART_FUTURES delivers the previous one, so the exchange's own bar stopped matching the tail and was silently dropped — leaving the tick-built approximation, with its partial volume, in place permanently. put now searches back a bounded number of buckets for the one it belongs to, and refuses to let a provisional bar overwrite a settled one. Tests cover all three invariants: a zero price parses as a trade with no price, a late closed bar replaces its bucket and keeps the exchange's volume, and a tick cannot overwrite a settled bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
53 lines
2 KiB
Python
53 lines
2 KiB
Python
from collections import defaultdict, deque
|
|
from typing import Protocol
|
|
|
|
from app.bars.models import Bar, Timeframe
|
|
|
|
|
|
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 = 5000):
|
|
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}
|