165 lines
5.7 KiB
Python
165 lines
5.7 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
|
|
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", "<unknown>"))
|
|
|
|
|
|
def main() -> None:
|
|
"""CLI entry point."""
|
|
asyncio.run(run())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|