"""Intraday feature computations for the ORB strategy. Pure functions operating on dict-based daily bar data (from fetch_daily_bars_bulk). No API calls or I/O. Used by orb_simulator.py and orb_pre_screen_candidates in screener.py. """ from __future__ import annotations from collections import deque def compute_atr_from_dicts(daily_bars: list[dict], period: int = 14) -> float | None: """14-period ATR using true range formula. Same formula as libs/features/market_features.py:atr_14() but operates on plain dicts with 'date', 'high', 'low', 'close' keys. Returns partial average if fewer than `period` bars are available. Requires at least 2 bars to compute one true range. """ if len(daily_bars) < 2: return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) true_ranges: list[float] = [] for i in range(1, len(sorted_bars)): curr = sorted_bars[i] prev = sorted_bars[i - 1] tr = max( curr["high"] - curr["low"], abs(curr["high"] - prev["close"]), abs(curr["low"] - prev["close"]), ) true_ranges.append(tr) if not true_ranges: return None recent = true_ranges[-period:] return sum(recent) / len(recent) def compute_avg_dollar_volume(daily_bars: list[dict], lookback: int = 30) -> float | None: """Average daily dollar volume = mean(close × volume) over last `lookback` bars.""" if not daily_bars: return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) recent = sorted_bars[-lookback:] dollar_vols = [ b["close"] * b["volume"] for b in recent if b.get("volume") and b.get("close") ] if not dollar_vols: return None return sum(dollar_vols) / len(dollar_vols) def compute_avg_daily_volume(daily_bars: list[dict], lookback: int = 14) -> float | None: """Average daily share volume over last `lookback` bars.""" if not daily_bars: return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) recent = sorted_bars[-lookback:] volumes = [b["volume"] for b in recent if b.get("volume")] if not volumes: return None return sum(volumes) / len(volumes) def compute_gap_pct(prev_close: float, today_open: float) -> float | None: """Gap % = (today_open - prev_close) / prev_close. Positive = gap up, negative = gap down. """ if prev_close <= 0 or today_open <= 0: return None return (today_open - prev_close) / prev_close def compute_rvol_approx( first_bar_volume: float, avg_daily_volume: float, bars_per_day: float = 78.0, ) -> float | None: """Approximate Relative Volume (RVOL) at market open. RVOL = first_bar_volume / expected_bar_volume where expected = avg_daily_volume / bars_per_day (uniform distribution assumption). 78 = 6.5 hours × 12 five-min-bars/hour = bars per full trading day. Note: actual morning volume is typically 2–3× the uniform expectation, so this RVOL will be systematically higher than "true" first-5-min RVOL. Factor this in when calibrating min_rvol thresholds (e.g. min_rvol=1.0 here ≈ 0.4 true RVOL). The approximation is consistent across all tickers, making it useful for ranking even if the absolute scale is inflated. """ if avg_daily_volume <= 0 or bars_per_day <= 0: return None expected = avg_daily_volume / bars_per_day if expected <= 0: return None return first_bar_volume / expected def enrich_daily_bars( daily_bars_by_ticker: dict[str, list[dict]], trading_days: list[str], ) -> dict[str, dict[str, dict]]: """Compute per-ticker per-day derived features from daily bars. Called once between Phase 1 and Phase 2 when using the ORB strategy. All features are computed from bars BEFORE the given date (no lookahead). Args: daily_bars_by_ticker: {ticker: [bar_dict, ...]} from fetch_daily_bars_bulk(). trading_days: Ordered list of date strings to compute enrichment for. Returns: {ticker: {date: { "atr_14": float | None, — ATR(14) from prior 14 days "avg_dollar_vol_30d": float | None, — 30-day avg daily dollar volume "avg_daily_vol_14d": float | None, — 14-day avg daily share volume "prev_close": float | None, — prior day's close (for gap calc) "today_open": float | None, — today's open (from today's bar) }}} """ result: dict[str, dict[str, dict]] = {} trading_days_set = set(trading_days) for ticker, bars in daily_bars_by_ticker.items(): if not bars: continue sorted_bars = sorted(bars, key=lambda b: b["date"]) ticker_result: dict[str, dict] = {} for i, today_bar in enumerate(sorted_bars): today_date = today_bar["date"][:10] if today_date not in trading_days_set: continue # bars BEFORE today (lookahead-free) prev_bars = sorted_bars[:i] prev_close = sorted_bars[i - 1]["close"] if i > 0 else None # 5-day prior momentum: (prev_close / close_5d_ago) - 1 # Lookahead-free: uses only bars before today. ret_5d: float | None = None if len(prev_bars) >= 6 and prev_close: close_5d_ago = prev_bars[-5]["close"] if close_5d_ago and close_5d_ago > 0: ret_5d = (prev_close - close_5d_ago) / close_5d_ago ticker_result[today_date] = { "atr_14": ( compute_atr_from_dicts(prev_bars, period=14) if len(prev_bars) >= 2 else None ), "avg_dollar_vol_30d": ( compute_avg_dollar_volume(prev_bars, lookback=30) if prev_bars else None ), "avg_daily_vol_14d": ( compute_avg_daily_volume(prev_bars, lookback=14) if prev_bars else None ), "prev_close": prev_close, "today_open": today_bar.get("open"), "ret_5d": ret_5d, } if ticker_result: result[ticker] = ticker_result return result