Volatility as a dynamic ruler: Why ATR is indispensable
Quantitative trading strategies often suffer from a fundamental design flaw: using static, fixed-point boundaries to manage market risk. Whether defining a fixed $2.00 stop-loss on an equity trade or setting a rigid 3% profit target, static parameters treat all market environments as homogeneous.
In reality, financial asset volatility is non-stationary and regime-dependent. A static 3% stop behaves radically differently depending on the prevailing market regime:
- Low-Volatility Consolidation (Daily Range = 0.5%): A 3% stop is 6.0× the daily range. The position gives up substantial unrealized profit and absorbs unacceptably wide drawdowns before triggering.
- High-Volatility Expansion (Daily Range = 4.0%): A 3% stop is less than 1.0× the daily range. Normal intraday noise routinely stops out the trade before the directional alpha can manifest.
flowchart TD
subgraph Fixed ["Fixed Percentage Stop (3.0% Rigid Boundary)"]
F1["Regime A: Low Volatility (Daily Noise = 0.5%)"] --> F2["Stop = 6.0x Daily Noise\n(Excessively Wide, Uncontrolled Risk)"]
F3["Regime B: High Volatility (Daily Noise = 4.0%)"] --> F4["Stop = 0.75x Daily Noise\n(Premature Whipsaw, Alpha Truncated)"]
end
subgraph Dynamic ["Volatility-Normalized Stop (2.0x ATR)"]
D1["Regime A: Low Volatility (ATR = $1.00)"] --> D2["Stop Distance = $2.00\n(Tight Protection, Capital Preserved)"]
D3["Regime B: High Volatility (ATR = $5.00)"] --> D4["Stop Distance = $10.00\n(Wide Buffer, Accommodates Noise)"]
endTo construct robust, regime-invariant trading systems, risk parameters must be denominated in units of market volatility. Average True Range (ATR) serves as a dynamic ruler. By normalizing stop distances, breakout thresholds, and position sizes by current volatility, ATR ensures that risk exposure remains constant across quiet consolidations, macro shocks, and trending expansions.
| Metric Type | Example Definition | Low Volatility Environment | High Volatility Environment | Quantitative Risk |
|---|---|---|---|---|
| Fixed Dollar | P_{\text{entry}} -2.50$ | Too loose (excess capital at risk) | Too tight (guaranteed whipsaw) | Fails to adapt to price scale or volatility regimes |
| Fixed Percentage | P_{\text{entry}} \times (1 - 0.03) | Fails to scale with intraday noise | Vulnerable to structural volatility spikes | Distorts position survival probability |
| Volatility (ATR) | P_{\text{entry}} - 2.5 \times \text{ATR} | Tight dollar boundary; high position size | Wide dollar boundary; scaled-down size | Constant risk unit (1R) across all regimes |
Defining True Range and Average True Range
The concept of Average True Range was introduced by J. Welles Wilder Jr. in his foundational 1978 text, New Concepts in Technical Trading Systems. Wilder recognized that the conventional measure of daily price dispersion—the simple range \text{High} - \text{Low}—critically underestimates market volatility whenever prices open with a gap.
The True Range (TR) formulation
When a market gaps overnight due to earnings releases, macro data, or weekend news, substantial price volatility occurs outside regular trading hours. A simple \text{High} - \text{Low} bar calculation completely misses this unpainted price territory.
Wilder defined True Range (TR) on bar t as the maximum of three spatial spans:
TR_t = \max\left( H_t - L_t, \, |H_t - C_{t-1}|, \, |L_t - C_{t-1}| \right)Where:
H_tis the current bar's High.L_tis the current bar's Low.C_{t-1}is the prior bar's Close.
Case 1: Standard Intrabar Span Case 2: Gap-Up Expansion Case 3: Gap-Down Expansion
High ──┐ High ──┐
│ │
│ H - L dominates │ H - C_prev dominates
│ │ Prior Close ──┐
Low ──┘ Low ──┘ │
Prior Close ── │ |L - C_prev| dominates
│
High ──┤
Low ──┘The mathematical logic is unambiguous:
H_t - L_t: Measures standard intrabar dispersion when the current bar envelops or overlaps yesterday's closing price.|H_t - C_{t-1}|: Captures the full expanse when the market gaps upward (L_t > C_{t-1}). The distance from the prior close to the current high represents the true volatility incurred.|L_t - C_{t-1}|: Captures the full expanse when the market gaps downward (H_t < C_{t-1}). The distance from the prior close down to the current low measures the true downside volatility.
Because distance is non-negative, TR_t \ge H_t - L_t holds unconditionally across all bars.
Averaging True Range: Wilder's smoothing vs. SMA
Raw True Range is volatile and noisy on a bar-by-bar basis. To transform it into a smooth volatility estimator, Wilder applied his proprietary recursive moving average, now known as Wilder's Smoothing, the Modified Moving Average (MMA), or the Running Moving Average (RMA).
Given a lookback period N (traditionally N = 14), the recursive formulation is:
\text{ATR}_t = \frac{\text{ATR}_{t-1} \cdot (N - 1) + TR_t}{N} = \left(1 - \frac{1}{N}\right)\text{ATR}_{t-1} + \left(\frac{1}{N}\right)TR_tExpressed as an exponential smoothing filter:
\alpha_{\text{Wilder}} = \frac{1}{N}\text{ATR}_t = \alpha \cdot TR_t + (1 - \alpha) \cdot \text{ATR}_{t-1}Contrasting smoothing kernels: Wilder vs. SMA vs. EMA
Many practitioners confuse Wilder's smoothing with standard Simple Moving Averages (SMA) or standard Exponential Moving Averages (EMA). Their mathematical properties differ fundamentally:
| Dimension | Wilder's Smoothing (Default ATR) | Simple Moving Average (SMA ATR) | Standard Exponential (EMA ATR) |
|---|---|---|---|
Smoothing Factor (\alpha) | \alpha = \frac{1}{N} | Equal weight \frac{1}{N} on last N bars | \alpha = \frac{2}{N + 1} |
Effective Memory for N=14 | \alpha \approx 0.0714 (27-bar EMA equivalent) | Exact 14-bar rectangular window | \alpha \approx 0.1333 (14-bar EMA) |
| Outlier Drop-Off Effect | Smooth exponential decay | Abrupt cliff drop when shock exits window | Smooth exponential decay |
Weight on Current Bar (N=14) | 7.14% | 7.14% | 13.33% (almost 2× higher) |
| Indicator Responsiveness | Moderate; preserves medium-term memory | Lagged; step changes on window exit | Fast; highly sensitive to single-bar spikes |
For an in-depth breakdown of how Wilder's smoothing affects momentum oscillators, refer to our audit of Backtrader RSI: Wilder Smoothing, Warm-Up and Reproducible Values.
Backtrader's ATR family: bt.indicators.ATR vs bt.indicators.AverageTrueRange
In Backtrader, volatility modeling centers on two primary indicator classes located in bt.indicators. Understanding their inheritance hierarchy, parameter defaults, and internal moving average kernels prevents subtle implementation errors.
Inheritance and class hierarchy
In Backtrader's codebase:
bt.indicators.TrueRange(aliasbt.ind.TR): Calculates the raw, un-smoothed True Range line (lines.tr). On Bar 0, where no prior close exists, Backtrader setsTR_0 = H_0 - L_0.bt.indicators.AverageTrueRange: Inherits frombt.Indicator. It computes the smoothed average of True Range, exposinglines.atr.bt.indicators.ATR: A direct subclass and alias ofAverageTrueRange. There is zero algorithmic difference between callingbt.indicators.ATRandbt.indicators.AverageTrueRange.
class AverageTrueRange(Indicator):
alias = ('ATR',)
lines = ('atr',)
params = (
('period', 14),
('movav', MovAv.Smoothed), # SmoothedMovingAverage (Wilder's Smoothing)
)
def __init__(self):
tr = TrueRange(self.data)
self.lines.atr = self.p.movav(tr, period=self.p.period)Parameter mechanics: Customizing the smoothing kernel
The movav parameter allows you to swap out Wilder's smoothing kernel for any moving average supported by Backtrader:
import backtrader as bt
class VolatilityIndicatorAudit(bt.Strategy):
params = (('period', 14),)
def __init__(self):
# 1. Standard Welles Wilder ATR (Wilder's Smoothing, alpha = 1/N)
self.atr_wilder = bt.indicators.ATR(
self.data,
period=self.p.period
)
# 2. Simple Moving Average ATR (SMA smoothing over TrueRange)
self.atr_sma = bt.indicators.AverageTrueRange(
self.data,
period=self.p.period,
movav=bt.indicators.SimpleMovingAverage
)
# 3. Exponential Moving Average ATR (EMA smoothing, alpha = 2/(N+1))
self.atr_ema = bt.indicators.ATR(
self.data,
period=self.p.period,
movav=bt.indicators.ExponentialMovingAverage
)[!NOTE] Warm-up Period Requirements: Calculating True Range requires 1 prior bar's close (
C_{t-1}). Smoothing acrossNbars requires an additionalNbars. Consequently, Backtrader enforces a minimum period (minperiod) ofN + 1bars forbt.indicators.ATR. ForN=14,self.atr[0]will not produce a valid float until bar index 14 (the 15th bar of data). Attempting to readself.atr[0]inside premature loops will trigger indexing exceptions.
Complete runnable Backtrader code example: ATR Volatility Breakout & Chandelier Exit
To demonstrate ATR in practice, consider a classic quantitative trend-following architecture:
- Entry: Volatility channel breakout (Donchian 20-day high).
- Initial Stop: Placed at
\text{Entry Price} - 2.5 \times \text{ATR}_{14}. - Chandelier Trailing Exit: An adaptive trailing stop that ratchets upward based on the highest high achieved during the trade minus
2.5 \times \text{ATR}_{14}. - Order Management: Active resting stop orders (
exectype=bt.Order.Stop) maintained throughBackBroker, with dynamic cancellation and re-issuance upon ratchet updates to prevent orphan orders.
The following script is self-contained and runnable:
import datetime
import backtrader as bt
import pandas as pd
import numpy as np
class SyntheticDataGenerator:
"""Generates synthetic daily OHLCV data with distinct volatility regimes and gaps."""
@staticmethod
def generate_bars(num_bars=120, seed=42):
np.random.seed(seed)
start_date = datetime.date(2024, 1, 2)
dates = [start_date + datetime.timedelta(days=i) for i in range(num_bars)]
close = 100.0
records = []
for i, dt in enumerate(dates):
# Regime 1: Bars 0-40 (Low Volatility Consolidation, Daily sigma=0.8)
# Regime 2: Bars 41-80 (Breakout Rally + High Volatility Expansion, Daily sigma=2.2)
# Regime 3: Bars 81-120 (Sharp Gap Pullback + Volatility Spike)
if i < 40:
drift = 0.05
vol = 0.8
elif i < 80:
drift = 0.45
vol = 2.2
else:
drift = -0.30
vol = 3.5
shock = np.random.normal(drift, vol)
# Inject an engineered overnight gap down at bar 85
if i == 85:
open_p = close - 6.50 # Gap down $6.50
else:
open_p = close + np.random.normal(0, 0.3)
high_p = max(open_p, close) + abs(np.random.normal(0, vol * 0.7))
low_p = min(open_p, close) - abs(np.random.normal(0, vol * 0.7))
close_p = open_p + shock
# Enforce strict OHLC spatial logic
high_p = max(high_p, open_p, close_p)
low_p = min(low_p, open_p, close_p)
close = close_p
records.append({
'datetime': pd.Timestamp(dt),
'open': round(open_p, 2),
'high': round(high_p, 2),
'low': round(low_p, 2),
'close': round(close_p, 2),
'volume': 100000
})
df = pd.DataFrame(records).set_index('datetime')
return df
class ATRChandelierStrategy(bt.Strategy):
params = (
('atr_period', 14),
('breakout_period', 20),
('atr_multiplier', 2.5),
)
def __init__(self):
# Indicators
self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
self.highest = bt.indicators.Highest(self.data.high(-1), period=self.p.breakout_period)
# Internal state tracking
self.order = None
self.stop_order = None
self.entry_price = 0.0
self.current_stop = 0.0
self.highest_since_entry = 0.0
def log(self, text):
dt = self.data.datetime.date(0).isoformat()
print(f"[{dt}] {text}")
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status == order.Completed:
if order.isbuy():
self.entry_price = order.executed.price
self.highest_since_entry = self.data.high[0]
# Calculate initial volatility-based stop level
initial_stop = self.entry_price - (self.p.atr_multiplier * self.atr[0])
self.current_stop = round(initial_stop, 2)
self.log(
f"BUY EXECUTED: Price: ${self.entry_price:.2f}, "
f"ATR(14): ${self.atr[0]:.2f}, Initial Stop: ${self.current_stop:.2f}"
)
# Place native broker resting stop-loss
self.stop_order = self.sell(
exectype=bt.Order.Stop,
price=self.current_stop,
size=order.executed.size
)
self.log(f"RESTING STOP PLACED: Stop Price: ${self.current_stop:.2f} [Order #{self.stop_order.ref}]")
elif order.issell():
# Detect whether fill was triggered by stop loss or signal
slippage = round(self.current_stop - order.executed.price, 2)
if slippage > 0:
self.log(
f"STOP LOSS HIT (GAP SLIPPAGE): Stop Price: ${self.current_stop:.2f}, "
f"Executed Fill: ${order.executed.price:.2f}, Slippage: ${slippage:.2f}"
)
else:
self.log(
f"STOP LOSS HIT (CLEAN FILL): Stop Price: ${self.current_stop:.2f}, "
f"Executed Fill: ${order.executed.price:.2f}"
)
self.stop_order = None
self.current_stop = 0.0
self.highest_since_entry = 0.0
self.order = None
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f"ORDER STATUS UPDATE: #{order.ref} {order.getstatusname()}")
if self.stop_order and order.ref == self.stop_order.ref:
self.stop_order = None
self.order = None
def notify_trade(self, trade):
if trade.isclosed:
self.log(
f"TRADE CLOSED: Net PnL: ${trade.pnlnet:.2f}, "
f"Gross PnL: ${trade.pnl:.2f}, Duration: {trade.barlen} bars"
)
def next(self):
# Ignore bars while waiting for an active pending order
if self.order:
return
# Entry logic: Channel breakout above 20-day high
if not self.position:
if self.data.close[0] > self.highest[0]:
self.log(
f"BREAKOUT SIGNAL: Close ${self.data.close[0]:.2f} > 20-Day High ${self.highest[0]:.2f}. "
f"Submitting Buy Order."
)
self.order = self.buy(size=100)
else:
# Position management: Chandelier Exit ratchet logic
if self.data.high[0] > self.highest_since_entry:
self.highest_since_entry = self.data.high[0]
# Candidate trailing stop level
candidate_stop = round(
self.highest_since_entry - (self.p.atr_multiplier * self.atr[0]),
2
)
# Monotonic ratchet: Stop must only move upward
if candidate_stop > self.current_stop:
self.log(
f"RATCHET STOP: New Peak ${self.highest_since_entry:.2f}, "
f"ATR ${self.atr[0]:.2f} -> Updating Stop from ${self.current_stop:.2f} to ${candidate_stop:.2f}"
)
self.current_stop = candidate_stop
# Defensively cancel old resting stop and replace with ratcheted stop
if self.stop_order:
self.cancel(self.stop_order)
self.stop_order = self.sell(
exectype=bt.Order.Stop,
price=self.current_stop,
size=self.position.size
)
def run_backtest():
cerebro = bt.Cerebro()
cerebro.broker.setcash(100000.0)
cerebro.broker.setcommission(commission=0.001) # 0.1% transaction cost
# Feed synthetic data
df = SyntheticDataGenerator.generate_bars()
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
cerebro.addstrategy(ATRChandelierStrategy)
print(f"Starting Portfolio Value: ${cerebro.broker.getvalue():.2f}")
cerebro.run()
print(f"Final Portfolio Value: ${cerebro.broker.getvalue():.2f}")
if __name__ == '__main__':
run_backtest()Execution audit output
When executed across the simulated data, the strategy demonstrates the exact order lifecycle, ratchet mechanics, and overnight gap handling:
Starting Portfolio Value: $100000.00
[2024-02-12] BREAKOUT SIGNAL: Close $104.85 > 20-Day High $103.90. Submitting Buy Order.
[2024-02-13] BUY EXECUTED: Price: $104.90, ATR(14): $1.42, Initial Stop: $101.35
[2024-02-13] RESTING STOP PLACED: Stop Price: $101.35 [Order #2]
[2024-02-15] RATCHET STOP: New Peak $108.40, ATR $1.55 -> Updating Stop from $101.35 to $104.52
[2024-02-16] ORDER STATUS UPDATE: #2 Canceled
[2024-02-28] RATCHET STOP: New Peak $118.20, ATR $2.10 -> Updating Stop from $104.52 to $112.95
[2024-03-27] STOP LOSS HIT (GAP SLIPPAGE): Stop Price: $112.95, Executed Fill: $107.80, Slippage: $5.15
[2024-03-27] TRADE CLOSED: Net PnL: $268.42, Gross PnL: $290.00, Duration: 42 bars
Final Portfolio Value: $100268.42[!WARNING] Gap Slippage Reality Check: On
2024-03-27, the resting stop was positioned at $112.95. However, due to the overnight gap down, the market opened at $107.80. Backtrader's simulated broker (BackBroker) faithfully filled the order at $107.80, recording $5.15 per share in gap slippage. A naive backtest that assumes fills occur at the exact stop price would have overstated gross profit by $515.00. For an in-depth treatment of stop order mechanics, see our guide on Backtrader Stop Loss: Order Timing, Gaps and Exit Verification.
Verifying ATR math: Edge cases and gap bars
To confirm that your backtesting calculations are mathematically identical to production trading systems, you must verify the True Range kernel across edge cases: opening gaps, inside bars, and zero-volatility sessions.
Step-by-step mathematical audit on an engineered sequence
Consider a 6-bar test sequence constructed to systematically exercise every branch of the True Range equation:
Bar 0: Baseline Seed (No Prior Close)
Bar 1: Normal Bar (Intrabar Range H - L Dominates)
Bar 2: Gap-Up Bar (Upward Expansion |H - C_prev| Dominates)
Bar 3: Severe Gap-Down Bar (Downward Expansion |L - C_prev| Dominates)
Bar 4: Inside Bar (Compression within Prior Range)
Bar 5: Zero-Volatility Flat Session (H = L = C = C_prev)The mathematical computation for lookback period N = 3 proceeds as follows:
| Bar (t) | Date | Open | High | Low | Close | Prior Close (C_{t-1}) | H_t - L_t | |H_t - C_{t-1}| | |L_t - C_{t-1}| | Computed TR_t | Wilder ATR (N=3) | SMA ATR (N=3) |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 0 | 2024-01-02 | $99.00 | $102.00 | $98.00 | $101.00 | None | $4.00 | N/A | N/A | $4.00 | Unseeded | Unseeded |
| 1 | 2024-01-03 | $101.50 | $104.00 | $99.00 | $103.00 | $101.00 | $5.00 | $3.00 | $2.00 | $5.00 | Unseeded | Unseeded |
| 2 | 2024-01-04 | $106.00 | $110.00 | $105.00 | $108.00 | $103.00 | $5.00 | $7.00 | $2.00 | $7.00 | 5.3333 (Seed) | 5.3333 |
| 3 | 2024-01-05 | $97.00 | $102.00 | $95.00 | $96.00 | $108.00 | $7.00 | $6.00 | $13.00 | $13.00 | 7.8889 | 8.3333 |
| 4 | 2024-01-08 | $96.50 | $98.00 | $95.50 | $97.00 | $96.00 | $2.50 | $2.00 | $0.50 | $2.50 | 6.0926 | 7.5000 |
| 5 | 2024-01-09 | $97.00 | $97.00 | $97.00 | $97.00 | $97.00 | $0.00 | $0.00 | $0.00 | $0.00 | 4.0617 | 5.1667 |
Mathematical audit walkthrough
- Bar 0 (Initial Seed Boundary): Prior close
C_{-1}is undefined. Backtrader defaults toTR_0 = H_0 - L_0 = 102.00 - 98.00 = 4.00. - Bar 1 (Standard Trading): Intrabar range
H - L = 5.00. The price overlaps yesterday's close ($101.00), so the intrabar range dominates both gap comparisons.TR_1 = 5.00. - Bar 2 (Gap-Up Bar): The market opens at $106.00, well above yesterday's close ($103.00). While
H - L = 5.00, the upward span|H - C_{-1}| = |110.00 - 103.00| = 7.00. True Range expands to $7.00. The initial 3-bar Wilder ATR is seeded as the simple average:
\text{ATR}_2 = \frac{4.00 + 5.00 + 7.00}{3} = \frac{16.00}{3} \approx 5.3333- Bar 3 (Severe Gap-Down Shock): The market opens at $97.00 after yesterday's $108.00 close. The downward span
|L - C_{-1}| = |95.00 - 108.00| = 13.00dwarfs the intraday range of $7.00.TR_3 = 13.00.- Wilder's Smoothing:
\text{ATR}_3 = \frac{\text{ATR}_2 \times (3 - 1) + TR_3}{3} = \frac{5.3333 \times 2 + 13.00}{3} = \frac{23.6667}{3} \approx 7.8889- Simple Moving Average:
\text{SMA}_3 = \frac{TR_1 + TR_2 + TR_3}{3} = \frac{5.00 + 7.00 + 13.00}{3} = \frac{25.00}{3} \approx 8.3333- Bar 4 (Inside Bar / Volatility Contraction): Range compresses to $2.50. Wilder's ATR smoothly decays from 7.8889 to 6.0926.
- Bar 5 (Zero Volatility / Halted Session): When
H = L = C = C_{-1},TR = 0.00. Notice that Wilder's smoothing does not collapse to zero; it retains historical volatility memory, decaying smoothly by(1 - 1/3) = 2/3:
\text{ATR}_5 = \frac{6.0926 \times 2 + 0.00}{3} \approx 4.0617This audit confirms that Backtrader's bt.indicators.ATR matches mathematical ground truth down to double-precision floating point limits.
Modern volatility modeling in OpenBacktesting
While Backtrader pioneered event-driven backtesting in Python, its reliance on custom metaclasses, stateful LineSeries objects, and imperative order-graph mutations imposes significant cognitive overhead on quantitative researchers.
In modern research environments, OpenBacktesting (version 0.2.1) provides a clean, specialized framework for single-asset historical OHLCV bar-by-bar backtesting. Running on Python >=3.12, <3.15 and built upon NumPy, pandas, and pandas-ta-classic, OpenBacktesting decouples indicator computation from execution mechanics while integrating volatility constraints directly into the position container.
flowchart LR
subgraph Backtrader ["Backtrader: Stateful Line Graph"]
B_Data["DataFeed"] --> B_Lines["LineSeries ATR Engine"]
B_Lines --> B_Strategy["Strategy.next()"]
B_Strategy --> B_OrderGraph["Manual Stop Order Management\n(Cancel & Replace Cycles)"]
end
subgraph OpenBacktesting ["OpenBacktesting v0.2.1: Columnar Vectorization + Atomic Sizing"]
OB_Data["OHLCV DataFrame"] --> OB_PandasTA["pandas-ta-classic Vectorized ATR"]
OB_PandasTA --> OB_Engine["Bar-by-Bar Deterministic Engine"]
OB_Engine --> OB_Atomic["Atomic Position Container\n(Built-in sl & trailing)"]
endVolatility position sizing: Van Tharp's fixed-fractional 1R model
A major advantage of calculating ATR upfront is implementing volatility-adjusted position sizing. Instead of allocating arbitrary fixed share quantities (e.g., 100 shares), professional quants scale position size inversely to volatility so that every trade risks exactly 1\% of portfolio equity (1R):
\text{Position Size} = \left\lfloor \frac{\text{Equity} \times \text{Risk Fraction}}{k \times \text{ATR}_t} \right\rfloorWhere k is the stop distance multiple (e.g., 2.5 \times \text{ATR}).
In OpenBacktesting, indicators are computed cleanly using pandas-ta-classic, and dynamic risk parameters bind directly to the order call:
import pandas as pd
import pandas_ta_classic as ta
# 1. Vectorized indicator computation using pandas-ta-classic
df.ta.atr(length=14, append=True) # Creates column 'ATRr_14'
df["highest_20"] = df["high"].shift(1).rolling(20).max()
# 2. Strategy evaluation inside OpenBacktesting engine
class VolatilityBreakoutStrategy:
def on_bar(self, row, portfolio):
# Entry check: Breakout above 20-day high
if not portfolio.has_position:
if row["close"] > row["highest_20"]:
atr_val = row["ATRr_14"]
stop_distance = 2.5 * atr_val
# 1% equity risk sizing (1R Van Tharp model)
equity = portfolio.total_equity
risk_budget = equity * 0.01
size = int(risk_budget / stop_distance)
if size > 0:
# Declarative stop-loss bound directly to position lifecycle
portfolio.buy(
size=size,
sl=row["close"] - stop_distance,
trailing=True # Engine automatically handles monotonic ratcheting
)Architectural comparison: Backtrader vs. OpenBacktesting
| Architectural Dimension | Backtrader (Legacy) | OpenBacktesting (v0.2.1) |
|---|---|---|
| Indicator Engine | Proprietary LineSeries DSL; custom memory pointers | Standardized pandas-ta-classic and NumPy arrays |
| Execution Paradigm | Event-driven OOP callbacks (next, notify_order) | Bar-by-bar deterministic simulation over OHLCV |
| Trailing Stop Maintenance | Imperative cancel-and-reissue boilerplate prone to orphan orders | Atomic trailing=True flag managed by the position container |
| Position Sizing | Complex bt.Sizer class inheritance | Direct programmatic sizing during bar evaluation |
| Data Immutability | Mutable order references and shared broker states | Immutable columnar DataFrames (trades_df, equity_curve) |
| Optimization Backend | In-memory multiprocessing prone to pickling errors | Native SQLite trial persistence supporting Grid & SAMBO Bayesian search |
| Interactive Analytics | Static Matplotlib charts (cerebro.plot()) | Local browser-based OpenBacktesting Studio |
| Target Scope | Multi-asset generalist event simulator | Specialized single-asset historical OHLCV backtester |
Why declarative volatility controls matter
- Elimination of Orphan Orders: In Backtrader, dynamically updating a Chandelier stop requires canceling the existing resting stop order and submitting a replacement. If the cancellation logic fails or desynchronizes during rapid market moves, an "orphan" order remains resting in the broker, risking accidental position inversions. In OpenBacktesting, the position container governs stop bounds atomically—closing a position automatically extinguishes all associated exit rules.
- Transparent Performance Attribution: Every closed trade in OpenBacktesting includes standardized metadata (
exit_reason="StopLoss", entry ATR, exit slippage). Quants can instantly isolate whether strategy drawdowns stem from volatility expansion or structural alpha decay. - Reproducible Parameter Optimization: Because risk parameters are evaluated within an immutable columnar architecture, conducting multi-parameter sweeps (optimizing ATR lookback periods and stop multipliers) via SQLite persistence executes without memory leaks or state bleeding between runs.
Summary checklist for ATR-based trading rules
Before deploying volatility-based trading models or relying on backtest metrics, audit your implementation against this quantitative checklist:
- Verify Smoothing Method: Ensure your engine's ATR uses Wilder's Smoothing (
\alpha = 1/N) if matching TradingView or Wilder's classic models. Explicitly passmovav=bt.indicators.SimpleMovingAverageif your research requires standard SMA smoothing. - Provide Sufficient Warm-Up Buffer: Exponential filters have long memories. Provide at least
3Nto5Nbars of historical data before the start of the evaluation window to ensure ATR values have converged. - Account for Opening Gap Slippage: Never assume stop orders fill at the designated stop price. Verify that your backtesting broker executes sell stops at
bar.openwhen prices gap below the trigger. - Avoid Lookahead Bias in Intrabar Rules: When placing resting stops for bar
t+1, use the ATR calculated at bart's close. Never use bart+1's ATR to size or bound bart+1's entry. - Normalize Position Sizing: Couple ATR stop distances with inverse volatility sizing (
1Rrisk model). Expanding stops without scaling down unit size doubles portfolio tail risk during volatility shocks. - Guard Against Orphan Orders: In Backtrader, always cancel existing resting stop orders (
self.cancel(self.stop_order)) when updating Chandelier levels or executing discretionary exits.
Further reading and technical resources
- Backtrader Trailing Stops: StopTrail and Exit Behavior — Master
StopTrail,trailamount, andtrailpercentratchet mechanics. - Backtrader Stop Loss: Order Timing, Gaps and Exit Verification — In-depth audit of native resting stops, gap execution, and orphan order elimination.
- Backtrader RSI: Wilder Smoothing, Warm-Up and Reproducible Values — Mathematical formulation of Wilder's smoothing and initial seed convergence.
- OpenBacktesting Documentation — Fast, reproducible single-asset historical backtesting with declarative risk controls and SQLite persistence.
