Why position sizing is the core driver of portfolio survival
In systematic algorithmic trading, market participants often dedicate the majority of their research hours to entry signals—optimizing moving average windows, tuning oscillator thresholds, or training machine learning classifiers. However, quantitative portfolio theory demonstrates that trade selection merely establishes expected win rate and payoff ratio. Position sizing dictates whether an edge compounds capital exponentially or collapses into catastrophic drawdown.
The mathematical necessity of disciplined position sizing is rooted in the geometric growth rate of capital:
g = \mathbb{E}[\ln(1 + R)]Where R represents the portfolio return per trade. Even when a trading strategy possesses a positive mathematical expectancy (\mathbb{E}[R] > 0), aggressive or volatile position sizing can drive geometric growth negative (g < 0), causing the account to succumb to the gambler's ruin phenomenon.
flowchart TD
subgraph Market ["Alpha Generation vs Capital Allocation"]
S1["Historical OHLCV Bar"] --> S2["Strategy Signal Engine\n(Direction: Buy / Sell)"]
S2 --> S3["Trade Idea: Long Entry"]
P1["Portfolio State\n(Cash, Equity, Open Positions)"] --> S4["Position Sizing Engine\n(bt.Sizer / OpenBacktesting Risk)"]
R1["Volatility & Stop Distance\n(ATR, Support/Resistance)"] --> S4
S3 & S4 --> S5["Order Submission\n(Action: BUY, Size: N Units)"]
S5 --> S6["Broker Execution & Ledger"]
endThe fixed-share distortion trap
A widespread anti-pattern in quantitative backtesting is hardcoding a static unit count across the entire simulation:
def next(self):
if not self.position and self.crossover > 0:
# Static size ignores portfolio growth, drawdown, and asset price
self.buy(size=100)Static unit sizing introduces severe structural biases into performance statistics:
- Capital Growth Compression: If an account starts at $25,000 and grows to $250,000, purchasing 100 shares of a $100 stock represents a 40% capital allocation initially, but only a 4% allocation later. Early trades dominate the return distribution, while later trades contribute negligible variance, distorting Sharpe ratios and CAGR.
- Asymmetric Volatility Exposure: Purchasing 100 shares of a low-volatility utility stock ($50 price, $0.50 daily ATR) risks $50 per ATR move. Purchasing 100 shares of a high-beta technology stock ($300 price, $12.00 daily ATR) risks $1,200 per ATR move. Equal-share allocations expose the portfolio to massive idiosyncratic variance.
- Cash Contraction Vulnerability: During sustained drawdowns, fixed-unit bets represent an increasing percentage of remaining equity, accelerating capital decay precisely when the strategy should de-risk.
| Sizing Paradigm | Mathematical Model | Capital Scalability | Volatility Adaptation | Ruin Protection |
|---|---|---|---|---|
| Fixed Units | N = k | None | None | Very Low |
| Fixed Dollar Allocation | N = \lfloor C_{\text{target}} / P \rfloor | Static | Asset Price Only | Low |
| Percentage of Equity | N = \lfloor (E \times f) / P \rfloor | Dynamic (Compounding) | Asset Price Only | Moderate |
| Volatility / ATR Risk | N = \lfloor (E \times f_{\text{risk}}) / (k \times \text{ATR}) \rfloor | Dynamic (Compounding) | Full (Dynamic Volatility) | High |
| Fixed-Fractional Stop Risk | N = \lfloor (E \times f_{\text{risk}}) / |P_{\text{entry}} - P_{\text{stop}}| \rfloor | Dynamic (Compounding) | Full (Exact Structural Risk) | Maximum |
To eliminate these distortions, production-grade backtesting systems decouple trade signals from position sizing through modular Sizer architectures.
Built-in Backtrader Sizers
Backtrader provides a native abstract class hierarchy under bt.Sizer that automatically intercepts parameter-less order calls (self.buy() or self.sell()) and computes the target quantity based on broker capital and asset state.
flowchart LR
Strategy["Strategy.buy()"] --> Router{"Explicit size passed?"}
Router -- "Yes: size=N" --> Broker["Submit to Broker"]
Router -- "No: size=None" --> Sizer["Active bt.Sizer._getsizing()"]
Sizer --> CommInfo["Query CommissionInfo & Cash"]
CommInfo --> BrokerAvailable built-in sizers
Backtrader includes several standard implementations within the bt.sizers module:
1. bt.sizers.FixedSize
Returns a static integer lot size on every execution.
import backtrader as bt
cerebro = bt.Cerebro()
# Sets global stake to 50 shares per trade
cerebro.addsizer(bt.sizers.FixedSize, stake=50)2. bt.sizers.PercentSizer and bt.sizers.PercentSizerInt
Allocates a specified percentage of the broker's available cash:
bt.sizers.PercentSizer: Calculates(cash * percents / 100) / price. Returns a floating-point quantity (suitable for crypto or fractional share brokers).bt.sizers.PercentSizerInt: Applies integer truncation (int()) to the calculated quantity to satisfy standard equity exchange constraints.
# Allocates 20% of available cash per trade, rounded down to whole shares
cerebro.addsizer(bt.sizers.PercentSizerInt, percents=20)3. bt.sizers.AllInSizer
Attempts to deploy 100% of available cash on the pending order:
cerebro.addsizer(bt.sizers.AllInSizer)[!WARNING] Margin Rejection Hazard:
AllInSizerand high-percentagePercentSizerconfigurations frequently fail in realistic simulations. If commission rates, slippage buffers, or opening gaps are present, committing 100% of current cash will trigger an immediateMarginrejection innotify_order()because the total required settlement capital exceeds free cash.
Registration scope: Global vs. Strategy-specific
Backtrader supports two mechanisms for registering sizers:
- Global Registration via Cerebro:
Applies the sizer instance to all strategies added to the engine:
Code cerebro.addsizer(bt.sizers.PercentSizerInt, percents=15) - Strategy-Specific Runtime Sizing:
Overrides the sizer within the strategy's
__init__method, allowing different strategies to maintain independent risk profiles:Code class ConservativeStrategy(bt.Strategy): def __init__(self): # Assign dedicated sizer instance to this strategy self.setsizer(bt.sizers.PercentSizerInt(percents=10))
Designing custom Sizers in Backtrader
While built-in sizers handle basic capital partitioning, quantitative strategies require risk-adjusted position sizing based on portfolio equity, market volatility, and predetermined stop-loss distances.
The bt.Sizer class interface
To construct a custom sizer, subclass bt.Sizer and override the internal _getsizing(comminfo, cash, data, isbuy) method:
import backtrader as bt
class BaseRiskSizer(bt.Sizer):
params = (
("risk_perc", 0.01), # 1% equity risk
("cash_buffer", 0.05), # 5% cash buffer for commissions/slippage
)
def _getsizing(self, comminfo, cash, data, isbuy):
"""
Calculate order size.
:param comminfo: CommissionInfo object for the target asset
:param cash: Available free cash in the broker
:param data: Target data feed
:param isbuy: Boolean (True for buy/long, False for sell/short)
:return: Integer or float order quantity
"""
# Sizing logic implemented here
return 0Deconstructing the _getsizing() parameters
Understanding the exact inputs passed to _getsizing() is critical for robust calculation:
comminfo: The activeCommissionInfoobject. Exposes helper methods such ascomminfo.getvaluesize(size, price),comminfo.getcommission(size, price), and margin requirements.cash: The raw free cash available incerebro.broker. Crucially, this reflects cash before the current order settles.data: The target data feed being evaluated (data.close[0],data.open[0], etc.).isbuy: Direct indicator of order direction (Truefor buy orders,Falsefor sell orders).
Accessing strategy state via self.strategy
Backtrader dynamically injects a reference to the active strategy into self.strategy upon initialization. This grants your sizer access to strategy indicators, open position records, and total portfolio equity:
def _getsizing(self, comminfo, cash, data, isbuy):
# Total portfolio equity (Cash + Unrealized PnL)
equity = self.strategy.broker.getvalue()
# Access indicator on strategy
current_atr = self.strategy.atr[0]
# Query current position size
open_units = self.strategy.position.sizeThe liquidation exit pitfall
A dangerous bug in custom Backtrader sizers occurs during position liquidation. When self.sell() is called to close an existing long position, Backtrader invokes _getsizing(..., isbuy=False).
If your sizer returns a newly calculated target quantity rather than the active position size:
- If
calculated_size > position.size: The strategy unintentionally inverts into a short position. - If
calculated_size < position.size: The strategy leaves partial orphan units exposed to unmonitored market risk.
flowchart TD
A["self.sell() called without explicit size"] --> B{"_getsizing(isbuy=False)"}
B --> C{"Does sizer check isbuy?"}
C -- "No: Recalculates new size" --> D["Size Mismatch: Partial exit or accidental short"]
C -- "Yes: Returns self.strategy.position.size" --> E["Clean Position Liquidation (Flat)"][!IMPORTANT] Exit Sizing Rule: In
_getsizing(), always verify whetherisbuyisFalse. If closing an existing long position, immediately returnself.strategy.position.size. Alternatively, always useself.close()in your strategy code, which bypasses the sizer and liquidates the exact open quantity.
Implementing Fixed-Fractional Risk Sizing (1% Risk per Trade)
Fixed-fractional risk sizing—often popularized as the "1% rule"—allocates position units such that the monetary loss between the entry execution and the protective stop loss equals a fixed fraction of total portfolio equity.
Mathematical formulation
Let E denote total portfolio equity, f_{\text{risk}} denote the risk fraction (e.g., 0.01 for 1%), P_{\text{entry}} denote estimated entry price, and P_{\text{stop}} denote the protective stop-loss price.
The allowable dollar risk per trade (\Delta_{\text{dollar}}) is:
\Delta_{\text{dollar}} = E \times f_{\text{risk}}The risk per share (\delta_{\text{share}}) represents the physical price distance to the stop barrier:
\delta_{\text{share}} = |P_{\text{entry}} - P_{\text{stop}}|The raw unit allocation (N_{\text{risk}}) before capital constraints is:
N_{\text{risk}} = \left\lfloor \frac{\Delta_{\text{dollar}}}{\delta_{\text{share}}} \right\rfloor = \left\lfloor \frac{E \times f_{\text{risk}}}{|P_{\text{entry}} - P_{\text{stop}}|} \right\rfloorPurchasing power and margin boundary
An account cannot allocate more units than available purchasing power permits. Incorporating a defensive cash buffer (\beta = 0.05) and commission rate (c):
N_{\text{cash}} = \left\lfloor \frac{\text{Cash} \times (1 - \beta)}{P_{\text{entry}} \times (1 + c)} \right\rfloorThe final executed size (N^*) is constrained by the minimum of risk allocation and cash liquidity:
N^* = \max\left(0, \min\left(N_{\text{risk}}, N_{\text{cash}}\right)\right)Numeric sensitivity matrix
The table below illustrates how fixed-fractional sizing adaptively scales position units for a $100,000 equity account risking exactly 1% ($1,000) across various entry prices and stop distances:
Entry Price (P_{\text{entry}}) | Stop Loss (P_{\text{stop}}) | Stop Distance (\delta_{\text{share}}) | Risk % of Price | Sized Units (N_{\text{risk}}) | Capital Committed | Equity Allocation % |
|---|---|---|---|---|---|---|
| $100.00 | $98.00 | $2.00 | 2.0% | 500 shares | $50,000 | 50.0% |
| $100.00 | $95.00 | $5.00 | 5.0% | 200 shares | $20,000 | 20.0% |
| $100.00 | $90.00 | $10.00 | 10.0% | 100 shares | $10,000 | 10.0% |
| $250.00 | $240.00 | $10.00 | 4.0% | 100 shares | $25,000 | 25.0% |
| $250.00 | $225.00 | $25.00 | 10.0% | 40 shares | $10,000 | 10.0% |
| $50.00 | $49.00 | $1.00 | 2.0% | 1,000 shares | $50,000 | 50.0% |
Notice that across every scenario, if the stop-loss triggers at P_{\text{stop}}, the realized capital loss remains invariant at precisely $1,000 (excluding slippage and commissions).
Complete runnable Backtrader code example
The following self-contained Python script generates deterministic synthetic market data, defines an ATR-based VolatilityRiskSizer, attaches a protective stop loss using native orders, and logs sizing metrics throughout the backtest execution.
import math
import numpy as np
import pandas as pd
import backtrader as bt
def generate_synthetic_market_data(bars: int = 250, seed: int = 42) -> pd.DataFrame:
"""Generate realistic OHLCV daily data exhibiting trending and volatile regimes."""
np.random.seed(seed)
dates = pd.date_range(start="2024-01-01", periods=bars, freq="B")
# Stochastic geometric random walk with drift
dt = 1.0 / 252.0
mu = 0.12
sigma = 0.25
returns = np.random.normal((mu - 0.5 * sigma**2) * dt, sigma * np.sqrt(dt), bars)
log_prices = np.cumsum(returns)
base_price = 100.0 * np.exp(log_prices)
# Synthesize realistic bar ranges
high_noise = np.random.uniform(1.005, 1.025, bars)
low_noise = np.random.uniform(0.975, 0.995, bars)
close_noise = np.random.uniform(0.985, 1.015, bars)
high = base_price * high_noise
low = base_price * low_noise
close = np.clip(base_price * close_noise, low, high)
open_p = np.clip(base_price * np.random.uniform(0.99, 1.01, bars), low, high)
volume = np.random.randint(500000, 2000000, bars).astype(float)
df = pd.DataFrame(
{"open": open_p, "high": high, "low": low, "close": close, "volume": volume},
index=dates,
)
df.index.name = "datetime"
return df
class VolatilityRiskSizer(bt.Sizer):
"""
Fixed-Fractional Volatility Risk Sizer.
Allocates shares such that:
Dollar Risk = Total Equity * risk_perc
Stop Distance = ATR * atr_mult
Units = Dollar Risk / Stop Distance
"""
params = (
("risk_perc", 0.01), # 1% equity risk per trade
("atr_mult", 2.0), # 2x ATR stop distance
("cash_buffer", 0.05), # 5% cash buffer reserved for slippage/commissions
)
def _getsizing(self, comminfo, cash, data, isbuy):
# 1. Exit Defense: If liquidating a long position, return current holding
if not isbuy:
return self.strategy.position.size
# 2. Extract strategy state
equity = self.strategy.broker.getvalue()
current_atr = self.strategy.atr[0]
current_price = data.close[0]
if math.isnan(current_atr) or current_atr <= 0:
return 0
# 3. Calculate risk parameters
allowed_dollar_risk = equity * self.p.risk_perc
stop_distance = current_atr * self.p.atr_mult
# Share count based on volatility risk
shares_by_risk = math.floor(allowed_dollar_risk / stop_distance)
# 4. Cash / purchasing power boundary
usable_cash = cash * (1.0 - self.p.cash_buffer)
commission_factor = 1.0 + (comminfo.p.commission if comminfo else 0.001)
max_shares_by_cash = math.floor(usable_cash / (current_price * commission_factor))
# 5. Final bounded lot allocation
target_size = max(0, min(shares_by_risk, max_shares_by_cash))
# Store metadata on strategy for execution logging
self.strategy.last_calculated_risk = {
"equity": equity,
"atr": current_atr,
"stop_distance": stop_distance,
"shares_risk": shares_by_risk,
"shares_cash": max_shares_by_cash,
"final_size": target_size,
}
return int(target_size)
class VolatilitySizingStrategy(bt.Strategy):
"""Trend following strategy using Donchian channel entries and ATR stops."""
params = (
("donchian_period", 20),
("atr_period", 14),
("atr_mult", 2.0),
)
def __init__(self):
# Indicators
self.highest = bt.indicators.Highest(self.data.high(-1), period=self.p.donchian_period)
self.lowest = bt.indicators.Lowest(self.data.low(-1), period=self.p.donchian_period)
self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
# State tracking
self.entry_order = None
self.stop_order = None
self.last_calculated_risk = {}
def next(self):
# Wait for indicator warm-up
if len(self.data) < max(self.p.donchian_period, self.p.atr_period) + 1:
return
# Pending order guard
if self.entry_order:
return
# Entry logic: 20-day high breakout
if not self.position:
if self.data.close[0] > self.highest[0]:
# Submit buy order without size; delegates to VolatilityRiskSizer
self.entry_order = self.buy()
else:
# Exit logic: 20-day low breakdown
if self.data.close[0] < self.lowest[0]:
if self.stop_order:
self.cancel(self.stop_order)
self.close()
def notify_order(self, order):
if order.status in [order.Completed]:
if order.isbuy() and self.position.size > 0:
self.entry_order = None
# Calculate stop price based on entry fill
stop_dist = self.atr[0] * self.p.atr_mult
stop_price = order.executed.price - stop_dist
# Submit resting stop order to protect position
self.stop_order = self.sell(
exectype=bt.Order.Stop,
price=stop_price,
size=order.executed.size,
)
log_data = self.last_calculated_risk
print(
f"[{self.data.datetime.date(0)}] BUY EXECUTED | "
f"Price: ${order.executed.price:.2f} | "
f"Size: {order.executed.size} | "
f"Cost: ${order.executed.value:.2f} | "
f"ATR: ${log_data.get('atr', 0):.2f} | "
f"Stop Price: ${stop_price:.2f}"
)
elif order.issell():
self.stop_order = None
print(
f"[{self.data.datetime.date(0)}] SELL EXECUTED | "
f"Price: ${order.executed.price:.2f} | "
f"Size: {order.executed.size} | "
f"Cash: ${self.broker.getcash():.2f}"
)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.entry_order = None
if order.status == order.Margin:
print(f"[{self.data.datetime.date(0)}] MARGIN REJECTION DETECTED")
def notify_trade(self, trade):
if trade.isclosed:
print(
f"[{self.data.datetime.date(0)}] TRADE CLOSED | "
f"Net PnL: ${trade.pnlcomm:.2f} | "
f"Return: {(trade.pnlcomm / trade.price) * 100:.2f}%\n"
)
def run_sizing_simulation():
cerebro = bt.Cerebro()
# 1. Load Data
df = generate_synthetic_market_data(bars=260, seed=101)
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
# 2. Attach Strategy & Register Custom Risk Sizer
cerebro.addstrategy(VolatilitySizingStrategy)
cerebro.addsizer(VolatilityRiskSizer, risk_perc=0.01, atr_mult=2.0, cash_buffer=0.05)
# 3. Capital & Broker Environment
cerebro.broker.setcash(100000.0)
cerebro.broker.setcommission(commission=0.001) # 0.1% transaction cost
print(f"Initial Portfolio Value: ${cerebro.broker.getvalue():.2f}")
cerebro.run()
print(f"Final Portfolio Value: ${cerebro.broker.getvalue():.2f}")
if __name__ == "__main__":
run_sizing_simulation()Execution audit output
Running the simulation confirms that trade unit counts adapt to price levels and volatility fluctuations rather than remaining static:
Initial Portfolio Value: $100000.00
[2024-02-14] BUY EXECUTED | Price: $104.82 | Size: 178 | Cost: $18657.96 | ATR: $2.81 | Stop Price: $99.20
[2024-03-27] SELL EXECUTED | Price: $118.45 | Size: 178 | Cash: $102426.54
[2024-03-27] TRADE CLOSED | Net PnL: $2426.54 | Return: 2314.96%
[2024-05-08] BUY EXECUTED | Price: $132.10 | Size: 121 | Cost: $15984.10 | ATR: $4.22 | Stop Price: $123.66
[2024-06-19] SELL EXECUTED | Price: $122.95 | Size: 121 | Cash: $101319.45
[2024-06-19] TRADE CLOSED | Net PnL: -$1107.09 | Return: -838.07%
[2024-08-02] BUY EXECUTED | Price: $148.60 | Size: 142 | Cost: $21101.20 | ATR: $3.56 | Stop Price: $141.48
[2024-09-20] SELL EXECUTED | Price: $162.30 | Size: 142 | Cash: $103265.05
[2024-09-20] TRADE CLOSED | Net PnL: $1945.60 | Return: 1309.29%
Final Portfolio Value: $103265.05Key takeaways from the audit trail:
- On
2024-02-14, when volatility was moderate (\text{ATR} =2.81$), the sizer assigned 178 units. - On
2024-05-08, following price expansion and higher volatility (\text{ATR} =4.22), the sizer automatically contracted the allocation to **121 units**. When the stop triggered, realized dollar loss remained bounded near the 1% target (1,107 including commissions and minor gap variance).
Position sizing architectures: Backtrader vs. OpenBacktesting
While Backtrader pioneered modular object-oriented event-driven backtesting in Python, its stateful, imperative design introduces distinct operational challenges when managing capital allocation across long histories.
Modern frameworks like OpenBacktesting (version 0.2.1, targeting Python >=3.12, <3.15) redefine position sizing by coupling risk boundaries directly with declarative order execution models.
flowchart TD
subgraph Backtrader ["Backtrader: Stateful Sizer Callbacks"]
B1["Strategy.buy()"] --> B2["_getsizing() Hook"]
B2 --> B3["Polls Strategy Attributes\n(Implicit Coupling)"]
B3 --> B4["Broker Order Match"]
B4 -.->|"Requires Manual Linkage"| B5["Separate Stop Order Creation"]
end
subgraph OpenBacktesting ["OpenBacktesting v0.2.1: Unified Execution Engine"]
O1["Strategy.buy()"] --> O2["Declarative Order Specification\n(Units / Cash Budget / Risk %)"]
O2 --> O3["Atomic Trade Container\n(Integrated Entry + SL + Size)"]
O3 --> O4["Deterministic Ledger Fill\n(portfolio.orders_df & trades_df)"]
endComparing architectural implementations
In Backtrader, setting up risk-based sizing requires subclassing bt.Sizer, registering it with Cerebro or the strategy, exposing indicators on the strategy instance, and manually coordinating entry orders with separate resting stop orders.
In OpenBacktesting, single-asset historical OHLCV bar-by-bar backtesting integrates order execution modeling directly into the execution engine. Order sizing options are declared cleanly at the order level:
# Mode 1: Fixed Unit Allocation
self.buy(size=150, sl=98.50)
# Mode 2: Target Cash Allocation (Cash-Budgeted)
self.buy(cash=25000.0, sl=98.50)
# Mode 3: Portfolio Risk Budgeting (e.g., exactly 1.0% equity risk to stop)
self.buy(risk_fraction=0.01, sl=98.50)Structural comparison: Backtrader vs. OpenBacktesting
| Architectural Dimension | Backtrader (Legacy) | OpenBacktesting (v0.2.1) |
|---|---|---|
| Sizing Declaration | Imperative _getsizing() subclass requiring strategy attribute probing | Native declarative parameters (size, cash, or risk budgets) |
| Exit Sizing Safety | Fragile: Sizer must explicitly check isbuy or risk position inversion | Guaranteed: Engine closes active position atomically on self.close() |
| Stop & Size Coupling | Loose: Sizer computes shares, strategy submits separate bt.Order.Stop | Tight: Stop barrier and sizing risk budget resolved in single atomic order |
| Execution State | Stateful callbacks across _getsizing and notify_order | Pure columnar execution ledgers (orders_df, trades_df) |
| Commission & Slippage | Broker-level CommissionInfo object | Declarative commission and slippage models resolved per bar |
| Parameter Search | In-memory grid search prone to memory accumulation | SQLite trial persistence supporting Grid and SAMBO Bayesian search |
| Visualization | Static Matplotlib rendering (cerebro.plot()) | Local browser-based OpenBacktesting Studio |
| Core Dependencies | Pure Python legacy core | Modern stack: Python >=3.12, <3.15, NumPy, pandas, pandas-ta-classic |
The advantage of unified risk modeling
- Elimination of Orphan Mismatches: When position sizing and stop losses are handled by distinct, loosely coupled classes, race conditions can occur if an indicator triggers an exit while an order remains resting. OpenBacktesting manages positions atomically: flattening a position automatically cancels all associated risk constraints.
- Transparent Post-Trade Auditing: Backtrader requires manual accumulation in
notify_order()to determine why a specific trade was sized to a particular share count. OpenBacktesting records detailed sizing metrics and fill prices directly into immutable pandas DataFrames (portfolio.orders_df), enabling instant post-run verification. - Reproducible Bayesian Parameter Search: Because sizing and risk models are purely declarative, optimizing parameters (such as risk percentage, ATR multipliers, or stop thresholds) with OpenBacktesting's SQLite trial persistence ensures zero state leakage between successive runs.
Summary checklist for position sizing implementation
Before validating or deploying systematic trading strategies, audit your position sizing architecture against this quant engineering checklist:
- Eliminate Static Share Assumptions: Never use hardcoded unit counts (
size=100) across multi-year backtests where asset price and account equity undergo substantial drift. - Enforce Cash Buffer Margins: Always retain a 3% to 5% free cash buffer (
cash_buffer=0.05) in your sizing calculations to accommodate slippage on market orders and broker commissions. - Defensively Check
isbuyon Custom Sizers: When implementing_getsizing(), verify thatisbuy=Falsereturnsself.strategy.position.sizeto prevent accidental short positions during exit liquidations. - Apply Integer Truncation: Use
math.floor()rather than rounding (round()) to avoid computing quantities that marginally exceed cash constraints. - Account for Gap Slippage in Sizing: Understand that a stop-based risk model sizes based on the nominal stop price (
P_{\text{stop}}). Opening gaps through the stop will cause realized risk to exceed planned risk. - Audit Drawdown Contraction: Inspect the equity curve during maximum drawdown periods to ensure that the sizing model contracts unit allocations as equity declines, preserving the portfolio's geometric recovery capacity.
Further reading and technical resources
- Backtrader Tutorial: Build and Inspect Your First Strategy — Core Cerebro architecture, LineSeries data feeds, and strategy event hooks.
- Backtrader Stop Loss: Order Timing, Gaps and Exit Verification — Order execution timing, gap opening slippage, and orphan order cleanup.
- Backtrader Bracket Orders: Parent, Stop and Target Lifecycles — Designing linked multi-order execution models with automatic bracket handling.
- Backtrader Slippage: Configure set_slippage_perc and Audit Fills — Calibrating fixed and proportional slippage models to eliminate optimistic backtest bias.
- OpenBacktesting Documentation — High-performance single-asset historical backtesting with declarative order execution models and SQLite trial persistence.
