"""Live pre-screening for ORB paper trading using Alpaca data. Adapts the backtester's screening logic (libs/intraday/screener.py) for live use. Key difference: we cannot use today's daily bar (open/high/low) before 9:30 ET, so pre_screen uses only lookback enrichment features (ATR14, avg_dollar_vol, prev_close). """ from __future__ import annotations import yaml from libs.intraday.domain import ORBStrategyParams # ── Universe YAML paths (mirrors screener.py _UNIVERSE_YAML_MAP) ────────────── _UNIVERSE_YAML_MAP = { "midlarge": "configs/symbols_midlarge_snapshot_exact.yaml", "largecap": "configs/symbols.yaml", "midcap": "configs/symbols_midcap.yaml", } def load_universe(source: str, symbols_file: str | None = None) -> list[str]: """Load ticker list from YAML universe. Supports: 'midlarge', 'largecap', 'midcap', or 'yaml' (requires symbols_file). Returns sorted, deduplicated list of uppercase ticker symbols. """ if source in _UNIVERSE_YAML_MAP: return _load_yaml_symbols(_UNIVERSE_YAML_MAP[source]) if source == "yaml": if not symbols_file: raise ValueError("source='yaml' requires symbols_file") return _load_yaml_symbols(symbols_file) # For unsupported dynamic sources (sp500, nasdaq100, screener), # fall back to midlarge YAML to avoid Oracle/API dependency. return _load_yaml_symbols(_UNIVERSE_YAML_MAP["midlarge"]) def _load_yaml_symbols(path: str) -> list[str]: """Load ticker list from a YAML symbols file. Mirrors screener.py:_load_yaml_symbols.""" 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)) return sorted({str(k).upper() for k in data.keys() if not k.startswith("_")}) raise ValueError(f"Unexpected YAML format in {path}") # ── Data format conversion ──────────────────────────────────────────────────── def bars_to_enrichment_format( bars: "dict[str, list]", ) -> dict[str, list[dict]]: """Convert AlpacaBroker.get_bars() Bar dataclass list to the dict format expected by enrich_daily_bars(). Input: {symbol: [Bar(date=str, open=float, ...), ...]} Output: {symbol: [{"date": str, "open": float, "high": float, ...}, ...]} """ result: dict[str, list[dict]] = {} for sym, bar_list in bars.items(): result[sym] = [ { "date": b.date, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume, } for b in bar_list ] return result def intraday_bars_to_format( raw: dict[str, list[dict]], ) -> dict[str, list[dict]]: """Ensure intraday bar dicts from AlpacaBroker.get_intraday_bars() are in the format expected by compute_orb_candidates(). The method already returns dicts with 'timestamp', 'open', etc. This function is a pass-through that filters out tickers with no bars. """ return {sym: bars for sym, bars in raw.items() if bars} # ── Live pre-screening ──────────────────────────────────────────────────────── def live_pre_screen( enrichment: dict[str, dict[str, dict]], date_str: str, params: ORBStrategyParams, ) -> list[str]: """Pre-screen tickers using only lookback enrichment features (no today's daily bar). This is the live replacement for orb_pre_screen_candidates() which requires today's daily bar (available only after market open). Filters (applied to the most recent enrichment entry before date_str): - prev_close >= min_price (proxy for current price) - atr_14 >= min_atr_14 - atr_14/prev_close in [min_atr_pct, max_atr_pct] (V23 quality filter) - avg_dollar_vol_30d >= min_avg_dollar_volume Returns list of qualifying tickers (unsorted). """ candidates: list[str] = [] for ticker, date_map in enrichment.items(): if not date_map: continue # Get the most recent enrichment date at or before date_str relevant_dates = sorted(d for d in date_map if d <= date_str) if not relevant_dates: continue feats = date_map[relevant_dates[-1]] prev_close = feats.get("prev_close", 0.0) or 0.0 atr_14 = feats.get("atr_14", 0.0) or 0.0 avg_dollar_vol = feats.get("avg_dollar_vol_30d", 0.0) or 0.0 if prev_close < params.min_price: continue if atr_14 < params.min_atr_14: continue if prev_close > 0: atr_ratio = atr_14 / prev_close if params.min_atr_pct is not None and atr_ratio < params.min_atr_pct: continue if params.max_atr_pct is not None and atr_ratio > params.max_atr_pct: continue if avg_dollar_vol < params.min_avg_dollar_volume: continue candidates.append(ticker) return candidates def get_latest_enrichment( enrichment: dict[str, dict[str, dict]], date_str: str, ticker: str, ) -> dict | None: """Get the most recent enrichment entry for a ticker before date_str.""" date_map = enrichment.get(ticker, {}) relevant = sorted(d for d in date_map if d < date_str) if not relevant: return None return date_map[relevant[-1]]