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.
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.0values = pd.DataFrame({"100 shares": fixed_pf.value, "1% risk": risk_pf.value})
values.vbt.plot().show()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 type | size means | Example |
|---|---|---|
"amount" (default) | Units of the asset | 10 buys 10 shares |
"value" | Cash to spend | 1000 buys $1,000 worth |
"percent" | Share of available cash, or of the position when selling | 0.5 uses half the cash |
"valuepercent" | Share of total portfolio value | 0.5 buys half the equity |
"targetamount" | The position to hold, in units | 10 buys or sells to reach 10 |
"targetvalue" | The position to hold, in cash | 1000 holds $1,000 worth |
"targetpercent" | The position to hold, as a weight | 0.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:
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: float64Ordinary 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:
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.400The 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:
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:
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: float64Combine 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:
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 SellKeep 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.
Related pages
- Orders and executionSimulate limit and stop-entry orders, fill prices, timing, rejections, and real fills
- Signal backtestingTurn entry and exit signals into long, short, reversing, and pyramided positions
- Portfolio accountingTrack shared cash, deposits, dividends, positions, records, and exposure per bar
- Stop loss and take profitBacktest stop losses, trailing stops, take profits, time stops, and exit ladders
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.