The process, from catalogue to verdict

Every tool was treated the same way: real data, out-of-sample evaluation, costs applied, parameter grids reported as distributions, and a pre-registered pass bar matched to the tool's role. This page documents each stage and the discipline that keeps the conclusions honest.

Eight steps, repeated for 45 tools

  1. Catalogue. The master Notion spec ("Signal & Strategy Elements" database) defines 18 elements, each with a "— Tools" sub-database. Each tool row already carries its Definition/Formula, Parameters to Sweep, Primary/Secondary Metrics, Applicable Markets, Hypothesis, and an empty Test Status.
  2. Triage data availability. Before writing any code: does the tool's core input exist in the pipeline? Rate/fundamental/event-calendar quantities (interest-rate differentials, dividend yields, futures curves, earnings dates) generally have no price-only proxy; price-derived statistical properties (spread, impact, realized vol) often have academic OHLCV proxies; purely mechanical calendar rules need no external data at all. If a free source exists but isn't piped yet (CFTC COT), it gets built — with user sign-off. If the gap is real, the tool is documented "data-blocked" with what would close it, and never tested on fabricated input.
  3. Implement. One module per tool in src/signals/<element>/, implementing exactly the catalogued formula. One runner per tool in scripts/run_<tool>.py. Twenty methodology patterns accumulated across the elements (simple directional, continuous/IC, stateful entry/exit, regime-filter classifier, cross-sectional, risk-input overlay, forecast-error input, comparative-hypothesis, etc.) — new tools reuse the closest pattern rather than inventing a new engine.
  4. Backtest. The shared vectorized engine converts signal → position → PnL with a 1-bar decision-to-execution lag (no lookahead), charges costs from turnover, and applies real funding-rate history where relevant.
  5. Sweep. Every parameter combination from the tool's own "Parameters to Sweep" runs through expanding walk-forward out-of-sample folds. Results are reported as the median across the whole grid — the best cell is recorded reference-only and never used as "the" result (cherry-pick guard).
  6. Measure. Per-fold metrics: IC, hit rate, Sharpe, Sortino, max drawdown, turnover; plus classification accuracy for regime filters and cost-prediction error (bps) for execution inputs. Bootstrap p-values test the pooled OOS returns; the Benjamini-Hochberg FDR correction is applied across all tools that carry a p-value.
  7. Verdict. A role-aware pass bar (below) produces Validated / Rejected, or the triage produces Data-blocked. Regime breakdown requires the result to hold across ≥2 of 3 sub-periods where applicable.
  8. Record. Each run writes runs/<tool>.json (the full audit trail) and prints a summary. The user reviews before anything is written back to Notion — no batch auto-writing.

No lookahead, real costs

The engine (src/backtest/engine.py) is deliberately small. A position decided at timestamp t is shifted one bar internally, so a bar's return only ever uses information known before that bar started. Costs are charged on position turnover (round-trip), and perp funding is applied from the real funding-rate history when available.

# src/backtest/engine.py (core)
def run_backtest(close, position, cost_fn, funding_rate=None):
    ret = close.pct_change()
    pos_lagged = position.reindex(close.index).ffill().fillna(0.0).shift(1).fillna(0.0)
    gross_ret = pos_lagged * ret
    turnover = pos_lagged.diff().abs()
    cost = cost_fn(turnover)
    net_ret = gross_ret - cost
    if funding_rate is not None:
        net_ret = net_ret - bybit_funding_cost(pos_lagged, funding_rate)
    return BacktestResult(gross_ret, net_ret, turnover, pos_lagged)
Costs applied to every backtest: Bybit taker 5.5 bps (100% taker assumption for sweep signals) + 2 bps maker for blended cases, plus real funding-rate history for perps (mean BTC funding ≈ 0.0001175/8h ≈ 12.9% annualized — not trivial), and a 1.5 bps flat spread+commission proxy for traditional-market instruments.

Out-of-sample, roll forward

Folds are expanding-window: each fold trains on everything up to a point and tests on the next window, then the train window grows. Defaults are 365 train / 90 test / 90 step bars in the series' own frequency (daily for perp 1d, scaled ×24 for hourly). A typical alpha-signal tool therefore runs 16–27 parameter cells × 22 OOS folds, and the reported metric is the median across all cell-fold combinations.

