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.

164 lines
5.9 KiB
Python

"""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]]