"""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 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") # --- Consume loop --- async for _msg_id, data in consumer.consume(): 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 = await ensemble.evaluate(ticker, snapshot, sentiment, weights) if signal is not None: await publisher.publish(signal.model_dump(mode="json")) signals_generated.add(1) for src in signal.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.direction.value, ticker, signal.strength, signal.strategy_sources, ) except Exception: logger.exception("Error processing scored article: %s", data.get("title", "")) def main() -> None: """CLI entry point.""" asyncio.run(run()) if __name__ == "__main__": main()