KhueApps
Home/Python/Turtle Trading in Python: ATR-Sized Breakout Backtest

Turtle Trading in Python: ATR-Sized Breakout Backtest

Last updated: October 10, 2025

Overview

This guide shows how to implement the classic Turtle Trading system in Python for single-asset backtesting. It uses:

  • Breakout entries: highest high (N) for long, lowest low (N) for short
  • ATR-based position sizing (risk parity by volatility)
  • Pyramiding: add units every 0.5 ATR up to a cap
  • Exit on shorter lookback channel breakout in the opposite direction

Defaults mirror System 1: entry_lookback=20, exit_lookback=10. You can switch to 55/20 for slower System 2.

Minimal working example (single asset)

The example below generates synthetic OHLC data, computes signals, simulates executions with fee/slippage, and prints performance.

import numpy as np
import pandas as pd

np.random.seed(7)

# ---------- Synthetic OHLC data ----------
N = 1200  # trading days
ret = np.random.normal(0, 0.001, N)
close = 100 * (1 + pd.Series(ret)).cumprod()
high = close * (1 + np.random.uniform(0.0, 0.01, N))
low = close * (1 - np.random.uniform(0.0, 0.01, N))
open_ = close.shift(1).fillna(close.iloc[0])

df = pd.DataFrame({"Open": open_, "High": high, "Low": low, "Close": close})

# ---------- Indicators ----------
def wilder_atr(df, n=20):
    h, l, c = df['High'], df['Low'], df['Close']
    prev_c = c.shift(1)
    tr = pd.concat([
        (h - l),
        (h - prev_c).abs(),
        (l - prev_c).abs()
    ], axis=1).max(axis=1)
    # Wilder's smoothing
    return tr.ewm(alpha=1/n, adjust=False).mean()

entry_lookback = 20
exit_lookback = 10
atr_n = 20

atr = wilder_atr(df, n=atr_n)

# Donchian channels (shifted to avoid lookahead)
entry_high = df['High'].rolling(entry_lookback).max().shift(1)
entry_low  = df['Low'].rolling(entry_lookback).min().shift(1)
exit_low   = df['Low'].rolling(exit_lookback).min().shift(1)
exit_high  = df['High'].rolling(exit_lookback).max().shift(1)

# ---------- Backtest ----------
init_cash = 100_000.0
risk_pct = 0.01          # risk 1% of equity per initial unit
max_units = 4
unit_add_atr = 0.5       # add a unit every 0.5 ATR
fee = 0.5                # per order, fixed
slip_bp = 1.0            # 1 bp slippage ≈ 0.01%

cash = init_cash
position = 0             # shares (can be negative if short)
units = 0                # number of units in current trade
last_add_price = np.nan  # last add level for pyramiding
side = 0                 # +1 long, -1 short, 0 flat

values = []
trades = []

def buy(shares, px):
    global cash, position
    trade_px = px * (1 + slip_bp/10_000)
    cost = shares * trade_px + fee
    cash -= cost
    position += shares
    return trade_px

def sell(shares, px):
    global cash, position
    trade_px = px * (1 - slip_bp/10_000)
    proceeds = shares * trade_px - fee
    cash += proceeds
    position -= shares
    return trade_px

unit_size = 0

