Markets: A Rigorous Course

Exercise · m04_backtest.py

Technical indicators as functions of the past, and a minimal event-driven backtester with costs

Implement every function, then press Run tests.

Conventions#

  • Every indicator value at date t uses data dated <= t only. Rolling windows are trailing, never centred.
  • Bar timing: the strategy sees the bar of date t after its close and returns a target weight w_t (fraction of equity: +1 long, 0 flat, -1 short). The engine fills that target at the OPEN of bar t+1. A weight decided at the close of t never earns the return of bar t itself.
  • Costs: cost_bps per side on traded value (all statutory and broker charges folded into one number; see M01) plus slippage_bps, applied by moving the fill price against the trader. The tests perturb FUTURE prices and check that past targets and equity do not move.
Run locally

From the repository root, with the exercises environment installed:

cd exercises && .venv/bin/python -m pytest modules/M04-systematic-research-swing/tests/test_m04_backtest.py

Tests (15)

  • test_sma_ema_definitions
  • test_rsi_first_value_and_identity
  • test_rsi_extremes
  • test_donchian_excludes_today
  • test_relative_volume_excludes_today
  • test_mansfield_rs_zero_when_ratio_constant
  • test_trend_template_up_and_down
  • test_weinstein_stage_cycle
  • test_no_lookahead_future_perturbation
  • test_strategy_only_sees_the_past
  • test_target_earns_next_bar_not_current
  • test_buy_and_hold_costs_exact
  • test_vectorised_matches_event_driven
  • test_unshifted_vectorised_is_lookahead
  • test_performance_summary

Theory: M04 notes. Run with Ctrl/⌘+Enter.

not run yet

Not saved: sign up to keep your code
"""M04 exercise: technical indicators as functions of the past, and a minimal
event-driven backtester with costs (notes §4.1, §4.2, §4.4).

Implement every function, then run from the repo root:

    cd exercises && .venv/bin/python -m pytest modules/M04-systematic-research-swing/tests/test_m04_backtest.py

Conventions
-----------
* Every indicator value at date t uses data dated <= t only. Rolling windows
  are trailing, never centred.
* Bar timing: the strategy sees the bar of date t after its close and returns a
  target weight w_t (fraction of equity: +1 long, 0 flat, -1 short). The engine
  fills that target at the OPEN of bar t+1. A weight decided at the close of t
  never earns the return of bar t itself.
* Costs: `cost_bps` per side on traded value (all statutory and broker charges
  folded into one number; see M01) plus `slippage_bps`, applied by moving the
  fill price against the trader.
The tests perturb FUTURE prices and check that past targets and equity do not move.
"""

from __future__ import annotations

from typing import Callable

import numpy as np
import pandas as pd

# --------------------------------------------------------------- indicators


def sma(x: pd.Series, n: int) -> pd.Series:
    """Trailing simple moving average SMA_n(x)_t = (1/n) sum_{k=0}^{n-1} x_{t-k}; NaN for the first n-1 values."""
    raise NotImplementedError


def ema(x: pd.Series, n: int) -> pd.Series:
    """EMA with alpha = 2/(n+1): E_t = alpha x_t + (1-alpha) E_{t-1}, E_0 = x_0 (no NaNs)."""
    raise NotImplementedError


def wilder_smooth(x: pd.Series, n: int) -> pd.Series:
    """Wilder's smoother on a differenced series (x_0 is NaN).

    W at position n = mean(x_1..x_n); then W_t = ((n-1) W_{t-1} + x_t) / n. Positions < n are NaN.
    """
    raise NotImplementedError


def rsi(close: pd.Series, n: int = 14) -> pd.Series:
    """Wilder's RSI_t = 100 U_t / (U_t + D_t) (notes Definition 4.4).

    U = wilder_smooth(max(dP, 0)), D = wilder_smooth(max(-dP, 0)), dP_t = P_t - P_{t-1}.
    If U + D = 0 return 50. Identity (Prop. 4.3): RSI = 50 + 50 W(dP) / W(|dP|).
    """
    raise NotImplementedError


def donchian_high(high: pd.Series, n: int) -> pd.Series:
    """max(H_{t-n}, ..., H_{t-1}): highest high of the PREVIOUS n bars (excludes bar t)."""
    raise NotImplementedError


def donchian_low(low: pd.Series, n: int) -> pd.Series:
    """min(L_{t-n}, ..., L_{t-1})."""
    raise NotImplementedError


def breakout(close: pd.Series, high: pd.Series, n: int) -> pd.Series:
    """Boolean close_t > donchian_high(high, n)_t; False where undefined."""
    raise NotImplementedError


def relative_volume(volume: pd.Series, n: int = 50) -> pd.Series:
    """RVOL_t = V_t / mean(V_{t-n}, ..., V_{t-1}); the average excludes today."""
    raise NotImplementedError


def mansfield_rs(price: pd.Series, index: pd.Series, n: int = 252) -> pd.Series:
    """Mansfield relative strength 100 (RS_t / SMA_n(RS)_t - 1), RS = price / index."""
    raise NotImplementedError


