trader.strategies.macd module

MACD trend-confirmed momentum, with an optional per-bar stop-loss.

Found while surveying ../backtesting (issue #35, 2026-08-20): MACD-based strategies were the best-performing entries in that repo’s own grid-search database. This ports two variants together, since the second is a strict superset of the first rather than a separate strategy type.

Core signal (standard 12/26/9 MACD):

  • MACD line = EMA(close, fast) - EMA(close, slow).

  • Signal line = EMA(MACD line, signal_period) – an EMA of a derived series, not of the closes themselves.

  • momentum_positive = MACD > signal AND signal > 0.

  • momentum_negative = MACD < signal AND signal < 0.

  • Trend filter: an N-period SMA of the close. trend_upward = close > MA; trend_downward = close < MA.

  • Buy: momentum_positive AND trend_upward. Sell: momentum_negative AND trend_downward. The MA trend filter is a confirmation gate on top of a bare MACD crossover, not a second independent signal – dropping it entirely is a different, out-of-scope variant (issue #35’s “Out of scope”).

Optional stop-loss, a per-bar exit check independent of the MACD/MA signal above: sell if close < entry_price * (1 - risk) OR close < previous_close * (1 - risk), where risk is stop_loss_pct. It is a modifier on this strategy’s signal, not a separate strategy type, and it is checked before the MACD/trend logic and can fire even while those indicators are still warming up – a held position needs protection from the first bar it exists, not only once warmup_bars() worth of history has accumulated.

Ships mode: shadow (see config/strategies.yaml); Decimal throughout, no pandas/numpy crossing into strategies/, matching every other rule strategy in this package.

Issue #109 (`requirements.md` §4.1): this strategy used to hand-roll its own EMA/MACD recursion internally rather than going through trader/indicators/ at all – the exact “same indicator, reinvented twice” gap the issue found. It now calls trader.indicators.macd, which delegates to talib.MACD (see that module for the boundary-conversion details). The underlying algorithm is unchanged (an SMA-seeded EMA-of-EMA-difference, the same construction the old hand-rolled _ema/_macd_and_signal used); only the engine computing it moved. One real, new constraint from that move: talib.MACD requires fast_period/slow_period/signal_period each >= 2 (a period of 1 is undefined for an EMA difference), enforced in __post_init__ below with this strategy’s own ConfigError, rather than surfacing as talib’s undecorated exception the first time evaluate() runs.

class trader.strategies.macd.MacdStrategy(id, fast_period=12, slow_period=26, signal_period=9, trend_period=50, stop_loss_pct=None)[source]

Bases: object

MACD momentum, confirmed by a moving-average trend filter, with an optional per-bar stop-loss.

See the module docstring for the buy/sell rule and the stop-loss semantics.

Parameters:
  • id (str)

  • fast_period (int)

  • slow_period (int)

  • signal_period (int)

  • trend_period (int)

  • stop_loss_pct (Decimal | None)

id: str
fast_period: int
slow_period: int
signal_period: int
trend_period: int
stop_loss_pct: Decimal | None
backtestable: ClassVar[bool] = True
warmup_bars()[source]
Return type:

int

evaluate(bars, position)[source]
Parameters:
Return type:

Signal