Features

Orders and execution

Simulate limit and stop-entry orders, fill prices, timing, rejections, and real fills

How exactly does a backtest fill your orders, and can it model orders that wait for a price? VBT simulates market, limit, and stop-entry orders with explicit rules for price, timing, expiry, and rejection, so you can backtest limit orders in Python and see why each order filled or did not.

Buy a breakout with a stop-entry order that expires after five bars
data = vbt.YFData.pull("BTC-USD", start="2023-01-01", end="2024-01-01")
sma = data.close.rolling(50).mean()
setup = data.close.vbt.crossed_above(sma)

order_type = data.symbol_wrapper.fill("market")
order_type[setup] = "limit"  
price = data.symbol_wrapper.fill(np.inf)
price[setup] = data.high.rolling(10).max()[setup]  

breakout_pf = vbt.PF.from_signals(
    data,
    entries=setup,
    exits=data.close.vbt.crossed_below(sma),
    order_type=order_type,
    price=price,
    limit_reverse=True,  
    limit_tif=5,
    time_delta_format="rows",
    size=0.3,
    init_cash=10_000,
    fees=0.0004,
)
print(setup.sum(), breakout_pf.orders.side_buy.count())
6 3
cols = ["Signal Index", "Fill Index", "Price", "Side", "Type"]
breakout_pf.orders.readable[cols].head(4)
               Signal Index                Fill Index         Price  Side    Type
0 2023-03-13 00:00:00+00:00 2023-03-14 00:00:00+00:00  24550.837891   Buy   Limit
1 2023-05-01 00:00:00+00:00 2023-05-01 00:00:00+00:00  28091.568359  Sell  Market
2 2023-06-20 00:00:00+00:00 2023-06-21 00:00:00+00:00  28388.968750   Buy   Limit
3 2023-07-30 00:00:00+00:00 2023-07-30 00:00:00+00:00  29275.308594  Sell  Market

Six setups produced three trades. The other three breakouts never happened within five bars, so their orders expired unfilled, exactly as a resting stop order would at a broker.

Pending limit and stop-entry orders

With order_type="limit", a signal creates an order that waits for its target price. The target comes from price, optionally moved by limit_delta, and the order is checked against the high and low of each following bar. limit_reverse=True flips the direction of the check, which turns a limit into a stop-entry order. Pending orders can expire after a number of bars or a duration with limit_tif, or at a date or period boundary with limit_expiry, which covers day orders, good-till-canceled, and good-till-date orders. A positive limit_delta places an ordinary buy limit below the price and a sell limit above it. Reversing the limit puts the trigger on the other side, for buying breakouts or selling breakdowns.

Use limit_delay=True when a limit should start checking prices on the next bar even if it could fill on its creation bar. This is separate from expiry: you choose when the order can start filling as well as when to stop waiting.

When the price gaps through the level, the fill price is a choice:

Fill a buy limit after a gap down
index = pd.date_range("2026-01-01", periods=4)
pf = vbt.PF.from_signals(
    pd.Series([100.0, 100.0, 98.5, 99.0], index=index),
    pd.Series([True, False, False, False], index=index),
    open=pd.Series([100.0, 100.5, 97.0, 98.0], index=index),
    high=pd.Series([100.0, 101.0, 99.0, 99.5], index=index),
    low=pd.Series([100.0, 99.0, 96.5, 97.5], index=index),
    order_type="limit",
    limit_delta=0.02,  
    limit_order_price=vbt.Param(["autolimit", "limit"]),
)
pf.orders.readable[["Column", "Signal Index", "Fill Index", "Price"]]
      Column Signal Index Fill Index  Price
0  autolimit   2026-01-01 2026-01-03   98.0
1      limit   2026-01-01 2026-01-03   97.0

The default fills an ordinary limit at its limit price, the conservative assumption, and fills a stop-entry order at the open when the price gaps through it. "limit" lets an ordinary limit fill at the better open.

