Skip to content

Latest commit

 

History

History
387 lines (312 loc) · 18.6 KB

File metadata and controls

387 lines (312 loc) · 18.6 KB

CLAUDE.md

Guidance for AI assistants working in this repository.

What this project is

A single-asset, bar-by-bar backtesting engine whose purpose is trustworthy accounting, not a profitable strategy. The worked example (an EMA(5/8) crossover on daily AAPL) deliberately reports a negative result: after realistic costs it returns +6.7% against buy-and-hold's +134%. The value of the repo is that you can believe that number.

Everything below serves one goal: a result the engine produces must not be an artifact of lookahead or sloppy accounting. When changing code here, that constraint outranks performance, brevity, and convenience.

Repository status — read this first

The repo is complete and runs from a fresh clone. pip install -r requirements.txt && pytest -q gives 32 passing tests in well under a second. The README no longer overstates the contents; the drift table that used to live here is gone because every row was resolved.

Two things are worth knowing before you touch anything:

  • The published results are pinned as tests. tests/test_reference.py asserts the README's headline numbers against the bundled sample. They are golden values, deliberately — they guard the one documented worked example. If your change moves them, that is a bug or a finding, not a test to relax.
  • The notebook's saved outputs are now partly stale. notebooks/ ema_backtest.ipynb has not been re-executed since the package was built, and its walk-forward cell still prints the old, incorrect +60.20% (see below). Everything else in it reproduces. Re-running it needs jupyter and would rewrite ~500 KB of embedded plot data, so it was left alone.

The walk-forward correction

The notebook reported a walk-forward out-of-sample return of +60.20%. That number is wrong and the code here produces +49.99%.

It was not reproducible from the notebook's own output: the four per-fold returns it prints (+0.197, −0.077, +0.170, +0.161) compound to +50.08%. Every per-fold value — chosen spans, in-sample Sharpe, OOS Sharpe, OOS return — reproduces exactly, and oos.index matches (buy-and-hold over those bars is +58.27% both before and after). So the fault was isolated to how the fold segments were joined: concatenating equity levels from separate runs splices a jump in at every seam and manufactures return no trade earned. walk_forward now compounds each fold's growth factor onto the last, which is why the docstring says so explicitly.

The correction moves the result against the strategy: walk-forward now underperforms buy-and-hold (+49.99% vs +58.27%) where the notebook had it tying. That is the direction this repo expects — if a fix ever makes the strategy look better, suspect the fix.

If you change what is published, update the README status note and this section in the same commit. A README that overstates the contents is the one failure mode this project cannot afford.

Layout

backtester/
  __init__.py          the public surface; everything is re-exported from here
  data.py              load_csv / load_sample / load_yfinance / generate_gbm
  strategy.py          Strategy base class + EMACrossover
  engine.py            CostModel, Fill, Trade, BacktestResult, run_backtest
                       — the load-bearing file; read it before changing anything
  metrics.py           Sharpe, drawdown, turnover, compute_metrics, reconcile_pnl
  analysis.py          buy_and_hold, compare_costs, parameter_grid, walk_forward
tests/
  test_lookahead.py    4 tests — the information barrier (the centerpiece)
  test_accounting.py   12 tests — cash identity, cost direction, flips, clipping
  test_reference.py    16 tests — the published numbers, pinned as golden values
data/
  sample_prices.csv    bundled AAPL daily OHLCV, 1,259 bars (runs offline)
notebooks/
  ema_backtest.ipynb   end-to-end narrative with saved outputs
requirements.txt       pinned runtime + test deps
README.md              the writeup, the results, and the status note

engine.py moved from the repo root into the package when it was built (a pure git mv, zero content change). There is no root-level engine module any more — import from the package.

How run_backtest works

run_backtest(prices, target, cost_model=None, initial_cash=100_000.0, max_leverage=1.0, allow_short=True) -> BacktestResult

  • prices: DataFrame with at least Open and Close, DatetimeIndex.
  • target: Series of target position fractions of equity. target[t] is decided at the close of bar t.

The no-lookahead barrier

