"""Williams Alligator: a trend-following crossover of three shifted,
smoothed moving averages.
Different signal shape from anything else in this module: `rsi_revert` and
`bollinger_revert` are level-triggered oscillators, `turtle` is a channel
breakout, and `ma_crossover` compares two *unshifted* averages. This one
compares three averages that are each forward-shifted in time before the
comparison happens at all, and the "level" it fires on is not a price level
but a specific *ordering* of three lines relative to price (issue #34).
The three lines, in Bill Williams' naming:
- `jaw` = 13-period smoothed average of the close, shifted forward 8 bars.
- `teeth` = 8-period smoothed average of the close, shifted forward 5 bars.
- `lips` = 5-period smoothed average of the close, shifted forward 3 bars.
"Smoothed" is exponential (`trader.indicators.ema`, `alpha = 2/(N+1)`), not
Wilder's or a simple average. The classic Alligator smooths the *median*
price `(high + low) / 2`; this implementation smooths the close instead, to
stay consistent with the fan check below (`jaw < teeth < lips < Close`, per
the issue) and with every other strategy in this module, all of which reason
about the close alone. That is a deliberate simplification the issue left
open, not an oversight.
"Feeding" (bullish): `jaw < teeth < lips < Close` — the three lines fan out
below price, in order. "Feeding" (bearish): the mirror image, `jaw > teeth >
lips > Close`. Anything else is the Alligator "sleeping" — tangled lines, no
trade either way.
The signal is the *transition* into a feeding state, not the state itself:
holding the transition rather than the state is what makes this
edge-triggered rather than level-triggered, and matches `ma_crossover`'s
transition-based signal rather than the level checks in `rsi_revert` /
`bollinger_revert`.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import ClassVar, Literal
from trader.domain import Bar, Position
from trader.indicators import ema
from trader.strategies.base import Action, Signal
__all__ = ["AlligatorStrategy"]
_FanState = Literal["bullish", "bearish"] | None
def _shift_forward(series: Sequence[Decimal], shift: int) -> list[Decimal | None]:
"""Plot each computed value `shift` bars later than it was computed.
This is the direction that matters, and it is easy to get backwards: a
forward-shifted line's value AT bar `i` is the raw average that was
computed `shift` bars EARLIER, at bar `i - shift` — not a value computed
at bar `i` and somehow known `shift` bars into a future nobody has seen
yet. Concretely: `shift_forward(series, shift)[i] is series[i - shift]`
for `i >= shift`, and `None` (the shifted line has not reached this bar
yet) before that. `shift <= 0` would read a value from bar `i` or later at
position `i`, which is exactly the lookahead this direction avoids; it is
rejected in `AlligatorStrategy.__post_init__` rather than left to produce
a wrong answer or an `IndexError` at the end of the series.
"""
out: list[Decimal | None] = [None] * len(series)
for i in range(shift, len(series)):
out[i] = series[i - shift]
return out
def _fan_state(
jaw: Decimal | None, teeth: Decimal | None, lips: Decimal | None, close: Decimal
) -> _FanState:
"""Whether the three lines fan out below price (`bullish`), above it
(`bearish`), or neither (`None` — the Alligator is "sleeping", including
while a line is still undefined this early in the series).
"""
if jaw is None or teeth is None or lips is None:
return None
if jaw < teeth < lips < close:
return "bullish"
if jaw > teeth > lips > close:
return "bearish"
return None
[docs]
@dataclass(frozen=True, slots=True)
class AlligatorStrategy:
"""Buy on the transition into feeding-bullish; sell on the transition
into feeding-bearish.
Edge-triggered by construction: the state itself (`jaw < teeth < lips <
close`, say) can hold for many consecutive bars while the Alligator keeps
feeding, and a level-triggered strategy would re-signal a BUY on every one
of them. Comparing this bar's state against the bar before it — the same
shape `ma_crossover` uses for its crossover — means a signal fires exactly
once per transition, on its first bar.
"""
id: str
jaw_period: int = 13
teeth_period: int = 8
lips_period: int = 5
jaw_shift: int = 8
teeth_shift: int = 5
lips_shift: int = 3
# See TurtleStrategy: `Strategy.backtestable`'s Protocol default is not
# structurally inherited, so the attribute must actually exist here.
# `ClassVar` keeps it out of the generated `__init__`/`__repr__`/`__eq__`.
#
# True: this strategy is pure price arithmetic over `bars` — no LLM call,
# no news fetch, no network I/O — exactly the shape `BacktestEvaluator`
# gates cheaply (it calls `evaluate()` once per bar in a backtest window
# of a few hundred bars). `LlmStrategy` is the only strategy in this
# module for which that would be ruinous, and it is unrelated to this one.
backtestable: ClassVar[bool] = True
def __post_init__(self) -> None:
# `jaw_period`/`teeth_period`/`lips_period` don't need a matching
# check here: they reach `trader.indicators.ema`, whose
# `_require_positive_period` already rejects anything below 1 (and the
# registry additionally range-checks every `*_period` config field
# before construction, same as every other strategy's periods).
#
# The shifts get no such help from either path — `*_shift` doesn't
# match the registry's `*period` suffix check, and nothing downstream
# validates them before `_shift_forward` runs. A shift of 0 is a
# legitimate (if unusual) "don't shift this line" configuration; a
# negative one would read `series[i - shift]` at `i > len(series) - 1
# + |shift|`, i.e. past the end of the array — silently wrong for most
# of the series and an `IndexError` at the last few bars. Caught here,
# at construction, rather than surfacing as a confusing crash deep in
# `evaluate()` on whichever bar first exposes it.
for name in ("jaw_shift", "teeth_shift", "lips_shift"):
value = getattr(self, name)
if value < 0:
raise ValueError(f"{name} must be >= 0, got {value}")
[docs]
def warmup_bars(self) -> int:
# Each line needs `period` bars for its EMA to have something to
# smooth plus `shift` more before the shifted line reaches the
# decision bar at all — e.g. the default jaw is a 13-period average
# shifted 8 bars, ~21 bars minimum. The +1 is so the bar *before* the
# decision bar also has every line defined: detecting a transition
# needs both bars' states, the same reason `ma_crossover` needs
# `period + 1` rather than `period`.
longest = max(
self.jaw_period + self.jaw_shift,
self.teeth_period + self.teeth_shift,
self.lips_period + self.lips_shift,
)
return longest + 1
[docs]
def evaluate(self, bars: Sequence[Bar], position: Position | None) -> Signal:
if len(bars) < self.warmup_bars():
return Signal(Action.HOLD, "Alligator lines not yet defined")
jaw = _shift_forward(ema(bars, self.jaw_period), self.jaw_shift)
teeth = _shift_forward(ema(bars, self.teeth_period), self.teeth_shift)
lips = _shift_forward(ema(bars, self.lips_period), self.lips_shift)
jaw_now, teeth_now, lips_now = jaw[-1], teeth[-1], lips[-1]
if jaw_now is None or teeth_now is None or lips_now is None:
# Provably unreachable given `warmup_bars()` above (every shift is
# `<= longest`, so the last index always has `i >= shift`), but
# kept as a real guard rather than an `assert`: an `assert` is
# compiled away under `-O`, and correctness here should not depend
# on the interpreter flag the process happened to start with.
return Signal(Action.HOLD, "Alligator lines not yet defined")
close_now = bars[-1].close
close_prev = bars[-2].close
state_now = _fan_state(jaw_now, teeth_now, lips_now, close_now)
state_prev = _fan_state(jaw[-2], teeth[-2], lips[-2], close_prev)
indicators: dict[str, Decimal | float] = {
"close": close_now,
"jaw": jaw_now,
"teeth": teeth_now,
"lips": lips_now,
}
# Edge-triggered: a signal fires only when the state *changes into*
# bullish/bearish this bar, not on every bar the state merely holds.
bullish_edge = state_now == "bullish" and state_prev != "bullish"
bearish_edge = state_now == "bearish" and state_prev != "bearish"
if position is None and bullish_edge:
return Signal(
Action.BUY,
f"Alligator turned feeding-bullish: jaw {jaw_now} < teeth "
f"{teeth_now} < lips {lips_now} < close {close_now}",
indicators,
)
if position is not None and bearish_edge:
return Signal(
Action.SELL,
f"Alligator turned feeding-bearish: jaw {jaw_now} > teeth "
f"{teeth_now} > lips {lips_now} > close {close_now}",
indicators,
)
# Two structurally different states reach this branch, so the reason
# has to say which — the same defect `rsi_revert`/`bollinger_revert`/
# `ma_crossover` shipped and fixed: a reason true only in one of the
# two states it is reached from is a false claim in the other.
state_label = state_now or "sleeping"
if position is None:
return Signal(
Action.HOLD,
f"Flat, and the alligator has not just turned feeding-bullish "
f"(state: {state_label})",
indicators,
)
return Signal(
Action.HOLD,
f"Holding, and the alligator has not just turned feeding-bearish "
f"(state: {state_label})",
indicators,
)