Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ ENV/
.mypy_cache/
.ruff_cache/
.uv-*-cache/
.uv-cache-codex-review/
.coverage
.coverage.*
htmlcov/
Expand Down
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,15 @@ a config's `robustness:` block in one pass. All five branch on
`validation.method`: with `walk_forward`, each starts from the same
walk-forward-stitched out-of-sample result rather than a single backtest, so
the evidence never silently comes from a different validation method than
the one configured. `stress-test` and `sensitivity` re-run the whole
walk-forward selection process per scenario/candidate, since each one
represents a different cost/methodology assumption or parameter to
re-optimise under. `bootstrap` and `permutation-test` do not: they resample
or permute the walk-forward's already-realised out-of-sample return series
statistically, without re-running the selection process itself.
the one configured. `sensitivity` re-runs the whole walk-forward selection process per candidate
parameter pair. `stress-test`'s commission/slippage scenarios reuse the
baseline's own cached per-fold candidate weights and only re-score them
under the new costs (cheaper, since selection never depends on execution
costs); its execution-delay and reduced-universe scenarios genuinely change
the weights themselves, so each still re-runs selection end to end.
`bootstrap` and `permutation-test` do neither: they resample or permute the
walk-forward's already-realised out-of-sample return series statistically,
without touching the selection process at all.

`walk-forward`, `stress-test`, `sensitivity` and `robustness` show a live
progress bar with an ETA in the terminal, and checkpoint their progress to
Expand Down Expand Up @@ -222,8 +225,10 @@ progress bar with an ETA while a run is in flight. Both modes' Robustness
tab includes stress tests, block bootstrap, a Monte Carlo permutation test
and a 2-parameter sensitivity heatmap,
individually or via "Run all robustness tests" — in Walk-forward mode,
stress tests and sensitivity re-run the whole selection process per
scenario/cell rather than a single backtest.
sensitivity re-runs the whole selection process per cell; stress tests do
too for execution-delay/reduced-universe scenarios, but reuse the
baseline's cached candidate weights (just re-scored under new costs) for
commission/slippage scenarios.

![QuantLab dashboard results](reports/figures/dashboard_results.png)

Expand Down Expand Up @@ -281,13 +286,13 @@ produces the full 26-fold report the numbers below are drawn from.
Real, non-cherry-picked results from real Yahoo Finance / Binance data (2008–2025
for ETFs, 2018–2025 for BTC), net of modelled transaction costs:

| Experiment | Universe | Period | CAGR | Sharpe | Max DD | Trades |
| Experiment | Universe | Period | CAGR | Sharpe | Max DD | Fills |
| --- | --- | --- | --- | --- | --- | --- |
| Cross-sectional momentum (example above) | 8 multi-asset ETFs | 2008–2025 | 5.7% | 0.44 | −15.8% | 277 |
| Cross-sectional momentum (example above) | 8 multi-asset ETFs | 2008–2025 | 5.5% | 0.43 | −15.8% | 1,514 |
| ↳ walk-forward out-of-sample | same | 26 folds | 4.7% | 0.32 | −18.5% | — |
| Mean reversion (z-score) | 5 equity ETFs | 2010–2025 | 4.4% | 0.25 | −34.6% | 1,180 |
| Mean reversion (z-score) | 5 equity ETFs | 2010–2025 | 4.4% | 0.25 | −34.6% | 3,351 |
| Pairs trading (EWA/EWC, vs SPY) | 2 country ETFs | 2010–2025 | 0.9% | −0.35 | −8.0% | 1,314 |
| Trend following (BTC) | BTCUSDT | 2018–2025 | 30.3% | 0.93 | −43.9% | 1,560 |
| Trend following (BTC) | BTCUSDT | 2018–2025 | 30.3% | 0.93 | −43.9% | 1,563 |

Every number above — including the negative Sharpe on the pairs trade — is
reported as computed; nothing here is filtered for looking good. **These are
Expand Down
30 changes: 30 additions & 0 deletions configs/btc_trend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
# Crypto uses 365 periods/year and higher costs than ETFs.
experiment_name: btc_trend_following

research_question: >-
Does a simple moving-average trend-following rule on Bitcoin capture a
persistent risk-adjusted edge once volatility targeting and elevated
crypto transaction costs are accounted for?
hypothesis: >-
H1: net of modelled transaction costs, long-only trend following on BTC
has a genuine positive risk-adjusted edge. H0: any apparent edge is
indistinguishable from noise or is eliminated by trading costs.

data:
instruments:
- symbol: BTCUSDT
Expand All @@ -18,6 +27,9 @@ strategy:
fast_window: 20
slow_window: 100
long_only: true
# Price series the strategy's own generate_signals() reads. Execution/
# costs always use the raw close regardless of this setting.
signal_price_type: adjusted_close