This is the load-bearing design decision. The loop in engine.py:139-200 runs in a fixed order per bar i:

  1. Trade first — act on pending, the target decided at the close of bar i-1, filling at opens[i].
  2. Mark equity at closes[i].
  3. Then read this bar's decision: pending = tgt[i], and snapshot prev_equity / prev_close.

tgt[i] is read after the fill for bar i has already happened, so a decision can only ever be executed on the following bar. There is exactly one place in the code where a decision becomes a fill (engine.py:146), and it is structurally incapable of seeing the future. Verified empirically: a target that first goes long at bar t leaves position[t] == 0 and establishes the position at bar t+1; scrambling all prices after bar k leaves every position through bar k bit-identical.

Do not reorder these three steps. Do not vectorise the loop. The comment at the top of engine.py says the loop is explicit so the order of operations is auditable — that is the reason, and it is worth more than the speed.

Position sizing

desired_units = pending * prev_equity / prev_close — sized on the previous bar's equity and close, then filled at the current bar's open. Two consequences that are correct but surprise people:

  • Realized exposure differs from the target whenever there is an overnight gap. A verified case: target 1.0, close 100 → open 110 yields exposure 1.111, not 1.0. max_leverage caps the target, not the realized exposure.
  • The engine only trades when the target changes (abs(pending - held_target) > _EPS). It does not rebalance to the target every bar. Trade counts and turnover therefore reflect real signal activity, not accounting drift. A step target that goes long once produces exactly one fill.

Positions are fractional units — there is no share rounding.

Frictions

All three always work against you; that is the honest direction, and it is not a knob to soften.

  • half_spread_bps + slippage_bps combine into CostModel.adverse, applied as open * (1 + direction * adverse) — you buy higher and sell lower.
  • commission_bps (per notional) + commission_fixed (per fill).
  • CostModel.frictionless() zeroes all four — used for gross-vs-net comparison and in tests where costs would obscure the property under test.

Accounting

Cash is the single source of truth. cash is decremented by the real cash flow of every fill plus commission; equity = cash + position * close holds exactly at every bar (verified: max error 0.0).

Average-cost basis, with commission folded into the per-unit price (buy_unit = fill + fee_per_unit, sell_unit = fill - fee_per_unit), so Trade.pnl is net of all frictions. The reduce/close/flip branch (engine.py:168-191) handles a position flip by booking a closed Trade for the portion that closes and opening a fresh position with the remainder — a long→short flip is 1 fill and 1 recorded trade.

If you touch this branch, re-verify that equity == cash + position * close still holds exactly and that flips still book exactly one trade.

Gotchas verified in this codebase

  • BacktestResult.target is the raw, pre-clip series. Clipping to max_leverage / allow_short happens on the internal tgt array only (engine.py:113-114), and the unclipped Series is what gets returned (engine.py:209). Passing target=3.0 with max_leverage=1.0 returns a .target of 3.0 while .exposure correctly caps at 1.0. Read .exposure, not .target, when you want what was actually traded.
  • Bar 0 never trades (i > 0 guard). The earliest possible fill is bar 1.
  • A misaligned target index is silently reindexed and zero-filled (engine.py:109-111). A target covering only part of the price index becomes flat everywhere else with no warning.
  • The notebook lives in notebooks/, which is what its sys.path.insert(0, os.path.abspath("..")) has always assumed. It was moved there when the package was built; before that the line pointed at the repo's parent. Don't move it back to the root without fixing that line.
  • _EPS = 1e-12 guards both the target-change check and the position-flat check. Don't replace it with exact == 0.0 comparisons.

Three calibration decisions worth not undoing

The package was reconstructed to reproduce the notebook's saved outputs, and three details had to be recovered by matching numbers rather than read off any spec. They look arbitrary and are not. Each was confirmed by an exact match on several independent metrics at once.

  1. EMACrossover forces the first slow bars flat. With adjust=False both EMAs are seeded from the same single close, so early on their difference measures how fast each escaped that shared seed — a warm-up artifact, not a crossover. Without the mask the sample produces 120 fills instead of 119, and every return metric drifts. With it, gross/net return, Sharpe, drawdown, win rate and final equity all match to 4 dp simultaneously.
  2. buy_and_hold does not route through run_backtest. A constant target of 1.0 sizes on the previous bar's close and fills at this bar's open, so an overnight gap leaves residual cash — a benchmark quietly levered by the size of the gap (it reads +134.78% instead of +134.32%, and a deeper drawdown). The benchmark instead spends exactly the cash on hand at the first tradeable open, paying the same frictions.
  3. ann_turnover_x is total notional over mean equity, annualised by periods_per_year / n_bars. Per-fill equity in the denominator is defensible and gives 47.78 rather than the published 46.52.

