QuantJourney Backtester

QuantJourney Backtester

Share product feedback

Thank you.

Your note is now in the QuantJourney inbox.

Research guide · Sizing & risk budgeting

Same signal, different portfolio

Once the signal says what to hold, everything that determines how the portfolio actually behaves — its volatility, its drawdowns, which crisis hurts it — is decided by how much of each position you hold. This guide works through the four canonical sizing schemes in risk units: the math of each, the failure mode of each, and the engine's pluggable risk-model layer that lets one line of config switch between them. Then it runs one trend signal through all five configurations on nineteen years of real data and lets 2008, 2020 and 2022 grade the schemes — each of which flatters a different one.

One signal, 5 sizing schemes Exact ERC risk contributions via the hosted convex solver Open-source iterative approximation COVID leverage trap, on the record

Sizing is a decision you are already making

Equal weight is not the absence of a sizing scheme. It is a specific one — with a specific, measurable risk profile that most people never look at.

The experiment for this whole guide: take the SMA(50/200) trend basket from the workflow guide — eight multi-asset ETFs, long the trending ones, equal weight, monthly rebalance, 5 bps per side, 2007–2026 — and change only the sizing layer. Same signal, same dates, same costs, and the same daily rescore calendar on every model, five portfolios:

experiment.py — the whole designpython
SIZINGS = [
    ("Equal weight",        None),
    ("Inverse vol",         InverseVolModel(lookback=63, blend_alpha=False)),
    ("Vol target 10%",      VolTargetModel(target_vol=0.10, lookback=63,
                                           max_leverage=1.5, rebalance_freq="D")),
    ("Risk parity (ERC)",   RiskParityModel(lookback=63, rebalance_freq="D")),
    ("Institutional chain", RiskModelChain([
                                RiskParityModel(lookback=63, rebalance_freq="D"),
                                VolTargetModel(target_vol=0.10, lookback=63,
                                               max_leverage=1.5, rebalance_freq="D"),
                                PositionLimitModel(max_weight=0.30,
                                                   max_total_leverage=1.5),
                            ])),
]

# Pin every model to a DAILY rescore so all five read an equally fresh
# estimate (inverse vol already rescores every bar; ERC and vol target
# default to monthly). RebalancePolicy(BME) stays the single execution
# clock, so only the sizing math differs across the five rows.
for label, model in SIZINGS:
    s = TrendBasket(..., risk_model=model)    # NOTHING else changes
    await s.run_strategy()

The ERC and Institutional Chain results shown below were generated using the hosted exact-ERC solver. Results from the open-source iterative implementation may differ slightly.

SizingCAGRVolSharpeMax DDWorst monthTurnoverAvg gross
Equal weight5.11%10.91%0.512−22.2%−8.3%3.4×0.89
Inverse vol4.80%9.57%0.538−22.1%−8.0%3.6×0.89
Vol target 10%5.37%10.54%0.550−28.9%−12.4%3.8×0.94
Risk parity (ERC)4.82%9.11%0.563−21.7%−8.0%3.9×0.89
Institutional chain5.73%10.68%0.576−25.0%−8.9%3.7×1.01
Equity curves of one trend signal under five sizing schemes, 2007-2026
Five full engine runs; the only difference between the curves is the risk_model= argument — every scheme rescored daily and executed monthly, so nothing but the sizing math moves. The spread — 4.8% to 5.7% CAGR, −21.7% to −28.9% max drawdown — is pure sizing.

Read the spread before reading any single row: the gap between the best and worst configuration is 0.9 points of CAGR and 7 points of drawdown — from the same signal. That is a larger performance difference than most signal "improvements" deliver, and it is available without touching the alpha at all. The rest of this guide explains each row: what the scheme optimizes, what it silently assumes, and which of the three crises in the sample called its bluff.

Where sizing lives in the engine

Sizing is a pipeline stage, not a strategy rewrite: signals → weights → risk model → rebalance policy → execution.