def trend_template(close: pd.Series, rs_rank: pd.Series | None = None, rs_min: float = 70.0) -> pd.Series:
    """Minervini Trend Template (notes Definition 4.6) as a boolean Series on daily bars.

    1. C > SMA150 and C > SMA200; 2. SMA150 > SMA200; 3. SMA200_t > SMA200_{t-21};
    4. SMA50 > SMA150 and SMA50 > SMA200; 5. C > SMA50; 6. C >= 1.30 * min(C over last 252 bars incl. t);
    7. C >= 0.75 * max(C over last 252 bars incl. t); 8. if rs_rank given, rs_rank >= rs_min.
    False wherever any input is undefined.
    """
    raise NotImplementedError


def weinstein_stage(weekly_close: pd.Series, n: int = 30, slope_lag: int = 4, flat_tol: float = 0.005) -> pd.Series:
    """Weinstein stage 1-4 (notes Definition 4.5).

    m = SMA_n(close); slope_t = m_t / m_{t-slope_lag} - 1.
    Stage 2 if close > m and slope > flat_tol; Stage 4 if close < m and slope < -flat_tol;
    otherwise Stage 3 if the most recent Stage-2-or-4 label was 2, else Stage 1 (start in "1").
    Float Series, NaN until m and slope are defined.
    """
    raise NotImplementedError


# --------------------------------------------------------------- strategies


def sma_crossover_strategy(fast: int = 50, slow: int = 200) -> Callable[[pd.DataFrame], float]:
    """Return a strategy(history) -> +1 if mean of last `fast` closes > mean of last `slow` closes, else 0.
    Return 0 while fewer than `slow` bars are available."""
    raise NotImplementedError


def tsmom_strategy(lookback: int = 252, skip: int = 21) -> Callable[[pd.DataFrame], float]:
    """Return a strategy(history) -> sign(ln(C_{t-skip} / C_{t-lookback})), with C_t the last close.
    Return 0 while len(history) <= lookback."""
    raise NotImplementedError


# --------------------------------------------------------------- engine


def run_backtest(
    bars: pd.DataFrame,
    strategy: Callable[[pd.DataFrame], float],
    cost_bps: float = 0.0,
    slippage_bps: float = 0.0,
    capital: float = 1.0,
    rebalance: str = "on_change",
) -> pd.DataFrame:
    """Event-driven backtest (notes Algorithm 4.1). bars has columns open, high, low, close, volume.

    State: cash = capital, shares = 0, target = 0 (pending), executed = 0 (last executed target).
    For t = 0..T-1:
      (a) eq_open = cash + shares * open_t.
          If rebalance == "always" or target != executed: desired = target * eq_open / open_t and executed = target;
          else desired = shares.
          trade = desired - shares. If trade != 0: fill = open_t (1 + sign(trade) s), s = slippage_bps/1e4;
          commission = |trade| fill c, c = cost_bps/1e4; cash -= trade * fill + commission;
          cost_t = commission + |trade| open_t s; shares = desired.
          turnover_t = |trade| open_t / eq_open.
      (b) equity_t = cash + shares * close_t.
      (c) target = strategy(bars.iloc[: t + 1])   # the strategy sees bars up to t only
    Raise ValueError for an unknown rebalance mode.
    Return DataFrame(index=bars.index, columns=["target", "shares", "equity", "cost", "turnover"]),
    where target is the value returned in (c) at t and shares is the holding over bar t.
    """
    raise NotImplementedError


def vectorised_backtest(
    bars: pd.DataFrame,
    target: pd.Series,
    cost_bps: float = 0.0,
    slippage_bps: float = 0.0,
    capital: float = 1.0,
) -> pd.Series:
    """Vectorised equivalent of run_backtest(..., rebalance="always") (notes Prop. 4.8).

    a_t = target_{t-1} (a_0 = 0); g_t = open_t / open_{t-1} (g_0 = 1);
    drifted weight d_t = a_{t-1} g_t / (1 + a_{t-1}(g_t - 1)) (a_{-1} = 0);
    E^o_t = E^o_{t-1} (1 + a_{t-1}(g_t - 1)) (1 - k |a_t - d_t|), k = (cost_bps + slippage_bps)/1e4, E^o_{-1} = capital;
    E_t = E^o_t (1 + a_t (close_t / open_t - 1)). Return the Series E_t named "equity".
    """
    raise NotImplementedError


def performance_summary(equity: pd.Series, periods: int = 252) -> dict:
    """{'cagr', 'vol', 'sharpe', 'max_drawdown'} from simple returns of equity (rf = 0).

    cagr = (E_end/E_0)^(1/years) - 1 with years = (number of returns)/periods;
    vol = std(ddof=1) sqrt(periods); sharpe = mean/std sqrt(periods); max_drawdown = min(E / cummax(E) - 1).
    """
    raise NotImplementedError

No runs yet

The stub's functions raise NotImplementedError until you write them. Fill in one, press Run tests, and watch its tests turn green.

Reference solution

Available after your first run, with an account approved for code runs.

Create a free account

Reading is open to everyone. An account keeps your progress, answers, notes and code drafts, and gives you the study plan, the journals and the simulator. Running code needs an account approved for it.