trading/services/signal_generator/main.py
Viktor Barzin 5a6b20c8f1
fix: resolve 13 important issues from code review
I1: Add graceful shutdown (SIGTERM/SIGINT) to all 5 background services
I2: Fix Dockerfile healthcheck to use curl on /metrics endpoint
I3: Fix StreamConsumer.ensure_group() to only catch BUSYGROUP errors
I4: Fix SimulatedBroker to reject orders with insufficient cash/shares
I5: Move ORM attribute access inside DB session context in trades routes
I6: Add Redis-based rate limiting (10 req/min/IP) on all auth endpoints
I8: Prevent backtest background task garbage collection
I9: Use Numeric(16,6) instead of Float for financial columns in migration
I10: Add index on trades.created_at for time-range queries
I11: Bind infrastructure ports to 127.0.0.1 in docker-compose
I12: Add migrations init service; all app services depend on it
I13: Fix user enumeration in login_begin (return options for non-existent users)
2026-02-22 17:58:01 +00:00

178 lines
6.3 KiB
Python

"""Signal Generator service -- main entry point.
Consumes ``news:scored`` articles from Redis Streams, updates sentiment
context per ticker, runs the weighted ensemble of trading strategies, and
publishes qualifying ``TradeSignal`` messages to ``signals:generated``.
"""
from __future__ import annotations
import asyncio
import logging
import signal
from collections import defaultdict, deque
from redis.asyncio import Redis
from services.signal_generator.config import SignalGeneratorConfig
from services.signal_generator.ensemble import WeightedEnsemble
from services.signal_generator.market_data import MarketDataManager
from shared.redis_streams import StreamConsumer, StreamPublisher
from shared.schemas.news import ScoredArticle
from shared.schemas.trading import SentimentContext
from shared.strategies import MeanReversionStrategy, MomentumStrategy, NewsDrivenStrategy
from shared.telemetry import setup_telemetry
logger = logging.getLogger(__name__)
# Maximum number of recent sentiment scores to retain per ticker
_MAX_SENTIMENT_SCORES = 50
# Default strategy weights (equal weighting)
_DEFAULT_WEIGHTS: dict[str, float] = {
"momentum": 0.333,
"mean_reversion": 0.333,
"news_driven": 0.334,
}
def _build_sentiment_context(
ticker: str,
scores: deque[float],
confidences: deque[float],
) -> SentimentContext:
"""Build a ``SentimentContext`` from accumulated per-ticker scores."""
score_list = list(scores)
conf_list = list(confidences)
return SentimentContext(
ticker=ticker,
avg_score=sum(score_list) / len(score_list) if score_list else 0.0,
article_count=len(score_list),
recent_scores=score_list[-10:],
avg_confidence=sum(conf_list) / len(conf_list) if conf_list else 0.0,
)
async def run(config: SignalGeneratorConfig | None = None) -> None:
"""Main service loop.
Connects to Redis, initialises strategies and telemetry, then
continuously consumes from ``news:scored`` and publishes qualifying
signals to ``signals:generated``.
"""
if config is None:
config = SignalGeneratorConfig()
logging.basicConfig(level=config.log_level)
logger.info("Starting Signal Generator service")
# --- Telemetry ---
meter = setup_telemetry("signal-generator", config.otel_metrics_port)
signals_generated = meter.create_counter(
"signals_generated",
description="Total trade signals emitted by the signal generator",
)
per_strategy_signal_count = meter.create_counter(
"per_strategy_signal_count",
description="Signals emitted, broken down by strategy",
)
# --- Redis ---
redis = Redis.from_url(config.redis_url, decode_responses=False)
consumer = StreamConsumer(redis, "news:scored", "signal-generator", "worker-1")
publisher = StreamPublisher(redis, "signals:generated")
# --- Market data ---
market_data = MarketDataManager()
# --- Strategies ---
strategies = [
MomentumStrategy(),
MeanReversionStrategy(),
NewsDrivenStrategy(),
]
ensemble = WeightedEnsemble(strategies, threshold=config.signal_strength_threshold)
# --- Strategy weights (default equal; could load from DB) ---
weights = dict(_DEFAULT_WEIGHTS)
# --- Per-ticker sentiment accumulators ---
sentiment_scores: dict[str, deque[float]] = defaultdict(lambda: deque(maxlen=_MAX_SENTIMENT_SCORES))
sentiment_confidences: dict[str, deque[float]] = defaultdict(lambda: deque(maxlen=_MAX_SENTIMENT_SCORES))
logger.info("Consuming from news:scored, publishing to signals:generated")
# Graceful shutdown on SIGTERM/SIGINT
shutdown_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, shutdown_event.set)
# --- Consume loop ---
try:
async for _msg_id, data in consumer.consume():
if shutdown_event.is_set():
break
try:
article = ScoredArticle.model_validate(data)
ticker = article.ticker
# Update sentiment accumulators
sentiment_scores[ticker].append(article.sentiment_score)
sentiment_confidences[ticker].append(article.confidence)
# Build sentiment context
sentiment = _build_sentiment_context(
ticker,
sentiment_scores[ticker],
sentiment_confidences[ticker],
)
# Get market snapshot (may be None if no bars received yet)
snapshot = market_data.get_snapshot(ticker)
if snapshot is None:
# Create a minimal snapshot from sentiment data alone
# (the news_driven strategy does not require market indicators)
from shared.schemas.trading import MarketSnapshot
snapshot = MarketSnapshot(
ticker=ticker,
current_price=0.0,
open=0.0,
high=0.0,
low=0.0,
close=0.0,
volume=0.0,
)
# Run ensemble
signal_result = await ensemble.evaluate(ticker, snapshot, sentiment, weights)
if signal_result is not None:
await publisher.publish(signal_result.model_dump(mode="json"))
signals_generated.add(1)
for src in signal_result.strategy_sources:
strategy_name = src.split(":")[0]
per_strategy_signal_count.add(1, {"strategy": strategy_name})
logger.info(
"Signal generated: %s %s strength=%.4f sources=%s",
signal_result.direction.value,
ticker,
signal_result.strength,
signal_result.strategy_sources,
)
except Exception:
logger.exception("Error processing scored article: %s", data.get("title", "<unknown>"))
finally:
await redis.aclose()
logger.info("Signal generator stopped gracefully")
def main() -> None:
"""CLI entry point."""
asyncio.run(run())
if __name__ == "__main__":
main()