Skip to content
GetProfitable
Search

Build the system end to end

Lesson 28 · about 16 min

Everything in the course comes together here in one script: a mean-reversion system that loads bars, computes a z-score signal, sizes by ATR, backtests with costs and next-open execution, walks forward over the parameter grid, and prints the report card. It is about 120 lines and every line has been explained in an earlier module. Build it as run_project.py in your project, run it on synthetic data, then swap in real bars from Module 2 and run it again.

The rule

At each daily close, compute the z-score of the close against its 20-bar mean and standard deviation. Go long when the z-score falls below −2. Exit when it rises above 0. Fill at the next open. Size the position so that a 2-ATR move loses 1% of equity, capped at 100% of equity. Pay 5 basis points per side.

Mean reversion on a random walk has no edge, so on synthetic data the honest result is a flat-to-negative walk-forward curve. That is the intended outcome of this lesson: to see a complete, correct pipeline report "no" without flinching.

The script

# run_project.py
import itertools
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv, validate_bars
from src.indicators import sma, atr

TRADING_DAYS, COST_BPS, RISK, ATR_MULT, MAX_LEV = 252, 5.0, 0.01, 2.0, 1.0
WINDOWS, ENTRIES, EXITS = [10, 20, 40], [-1.5, -2.0, -2.5], [0.0, 0.5]
TRAIN_BARS, TEST_BARS = 756, 252


def zscore_signal(close: pd.Series, window: int, entry: float, exit_: float) -> pd.Series:
    z = (close - sma(close, window)) / close.rolling(window).std(ddof=1)
    raw = pd.Series(np.nan, index=close.index)
    raw[z < entry] = 1.0
    raw[z > exit_] = 0.0
    signal = raw.ffill().fillna(0.0)
    signal[z.isna()] = np.nan
    return signal


def atr_size(bars: pd.DataFrame) -> pd.Series:
    stop_frac = ATR_MULT * atr(bars) / bars["close"]
    return (RISK / stop_frac).clip(upper=MAX_LEV)


def net_returns(bars: pd.DataFrame, signal: pd.Series) -> pd.Series:
    """Next-open execution: open-to-open returns, signal and size lagged two bars."""
    position = (signal * atr_size(bars)).shift(2).fillna(0.0)
    turnover = position.diff().abs().fillna(0.0)
    gross = (position * bars["open"].pct_change()).fillna(0.0)
    return gross - turnover * COST_BPS / 10_000.0


def sharpe(r: pd.Series) -> float:
    sd = r.std(ddof=1)
    return float(r.mean() / sd * np.sqrt(TRADING_DAYS)) if sd > 0 else float("nan")


def max_drawdown(r: pd.Series) -> float:
    eq = (1 + r).cumprod()
    return float((eq / eq.cummax() - 1).min())


def grid(bars: pd.DataFrame) -> pd.DataFrame:
    rows = []
    for w, e, x in itertools.product(WINDOWS, ENTRIES, EXITS):
        r = net_returns(bars, zscore_signal(bars["close"], w, e, x))
        rows.append({"window": w, "entry": e, "exit": x, "sharpe": sharpe(r)})
    return pd.DataFrame(rows)


def choose(g: pd.DataFrame) -> tuple[int, float, float]:
    """Best neighbourhood over window (entry/exit held fixed): a plateau, not a peak."""
    scores = g.groupby(["entry", "exit"])["sharpe"].transform(lambda s: s.rolling(3, center=True, min_periods=1).mean())
    best = g.loc[scores.idxmax()]
    return int(best["window"]), float(best["entry"]), float(best["exit"])


def walk_forward(bars: pd.DataFrame) -> tuple[pd.Series, pd.DataFrame]:
    chunks, log, start = [], [], 0
    while start + TRAIN_BARS + TEST_BARS <= len(bars):
        train = bars.iloc[start: start + TRAIN_BARS]
        w, e, x = choose(grid(train))
        warm = max(0, start + TRAIN_BARS - 3 * max(WINDOWS))
        test = bars.iloc[warm: start + TRAIN_BARS + TEST_BARS]
        r = net_returns(test, zscore_signal(test["close"], w, e, x)).iloc[-TEST_BARS:]
        chunks.append(r)
        log.append({"test_start": r.index[0].date(), "window": w, "entry": e, "exit": x,
                    "oos_sharpe": round(sharpe(r), 2), "oos_dd": round(max_drawdown(r), 3)})
        start += TEST_BARS
    return pd.concat(chunks), pd.DataFrame(log)


def bootstrap_dd_p05(r: pd.Series, n: int = 1000, block: int = 20, seed: int = 0) -> float:
    rng, arr = np.random.default_rng(seed), r.to_numpy()
    n_blocks = int(np.ceil(len(arr) / block))
    starts = rng.integers(0, len(arr) - block + 1, size=(n, n_blocks))
    idx = (starts[:, :, None] + np.arange(block)[None, None, :]).reshape(n, -1)[:, : len(arr)]
    paths = arr[idx]
    eq = np.cumprod(1 + paths, axis=1)
    dds = (eq / np.maximum.accumulate(eq, axis=1) - 1).min(axis=1)
    return float(np.percentile(dds, 5))


