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"]
|
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"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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_"}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue