QuantJourney Backtester

QuantJourney Backtester

Share product feedback

✓

Thank you.

Your note is now in the QuantJourney inbox.

Engine guide · Strategy modes

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.

2 execution modes, 3 paths 6 exposure profiles as code 5 rebalancing trigger layers Loud failures, no silent knobs

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.

ConceptMeaningUnitExample
signalsAlpha intentdimensionless1, 0, −1, z-score, rank
target_weightsDesired allocationfraction of NAVSPY = 0.20
actual_weightsRealized, drifted allocationfraction of NAVSPY = 0.213
position unitsShares / contracts heldunits125 shares
ordersRequested tradesunitsbuy 50 SPY
fillsExecuted tradesunits, price, cost50 @ 512.25 + costs
NAVTotal portfolio valuecurrencycash + Σ units × price

The two identities that anchor everything downstream — portfolio value and the one-bar timing rule:

NAVt  =  casht  +  ∑jqj,t Pj,trt(p)  =  ∑jwj, t−1  rj, t\mathrm{NAV}_t \;=\; \text{cash}_t \;+\; \sum_j q_{j,t}\, P_{j,t} \qquad\quad r^{(p)}_{t} \;=\; \sum_{j} w_{j,\,t-1}\; r_{j,\,t}

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:

strategy_contract.pypython
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.

PathConfigWhat executesCosts come fromUse 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:

loud_failures.py — real engine behaviorpython
# 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":

profile_long_cash.py — invariant: 0 ≤ w, Σw ≤ 1python
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:

profile_long_only.py — invariant: Σw ≈ 1, w ≤ cappython
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:

profile_long_short.py — invariant: gross ≈ 1, |net| ≤ bandpython
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:

profile_market_neutral.py — invariant: net ≈ 0, gross ≤ 1python
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
Dollar-neutral is not risk-neutral, and the engine will not pretend otherwise

±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:

profile_orders.py — example_orders_01, real APIpython
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.

full_config.pypython
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.

wj,tdrift  =  wj,t−1 (1+rj,t)  casht−1+∑kwk,t−1 (1+rk,t)  w_{j,t}^{\text{drift}} \;=\; \frac{w_{j,t-1}\,(1 + r_{j,t})}{\;\text{cash}_{t-1} + \sum_k w_{k,t-1}\,(1 + r_{k,t})\;}
rebalance_policy.py — all five layerspython
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
LayerTriggerWatch out for
L1 · Calendarfrequency, calendar_dates, weekdayMatch the schedule to the signal's cadence — the costs guide measures a 6-policy grid
L2a · Drift banddrift_threshold, drift_typeDrift 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 errortracking_error_threshold, _windowSilently skipped without benchmark_returns — pass the series explicitly
L3 · Signal changerebalance_on_signal_change, signal_change_thresholdDetected on post-normalization targets, so universe changes fire it by design
L4 · Circuit breakermax_drawdown_trigger, _action, cooldownProtective override — flatten or halve, then cool down
L5 · Turnover budgetmax_annual_turnoverA 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.

invariants.pypython
# 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.

wrong_vs_right.pypython
# 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.

Continue