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.

508 lines
18 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.

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