61 lines
2 KiB
Python
61 lines
2 KiB
Python
"""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),
|
|
)
|