Skip to content

Multi-strategy portfolios

portfolio(strategies=[...], weights=[...]) in .qe runs N independent signals on the same data with a fixed slice of the total capital each. The combined backtest stays a single value (backtest(strategy = portfolio(...))), but the output carries per-strategy attribution so you can see who contributed what.

This document explains the semantics — what the runner actually does, why we chose this shape, and how to read the attribution panel.

TL;DR

let trend = signal(
  entry = cross_above(sma(close, 10), sma(close, 50)),
  exit  = cross_below(sma(close, 10), sma(close, 50)),
  symbol = "SPY",
)
let rev = signal(
  entry = rsi(close, 14) < 30,
  exit  = rsi(close, 14) > 70,
  symbol = "SPY",
)

backtest(
  data     = yahoo("SPY", "1d", "2020-01-01", "2024-12-31"),
  strategy = portfolio(
    strategies = [trend, rev],
    weights    = [0.6, 0.4],
    names      = ["trend", "rev"],
  ),
  execution = execution(capital = 100_000),
  output    = output(results = "out/portfolio.json"),
)
  • Each child gets capital × weight[i] at start (60 000 / 40 000 above).
  • Each child runs as an independent backtest on the same series.
  • Combined equity at bar t = sum of every child's equity at bar t.
  • F4 BCKT shows the combined curve in bold, each sub-strategy as a thin coloured overlay, and an ATTRIBUTION panel with per-strategy metrics.

Execution model — N parallel backtests

The runner is qe::backtest::run_portfolio and it does exactly one thing: for each child SignalValue, build an ExpressionStrategy, allocate a fresh MultiPortfolio with capital × weight[i], run MultiEngine::run on the shared series, stash the equity curve + trades. Then aggregate.

Three consequences worth knowing about:

  1. No cross-strategy netting. If trend is long AAPL and rev is short AAPL, the combined book holds both positions simultaneously. They don't cancel to zero at the position level — they cancel at the equity level (their PnL changes offset). This is what "independent backtests" means: each child is making its own decisions about its own slice of capital.

  2. Per-strategy attribution is exact. Each child has its own equity curve and its own trades, so ATTRIBUTION's per-strategy Sharpe / Return / Max DD numbers are real — they describe what that child did with its own sub-capital, not a share-of-portfolio estimate.

  3. Hot loop is unchanged. The engine doesn't know portfolio exists; it runs MultiEngine::run<ExpressionStrategy> N times in sequence, exactly the same code path single-signal uses.

What v1 deliberately doesn't do

Feature Status
Within-run cross-strategy rebalancing (rebalance = "monthly", etc.) Planned. Needs cross-child cash flow that doesn't fit the "N independent runs" shape. v1 enforces rebalance = "never".
Risk parity / inverse-vol / dynamic weights Future. v1 weights are constants.
Strategy correlations / portfolio variance optimization Future. v1 reports correlations as a side effect of running the children; no optimization layer.
Each strategy on its own data universe Future. v1 children share backtest.data — every child runs on the same series.
Portfolio inside walk_forward(...) Future. The walk-forward runner rejects portfolio(...) bases with a clear error today.

Reading the ATTRIBUTION panel

When F4 BCKT detects a schema v4 results.json, the rightmost panel switches from the monthly heatmap to ATTRIBUTION:

Column Meaning
STRATEGY names[i] from the portfolio(...) call, or auto-strategy_N if omitted.
WEIGHT weights[i], as a percentage.
RETURN This child's total return on its own sub-capital. Compare to the combined Return in KEY STATS.
SHARPE This child's Sharpe — same caveat as any sample-Sharpe: short series → noisy.
MAXDD Max drawdown observed on this child's curve.
CONTRIB child_pnl / combined_pnl, signed. Sums to 100% when combined PnL is non-zero. Negative when this child lost money in a winning portfolio (or vice versa) — useful for spotting "this strategy is dragging the book".

Reading example: combined Return +5%, trend Contrib +120%, rev Contrib −20% → rev lost money but trend carried the portfolio. That's a signal to question whether rev is worth its weight at all.

When to use portfolios

The most common practical case is uncorrelated strategies on the same instrument — trend-following + mean-reversion on SPY, say. Each strategy has different drawdown profiles, and combining them with fixed weights gives a smoother combined curve than either alone (assuming the strategies are actually uncorrelated — verify with the equity overlay; if both children move together, you don't have a diversification benefit, you have a leverage problem).

Less common but also reasonable: the same strategy with different parameter sets, e.g. signal(... sma(close, 10/50)) + signal(... sma(close, 20/100)), to hedge against a single parameter choice doing badly out-of-sample. This is the manually-staged version of what walk_forward(... optimize) does automatically — see docs/walk-forward.md.

Schema v4 result shape

results.json for a portfolio run is schema_version 4. Top-level summary and equity_curve are the combined view; strategies[] carries the per-child data:

{
  "schema_version": 4,
  "strategy": {"name": "portfolio",
               "params": {"names": [...], "weights": [...]}},
  "summary": { ...combined... },
  "equity_curve": [...combined...],
  "initial_capital": 100000.0,
  "strategies": [
    { "name": "trend",
      "capital": 60000.0,
      "weight": 0.6,
      "summary": { ...per-child... },
      "equity_curve": [...],
      "trades": [...]
    }, ...
  ]
}

The HTML report renders the same shape — combined equity bold + sub-strategy lines overlaid + a strategy attribution table after the KPI cards.

See also