The strategy's _compute_weights() returns raw conviction — what the signal wants to own. The risk model receives those targets together with the asset return history and reshapes them; the rebalance policy then decides on which dates the reshaped targets become trades. Keeping the layers separate is what made the five-run experiment above a five-line change — and what makes sizing schemes comparable at all, because everything else is held fixed by construction:

risk/base.py — the contractpython
from backtester.risk.base import RiskModel

class RiskModel(abc.ABC):
    @abc.abstractmethod
    def adjust(
        self,
        weights: pd.DataFrame,     # raw targets from _compute_weights()
        returns: pd.DataFrame,     # daily asset returns, same shape
        *,
        metadata: dict | None = None,
    ) -> pd.DataFrame:
        """Adjust weights given historical returns."""

# The contract every model honors:
#  1. Estimates use STRICTLY PRIOR windows - returns[i-lookback : i]
#     never includes bar i. Sizing cannot look ahead.
#  2. A model may RESIZE positions, never CREATE them - an instrument
#     the signal left at zero stays at zero.
#  3. Total exposure is preserved unless changing it is the model's
#     entire point (vol targeting).
The no-look-ahead guarantee, in the sizing layer too

Every estimate a risk model consumes — per-asset vol, portfolio vol, the covariance matrix — is computed on a window that ends strictly before the bar being sized. The same decide-at-t-earn-at-t+1 discipline the workflow guide demands of signals applies to sizing, because a sizing layer that peeks is just look-ahead wearing a risk costume.

Reading a portfolio in risk units

Capital weights answer "where is the money?" Risk contributions answer "where is the pain?" The two pictures disagree far more than intuition expects.

Portfolio volatility decomposes exactly — not approximately — into per-asset contributions. This is Euler's theorem applied to σp: each asset's contribution is its weight times its marginal effect on portfolio risk, and the contributions sum to the total:

σp  =  wΣw  =  i=1nwi(Σw)iσpRCi\sigma_p \;=\; \sqrt{w^{\top}\Sigma\, w} \;=\; \sum_{i=1}^{n} \underbrace{\frac{w_i\,(\Sigma w)_i}{\sigma_p}}_{RC_i}

Dividing by σp once more gives shares that sum to one. A share can be negative — an asset negatively correlated with the rest of the book removes risk even while consuming capital:

RC sharei  =  wi(Σw)iwΣw(iRC sharei=1, shares can be negative)\text{RC share}_i \;=\; \frac{w_i\,(\Sigma w)_i}{w^{\top}\Sigma w} \qquad \Big(\textstyle\sum_i \text{RC share}_i = 1,\ \text{shares can be negative}\Big)

Now apply this lens to the plain equal-weight eight-ETF basket, using the realized 2007–2026 covariance. Capital says "12.5% each, nicely diversified". Risk says something else entirely:

AssetAnn. volRC share — equal weightRC share — inverse volERC weightRC share — ERC
SPY (US equity)19.8%16.6%16.7%8.3%12.5%
EFA (intl equity)21.7%18.9%17.7%7.0%12.5%
EEM (EM equity)28.0%24.2%17.4%5.6%12.5%
TLT (20y+ bonds)15.2%−0.8%4.0%17.3%12.5%
IEF (7–10y bonds)7.0%−0.3%4.6%35.3%12.5%
GLD (gold)17.5%6.7%11.0%10.8%12.5%
DBC (commodities)19.2%11.2%12.9%10.2%12.5%
VNQ (REITs)29.8%23.5%15.8%5.5%12.5%
Risk contribution shares by asset for equal weight, inverse vol and ERC weighting
The same eight assets under three weighting schemes, realized covariance 2007–2026. Equal weight concentrates 83% of the risk in the four equity-like assets; ERC pins every asset to the 12.5% line exactly.

Three readings. The equal-weight basket is an equity portfolio wearing a diversified costume: SPY, EFA, EEM and VNQ carry 83% of the risk. The bonds' shares are negative — over this sample TLT and IEF hedged the book more than they risked it, so in risk terms the "12.5% capital each" story was fiction on both ends. And the ERC column shows what actually equalizing risk requires: over half the capital (52.6%) in the two bond ETFs, with the three most volatile assets cut to 5–8% each. No intuition produces those numbers; a covariance matrix does.

