Ratio-corrected IEX volume: free fix for the 2-5% live undercount (estimator + audit)

With the nightly SIP backfill making all BASELINES consolidated-scale, the still-IEX live
ingest would make Monday's RVOL read iex_intraday/sip_baseline ~= 25-50x too LOW and choke
the scanner. Free-key SIP is only 15-min DELAYED, so each morning the true per-name capture
ratio is directly measurable:

- research/ingestion/volume_ratio.py: compute_ratios fetches BOTH feeds for the identical
  embargo-capped window (never vs our DB bars — those are scaled; that would double-correct);
  guardrails (min samples, clamp [1,300]); persist_ratios records per-day ratios.
- ingest_live_minute_bars(volume_scale=...): scales bar volumes per symbol at ingest, marks
  source_provider=alpaca_iex_scaled. Missing symbol -> 1.0 (conservative: reads LOW, blocks).
  Constant per-symbol scale leaves cumulative VWAP unchanged (cancels in the weighted mean).
- run_live_paper: "vratio" step every 300s (VOLUME_RATIO_ENABLED/INTERVAL) merges fresh
  ratios (a name gone thin keeps its last good ratio); step_ingest applies them.
- premarket_eval nightly audit: snapshots the day's scaled ESTIMATES before the SIP truth
  overwrites, then logs median/worst error to data/volume_estimator_audit.tsv — the
  estimator's accuracy is self-measured daily, which is exactly the evidence that decides
  whether the $99/mo real-time SIP plan is ever needed.

Live check (2026-07-02 full session): ratios are hugely name-dependent — DSY x300(clamped),
CETX x286, SOFI x45, SNDQ x42, NOK x17 — validating per-name daily measurement over any
global constant. 3 pure unit tests (ratio math + thin-sample skip; ingest scaling + provider
marking; no-ratio unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-07-02 22:58:01 -04:00
1 parent f8f85b3963
commit 1210ac8ef6
5 files changed
+193 -3

No files matched your search

+25 -1
View File
@@ -366,6 +366,10 @@ def build_runtime(args: argparse.Namespace) -> dict:
return {
"symbols": symbols,
"symbols_source": symbols_source,
# per-symbol IEX->consolidated volume correction (research.ingestion.volume_ratio):
# measured each morning from the delayed free SIP feed, applied at ingest so live
# RVOL/liquidity signals are consolidated-scale like their (nightly-SIP) baselines
"vol_ratios": {},
"client": client,
"has_keys": has_keys,
"store": store,
@@ -524,6 +528,23 @@ def main(argv: list[str] | None = None) -> int:
# lag can't flag the whole book (pure + unit-tested in runtime.halt_guard)
return is_halt_gap(_symbol_bar_age(symbol), _freshest_bar_age(), gap_s)
def step_vratio():
"""Measure today's per-name IEX->SIP volume capture ratio from the delayed
(free) SIP feed and keep it for ingest scaling. Merge, never clear: a name
whose sample got too thin keeps its last good ratio for the session."""
if not client:
return "skipped (no keys)"
from research.ingestion.volume_ratio import compute_ratios, persist_ratios
fresh = compute_ratios(client, list(symbols))
if fresh:
rt["vol_ratios"].update(fresh)
persist_ratios(rt["research_con"], fresh)
known = rt["vol_ratios"]
if not known:
return "no ratios yet"
med = sorted(known.values())[len(known) // 2]
return f"{len(fresh)} fresh / {len(known)} known, median x{med:.0f}"
def step_ingest():
if not client:
return "skipped (no keys)"
@@ -532,7 +553,8 @@ def main(argv: list[str] | None = None) -> int:
_ingest_first["v"] = False
else:
lb = refresh_lb
res = ingest_live_minute_bars(rt["research_con"], client, symbols, lookback_minutes=lb)
res = ingest_live_minute_bars(rt["research_con"], client, symbols, lookback_minutes=lb,
volume_scale=rt["vol_ratios"])
# Freshness watchdog: 45% of passes historically returned 0 rows with NO
# error — indistinguishable from a feed outage. Make staleness VISIBLE
# (emit + display); hard-block arming only if explicitly enabled, so a
@@ -1050,6 +1072,8 @@ def main(argv: list[str] | None = None) -> int:
scheduler.add("discover", step_discover,
float(os.environ.get("DISCOVER_INTERVAL_SECONDS", "300")),
enabled=_flag("DISCOVER_ENABLED", "1"))
scheduler.add("vratio", step_vratio, float(os.environ.get("VOLUME_RATIO_INTERVAL_SECONDS", "300")),
enabled=_flag("VOLUME_RATIO_ENABLED", "1"))
scheduler.add("ingest", step_ingest, float(os.environ.get("LIVE_BARS_INTERVAL_SECONDS", "60")),
enabled=_flag("LIVE_BARS_ENABLED", "1"))
scheduler.add("watch", step_watch, float(os.environ.get("WATCHER_INTERVAL_SECONDS", "30")),