Modes, contracts, invariants
Every strategy on the engine is three decisions: what the strategy returns (target weights or explicit orders), what exposure profile its numbers encode (long/cash, fully invested, long-short, market-neutral, leveraged), and which accounting path turns those numbers into NAV. This page maps all three against the engine as it actually ships — every config block below is the real constructor surface, every code sample the real API, and the invariants at the end are what the reports assume you kept.
The core mental model
One pipeline, staged so each concept has exactly one home: signals → target weights → risk model → rebalance policy → execution → accounting → report.
The stages exist to keep distinct quantities from blurring into each other. A signal is conviction, dimensionless. A target weight is desired capital, a fraction of NAV. An actual weight is what you hold after prices drift it. Units are shares or contracts; orders are requests; fills are what actually happened, at a price, with a cost. Most silent backtest bugs are category errors between these — multiplying returns by units, renormalizing a market-neutral book by its net sum, treating a submitted order as a fill.
| Concept | Meaning | Unit | Example |
|---|---|---|---|
signals | Alpha intent | dimensionless | 1, 0, −1, z-score, rank |
target_weights | Desired allocation | fraction of NAV | SPY = 0.20 |
actual_weights | Realized, drifted allocation | fraction of NAV | SPY = 0.213 |
position units | Shares / contracts held | units | 125 shares |
orders | Requested trades | units | buy 50 SPY |
fills | Executed trades | units, price, cost | 50 @ 512.25 + costs |
NAV | Total portfolio value | currency | cash + Σ units × price |
The two identities that anchor everything downstream — portfolio value and the one-bar timing rule:
The strategy contract mirrors the pipeline: weights-mode strategies implement two vectorized stages, orders-mode
strategies implement one per-bar callback. Keeping _compute_signals and
_compute_weights separate is what lets the sizing
layer swap without touching the alpha:
class MyStrategy(Backtester):
# Weights mode: two stages, both vectorized over the full frame
def _compute_signals(self) -> pd.DataFrame:
"""What looks attractive. Dimensionless: 1/0/-1, z-score, rank."""
...
def _compute_weights(self) -> pd.DataFrame:
"""How much capital that conviction gets. Fraction of NAV per name."""
...
# Orders mode: one callback per bar instead
def _compute_orders(self, date, bars, current_positions, nav) -> None:
"""Submit explicit orders against the fill engine."""
... The real mode matrix: two modes, three paths
One constructor argument picks what your strategy returns; a second picks how weights become transactions. Together they define three execution paths with different physics and different cost knobs.
| Path | Config | What executes | Costs come from | Use when |
|---|---|---|---|---|
| Fast weights | execution_mode="weights"weight_execution="fast" | Weight accounting, close-to-close; trades are implied share deltas | weight_cost_model (default 1 bp) | Research iteration, permutation tests, anything daily and liquid |
| Weights → orders | execution_mode="weights"weight_execution="orders" | Targets routed through real orders and fills; integer shares, margin and buying-power checks | slippage_model + commission_scheme, per fill | Pre-deployment realism check; contract-aware sizing (futures, FX lots) |
| Orders | execution_mode="orders" | Explicit order objects: market, limit, stop, stop-limit, trailing, bracket, OCO | slippage_model + commission_scheme, per fill | Stops and brackets, intraday timing, event-driven entries |
Each path ignores the other paths' knobs — and says so out loud. Configure a slippage_model on the
fast-weights path and the engine logs an ignored-knob warning rather than silently doing nothing; the same
honesty applies in every direction (the costs guide maps the full
matrix). Misconfiguration fails at construction time, not mid-run:
# The constructor fails loudly instead of running a different backtest:
Backtester(..., rebalance_polcy=RebalancePolicy()) # typo
# TypeError: Backtester.__init__() got unexpected keyword argument(s):
# rebalance_polcy. Check for typos - unknown kwargs are not
# silently ignored.
Backtester(..., execution_mode="positions")
# ValueError: execution_mode must be 'weights' or 'orders'
# (case-insensitive), got 'positions'
Backtester(..., target_volatility=0.15)
# accepted - but logged as report metadata ONLY:
# "[Backtester] target_volatility accepted for report metadata only -
# volatility targeting is not enforced." -> use VolTargetModel instead. Exposure profiles are code contracts, not switches
There is no exposure_mode knob — deliberately. The exposure profile is encoded in the weights your
strategy emits; strategy_type is the label the reports print. This section is the recipe book, one
profile at a time, each with its invariant.
Long / Cash. Hold the attractive names, keep the rest in cash — trend following, rotation, risk-on/risk-off. The defining discipline: unallocated capital is intentional. Weights are non-negative, row sums may be well below 1.0, and the engine's cash sleeve earns the residual. The classic bug is renormalizing after a cap clip, which silently converts "25% cap, rest in cash" into "everything scaled back up to full investment":
class TrendBasket(Backtester):
"""Long / Cash: hold trending names, keep the rest in cash."""
def _compute_signals(self) -> pd.DataFrame:
fast = self.instruments_data.get_feature("SMA_50_close")
slow = self.instruments_data.get_feature("SMA_200_close")
valid = fast.notna() & slow.notna()
return (fast > slow).astype(float).where(valid, 0.0)
def _compute_weights(self) -> pd.DataFrame:
active = self.signals == 1.0
counts = active.sum(axis=1)
weights = active.div(counts.where(counts > 0), axis=0).fillna(0.0)
weights = weights.clip(upper=0.25)
# Do NOT renormalize after clipping - the residual stays in cash.
# That is the entire point of a Long / Cash profile.
return weights Long-only, fully invested. Benchmark-relative books: top-N momentum, factor portfolios, equal-weight baskets. Row sums target 1.0 — but the cap constraint still binds, and the two can conflict. Renormalize only when doing so provably cannot push a name back through its cap; otherwise accept the cash residual or solve the constrained allocation properly:
def _compute_weights(self) -> pd.DataFrame:
close = self.instruments_data.get_feature("adj_close")
momentum = close / close.shift(126) - 1.0
ranks = momentum.rank(axis=1, ascending=False)
selected = (ranks <= 5) & momentum.notna()
weights = selected.div(selected.sum(axis=1).clip(lower=1), axis=0)
weights = weights.clip(upper=self.max_position_size)
# Renormalizing back to 1.0 is allowed ONLY if it cannot push any
# name back through the cap. With 5 names capped at 25%, a full
# renormalization is safe; with 3 names it is not - check, don't hope.
return weights.astype(float) Long / Short, gross-controlled. Winners-minus-losers, factor long-short. The book is defined by its gross exposure |w|.sum() and its net exposure w.sum() — and the single most important line in this profile is which one you normalize by:
def _compute_weights(self) -> pd.DataFrame:
close = self.instruments_data.get_feature("adj_close")
momentum = close / close.shift(126) - 1.0
long_mask = momentum.rank(axis=1, ascending=False) <= 3
short_mask = momentum.rank(axis=1, ascending=True) <= 3
longs = long_mask.div(long_mask.sum(axis=1).clip(lower=1), axis=0) * 0.5
shorts = short_mask.div(short_mask.sum(axis=1).clip(lower=1), axis=0) * -0.5
weights = (longs + shorts).fillna(0.0)
# Gross = |w|.sum() = 1.0, net = w.sum() ~ 0. Normalize long-short
# books by GROSS, never by the net sum - dividing by a near-zero
# net explodes the book.
return weights Market-neutral pairs. The two-leg special case — the full treatment, including the half-life diagnostic that decides whether the spread is tradeable at all, is the pairs guide. The shape of the code:
def _compute_signals(self) -> pd.DataFrame:
close = self.instruments_data.get_feature("adj_close")
a, b = self.instruments[0], self.instruments[1]
spread = np.log(close[a]) - np.log(close[b])
z = (spread - spread.rolling(60).mean()) / spread.rolling(60).std()
signals = pd.DataFrame(0.0, index=close.index, columns=close.columns)
state = 0
for i, zi in enumerate(z):
if pd.isna(zi):
state = 0
elif state == 0:
if zi > 2.0: state = -1
elif zi < -2.0: state = 1
elif abs(zi) < 0.5:
state = 0
signals.iloc[i] = [state, -state]
return signals
def _compute_weights(self) -> pd.DataFrame:
return self.signals * 0.5 # +/-0.5 per leg: gross 1.0, net ~0
±0.5 per leg zeroes the net dollar exposure — beta, duration, sector and currency exposure survive. The
research baseline also does not model short borrow or financing; a serious market-neutral claim adds those
assumptions explicitly and checks the rolling-beta chart in the report, not just the net-exposure line.
Beta-targeting, if you need it, is a custom RiskModel — the
sizing guide documents the adjust()
contract that makes one a twenty-line class.
Leveraged / futures. Managed-futures and vol-targeted books where gross exposure can exceed
NAV. Two engine features carry this profile: contract_specs (multipliers, integer contracts, FX
lot sizes — API metadata fills them, explicit specs override) and the orders-family execution paths, which
enforce margin and buying power per fill. Weight-mode leverage is possible (a VolTargetModel with
max_leverage above 1 produces it), but futures deserve contract-aware execution — see
example_orders_20_futures_donchian_contracts and example_weights_25_continuous_futures_trend
in the catalog.
Order-driven. When the strategy's edge lives in execution timing — stops, brackets, limits, intraday entries — weights cannot express it. The strategy talks to the fill engine directly, and the blotter becomes the source of truth:
from backtester.execution.order_types import Order, OrderSide, OrderType
class MarketSMACrossover(Backtester):
"""Order mode: the strategy submits explicit orders per bar."""
def _compute_orders(self, date, bars, current_positions, nav) -> None:
fast = self.instruments_data.get_feature("SMA_20_close")
slow = self.instruments_data.get_feature("SMA_50_close")
if date not in fast.index:
return
for inst in self.instruments:
f, s = fast.loc[date, inst], slow.loc[date, inst]
if pd.isna(f) or pd.isna(s):
continue
pos = current_positions.get(inst, 0.0)
signal = 1 if f > s else 0
prev = self._prev_signal.get(inst, 0)
if signal == 1 and prev == 0 and pos == 0 and not self._has_pending(inst):
shares = int(nav * 0.18 / bars[inst].close)
if shares > 0:
self.fill_engine.submit(
Order(inst, OrderSide.BUY, shares, OrderType.MARKET))
elif signal == 0 and prev == 1 and pos > 0:
self.fill_engine.cancel_all(instrument=inst)
self.fill_engine.submit(
Order(inst, OrderSide.SELL, pos, OrderType.MARKET))
self._prev_signal[inst] = signal The constructor, as it actually is
One honest, complete example — every argument below exists, does what it says, and is validated at construction time.
strategy = TrendBasket(
# ── identity + data ──
api_key=os.environ.get("QJ_API_KEY"),
strategy_name="TrendBasket_v1",
strategy_type="Long / Cash", # report label - free string
initial_capital=100_000,
instruments=["SPY", "EFA", "EEM", "TLT", "IEF", "GLD", "DBC", "VNQ"],
backtest_period={"start": "2007-01-03", "end": "2026-01-01"},
benchmark_symbol="SPY",
source="yfinance",
# ── execution mode ──
execution_mode="weights", # "weights" | "orders"
weight_execution="fast", # weights only: "fast" | "orders"
# ── costs (fast weights path) ──
weight_cost_model=FixedBpsWeightCostModel(total_bps=5.0),
# ── policy layers ──
rebalance_policy=RebalancePolicy(frequency="BME", drift_threshold=0.05),
risk_model=RiskModelChain([
VolTargetModel(target_vol=0.10, lookback=63, max_leverage=1.5),
PositionLimitModel(max_weight=0.30, max_total_leverage=1.5),
]),
max_position_size=0.25, # weights mode, no custom risk_model:
# auto-routed into a PositionLimitModel
# ── indicators computed engine-side ──
indicators_config=[
{"function": "SMA", "price_cols": ["close"], "params": {"periods": [50, 200]}},
],
# ── strictness ──
strict_reporting=False,
allow_partial_data=False, # missing instruments raise, not shrink
)
await strategy.run_strategy()
Three behaviors worth knowing behind the arguments. The cash buffer is portfolio accounting,
not a constructor knob: weight-mode accounting reserves a 5% cash sleeve by default (targets are scaled by
1 − buffer), which is why a "fully invested" fast-weights book reports gross exposure near 0.95.
max_position_size in weights mode is enforced, not decorative — the engine routes
it into a PositionLimitModel unless you supplied your own risk model, in which case your chain owns
the caps. target_volatility is the opposite: accepted for report metadata only,
with a warning in the log — enforcement is VolTargetModel's job, deliberately, so that vol
targeting is a visible, parameterized decision rather than a hidden default.
Rebalancing decides when, never what
The strategy produces targets; the declarative policy decides on which bars targets become trades. Between those bars, positions drift with prices — and the engine tracks the drifted weights explicitly.
from backtester.portfolio.rebalance import RebalancePolicy, RebalancePresets
policy = RebalancePolicy(
frequency="BME", # D | W | BME | BQE | BYE | "21D" | None
weekday=4, # used when frequency="W"
drift_threshold=0.05, # L2a: held vs TODAY's target
drift_type="absolute", # or "relative"
tracking_error_threshold=0.06, # L2b: needs benchmark_returns
tracking_error_window=63,
rebalance_on_signal_change=False, # L3: fires on target changes
signal_change_threshold=0.01,
max_drawdown_trigger=-0.15, # L4: circuit breaker
max_drawdown_action="flatten", # or "halve"
circuit_breaker_cooldown_days=5,
max_annual_turnover=4.0, # L5: rolling 252d turnover budget
partial_rebalance=False, # snap only drifted names
)
RebalancePresets.MONTHLY # BME
RebalancePresets.MONTHLY_WITH_DRIFT # BME + 5% band
RebalancePresets.RISK_MANAGED # BME + drift + DD breaker + TO budget | Layer | Trigger | Watch out for |
|---|---|---|
| L1 · Calendar | frequency, calendar_dates, weekday | Match the schedule to the signal's cadence — the costs guide measures a 6-policy grid |
| L2a · Drift band | drift_threshold, drift_type | Drift is measured against today's target — on a daily-updated signal a 5% band becomes a near-daily policy (measured: 5.4× → 32× turnover) |
| L2b · Tracking error | tracking_error_threshold, _window | Silently skipped without benchmark_returns — pass the series explicitly |
| L3 · Signal change | rebalance_on_signal_change, signal_change_threshold | Detected on post-normalization targets, so universe changes fire it by design |
| L4 · Circuit breaker | max_drawdown_trigger, _action, cooldown | Protective override — flatten or halve, then cool down |
| L5 · Turnover budget | max_annual_turnover | A budget below the strategy's natural rate forces it to hold what it would sell — measured: −43.9% MDD in the GFC |
Validation by profile
The engine validates the weights frame on ingest — alignment, shape, finiteness. The profile invariants are yours to assert, and cheap to keep.
# The engine validates the weights frame on ingest - shape, index
# alignment, finite values. Your strategy should hold its own
# invariants too, per profile:
w = strategy.portfolio_data.weights
# Long / Cash: never exceed full investment
assert w.min().min() >= 0.0
assert w.sum(axis=1).max() <= 1.0 + 1e-9
# Long / Short gross: normalize by gross, bound the net
gross = w.abs().sum(axis=1)
net = w.sum(axis=1)
assert gross.max() <= 1.0 + 1e-9
assert net.abs().max() <= 0.02 + 1e-9
# Any profile: no NaN weights, ever - flat is 0.0, not "unknown"
assert not w.isna().any().any() Then validate the strategy like evidence, not like code: the MCPT suite for skill-versus-luck, walk-forward for persistence, and the workflow guide's seven gates for the full pipeline. Weight purity — everything derived from the frames the strategy receives — is what makes all three applicable without modification.
Accounting invariants the reports assume
Every chart and metric downstream rests on a handful of identities. Break one in custom analysis and the numbers will still render — they will just be wrong.
# Units and weights are different animals - never multiply returns
# by share counts:
pnl_wrong = returns * position_units # WRONG: unit mismatch
contribution = returns * weights.shift(1) # return contribution
pnl_dollars = returns * position_values.shift(1) # dollar P&L contribution
# And the timing rule that underlies every number in the report:
# decisions computed from bar t earn bar t+1. The engine shifts held
# weights internally; if you reconstruct returns yourself, shift too.
The one-bar rule deserves its own sentence, because it is the engine's core anti-look-ahead guarantee: targets
computed from bar t are held from bar t+1 — the engine shifts internally, orders fill at the
next open by default on the orders paths, and every rolling estimate in the shipped examples is strictly
trailing. NAV reconciles as cash plus marked positions on every bar; on the orders paths every trade exists in
the blotter with its price and cost, and a missing return is NaN — poisoned honestly, never a
phantom zero. These invariants are the reason a p-value or a fold table computed on top of the engine means
anything at all.