portfolio:
allocator: volatility_targeting
Expand Down Expand Up @@ -57,12 +69,30 @@ reproducibility:
robustness:
stress_test:
enabled: true
# Each list is a set of magnitudes evaluated independently (one
# scenario row per value); an empty list disables that scenario type
# entirely. Crypto's higher baseline costs (see execution: above) make
# a larger commission stress meaningful here too.
commission_multipliers: [2.0, 5.0]
slippage_multipliers: [2.0]
execution_delays: [1]
best_days_removed: [10]
bootstrap:
enabled: true
# Width of the reported p_lower/p_upper percentile band (0.90 -> the
# 5th/95th percentiles).
confidence_level: 0.90
permutation_test:
enabled: true
sensitivity:
enabled: true
parameters:
fast_window: [10, 20, 40]
slow_window: [50, 100, 200]

# Where and what a run saves. Omit entirely for the default: reports/
# generated/<experiment_name>/, both artefact toggles enabled.
output:
# directory: reports/generated/btc_trend_following
save_html_report: true
save_figures: true
13 changes: 13 additions & 0 deletions configs/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ data:
strategy:
name: buy_and_hold
parameters: {}
# Price series the strategy's own generate_signals() reads. Execution/
# costs always use the raw close regardless of this setting.
signal_price_type: adjusted_close

portfolio:
allocator: equal_weight
Expand Down Expand Up @@ -60,5 +63,15 @@ robustness:
enabled: true
bootstrap:
enabled: true
# Width of the reported p_lower/p_upper percentile band (0.90 -> the
# 5th/95th percentiles).
confidence_level: 0.90
permutation_test:
enabled: true

# Where and what a run saves. Omit entirely for the default: reports/
# generated/<experiment_name>/, both artefact toggles enabled.
output:
# directory: reports/generated/default_buy_and_hold
save_html_report: true
save_figures: true
34 changes: 34 additions & 0 deletions configs/demo_offline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
# NOTE: the prices here are SYNTHETIC (generated), not real market data.
experiment_name: demo_offline_momentum

research_question: >-
Does cross-sectional momentum on this small offline demo universe
produce a stable out-of-sample signal, purely as a fast, no-network
illustration of the walk-forward pipeline?
hypothesis: >-
H1: the walk-forward pipeline selects a consistent momentum parameter
across folds on this synthetic demo data. H0: fold-to-fold selection is
effectively random. (Demo data is synthetic -- not evidence about real
markets either way.)

data:
# The synthetic symbols model US equity ETFs.
instruments:
Expand Down Expand Up @@ -34,6 +44,9 @@ strategy:
bottom_fraction: 0.5
long_short: false
signal_scaling: binary
# Price series the strategy's own generate_signals() reads. Execution/
# costs always use the raw close regardless of this setting.
signal_price_type: adjusted_close

portfolio:
allocator: inverse_volatility
Expand Down Expand Up @@ -64,6 +77,9 @@ validation:
train_window: 500
validation_window: 126
test_window: 126
# Advance between consecutive folds' train windows. Omit (or set equal to
# test_window) for the default: contiguous, non-overlapping test blocks.
step: 126
expanding: true
optimization_metric: sharpe

Expand All @@ -80,12 +96,30 @@ reproducibility:
robustness:
stress_test:
enabled: true
# Each list is a set of magnitudes evaluated independently (one
# scenario row per value); an empty list disables that scenario type
# entirely.
commission_multipliers: [2.0, 5.0]
slippage_multipliers: [2.0]
execution_delays: [1]
best_days_removed: [10]
reduce_universe_by: [1]
bootstrap:
enabled: true
# Width of the reported p_lower/p_upper percentile band (0.90 -> the
# 5th/95th percentiles).
confidence_level: 0.90
permutation_test:
enabled: true
sensitivity:
enabled: true
parameters:
lookback_period: [126, 189, 252]
top_fraction: [0.25, 0.50]

# Where and what a run saves. Omit entirely for the default: reports/
# generated/<experiment_name>/, both artefact toggles enabled.
output:
# directory: reports/generated/demo_offline_momentum
save_html_report: true
save_figures: true
39 changes: 35 additions & 4 deletions configs/mean_reversion_etfs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
# research notebook.
experiment_name: mean_reversion_etfs

research_question: >-
Does short-term price mean reversion across liquid ETFs generate a
positive risk-adjusted edge after transaction costs?
hypothesis: >-
H1: net of modelled transaction costs, a rolling z-score mean-reversion
signal across this ETF universe has a genuine positive risk-adjusted
edge. H0: any apparent edge is indistinguishable from noise or is
eliminated by trading costs.

