from __future__ import annotations from typing import ClassVar from app.engine.strategies.base import Strategy, StrategyContext, register from app.models import PositionSide @register class DonchianBreakout(Strategy): """Breakout of the highest high, exit on the lowest low.""" key: ClassVar[str] = "donchian_breakout" name: ClassVar[str] = "Donchian Breakout" description: ClassVar[str] = ( "Open long when the close breaks above the highest high of the entry " "lookback; exit on a close below the lowest low of the exit lookback. " "Optional ATR stop." ) default_params: ClassVar[dict] = { "entry_period": 20, "exit_period": 10, "atr_period": 14, "atr_stop_mult": 2.0, "size": 1.0, } param_help: ClassVar[dict] = { "entry_period": "Breakout lookback for entries", "exit_period": "Lookback for the exit channel", "atr_period": "ATR lookback for the stop", "atr_stop_mult": "Stop distance in ATRs (0 disables)", "size": "Fraction of equity per trade", } param_bounds: ClassVar[dict] = { "entry_period": (2, 200), "exit_period": (2, 200), "atr_stop_mult": (0.0, 10.0), "size": (0.01, 1.0), } def on_candle(self, ctx: StrategyContext) -> None: entry_period = int(self.params["entry_period"]) exit_period = int(self.params["exit_period"]) if len(ctx.candles) < max(entry_period, exit_period) + 1: return # Exclude the current candle from the channel. recent_high = max(c.high for c in ctx.candles[-entry_period - 1 : -1]) recent_low = min(c.low for c in ctx.candles[-exit_period - 1 : -1]) if ctx.is_flat and ctx.price > recent_high: 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, reason=f"broke {entry_period}-bar high", ) elif ctx.side == PositionSide.LONG and ctx.price < recent_low: ctx.close(reason=f"broke {exit_period}-bar low")