Files
momentum-system/research/ingestion/market_data.py
momentum devandClaude Opus 4.8 c146341bb4 Harden screener auth-vs-empty + boot fallback + news DB-error (audit HIGH#1/MEDIUM)
- discover_active_symbols: distinguish AUTH/entitlement failures (HTTP 401/403,
  logged as ERROR, 'not no-movers') from transient/empty in the screener except.
- run_live_paper: the boot watchlist event records an explicit error when it
  falls back (screener empty at boot) instead of looking like 'market quiet'.
- discovery.news_candidates: log DB faults instead of swallowing to [].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:03:48 -04:00

266 lines
9.4 KiB
Python

"""Market data ingestion: Alpaca bars -> research market.duckdb.
This module fixes the root cause of "live paper trading never worked":
the watcher reads *minute* bars from the research database, but the old
ingestion only ever wrote *daily* bars for a handful of mega-caps and
hardcoded ``is_regular_hours = TRUE`` on every row. Here we:
* ingest 1-minute bars (IEX feed by default) for a configurable symbol list,
* derive ``is_premarket`` / ``is_regular_hours`` / ``is_afterhours`` from the
bar timestamp in US/Eastern (regular session 09:30-16:00),
* backfill daily bars so the watcher can compute gap %% and relative volume.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
REGULAR_OPEN = time(9, 30)
REGULAR_CLOSE = time(16, 0)
PREMARKET_OPEN = time(4, 0)
AFTERHOURS_CLOSE = time(20, 0)
logger = logging.getLogger(__name__)
def classify_session(ts_utc: datetime) -> tuple[date, bool, bool, bool]:
"""Return (session_date, is_premarket, is_regular_hours, is_afterhours)."""
if ts_utc.tzinfo is None:
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
local = ts_utc.astimezone(EASTERN)
t = local.time()
is_pre = PREMARKET_OPEN <= t < REGULAR_OPEN
is_reg = REGULAR_OPEN <= t < REGULAR_CLOSE
is_aft = REGULAR_CLOSE <= t < AFTERHOURS_CLOSE
return local.date(), is_pre, is_reg, is_aft
def parse_alpaca_timestamp(raw: str) -> datetime:
"""Alpaca returns RFC3339 timestamps like '2026-06-11T13:30:00Z'."""
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
@dataclass
class IngestionResult:
minute_rows: int = 0
daily_rows: int = 0
symbols: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
# per-symbol rows written this pass (incl. 0 for requested-but-empty symbols),
# so "are bars collected?" is answerable per symbol, not just in aggregate.
per_symbol: dict = field(default_factory=dict)
def upsert_minute_bars(
con,
symbol: str,
bars: list[dict],
source_provider: str = "alpaca_iex",
) -> int:
"""Insert/replace Alpaca minute bars for one symbol. Returns row count."""
rows = []
for bar in bars:
ts = parse_alpaca_timestamp(bar["t"])
session_date, is_pre, is_reg, is_aft = classify_session(ts)
rows.append(
(
symbol,
ts.astimezone(timezone.utc).replace(tzinfo=None),
session_date,
is_pre,
is_reg,
is_aft,
float(bar["o"]),
float(bar["h"]),
float(bar["l"]),
float(bar["c"]),
int(bar["v"]),
float(bar.get("vw") or bar["c"]),
None, # spread_pct (not available from bars endpoint)
False, # halt_status
source_provider,
1.0, # quality_score: real exchange data
)
)
if not rows:
return 0
con.executemany(
"""
INSERT INTO minute_bars (
symbol, timestamp, session_date,
is_premarket, is_regular_hours, is_afterhours,
open, high, low, close, volume, vwap,
spread_pct, halt_status, source_provider, quality_score
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (symbol, timestamp) DO UPDATE SET
session_date = EXCLUDED.session_date,
is_premarket = EXCLUDED.is_premarket,
is_regular_hours = EXCLUDED.is_regular_hours,
is_afterhours = EXCLUDED.is_afterhours,
open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low,
close = EXCLUDED.close, volume = EXCLUDED.volume, vwap = EXCLUDED.vwap,
spread_pct = EXCLUDED.spread_pct, halt_status = EXCLUDED.halt_status,
source_provider = EXCLUDED.source_provider,
quality_score = EXCLUDED.quality_score
""",
rows,
)
return len(rows)
def upsert_daily_bars(con, symbol: str, bars: list[dict]) -> int:
"""Insert/replace Alpaca daily bars, deriving previous_close per row."""
ordered = sorted(bars, key=lambda b: b["t"])
rows = []
prev_close: float | None = None
for bar in ordered:
ts = parse_alpaca_timestamp(bar["t"]).astimezone(EASTERN)
close = float(bar["c"])
rows.append(
(
symbol,
ts.date(),
float(bar["o"]),
float(bar["h"]),
float(bar["l"]),
close,
int(bar["v"]),
float(bar.get("vw") or close),
prev_close,
)
)
prev_close = close
if not rows:
return 0
con.executemany(
"""
INSERT INTO daily_bars (
symbol, trade_date, open, high, low, close, volume, vwap,
previous_close
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (symbol, trade_date) DO UPDATE SET
open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low,
close = EXCLUDED.close, volume = EXCLUDED.volume, vwap = EXCLUDED.vwap,
previous_close = EXCLUDED.previous_close
""",
rows,
)
return len(rows)
def ingest_live_minute_bars(
con,
client,
symbols: list[str],
lookback_minutes: int = 240,
feed: str | None = None,
) -> IngestionResult:
"""Pull recent 1-minute bars for `symbols` into the research database.
`client` is an AlpacaPaperClient (or anything with get_minute_bars).
Free Alpaca keys can only see IEX data, and the SIP feed additionally
embargoes the most recent 15 minutes; we end the window slightly in the
past so the request never 403s.
"""
result = IngestionResult()
if not symbols:
return result
result.per_symbol = {s: 0 for s in symbols}
now = datetime.now(timezone.utc)
start = now - timedelta(minutes=lookback_minutes)
end = now - timedelta(seconds=30)
try:
bars_by_symbol = client.get_minute_bars(
symbols,
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=end.isoformat().replace("+00:00", "Z"),
feed=feed,
)
except Exception as exc: # noqa: BLE001 - report, don't crash the loop
result.errors.append(f"minute bars fetch failed: {exc}")
return result
for symbol, bars in bars_by_symbol.items():
try:
count = upsert_minute_bars(con, symbol, bars)
result.per_symbol[symbol] = count
if count:
result.minute_rows += count
result.symbols.append(symbol)
except Exception as exc: # noqa: BLE001
result.errors.append(f"{symbol}: minute upsert failed: {exc}")
return result
def ingest_daily_history(
con,
client,
symbols: list[str],
days: int = 30,
) -> IngestionResult:
"""Backfill daily bars so gap %% / relative volume have a baseline."""
result = IngestionResult()
if not symbols:
return result
start = (datetime.now(timezone.utc) - timedelta(days=days * 2)).date()
try:
bars_by_symbol = client.get_daily_bars(
symbols, start_iso=f"{start.isoformat()}T00:00:00Z"
)
except Exception as exc: # noqa: BLE001
result.errors.append(f"daily bars fetch failed: {exc}")
return result
for symbol, bars in bars_by_symbol.items():
try:
count = upsert_daily_bars(con, symbol, bars)
if count:
result.daily_rows += count
result.symbols.append(symbol)
except Exception as exc: # noqa: BLE001
result.errors.append(f"{symbol}: daily upsert failed: {exc}")
return result
def discover_active_symbols(
client,
top: int = 20,
price_min: float = 1.0,
price_max: float = 20.0,
) -> list[str]:
"""Use Alpaca's most-actives screener to find live momentum candidates.
Filters the screener output to the strategy's price band using latest
trades. Failures return an empty list so the caller can fall back to a
static symbol list.
"""
try:
# Alpaca caps the most-actives `top` at 100; never request more.
actives = client.get_most_actives(top=min(top * 3, 100), by="volume")
symbols = [a["symbol"] for a in actives if a.get("symbol")]
if not symbols:
return []
trades = client.get_latest_trades(symbols) # batched internally; price them all
in_band = []
for symbol in symbols:
trade = trades.get(symbol)
if not trade:
continue
price = float(trade.get("p") or 0.0)
if price_min <= price <= price_max:
in_band.append(symbol)
if len(in_band) >= top:
break
return in_band
except Exception as exc: # noqa: BLE001 - screener needs a paid feed sometimes
status = getattr(exc, "status", None)
if status in (401, 403):
logger.error(
"most-actives screener AUTH/ENTITLEMENT failure (HTTP %s) — check "
"ALPACA keys / data plan; this is NOT 'no movers': %s", status, exc)
else:
logger.warning("most-actives screener failed (HTTP %s): %s", status, exc)
return []