Order sizes are resolved when the order fills, not when it is placed, so a percentage size uses the cash available at that moment. The signal simulator keeps one pending limit order per column. While it waits, a new signal in the same direction is ignored by default, so the order keeps its first price. upon_adj_limit_conflict="cancelexecute" replaces it instead, which re-quotes the order from each new signal:

Keep or re-quote a buy limit while the price rises
index = pd.date_range("2026-01-01", periods=5)
close = pd.Series([100.0, 101.0, 102.0, 103.0, 99.0], index=index)
open_ = pd.Series([100.0, 100.5, 101.5, 102.5, 102.0], index=index)
pf = vbt.PF.from_signals(
    close,
    pd.Series([True, True, True, True, False], index=index),
    open=open_,
    high=np.maximum(open_, close) + 0.5,
    low=np.minimum(open_, close) - 0.5,
    order_type="limit",
    limit_delta=0.02,
    upon_adj_limit_conflict=vbt.Param(["keepignore", "cancelexecute"]),
)
pf.orders.readable[["Column", "Signal Index", "Fill Index", "Price"]]
          Column Signal Index Fill Index   Price
0  cancelexecute   2026-01-04 2026-01-05  100.94

The first order waits at $98, 2% below the first close, and the drop on the last day stops at $98.50, so it never fills. The re-quoted order follows the rising closes, sits at $100.94 after the fourth signal, and fills. upon_opp_limit_conflict does the same for an opposite signal, and cancels the pending order by default.

Limit prices can also be changed while the order waits from an adjustment function, and strategies that need several resting orders at once, such as grids or order books, manage them in a callback or a custom simulator. The members-only custom order logic recipe tracks opposing breakout levels. Event-driven backtesting shows how to keep custom state and place multiple orders within a bar.

Adjusting pending orders

Adjust a pending order as conditions change. Move its price or temporarily suspend fills while keeping the order active. For example, you might block entries during scheduled news or while the portfolio already has enough open positions.

Here a buy limit waits 2% below $100. The price reaches $98 on the second day, but a rule blocks fills that day. The same order fills when $98 is reached again:

Temporarily suspend a pending buy limit
index = pd.date_range("2026-01-01", periods=4)
close = pd.Series([100.0, 98.0, 99.0, 98.0], index=index)
can_fill = np.array([True, False, True, True])

@njit
def adjust_limit_nb(ctx, can_fill):
    if ctx.last_limit_info["creation_idx"][ctx.col] != -1:
        ctx.last_limit_info["delta"][ctx.col] = 0.02 if can_fill[ctx.i] else np.inf

pf = vbt.PF.from_signals(
    close,
    entries=[True, False, False, False],
    order_type="limit",
    limit_delta=0.02,
    adjust_func_nb=adjust_limit_nb,
    adjust_args=(can_fill,),
)
pf.orders.readable[["Signal Index", "Fill Index", "Price"]]
  Signal Index Fill Index  Price
0   2026-01-01 2026-01-04   98.0

Setting the stored delta to infinity puts this buy limit out of reach. Restoring the 2% delta makes it eligible again, without needing another entry signal.

Price and time thresholds

Stops and limits accept levels in several formats through delta_format:

FormatMeaningExample for a $100 entry
"percent" (default)Fraction of the reference price0.05 is $5 away
"percent100"Percentage points5 is $5 away
"absolute"Price distance5 is $5 away
"target"The price itself95 is the level

Time limits work the same way with time_delta_format: "index" reads durations such as "3D", and "rows" counts bars. When a new signal arrives while an order is pending, upon_adj_limit_conflict and upon_opp_limit_conflict decide whether the pending order is kept or canceled and whether the new signal executes. Stops have the matching upon_adj_stop_conflict and upon_opp_stop_conflict.

Execution price and timing

By default, an order fills at the close of the bar it was generated on. price accepts "open", "close", "nextopen", and "nextclose", or an array of prices. To delay everything by a number of bars, from_ago reads the order information from that many bars back. The Order delays highlight below compares both timings on a chart.

VBT splits each bar into three parts, the open, the time between open and close, and the close, and processes whatever is due first. That is how a pending limit triggered at the open beats an order you placed at the close. Prices outside the bar's low-to-high range are accepted by default. price_area_vio_mode="cap" clips them to the range and "error" raises. When prices come from your own calculations, "error" is a cheap way to catch fills that the bar could not have produced.

Bid and ask prices are arrays like any other. Fill one price array with the ask at entries and the bid at exits:

Buy at the ask and sell at the bid
index = pd.date_range("2026-01-01", periods=3)
bid = pd.Series([99.9, 101.9, 102.9], index=index)
ask = pd.Series([100.1, 102.1, 103.1], index=index)
entries = pd.Series([True, False, False], index=index)
exits = pd.Series([False, False, True], index=index)

price = (bid + ask) / 2  
price[entries] = ask[entries]
price[exits] = bid[exits]
pf = vbt.PF.from_signals(price, entries, exits, price=price, size=1)
pf.orders.readable[["Fill Index", "Side", "Price"]]
  Fill Index  Side  Price
0 2026-01-01   Buy  100.1
1 2026-01-03  Sell  102.9

For resting limits, you can also add bid or ask checks in an adjustment function and cancel an order when your quote condition is not met. This lets you use the executable side of your own quote data when deciding which orders to allow.

Processing order within a bar

When several assets share one cash balance, the order in which they trade on a bar matters. Selling first frees the cash that buying needs. call_seq="auto" sorts the orders of a bar by their value so that sells go first:

Rotate from one asset into two others on the same bar
index = pd.date_range("2026-01-01", periods=2)
close = pd.DataFrame(
    {"A": [10.0, 10.0], "B": [20.0, 20.0], "C": [40.0, 40.0]},
    index=index
)
weights = pd.DataFrame(
    {"A": [0.0, 0.5], "B": [0.0, 0.5], "C": [1.0, 0.0]},
    index=index
)
kwargs = dict(
    size=weights,
    size_type="targetpercent",
    group_by=True,
    cash_sharing=True
)

vbt.PF.from_orders(close, **kwargs).allocations.iloc[-1]  
A    0.0
B    0.0
C    0.0
Name: 2026-01-02 00:00:00, dtype: float64
vbt.PF.from_orders(close, call_seq="auto", **kwargs).allocations.iloc[-1]
A    0.5
B    0.5
C    0.0
Name: 2026-01-02 00:00:00, dtype: float64

With call_seq you can also pass your own order per bar, for example to rank assets.

Fills, limits, and rejections

VBT checks order size and available cash before filling. min_size ignores orders that are too small, max_size caps each order, for example at a share of the bar's volume, and an order larger than the available cash fills partially unless allow_partial=False. Pass log=True to record the request, the result, and the reason for every order:

Cap, partially fill, and ignore orders
index = pd.date_range("2026-01-01", periods=4)
close = pd.Series([10.0, 10.0, 10.0, 10.0], index=index)
pf = vbt.PF.from_orders(
    close,
    size=[50, 200, 0.5, -1000],
    min_size=1,
    max_size=100,
    init_cash=3000,
    log=True,
)
cols = ["Index", "[REQ] Size", "[RES] Size", "[RES] Status", "[RES] Status Info"]
pf.logs.readable[cols]
       Index  [REQ] Size  [RES] Size [RES] Status  [RES] Status Info
0 2026-01-01        50.0        50.0       Filled               None
1 2026-01-02       200.0       100.0       Filled        PartialFill
2 2026-01-03         0.5         NaN      Ignored  MinSizeNotReached
3 2026-01-04     -1000.0       100.0       Filled        PartialFill

