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.
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 3cols = ["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 MarketSix 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:
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.0The 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:
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.94The 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:
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.0Setting 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:
| Format | Meaning | Example for a $100 entry |
|---|---|---|
"percent" (default) | Fraction of the reference price | 0.05 is $5 away |
"percent100" | Percentage points | 5 is $5 away |
"absolute" | Price distance | 5 is $5 away |
"target" | The price itself | 95 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:
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.9For 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:
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: float64vbt.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: float64With 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:
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 PartialFillThe 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:
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.534242pd.Series({"simulated": breakout_pf.total_return, "live": live_pf.total_return})simulated 0.574451
live 0.565769
dtype: float64Every 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.
✅ 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.
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.0pf.value.tolist() [999.0, 1009.0, 1018.0]✅ Limit and stop orders can now be defined using a target price instead of a delta.
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_afterTimedeltaIndex([ '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)✅ 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.
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()✅ 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.
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()✅ 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.
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()✅ 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.
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)Related pages
- Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Event-driven backtestingWrite compiled callbacks for cooldowns, position limits, and custom simulators
- Stop loss and take profitBacktest stop losses, trailing stops, take profits, time stops, and exit ladders
- Realistic backtestsAvoid look-ahead bias, impossible fills, and survivorship bias in your backtests
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.