Inverse volatility — the first risk budget

One estimate per asset, no matrix: capital inversely proportional to each asset's own volatility.

wi  =  1/σ^ij1/σ^jw_i \;=\; \frac{1/\hat{\sigma}_i}{\sum_{j} 1/\hat{\sigma}_j}
inverse_vol.pypython
from backtester.risk import InverseVolModel

strategy = TrendBasket(
    ...,
    risk_model=InverseVolModel(
        lookback=63,          # trailing window for per-asset vol
        min_vol=0.01,         # floor - prevents 1/sigma blowups
        blend_alpha=False,    # False: pure 1/vol among active assets
                              # True:  raw_weight x 1/vol, renormalized
                              #        (conviction x risk adjustment)
    ),
)

Inverse vol equalizes standalone risk: if every pairwise correlation were identical, it would be exact ERC. Its virtue is robustness — n vol estimates instead of n(n+1)/2 covariance entries — and the table above shows it doing real work: EEM's risk share drops from 24.2% to 17.4%, the bonds' rise from negative to positive. Its blind spot is the correlation structure: it cannot see that the equity block co-moves, so it still grants the block two thirds of the total risk.

The measured run makes the blind spot concrete. Inverse vol delivered the intended vol reduction (9.4% vs 10.9% for equal weight) — and a worse drawdown: −26.7%, peaking March 2022 and bottoming October 2023. The scheme's bond tilt was calibrated on the pre-2022 regime, where bond vol was low and bond-equity correlation was negative. In 2022 rates repriced, the correlation flipped positive, and the "low-risk" sleeve became the epicenter — while the vol estimates, trailing 63 days behind, kept feeding it capital. A sizing scheme is a bet on the stability of its own inputs; inverse vol bets on two of them at once.

Volatility targeting — sizing the whole book

The previous schemes decide the shape of the portfolio. Vol targeting decides its level: one scalar, applied to everything, so realized volatility tracks a chosen target.

st  =  min ⁣(σσ^p,t,  L),w~t  =  stwts_t \;=\; \min\!\Big(\frac{\sigma^{*}}{\hat{\sigma}_{p,t}}\,,\; L\Big)\,, \qquad \tilde{w}_t \;=\; s_t \cdot w_t
vol_target.pypython
from backtester.risk import VolTargetModel

strategy = TrendBasket(
    ...,
    risk_model=VolTargetModel(
        target_vol=0.10,       # annualized portfolio target
        lookback=63,           # trailing window for realized vol
        max_leverage=1.5,      # hard cap on the scale factor
        rebalance_freq="D",    # how often to RE-ESTIMATE the scale
    ),
)

# Two clocks, not one: this rebalance_freq re-estimates the scale, but the
# book only MOVES when RebalancePolicy fires a trade. The slower clock wins
# — which is the entire story of the COVID chart below.

The promise is a portfolio that leans in when markets are calm and de-risks when they are turbulent — and across regimes, it delivers: the rolling-vol chart below shows the teal line hugging the 10% target through the long calm stretches where equal weight wanders between 6% and 18%. The trap is hiding in the word "trailing". The scale st is an estimate of the recent past, held until the next rescore. When volatility jumps, the scheme is — by construction — positioned for the world that just ended.

COVID gross exposure of the vol target run three ways: monthly versus daily rescore and execution
The same 10% vol target through COVID, run three ways. Monthly execution with a monthly rescore (red) and monthly execution with a daily rescore (gold) are indistinguishable through the crash — both ride 1.4× into the February calm and sit at 1.54× on the March 23 bottom. Only daily execution (teal) cuts exposure during the fall, reaching 0.40× at the bottom. The rescore frequency barely moves the book; the rebalance calendar is what holds the leverage through the crash.

