feat: signal generator — weighted ensemble with market data
This commit is contained in:
parent
e483e9987f
commit
f3e5fc944d
11 changed files with 1013 additions and 0 deletions
122
services/signal_generator/market_data.py
Normal file
122
services/signal_generator/market_data.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue