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