You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

395 lines
14 KiB
Python

"""Two-phase intraday data loading pipeline.
Phase 1: Fetch daily bars for the full universe → identify candidate ticker-days.
Phase 2: Fetch 5-min intraday bars for candidates (cache-first).
This approach reduces API calls by ~97% vs brute-force (fetching intraday for all stocks every day).
"""
from __future__ import annotations
import asyncio
import sys
from collections import defaultdict
from typing import Callable
import yaml
from libs.intraday.cache import IntradayCache
from libs.intraday.domain import UniverseParams
from libs.oracle_client import OracleClient
from libs.oracle_client.price import PriceService
from libs.oracle_client.screener import ScreenerService
# ── Universe Shortcuts ─────────────────────────────────────────────────────
_UNIVERSE_YAML_MAP = {
"midlarge": "configs/symbols_midlarge_snapshot_exact.yaml",
"largecap": "configs/symbols.yaml",
"midcap": "configs/symbols_midcap.yaml",
}
async def resolve_universe(params: UniverseParams, client: OracleClient) -> list[str]:
"""Resolve the list of ticker symbols based on universe config.
Supported sources:
- 'sp500': S&P 500 constituents from Oracle index endpoint
- 'nasdaq100': Nasdaq 100 constituents from Oracle index endpoint
- 'midlarge': 971-ticker YAML fallback file
- 'largecap': 741-ticker YAML fallback file
- 'midcap': 802-ticker YAML file
- 'yaml': custom YAML file (requires symbols_file param)
- 'screener': live screener query
Returns sorted, deduplicated list of uppercase ticker symbols.
"""
source = params.source.lower()
# Index-based sources
if source in ("sp500", "nasdaq100"):
data = await client.get(f"/api/v1/stocks/index/{source}")
tickers = [c["symbol"].upper() for c in data.get("constituents", []) if c.get("symbol")]
return sorted(set(tickers))
# YAML file shortcuts
if source in _UNIVERSE_YAML_MAP:
yaml_path = _UNIVERSE_YAML_MAP[source]
return _load_yaml_symbols(yaml_path)
# Custom YAML file
if source == "yaml":
if not params.symbols_file:
raise ValueError("source='yaml' requires symbols_file to be set")
return _load_yaml_symbols(params.symbols_file)
# Live screener query
if source == "screener":
screener = ScreenerService(client)
stocks = await screener.search_all_stocks(
market_cap_min=params.market_cap_min,
min_avg_volume=params.avg_volume_min,
price_min=params.min_price if params.min_price > 0 else None,
)
return sorted({s.symbol.upper() for s in stocks if s.symbol})
raise ValueError(f"Unknown universe source: {source!r}. "
"Use: sp500, nasdaq100, midlarge, largecap, midcap, yaml, screener")
def _load_yaml_symbols(path: str) -> list[str]:
"""Load ticker list from a YAML symbols file."""
with open(path) as f:
data = yaml.safe_load(f)
if isinstance(data, list):
return sorted({str(s).upper() for s in data if s})
if isinstance(data, dict):
symbols: list[str] = []
for key in ("symbols", "existing_only", "screener", "existing-only"):
if key in data:
val = data[key]
if isinstance(val, list):
symbols.extend(str(s).upper() for s in val if s)
if symbols:
return sorted(set(symbols))
# Flat dict of symbol -> metadata
return sorted({str(k).upper() for k in data.keys() if not k.startswith("_")})
raise ValueError(f"Unexpected YAML format in {path}")
# ── Phase 1: Daily Bar Bulk Fetch ──────────────────────────────────────────
async def fetch_daily_bars_bulk(
tickers: list[str],
start_date: str,
end_date: str,
client: OracleClient,
concurrency: int = 20,
progress_callback: Callable[[int, int], None] | None = None,
) -> dict[str, list[dict]]:
"""Fetch daily OHLCV bars for all tickers in parallel.
Returns {ticker: [bar_dict, ...]}.
Tickers with no data or errors are silently omitted.
"""
svc = PriceService(client)
semaphore = asyncio.Semaphore(concurrency)
results: dict[str, list[dict]] = {}
lock = asyncio.Lock()
completed = 0
async def fetch_one(ticker: str) -> None:
nonlocal completed
async with semaphore:
try:
resp = await svc.get_daily_bars(ticker, start=start_date, end=end_date)
if resp.bars:
bars = [
{
"date": b.date,
"open": b.open,
"high": b.high,
"low": b.low,
"close": b.close,
"volume": b.volume,
}
for b in resp.bars
]
async with lock:
results[ticker] = bars
except Exception:
pass
finally:
async with lock:
completed += 1
if progress_callback:
progress_callback(completed, len(tickers))
tasks = [asyncio.create_task(fetch_one(t)) for t in tickers]
await asyncio.gather(*tasks)
return results
# ── Phase 2: Pre-Screening ─────────────────────────────────────────────────
def pre_screen_candidates(
daily_bars: dict[str, list[dict]],
trading_days: list[str],
threshold: float = 0.015,
max_per_day: int = 30,
) -> dict[str, list[str]]:
"""Identify candidate ticker-days using daily bar heuristics.
A ticker-day is a candidate if (high - open) / open >= threshold.
This indicates the stock rose significantly above its opening price at some
point during the day — a necessary condition for being a morning gainer.
Returns:
{date: [ticker1, ticker2, ...]} — ranked by (high-open)/open descending,
capped at max_per_day per date.
Note: This is a conservative filter. If threshold is too high, some actual
morning gainers may be missed. 1.5% default catches most meaningful movers.
"""
# Build per-ticker lookup: {ticker -> {date -> bar}}
ticker_date_bar: dict[str, dict[str, dict]] = {}
for ticker, bars in daily_bars.items():
date_map: dict[str, dict] = {}
for b in bars:
d = b["date"][:10]
date_map[d] = b
ticker_date_bar[ticker] = date_map
candidates: dict[str, list[str]] = defaultdict(list)
day_scores: dict[str, dict[str, float]] = defaultdict(dict)
for ticker, date_map in ticker_date_bar.items():
for day in trading_days:
b = date_map.get(day)
if not b:
continue
open_p = b.get("open", 0)
high_p = b.get("high", 0)
if open_p <= 0:
continue
score = (high_p - open_p) / open_p
if score >= threshold:
candidates[day].append(ticker)
day_scores[day][ticker] = score
# Rank and cap per day
result: dict[str, list[str]] = {}
for day, tickers in candidates.items():
ranked = sorted(tickers, key=lambda t: day_scores[day].get(t, 0), reverse=True)
result[day] = ranked[:max_per_day]
return result
# ── ORB Pre-Screening ─────────────────────────────────────────────────────
def orb_pre_screen_candidates(
daily_bars: dict[str, list[dict]],
trading_days: list[str],
enrichment: dict[str, dict[str, dict]],
min_price: float = 10.0,
min_atr: float = 0.50,
min_avg_dollar_vol: float = 25_000_000.0,
max_per_day: int | None = 50,
) -> dict[str, list[str]]:
"""Identify ORB candidate ticker-days using liquidity and volatility filters.
All filters use PRIOR-day information only (no lookahead):
- price >= min_price (from today's daily bar open — known at market open)
- ATR(14) >= min_atr (from prior bars via enrichment)
- avg_dollar_vol_30d >= min_avg_dollar_vol (from prior bars via enrichment)
Ranking uses ATR/price (expected daily range as % of price) — a lookahead-free
proxy for intraday volatility. Higher ATR/price = more likely to produce
meaningful ORB breakouts.
Returns:
{date: [ticker1, ticker2, ...]} — ranked by ATR/price descending,
capped at max_per_day per date when max_per_day is set.
"""
from collections import defaultdict
# Build {ticker -> {date -> bar}} for quick lookup
ticker_date_bar: dict[str, dict[str, dict]] = {}
for ticker, bars in daily_bars.items():
date_map: dict[str, dict] = {}
for b in bars:
d = b["date"][:10]
date_map[d] = b
ticker_date_bar[ticker] = date_map
candidates: dict[str, list[str]] = defaultdict(list)
day_scores: dict[str, dict[str, float]] = defaultdict(dict)
for ticker, date_map in ticker_date_bar.items():
for day in trading_days:
b = date_map.get(day)
if not b:
continue
open_p = b.get("open", 0)
if open_p < min_price:
continue
# All filters from enrichment (computed from PRIOR bars — no lookahead)
ticker_enrich = enrichment.get(ticker, {}).get(day, {})
atr = ticker_enrich.get("atr_14")
avg_dollar_vol = ticker_enrich.get("avg_dollar_vol_30d")
if atr is None or atr < min_atr:
continue
if avg_dollar_vol is None or avg_dollar_vol < min_avg_dollar_vol:
continue
# Score: ATR / price = expected daily range % (lookahead-free)
atr_pct = atr / open_p
candidates[day].append(ticker)
day_scores[day][ticker] = atr_pct
# Rank by ATR/price descending and optionally cap
result: dict[str, list[str]] = {}
for day, tickers in candidates.items():
ranked = sorted(tickers, key=lambda t: day_scores[day].get(t, 0), reverse=True)
result[day] = ranked if max_per_day is None else ranked[:max_per_day]
return result
# ── Phase 2: Intraday Bar Bulk Fetch ──────────────────────────────────────
async def fetch_intraday_bulk(
candidates: dict[str, list[str]],
client: OracleClient,
cache: IntradayCache | None,
interval: str = "5min",
concurrency: int = 8,
progress_callback: Callable[[int, int, int, int], None] | None = None,
) -> dict[str, dict[str, list[dict]]]:
"""Fetch 5-min intraday bars for candidate ticker-days (cache-first).
Returns {date: {ticker: [bar_dict, ...]}}.
Args:
candidates: {date -> [ticker, ...]} from pre_screen_candidates().
client: Oracle API client.
cache: Intraday Parquet cache.
interval: Candle interval (default '5min').
concurrency: Max concurrent API calls (Semaphore). Keep <= 8 to respect rate limits.
progress_callback: Called with (completed, total, cache_hits, api_calls).
"""
# Oracle endpoint uses "5m" format; config uses "5min" format
oracle_interval = interval.replace("min", "m")
# Build flat list and separate cache hits from misses
total = sum(len(tickers) for tickers in candidates.values())
completed = 0
cache_hits = 0
api_calls = 0
lock = asyncio.Lock()
semaphore = asyncio.Semaphore(concurrency)
result: dict[str, dict[str, list[dict]]] = defaultdict(dict)
misses: dict[str, list[str]] = defaultdict(list) # {day: [ticker, ...]}
# Phase 1: resolve cache hits synchronously
for day in sorted(candidates.keys()):
for ticker in candidates[day]:
cached = cache.get(ticker, day) if cache else None
if cached is not None:
result[day][ticker] = cached
completed += 1
cache_hits += 1
else:
misses[day].append(ticker)
if progress_callback:
progress_callback(completed, total, cache_hits, api_calls)
# Phase 2: fetch cache misses via multi-ticker endpoint (batched by date, chunk ≤ 75)
CHUNK = 75
async def fetch_day_chunk(day: str, chunk: list[str]) -> None:
nonlocal completed, api_calls
fetched: dict[str, list[dict]] = {}
async with semaphore:
try:
raw = await client.get(
"/api/v1/alpaca/intraday",
params={
"tickers": ",".join(chunk),
"interval": oracle_interval,
"start_date": day,
"end_date": day,
},
)
bars_by_ticker = raw.get("bars", {})
for ticker in chunk:
fetched[ticker] = [
{
"timestamp": b.get("timestamp", ""),
"open": float(b.get("open", 0)),
"high": float(b.get("high", 0)),
"low": float(b.get("low", 0)),
"close": float(b.get("close", 0)),
"volume": float(b.get("volume", 0)),
"vwap": float(b.get("vwap", 0) or 0),
}
for b in bars_by_ticker.get(ticker, [])
]
except Exception:
pass
async with lock:
for ticker in chunk:
bars = fetched.get(ticker, [])
if cache and bars:
cache.put(ticker, day, bars)
if bars:
result[day][ticker] = bars
completed += len(chunk)
api_calls += 1
_c, _t, _ch, _ac = completed, total, cache_hits, api_calls
if progress_callback:
progress_callback(_c, _t, _ch, _ac)
tasks = [
asyncio.create_task(fetch_day_chunk(day, chunk))
for day, tickers in sorted(misses.items())
for chunk in (
tickers[i : i + CHUNK] for i in range(0, len(tickers), CHUNK)
)
]
await asyncio.gather(*tasks)
return dict(result)