The size limits follow size_type, so with size_type="value" they are amounts of money. cash_limit caps the cash a single order may use, size_granularity rounds sizes to whole shares or lots, and reject_prob rejects a random share of orders to test robustness. To stop at the first order that cannot be filled as requested, pass raise_reject=True. A NaN size places no order, which is how to keep a suspended asset's position untouched during a rebalance.

Live fills versus the backtest

Executions from a broker or another backtester become a full portfolio by passing them as order records. That makes it easy to measure how far live trading drifted from the simulation. Here sample fills for the breakout strategy above are 0.15% worse on each side and pay higher fees:

Compare live fills with the simulated breakout
import io

csv = io.StringIO("""date,side,size,price,fees
2023-03-14,Buy,0.3,24587.67,7.38
2023-05-01,Sell,0.3,28049.43,8.41
2023-06-21,Buy,0.3,28431.55,8.53
2023-07-30,Sell,0.3,29231.40,8.77
2023-10-01,Buy,0.3,27529.99,8.26
""")
fills = pd.read_csv(csv, parse_dates=["date"])
fills["idx"] = data.index.get_indexer(fills.pop("date").dt.tz_localize("UTC"))  
live_pf = vbt.PF(data.close, order_records=fills, init_cash=10_000)

pd.DataFrame({
    "simulated": breakout_pf.trades.returns.values,
    "live": live_pf.trades.returns.values,
})
   simulated      live
0   0.143363  0.138652
1   0.030409  0.026104
2   0.537144  0.534242
pd.Series({"simulated": breakout_pf.total_return, "live": live_pf.total_return})
simulated    0.574451
live         0.565769
dtype: float64

Every trade, drawdown, and metric is now available for the live account, and the 0.9 point gap in total return is the cost of execution. The Portfolio from fills highlight below shows the minimal form. Order records are taken as given, without checking cash. To run the same fills through the simulator instead, so that cash limits and fees apply, pass them as records.

Portfolio from fills

✅ Turn your execution records into a VBT portfolio and explore trades, drawdowns, and performance with the same tools you use for backtests. Missing record fields receive defaults, and readable side names are mapped automatically, keeping the setup light.

Calculate portfolio value and profit from two fills
close = pd.Series(
    [100.0, 105.0, 110.0],
    index=pd.date_range("2026-09-01", periods=3, freq="D")
)
fills = [
    dict(idx=0, size=2, price=100, fees=1, side="Buy"),
    dict(idx=2, size=2, price=110, fees=1, side="Sell"),
]
pf = vbt.PF(close, order_records=fills, init_cash=1000, freq="1D")

print(pf.total_profit)  
18.0
pf.value.tolist()  
[999.0, 1009.0, 1018.0]

Target price

✅ Limit and stop orders can now be defined using a target price instead of a delta.

Set the SL to the previous low in a random portfolio
data = vbt.YFData.pull("BTC-USD")
pf = vbt.PF.from_random_signals(
    data,
    n=100,
    seed=42,
    sl_stop=data.low.vbt.ago(1),
    delta_format="target"
)
sl_orders = pf.orders.stop_type_sl
signal_index = pf.wrapper.index[sl_orders.signal_idx.values]
hit_index = pf.wrapper.index[sl_orders.idx.values]
hit_after = hit_index - signal_index
hit_after
TimedeltaIndex([ '7 days',  '3 days',  '1 days',  '5 days',  '4 days',
                 '1 days', '28 days',  '1 days',  '1 days',  '1 days',
                 '1 days',  '1 days', '13 days', '10 days',  '5 days',
                 '1 days',  '3 days',  '4 days',  '1 days',  '9 days',
                 '5 days',  '1 days',  '1 days',  '2 days',  '1 days',
                 '1 days',  '1 days',  '3 days',  '1 days',  '1 days',
                 '1 days',  '1 days',  '1 days',  '2 days',  '2 days',
                 '1 days', '12 days',  '3 days',  '1 days',  '1 days',
                 '1 days',  '1 days',  '1 days',  '3 days',  '1 days',
                 '1 days',  '1 days',  '4 days',  '1 days',  '1 days',
                 '2 days',  '6 days', '11 days',  '1 days',  '2 days',
                 '1 days',  '1 days',  '1 days',  '1 days',  '1 days',
                 '4 days', '10 days',  '1 days',  '1 days',  '1 days',
                 '2 days',  '3 days',  '1 days'],
               dtype='timedelta64[ns]', name='Date', freq=None)

