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.

177 lines
6.2 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
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 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)
}}}
"""
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