for i in range(len(df)):
    px = df['Close'].iloc[i]
    a = atr.iloc[i]
    eh, el = entry_high.iloc[i], entry_low.iloc[i]
    xl, xh = exit_low.iloc[i], exit_high.iloc[i]

    # Compute current equity/value first
    value = cash + position * px
    # Skip until indicators available
    if pd.isna(a) or pd.isna(eh) or pd.isna(el) or pd.isna(xl) or pd.isna(xh):
        values.append(value)
        continue

    # Exits
    if side == 1 and px < xl:
        # exit long
        sell(position, px)
        trades.append((i, 'exit_long', px))
        side = 0; units = 0; unit_size = 0; last_add_price = np.nan
    elif side == -1 and px > xh:
        # exit short
        buy(-position, px)
        trades.append((i, 'exit_short', px))
        side = 0; units = 0; unit_size = 0; last_add_price = np.nan

    # Entries (only if flat)
    if side == 0:
        equity = cash  # when flat, equity==cash
        # ATR-dollar risk per share ≈ ATR (assuming $1 per point)
        # Unit size = risk capital / ATR
        u = int(max(1, (risk_pct * equity) // max(a, 1e-9)))
        if px > eh:  # long breakout
            unit_size = u
            buy(unit_size, px)
            trades.append((i, 'enter_long', px))
            side = 1; units = 1; last_add_price = px
        elif px < el:  # short breakdown
            unit_size = u
            sell(unit_size, px)
            trades.append((i, 'enter_short', px))
            side = -1; units = 1; last_add_price = px

    # Pyramiding (after entry/while in trade)
    if side == 1 and units > 0 and units < max_units and px >= last_add_price + unit_add_atr * a:
        buy(unit_size, px)
        trades.append((i, 'add_long', px))
        units += 1
        last_add_price = px
    elif side == -1 and units > 0 and units < max_units and px <= last_add_price - unit_add_atr * a:
        sell(unit_size, px)
        trades.append((i, 'add_short', px))
        units += 1
        last_add_price = px

    values.append(cash + position * px)

# Equity curve and metrics
curve = pd.Series(values, index=df.index)
ret = curve.pct_change().fillna(0)

def cagr(curve, periods=252):
    total = curve.iloc[-1] / curve.iloc[0]
    years = len(curve) / periods
    return total ** (1/years) - 1

def sharpe(returns, periods=252):
    mu, sd = returns.mean() * periods, returns.std() * np.sqrt(periods)
    return mu / sd if sd > 0 else 0.0

def max_drawdown(curve):
    roll_max = curve.cummax()
    dd = curve / roll_max - 1
    return dd.min()

print("Final equity:", round(curve.iloc[-1], 2))
print("CAGR:", round(cagr(curve)*100, 2), "%")
print("Sharpe:", round(sharpe(ret), 2))
print("Max DD:", round(max_drawdown(curve)*100, 2), "%")
print("Trades:", len(trades))
print(curve.tail())

What this does:

  • Computes 20-day ATR and Donchian channels
  • Enters on 20-day breakouts, exits on 10-day channels
  • Sizes by ATR to target 1% capital risk per initial unit
  • Adds up to 4 units, every 0.5 ATR in favor
  • Reports equity, CAGR, Sharpe, and max drawdown

Quickstart

  1. Install prerequisites
  • Python 3.9+
  • pip install pandas numpy
  1. Drop in your own data
  • Provide OHLC columns: Open, High, Low, Close indexed by date
# Example: load from CSV with columns Date,Open,High,Low,Close
prices = pd.read_csv("data.csv", parse_dates=["Date"], index_col="Date")
# Then reuse the indicator and backtest sections on `prices` instead of `df`.
  1. Tune parameters
  • entry_lookback: 20 or 55
  • exit_lookback: 10 or 20
  • risk_pct: 0.5% to 2% typical
  • max_units: 1–4
  • unit_add_atr: 0.5 ATR classic
  1. Run and validate
  • Check indicator alignment (shift to avoid lookahead)
  • Inspect trades and equity curve

Key parameters and defaults

ParameterDefaultMeaning
entry_lookback20Days for breakout entry channel
exit_lookback10Days for exit channel
atr_n20ATR lookback for volatility
risk_pct0.01Fraction of equity risked per initial unit
max_units4Max pyramiding units per trade
unit_add_atr0.5Add a unit every this ATR move
fee0.5Fixed per-order fee
slip_bp1.0Slippage in basis points per trade

Notes:

  • For equities, “ATR-dollar per share” approximates ATR itself. For futures/FX, multiply by point value/contract size.

Pitfalls and gotchas

  • Lookahead bias: Always shift channels by 1 bar. Never use today’s high/low to decide today’s entry.
  • ATR warmup: ATR and channels are invalid until enough history exists. Skip trades until indicators are ready.
  • Shorting constraints: The example allows shorting without borrow or fees. In production, include borrow availability and costs.
  • Sizing realism: This example assumes fractional liquidity at mid with bp slippage. For small caps or crypto, use volume filters and conservative slippage.
  • Pyramiding risk: Adding units increases exposure nonlinearly. Consider capping total risk or trailing stops.
  • System 2 filter: Original rules skip a 55-day entry if the last 55-day breakout trade was profitable. If you implement it, track per-market last outcome.
  • Transaction costs: Results are sensitive to fees and slippage; stress test these.

Performance notes

  • Vectorize indicators: rolling and ewm are fast in pandas. Avoid Python loops for indicator calc; use loops only where path dependence exists (position management).
  • Pre-allocate: Store arrays (numpy) for value/position to reduce overhead.
  • numba: JIT-compile the trade loop for 3–20x speedup on large datasets.
  • Multiple symbols: Process per symbol, then concatenate results. Avoid iterrows; prefer iloc with integer indices.
  • Memory: For long histories, store only needed series (Close, High, Low, ATR, channels) and write detailed trades to disk.

Variations

  • System 2: entry_lookback=55, exit_lookback=20, with the “no new entry after profitable trade” filter.
  • Long-only: Disable shorts for markets where shorting is impractical.
  • Risk model: Use volatility target per portfolio (e.g., 10% annualized) and scale exposure accordingly.
  • Portfolio: Trade multiple, uncorrelated markets and cap per-market risk to diversify.

FAQ

  • Q: Why ATR for position sizing? A: It scales exposure inversely to volatility so each trade risks similar dollars.

  • Q: What timeframes work best? A: Daily bars are standard. Intraday works, but costs and noise increase.

  • Q: How many units should I add? A: Classic is up to 4 units at 0.5 ATR spacing. Fewer units reduce risk/turnover.

  • Q: Can I use adjusted prices? A: Use unadjusted High/Low for channels and ATR; Close can be adjusted for PnL consistency.

  • Q: How do I avoid overfitting? A: Keep canonical parameters (20/10 or 55/20), test out-of-sample, and limit degrees of freedom.

Next Article: Generating Real-Time Trading Signals with yfinance and Python

Series: Algorithmic Trading with Python

Python