Order delays

✅ By default, VBT executes every order at the end of the current bar. Previously, if you wanted to delay execution to the next bar, you had to manually shift all order-related arrays by one bar, which made the process error-prone. Now, you can simply specify how many bars in the past should be used to take order information from. In addition, the price argument now supports "nextopen" and "nextclose" as options, providing a one-line solution.

Compare orders without and with a delay in a random portfolio
pf = vbt.PF.from_random_signals(
    vbt.YFData.pull("BTC-USD", start="2021-01", end="2021-02"),
    n=3,
    seed=42,
    price=vbt.Param(["close", "nextopen"])
)
fig = pf.orders["close"].plot(
    buy_trace_kwargs=dict(name="Buy (close)", marker=dict(symbol="triangle-up-open")),
    sell_trace_kwargs=dict(name="Sell (close)", marker=dict(symbol="triangle-down-open"))
)
pf.orders["nextopen"].plot(
    plot_ohlc=False,
    plot_close=False,
    buy_trace_kwargs=dict(name="Buy (nextopen)"),
    sell_trace_kwargs=dict(name="Sell (nextopen)"),
    fig=fig
)
fig.show()
BTC-USD OHLC with random orders executed at the close or delayed until the next open Figure data (JSON)

Limit orders

✅ Long-awaited support for limit orders is now available for signal-based simulation! Includes time-in-force (TIF) orders such as DAY, GTC, GTD, LOO, and FOK ⏰ You can also reverse a limit order or create it using a delta for easier testing.

Explore how limit delta affects number of orders in a random portfolio
pf = vbt.PF.from_random_signals(
    vbt.YFData.pull("BTC-USD"),
    n=100,
    seed=42,
    order_type="limit",
    limit_delta=vbt.Param(np.arange(0.001, 0.1, 0.001)),  
)
pf.orders.count().vbt.plot(
    xaxis_title="Limit delta",
    yaxis_title="Order count"
).show()
Order count across limit-order delta values in a seeded random BTC-USD portfolio Figure data (JSON)

Delta formats

✅ Previously, stop orders could only be provided as percentages. While this worked for single values, it often required extra transformations for arrays. For example, setting SL to ATR meant you needed to know the entry price. More generally, to lock in a specific dollar amount of a trade, you might want to use a fixed price trailing stop. To address this, VBT now offers multiple stop value formats ("delta formats") to choose from.

Use ATR as SL
data = vbt.YFData.pull("BTC-USD")
atr = vbt.talib("ATR").run(data.high, data.low, data.close).real
pf = vbt.PF.from_holding(
    data.loc["2022-01-01":"2022-01-07"],
    sl_stop=atr.loc["2022-01-01":"2022-01-07"],
    delta_format="absolute"
)
pf.orders.plot().show()
BTC-USD OHLC with a holding trade stopped using ATR as an absolute stop-loss Figure data (JSON)

Bar skipping

✅ Simulation based on orders and signals can now (partially) skip bars that do not define any orders, often resulting in significant speedups for strategies with sparsely distributed orders.

Benchmark bar skipping in a buy-and-hold portfolio
data = vbt.BinanceData.pull("BTCUSDT", start="one month ago UTC", timeframe="minute")
size = data.symbol_wrapper.fill(np.nan)
size[0] = np.inf
%%timeit
vbt.PF.from_orders(data, size, ffill_val_price=True)  
5.92 ms ± 300 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit
vbt.PF.from_orders(data, size, ffill_val_price=False)
2.75 ms ± 16 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

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.