from __future__ import annotations from typing import ClassVar from app.engine.strategies.base import Strategy, StrategyContext, register from app.models import PositionSide @register class Momentum(Strategy): """Rate-of-change momentum, long-only.""" key: ClassVar[str] = "momentum" name: ClassVar[str] = "Momentum (ROC)" description: ClassVar[str] = ( "Open long when the rate of change over the lookback exceeds the entry " "threshold; exit when momentum turns negative." ) default_params: ClassVar[dict] = { "period": 10, "entry_threshold": 0.01, "atr_period": 14, "atr_stop_mult": 2.0, "trailing_stop_pct": 0.0, "size": 1.0, } param_help: ClassVar[dict] = { "period": "ROC lookback in bars", "entry_threshold": "Minimum ROC to enter (0.01 = 1%)", "atr_period": "ATR lookback for the initial stop", "atr_stop_mult": "Stop distance in ATRs (0 disables)", "trailing_stop_pct": "Trailing stop distance as a fraction of price (0 disables)", "size": "Fraction of equity per trade", } param_bounds: ClassVar[dict] = { "period": (2, 200), "entry_threshold": (0.0, 1.0), "atr_stop_mult": (0.0, 10.0), "trailing_stop_pct": (0.0, 0.5), "size": (0.01, 1.0), } def on_candle(self, ctx: StrategyContext) -> None: value = ctx.roc(int(self.params["period"])) if value is None: return if ctx.is_flat and value > float(self.params["entry_threshold"]): stop = None mult = float(self.params["atr_stop_mult"]) if mult > 0: atr_value = ctx.atr(int(self.params["atr_period"])) if atr_value: stop = ctx.price - mult * atr_value ctx.enter_long( size=float(self.params["size"]), stop_loss=stop, trailing_stop_pct=float(self.params["trailing_stop_pct"]), reason=f"ROC {value:.2%} above threshold", ) elif ctx.side == PositionSide.LONG and value < 0: ctx.close(reason=f"ROC {value:.2%} turned negative")