"""In-memory market data manager with rolling OHLCV windows. Maintains a per-ticker deque of recent bars and computes technical indicators (SMA, RSI) on demand when building ``MarketSnapshot`` objects. """ from __future__ import annotations from collections import deque from typing import Any from shared.schemas.trading import MarketSnapshot, OHLCVBar # Default rolling-window sizes _DEFAULT_MAX_BARS = 100 _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] 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), rsi=self._compute_rsi(closes, _RSI_PERIOD), 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)