It is tempting to blame the monthly rescore — the scale was set on stale data. The engine says otherwise. Run the identical vol target three ways and the cause separates cleanly into three distinct lags:

ConfigurationGross, Feb 19Gross, Mar 23 (bottom)Max DDWorst month
Monthly execution + monthly rescore1.41×1.54×−28.9%−12.4%
Monthly execution + daily rescore1.41×1.54×−28.9%−12.4%
Daily execution + daily rescore1.42×0.40×−19.4%−8.5%

Three lags, and only one of them matters here. Estimation lag is irreducible: even the daily/daily book enters the crash at 1.42×, because a 63-day window cannot price a volatility spike that has not happened yet — no configuration escapes that first column. Model-refresh lag — how often the risk model recomputes its target — is the dial people reach for first, and the top two rows show it does nothing here: a daily rescore under monthly execution is bit-for-bit identical to a monthly rescore straight through the crash. Execution lag is the entire effect: the position only moves when RebalancePolicy fires, so under monthly rebalancing the levered February book is frozen through the March collapse. Switch execution to daily and the same target deleverages during the fall — max drawdown −28.9% → −19.4%, worst month −12.4% → −8.5% — at the cost of turnover and whipsaw.

So the lesson is sharper than "estimation takes time". Vol targeting stabilizes regime volatility and cannot anticipate the transition into a crisis — that part is estimation lag, and it is real and irreducible. But the deepest drawdown in this experiment, in its shortest window (peak February 21, trough March 18, 2020), was manufactured by the rebalance calendar, not the estimator. A faster rebalance_freq on the risk model would not have touched it; a faster execution policy would have. Name which lag you are looking at before you reach for a dial — two of the three here move the wrong one.

Rolling 63-day realized volatility for equal weight, vol target and ERC sizing
What each scheme actually stabilizes: 63-day realized vol. Vol targeting (teal) tracks the 10% line in calm regimes and overshoots hardest in the 2020 spike; ERC (gold) runs structurally lower without targeting anything.

Risk parity — equal risk, actually equal

ERC is inverse vol upgraded with the full covariance matrix: every asset contributes the same share of portfolio risk, correlations included.

wi(Σw)i  =  wj(Σw)ji,jRC sharei=1nw_i\,(\Sigma w)_i \;=\; w_j\,(\Sigma w)_j \quad \forall\, i,j \qquad\Longleftrightarrow\qquad \text{RC share}_i = \tfrac{1}{n}

Unlike the closed-form schemes, ERC weights have no explicit formula — they are the solution of a fixed-point problem. The hosted QJ Backtester solves Spinu's convex formulation using a damped Newton method, producing equal-risk-contribution weights to numerical tolerance. The open-source package includes a lightweight iterative approximation, while exact ERC is available in the hosted engine:

F(w)  =  12wΣw    1ni=1nlnwi,F=0    wi(Σw)i=1nF(w) \;=\; \tfrac{1}{2}\, w^{\top}\Sigma\, w \;-\; \tfrac{1}{n}\sum_{i=1}^{n} \ln w_i\,, \qquad \nabla F = 0 \;\Rightarrow\; w_i\,(\Sigma w)_i = \tfrac{1}{n}
risk_parity.pypython
from backtester.risk import RiskParityModel

strategy = TrendBasket(
    ...,
    risk_model=RiskParityModel(
        lookback=63,     # trailing covariance window
        max_iter=50,     # solver iteration cap
        tol=1e-8,
    ),
)

# Results in this guide use the hosted exact-ERC solver. The open-source
# implementation uses a lightweight iterative approximation, so its
# numerical results may differ slightly.

In the measured run, ERC is the honest defensive scheme in the lineup: the lowest volatility (9.25%), the shallowest drawdown (−21.6%), the mildest worst month (−8.0%), and — unlike inverse vol — it earned its lower risk without the 2022 faceplant, because the covariance window saw the correlation flip and rebalanced the risk budget away from the failing hedge. The price: a Sharpe only 0.04 above equal weight, moderate extra turnover (3.9× vs 3.4×) from tracking a moving covariance, and — the structural one — a lower expected return, because equalizing risk means allocating away from the highest-premium assets. ERC buys smoothness, not alpha. Whether that trade is good depends on what the smoothness is for — which is the chain's question.

