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

@ -18,7 +18,7 @@ dependencies = [
api = ["fastapi>=0.110", "uvicorn[standard]>=0.27", "websockets>=12.0", "webauthn>=2.0", "pyjwt[crypto]>=2.8"] 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"] news = ["feedparser>=6.0", "praw>=7.7", "asyncpraw>=7.7", "httpx>=0.27"]
sentiment = ["transformers>=4.38", "torch>=2.2", "ollama>=0.1"] 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"] 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"] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=4.1", "ruff>=0.3", "mypy>=1.8", "httpx>=0.27"]

View file

@ -1,9 +1,15 @@
"""Seed default trading strategies. """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 - momentum
- mean_reversion - mean_reversion
- news_driven - news_driven
- value
- macd_crossover
- bollinger_breakout
- vwap
- liquidity
- ma_stack
Usage: Usage:
python -m scripts.seed_strategies python -m scripts.seed_strategies
@ -30,7 +36,7 @@ DEFAULT_STRATEGIES = [
"Buy when price crosses above N-period SMA with increasing volume; " "Buy when price crosses above N-period SMA with increasing volume; "
"sell when it crosses below." "sell when it crosses below."
), ),
"current_weight": 0.333, "current_weight": 0.111,
"active": True, "active": True,
}, },
{ {
@ -39,7 +45,7 @@ DEFAULT_STRATEGIES = [
"Buy when RSI < 30 (oversold); sell when RSI > 70 (overbought). " "Buy when RSI < 30 (oversold); sell when RSI > 70 (overbought). "
"Signal strength proportional to RSI extremity." "Signal strength proportional to RSI extremity."
), ),
"current_weight": 0.333, "current_weight": 0.111,
"active": True, "active": True,
}, },
{ {
@ -48,7 +54,61 @@ DEFAULT_STRATEGIES = [
"Buy on strong positive sentiment (score > 0.7, confidence > 0.6); " "Buy on strong positive sentiment (score > 0.7, confidence > 0.6); "
"sell on strong negative. Decay factor for stale news (> 4 hours)." "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, "active": True,
}, },
] ]

View file

@ -10,5 +10,8 @@ class SignalGeneratorConfig(BaseConfig):
alpaca_secret_key: str = "" alpaca_secret_key: str = ""
signal_strength_threshold: float = 0.15 signal_strength_threshold: float = 0.15
watchlist: list[str] = [] watchlist: list[str] = []
alpha_vantage_api_key: str = ""
fmp_api_key: str = ""
fundamentals_cache_ttl_hours: int = 24
model_config = {"env_prefix": "TRADING_"} 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.models.trading import SignalDirection as SignalDirectionModel
from shared.redis_streams import StreamConsumer, StreamPublisher from shared.redis_streams import StreamConsumer, StreamPublisher
from shared.schemas.news import ScoredArticle from shared.schemas.news import ScoredArticle
from shared.schemas.trading import MarketSnapshot, SentimentContext from shared.schemas.trading import FundamentalsSnapshot, MarketSnapshot, SentimentContext
from shared.strategies import MeanReversionStrategy, MomentumStrategy, NewsDrivenStrategy 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 from shared.telemetry import setup_telemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,9 +51,15 @@ _MAX_SENTIMENT_SCORES = 50
# Default strategy weights (equal weighting) # Default strategy weights (equal weighting)
_DEFAULT_WEIGHTS: dict[str, float] = { _DEFAULT_WEIGHTS: dict[str, float] = {
"momentum": 0.333, "momentum": 0.111,
"mean_reversion": 0.333, "mean_reversion": 0.111,
"news_driven": 0.334, "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, signals_generated,
per_strategy_signal_count, per_strategy_signal_count,
db_session_factory: async_sessionmaker | None = None, db_session_factory: async_sessionmaker | None = None,
fundamentals_cache: dict[str, FundamentalsSnapshot] | None = None,
) -> None: ) -> None:
"""Consume scored articles from ``news:scored``, run the ensemble, and publish signals. """Consume scored articles from ``news:scored``, run the ensemble, and publish signals.
@ -145,6 +167,10 @@ async def _consume_scored_articles(
volume=0.0, volume=0.0,
) )
# Inject fundamentals into snapshot
if fundamentals_cache:
snapshot.fundamentals = fundamentals_cache.get(ticker)
# Run ensemble # Run ensemble
signal_result = await ensemble.evaluate(ticker, snapshot, sentiment, weights) 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: async def run(config: SignalGeneratorConfig | None = None) -> None:
"""Main service loop. """Main service loop.
@ -243,6 +293,12 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
MomentumStrategy(), MomentumStrategy(),
MeanReversionStrategy(), MeanReversionStrategy(),
NewsDrivenStrategy(), NewsDrivenStrategy(),
ValueStrategy(),
MACDCrossoverStrategy(),
BollingerBreakoutStrategy(),
VWAPStrategy(),
LiquidityStrategy(),
MAStackStrategy(),
] ]
ensemble = WeightedEnsemble(strategies, threshold=config.signal_strength_threshold) ensemble = WeightedEnsemble(strategies, threshold=config.signal_strength_threshold)
@ -257,6 +313,35 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
except Exception: except Exception:
logger.exception("Failed to initialise DB — signals will NOT be persisted") 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( logger.info(
"Consuming from news:scored and market:bars, publishing to signals:generated" "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, signals_generated,
per_strategy_signal_count, per_strategy_signal_count,
db_session_factory, db_session_factory,
fundamentals_cache,
) )
) )
tg.create_task( tg.create_task(
@ -291,6 +377,15 @@ async def run(config: SignalGeneratorConfig | None = None) -> None:
bars_received_counter, 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: finally:
await redis.aclose() await redis.aclose()
logger.info("Signal generator stopped gracefully") logger.info("Signal generator stopped gracefully")

View file

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