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.

318 lines
11 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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_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 23× 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
),
"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