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"]
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"]

View file

@ -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,
},
]

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