"""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 import math import statistics 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_entropy_approx(daily_bars: list[dict], lookback: int = 20) -> float | None: """Normalized Shannon entropy of recent close-to-close returns. Uses fixed return buckets and returns a value in [0, 1], where lower values indicate more ordered / repetitive recent behaviour and higher values indicate a broader return distribution. """ if len(daily_bars) < max(lookback, 2): return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) recent = sorted_bars[-(lookback + 1):] returns: list[float] = [] for i in range(1, len(recent)): prev_close = recent[i - 1].get("close") curr_close = recent[i].get("close") if not prev_close or prev_close <= 0 or curr_close is None: continue returns.append((curr_close - prev_close) / prev_close) if len(returns) < lookback: return None edges = [-0.05, -0.02, -0.01, -0.0025, 0.0025, 0.01, 0.02, 0.05] counts = [0] * (len(edges) + 1) for ret in returns[-lookback:]: placed = False for idx, edge in enumerate(edges): if ret < edge: counts[idx] += 1 placed = True break if not placed: counts[-1] += 1 total = sum(counts) if total <= 0: return None probs = [count / total for count in counts if count > 0] if not probs: return None entropy = -sum(p * math.log(p) for p in probs) max_entropy = math.log(len(counts)) if max_entropy <= 0: return None return entropy / max_entropy def compute_obv_slope_approx(daily_bars: list[dict], lookback: int = 20) -> float | None: """OBV accumulation slope over `lookback` days, normalized by average volume. Positive = accumulation (volume on up-days exceeds down-days in recent window). Negative = distribution. Returns slope-per-day / avg_volume, roughly in [-1, 1]. """ if len(daily_bars) < lookback + 2: return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) recent = sorted_bars[-(lookback + 1):] obv_series = [0.0] total_vol = 0.0 for i in range(1, len(recent)): pc = recent[i - 1].get("close") cc = recent[i].get("close") vol = float(recent[i].get("volume") or 0) total_vol += vol if not pc or pc <= 0: continue if cc > pc: obv_series.append(obv_series[-1] + vol) elif cc < pc: obv_series.append(obv_series[-1] - vol) else: obv_series.append(obv_series[-1]) avg_vol = total_vol / lookback if lookback > 0 else 1.0 if avg_vol <= 0: return None n = len(obv_series) x_mean = (n - 1) / 2.0 y_mean = sum(obv_series) / n numerator = sum((i - x_mean) * (obv_series[i] - y_mean) for i in range(n)) denominator = sum((i - x_mean) ** 2 for i in range(n)) if denominator <= 0: return 0.0 return (numerator / denominator) / avg_vol def compute_average_true_range(daily_bars: list[dict], lookback: int) -> float | None: """Average true range over the last `lookback` completed daily bars.""" 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 len(true_ranges) < lookback: return None recent = true_ranges[-lookback:] return sum(recent) / len(recent) def compute_average_range(daily_bars: list[dict], lookback: int) -> float | None: """Average high-low range over the last `lookback` completed daily bars.""" if len(daily_bars) < lookback: return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) recent = sorted_bars[-lookback:] ranges = [b["high"] - b["low"] for b in recent if b.get("high") is not None and b.get("low") is not None] if len(ranges) < lookback: return None return sum(ranges) / len(ranges) def compute_gap_zscore( daily_bars: list[dict], today_open: float, lookback: int = 20, ) -> float | None: """Today's opening gap z-score relative to prior completed daily gaps.""" if len(daily_bars) < max(lookback + 1, 2): return None sorted_bars = sorted(daily_bars, key=lambda b: b["date"]) if today_open <= 0: return None prev_close = sorted_bars[-1].get("close") if prev_close is None or prev_close <= 0: return None gaps: list[float] = [] for i in range(1, len(sorted_bars)): prev = sorted_bars[i - 1].get("close") curr_open = sorted_bars[i].get("open") if prev and prev > 0 and curr_open and curr_open > 0: gaps.append((curr_open - prev) / prev) if len(gaps) < lookback: return None sample = gaps[-lookback:] mean_gap = statistics.mean(sample) std_gap = statistics.stdev(sample) if len(sample) >= 2 else 0.0 if std_gap <= 0: return 0.0 today_gap = (today_open - prev_close) / prev_close return (today_gap - mean_gap) / std_gap 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) "entropy_20d": float | None, — normalized entropy of recent returns "atr_ratio_10_60": float | None, — ATR(10) / ATR(60) "range_compression_10_60": float | None, — avg_range_10 / avg_range_60 "gap_zscore_20d": float | None, — today's opening gap z-score }}} """ 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, "entropy_20d": ( compute_entropy_approx(prev_bars, lookback=20) if len(prev_bars) >= 20 else None ), "obv_slope_20": ( compute_obv_slope_approx(prev_bars, lookback=20) if len(prev_bars) >= 22 else None ), "obv_slope_5": ( compute_obv_slope_approx(prev_bars, lookback=5) if len(prev_bars) >= 7 else None ), "atr_ratio_10_60": _compute_ratio( compute_average_true_range(prev_bars, lookback=10), compute_average_true_range(prev_bars, lookback=60), ), "range_compression_10_60": _compute_ratio( compute_average_range(prev_bars, lookback=10), compute_average_range(prev_bars, lookback=60), ), "gap_zscore_20d": ( compute_gap_zscore(prev_bars, today_bar.get("open") or 0.0, lookback=20) if len(prev_bars) >= 21 and (today_bar.get("open") or 0.0) > 0 else None ), } if ticker_result: result[ticker] = ticker_result return result def _compute_ratio(numerator: float | None, denominator: float | None) -> float | None: if numerator is None or denominator is None or denominator == 0: return None return numerator / denominator