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