|
|
"""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"),
|
|
|
"synthetic_today_daily": bool(today_bar.get("synthetic_today_daily")),
|
|
|
"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
|