Lesson 3.1.4 · 75 min
Diversified futures systems: Carver's approach
Robert Carver proposes a futures system that does not buy or sell on signals but on forecasts: continuous numbers, scaled so that their average absolute value is 10, combined across rules and turned into contracts with the account's risk target. This lesson builds it in full on the 23 synthetic futures of the level, with trend and carry, and measures each piece: the scalars, the diversification multipliers, buffering, costs and what combining adds.
- Module
- 3.1 · Futures in depth
- Duration
- 75 min
- Prerequisites
- 3.1.3 · Roll management, margins, SPAN and implied leverage, 2.2.2 · Universe, signal, filter, entry, exit, sizing and risk management, 2.3.1 · Trend following and momentum, 2.3.4 · Carry in currencies, futures and bonds; seasonality and calendar effects
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
Forecasts, not signals
The systems of lessons 2.2.2 and 2.3.1 decided “long, short or flat” and sized by volatility. Robert Carver, in Systematic Trading (2015) and Advanced Futures Trading Strategies (2023), separates those two things differently: each rule produces a forecast, a continuous number that says how much is expected and with how much conviction, and a single formula turns any forecast into contracts. The pieces:
Mermaid source
flowchart TB P[Adjusted price] --> R[Raw rule] V[Volatility] --> R R --> E[× scalar] E --> T[Cap ±20] T --> C[Weights × FDM] C --> N[Contracts] N --> B[Buffer and order]
The conventions are those of his open-source library, pysystemtrade (GPL v3 license): the average absolute forecast is
10 (average_absolute_forecast: 10.0 in its default configuration), it is capped at ±20 (forecast_cap: 20.0), and
the diversification multipliers are capped at 2.5 (dm_max: 2.5). With a forecast of +10 the system has the “average”
position in a market; with +20, twice that; with −5, half of it short. The goal of this lesson is to build the whole
system and measure what each piece adds, not to reproduce pysystemtrade line by line: where the lesson departs from its
configuration, it says so.
The starting data: Carver calculates trend rules on the price adjusted by difference (the “Panama” method of lesson 1.4.6): the daily change in points of the contract held, accumulated. It preserves price differences, which is what a contract gains or loses, and volatility is measured in the same units:
import numpy as np
import pandas as pd
curvas = pd.read_parquet("futuros3_curvas.parquet")
ctr = pd.read_csv("futuros3_contratos.csv",
parse_dates=["vencimiento", "fecha_roll"])
esp = pd.read_csv("futuros3_especificaciones.csv",
index_col="mercado")
fx = pd.read_parquet("fx3_diario.parquet")
usd_qev = fx[fx["divisa"] == "QEV"].set_index("fecha")[
"usd_por_unidad"]
MERCADOS = list(esp.index[esp["mercado_base"].isna()])
def serie(mercado):
"""Contract held (roll on fecha_roll), its price, the daily
change in points of the same contract and the annual carry."""
c = curvas[curvas["mercado"] == mercado]
P = c.pivot(index="fecha", columns="contrato",
values="liquidacion")
cm = ctr[ctr["mercado"] == mercado].reset_index(drop=True)
pos = np.searchsorted(cm["fecha_roll"].to_numpy(),
P.index.to_numpy(), side="right")
tiene = cm["contrato"].to_numpy()[pos]
sig = cm["contrato"].to_numpy()[np.minimum(pos + 1, len(cm) - 1)]
i, j = P.columns.get_indexer(tiene), P.columns.get_indexer(sig)
f, Pv = np.arange(len(P)), P.to_numpy()
ayer = np.r_[i[0], i[:-1]]
dif = np.r_[np.nan, Pv[f[1:], ayer[1:]] - Pv[f[1:] - 1, ayer[1:]]]
v = cm.set_index("contrato")["vencimiento"]
dt = (v.reindex(sig).to_numpy()
- v.reindex(tiene).to_numpy()).astype("timedelta64[D]")
F2 = np.where(j >= 0, Pv[f, np.maximum(j, 0)], np.nan)
carry = np.log(Pv[f, i] / F2) / (dt.astype(float) / 365)
return pd.DataFrame({"contrato": tiene, "precio": Pv[f, i],
"dif": dif, "carry": carry}, index=P.index)
S = {m: serie(m) for m in MERCADOS}
fechas = S["RVA"].index
tabla = lambda col: pd.DataFrame(
{m: S[m][col] for m in MERCADOS}).reindex(fechas)
precio, dif, contrato = tabla("precio"), tabla("dif"), tabla("contrato")
carry = tabla("carry")
ajustado = dif.fillna(0).cumsum() # difference adjustment
sigma_p = dif.ewm(span=35, min_periods=20).std() # points per day
print(precio.shape, fechas[0].date(), fechas[-1].date())
pd.DataFrame({"precio": precio.iloc[-1],
"sigma_dia_pts": sigma_p.iloc[-1],
"sigma_anual_pct": 100 * np.sqrt(252) * sigma_p.iloc[-1]
/ precio.iloc[-1]}).loc[["RVA", "TPC", "PEC", "GRM"]
].round(3)(4024, 23) 2010-01-04 2025-12-31
precio sigma_dia_pts sigma_anual_pct
RVA 6747.000 131.746 30.998
TPC 112.547 0.460 6.481
PEC 60.900 1.544 40.252
GRM 340.250 4.196 19.57923 markets and 4,024 sessions. Volatility in points is a 35-session exponential average of the daily changes, the same
window that pysystemtrade's EWMAC rule uses by default (vol_days=35); Carver also uses variants that blend recent
volatility with long-term volatility, which are not applied here. On the last day, RVA moves 131.7 points a day and
the 10-year note, 0.46: three orders of magnitude that have to be removed before comparing rules across markets.
EWMAC: the rule and its scaling
Carver's trend rule is the difference between two exponential averages of the adjusted price, fast and slow (with the slow one four times longer), divided by the daily volatility of the price in points:
It is what ewmac.py does in pysystemtrade: raw_ewmac = fast_ewma - slow_ewma and raw_ewmac / vol.ffill(). The
result is in daily standard deviations and has no natural scale, so it is multiplied by a scalar that brings its
average absolute value to 10. The scalar is estimated by pooling all the markets, so that it does not depend on one,
and with an initial window that is not used afterwards to judge the rule:
def ewmac(rapida, lenta):
m_r = ajustado.ewm(span=rapida, min_periods=rapida).mean()
m_l = ajustado.ewm(span=lenta, min_periods=lenta).mean()
return (m_r - m_l) / sigma_p
ESTIMA = slice("2011", "2013") # window for the scalars
brutos = {f"ewmac{a}_{4 * a}": ewmac(a, 4 * a)
for a in (8, 16, 32, 64)}
escalar = {}
for k, b in brutos.items():
media_abs = b.loc[ESTIMA].abs().stack().mean()
resto = b.loc["2014":].abs().stack().mean()
escalar[k] = 10 / media_abs
print(f"{k:11s} scalar {10 / media_abs:5.2f} "
f"(with 2014-2025: {10 / resto:5.2f})")ewmac8_32 scalar 5.65 (with 2014-2025: 5.94)
ewmac16_64 scalar 3.74 (with 2014-2025: 4.01)
ewmac32_128 scalar 2.45 (with 2014-2025: 2.65)
ewmac64_256 scalar 1.76 (with 2014-2025: 1.76)The scalars estimated with 2011-2013 are 5.65, 3.74, 2.45 and 1.76 for the four speeds; with 2014-2025, 5.94, 4.01, 2.65 and 1.76. The chapter 15 example configuration of pysystemtrade uses 5.3, 3.75, 2.65 and 1.87: almost the same, on completely different data. It is not a coincidence. With the rule in risk units, its scale depends on the windows of the averages and on how volatility is measured, and little on the market. That is why a scalar estimated with many markets works for all of them, and why it is reasonable to fix it once and not re-estimate it every day. What the scalar does not say is whether the rule makes money.
def escala(bruto, k, tope=20):
return (bruto * k).clip(-tope, tope)
prev = {"ewmac16_64": escala(brutos["ewmac16_64"],
escalar["ewmac16_64"]),
"ewmac64_256": escala(brutos["ewmac64_256"],
escalar["ewmac64_256"])}
for k, p in prev.items():
x = p.loc["2011":].stack()
print(k, f"mean |f| {x.abs().mean():.1f},",
f"at the cap {100 * (x.abs() == 20).mean():.1f} %,",
f"std. dev. {x.std():.1f}")ewmac16_64 mean |f| 8.9, at the cap 9.3 %, std. dev. 10.8
ewmac64_256 mean |f| 9.1, at the cap 11.3 %, std. dev. 11.0Over the whole sample, the average absolute value ends up at 8.9 and 9.1, a little below 10 because the cap clips the extremes: 9.3% and 11.3% of market-days are at ±20. The cap exists for two reasons: a huge forecast is usually a data error or a market in a move that has already happened, and it limits how much a single market can weigh in the portfolio.
Carry as a forecast
The carry of lesson 3.1.2 (the annualized slope between the contract held and the next one) is turned into a forecast
the same way: in risk units, divided by the annual volatility of the return. pysystemtrade calculates raw carry as “the
annualized roll divided by the annualized volatility” (rawdata.py) and smooths it with a 90-day exponential average
(carry.py); here it is smoothed with 30 sessions, a choice of this lesson:
vol_anual = (dif / precio.shift(1)).ewm(span=35, min_periods=20
).std() * np.sqrt(252)
bruto_carry = carry.ewm(span=30, min_periods=10).mean() / vol_anual
escalar["carry"] = 10 / bruto_carry.loc[ESTIMA].abs().stack().mean()
prev["carry"] = escala(bruto_carry, escalar["carry"])
print(f"carry scalar: {escalar['carry']:.1f}")
ultimo = prev["carry"].loc["2025-11-26"]
ultimo.sort_values().round(1).iloc[[0, 1, 2, -3, -2, -1]]carry scalar: 16.7
GRS -20.0
GRH -20.0
GRM -20.0
GNA 15.6
PEC 19.5
PEH 20.0
Name: 2025-11-26 00:00:00, dtype: float64The carry scalar comes out at 16.7, different from the 30 in the pysystemtrade configuration, because the data and the way of calculating carry are not the same (here, in logs and with different smoothing): the scalar is estimated for each definition. On November 26, 2025, the soybean and corn agriculturals are at −20 (steep contango) and energy close to +20 (backwardation in crude oil and heating oil, lesson 3.1.2). A detail that the code does not handle and a real system does: in markets with a seasonal shape in the curve (gas, electricity), the carry between consecutive months jumps with the calendar, and that part is not carry (lesson 3.1.2).
Combining: weights and the diversification multiplier
The combined forecast is a weighted average of the forecasts of each rule. Since the rules are not perfectly correlated, the average moves less than each one and its average absolute value ends up below 10. The forecast diversification multiplier (FDM) brings it back to its scale:
with the weights and the correlation matrix of the forecasts. The lesson's weights are 0.25 for each EWMAC and 0.50 for carry, similar to those of the pysystemtrade chapter 15 example (0.21, 0.08 and 0.21 for three EWMACs and 0.50 for carry):
apiladas = pd.DataFrame({k: p.loc[ESTIMA].stack()
for k, p in prev.items()}).dropna()
rho = apiladas.corr()
w = pd.Series({"ewmac16_64": 0.25, "ewmac64_256": 0.25,
"carry": 0.50})
fdm = min(2.5, 1 / np.sqrt(w @ rho @ w))
print(rho.round(2))
print(f"FDM = {fdm:.3f}")
combinada = sum(prev[k] * w[k] for k in w.index) * fdm
combinada = combinada.clip(-20, 20)
x = combinada.loc["2011":].stack()
print(f"combined: mean |f| {x.abs().mean():.1f}, "
f"at the cap {100 * (x.abs() == 20).mean():.1f} %") ewmac16_64 ewmac64_256 carry
ewmac16_64 1.00 0.61 0.34
ewmac64_256 0.61 1.00 0.36
carry 0.34 0.36 1.00
FDM = 1.263
combined: mean |f| 7.9, at the cap 7.5 %The two EWMACs have a correlation of 0.61 (they are the same idea at two speeds) and carry, 0.34 and 0.36 with them. The FDM is 1.263. The combined forecast has an average absolute value of 7.9, below 10: the FDM was estimated with 2011-2013 and the cap clips 7.5% of the days. A system that wanted exactly its risk target would re-estimate the FDM with an expanding window; the consequence, here, is that the system will have somewhat less volatility than requested.
The figure shows the combined forecast for crude oil from 2019 to 2023 below its adjusted price. It spends almost all of 2020 at the −20 cap (downtrend and contango at the same time), rises with the 2021-2022 recovery without going above +11 (the trend is positive but carry, in the data, is still negative for a good part of that time) and goes back to −20 in 2023. The difference-adjusted price reaches values close to zero at the end of 2023: with this adjustment, old levels are not real prices, and that is why the rules use price differences and not percentages on the adjusted price.
From forecast to number of contracts
The position in each market comes from a single formula. With capital , an annual risk target , the weight of the market in the portfolio and the annual money risk of one contract (lesson 3.1.1):
where is the multiplier and the exchange rate to USD. The instrument diversification multiplier (IDM) does with markets what the FDM does with rules: since markets do not move together, a portfolio with in each one has less risk than , and the IDM compensates for it.
normal = (dif / sigma_p.shift(1)).loc[ESTIMA]
rho_i = normal.corr()
w_i = pd.Series(1 / len(MERCADOS), index=MERCADOS)
idm_bruto = 1 / np.sqrt(w_i @ rho_i @ w_i)
idm = min(2.5, idm_bruto)
print(f"IDM uncapped {idm_bruto:.2f}, capped {idm:.2f}")IDM uncapped 2.81, capped 2.50With 23 markets, equal weights and the correlations of the data (high within each sector and almost zero across sectors, lesson 3.1.1), the uncapped IDM would be 2.81; with the 2.5 cap, it stays at 2.5. The cap is a precaution: the IDM is estimated with calm-period correlations, and in a crisis correlations rise and so does the real risk of the portfolio. The pysystemtrade example configuration uses 1.89 with its universe. Equal weights per market are a simplification: Carver allocates by sector and then within each one, so that five correlated indices do not weigh five times as much as one currency.
CAPITAL, TAU = 5_000_000, 0.20
mult = esp.loc[MERCADOS, "multiplicador"]
cambio = pd.DataFrame(
{m: (usd_qev.reindex(fechas).ffill()
if esp.loc[m, "divisa"] == "QEV"
else pd.Series(1.0, index=fechas)) for m in MERCADOS})
riesgo_contrato = sigma_p * np.sqrt(252) * mult * cambio # USD/year
media = idm * w_i * CAPITAL * TAU / riesgo_contrato
optimo = combinada / 10 * media
d = "2025-11-26"
pd.DataFrame({"prevision": combinada.loc[d],
"riesgo_contrato": riesgo_contrato.loc[d],
"posicion_media": media.loc[d],
"optimo": optimo.loc[d]}).loc[
["RVA", "TPA", "PEC", "GNA", "MTO", "GRM"]].round(2) prevision riesgo_contrato posicion_media optimo
RVA 8.44 83822.01 1.30 1.09
TPA 0.89 4835.93 22.48 1.99
PEC 20.00 20918.48 5.20 10.39
GNA 14.79 22301.88 4.87 7.21
MTO 7.27 26192.67 4.15 3.02
GRM -17.47 2768.80 39.26 -68.58With 5 million USD and a 20% risk target, the average position (that of a +10 forecast) is 1.30 contracts in RVA,
22.48 in the 2-year note and 39.26 in corn. On November 26, 2025, the crude oil forecast is at the cap (+20) and the
system would want 10.39 contracts, twice its average position; corn's is −17.47 and it would want 68.58 contracts short.
RVA, at +8.44, would want 1.09: with a large index contract, rounding matters.
Buffering and costs
An optimal position of 1.09 contracts one day and 0.96 the next does not justify a trade. Carver avoids those trades
with a buffer: a band is calculated around the optimum (in pysystemtrade, buffer_size: 0.10, 10% of the average
position) and you only trade if the current position falls outside it, and only up to the edge of the band
(buffer_trade_to_edge: True):
Mermaid source
flowchart LR
O[Optimum ± buffer] --> Q{Position inside?}
Q -- yes --> X[Do not trade]
Q -- no --> Y[Trade to the edge]The daily loop uses the position decided with the previous session's data, executed at the close of the session (lesson 2.2.2), in whole contracts. Each change pays half a tick and the commission per contract, and each roll, two sides on the whole position:
def contratos(optimo, media, buffer=0.10):
"""Whole position: trade only if the current one falls outside
optimum ± buffer·average position, and only up to the edge."""
P = optimo.shift(1).to_numpy() # decided with data from t − 1
B = buffer * media.shift(1).to_numpy()
N, actual = np.zeros_like(P), np.zeros(P.shape[1])
for t in range(len(P)):
bajo, alto = np.round(P[t] - B[t]), np.round(P[t] + B[t])
nuevo = np.clip(actual, bajo, alto)
actual = np.where(np.isnan(P[t]), 0.0, nuevo)
N[t] = actual
return pd.DataFrame(N, index=optimo.index, columns=optimo.columns)
coste_lado = (0.5 * esp.loc[MERCADOS, "valor_tick"]
+ esp.loc[MERCADOS, "comision"]) * cambio # USD
roll = contrato.ne(contrato.shift(1))
def backtest(N):
pyg = (N.shift(1) * dif * mult * cambio).sum(axis=1)
c = (N.diff().abs() * coste_lado
+ 2 * N.shift(1).abs() * roll * coste_lado).sum(axis=1)
return (pyg / CAPITAL).loc["2011":], (c / CAPITAL).loc["2011":]
def resumen(r):
return {"sharpe": r.mean() / r.std() * np.sqrt(252),
"vol_pct": 100 * r.std() * np.sqrt(252),
"rent_pct": 100 * r.mean() * 252}
filas = {}
for nombre, b in [("no buffer", 0.0), ("buffer 10%", 0.10)]:
N = contratos(optimo, media, b)
bruto, coste = backtest(N)
cambios = (N.diff().abs() > 0).loc["2011":].sum().sum() / 15
filas[nombre] = {**resumen(bruto - coste),
"coste_pct": 100 * coste.sum() / 15,
"operaciones_ano": cambios}
pd.DataFrame(filas).T.round(2) sharpe vol_pct rent_pct coste_pct operaciones_ano
no buffer 0.92 16.53 15.19 0.91 2134.67
buffer 10% 0.86 16.34 14.11 0.69 1132.07The buffer almost halves the trades (from 2,135 to 1,132 position changes a year across the 23 markets) and costs from 0.91% to 0.69% a year. But the net Sharpe falls from 0.92 to 0.86: what is saved in costs is less than what is lost by being farther from the optimal position. It is not a general rule. With 5 million USD, costs of half a tick and a volatility of 16%, costs are small (0.7-0.9 points a year against 14-15 of return); with more expensive markets, less capital or a faster system, the balance changes, and the first exercise checks it. The 0.06 difference between the two rows, moreover, is small relative to the standard error of a 15-year Sharpe (around 0.25).
Each rule, the system and the known answer
def sistema(prevision):
N = contratos(prevision / 10 * media, media)
bruto, coste = backtest(N)
return bruto - coste
netos = {k: sistema(p) for k, p in prev.items()}
netos["trend (two EWMACs)"] = sistema(
(0.5 * prev["ewmac16_64"] + 0.5 * prev["ewmac64_256"]).clip(
-20, 20) * min(2.5, 1 / np.sqrt(
np.array([0.5, 0.5]) @ rho.iloc[:2, :2].to_numpy()
@ np.array([0.5, 0.5]))))
netos["combined"] = sistema(combinada)
pd.DataFrame({k: resumen(r) for k, r in netos.items()}).T.round(2) sharpe vol_pct rent_pct
ewmac16_64 0.99 19.75 19.47
ewmac64_256 0.60 20.55 12.28
carry 0.53 14.69 7.82
trend (two EWMACs) 0.86 20.14 17.42
combined 0.86 16.34 14.11Net of costs, from 2011 to 2025: the fast EWMAC alone gives a Sharpe of 0.99, the slow one 0.60, carry 0.53, the two EWMACs together 0.86 and the combination of all three, 0.86 with a volatility of 16.3% (below the requested 20%, because of the combined forecast's average absolute value of 7.9). The header figure plots the curves: 1 USD in 2011 ends at 13.8 with the fast EWMAC, 4.6 with the slow one, 2.7 with carry and 6.8 with the combination.
That a single rule came out better than the combination is not an argument for keeping it. With 15 years, the standard error of a Sharpe is about 0.25, and picking the best of several correlated rules after the fact is the multiple testing bias (lesson 2.1.8). The combination has less volatility and does not depend on a single rule continuing to work.
neto = netos["combined"]
tramos = {f"{a}-{b}": float(resumen(neto.loc[str(a):str(b)])["sharpe"])
for a, b in [(2011, 2015), (2016, 2020), (2021, 2025)]}
capital = (1 + neto).cumprod()
caida = (capital / capital.cummax() - 1).min()
print({k: round(v, 2) for k, v in tramos.items()})
print(f"maximum drawdown {100 * caida:.1f} %")
comprar = sistema(pd.DataFrame(10.0, index=fechas,
columns=MERCADOS))
print(f"buy and hold: Sharpe "
f"{resumen(comprar)['sharpe']:.2f}, correlation with the "
f"system {neto.corr(comprar):.2f}"){'2011-2015': 0.96, '2016-2020': 0.61, '2021-2025': 1.02}
maximum drawdown -30.1 %
buy and hold: Sharpe 0.61, correlation with the system 0.12By five-year period, the Sharpe of the combination is 0.96, 0.61 and 1.02; the maximum drawdown, 30.1%. Buy and hold with the same sizing (a fixed forecast of +10 in every market) gives 0.61, with a correlation of 0.12 with the system. The data README measures simpler portfolios on the same files (weights of 10% over the 63-session volatility, no costs, simple returns) and gives 0.99 for the EWMAC 16/64 + 64/256 trend and 0.53 for buy and hold, close to this lesson's 0.61. That measure hides a trap worth seeing once: with the same weights, just changing the type of return being averaged is enough to sink the buy-and-hold Sharpe:
log_r = np.log(precio / (precio - dif)) # ln(P_t / P_t−1)
vol63 = log_r.rolling(63, min_periods=40).std() * np.sqrt(252)
pesos = (0.10 / vol63).shift(1)
for nombre, r in [("logarithmic", log_r),
("arithmetic", np.exp(log_r) - 1)]:
cartera = (pesos * r).mean(axis=1).iloc[260:]
print(f"{nombre:12s} Sharpe "
f"{cartera.mean() / cartera.std() * np.sqrt(252):.2f}")logarithmic Sharpe 0.23
arithmetic Sharpe 0.54With the same weights, averaging log returns gives a Sharpe of 0.23 and averaging arithmetic returns, 0.54. The return of a portfolio is the weighted average of the arithmetic returns; the log return of each market is lower than the arithmetic one by half the variance () on average, about 6 to 9 points a year in a commodity with 35-42% volatility, and that difference is subtracted from each volatile market in the portfolio even though nobody pays it. The README tables use simple returns for that reason. This lesson's system calculates the result in money, contract by contract, and does not have that problem.
What Carver's approach solves, and what it does not
- It separates the idea from the size. Each rule only has to produce a forecast in risk units; the size is decided by a common formula. Adding a rule does not force you to rewrite the system.
- It makes markets comparable. With volatility in points and the risk of one contract, a bond and an index enter the same formula.
- It puts diversification in numbers. FDM and IDM say how much extra risk can be taken per rule and per market because they do not move together, with a cap in case the correlations lie.
- It does not solve the choice of rules or weights (that is research, with all the biases of module 2.5), nor the tail risk of large bond positions (lesson 3.1.3), nor granularity with little capital.
Exercises
- Repeat the buffer comparison with costs of two ticks per side instead of half a tick. Does the Sharpe still fall with the buffer?
Solution
coste_lado = (2 * esp.loc[MERCADOS, "valor_tick"]
+ esp.loc[MERCADOS, "comision"]) * cambio
filas = {}
for nombre, b in [("no buffer", 0.0), ("buffer 10%", 0.10)]:
bruto, coste = backtest(contratos(optimo, media, b))
filas[nombre] = {**resumen(bruto - coste),
"coste_pct": 100 * coste.sum() / 15}
coste_lado = (0.5 * esp.loc[MERCADOS, "valor_tick"]
+ esp.loc[MERCADOS, "comision"]) * cambio
pd.DataFrame(filas).T.round(2) sharpe vol_pct rent_pct coste_pct
no buffer 0.80 16.53 13.15 2.95
buffer 10% 0.77 16.34 12.56 2.24With two ticks per side, costs go from 0.91% to 2.95% a year without a buffer and from 0.69% to 2.24% with it, and the
net Sharpe falls to 0.80 and 0.77. The difference in favor of not using a buffer shrinks (from 0.06 to 0.03), but it
does not change sign: in this system, with average positions of several contracts in almost every market and forecasts
that move slowly, what is lost by moving away from the optimum weighs more than what is saved. The buffer is not an
automatic improvement: its width is chosen by looking at the turnover and the cost per trade of each market, and it is
tested like any other parameter. The cell leaves coste_lado as it was so as not to change the following ones.
- With a capital of 250,000 USD, how many markets would have an average position of less than one contract? What happens to the realized risk of the system?
Solution
pequena = idm * w_i * 250_000 * TAU / riesgo_contrato
print("markets with average position < 1:",
int((pequena.loc["2025-11-26"] < 1).sum()), "of", len(MERCADOS))
N = contratos(combinada / 10 * pequena, pequena)
pyg = (N.shift(1) * dif * mult * cambio).sum(axis=1) / 250_000
r = pyg.loc["2011":]
print(f"realized vol {100 * r.std() * np.sqrt(252):.1f} %, "
f"days without a position in RVA "
f"{100 * (N['RVA'].loc['2011':] == 0).mean():.0f} %")markets with average position < 1: 19 of 23
realized vol 13.9 %, days without a position in RVA 78 %With 250,000 USD, 19 of the 23 markets have an average position of less than one contract: their whole-contract
position jumps between 0 and 1 (or 2), and they spend a good part of the time with no position (in RVA, 78% of the
days). Realized volatility falls to 13.9%, far from the 20% target, and the real diversification is much lower than
the IDM assumes. The solutions are micros and minis (lesson 3.1.1), fewer markets with more weight each, or accepting
the tracking error.
- Why is a rule's scalar estimated by pooling all the markets and not one per market? What would happen if each market had its own, estimated with the whole sample?
Solution
Because the scalar should only normalize the scale of the rule, which is almost the same in every market when the rule is in risk units. A per-market scalar estimated with the whole sample would have two problems. The first, noise: in a market with few long trends, the average absolute value is low and the scalar high, and the system would give more weight exactly where the rule has been weakest. The second, look-ahead: if the scalar is calculated with future data, the 2012 position depends on how the rule did in 2020. Pooling markets and fixing the scalar with an initial window (or with an expanding window) avoids both.
Each answer is folded under its question
Self-assessment
Why does Carver divide the difference of the averages of an EWMAC rule by the volatility of the price in points?
- ASo that the forecast measures the strength of the trend in risk units, comparable between a bond that moves 0.46 points a day and an index that moves 131
- BSo that the forecast is always positive
- CSo that the system trades less
- DBecause the broker requires it
Show answer
Answer A. So that the forecast measures the strength of the trend in risk units, comparable between a bond that moves 0.46 points a day and an index that moves 131
Without normalizing, the difference of averages of RVA would be hundreds of times that of TPC just because of the price scale. Divided by the daily volatility in points, both are measured in standard deviations and the same scalar can be used in every market.
The EWMAC 16/64 scalar estimated with 23 markets from 2011 to 2013 is 3.74 and the pysystemtrade example configuration uses 3.75. What does it indicate?
- AThat the data is copied from pysystemtrade
- BThat the scalar depends mostly on the shape of the rule and little on the market: with the forecast in risk units, the average absolute value of the raw rule is similar in any series whose volatility is estimated in a similar way
- CThat the scalar has to be re-estimated every day
- DThat the rule works
Show answer
Answer B. That the scalar depends mostly on the shape of the rule and little on the market: with the forecast in risk units, the average absolute value of the raw rule is similar in any series whose volatility is estimated in a similar way
The scalar only normalizes the scale of the rule, and that scale depends on the windows of the averages and on how volatility is measured. That is why it is estimated by pooling markets and is stable: with 2014-2025 it comes out at 4.01. It says nothing about whether the rule makes money.
Three forecasts with correlations from 0.34 to 0.61 and weights 0.25, 0.25 and 0.50 give an FDM of 1.263. What is it for?
- ATo leverage the system more for no reason
- BTo reduce costs
- CSo that the combination, which has less dispersion than each forecast separately because they are weakly correlated, gets back to an average absolute value close to 10
- DTo correct survivorship bias
Show answer
Answer C. So that the combination, which has less dispersion than each forecast separately because they are weakly correlated, gets back to an average absolute value close to 10
A weighted average of weakly correlated forecasts moves less than each one. Without the FDM, the system would have less risk than the target. The FDM is 1/√(w′ρw), with a cap (2.5 in pysystemtrade).
In the lesson, the 10% buffer reduces trades from 2,135 to 1,132 a year and costs from 0.91% to 0.69% a year, but the net Sharpe falls from 0.92 to 0.86. How should it be interpreted?
- AThat the buffer is a mistake
- BThat a 50% buffer should be used
- CThat the backtest is wrong
- DThat with these costs, low relative to volatility, what is saved does not make up for what is lost by moving away from the optimal position; the buffer gains value when costs are higher
Show answer
Answer D. That with these costs, low relative to volatility, what is saved does not make up for what is lost by moving away from the optimal position; the buffer gains value when costs are higher
The buffer trades costs for tracking error. Here costs take less than one point a year and the Sharpe difference (0.06) is small relative to its standard error. With higher costs (more expensive markets, smaller capital) the balance changes.
The combination (Sharpe 0.86) returns less than the EWMAC 16/64 rule alone (0.99). What conclusion is reasonable?
- AKeep only EWMAC 16/64
- BThat carry is useless in every market
- CNo strong one: with 15 years the standard error of a Sharpe is about 0.25, the rules are correlated and picking the best one after the fact is overfitting; the combination has less volatility and does not depend on a single rule
- DThat the scalars have to be changed
Show answer
Answer C. No strong one: with 15 years the standard error of a Sharpe is about 0.25, the rules are correlated and picking the best one after the fact is overfitting; the combination has less volatility and does not depend on a single rule
Picking the rule that came out best in the backtest is the multiple testing error (lesson 2.1.8). Carver combines rules because nobody knows in advance which one will do better; the combination reduces the dependence on a single one.
Key points
- A forecast is a continuous number in risk units: EWMAC = (fast average − slow average) / price volatility in points; carry = annual carry / annual volatility. It is scaled so that its average absolute value is 10 and capped at ±20.
- The scalars are estimated by pooling markets and are stable: 3.74 for EWMAC 16/64 and 1.76 for 64/256 with 2011-2013, close to the 3.75 and 1.87 of the pysystemtrade example configuration.
- The combination is rescaled with the FDM (1.263 with three forecasts) and the allocation across markets with the IDM (2.81 uncapped, 2.5 capped): diversification allows more risk per market for the same total risk.
- Contracts = forecast/10 × IDM × weight × capital × risk target / annual risk of one contract, in whole contracts and with a buffer of 10% of the average position.
- Net of costs, the system gives a Sharpe of 0.86 with a volatility of 16.3% and a maximum drawdown of 30.1% (2011-2025), and 0.96, 0.61 and 1.02 by five-year period. The data is synthetic with a planted trend: the result validates the code, not the strategy.
- A portfolio return is calculated with arithmetic returns: averaging weighted log returns gives a Sharpe of 0.23 for the same buy and hold that, measured properly, gives 0.54.