The CLI and BacktestEngine.run() together execute this sequence:
- Load, clean and validate data in
DataLoader. - Isolate tradable symbols and compute adjusted-price returns.
- Compute features inside the strategy and generate signals at
t. - Transform signals into target weights with the allocator.
- Apply optional portfolio-level volatility targeting.
- Apply hard portfolio constraints.
- Apply the configured rebalance schedule and turnover cap.
- Apply any additional execution-delay stress assumption.
- Shift held weights one period before computing returns — the look-ahead barrier.
- When
portfolio.model_weight_driftis enabled, evolve the shifted, executed weights forward by organic price drift between real trades (see Weight drift) — otherwise unchanged, holding them constant until the next scheduled rebalance. - Solve turnover, costs, gross/net returns and equity, including equity-dependent volume slippage.
- Build the benchmark and trade log from the same dates and cost assumptions.
- Compute metrics and assemble the
BacktestResult.
The return earned in period t must come from a position decided before
t. quantlab.backtesting.accounting.run_accounting enforces this via
quantlab.execution.orders.executed_weights:
executed_weights = compute_executed_weights(
held_weights,
tradable=tradable,
)
gross_returns = (executed_weights * asset_returns).sum(axis=1)For a single-calendar universe (tradable=None) this reduces to the simple
mental model held_weights.shift(1) (first row zeroed); when a per-symbol
tradable mask is given, the shift is per-symbol tradability-aware instead
— a decision made right before a closure lands on that symbol's own next
tradable row, not the raw next row, so it is never misattributed as trading
during the closure itself.
This is directly unit-tested (tests/unit/test_accounting.py): a signal that
turns long on date i must show zero gain on date i and only starts
capturing returns from date i + 1.
ExecutionConfig.execution_timing (same_bar_close / next_bar_open /
immediate_after_close) controls how a period's return contribution is
attributed relative to the bar the decision was made on:
-
same_bar_close(the default): the decision made from barT's close is treated as if it were also filled at that same close. Standard backtesting idealization, not causally realizable live — a real order cannot be conditioned on a bar's own final close and also fill at that exact close. -
next_bar_open: decide at close(T), fill at open(T+1) — realizable on a session-based market (a regular order queued overnight). Every date's return is decomposed into an overnight leg (close[T-1] -> open[T], earned on the pre-trade holding) and an intraday leg (open[T] -> close[T], earned on the post-trade holding), and the two legs are compounded,(1+overnight_leg)*(1+intraday_leg) - 1, never simply added — a portfolio 100% in A that gains 10% overnight, then rotates entirely into B, which itself gains 10% intraday, has genuinely earned1.10*1.10-1 = 21%that day (the overnight gain funds a larger position in B), not10%+10% = 20%. Both legs are computed portfolio-wide (every column, not only the ones that traded that day, since the whole book — including untraded positions — earns the overnight leg together before any of that day's trades execute); this provably collapses back to the plain close-to-close formula whenever nothing traded anywhere in the book (seequantlab.backtesting.accounting._gross_returns_and_cost_basis's own docstring for the derivation). Turnover and every cost derived from it (commission, spread, volume-dependent slippage) are sized against the equity that exists at the open —equity[T-1] * (1+overnight_leg)— since that is what a real order at the open actually has to move against; each cost component is then rescaled by that same(1+overnight_leg)factor before it is ever subtracted from a return or reported, soAccountingResult.costsand the net-return curve stay on the SAME close(T-1)-equity basis throughout, never a mix of the two. Stop-loss/take-profit barriers use the same two-leg compounding at the group level (see Stop-loss / take-profit below) so a gap that happened before a position's own entry is never credited toward (or blamed on) its cumulative barrier return. The adjusted open price this needs is derived byquantlab.backtesting.execution_prices.RatioApproximatedExecutionPrices— exact for a pure split, only approximate for a cash-dividend adjustment (see that class's own docstring).Combined with
portfolio.model_weight_drift=True(the platform default),apply_weight_drift's own per-row loop applies the SAME overnight-then-intraday split to its internaldollar/equitystate: the overnight leg is applied FIRST, before any decision that row makes (anchor detection, themaximum_turnovercap, hard-risk-limit compliance), so every decision is evaluated against the real open(T) weight, never a stale close(T-1) one; only the remaining intraday leg advances state at the row's end, ready for the next row's own overnight leg. A hard-risk-limit or turnover-cap correction therefore lands on the SAME row the gap that triggered it happened on, exactly like the ordinary step-function path above. -
immediate_after_close: decide and fill an idealized instant after a bar closes — realizable only on a continuous (24/7) market, where there is no session gap to separate an overnight leg from an intraday one, so this mode keeps the plain close-to-close formula (numerically identical tosame_bar_close, but for a causally different reason: no look-ahead is needed to compute a decision from the finalized close and then submit an order an instant later, unlike a session-based close). This is still a modeling approximation, not a guarantee of a literal zero-latency fill at the exact printed close — real network/exchange latency and any price movement during it are not modeled, the same disclosed idealization every other execution-timing mode makes about its own reference price. Rejected at config-validation time for any instrument whose calendar isn't"24/7"(ExperimentConfig._check_immediate_after_close_needs_a_247_market) — nothing would otherwise stop it from silently producingsame_bar_close-identical numbers on a session-based market while claiming a materially different execution assumption.
Changing this field is optional for ordinary backtesting — QuantLab does
not force a default change. quantlab.validation.walk_forward shares the
exact same overnight_returns derivation
(quantlab.backtesting.execution_prices.overnight_returns_for_config) so
walk-forward validation stays numerically consistent with a single
backtest using the same config. BacktestEngine.run accepts a custom
execution_price_source override, and WalkForwardValidator's own
constructor accepts the identical parameter and forwards it to every
internal accounting call site (fold selection, OOS stitching, and
rescore_with_costs) — a non-default source given to a single backtest
is honoured identically by a WalkForwardValidator built with the same
source over the same config.
StrategyConfig.signal_price_type (adjusted_close/close) controls
only what a strategy's own generate_signals() sees — it does not affect
execution or costs, which never depend on it:
- Returns always use the adjusted close (and, under
execution_timing="next_bar_open", the adjusted open too — see above). - Commission/spread costs are computed from weight/notional changes alone; no price series is consulted at all.
- Volume-based slippage's average-daily-volume basis uses the RAW (unadjusted) close instead, since dollar ADV should reflect real historical trading volume against the price actually traded at, not a split/dividend-adjusted one.
equity_0 = initial_capital
equity_t = equity_{t-1} * (1 + net_return_t)
Gross and net equity curves are both retained, so cost drag can be
inspected via result.gross_net_comparison().
A strategy's stop_loss_pct/take_profit_pct (fractional, e.g. 0.10 =
10%, None by default on every built-in strategy -- disabled with
strictly no change to accounting's numbers) force-flatten a position when
its cumulative return since entry breaches the configured threshold. This
operates on the real executed position (accounting.executed_weights,
after the allocator, portfolio constraints, rebalancing schedule and
turnover cap), never on a strategy's raw signal -- a signal is not
necessarily a realized position. For a symbol/group G, at each date:
gross_exposure = sum(|executed_weight| for each symbol in G)
group_return = sum(executed_weight * asset_return for each symbol in G) / gross_exposure
group_return is per unit of the group's own realized exposure that
date, not a dollar contribution to total portfolio equity — this makes it
correct regardless of a static or dynamic hedge ratio, rebalancing,
weight changes, long/short direction or partial fills. For a single
symbol it reduces to sign(executed_weight) * asset_return, the standard
price-based stop-loss/take-profit. A strategy declares a multi-symbol
group via BaseStrategy.position_groups() (e.g. pairs_trading's two
legs, so a stop-loss triggers on the pair's combined P&L, not either
leg's own return in isolation) — every symbol not covered by a declared
group is its own independent group. Thresholds are evaluated on gross
(pre-cost) return: QuantLab's execution cost model is portfolio-level
only (no per-symbol/per-group cost decomposition), so an exact net-of-cost
trigger is not presently computable — a deliberate, disclosed design
convention. Once triggered, no immediate re-entry at a rebased price: the
position stays flat until its next real flat-to-non-flat transition. See
quantlab.backtesting.accounting._detect_stop_loss_take_profit.
Under execution_timing="next_bar_open", group_return is instead the
group's own compounded overnight/intraday two-leg return (rescaled to
the group's own gross exposure, mirroring Execution
timing's portfolio-level derivation exactly, but at
the group scale) — never the plain close-to-close formula above. This
matters specifically for a position entered at the open: a gap that
happened before that entry must not be credited toward (or blamed on)
the position's cumulative barrier return, since the position was not yet
held during that gap. See
quantlab.backtesting.accounting._group_return_with_execution_timing.
If an asset return is missing while a non-zero position is held, accounting
raises BacktestError; it never invents a 0% move. A missing return on an
unheld asset contributes nothing. A row with no held exposure is treated as a
zero portfolio return.
portfolio_return = weight * asset_return. A negative weight combined with a
negative asset return correctly produces a gain — no special-casing needed;
this is a straight consequence of the formula and is unit-tested.
Three components are charged from traded notional (expressed as a fraction of equity when the model receives weight changes):
- Commission:
traded_notional * commission_bps / 10_000 - Spread:
traded_notional * spread_bps / 20_000(half-spread) - Slippage: a constant rate (
bps / 10_000) or a nonlinear volume-based impact rate (base_bps + impact_coefficient * sqrt(order / ADV)). The volume model requires finite positive ADV for every traded asset.
ExecutionModel aggregates all three; the trade log
(quantlab.backtesting.trade_log) itemises every fill with its own cost
breakdown.
This section describes rebalancing.py's own DECISION-timeline output --
the rebalance target the strategy/allocator/constraints machinery
decides to chase. It is not automatically the weight actually held, and
its own turnover formula is not automatically the real trade executed:
when portfolio.model_weight_drift is enabled (the default -- see
Weight drift below), organic price drift between real
trades changes the weight actually held continuously, and a genuine trade
is instead reported by apply_weight_drift's own trade_changes output --
exactly zero on a pure-drift row (price moved; nothing was traded) and the
real size on an anchor or a landed compliance/turnover-cap correction.
Daily allocator output is a target; apply_rebalancing samples it on
rebalance dates (daily / weekly / monthly / quarterly) and holds it constant
between them — at THIS decision-timeline layer, trades, and therefore
costs, only occur at rebalances. Turnover is sum(|held_t - held_{t-1}|)
(w_{-1} = 0), directly matching the manual example of capital 100k,
turnover 0.5, 10 bps → cost 50.
For a mixed-calendar portfolio, rebalance_and_cap_turnover is
tradability-aware: a closed instrument (per its own calendar, see
Data pipeline) never
trades on a closed date, and its rebalance target becomes a pending debt that
keeps retrying — at every following tradable session, not only the next
scheduled rebalance — until fully executed, even if maximum_turnover spreads
that execution across several sessions. Portfolio constraints (gross/net
exposure, max weight, long-only) are enforced on the actually-executed
holdings after accounting for frozen/closed instruments, not just on the
theoretical fully-open target, since freezing one instrument while others move
can push the real portfolio out of its mandate even when the target was
compliant. For a single-calendar experiment this machinery is a proven no-op:
behaviour is byte-identical to the plain rebalance/turnover-cap path above.
rebalance_and_cap_turnover's output — including a pending target resolving
there on a reopening day — is still only a decision, dated that day. Every
decision, on a reopening day or an ordinary rebalance date alike, is subject
to the same one-period (tradability-respecting) look-ahead shift applied by
the accounting layer before it affects executed weights, turnover or costs —
a target that resolves in held_weights on a symbol's reopening day therefore
does not reach the accounting layer until that symbol's next tradable
session, not the reopening day itself.
Everything above describes rebalancing.py's own output: a decision-timeline
step function, constant between rebalance dates. A real portfolio does not
actually stay constant between trades — each asset's own price move drifts
its dollar exposure, and therefore its weight, continuously. When
portfolio.model_weight_drift is True (the default),
quantlab.backtesting.accounting.apply_weight_drift evolves the already
shifted, executed weights forward between genuine trades, via a per-column
dollar exposure and a single shared relative equity E
(weight[i] = dollar[i] / E).
Conceptually, two independent kinds of debt drive every row's output, in
priority order: a hard-risk-limit breach
(maximum_weight/maximum_gross_exposure/maximum_net_exposure/long_only)
is corrected first, via a genuine linear program — never a "clip and scale
toward zero" heuristic,
which can move exposure in the wrong direction — that finds the minimal-
turnover point restoring compliance; an ordinary fresh rebalance decision
is applied second, turnover-capped like a decision-level rebalance. See
Weight-drift mechanics and the compliance-restoration LP
for the full per-column debt priority order, anchor detection, the
bankruptcy guard, and the LP's exact formulation — this section only
states the invariants and limitations a caller needs to know:
- Output is always a pre-period value — the weight held going into a row, before that row's own return is applied — never the post-period value, which would double-count that row's own return.
- A declared position group (e.g.
pairs_trading's two legs, seeBaseStrategy.position_groups()) is always corrected as one coherent unit via a single shared scaling factor, never one leg moving alone. - The correction is sign/support-preserving: an existing long may shrink or grow further long, an existing short may shrink or grow further short, but neither crosses zero, and a column already at exactly zero is never opened into a brand-new position — it can never invent a hedge the strategy's own signal never asked for.
- A closed asset's dollar exposure does not move, but its weight still
drifts purely through
E's own movement from every other tradable asset's real return. - A hard risk-limit breach detected using row
t's own drift cannot execute until rowt+1at the earliest — the same look-ahead barrier as every other decision in this module — and if the responsible exposure sits in a currently-closed column, full correction may be impossible until it reopens; the LP applies the best achievable fix meanwhile, carrying the residual as a pending breach rather than raising or silently dropping it. - Never produces
inf/NaN: a bankrupt anchor-episode (relativeE <= EPSILON) is force-flattened and logged instead of dividing by (near-)zero. model_weight_drift=Falseremains available as an optional constant- weight compatibility mode (byte-identical to the step function described above), not the recommended path.- The compliance-restoration LP's own basis is gross/pre-cost, the same
disclosed convention already used by
stop_loss_pct/take_profit_pct.
quantlab.backtesting.trade_log._classify_reason assigns every fill's
trigger_reason_code/adjustment_reason_codes from real, per-layer
provenance signals — never deduced after the fact from new != desired.
execution_delay/the rebalance-sampling frequency are not adjustments:
they are uniform timing conventions baked into every comparison below, so
they shift when a trigger is consumed, never what explains one trade's
execution differing from another's.
Trigger — the single most-upstream event currently consumed that
initiated the target change (not exhaustive: when strategy_signal is the
trigger, a downstream layer subsequently recomputing the target is a
mechanical consequence of that same event, not separately lost
information):
| Priority | Code | Fires when |
|---|---|---|
| 1 | strategy_signal |
The strategy's own decision changed since the last rebalance (from its diagnostic decision proxy — decision_signal() when provided, else the raw signal). |
| 2 | portfolio_rebalance |
Only the allocator's output changed. |
| 3 | volatility_target_adjustment |
Only vol-targeting changed the target. |
| — | (none) | Nothing above changed. |
Adjustment(s) — collected independently of trigger, from each layer's
own real provenance signal; a higher-priority cause fully explains the row
and suppresses lower-priority ones (the precise clip value of a lower
layer becomes moot once a higher one applies). Priorities 1–3 never touch
trigger, which keeps reflecting what the strategy actually wanted:
| Priority | Code(s) | Fires when |
|---|---|---|
| 1 | forced_liquidation |
Portfolio ruin (AccountingResult.ruined) — overrides everything else. |
| 2 | stop_loss / take_profit |
A real force-flatten (AccountingResult.stop_loss_triggered/take_profit_triggered) — overrides ordinary constraints, overridden by forced_liquidation. |
| 3 | drift_compliance / drift_compliance_pending |
The drift-compliance LP restored (or attempted to restore) a hard risk limit breached by organic drift — overrides ordinary constraint/tradability/turnover_cap adjustments, overridden by a stop-loss/take-profit breach on that same corrected weight. |
| 4 | Constraint name(s), tradability, turnover_cap |
A contributing constraint (direct/redistribution), a closure catch-up/feasibility limit, or the turnover budget itself. |
| 5 | position_rescaling / deferred_catchup |
Last-resort fallback: the target is still drifting with no known trigger (e.g. pairs_trading's price/beta rescaling), or the causal layer is genuinely unknown — reached only when nothing above explains the row. |