def main(bars: pd.DataFrame) -> None:
    bars = validate_bars(bars)
    oos, log = walk_forward(bars)
    trials = len(WINDOWS) * len(ENTRIES) * len(EXITS) * len(log)
    print(log.to_string(index=False))
    print(f"\nOOS Sharpe {sharpe(oos):.2f} | OOS max DD {max_drawdown(oos):.1%} | "
          f"bootstrap p05 DD {bootstrap_dd_p05(oos):.1%} | cost {COST_BPS:.0f} bps | "
          f"trials {trials} | positive windows {(log['oos_sharpe'] > 0).sum()}/{len(log)}")
    doubled = COST_BPS * 2
    turnover = (zscore_signal(bars["close"], 20, -2.0, 0.0) * atr_size(bars)).shift(2).diff().abs().fillna(0).sum()
    print(f"at {doubled:.0f} bps the extra drag over the sample is roughly {turnover * COST_BPS / 10_000:.1%}")


if __name__ == "__main__":
    main(synthetic_ohlcv(3000, seed=42))

Reading the output

The log table shows, per walk-forward window, which parameters the training grid chose and how they did out of sample. The one-line report card at the bottom is the Module 8 standard. On the synthetic walk expect an OOS Sharpe near zero, a bootstrap 5th-percentile drawdown deeper than the historical one, and parameters that wander between windows. Some windows will show a NaN Sharpe and zero drawdown: the chosen parameters (typically a −2.5 entry on a short window) never fired in that year, which is itself information about how rarely the rule trades. That is a complete, honest "no edge here".

Now the interesting part. Replace the last line with real bars:

from src.store import load_bars
main(load_bars("SPY", "1d"))          # or any symbol you stored in Module 2

Whatever the result, run it at COST_BPS = 10 as well before drawing a conclusion, and look at the per-window log before the headline. A positive OOS Sharpe driven by one window is the picture of one regime, not an edge.

Key idea: A complete system is data validation, a signal function, a sizing function, a return function with lag and costs, a parameter search on training data only, a stitched out-of-sample curve, and a report card. Each piece is small; the discipline is in never skipping one.

Extending it

Three extensions, in order of value:

  1. More instruments. Run the same walk-forward on ten uncorrelated symbols and report the distribution of OOS Sharpes. One instrument gives one number; ten give a picture. Correlated instruments (three US index ETFs) are one bet in a trench coat, as the Risk Management course explains.
  2. The event engine. Replace net_returns with the Module 7 EventBacktester and a strategy class that places a bracket with a 2-ATR stop. Compare the two results; the gap is the value (or cost) of the stop.
  3. Paper trading. Wrap zscore_signal in run_once from Module 9, wire the paper broker, schedule it, and let it run for a month. Compare the paper fills with the backtest's fills for the same dates. This is the last test before any real money, and it is the one most people skip.

Try it: Change the strategy to momentum: long when the 12-month return (252 bars) is positive and the 1-month return (21 bars) is not the top of its 12-month range, flat otherwise. Keep every other line of the script. It should take under ten minutes, which is the point of having built the pipeline once.

Recap

  • One script: validate, signal, size, lagged net returns, grid on training only, walk-forward, bootstrap, report card.
  • On synthetic data the correct output is "no edge", and the pipeline should say so cleanly.
  • Run real data at the base cost and at double before concluding anything; read the window log before the headline.
  • Extend with more instruments, the event engine with real stops, then a month of paper trading.
  • Swapping the signal function is a ten-minute change once the pipeline exists.

See it drawn

Original diagrams for the ideas on this page. Illustrative, not real market data.

An equity curve and its drawdownAn account balance rising over a year, falling from a peak to a trough, then climbing back to the old peak.ACCOUNT EQUITY$20k$12k$8k024681012TIME (MONTHS)PEAK $16,000TROUGH $12,000DRAWDOWN−25%RECOVERY
Equity curve and drawdown. An account balance plotted month by month. The fall from the $16,000 peak to the $12,000 trough is a 25% drawdown, and the shaded area lasts until the balance climbs back to the old peak.
The spread of outcomes behind an expectancyA histogram of forty trades: a tall block of small losses on the left, a low spread of larger wins on the right, and a line marking the average outcome.NUMBER OF TRADES051024 LOSSES, AVG −$20016 WINS, AVG +$600EXPECTANCY +$120−$400−$200$0+$200+$400+$600+$800PROFIT OR LOSS PER TRADEexpectancy = (40% × $600) − (60% × $200) = +$120 per trade
Expectancy: the average trade. Forty trades sorted by outcome: 24 small losses and 16 larger wins. Weighting each side by how often it happens gives the average result per trade, marked here by the dashed line at +$120.