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:

Code
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.

Code
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"]
    end

The fixed-share distortion trap

A widespread anti-pattern in quantitative backtesting is hardcoding a static unit count across the entire simulation:

Naive fixed-share order submission (ANTI-PATTERN)
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:

  1. 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.
  2. 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.
  3. 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 ParadigmMathematical ModelCapital ScalabilityVolatility AdaptationRuin Protection
Fixed UnitsN = kNoneNoneVery Low
Fixed Dollar AllocationN = \lfloor C_{\text{target}} / P \rfloorStaticAsset Price OnlyLow
Percentage of EquityN = \lfloor (E \times f) / P \rfloorDynamic (Compounding)Asset Price OnlyModerate
Volatility / ATR RiskN = \lfloor (E \times f_{\text{risk}}) / (k \times \text{ATR}) \rfloorDynamic (Compounding)Full (Dynamic Volatility)High
Fixed-Fractional Stop RiskN = \lfloor (E \times f_{\text{risk}}) / |P_{\text{entry}} - P_{\text{stop}}| \rfloorDynamic (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.

Code
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 --> Broker

Available 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.

Configuring bt.sizers.FixedSize
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.
Configuring bt.sizers.PercentSizerInt
# 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:

Configuring bt.sizers.AllInSizer
cerebro.addsizer(bt.sizers.AllInSizer)

[!WARNING] Margin Rejection Hazard: AllInSizer and high-percentage PercentSizer configurations frequently fail in realistic simulations. If commission rates, slippage buffers, or opening gaps are present, committing 100% of current cash will trigger an immediate Margin rejection in notify_order() because the total required settlement capital exceeds free cash.

Registration scope: Global vs. Strategy-specific

Backtrader supports two mechanisms for registering sizers:

  1. Global Registration via Cerebro: Applies the sizer instance to all strategies added to the engine:
    Code
    cerebro.addsizer(bt.sizers.PercentSizerInt, percents=15)
  2. 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:

Custom Sizer skeleton structure
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 0

Deconstructing the _getsizing() parameters

Understanding the exact inputs passed to _getsizing() is critical for robust calculation:

  • comminfo: The active CommissionInfo object. Exposes helper methods such as comminfo.getvaluesize(size, price), comminfo.getcommission(size, price), and margin requirements.
  • cash: The raw free cash available in cerebro.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 (True for buy orders, False for 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:

Accessing strategy metrics inside custom Sizer
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.size

The 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.
Code
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 whether isbuy is False. If closing an existing long position, immediately return self.strategy.position.size. Alternatively, always use self.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_&#123;\text&#123;risk&#125;&#125; denote the risk fraction (e.g., 0.01 for 1%), P_&#123;\text&#123;entry&#125;&#125; denote estimated entry price, and P_&#123;\text&#123;stop&#125;&#125; denote the protective stop-loss price.

The allowable dollar risk per trade (\Delta_&#123;\text&#123;dollar&#125;&#125;) is:

Code
\Delta_&#123;\text&#123;dollar&#125;&#125; = E \times f_&#123;\text&#123;risk&#125;&#125;

The risk per share (\delta_&#123;\text&#123;share&#125;&#125;) represents the physical price distance to the stop barrier:

Code
\delta_&#123;\text&#123;share&#125;&#125; = |P_&#123;\text&#123;entry&#125;&#125; - P_&#123;\text&#123;stop&#125;&#125;|

The raw unit allocation (N_&#123;\text&#123;risk&#125;&#125;) before capital constraints is:

Code
N_&#123;\text&#123;risk&#125;&#125; = \left\lfloor \frac&#123;\Delta_&#123;\text&#123;dollar&#125;&#125;&#125;&#123;\delta_&#123;\text&#123;share&#125;&#125;&#125; \right\rfloor = \left\lfloor \frac&#123;E \times f_&#123;\text&#123;risk&#125;&#125;&#125;&#123;|P_&#123;\text&#123;entry&#125;&#125; - P_&#123;\text&#123;stop&#125;&#125;|&#125; \right\rfloor

Purchasing 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):

Code
N_&#123;\text&#123;cash&#125;&#125; = \left\lfloor \frac&#123;\text&#123;Cash&#125; \times (1 - \beta)&#125;&#123;P_&#123;\text&#123;entry&#125;&#125; \times (1 + c)&#125; \right\rfloor

The final executed size (N^*) is constrained by the minimum of risk allocation and cash liquidity:

Code
N^* = \max\left(0, \min\left(N_&#123;\text&#123;risk&#125;&#125;, N_&#123;\text&#123;cash&#125;&#125;\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_&#123;\text&#123;entry&#125;&#125;)Stop Loss (P_&#123;\text&#123;stop&#125;&#125;)Stop Distance (\delta_&#123;\text&#123;share&#125;&#125;)Risk % of PriceSized Units (N_&#123;\text&#123;risk&#125;&#125;)Capital CommittedEquity Allocation %
$100.00$98.00$2.002.0%500 shares$50,00050.0%
$100.00$95.00$5.005.0%200 shares$20,00020.0%
$100.00$90.00$10.0010.0%100 shares$10,00010.0%
$250.00$240.00$10.004.0%100 shares$25,00025.0%
$250.00$225.00$25.0010.0%40 shares$10,00010.0%
$50.00$49.00$1.002.0%1,000 shares$50,00050.0%

Notice that across every scenario, if the stop-loss triggers at P_&#123;\text&#123;stop&#125;&#125;, 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.

run_position_sizing_backtest.py
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:

Execution Audit Log
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.05

Key takeaways from the audit trail:

  • On 2024-02-14, when volatility was moderate (\text&#123;ATR&#125; =2.81$), the sizer assigned 178 units.
  • On 2024-05-08, following price expansion and higher volatility (\text&#123;ATR&#125; =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.

Code
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)"]
    end

Comparing 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:

Declarative position sizing paradigms in OpenBacktesting
# 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 DimensionBacktrader (Legacy)OpenBacktesting (v0.2.1)
Sizing DeclarationImperative _getsizing() subclass requiring strategy attribute probingNative declarative parameters (size, cash, or risk budgets)
Exit Sizing SafetyFragile: Sizer must explicitly check isbuy or risk position inversionGuaranteed: Engine closes active position atomically on self.close()
Stop & Size CouplingLoose: Sizer computes shares, strategy submits separate bt.Order.StopTight: Stop barrier and sizing risk budget resolved in single atomic order
Execution StateStateful callbacks across _getsizing and notify_orderPure columnar execution ledgers (orders_df, trades_df)
Commission & SlippageBroker-level CommissionInfo objectDeclarative commission and slippage models resolved per bar
Parameter SearchIn-memory grid search prone to memory accumulationSQLite trial persistence supporting Grid and SAMBO Bayesian search
VisualizationStatic Matplotlib rendering (cerebro.plot())Local browser-based OpenBacktesting Studio
Core DependenciesPure Python legacy coreModern stack: Python >=3.12, <3.15, NumPy, pandas, pandas-ta-classic

The advantage of unified risk modeling

  1. 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.
  2. 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.
  3. 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 isbuy on Custom Sizers: When implementing _getsizing(), verify that isbuy=False returns self.strategy.position.size to 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_&#123;\text&#123;stop&#125;&#125;). 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