trading/services/signal_generator/market_data.py

279 lines
9.4 KiB
Python

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