Files
momentum-system/tests/unit/test_volume_ratio.py
T
momentum devandClaude Opus 5.5 efde7c721d fix: one invalid symbol can no longer stop all live bar ingestion
At 09:34 ET on 2026-09-28 Alpaca's movers screener returned 'TSX:STC', a
Toronto listing. The discovery filter only dropped symbols containing a dot,
so it joined the universe. The live ingest fetches bars for every symbol in
one batched request, and Alpaca rejects the WHOLE batch with 400 "invalid
symbol". From 09:35 every ingest pass stored 0 rows and the loop ran on
stale bars ("INGEST STALE: freshest bar 458s old").

- Discovery keeps only plain 1-5 letter US tickers from both screeners.
- The ingest never sends a non-US or invalid symbol. If Alpaca still rejects
  one ("invalid symbol: X"), it drops X and retries the rest (up to 3 times),
  so one bad name costs one symbol, not the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
2026-09-28 08:48:51 -05:00

139 lines
5.6 KiB
Python

"""Ratio-corrected IEX volume: the free fix for the 2-5% IEX undercount. Pure tests —
a stub client provides both feeds, a stub con captures the upserted rows."""
from research.ingestion.market_data import ingest_live_minute_bars
from research.ingestion.volume_ratio import compute_ratios
def _bars(n, vol, t0="2026-07-06T13:3{i}:00Z", px=5.0):
return [{"t": f"2026-07-06T13:3{i}:00Z", "o": px, "h": px + .05, "l": px - .05,
"c": px, "v": vol, "vw": px} for i in range(n)]
class _Client:
"""Serves distinct volumes per feed (iex thin, sip fat) + per symbol."""
def __init__(self):
self.by_feed = {
"iex": {"GOOD": _bars(5, 100), "DUST": _bars(5, 10)},
"sip": {"GOOD": _bars(5, 2500), "DUST": _bars(5, 200)},
}
def get_minute_bars(self, symbols, start_iso, end_iso=None, feed=None, limit=10_000):
src = self.by_feed.get(feed or "iex", {})
return {s: src.get(s, []) for s in symbols}
class _Con:
"""Captures executemany rows; enough surface for upsert_minute_bars."""
def __init__(self):
self.rows = []
def executemany(self, sql, rows):
self.rows.extend(rows)
def execute(self, *a, **k):
return self
def test_compute_ratios_measures_and_skips_thin(monkeypatch):
# Freeze the clock inside the stub's bar-date window. compute_ratios bails
# (`end <= start`) when UTC now-16min is before today's 08:00Z — true nightly
# from ~00:00-04:16 ET — so with hardcoded 2026-07-06 bars this test flaked at
# the date rollover regardless of the stub data. Pin a mid-session UTC clock.
import research.ingestion.volume_ratio as vr
from datetime import date as _date, datetime as _dt, timezone
class _FrozenDT:
@staticmethod
def now(tz=None):
return _dt(2026, 7, 6, 18, 0, 0, tzinfo=tz or timezone.utc)
class _FrozenDate:
@staticmethod
def today():
return _date(2026, 7, 6)
monkeypatch.setattr(vr, "datetime", _FrozenDT)
monkeypatch.setattr(vr, "date", _FrozenDate)
r = compute_ratios(_Client(), ["GOOD", "DUST", "MISSING"])
assert r["GOOD"] == 25.0 # 12500 sip / 500 iex
assert "DUST" not in r # sip sample below floor -> untrusted
assert "MISSING" not in r
def test_ingest_scales_volume_and_marks_provider():
con = _Con()
ingest_live_minute_bars(con, _Client(), ["GOOD"], volume_scale={"GOOD": 25.0})
flat = [v for row in con.rows for v in row]
assert 2500 in flat and 100 not in flat # 100-share bars scaled to 2500
assert "alpaca_iex_scaled" in flat
def test_ingest_without_ratio_is_unchanged():
con = _Con()
ingest_live_minute_bars(con, _Client(), ["GOOD"]) # no scale dict
flat = [v for row in con.rows for v in row]
assert 100 in flat and 2500 not in flat
assert "alpaca_iex" in flat and "alpaca_iex_scaled" not in flat
class _Settings:
feed = "sip"
class _SipClient(_Client):
settings = _Settings()
def get_minute_bars(self, symbols, start_iso, end_iso=None, feed=None, limit=10_000):
# live SIP feed: already-consolidated volumes
return {s: self.by_feed["sip"].get(s, []) for s in symbols}
def test_sip_feed_refuses_double_scaling():
# once the paid plan makes the live feed SIP, applying the x25 ratios again would
# over-count 25x — the ingest must refuse to scale a non-IEX feed.
con = _Con()
ingest_live_minute_bars(con, _SipClient(), ["GOOD"], volume_scale={"GOOD": 25.0})
flat = [v for row in con.rows for v in row]
assert 2500 in flat and 62500 not in flat # sip volume kept, NOT re-scaled
assert "alpaca_sip" in flat and "alpaca_iex_scaled" not in flat
def test_tradier_wrapper_reads_the_wrapped_feed():
# the live Tradier stack wraps the Alpaca data client; the wrapper's own
# settings have no `feed`, which used to read as "iex" and re-scale real SIP
# volume by the IEX ratios (the 80-160x live over-count, 2026-07-31..09-25)
from tradier_broker.client import TradierClient
from tradier_broker.settings import TradierSettings
wrapped = TradierClient(TradierSettings(access_token="t", account_id="a"),
data_client=_SipClient())
con = _Con()
ingest_live_minute_bars(con, wrapped, ["GOOD"], volume_scale={"GOOD": 25.0})
flat = [v for row in con.rows for v in row]
assert 2500 in flat and 62500 not in flat
assert "alpaca_sip" in flat and "alpaca_iex_scaled" not in flat
class _RejectingClient(_SipClient):
"""Alpaca 400s the WHOLE batched request when any symbol is invalid."""
def __init__(self):
super().__init__()
self.calls = []
def get_minute_bars(self, symbols, start_iso, end_iso=None, feed=None, limit=10_000):
self.calls.append(list(symbols))
if "ZZZZ" in symbols:
raise RuntimeError('Alpaca API error 400: {"message":"invalid symbol: ZZZZ"}')
return super().get_minute_bars(symbols, start_iso, end_iso, feed, limit)
def test_one_bad_symbol_cannot_blind_the_whole_ingest():
# 2026-09-28 09:35 ET: 'TSX:STC' from the movers screener 400'd every batched
# bars request and live ingestion stopped for the session.
client = _RejectingClient()
con = _Con()
res = ingest_live_minute_bars(con, client, ["GOOD", "TSX:STC", "ZZZZ"])
assert "TSX:STC" not in client.calls[0] # never sent at all
assert client.calls[-1] == ["GOOD"] # rejected one dropped, retried
assert res.minute_rows > 0
assert any("TSX:STC" in e for e in res.errors) and any("ZZZZ" in e for e in res.errors)