Features

Position sizing

Size trades in units, cash, percent of equity, target weights, or risk per trade

How much should each trade buy? The answer changes a backtest as much as the entry rule does. VBT lets you test position sizing in Python with fixed units, cash amounts, percent of equity, target weights, or risk-based sizes computed from the distance to your stop, and compare the rules side by side.

Risk 1% of equity per trade instead of buying 100 shares
data = vbt.YFData.pull("SPY", start="2015-01-01", end="2025-01-01")
entries = data.close.vbt.crossed_above(data.close.rolling(50).mean())
stop_dist = 2 * data.run("atr", window=14).atr

@njit
def risk_size_nb(c, size, stop_dist, risk):  
    if c.last_position[c.col] == 0:
        equity = c.last_value[c.col]
        size[c.i, c.col] = risk * equity / vbt.pf_nb.select_nb(c, stop_dist)

kwargs = dict(
    sl_stop=stop_dist,
    tp_stop=2 * stop_dist,
    delta_format="absolute",
    init_cash=100_000,
)
fixed_pf = vbt.PF.from_signals(data, entries, size=100, **kwargs)
risk_pf = vbt.PF.from_signals(
    data,
    entries,
    adjust_func_nb=risk_size_nb,
    adjust_args=(vbt.Rep("size"), vbt.Rep("stop_dist"), 0.01),
    broadcast_named_args=dict(stop_dist=stop_dist),
    size=np.nan,
    arg_config=dict(size=dict(full_shape=True)),  
    **kwargs,
)

pd.DataFrame({
    "100 shares": fixed_pf.trades.losing.pnl.to_pd(ignore_index=True).describe(),
    "1% risk": risk_pf.trades.losing.pnl.to_pd(ignore_index=True).describe(),
}).loc[["count", "mean", "std", "min", "max"]].round(0)
       100 shares  1% risk
count        17.0     17.0
mean       -882.0  -1100.0
std         437.0    196.0
min       -1773.0  -1459.0
max        -253.0   -513.0
values = pd.DataFrame({"100 shares": fixed_pf.value, "1% risk": risk_pf.value})
values.vbt.plot().show()
SPY portfolio value from 2015 to 2024 for a fixed 100-share position size and for 1% equity risk per trade Figure data (JSON)

Both versions take the same 37 trades with a 2 ATR stop and a 4 ATR target. With 100 shares, a losing trade cost anywhere from $253 to $1,773, depending on how volatile the market was. With 1% risk, losses stay close to 1% of equity. They are not exactly equal because prices can gap through the stop.

Size types

size is a number, and size_type says what it means:

Size typesize meansExample
"amount" (default)Units of the asset10 buys 10 shares
"value"Cash to spend1000 buys $1,000 worth
"percent"Share of available cash, or of the position when selling0.5 uses half the cash
"valuepercent"Share of total portfolio value0.5 buys half the equity
"targetamount"The position to hold, in units10 buys or sells to reach 10
"targetvalue"The position to hold, in cash1000 holds $1,000 worth
"targetpercent"The position to hold, as a weight0.25 holds 25% of equity

Each percentage type also has a 100 variant that reads 50 as 50%. The difference between ordinary and target types shows when the same order repeats:

Send the same order on two bars
close = pd.Series([50.0, 50.0], index=pd.date_range("2026-01-01", periods=2))
pf = vbt.PF.from_orders(
    close,
    size=vbt.Param([10, 1000, 0.5, 0.25], level=0),
    size_type=vbt.Param(
        ["amount", "value", "valuepercent", "targetpercent"],
        level=0
    ),
    init_cash=10_000,
)
pf.assets.iloc[-1]
size     size_type
10.00    amount            20.0
1000.00  value             40.0
0.50     valuepercent     200.0
0.25     targetpercent     50.0
Name: 2026-01-02 00:00:00, dtype: float64

Ordinary types buy again on every bar. A target type only trades the difference, so the second order does nothing. That makes target weights the natural way to rebalance. A NaN weight skips the bar and keeps the position, while a weight of zero closes it:

Rebalance to 60/40 on the first trading day of each month
data = vbt.YFData.pull(["SPY", "TLT"], start="2020-01-01", end="2025-01-01")
rebalance = ~data.index.tz_localize(None).to_period("M").duplicated()
weights = data.symbol_wrapper.fill(np.nan)
weights[rebalance] = [0.6, 0.4]

pf = vbt.PF.from_orders(
    data,
    size=weights,
    size_type="targetpercent",
    group_by=True,
    cash_sharing=True,
    call_seq="auto",  
    fees=0.0005,
)
pf.allocations.iloc[[0, 20, 21]].round(3)
symbol                       SPY    TLT
Date
2020-01-02 00:00:00-05:00  0.600  0.400
2020-01-31 00:00:00-05:00  0.582  0.418
2020-02-03 00:00:00-05:00  0.600  0.400

