Features
Fees, slippage, and leverage
Model commissions, slippage, leverage, futures multipliers, and negative prices
A strategy that looks good before costs can fail after them. VBT lets you backtest with fees and slippage, fixed and per-share commissions, leverage, and futures contract multipliers, and test several cost levels in one call to see how much margin your edge really has.
data = vbt.YFData.pull("SPY", start="2015-01-01", end="2025-01-01")
fast = data.close.rolling(10).mean()
slow = data.close.rolling(30).mean()
pf = vbt.PF.from_signals(
data,
fast.vbt.crossed_above(slow),
fast.vbt.crossed_below(slow),
fees=vbt.Param([0, 0.0005, 0.001, 0.002]),
init_cash=10_000,
)
metrics = ["total_return", "total_fees_paid", "sharpe_ratio", "total_trades"]
pf.stats(metrics, agg_func=None).round(2) Total Return [%] Total Fees Paid Sharpe Ratio Total Trades
fees
0.0000 99.33 0.00 0.83 37
0.0005 92.09 476.16 0.78 37
0.0010 85.11 933.33 0.74 37
0.0020 71.91 1793.57 0.66 37pf.value.vbt.plot().show()With only 37 trades in ten years, 20 basis points per order still take 27 points of total return. A strategy that trades daily would lose far more, which is why every backtest should include realistic costs from the start.
Fees and commissions
fees is a fraction of each order's value and fixed_fees a flat amount per order. A per-share
commission becomes a fraction by dividing it by the price:
close = pd.Series([50.0], index=pd.date_range("2026-01-01", periods=1))
pf = vbt.PF.from_orders(
close,
size=100,
fees=vbt.Param(
[0.001, 0.0, 0.005 / 50],
keys=["0.1% of value", "$1 per order", "$0.005 per share"],
level=0,
),
fixed_fees=vbt.Param([0.0, 1.0, 0.0], level=0),
init_cash=10_000,
)
pf.orders.fees.to_pd().iloc[0].droplevel("fixed_fees")fees
0.1% of value 5.0
$1 per order 1.0
$0.005 per share 0.5
Name: 2026-01-01 00:00:00, dtype: float64Fees are arrays like every other argument, so they can differ per asset and per bar. Negative fees model rebates. To charge maker and taker rates, fill the fee array by signal type, for example a lower fee where the entry is a limit order. When the order type that fills is not known in advance, as with stops, or when fees depend on recent volume tiers, set the fee in a callback before the order executes.
Slippage
slippage moves market-order fills against you by a fraction of the price: buys fill higher and
sells fill lower. In the signal simulator, limit orders use their
limit fill-price rules
instead.
index = pd.date_range("2026-01-01", periods=2)
pf = vbt.PF.from_signals(
pd.Series([100.0, 110.0], index=index),
pd.Series([True, False], index=index),
pd.Series([False, True], index=index),
slippage=vbt.Param([0, 0.001]),
)
pf.orders.readable[["Column", "Side", "Price"]] Column Side Price
0 0.000 Buy 100.00
1 0.000 Sell 110.00
2 0.001 Buy 100.10
3 0.001 Sell 109.89Slippage is applied after the simulator has decided that an order fills. A stop at $100 with 10
basis points of slippage triggers when the price reaches $100 and fills at $99.90. Slippage can push
a price outside the bar's range, which price_area_vio_mode="cap" prevents.
You can pass slippage as an array, with different estimates for each asset and bar, or update it in a callback as conditions change. For example, use larger estimates for less liquid assets or volatile periods, then compare the strategy across several assumptions. If you have bid and ask data, the execution-price example shows how to buy at the ask and sell at the bid.
Leverage and borrowing
Leverage decides how much of a position may be funded with borrowed money. It does not change the size of an order by itself, so a leveraged strategy asks for more than 100% of its equity and allows the account to borrow the rest:
data = vbt.YFData.pull("SPY", start="2020-01-01", end="2025-01-01")
pf = vbt.PF.from_orders(
data,
size=2.0,
size_type="targetpercent",
leverage=vbt.Param([1, 2]),
init_cash=10_000,
)
pd.DataFrame({
"shares": pf.assets.iloc[0],
"total return": pf.total_return,
"max drawdown": pf.max_drawdown,
}).round(3) shares total return max drawdown
leverage
1 33.853 0.946 -0.337
2 67.707 1.206 -0.586Without leverage, the account can only buy with its own cash and the 200% target is capped at 100%. With leverage 2, it holds twice as many shares, rebalanced every day like a leveraged ETF. Total return rose from 95% to 121%, and the maximum drawdown from 34% to 59%.
The size type decides how much of the borrowing capacity an order asks for. With leverage=3 and
$1,000 of cash at $100 a share, size=1.0 with size_type="percent" buys 30 shares, all of the
leveraged buying power, while "valuepercent" buys 10 shares, 100% of equity.
The default leverage_mode="lazy" borrows only once own cash runs out, while "eager" applies
leverage to every order and uses only part of the available cash. Fees are paid from own cash. The
account tracks debt, locked cash, and free cash on every bar. The engine does not liquidate
positions when equity reaches zero. Orders stop filling, and you decide how to treat that point in
the analysis.
Borrowing costs and funding payments
Financing costs depend on how much you borrow and how long you hold the position. A callback can
read the debt and charge interest as negative cash_earnings, even on days without a trade. Here
eager leverage funds a $1,000 position with $500 of borrowed cash. A sample daily rate of 0.1%
charges $0.50 for each day after entry:
close = pd.Series([100.0, 100.0, 100.0], index=pd.date_range("2026-01-01", periods=3))
@njit
def financing_nb(ctx, cash_earnings, daily_rate):
cash_earnings[ctx.i, ctx.col] = -ctx.last_debt[ctx.col] * daily_rate
pf = vbt.PF.from_signals(
close,
entries=[True, False, False],
size=10,
init_cash=1000,
leverage=2,
leverage_mode="eager",
save_state=True,
cash_earnings=np.zeros(3),
adjust_func_nb=financing_nb,
adjust_args=(vbt.Rep("cash_earnings"), 0.001),
)
pd.DataFrame({"debt": pf.debt, "financing": pf.cash_earnings, "value": pf.value}) debt financing value
2026-01-01 500.0 0.0 1000.0
2026-01-02 500.0 -0.5 999.5
2026-01-03 500.0 -0.5 999.0The price stays flat, so the change in equity comes entirely from financing. The example keeps cash available to pay it. Negative cash earnings are limited to free cash, so a fully invested account cannot pay them.
Funding payments on perpetual swaps work the same way: a callback multiplies the position value by the rate at each funding time. Set the payment's sign for the position's direction, and use your own rate series and schedule. VBT includes these cash flows in portfolio value and returns.
Futures and unusual instruments
A futures contract controls a multiple of its quoted price. multiplier applies that factor to
order values, cash flows, and profit, so one E-mini S&P 500 contract moves $50 per point. Prices may
also be zero or negative, which happens with spreads and with crude oil futures in April 2020. The
Contract multiplier and Negative price highlights below
show both on real data. Contract rolls are not handled for you: back-adjust a continuous series, or
close the expiring contract and open the next one with your own signals.
Profit and loss is realized when positions close, and equity marks open positions to market on every bar. Daily variation margin, where exchanges move unrealized profit into cash each day, is not modeled separately. For most strategy research the resulting equity curve is the same, but cash balances during open trades differ from a futures account statement.
Multiple currencies
Cash and prices must be in the same currency. For a single currency pair, quote the price in your account currency, inverting the pair if necessary, together with any order price arrays. For holdings such as foreign stocks, convert each price series to the account currency before the simulation. That includes currency exposure in each asset's value. Separate currency balances, conversion costs, and conversion of leveraged FX profit and loss require custom accounting.
✅ All simulation entry points now accept a multiplier that scales every order by a constant factor, making it straightforward to model futures contracts and other derivatives where one contract represents multiple units of the underlying asset.
data = vbt.YFData.pull("ES=F", start="2023", end="2024")
fast_sma = data.run("talib_func:sma", timeperiod=10)
slow_sma = data.run("talib_func:sma", timeperiod=30)
entries = fast_sma.vbt.crossed_above(slow_sma)
exits = fast_sma.vbt.crossed_below(slow_sma)
pf_stock = vbt.PF.from_signals(
data,
entries=entries,
exits=exits,
size=1,
init_cash=500_000,
)
pf_futures = vbt.PF.from_signals(
data,
entries=entries,
exits=exits,
size=1,
multiplier=50,
init_cash=500_000,
)
print(pf_stock.total_profit)627.25print(pf_futures.total_profit) 31362.5print(pf_futures.trades.readable[["Avg Entry Price", "Avg Exit Price", "PnL", "Return"]]) Avg Entry Price Avg Exit Price PnL Return
0 4057.50 4138.00 4025.0 0.019840
1 4212.00 4480.75 13437.5 0.063806
2 4490.25 4378.75 -5575.0 -0.024832
3 4430.50 4820.00 19475.0 0.087913✅ The simulation engine previously rejected negative prices at validation time, making it impossible to model instruments whose price can legally go below zero (such as the infamous April 2020 WTI crude oil event). Negative prices are now fully supported across the entire pipeline.
data = vbt.YFData.pull("CL=F", start="2020-02-01", end="2020-07-01")
entries = pd.Series(False, index=data.index)
exits = pd.Series(False, index=data.index)
entries["2020-03-03"] = True
exits["2020-05-04"] = True
pf = vbt.PF.from_signals(
data,
entries=entries,
exits=exits,
size=1,
multiplier=1000,
init_cash=200_000,
)
print(data.close[data.close < 0]) Date
2020-04-20 00:00:00-04:00 -37.630001
Name: Close, dtype: float64print(pf.trades.readable[["Avg Entry Price", "Avg Exit Price", "PnL", "Return"]]) Avg Entry Price Avg Exit Price PnL Return
0 47.18 20.389999 -26790.000916 -0.567825print(pf.value[["2020-03-03", "2020-03-09", "2020-04-20", "2020-05-04"]].round(2)) Date
2020-03-03 00:00:00-05:00 200000.0
2020-03-09 00:00:00-04:00 183950.0
2020-04-20 00:00:00-04:00 115190.0
2020-05-04 00:00:00-04:00 173210.0
Name: value, dtype: float64✅ Leverage is now an integral part of portfolio simulation. Supports two leverage modes: lazy
(enables leverage only if there is not enough cash) and eager (enables leverage while using only
part of the available cash). Allows setting leverage per order, and can also determine the optimal
leverage value automatically to fulfill any order requirement! 🏋️
data = vbt.YFData.pull("BTC-USD", start="2020", end="2022")
pf = vbt.PF.from_random_signals(
data,
n=100,
seed=42,
leverage=vbt.Param([0.5, 1, 2, 3]),
)
pf.value.vbt.plot().show()Related pages
- Orders and executionSimulate limit and stop-entry orders, fill prices, timing, rejections, and real fills
- Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Portfolio accountingTrack shared cash, deposits, dividends, positions, records, and exposure per bar
- Event-driven backtestingWrite compiled callbacks for cooldowns, position limits, and custom simulators
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.