"""In-memory market data manager with rolling OHLCV windows. Maintains a per-ticker deque of recent bars and computes technical indicators (SMA, RSI, EMA, MACD, Bollinger Bands, VWAP, ATR) on demand when building ``MarketSnapshot`` objects. """ from __future__ import annotations import math from collections import deque from typing import Any from shared.schemas.trading import MarketSnapshot, OHLCVBar # Default rolling-window sizes _DEFAULT_MAX_BARS = 250 _RSI_PERIOD = 14 class MarketDataManager: """Manages in-memory rolling windows of OHLCV bars per ticker. Parameters ---------- max_bars: Maximum number of bars to retain per ticker. """ def __init__(self, max_bars: int = _DEFAULT_MAX_BARS) -> None: self.max_bars = max_bars self._bars: dict[str, deque[OHLCVBar]] = {} # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def add_bar(self, ticker: str, bar_data: dict[str, Any] | OHLCVBar) -> None: """Append a bar to the rolling window for *ticker*. ``bar_data`` can be a dict (parsed from JSON) or an ``OHLCVBar`` instance. """ if isinstance(bar_data, dict): bar = OHLCVBar.model_validate(bar_data) else: bar = bar_data if ticker not in self._bars: self._bars[ticker] = deque(maxlen=self.max_bars) self._bars[ticker].append(bar) def get_snapshot(self, ticker: str) -> MarketSnapshot | None: """Build a ``MarketSnapshot`` from the rolling window. Returns ``None`` if no bars have been recorded for *ticker*. """ bars = self._bars.get(ticker) if not bars: return None latest = bars[-1] closes = [b.close for b in bars] bar_list = list(bars) macd_result = self._compute_macd(closes) macd_val = macd_signal = macd_hist = None if macd_result: macd_val, macd_signal, macd_hist = macd_result boll_result = self._compute_bollinger(closes) boll_upper = boll_mid = boll_lower = None if boll_result: boll_upper, boll_mid, boll_lower = boll_result return MarketSnapshot( ticker=ticker, current_price=latest.close, open=latest.open, high=latest.high, low=latest.low, close=latest.close, volume=latest.volume, sma_20=self._compute_sma(closes, 20), sma_50=self._compute_sma(closes, 50), sma_200=self._compute_sma(closes, 200), rsi=self._compute_rsi(closes, _RSI_PERIOD), ema_9=self._compute_ema(closes, 9), ema_21=self._compute_ema(closes, 21), macd=macd_val, macd_signal=macd_signal, macd_histogram=macd_hist, bollinger_upper=boll_upper, bollinger_mid=boll_mid, bollinger_lower=boll_lower, vwap=self._compute_vwap(bar_list), atr=self._compute_atr(bar_list), bars=[b.model_dump(mode="json") for b in bars], ) def has_ticker(self, ticker: str) -> bool: """Return ``True`` if at least one bar exists for *ticker*.""" return ticker in self._bars and len(self._bars[ticker]) > 0 # ------------------------------------------------------------------ # Technical indicator helpers # ------------------------------------------------------------------ @staticmethod def _compute_sma(closes: list[float], period: int) -> float | None: """Compute the simple moving average over the last *period* closes. Returns ``None`` if there are fewer than *period* data points. """ if len(closes) < period: return None return sum(closes[-period:]) / period @staticmethod def _compute_rsi(closes: list[float], period: int = 14) -> float | None: """Compute the standard RSI over the last *period+1* closes. Uses the average-gain / average-loss method. Returns ``None`` if there are not enough data points (need at least ``period + 1`` closes to compute ``period`` deltas). """ if len(closes) < period + 1: return None # Only use the most recent period+1 closes relevant = closes[-(period + 1):] deltas = [relevant[i + 1] - relevant[i] for i in range(len(relevant) - 1)] gains = [d for d in deltas if d > 0] losses = [-d for d in deltas if d < 0] avg_gain = sum(gains) / period if gains else 0.0 avg_loss = sum(losses) / period if losses else 0.0 if avg_loss == 0: return 100.0 # No losses -> RSI is 100 rs = avg_gain / avg_loss rsi = 100.0 - (100.0 / (1.0 + rs)) return round(rsi, 4) @staticmethod def _compute_ema(closes: list[float], period: int) -> float | None: """Compute the exponential moving average over *closes*. Seeds with the SMA of the first *period* closes, then applies the EMA multiplier ``2 / (period + 1)`` for each subsequent close. Returns ``None`` if there are fewer than *period* data points. """ if len(closes) < period: return None multiplier = 2.0 / (period + 1) # Seed with SMA of first `period` values ema = sum(closes[:period]) / period for price in closes[period:]: ema = (price - ema) * multiplier + ema return round(ema, 6) @staticmethod def _compute_macd( closes: list[float], ) -> tuple[float, float, float] | None: """Compute MACD(12, 26, 9). Needs at least 35 closes (26 for slow EMA seed + 9 for signal line). Returns ``(macd_line, signal_line, histogram)`` or ``None``. """ if len(closes) < 35: return None # Helper to compute a running EMA series from closes def _ema_series(data: list[float], period: int) -> list[float]: multiplier = 2.0 / (period + 1) ema = sum(data[:period]) / period result = [ema] for val in data[period:]: ema = (val - ema) * multiplier + ema result.append(ema) return result ema12_series = _ema_series(closes, 12) ema26_series = _ema_series(closes, 26) # Align: ema12_series starts at index 12, ema26_series at index 26 # After ema26 seed, we have len(closes)-26 subsequent values (+1 for seed) # We need to align ema12 to the same time window as ema26 # ema26 has values for indices 26..len(closes)-1 (total: len-26+1 entries incl seed at 26) # ema12 has values for indices 12..len(closes)-1 (total: len-12+1 entries incl seed at 12) # Offset into ema12 for index 26 = 26 - 12 = 14 offset = 26 - 12 macd_values = [ ema12_series[offset + i] - ema26_series[i] for i in range(len(ema26_series)) ] if len(macd_values) < 9: return None # Signal line = EMA-9 of MACD values signal_series = _ema_series(macd_values, 9) macd_line = round(macd_values[-1], 6) signal_line = round(signal_series[-1], 6) histogram = round(macd_line - signal_line, 6) return (macd_line, signal_line, histogram) @staticmethod def _compute_bollinger( closes: list[float], period: int = 20, num_std: float = 2.0 ) -> tuple[float, float, float] | None: """Compute Bollinger Bands (SMA ± *num_std* standard deviations). Returns ``(upper, mid, lower)`` or ``None`` if insufficient data. """ if len(closes) < period: return None window = closes[-period:] mid = sum(window) / period variance = sum((x - mid) ** 2 for x in window) / period std = math.sqrt(variance) upper = round(mid + num_std * std, 6) mid_r = round(mid, 6) lower = round(mid - num_std * std, 6) return (upper, mid_r, lower) @staticmethod def _compute_vwap(bars: list[OHLCVBar]) -> float | None: """Compute the cumulative Volume-Weighted Average Price. Typical price = (high + low + close) / 3. Returns ``None`` if no bars are provided. """ if not bars: return None cum_tp_vol = 0.0 cum_vol = 0.0 for bar in bars: typical = (bar.high + bar.low + bar.close) / 3.0 cum_tp_vol += typical * bar.volume cum_vol += bar.volume if cum_vol == 0: return None return round(cum_tp_vol / cum_vol, 6) @staticmethod def _compute_atr(bars: list[OHLCVBar], period: int = 14) -> float | None: """Compute the Average True Range. Needs at least ``period + 1`` bars (to compute ``period`` true ranges). True range = max(H-L, |H-prevC|, |L-prevC|). Returns ``None`` if insufficient data. """ if len(bars) < period + 1: return None true_ranges: list[float] = [] for i in range(1, len(bars)): h = bars[i].high l = bars[i].low # noqa: E741 prev_c = bars[i - 1].close tr = max(h - l, abs(h - prev_c), abs(l - prev_c)) true_ranges.append(tr) # Simple average of the last `period` true ranges recent = true_ranges[-period:] return round(sum(recent) / period, 6)