# src/backtest/walkforward.py — expanding windows
def expanding_walkforward(index, train_bars, test_bars, step_bars):
    folds = []
    train_end = train_bars
    while train_end + test_bars <= len(index):
        folds.append(Fold(train_idx=index[:train_end],
                          test_idx=index[train_end: train_end + test_bars]))
        train_end += step_bars
    return folds
Regime discipline (spec §3): OOS returns are also sliced into three market regimes — 2020–21 bull, 2022 bear, 2023–24 chop/recovery — and the alpha-signal bar requires positive performance in ≥2 of 3. A result that lives in a single regime is not a result.

Report the distribution, not the best cell

Every tool's own "Parameters to Sweep" grid is a Cartesian product swept through every fold. run_sweep returns the full per-cell, per-fold metric distribution; verdicts use the grid median. The single best cell is stored as best_cell_*_reference_only — visible in the run JSONs, never decisive. This is the project's main defense against data-mining: a grid of 24 MA crossovers will always contain one that backtests beautifully; the median across all of them will not.

# metrics reported per OOS fold (scripts/run_moving_average_crossover.py)
def fold_metric_fn(net_returns, position, close):
    return {
        "sharpe":      ev.sharpe_ratio(net_returns),
        "ic":          ev.information_coefficient(position.shift(1), close.pct_change()),
        "hit_rate":    ev.hit_rate(position, net_returns),
        "max_drawdown": ev.max_drawdown(net_returns),
        "turnover":    float(position.diff().abs().sum()),
    }

The measurement set

MetricDefinitionUsed for
IC (Information Coefficient)Spearman rank correlation between lagged signal and forward returnAlpha signals — is the ranking predictive at all?
Hit rateFraction of active (nonzero-position) bars with position × return > 0Secondary alpha metric
Sharpe ratioMean / std × √periods-per-year, annualized from the bar frequencyPrimary metric for most alpha tools (bar > 0.3 net)
Sortino ratioMean / downside std (negative returns only)Risk-input & allocation comparisons
Max drawdownPeak-to-trough equity declineRisk-input bar (must be reduced vs. baseline)
Classification accuracyFraction of correctly labelled regime bars vs. a naive baselineRegime filters
Cost prediction error (bps)Mean absolute error of a cost/impact forecast vs. realized proxyExecution-input tools, vs. a flat-bps baseline
Turnover-adjusted returnTotal return / total turnoverComparative-hypothesis tools (e.g. holding period)

Bootstrap p-values + multiple-testing correction

Pooled out-of-sample returns are tested with a 2,000-resample bootstrap against H0: mean ≤ 0. Because ~17 tools carry a p-value (alpha-role tools), the Benjamini-Hochberg FDR correction (α=0.05) is applied across the set. Running 45 tools guarantees some will look significant by chance; BH is the explicit accounting for that. (Result: the correction changed zero verdicts — see Findings.)

One bar per tool role

A trend signal and a stop-loss overlay answer different questions, so they get different bars (src/evaluation/pass_bar.py).

RolePass bar (all criteria must hold)
Alpha signalOOS median IC > 0 AND net Sharpe > 0.3 AND significant after multiple-testing correction AND positive in ≥2/3 regimes
Regime filterClassification accuracy > naive baseline AND (gated Sharpe > ungated OR gated drawdown > ungated)
Risk inputMax drawdown reduced vs. baseline sizing AND Sortino ratio ≥ 0.9× baseline
Execution inputCost-prediction error < flat-bps baseline error
Portfolio constructionBlended (e.g. risk-parity / MVO) Sharpe > equal-weight Sharpe
Read the OR carefully. The regime-filter bar passes on either Sharpe improvement or drawdown improvement. That OR is why HMM and ATR carry "Validated (marginal)" labels — they pass on one leg while failing the other. The Findings page unpacks each one.

Anti-patterns the project refused

Everything re-runs from scratch

One script per tool → one JSON per tool in runs/; 9 test modules (85 tests) cover the engine, costs, metrics, pass bars, significance, walk-forward, sweep, and schema. The data cache is regenerable (though re-pulling BTC 1h history takes ~10 min against Bybit rate limits — keep data/cache/ when you can). The full workflow for adding a new tool is on the Repo page.