feat: wire 6 new strategies and fundamentals into signal generator

This commit is contained in:
Viktor Barzin 2026-02-23 21:55:59 +00:00
parent 4d6bebe6f7
commit b8eaa20d63
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 169 additions and 11 deletions

View file

@ -10,5 +10,8 @@ class SignalGeneratorConfig(BaseConfig):
alpaca_secret_key: str = ""
signal_strength_threshold: float = 0.15
watchlist: list[str] = []
alpha_vantage_api_key: str = ""
fmp_api_key: str = ""
fundamentals_cache_ttl_hours: int = 24
model_config = {"env_prefix": "TRADING_"}

View file

@ -25,8 +25,23 @@ from shared.models.trading import Signal as SignalModel
from shared.models.trading import SignalDirection as SignalDirectionModel
from shared.redis_streams import StreamConsumer, StreamPublisher
from shared.schemas.news import ScoredArticle
from shared.schemas.trading import MarketSnapshot, SentimentContext
from shared.strategies import MeanReversionStrategy, MomentumStrategy, NewsDrivenStrategy
from shared.schemas.trading import FundamentalsSnapshot, MarketSnapshot, SentimentContext
from shared.strategies import (
BollingerBreakoutStrategy,
LiquidityStrategy,
MACDCrossoverStrategy,
MAStackStrategy,
MeanReversionStrategy,
MomentumStrategy,
NewsDrivenStrategy,
ValueStrategy,
VWAPStrategy,
)
from shared.fundamentals.alpha_vantage import AlphaVantageProvider
from shared.fundamentals.fmp import FMPProvider
from shared.fundamentals.yahoo import YahooFinanceProvider
from shared.fundamentals.rotating import RotatingProvider
from shared.fundamentals.cache import CachedFundamentalsProvider
from shared.telemetry import setup_telemetry
logger = logging.getLogger(__name__)
@ -36,9 +51,15 @@ _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,
"momentum": 0.111,
"mean_reversion": 0.111,
"news_driven": 0.111,
"value": 0.111,
"macd_crossover": 0.111,
"bollinger_breakout": 0.111,
"vwap": 0.111,
"liquidity": 0.112,
"ma_stack": 0.111,
}
@ -98,6 +119,7 @@ async def _consume_scored_articles(
signals_generated,
per_strategy_signal_count,
db_session_factory: async_sessionmaker | None = None,
fundamentals_cache: dict[str, FundamentalsSnapshot] | None = None,
) -> None:
"""Consume scored articles from ``news:scored``, run the ensemble, and publish signals.
@ -145,6 +167,10 @@ async def _consume_scored_articles(
volume=0.0,
)
# Inject fundamentals into snapshot
if fundamentals_cache:
snapshot.fundamentals = fundamentals_cache.get(ticker)
# Run ensemble
signal_result = await ensemble.evaluate(ticker, snapshot, sentiment, weights)
@ -197,6 +223,30 @@ async def _consume_scored_articles(
)
async def _refresh_fundamentals(
provider: CachedFundamentalsProvider,
cache: dict[str, FundamentalsSnapshot],
watchlist: list[str],
shutdown_event: asyncio.Event,
) -> None:
"""Periodically refresh fundamental data for all watchlist tickers."""
while not shutdown_event.is_set():
await asyncio.sleep(3600 * 24) # 24 hours
if shutdown_event.is_set():
break
logger.info("Starting daily fundamentals refresh")
for ticker in watchlist:
if shutdown_event.is_set():
break
try:
snap = await provider.fetch(ticker)
if snap:
cache[ticker] = snap
except Exception:
logger.exception("Failed to refresh fundamentals for %s", ticker)
logger.info("Fundamentals refresh complete")
async def run(config: SignalGeneratorConfig | None = None) -> None:
"""Main service loop.
@ -243,6 +293,12 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
MomentumStrategy(),
MeanReversionStrategy(),
NewsDrivenStrategy(),
ValueStrategy(),
MACDCrossoverStrategy(),
BollingerBreakoutStrategy(),
VWAPStrategy(),
LiquidityStrategy(),
MAStackStrategy(),
]
ensemble = WeightedEnsemble(strategies, threshold=config.signal_strength_threshold)
@ -257,6 +313,35 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
except Exception:
logger.exception("Failed to initialise DB — signals will NOT be persisted")
# --- Fundamentals ---
fundamentals_cache: dict[str, FundamentalsSnapshot] = {}
cached_fundamentals_provider: CachedFundamentalsProvider | None = None
try:
providers = []
if config.alpha_vantage_api_key:
providers.append(AlphaVantageProvider(api_key=config.alpha_vantage_api_key))
if config.fmp_api_key:
providers.append(FMPProvider(api_key=config.fmp_api_key))
providers.append(YahooFinanceProvider()) # no API key needed
if providers and db_session_factory is not None:
rotating = RotatingProvider(providers)
cached_fundamentals_provider = CachedFundamentalsProvider(
rotating, db_session_factory, cache_ttl_hours=config.fundamentals_cache_ttl_hours,
)
# Pre-fetch fundamentals for watchlist
for ticker in config.watchlist:
try:
snap = await cached_fundamentals_provider.fetch(ticker)
if snap:
fundamentals_cache[ticker] = snap
logger.info("Loaded fundamentals for %s", ticker)
except Exception:
logger.exception("Failed to fetch fundamentals for %s", ticker)
logger.info("Fundamentals loaded for %d/%d tickers", len(fundamentals_cache), len(config.watchlist))
except Exception:
logger.exception("Failed to initialise fundamentals — strategies will run without fundamental data")
logger.info(
"Consuming from news:scored and market:bars, publishing to signals:generated"
)
@ -281,6 +366,7 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
signals_generated,
per_strategy_signal_count,
db_session_factory,
fundamentals_cache,
)
)
tg.create_task(
@ -291,6 +377,15 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
bars_received_counter,
)
)
if cached_fundamentals_provider is not None:
tg.create_task(
_refresh_fundamentals(
cached_fundamentals_provider,
fundamentals_cache,
config.watchlist,
shutdown_event,
)
)
finally:
await redis.aclose()
logger.info("Signal generator stopped gracefully")

View file

@ -15,7 +15,7 @@ from shared.schemas.trading import MarketSnapshot, OHLCVBar
# Default rolling-window sizes
_DEFAULT_MAX_BARS = 100
_DEFAULT_MAX_BARS = 250
_RSI_PERIOD = 14