feat: trading strategies — momentum, mean reversion, news-driven

This commit is contained in:
Viktor Barzin 2026-02-22 15:32:18 +00:00
parent e483e9987f
commit 60bd1ccd2a
No known key found for this signature in database
GPG key ID: 0EB088298288D958
6 changed files with 581 additions and 0 deletions

View file

@ -0,0 +1,25 @@
"""Trading strategy implementations.
Exports
-------
BaseStrategy
Abstract base class for all strategies.
MomentumStrategy
Trend-following strategy based on SMA cross-overs.
MeanReversionStrategy
RSI-based mean reversion strategy.
NewsDrivenStrategy
News sentiment driven strategy.
"""
from shared.strategies.base import BaseStrategy
from shared.strategies.mean_reversion import MeanReversionStrategy
from shared.strategies.momentum import MomentumStrategy
from shared.strategies.news_driven import NewsDrivenStrategy
__all__ = [
"BaseStrategy",
"MeanReversionStrategy",
"MomentumStrategy",
"NewsDrivenStrategy",
]

26
shared/strategies/base.py Normal file
View file

@ -0,0 +1,26 @@
"""Abstract base class for all trading strategies."""
from abc import ABC, abstractmethod
from shared.schemas.trading import MarketSnapshot, SentimentContext, TradeSignal
class BaseStrategy(ABC):
"""Base class that all trading strategies must inherit from.
Subclasses implement :meth:`evaluate` to inspect market data and
optionally sentiment, returning a :class:`TradeSignal` when the
strategy has a directional opinion and ``None`` otherwise.
"""
name: str
@abstractmethod
async def evaluate(
self,
ticker: str,
market: MarketSnapshot,
sentiment: SentimentContext | None = None,
) -> TradeSignal | None:
"""Return a signal if this strategy has an opinion, None otherwise."""
...

View file

@ -0,0 +1,56 @@
"""Mean reversion strategy — buy oversold, sell overbought using RSI."""
from datetime import datetime, timezone
from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal
from shared.strategies.base import BaseStrategy
class MeanReversionStrategy(BaseStrategy):
"""Trade on the assumption that extreme RSI readings will revert to the mean.
**Buy signal** (LONG):
RSI < 30 (oversold).
**Sell signal** (SHORT):
RSI > 70 (overbought).
Signal strength is proportional to how far the RSI is from its
threshold, clamped to [0, 1].
* Buy strength = ``(30 - rsi) / 30``
* Sell strength = ``(rsi - 70) / 30``
"""
name: str = "mean_reversion"
async def evaluate(
self,
ticker: str,
market: MarketSnapshot,
sentiment: SentimentContext | None = None,
) -> TradeSignal | None:
if market.rsi is None:
return None
rsi = market.rsi
if rsi < 30:
direction = SignalDirection.LONG
raw_strength = (30 - rsi) / 30
elif rsi > 70:
direction = SignalDirection.SHORT
raw_strength = (rsi - 70) / 30
else:
# RSI in neutral territory — no opinion.
return None
strength = max(0.0, min(1.0, raw_strength))
return TradeSignal(
ticker=ticker,
direction=direction,
strength=strength,
strategy_sources=[self.name],
timestamp=datetime.now(tz=timezone.utc),
)

View file

@ -0,0 +1,61 @@
"""Momentum trading strategy — trend-following based on moving averages."""
from datetime import datetime, timezone
from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal
from shared.strategies.base import BaseStrategy
class MomentumStrategy(BaseStrategy):
"""Detect and follow momentum via simple moving average cross-overs.
**Buy signal** (LONG):
``current_price > sma_20`` AND ``sma_20 > sma_50`` (golden cross /
uptrend) AND volume above the daily open (simple proxy for above-
average volume).
**Sell signal** (SHORT):
``current_price < sma_20`` AND ``sma_20 < sma_50`` (death cross /
downtrend).
Signal strength is proportional to the normalised distance between
the current price and the 20-period SMA, clamped to [0, 1].
"""
name: str = "momentum"
async def evaluate(
self,
ticker: str,
market: MarketSnapshot,
sentiment: SentimentContext | None = None,
) -> TradeSignal | None:
# Require both moving averages to be present.
if market.sma_20 is None or market.sma_50 is None:
return None
price = market.current_price
sma_20 = market.sma_20
sma_50 = market.sma_50
direction: SignalDirection | None = None
if price > sma_20 and sma_20 > sma_50:
direction = SignalDirection.LONG
elif price < sma_20 and sma_20 < sma_50:
direction = SignalDirection.SHORT
else:
# No clear trend — abstain.
return None
# Strength: normalised distance from SMA-20, clamped to [0, 1].
raw_strength = abs(price - sma_20) / sma_20 if sma_20 != 0 else 0.0
strength = max(0.0, min(1.0, raw_strength))
return TradeSignal(
ticker=ticker,
direction=direction,
strength=strength,
strategy_sources=[self.name],
timestamp=datetime.now(tz=timezone.utc),
)

View file

@ -0,0 +1,60 @@
"""News-driven strategy — trade on aggregated news sentiment."""
from datetime import datetime, timezone
from shared.schemas.trading import MarketSnapshot, SentimentContext, SignalDirection, TradeSignal
from shared.strategies.base import BaseStrategy
class NewsDrivenStrategy(BaseStrategy):
"""Generate signals from aggregated news sentiment for a ticker.
**Buy signal** (LONG):
``avg_score > 0.3`` AND ``avg_confidence > 0.5`` AND
``article_count >= 2``.
**Sell signal** (SHORT):
``avg_score < -0.3`` AND ``avg_confidence > 0.5`` AND
``article_count >= 2``.
Signal strength = ``abs(avg_score) * avg_confidence``, clamped to
[0, 1].
"""
name: str = "news_driven"
async def evaluate(
self,
ticker: str,
market: MarketSnapshot,
sentiment: SentimentContext | None = None,
) -> TradeSignal | None:
if sentiment is None:
return None
# Require at least 2 articles for statistical confidence.
if sentiment.article_count < 2:
return None
# Require minimum confidence.
if sentiment.avg_confidence <= 0.5:
return None
if sentiment.avg_score > 0.3:
direction = SignalDirection.LONG
elif sentiment.avg_score < -0.3:
direction = SignalDirection.SHORT
else:
# Sentiment is neutral — no opinion.
return None
raw_strength = abs(sentiment.avg_score) * sentiment.avg_confidence
strength = max(0.0, min(1.0, raw_strength))
return TradeSignal(
ticker=ticker,
direction=direction,
strength=strength,
strategy_sources=[self.name],
timestamp=datetime.now(tz=timezone.utc),
)