The weights drift during the month and return to 60/40 on the next rebalance.

Weights are fractions of the group's value, so without leverage they should add up to at most 1. The simulator processes the columns of a bar in order and stops buying when the cash runs out: weights of 0.5 on three assets fill the first two and leave nothing for the third.

Target sizes and signals

With entry and exit signals, a target size can contradict the signal. An entry with a target below the current position would sell. Use target sizes with from_orders, or let from_signals derive the signals from the targets with order_mode=True, as shown on the Signal backtesting page.

Available cash or portfolio equity?

Half the available cash and half the portfolio value can produce very different allocations. With shared cash, each buy leaves less cash for the next asset. In this example both assets cost $10, and A trades before B:

Compare half the available cash with half the equity
close = pd.DataFrame(
    {"A": [10.0], "B": [10.0]},
    index=pd.date_range("2026-01-01", periods=1),
)
positions = {}
for size_type in ["percent", "valuepercent"]:
    pf = vbt.PF.from_orders(
        close,
        size=0.5,
        size_type=size_type,
        group_by=True,
        cash_sharing=True,
        init_cash=1000,
    )
    positions[size_type] = pf.assets.iloc[0]
pd.DataFrame(positions)
   percent  valuepercent
A     50.0          50.0
B     25.0          50.0

"percent" spends $500 on A, then half the remaining $500 on B. "valuepercent" spends $500 on each because the equity is still $1,000. Choose the first when each new trade should use a share of remaining cash, and the second when its size should follow total account value. The Portfolio accounting page covers how assets share that account.

Risk-based sizing

Risk-based sizing divides a risk budget by the loss per unit if the stop is hit. When the budget is a fixed amount of cash, it is a plain array computation: size = 1000 / stop_dist with size_type="amount". When the budget depends on equity, as in the example above, the size must be computed during the simulation, because equity depends on earlier fills and the value of open positions.

An adjustment function is the simplest place for that. It runs before each bar's signals, reads the current equity, cash, and position, and writes the size the next order will use. Inside callbacks, vbt.pf_nb.get_order_size_nb converts a percentage or target size into units, which helps when you combine a sizing rule with signals. The conversion ignores fees, slippage, and lot sizes, so leave a margin when cash is tight.

The same pattern covers volatility targeting, Kelly-style fractions estimated from past trades, and sizes produced by a separate model.

Sizes and target weights can also come from arrays, so an external model can supply a different allocation for every asset and rebalance date. Use callbacks when the rule also needs the simulated account state, such as reducing the next order after losses. This lets you compare your own sizing rules with fixed sizes using the same entries and exits. For weights calculated from a portfolio objective, see Portfolio optimization.

Lots and increments

Real markets trade in whole shares, contracts, or minimum lot steps. size_granularity rounds every order down to a multiple of the step:

Round a $1,000 order to whole shares
close = pd.Series([33.0], index=pd.date_range("2026-01-01", periods=1))
pf = vbt.PF.from_orders(
    close,
    size=1000,
    size_type="value",
    size_granularity=vbt.Param([np.nan, 1]),
    init_cash=10_000,
)
pf.orders.size.to_pd().iloc[0]
size_granularity
NaN    30.30303
1.0    30.00000
Name: 2026-01-01 00:00:00, dtype: float64

Combine it with min_size and size_type="value" to skip orders below a minimum notional, as exchanges require. A granularity setting also applies when closing, so a fractional position left over from earlier orders may not close completely.

Compounding

Whether profits are reinvested depends on the sizing rule. The default, size=np.inf, spends all available cash, so each trade grows with the account. Percent of equity and risk-based sizes tied to equity also scale with the account. A fixed amount keeps the unit count constant, while a fixed value keeps the requested cash amount constant. Both let you compare trades without automatically increasing the requested size after a profit. A fixed size still needs the cash to pay for it: when the account is short, the order fills partially, and allow_partial=False rejects it instead.

Sizing for reversals

A reversal closes the current position and opens the opposite one in a single order, so the order is larger than the new position:

Reverse a one-unit long into a one-unit short
close = pd.Series([10.0, 11.0, 12.0], index=pd.date_range("2026-01-01", periods=3))
pf = vbt.PF.from_signals(
    close,
    entries=pd.Series([True, False, False], index=close.index),
    exits=pd.Series([False, True, False], index=close.index),
    direction="both",
    size=1,
)
pf.orders.readable[["Fill Index", "Size", "Side"]]
  Fill Index  Size  Side
0 2026-01-01   1.0   Buy
1 2026-01-02   2.0  Sell

Keep this in mind with max_size: a cap of one unit turns a one-unit reversal into a plain exit. To reduce a position instead of reversing it, enable accumulation.

Copyright © 2021–2026 Oleg Polakow. All rights reserved.

Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.