data:
instruments:
- symbol: SPY
Expand All @@ -29,10 +38,14 @@ strategy:
name: mean_reversion
parameters:
lookback_period: 20
entry_zscore: 2.0
exit_zscore: 0.5
stop_zscore: 4.0
indicator: zscore
entry_threshold: 2.0
exit_threshold: 0.5
stop_threshold: 4.0
long_only: true
# Price series the strategy's own generate_signals() reads. Execution/
# costs always use the raw close regardless of this setting.
signal_price_type: adjusted_close

portfolio:
allocator: equal_weight
Expand Down Expand Up @@ -65,12 +78,30 @@ reproducibility:
robustness:
stress_test:
enabled: true
# Each list is a set of magnitudes evaluated independently (one
# scenario row per value); an empty list disables that scenario type
# entirely.
commission_multipliers: [2.0, 5.0]
slippage_multipliers: [2.0]
execution_delays: [1]
best_days_removed: [10]
reduce_universe_by: [1]
bootstrap:
enabled: true
# Width of the reported p_lower/p_upper percentile band (0.90 -> the
# 5th/95th percentiles).
confidence_level: 0.90
permutation_test:
enabled: true
sensitivity:
enabled: true
parameters:
lookback_period: [10, 20, 40]
entry_zscore: [1.5, 2.0, 2.5]
entry_threshold: [1.5, 2.0, 2.5]

# Where and what a run saves. Omit entirely for the default: reports/
# generated/<experiment_name>/, both artefact toggles enabled.
output:
# directory: reports/generated/mean_reversion_etfs
save_html_report: true
save_figures: true
47 changes: 43 additions & 4 deletions configs/momentum_sp500.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
# Example experiment:
# Robust Cross-Sectional Momentum Across Liquid Multi-Asset ETFs.
#
# Research question: Can cross-sectional momentum generate stable out-of-sample
# risk-adjusted returns across liquid ETFs after transaction costs and
# volatility targeting?
experiment_name: cross_sectional_momentum_etfs

# Overrides the auto-generated report text (see quantlab.reporting.
# research_summary) with this experiment's own wording. Both are optional --
# omit either to fall back to the auto-generated version.
research_question: >-
Can cross-sectional momentum generate stable out-of-sample risk-adjusted
returns across liquid ETFs after transaction costs and volatility
targeting?
hypothesis: >-
H1: net of modelled transaction costs, long-only cross-sectional momentum
across this ETF universe has a genuine positive risk-adjusted edge. H0: any
apparent edge is indistinguishable from noise or is eliminated by trading
costs and volatility targeting.

data:
instruments:
- symbol: SPY # US large cap
Expand Down Expand Up @@ -46,6 +55,11 @@ strategy:
bottom_fraction: 0.25
long_short: false # long-only for the main version
signal_scaling: binary
# Price series the strategy's own generate_signals() reads. Execution/
# costs always use the raw close regardless of this setting. Default is
# "adjusted_close" (recommended for equities/ETFs, avoids false signals
# from splits/dividends) -- shown explicitly here for illustration.
signal_price_type: adjusted_close

portfolio:
allocator: inverse_volatility
Expand Down Expand Up @@ -77,6 +91,13 @@ validation:
train_window: 1000
validation_window: 252
test_window: 126
# Advance between consecutive folds' train windows. Omit (or set equal to
# test_window) for the default: contiguous, non-overlapping test blocks.
# A smaller step overlaps test blocks for denser evaluation at higher
# compute cost. step must not exceed test_window -- a larger value is
# rejected outright (it would skip dates between folds' test blocks
# entirely), not silently allowed to skip periods.
step: 126
expanding: true
optimization_metric: sharpe
# Freeze the published search space: tune timing while keeping the 25%
Expand All @@ -91,10 +112,21 @@ reproducibility:
robustness:
stress_test:
enabled: true
# Each list is a set of magnitudes evaluated independently (one
# scenario row per value); an empty list disables that scenario type
# entirely. Values shown here match this project's own defaults.
commission_multipliers: [2.0, 5.0]
slippage_multipliers: [2.0]
execution_delays: [1]
best_days_removed: [10]
reduce_universe_by: [1]
bootstrap:
enabled: true
n_iterations: 1000
block_size: 5
# Width of the reported p_lower/p_upper percentile band (0.90 -> the
# 5th/95th percentiles).
confidence_level: 0.90
permutation_test:
enabled: true
n_iterations: 1000
Expand All @@ -105,3 +137,10 @@ robustness:
parameters:
lookback_period: [126, 189, 252]
top_fraction: [0.10, 0.25, 0.50]

# Where and what a run saves. Omit entirely for the default: reports/
# generated/<experiment_name>/, both artefact toggles enabled.
output:
# directory: reports/generated/cross_sectional_momentum_etfs
save_html_report: true
save_figures: true
Loading
Loading