Position limits — the compliance floor

Whatever the statistical schemes propose, hard constraints dispose. Caps, floors, leverage and sector limits run last, so the final book respects them no matter what produced it.

position_limits.pypython
from backtester.risk import PositionLimitModel

limits = PositionLimitModel(
    max_weight=0.30,             # per-instrument cap
    min_weight=0.02,             # floor for ACTIVE positions (0 = off)
    max_total_leverage=1.5,      # cap on sum(|w|)
    sector_limits={"equity": 0.60, "rates": 0.50},
)

# Sector caps need a sector map in metadata:
#   metadata={"sectors": {"SPY": "equity", "TLT": "rates", ...}}
# Excess above a cap is redistributed proportionally among uncapped
# active instruments, iteratively, so total exposure survives intact.

The redistribution detail matters: naive capping (clip and renormalize everything) leaks exposure back into the capped names. The engine caps iteratively — excess is redistributed only among instruments with headroom, and the loop repeats until no cap is breached, so a 30% cap means 30%, exactly, while total exposure survives. The same model quietly powers the max_position_size= constructor argument: in weights mode the engine routes it into a PositionLimitModel, so the cap is enforced accounting, not a report label.

Composing the stack

The schemes are orthogonal decisions — shape, level, constraints — and the engine composes them in one chain, output of each feeding the next.

institutional_chain.pypython
from backtester.risk import (
    PositionLimitModel, RiskModelChain, RiskParityModel, VolTargetModel,
)

strategy = TrendBasket(
    ...,
    risk_model=RiskModelChain([
        RiskParityModel(lookback=63),                  # 1. shape: equal risk
        VolTargetModel(target_vol=0.10, lookback=63,   # 2. level: 10% vol,
                       max_leverage=1.5),              #    lever if needed
        PositionLimitModel(max_weight=0.30,            # 3. compliance: caps
                           max_total_leverage=1.5),    #    bind LAST
    ]),
)

# Order is semantics, not style:
#   ERC -> VolTarget:  target the vol of the DIVERSIFIED book
#   VolTarget -> Limits: caps clip whatever leverage produced
# Reversed, the chain answers a different question.

This is the standard institutional construction — risk parity funds and managed-futures books are variations of exactly this stack — and the measured result shows why: Sharpe 0.576 and 5.73% CAGR, the best of the experiment. The mechanism is visible in the "avg gross" column of the headline table: the ERC book runs at 9.11% vol, well under the 10% target, so the vol-target stage levers it (average gross exposure 1.01, peaks at 1.5×) — spending the risk budget that ERC's diversification freed up. Leverage applied to a genuinely diversified book is the one free-ish lunch in this guide.

And the honest cost, in the same table: the chain's drawdown is −25.0%, three points deeper than pure ERC, and its worst month −8.9% — the vol-target stage's crisis exposure, inherited. Each overlay solves the problem the previous one left and introduces its own; the composition is better on the average day and worse on the worst day than its most defensive component. That is not a flaw to engineer away — it is the actual shape of the trade-off, and the reason the stack must be measured as a whole rather than reasoned about one layer at a time.

Estimation risk is the hidden parameter

Every scheme above replaced the question "how much do I hold?" with "how well can I estimate risk?" That trade has terms and conditions.

