feat: wire 6 new strategies and fundamentals into signal generator
This commit is contained in:
parent
4d6bebe6f7
commit
b8eaa20d63
5 changed files with 169 additions and 11 deletions
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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_"}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue