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:
1 parent
f8f85b3963
commit
1210ac8ef6
5 files changed
+193
-3
No files matched your search
+25
-1
@@ -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")),
|
||||
|
||||
Reference in new issue
Block a user