VectorBT's Portfolio Engine: from_signals vs. from_orders, and the Pitfalls That Skew Backtests
VectorBT is the fastest of the mainstream Python backtesting frameworks because itsPortfolio object simulates an entire strategy as array operations rather than an event loop. That speed comes from a set of defaults and conventions that are easy to get wrong in ways that don't throw an error -- they just quietly change your results. This guide walks through three of them, each verified by running both the "wrong" and "right" version side by side on the same data so the size of the effect is measured, not asserted.
Building the Test Case
A standard 10/50-day SMA crossover on 300 bars of synthetic daily data, entering on a bullish crossover and exiting on a bearish one:
import numpy as np
import pandas as pd
import vectorbt as vbt
close = pd.Series(...) # your OHLCV close series
fast = close.rolling(10).mean()
slow = close.rolling(50).mean()
entries = (fast > slow) & (fast.shift(1) <= slow.shift(1))
exits = (fast < slow) & (fast.shift(1) >= slow.shift(1))
Pitfall 1: from_signals and from_orders Disagree by Default
Portfolio.from_signals() takes boolean entry/exit arrays and is built specifically for signal-based strategies. Portfolio.from_orders() is the more general primitive, taking an explicit order-size series -- and it's tempting to reach for it once you need something from_signals doesn't directly support. Porting the same crossover to from_orders looks like the obvious equivalent:
pf_signals = vbt.Portfolio.from_signals(
close, entries, exits, init_cash=100_000, fees=0.0005, slippage=0.0005,
)
size = pd.Series(np.nan, index=close.index)
size[entries] = np.inf # "buy with all available cash"
size[exits] = -np.inf # "sell the entire position"
pf_orders = vbt.Portfolio.from_orders(
close, size, size_type="amount", init_cash=100_000, fees=0.0005, slippage=0.0005,
)
On the test data, these produce different final portfolio values -- $125,622 for from_signals against $111,450 for the naive from_orders port, a difference large enough to change a strategy's apparent viability. The trade log explains why: from_signals defaults to direction="longonly", so an exit signal simply closes the long. from_orders defaults to direction="both", so a -np.inf order doesn't just close the long -- it keeps selling straight through zero into a large short position, which then gets unwound and re-entered on the next signal. The result is extra phantom round-trips that were never intended by a long-only crossover.
The fix is one keyword argument:
pf_orders_fixed = vbt.Portfolio.from_orders(
close, size, size_type="amount", init_cash=100_000,
fees=0.0005, slippage=0.0005, direction="longonly",
)
pf_orders_fixed.final_value() now matches pf_signals.final_value() exactly
With direction="longonly" set explicitly, both methods produce identical trade counts and final equity, confirming the two APIs are equivalent -- the direction default was the entire discrepancy. If you ever port signal logic from from_signals to from_orders (commonly needed for order types from_signals doesn't support, like target-percent rebalancing), set direction explicitly rather than relying on the default.
Pitfall 2: Zero Fees and Slippage Are the Default
A second, quieter issue in the same example: calling from_signals without fees or slippage arguments simulates costless, frictionless execution.
pf_free = vbt.Portfolio.from_signals(close, entries, exits, init_cash=100_000)
pf_costed = vbt.Portfolio.from_signals(
close, entries, exits, init_cash=100_000, fees=0.0005, slippage=0.0005,
)
On the same 300-bar test case, pf_free finishes at $126,252 and pf_costed (5 bps commission plus 5 bps slippage -- a reasonable assumption for a liquid large-cap) finishes at $125,622. That's a small gap on three trades over roughly a year; it compounds directly with trade frequency, so a mean-reversion strategy with hundreds of round trips will show a far larger gap between the "free" and "costed" backtest than a slow crossover will. Since fees=0 and slippage=0 are the library defaults, a backtest run without explicitly setting them is, silently, the frictionless version.
Pitfall 3: Same-Bar Signals Are a Look-Ahead
The crossover signal above is computed from the same bar's closing price -- fast > slow evaluated using today's close. Feeding that directly into entries at the same timestamp means the backtest enters at today's close using information (today's close) that only exists at today's close. In live trading you cannot act on a signal before the price that generates it has printed. The realistic version shifts execution to the next bar:
entries_shifted = entries.shift(1).fillna(False).astype(bool)
exits_shifted = exits.shift(1).fillna(False).astype(bool)
pf_shifted = vbt.Portfolio.from_signals(
close, entries_shifted, exits_shifted, init_cash=100_000, fees=0.0005, slippage=0.0005,
)
On the test data, the same-bar version finishes at $125,622 and the next-bar version finishes at $129,095 -- different results, in either direction, not because one is "better" but because they represent different (and only one of them executable) assumptions about when the strategy could actually have traded. Note the explicit .astype(bool): Series.shift() on a boolean array introduces NaN for the first row, which silently upcasts the dtype to object -- passing that into from_signals fails inside VectorBT's Numba-compiled core with an opaque TypingError rather than a clear message about the dtype.
Pitfall 4: freq Inference Fails on Real Trading Calendars
A fourth issue surfaces the moment you call any annualized metric:
pf = vbt.Portfolio.from_signals(close, entries, exits, init_cash=100_000)
pf.sharpe_ratio()
ValueError: <BusinessDay> is a non-fixed frequency
This happens whenever close.index is a business-day index (from pd.bdate_range, or from real market data where weekends and holidays are simply absent) -- VectorBT tries to infer a fixed timedelta from the index frequency to annualize returns, and BusinessDay isn't a fixed-length offset the way Day or Hour is. The fix is to pass freq explicitly rather than rely on inference:
pf = vbt.Portfolio.from_signals(close, entries, exits, init_cash=100_000, freq="D")
pf.sharpe_ratio() # now returns a value instead of raising
This is easy to miss in development because it only breaks when you call a metric that needs annualization (sharpe_ratio, annualized_return, stats()), not when you build the Portfolio object itself -- so a script that builds several portfolios and prints raw final_value() numbers can run cleanly, then fail the moment someone adds a Sharpe comparison.
A Checklist Before Trusting a VectorBT Backtest
- If you built the same strategy with both
from_signalsandfrom_orders, confirmdirectionmatches between them -- don't assume the defaults agree. - Set
feesandslippageexplicitly. Zero is a valid choice for some research questions, but it should be a choice, not an unnoticed default. - Shift signals derived from the same bar's close so entries/exits execute on the next bar, matching what would actually be tradeable live.
- Pass
freqexplicitly whenever your index isn't a fixed-frequencyDatetimeIndex-- which, for real daily market data, is essentially always.
Portfolio calls yourself in the Strategy Builder.
Key Takeaways
Portfolio.from_signals()defaults todirection="longonly";Portfolio.from_orders()defaults todirection="both". Porting a long-only signal strategy between the two without settingdirectionexplicitly introduces phantom short trades and changes the result -- verified at roughly an 11% difference in final equity on the test case here.feesandslippagedefault to zero. A backtest run without setting them is the frictionless, unrealistic version, even though nothing in the API signals that.- Signals computed from the same bar's close and fed into
entries/exitsat that same timestamp assume an unrealistic, unexecutable fill; shift by one bar to simulate acting on the signal after it's actually known. Series.shift()on a boolean array upcasts toobjectdtype on the introducedNaN; cast back toboolexplicitly or VectorBT's Numba core raises an opaqueTypingError.- VectorBT cannot infer an annualization frequency from a non-fixed-frequency index (
BusinessDay, or any real trading calendar) -- passfreqexplicitly or metrics likesharpe_ratio()raiseValueErrorat call time, not atPortfolioconstruction time.
Frequently Asked Questions
Is from_orders ever a better choice than from_signals?
Yes, for anything from_signals doesn't directly express: target-percentage rebalancing, variable position sizing per signal, or custom order logic driven by a size series rather than boolean flags. The pitfall isn't that from_orders is worse -- it's more general and requires you to be explicit about things from_signals defaults sensibly (direction, in particular). Use from_signals for straightforward boolean entry/exit strategies and reach for from_orders only when you need the extra control, setting direction and size_type deliberately rather than accepting defaults you haven't checked.
Why does VectorBT default to zero fees and slippage instead of a realistic default?
There's no universally "realistic" default -- costs vary enormously by instrument, venue, and order type, so any default VectorBT picked would be wrong for most users some of the time. The library's design philosophy leans toward explicit configuration over implicit assumptions; the tradeoff is that it's easy to forget to set them, especially when iterating quickly on signal logic before moving to cost-aware evaluation. Treat "did I set fees and slippage" as a standard item to check before reading any backtest result, the same way you'd check for look-ahead bias.
How much does the same-bar vs. next-bar signal timing actually matter?
It depends entirely on the strategy's holding period and how much the entry price moves between the signal bar and the next bar. For a slow-moving crossover on daily bars, the difference is usually a matter of a few basis points per trade from the price gap between bars. For higher-frequency strategies, or ones where the signal itself is correlated with a same-bar price jump (a breakout strategy is the classic case), same-bar execution can substantially overstate performance because it implicitly assumes you traded at a price that had already moved in your favor before you could have known to trade.