The package API

from backtester import (
    load_sample, load_csv, load_yfinance, generate_gbm,   # data.py
    Strategy, EMACrossover,                                # strategy.py
    CostModel, run_backtest,                               # engine.py
    compute_metrics, sharpe_ratio, max_drawdown,
    infer_periods_per_year, reconcile_pnl,                 # metrics.py
    buy_and_hold, compare_costs, parameter_grid, walk_forward,  # analysis.py
)

Contracts:

  • load_sample() / load_sample("AAPL") → OHLCV DataFrame, DatetimeIndex named Date, columns Open, High, Low, Close, Volume, all float. Bundled AAPL daily, 1,259 bars, 2013-02-08 → 2018-02-07, extracted from all_stocks_5yr.csv in the public plotly/datasets mirror. Split- but not dividend-adjusted. All loaders return float columns — the lookahead tests scale whole row slices by a float array, which an integer Volume column would reject.
  • load_csv(path) — any CSV with Date, Open, High, Low, Close.
  • load_yfinance(sym, start=...) — optional dep, commented out in requirements.txt.
  • generate_gbm(n=..., seed=...) → same frame shape; every column must be numeric, since tests multiply .iloc[k+1:, :] wholesale.
  • EMACrossover(fast, slow).generate_signals(prices) → Series of target fractions in [-1, 1] on the price index. Causal: EMAs use ewm(span=..., adjust=False) over closes only. First slow bars forced flat (see calibration note 1). Raises ValueError if fast >= slow.
  • infer_periods_per_year(index) → 252 for daily bars.
  • compute_metrics(result) → dict keyed exactly: total_return, cagr, sharpe, ann_vol, max_drawdown, win_rate, n_trades, ann_turnover_x, final_equity.
  • sharpe_ratio(returns, periods_per_year) → float. rf = 0, ddof=1, annualized by √252.
  • max_drawdown(equity) → negative float.
  • reconcile_pnl(result) → dict {cash_residual, trade_residual}, both ≈ 0; also prints fill and trade counts. It rebuilds the equity curve two independent ways and checks both against zero — keep that property.
  • buy_and_hold(prices, cost_model) → equity Series, fully invested at the first tradeable open (see calibration note 2).
  • compare_costs(prices, target) → DataFrame indexed by the metric names above, columns before_costs, after_costs, delta.
  • parameter_grid(prices, fasts, slows, metric="sharpe") → DataFrame, rows = fasts, cols = slows, may contain NaN where fast >= slow.
  • walk_forward(prices, fasts, slows, n_splits=5)(summary, oos). Bars are cut into n_splits + 1 blocks via np.linspace(0, n, n_splits + 2).astype(int); folds run range(2, n_splits+1), so n_splits=5 on 1,259 bars yields 4 rows with train_bars 419/629/839/1049 and test_bars 210. summary columns: fold, train_bars, test_bars, chosen_fast, chosen_slow, in_sample_sharpe, oos_sharpe, oos_return. Each fold runs through the end of its test block and scores only that window, so the book carries in rather than being flattened at every boundary. oos compounds fold growth factors — do not concatenate raw equity levels, which is exactly the bug that produced the old +60.20%.

