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
139 lines
5.6 KiB
Python
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)
|