Skip to main content

Tutorial 3: Custom strategy

This tutorial walks through the full lifecycle of a new strategy: write a factor → backtest → walk-forward validate → paper trade → monitor drift.

Time: ~45 min plus actual market hours for the paper trading leg.

The strategy: crypto realized-vol mean reversion

We'll build the strategy that the source MTS1B firm actually deploys: rank crypto by 21-day realized vol, long the lowest, short the highest, monthly rebalance.

The hypothesis: high recent vol tends to mean-revert (crowded positions, post-event drift). Long the calm ones, short the chaotic ones.

Step 1 — Write the factor

my_strategy/factors/realized_vol.py
import numpy as np
from mts1b_quantkit.factors import register
from mts1b_foundation.market_data import UniversePanel


@register("f_my_realized_vol")
def f_my_realized_vol(panel: UniversePanel, /, h: int = 21) -> np.ndarray:
"""Cross-sectional realized vol z-score.

Higher value = higher realized vol = candidate for SHORT (mean reversion).
Lower value = lower realized vol = candidate for LONG.

Args:
panel: UniversePanel with .close (T, A)
h: lookback window in days (21 = ~1 month)

Returns:
(T, A) z-scored realized vol. NaN where insufficient history.
"""
close = panel.close # (T, A)
log_ret = np.log(close[1:] / close[:-1]) # (T-1, A)

# Rolling std with EWMA decay
vol = np.empty_like(close)
vol[:h] = np.nan
for t in range(h, len(close)):
window = log_ret[t-h:t]
vol[t] = np.nanstd(window, axis=0) * np.sqrt(252)

# Z-score cross-sectionally per row
z = (vol - np.nanmean(vol, axis=1, keepdims=True)) / np.nanstd(vol, axis=1, keepdims=True)
return z

Step 2 — Quick backtest (10 seconds)

my_strategy/quick_backtest.py
from mts1b_GPUbacktester import run_single
from mts1b_quantkit.factors import get
from my_strategy.factors.realized_vol import * # noqa — registers the factor


result = run_single(
factor=get("f_my_realized_vol"),
params={"h": 21},
universe="crypto-top-10",
start="2022-01-01", end="2026-01-01",
rebal="weekly",
sizing={"method": "equal_weight_ls", "n_long": 2, "n_short": 2, "gross": 1.0},
cost_bps=60, # crypto is expensive: 30bps each side
invert=True, # buy low z (low vol), sell high z (high vol)
)

print(f"Sharpe: {result.sharpe:.2f}")
print(f"Calmar: {result.calmar:.2f}")
print(f"Max DD: {result.max_drawdown:.2%}")
print(f"CAGR: {result.cagr:.2%}")
print(f"IC: {result.ic:.3f} (t-stat: {result.t_stat:.1f})")
print(f"Turnover: {result.turnover_annualized:.0f}%/yr")

Expected output (varies):

Sharpe: 1.43
Calmar: 0.91
Max DD: -12.4%
CAGR: +18.3%
IC: 0.062 (t-stat: 6.41)
Turnover: 870%/yr

A Sharpe of 1.43 net of 60bps is promising. But run a single backtest and you've got nothing — let's validate properly.

Step 3 — Walk-forward validation

my_strategy/walk_forward.py
from mts1b_quantkit.cv import walk_forward
from mts1b_quantkit.factors import get


cv = walk_forward(
factor=get("f_my_realized_vol"),
params_grid={
"h": [10, 21, 42, 63], # try several lookbacks
},
universe="crypto-top-10",
start="2020-01-01", end="2026-01-01",

train_window=252, # 1 year train
test_window=63, # 3 month test
step=63, # roll forward 3 months at a time

rebal="weekly",
sizing={"method": "equal_weight_ls", "n_long": 2, "n_short": 2, "gross": 1.0},
cost_bps=60,
invert=True,
)

print(f"Folds: {cv['n_folds']}")
print(f"OOS Sharpe: {cv['agg_sharpe']:.2f} (CI95: {cv['ci95_sharpe']})")
print(f"OOS IC: {cv['agg_ic']:.3f} (t-stat: {cv['agg_t_stat']:.1f})")
print(f"Best params: {cv['best_params']}")
print(f"Stability: {cv['stability_score']:.2f} (1.0 = consistent across folds)")