Reference numbers (from the notebook's saved outputs)

Use these to check that a rebuild reproduces the published story. AAPL daily, 2013-02-08 → 2018-02-07, 1,259 bars, EMA(5/8) long/short, default CostModel.

before costs after costs buy & hold
total return +11.95% +6.73% +134.32%
Sharpe 0.213 0.170 0.85
max drawdown −41.13% −42.97% −32.08%
win rate 31.36% 30.51%
trades 118 118 1
ann. turnover 46.5× 46.6× 0

Also: 119 fills; daily-return t-stat 0.38 (cannot reject mean = 0); parameter grid Sharpe mean 0.14, min −0.48, max +0.71, 53% positive; walk-forward picks (3, 89) in every fold, with in-sample Sharpe 0.851/0.962/0.572/0.658, OOS Sharpe 1.060/−0.273/1.130/0.996 and OOS returns +0.197/−0.077/+0.170/+0.161.

Walk-forward OOS total is +49.99% against buy-and-hold's +58.27% over the same bars. The notebook's saved +60.20% is wrong — see the walk-forward correction above.

Every number in the table plus the fill/trade counts and the buy-and-hold row is asserted in tests/test_reference.py, so pytest -q is the regression test. A change that moves them is either a bug or a finding that needs to be stated explicitly.

Development

Environment

pip install -r requirements.txt   # numpy, pandas, matplotlib, pytest

Pins matter: numpy>=1.26,<3.0, pandas>=2.0,<3.0, pytest>=8.0,<9.0. The notebook was authored on Python 3.12 (per its language_info); engine.py is verified working on 3.11 with numpy 2.4 / pandas 2.3. It uses from __future__ import annotations and typing.List, so it carries no syntax requirement beyond what the pinned numpy needs.

Running things

pytest -q                            # 32 tests, ~0.5s
pytest -q tests/test_lookahead.py    # just the information barrier
pytest -q tests/test_reference.py    # just the published-number regression

Run from the repo root — there is no packaging metadata (no setup.py or pyproject.toml), so backtester is importable because the root is on sys.path, and data/sample_prices.csv is found relative to the package directory's parent. A quick end-to-end check:

import numpy as np
from backtester import load_sample, EMACrossover, CostModel, run_backtest, reconcile_pnl

prices = load_sample("AAPL")
target = EMACrossover(5, 8).generate_signals(prices)
net = run_backtest(prices, target, CostModel())

assert np.allclose(net.equity, net.cash + net.position * prices["Close"])
reconcile_pnl(net)   # both residuals ~0; prints fill and trade counts

Testing conventions

Two suites are property-based — they assert structural invariants that must hold for any input, never numbers that happened to come out of one run:

  • test_lookahead.py — a signal at bar t moves nothing until t+1; scrambling prices after bar k leaves signals and positions through k unchanged; a flat target never trades.
  • test_accounting.pyequity == cash + position * close exactly at every bar; reconcile_pnl residuals ≈ 0; frictions are monotonically adverse; buys fill above the open and sells below; a flip is 1 fill and 1 trade; clipping binds on .exposure not .target.

One suite is deliberately golden-value:

  • test_reference.py — the published worked example, pinned. Scoped to that one documented result; do not grow it into a general correctness net.

New tests go in the first category by default: perturb the future and assert the past is untouched, or assert an accounting identity holds exactly. Use CostModel.frictionless() when costs would obscure the property, and fixed seeds for any generated data. generate_gbm(n=..., seed=...) is the standard fixture.

Style

Match engine.py: from __future__ import annotations, dataclasses for value types, type hints on public functions, NumPy-style docstrings, ~100-char lines, module-level constants like _EPS. Comments explain why (# Real cash flow, # Flip: remainder opens a fresh position on the other side), not what. Keep the hot loop on plain NumPy arrays rather than .iloc access.

Commits

Recent history uses Conventional Commits with optional scope:

docs: state what is actually published in this repository
build: pin runtime and test dependencies
test(engine): move lookahead suite into tests/ to match README

Follow that. Earlier commits (Add files via upload) are GitHub web-UI defaults and are not the convention.

There is no CI configuration in this repo — no .github/. Nothing runs the tests automatically.

Working norms for this repo

  • Never weaken an invariant to make a number look better. Costs work against the trader; the fill happens on the next bar; cash reconciles exactly. If a change makes results look better, suspect the change first.
  • State negative results plainly. The whole repo exists to demonstrate that. If a change makes the strategy look worse, or a test reveals a flaw, report it directly.
  • Keep the README honest. If you add or remove published code, update the status note and the drift table above in the same commit.
  • Don't commit the notebook with cleared outputs — the saved outputs are currently the only record of the published results.