|
|
"""Market-side feature calculations from price bar data."""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
from libs.oracle_client.models import PriceBar
|
|
|
|
|
|
|
|
|
def reaction_day_return(bars: list[PriceBar], event_date: str) -> float | None:
|
|
|
"""(close - prev_close) / prev_close on event date."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
event_bar = dated[event_date]
|
|
|
# Find previous bar
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx == 0:
|
|
|
return None
|
|
|
prev_bar = dated[sorted_dates[idx - 1]]
|
|
|
if prev_bar.close == 0:
|
|
|
return None
|
|
|
return (event_bar.close - prev_bar.close) / prev_bar.close
|
|
|
|
|
|
|
|
|
def volume_ratio_20d(bars: list[PriceBar], event_date: str) -> float | None:
|
|
|
"""Event day volume / 20-day average volume before event."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < 1:
|
|
|
return None
|
|
|
prior = sorted_dates[max(0, idx - 20) : idx]
|
|
|
if not prior:
|
|
|
return None
|
|
|
avg_vol = sum(dated[d].volume for d in prior) / len(prior)
|
|
|
if avg_vol == 0:
|
|
|
return None
|
|
|
return dated[event_date].volume / avg_vol
|
|
|
|
|
|
|
|
|
def avg_dollar_volume_20d(bars: list[PriceBar], event_date: str) -> float | None:
|
|
|
"""20-day average dollar volume before the event day."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < 1:
|
|
|
return None
|
|
|
prior = sorted_dates[max(0, idx - 20) : idx]
|
|
|
if not prior:
|
|
|
return None
|
|
|
dollar_volumes = [dated[d].close * dated[d].volume for d in prior]
|
|
|
return sum(dollar_volumes) / len(dollar_volumes)
|
|
|
|
|
|
|
|
|
def close_location(bar: PriceBar) -> float | None:
|
|
|
"""(close - low) / (high - low): 0=closed at low, 1=at high."""
|
|
|
rng = bar.high - bar.low
|
|
|
if rng == 0:
|
|
|
return None
|
|
|
return (bar.close - bar.low) / rng
|
|
|
|
|
|
|
|
|
def gap_size(bars: list[PriceBar], event_date: str) -> float | None:
|
|
|
"""(open_today - close_yesterday) / close_yesterday."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx == 0:
|
|
|
return None
|
|
|
today = dated[event_date]
|
|
|
yesterday = dated[sorted_dates[idx - 1]]
|
|
|
if yesterday.close == 0:
|
|
|
return None
|
|
|
return (today.open - yesterday.close) / yesterday.close
|
|
|
|
|
|
|
|
|
def atr_14(bars: list[PriceBar]) -> float | None:
|
|
|
"""14-period Average True Range."""
|
|
|
if len(bars) < 2:
|
|
|
return None
|
|
|
sorted_bars = sorted(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) < 14:
|
|
|
return sum(true_ranges) / len(true_ranges) if true_ranges else None
|
|
|
return sum(true_ranges[-14:]) / 14
|
|
|
|
|
|
|
|
|
def pre_event_momentum(bars: list[PriceBar], event_date: str, lookback: int = 20) -> float | None:
|
|
|
"""Return of the stock over `lookback` trading days before event_date."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < lookback:
|
|
|
return None
|
|
|
close_now = dated[sorted_dates[idx]].close
|
|
|
close_past = dated[sorted_dates[idx - lookback]].close
|
|
|
if close_past <= 0:
|
|
|
return None
|
|
|
return (close_now - close_past) / close_past
|
|
|
|
|
|
|
|
|
def price_vs_sma(bars: list[PriceBar], event_date: str, window: int = 20) -> float | None:
|
|
|
"""(close - SMA) / SMA on event_date. Positive = above SMA."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < window:
|
|
|
return None
|
|
|
close_now = dated[sorted_dates[idx]].close
|
|
|
sma = sum(dated[sorted_dates[idx - i]].close for i in range(window)) / window
|
|
|
if sma <= 0:
|
|
|
return None
|
|
|
return (close_now - sma) / sma
|
|
|
|
|
|
|
|
|
def pre_event_volatility(bars: list[PriceBar], event_date: str, lookback: int = 20) -> float | None:
|
|
|
"""Standard deviation of daily returns over `lookback` days before event_date."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < lookback + 1: # need lookback+1 bars to get lookback returns
|
|
|
return None
|
|
|
returns = []
|
|
|
for i in range(idx - lookback, idx):
|
|
|
prev_close = dated[sorted_dates[i]].close
|
|
|
curr_close = dated[sorted_dates[i + 1]].close
|
|
|
if prev_close <= 0:
|
|
|
continue
|
|
|
returns.append((curr_close - prev_close) / prev_close)
|
|
|
if len(returns) < lookback // 2:
|
|
|
return None
|
|
|
mean = sum(returns) / len(returns)
|
|
|
variance = sum((r - mean) ** 2 for r in returns) / len(returns)
|
|
|
return variance ** 0.5
|
|
|
|
|
|
|
|
|
def pre_event_rsi(bars: list[PriceBar], event_date: str, period: int = 14) -> float | None:
|
|
|
"""RSI on event_date using `period` lookback. Returns 0-100 scale."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < period + 1:
|
|
|
return None
|
|
|
gains = []
|
|
|
losses = []
|
|
|
for i in range(idx - period, idx):
|
|
|
prev_close = dated[sorted_dates[i]].close
|
|
|
curr_close = dated[sorted_dates[i + 1]].close
|
|
|
change = curr_close - prev_close
|
|
|
if change >= 0:
|
|
|
gains.append(change)
|
|
|
losses.append(0.0)
|
|
|
else:
|
|
|
gains.append(0.0)
|
|
|
losses.append(abs(change))
|
|
|
avg_gain = sum(gains) / period
|
|
|
avg_loss = sum(losses) / period
|
|
|
if avg_loss == 0:
|
|
|
return 100.0
|
|
|
rs = avg_gain / avg_loss
|
|
|
return 100.0 - (100.0 / (1.0 + rs))
|
|
|
|
|
|
|
|
|
def pre_event_bb_position(bars: list[PriceBar], event_date: str, window: int = 20, num_std: float = 2.0) -> float | None:
|
|
|
"""Bollinger Band %B: (close - lower) / (upper - lower). >1 = above upper band."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < window:
|
|
|
return None
|
|
|
closes = [dated[sorted_dates[idx - i]].close for i in range(window)]
|
|
|
sma = sum(closes) / window
|
|
|
if sma <= 0:
|
|
|
return None
|
|
|
std = (sum((c - sma) ** 2 for c in closes) / window) ** 0.5
|
|
|
if std <= 0:
|
|
|
return 0.5 # flat price = middle of band
|
|
|
upper = sma + num_std * std
|
|
|
lower = sma - num_std * std
|
|
|
band_width = upper - lower
|
|
|
if band_width <= 0:
|
|
|
return 0.5
|
|
|
close = dated[sorted_dates[idx]].close
|
|
|
return (close - lower) / band_width
|
|
|
|
|
|
|
|
|
def pre_event_obv_slope(bars: list[PriceBar], event_date: str, lookback: int = 20) -> float | None:
|
|
|
"""OBV slope over `lookback` days, normalized by average volume.
|
|
|
|
|
|
Positive = accumulation (volume on up-days > volume on down-days).
|
|
|
Returns slope per day / avg_volume, roughly in [-1, 1] range.
|
|
|
"""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < lookback + 1:
|
|
|
return None
|
|
|
# Compute OBV series for the lookback window
|
|
|
obv_series = [0.0]
|
|
|
total_vol = 0.0
|
|
|
for i in range(idx - lookback + 1, idx + 1):
|
|
|
prev_close = dated[sorted_dates[i - 1]].close
|
|
|
curr = dated[sorted_dates[i]]
|
|
|
vol = float(curr.volume)
|
|
|
total_vol += vol
|
|
|
if curr.close > prev_close:
|
|
|
obv_series.append(obv_series[-1] + vol)
|
|
|
elif curr.close < prev_close:
|
|
|
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
|
|
|
# Linear regression slope: OBV vs time index
|
|
|
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
|
|
|
slope = numerator / denominator
|
|
|
return slope / avg_vol # normalized slope
|
|
|
|
|
|
|
|
|
def pre_event_hurst(bars: list[PriceBar], event_date: str, lookback: int = 60) -> float | None:
|
|
|
"""Hurst exponent via R/S (Rescaled Range) analysis.
|
|
|
|
|
|
H > 0.5 = trending (persistent), H < 0.5 = mean-reverting (anti-persistent).
|
|
|
H ≈ 0.5 = random walk. Uses sub-windows of sizes [8, 16, 32] for regression.
|
|
|
"""
|
|
|
import math
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < lookback + 1:
|
|
|
return None
|
|
|
returns = []
|
|
|
for i in range(idx - lookback, idx):
|
|
|
prev_close = dated[sorted_dates[i]].close
|
|
|
curr_close = dated[sorted_dates[i + 1]].close
|
|
|
if prev_close <= 0:
|
|
|
continue
|
|
|
returns.append((curr_close - prev_close) / prev_close)
|
|
|
if len(returns) < 30:
|
|
|
return None
|
|
|
|
|
|
def rs_stat(series: list[float]) -> float:
|
|
|
n = len(series)
|
|
|
mean = sum(series) / n
|
|
|
deviations = [x - mean for x in series]
|
|
|
cumdev = []
|
|
|
s = 0.0
|
|
|
for d in deviations:
|
|
|
s += d
|
|
|
cumdev.append(s)
|
|
|
r = max(cumdev) - min(cumdev)
|
|
|
std = (sum(d ** 2 for d in deviations) / n) ** 0.5
|
|
|
if std <= 0:
|
|
|
return 0.0
|
|
|
return r / std
|
|
|
|
|
|
# Compute R/S for different window sizes
|
|
|
window_sizes = [s for s in [8, 12, 16, 24, 32] if s <= len(returns) // 2]
|
|
|
if len(window_sizes) < 2:
|
|
|
return None
|
|
|
|
|
|
log_n = []
|
|
|
log_rs = []
|
|
|
for w in window_sizes:
|
|
|
rs_values = []
|
|
|
for start in range(0, len(returns) - w + 1, w):
|
|
|
chunk = returns[start:start + w]
|
|
|
if len(chunk) == w:
|
|
|
rs_values.append(rs_stat(chunk))
|
|
|
if rs_values:
|
|
|
avg_rs = sum(rs_values) / len(rs_values)
|
|
|
if avg_rs > 0:
|
|
|
log_n.append(math.log(w))
|
|
|
log_rs.append(math.log(avg_rs))
|
|
|
|
|
|
if len(log_n) < 2:
|
|
|
return None
|
|
|
|
|
|
# Linear regression: log(R/S) = H * log(n) + c
|
|
|
n = len(log_n)
|
|
|
x_mean = sum(log_n) / n
|
|
|
y_mean = sum(log_rs) / n
|
|
|
num = sum((log_n[i] - x_mean) * (log_rs[i] - y_mean) for i in range(n))
|
|
|
den = sum((log_n[i] - x_mean) ** 2 for i in range(n))
|
|
|
if den <= 0:
|
|
|
return 0.5
|
|
|
return num / den
|
|
|
|
|
|
|
|
|
def pre_event_entropy(bars: list[PriceBar], event_date: str, lookback: int = 60, n_bins: int = 10) -> float | None:
|
|
|
"""Shannon entropy of return distribution. Lower = more predictable patterns."""
|
|
|
import math
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < lookback + 1:
|
|
|
return None
|
|
|
returns = []
|
|
|
for i in range(idx - lookback, idx):
|
|
|
prev_close = dated[sorted_dates[i]].close
|
|
|
curr_close = dated[sorted_dates[i + 1]].close
|
|
|
if prev_close <= 0:
|
|
|
continue
|
|
|
returns.append((curr_close - prev_close) / prev_close)
|
|
|
if len(returns) < lookback // 2:
|
|
|
return None
|
|
|
|
|
|
# Bin returns
|
|
|
min_r = min(returns)
|
|
|
max_r = max(returns)
|
|
|
if max_r <= min_r:
|
|
|
return 0.0 # zero entropy = perfectly predictable
|
|
|
bin_width = (max_r - min_r) / n_bins
|
|
|
counts = [0] * n_bins
|
|
|
for r in returns:
|
|
|
b = min(n_bins - 1, int((r - min_r) / bin_width))
|
|
|
counts[b] += 1
|
|
|
|
|
|
# Shannon entropy
|
|
|
total = len(returns)
|
|
|
entropy = 0.0
|
|
|
for c in counts:
|
|
|
if c > 0:
|
|
|
p = c / total
|
|
|
entropy -= p * math.log(p)
|
|
|
return entropy
|
|
|
|
|
|
|
|
|
def pre_event_ou_theta(bars: list[PriceBar], event_date: str, lookback: int = 60) -> float | None:
|
|
|
"""Ornstein-Uhlenbeck mean-reversion speed θ via AR(1) regression.
|
|
|
|
|
|
Fits x_t = α + β*x_{t-1} + ε on log prices. θ = -ln(β).
|
|
|
High θ = fast reversion = PEAD unfriendly. Low θ = slow/no reversion = PEAD friendly.
|
|
|
"""
|
|
|
import math
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < lookback + 1:
|
|
|
return None
|
|
|
log_prices = []
|
|
|
for i in range(idx - lookback, idx + 1):
|
|
|
c = dated[sorted_dates[i]].close
|
|
|
if c <= 0:
|
|
|
return None
|
|
|
log_prices.append(math.log(c))
|
|
|
if len(log_prices) < lookback:
|
|
|
return None
|
|
|
# AR(1) regression: y = log_prices[1:], x = log_prices[:-1]
|
|
|
y = log_prices[1:]
|
|
|
x = log_prices[:-1]
|
|
|
n = len(y)
|
|
|
x_mean = sum(x) / n
|
|
|
y_mean = sum(y) / n
|
|
|
num = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
|
|
|
den = sum((x[i] - x_mean) ** 2 for i in range(n))
|
|
|
if den <= 0:
|
|
|
return None
|
|
|
beta = num / den
|
|
|
if beta <= 0 or beta >= 1.0:
|
|
|
return 0.0 # no mean reversion or explosive
|
|
|
return -math.log(beta)
|
|
|
|
|
|
|
|
|
def pre_event_gravitational_pull(bars: list[PriceBar], event_date: str) -> float | None:
|
|
|
"""Distance from MA cluster (SMA20, SMA50) normalized by ATR14.
|
|
|
|
|
|
High value = price far from MAs = strong gravitational pull back = mean reversion risk.
|
|
|
Low value = price near MAs = stable = PEAD friendly.
|
|
|
"""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < 50:
|
|
|
return None
|
|
|
close = dated[sorted_dates[idx]].close
|
|
|
sma20 = sum(dated[sorted_dates[idx - i]].close for i in range(20)) / 20
|
|
|
sma50 = sum(dated[sorted_dates[idx - i]].close for i in range(50)) / 50
|
|
|
ma_center = (sma20 + sma50) / 2
|
|
|
# ATR14 for normalization
|
|
|
true_ranges = []
|
|
|
for i in range(max(1, idx - 14), idx + 1):
|
|
|
curr = dated[sorted_dates[i]]
|
|
|
prev = dated[sorted_dates[i - 1]]
|
|
|
tr = max(curr.high - curr.low, abs(curr.high - prev.close), abs(curr.low - prev.close))
|
|
|
true_ranges.append(tr)
|
|
|
atr = sum(true_ranges) / len(true_ranges) if true_ranges else 1.0
|
|
|
if atr <= 0:
|
|
|
return None
|
|
|
return abs(close - ma_center) / atr
|
|
|
|
|
|
|
|
|
def pre_event_market_temperature(bars: list[PriceBar], event_date: str) -> float | None:
|
|
|
"""Market temperature: 5d vol / 20d vol ratio.
|
|
|
|
|
|
> 1.0 = heating up (recent vol increasing). < 1.0 = cooling down (calming).
|
|
|
Low temperature (< 1.0) = orderly conditions = PEAD friendly.
|
|
|
"""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
sorted_dates = sorted(dated.keys())
|
|
|
if event_date not in dated:
|
|
|
return None
|
|
|
idx = sorted_dates.index(event_date)
|
|
|
if idx < 21:
|
|
|
return None
|
|
|
|
|
|
def vol_window(start_idx: int, length: int) -> float:
|
|
|
rets = []
|
|
|
for i in range(start_idx - length, start_idx):
|
|
|
pc = dated[sorted_dates[i]].close
|
|
|
cc = dated[sorted_dates[i + 1]].close
|
|
|
if pc > 0:
|
|
|
rets.append((cc - pc) / pc)
|
|
|
if len(rets) < 2:
|
|
|
return 0.0
|
|
|
mean = sum(rets) / len(rets)
|
|
|
return (sum((r - mean) ** 2 for r in rets) / len(rets)) ** 0.5
|
|
|
|
|
|
vol_5d = vol_window(idx, 5)
|
|
|
vol_20d = vol_window(idx, 20)
|
|
|
if vol_20d <= 0:
|
|
|
return None
|
|
|
return vol_5d / vol_20d
|
|
|
|
|
|
|
|
|
def compute_market_features(
|
|
|
bars: list[PriceBar], event_date: str
|
|
|
) -> dict[str, Any]:
|
|
|
"""Compute all market features for an event date."""
|
|
|
dated = {b.date: b for b in bars}
|
|
|
event_bar = dated.get(event_date)
|
|
|
|
|
|
features: dict[str, Any] = {
|
|
|
"reaction_day_return": reaction_day_return(bars, event_date),
|
|
|
"volume_ratio_20d": volume_ratio_20d(bars, event_date),
|
|
|
"avg_dollar_volume_20d": avg_dollar_volume_20d(bars, event_date),
|
|
|
"gap_size": gap_size(bars, event_date),
|
|
|
"atr_14": atr_14(bars),
|
|
|
"pre_event_momentum_20d": pre_event_momentum(bars, event_date, 20),
|
|
|
"price_vs_sma20": price_vs_sma(bars, event_date, 20),
|
|
|
"pre_event_volatility_20d": pre_event_volatility(bars, event_date, 20),
|
|
|
"pre_event_rsi_14": pre_event_rsi(bars, event_date, 14),
|
|
|
"pre_event_bb_position": pre_event_bb_position(bars, event_date, 20),
|
|
|
"pre_event_obv_slope_20d": pre_event_obv_slope(bars, event_date, 20),
|
|
|
"pre_event_hurst_60d": pre_event_hurst(bars, event_date, 60),
|
|
|
"pre_event_entropy_60d": pre_event_entropy(bars, event_date, 60),
|
|
|
"pre_event_ou_theta_60d": pre_event_ou_theta(bars, event_date, 60),
|
|
|
"pre_event_gravitational_pull": pre_event_gravitational_pull(bars, event_date),
|
|
|
"pre_event_market_temperature": pre_event_market_temperature(bars, event_date),
|
|
|
}
|
|
|
if event_bar:
|
|
|
features["close_location"] = close_location(event_bar)
|
|
|
features["event_close"] = event_bar.close
|
|
|
features["event_volume"] = event_bar.volume
|
|
|
features["reaction_day_low"] = event_bar.low
|
|
|
features["reaction_day_high"] = event_bar.high
|
|
|
else:
|
|
|
features["close_location"] = None
|
|
|
features["event_close"] = None
|
|
|
features["event_volume"] = None
|
|
|
features["reaction_day_low"] = None
|
|
|
features["reaction_day_high"] = None
|
|
|
|
|
|
return features
|