If the OOS Sharpe is materially lower than the in-sample (Step 2), the factor is overfit to a specific regime. Stop and go think.

Step 4 — Statistical confirmation

my_strategy/stats.py
from mts1b_quantkit.stats import (
bootstrap_sharpe_ci,
information_coefficient,
decay_curve,
regime_split,
)

# 95% confidence interval on Sharpe via bootstrap
ci = bootstrap_sharpe_ci(result.returns, n_boot=1000)
print(f"Sharpe 95% CI: [{ci.lower:.2f}, {ci.upper:.2f}]")

# IC by horizon
ic_curve = decay_curve(
factor=get("f_my_realized_vol"),
params={"h": 21},
universe="crypto-top-10",
horizons=[1, 5, 10, 21, 42, 63],
)
for h, ic in ic_curve.items():
print(f" IC at {h}-day horizon: {ic:.3f}")

# Decompose by market regime
regime = regime_split(result, regimes=["bull", "chop", "crash"])
for r, stats in regime.items():
print(f" {r}: Sharpe={stats['sharpe']:.2f}, MaxDD={stats['max_dd']:.2%}")

The factor is robust if:

  • Bootstrap CI95 lower bound on Sharpe is above 0
  • IC monotonically decays (no spurious bumps at random horizons)
  • Sharpe is positive across all regimes (bull / chop / crash)

Step 5 — Cost-stress

my_strategy/cost_stress.py
for cost in [10, 30, 60, 100, 150]:
r = run_single(
factor=get("f_my_realized_vol"),
params={"h": 21},
universe="crypto-top-10",
start="2022-01-01", end="2026-01-01",
rebal="weekly",
sizing={"method": "equal_weight_ls", "n_long": 2, "n_short": 2},
cost_bps=cost,
invert=True,
)
print(f"cost={cost:3d}bps → Sharpe {r.sharpe:.2f}, CAGR {r.cagr:+.1%}")

If the strategy breaks at 80 bps, it's not robust to fee creep.

Step 6 — Register for live use

If steps 3-5 look good, register the strategy:

mts mts1b-research strategy register \
--strategy-id my_crypto_revol_v1 \
--fund-id paper-crypto-demo \
--factor f_my_realized_vol \
--params '{"h": 21}' \
--universe crypto-top-10 \
--rebal weekly \
--sizing '{"method": "equal_weight_ls", "n_long": 2, "n_short": 2, "gross": 1.0}' \
--invert true \
--cost-bps 60 \
--enabled true

The strategy now runs live (paper) at every weekly close.

Step 7 — Monitor drift

mts mts1b-research drift show --strategy-id my_crypto_revol_v1
strategy=my_crypto_revol_v1
backtest_sharpe: 1.43 (95% CI: [1.12, 1.81])
live_sharpe_30d: 1.27 ← within CI95, OK
live_sharpe_90d: 1.51 ← within CI95, OK

backtest_ic: 0.062
live_ic_30d: 0.054
live_ic_90d: 0.061

drift_zscore: -0.24 ← well within bounds
decay_status: NORMAL ← all green

If drift_zscore goes below -1.0, mts1b-research automatically halves the sleeve allocation. Below -2.0, the factor is shadowed (live orders halted).

Step 8 — Promote to live trading (when ready)

After 90+ days of consistent paper performance, switch the broker:

mts mts1b-treasury fund create \
--fund-id live-crypto-revol \
--nav 50000 \
--broker coinbase

And register the same strategy against the new fund. Everything else is identical — the strategy doesn't know it's now on a real broker.

Anti-patterns to avoid

Don'tDo
Optimize a single param on the full datasetWalk-forward with rolling windows
Pick the best params from the gridCheck stability across all params
Backtest at zero costAlways use realistic cost_bps
Compare to wrong benchmarkCompare to buy-and-hold of same universe
Promote on 10 tradesNeed 100+ live trades to confirm
Ignore drift alertsTreat drift_zscore < -1.0 as actionable

What's next