diff --git a/pyproject.toml b/pyproject.toml index 92e5b25..79b3136 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ api = ["fastapi>=0.110", "uvicorn[standard]>=0.27", "websockets>=12.0", "webauthn>=2.0", "pyjwt[crypto]>=2.8"] news = ["feedparser>=6.0", "praw>=7.7", "asyncpraw>=7.7", "httpx>=0.27"] sentiment = ["transformers>=4.38", "torch>=2.2", "ollama>=0.1"] -trading = ["alpaca-py>=0.21", "pytz>=2024.1"] +trading = ["alpaca-py>=0.21", "pytz>=2024.1", "yfinance>=0.2", "httpx>=0.27"] backtester = ["numpy>=1.26", "pandas>=2.2"] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=4.1", "ruff>=0.3", "mypy>=1.8", "httpx>=0.27"] diff --git a/scripts/seed_strategies.py b/scripts/seed_strategies.py index 390ba0d..861c714 100644 --- a/scripts/seed_strategies.py +++ b/scripts/seed_strategies.py @@ -1,9 +1,15 @@ """Seed default trading strategies. -Inserts three strategies with equal initial weights (0.333 each): +Inserts nine strategies with equal initial weights (~0.111 each): - momentum - mean_reversion - news_driven + - value + - macd_crossover + - bollinger_breakout + - vwap + - liquidity + - ma_stack Usage: python -m scripts.seed_strategies @@ -30,7 +36,7 @@ DEFAULT_STRATEGIES = [ "Buy when price crosses above N-period SMA with increasing volume; " "sell when it crosses below." ), - "current_weight": 0.333, + "current_weight": 0.111, "active": True, }, { @@ -39,7 +45,7 @@ DEFAULT_STRATEGIES = [ "Buy when RSI < 30 (oversold); sell when RSI > 70 (overbought). " "Signal strength proportional to RSI extremity." ), - "current_weight": 0.333, + "current_weight": 0.111, "active": True, }, { @@ -48,7 +54,61 @@ DEFAULT_STRATEGIES = [ "Buy on strong positive sentiment (score > 0.7, confidence > 0.6); " "sell on strong negative. Decay factor for stale news (> 4 hours)." ), - "current_weight": 0.333, + "current_weight": 0.111, + "active": True, + }, + { + "name": "value", + "description": ( + "Fundamental valuation: LONG when PEG < 1.0 and P/E < 25 with positive " + "EPS growth; SHORT when PEG > 3.0 or P/E > 50 with negative growth." + ), + "current_weight": 0.111, + "active": True, + }, + { + "name": "macd_crossover", + "description": ( + "MACD/signal line crossover: LONG on bullish crossover (MACD crosses above " + "signal), SHORT on bearish. Strength from histogram magnitude." + ), + "current_weight": 0.111, + "active": True, + }, + { + "name": "bollinger_breakout", + "description": ( + "Bollinger Band breakout: LONG on upper band break with high volume (momentum) " + "or below lower band (mean reversion). SHORT on failed breakout." + ), + "current_weight": 0.111, + "active": True, + }, + { + "name": "vwap", + "description": ( + "VWAP crossover: LONG when price crosses above VWAP with increasing volume, " + "SHORT when below." + ), + "current_weight": 0.111, + "active": True, + }, + { + "name": "liquidity", + "description": ( + "Volume-based: LONG on high relative volume (>2x) with rising price, " + "SHORT on high volume with falling price or bearish divergence." + ), + "current_weight": 0.112, + "active": True, + }, + { + "name": "ma_stack", + "description": ( + "Moving average alignment: LONG when price > EMA-9 > EMA-21 > SMA-50 > " + "SMA-200 (full bull stack). Golden/death cross detection." + ), + "current_weight": 0.111, "active": True, }, ] diff --git a/services/signal_generator/config.py b/services/signal_generator/config.py index e188062..40ecbe1 100644 --- a/services/signal_generator/config.py +++ b/services/signal_generator/config.py @@ -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_"} diff --git a/services/signal_generator/main.py b/services/signal_generator/main.py index 48210c6..7b92a5c 100644 --- a/services/signal_generator/main.py +++ b/services/signal_generator/main.py @@ -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") diff --git a/services/signal_generator/market_data.py b/services/signal_generator/market_data.py index 4c69ef1..9d945e5 100644 --- a/services/signal_generator/market_data.py +++ b/services/signal_generator/market_data.py @@ -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