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.

Test one strategy at four fee levels in one call
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            37
pf.value.vbt.plot().show()
SPY moving average crossover portfolio value from 2015 to 2024 at fees of 0, 5, 10, and 20 basis points Figure data (JSON)

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:

Compare three commission models on a 100-share order
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: float64

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

Apply 10 basis points of slippage
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.89

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

Hold 200% of equity in SPY, rebalanced daily
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.586

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

Charge interest on borrowed cash each day
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.0

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

Contract multiplier

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

SMA crossover on E-mini S&P 500 futures
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.25
print(pf_futures.total_profit)  
31362.5
print(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

Negative price

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

Hold WTI crude oil through its negative close in April 2020
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: float64
print(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.567825
print(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

✅ 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! 🏋️

Explore how leverage affects the equity curve in a random portfolio
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()
BTC-USD random portfolio equity curves at four leverage levels from 2020 through 2021 Figure data (JSON)

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.