All four schemes consume estimates — a vol, a covariance — computed on a trailing window, and the window length is a real parameter with a real trade-off: short windows (21–63 days) adapt fast and whipsaw on noise; long windows (252+) are stable and permanently late. The measured runs used 63 days throughout — but the two failures we documented are not the same animal, and conflating them points you at the wrong fix. Inverse vol feeding the 2022 bond crash is estimation lag proper: the trailing window had simply not seen the regime. Vol targeting levered into COVID is mostly execution lag — a rebalance-calendar artifact, as the three-way table above shows, that a faster estimator does nothing for. Estimation lag is irreducible and you manage it; execution lag is a scheduling choice and you fix it. Three disciplines keep the estimation honest rather than hopeful. Floors and fallbacks: the engine floors vol estimates (min_vol) so a quiet quarter cannot produce a 50× position, and degenerate covariances fall back to inverse-vol weights rather than crashing or fabricating. Strictly prior windows: enforced by the layer's contract, not by author discipline. And the same statistical bar as the signal: a sizing overlay changes the return series, so it changes the evidence — the validation gates run on the sized strategy, and an overlay added after validation invalidates the certificate. Sizing is part of the process being tested, never a garnish on top of it.

Do not tune the risk layer into an alpha layer

Lookbacks, targets and caps are estimation hygiene, not free parameters. If you optimize target_vol to maximize the backtest Sharpe, you have added dimensions to the search space, and the selection-bias corrections must know about every value you tried. The defensible pattern: fix the risk parameters from mandate and estimation-quality arguments (a 10% target because the mandate says so; 63 days because shorter is noise), then validate the whole configuration once.

The sizing checklist

Six questions to answer before the sizing layer is done.

  1. Look at the book in risk units

    Compute RC shares. If one asset class carries 80% of the risk, you own a concentrated portfolio wearing a diversified costume — decide deliberately whether that is intended.

  2. Pick shape and level separately

    Shape (EW / inverse vol / ERC) and level (vol target, leverage cap) are different decisions with different failure modes. Choose each on its own merits; compose them in the chain.

  3. Stress the scheme in the regime that breaks it

    Inverse vol: correlation flips (2022). Vol targeting: vol spikes from calm (2020). ERC: premium concentration. Every scheme has one; make sure your sample contains it.

  4. Mind the estimation lag

    Whatever the lookback, the scheme is positioned for the recent past. Ask what happens in the first month of a regime it has not seen — that is where its worst month lives.

  5. Cap what must never happen

    Statistical schemes propose; PositionLimitModel disposes. Per-name caps, leverage caps and sector limits run last in the chain, always.

  6. Validate the sized strategy

    The overlay changes the return series, so the statistical gates run on the final configuration — sizing included, costs included.

Need this run on your strategy?

We do independent validation engagements — permutation tests, walk-forward, Sharpe selection diagnostics, canonical CSCV context and rolling rank stability on your code or track record, delivered as a signed, reproducible report.

Validation services →

References

  • Maillard, S., Roncalli, T. & Teïletche, J. (2010). The Properties of Equally Weighted Risk Contribution Portfolios. Journal of Portfolio Management 36(4) — existence, uniqueness and the vol ordering EW ≥ ERC ≥ min-var.
  • Roncalli, T. (2013). Introduction to Risk Parity and Budgeting. Chapman & Hall — the standard reference for risk budgeting mathematics.
  • Spinu, F. (2013). An Algorithm for Computing Risk Parity Weights. SSRN 2297383 — the convex formulation the hosted solver minimizes.
  • Griveau-Billion, T., Richard, J.-C. & Roncalli, T. (2013). A Fast Algorithm for Computing High-Dimensional Risk Parity Portfolios. SSRN 2325255 — coordinate-descent and Newton approaches to the same problem.
  • Moreira, A. & Muir, T. (2017). Volatility-Managed Portfolios. Journal of Finance 72(4) — the case for vol scaling across factor portfolios.
  • Harvey, C., Hoyle, E., Korgaonkar, R., Rattray, S., Sargaison, M. & van Hemert, O. (2018). The Impact of Volatility Targeting. Journal of Portfolio Management 45(1) — where vol targeting helps (risk assets) and where it does not.
  • DeMiguel, V., Garlappi, L. & Uppal, R. (2009). Optimal Versus Naive Diversification: How Inefficient is the 1/N Portfolio Strategy? Review of Financial Studies 22(5) — why equal weight is a hard benchmark to beat.

Continue the research