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
- Install prerequisites
- Python 3.9+
- pip install pandas numpy
- 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`.
- 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
- Run and validate
- Check indicator alignment (shift to avoid lookahead)
- Inspect trades and equity curve
Key parameters and defaults
Parameter | Default | Meaning |
---|---|---|
entry_lookback | 20 | Days for breakout entry channel |
exit_lookback | 10 | Days for exit channel |
atr_n | 20 | ATR lookback for volatility |
risk_pct | 0.01 | Fraction of equity risked per initial unit |
max_units | 4 | Max pyramiding units per trade |
unit_add_atr | 0.5 | Add a unit every this ATR move |
fee | 0.5 | Fixed per-order fee |
slip_bp | 1.0 | Slippage 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.