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