Source code for trader.strategies.turtle

"""Donchian breakout, the entry/exit core of the Turtle system.

Deliberately not the full Turtle system: no pyramiding, no ATR position sizing,
no 2N stop. Sizing and stops belong to the engine so strategies remain
comparable on signal quality alone.
"""

from collections.abc import Sequence
from dataclasses import dataclass
from typing import ClassVar

from trader.domain import Bar, Position
from trader.indicators import donchian_high, donchian_low
from trader.strategies.base import Action, Signal

__all__ = ["TurtleStrategy"]


[docs] @dataclass(frozen=True, slots=True) class TurtleStrategy: """Buy a breakout above the entry channel; exit below the exit channel.""" id: str entry_period: int = 20 exit_period: int = 10 # `Strategy.backtestable` defaults to True on the Protocol, but that default # is not inherited structurally — a class that doesn't subclass `Strategy` # gets no attribute at all. `ClassVar` keeps this out of the generated # `__init__`/`__repr__`/`__eq__`, so instantiation is unaffected. backtestable: ClassVar[bool] = True
[docs] def warmup_bars(self) -> int: # Donchian's first defined value sits at index `period`. return max(self.entry_period, self.exit_period) + 1
[docs] def evaluate(self, bars: Sequence[Bar], position: Position | None) -> Signal: entry = donchian_high(bars, self.entry_period)[-1] if bars else None exit_level = donchian_low(bars, self.exit_period)[-1] if bars else None if entry is None or exit_level is None: return Signal(Action.HOLD, "Donchian channels not yet defined") close = bars[-1].close indicators = { "close": close, "entry_channel": entry, "exit_channel": exit_level, } if position is None and close > entry: return Signal( Action.BUY, f"Close {close} broke above channel high {entry}", indicators ) if position is not None and close < exit_level: return Signal( Action.SELL, f"Close {close} broke below channel low {exit_level}", indicators, ) return Signal(Action.HOLD, f"Close {close} inside the channels", indicators)