Guidance for AI assistants working in this repository.
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.
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.pyasserts 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.ipynbhas 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 needsjupyterand would rewrite ~500 KB of embedded plot data, so it was left alone.
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.
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.
run_backtest(prices, target, cost_model=None, initial_cash=100_000.0, max_leverage=1.0, allow_short=True) -> BacktestResult
prices: DataFrame with at leastOpenandClose, DatetimeIndex.target: Series of target position fractions of equity.target[t]is decided at the close of bart.
This is the load-bearing design decision. The loop in engine.py:139-200 runs
in a fixed order per bar i:
- Trade first — act on
pending, the target decided at the close of bari-1, filling atopens[i]. - Mark equity at
closes[i]. - Then read this bar's decision:
pending = tgt[i], and snapshotprev_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.
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_leveragecaps 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.
All three always work against you; that is the honest direction, and it is not a knob to soften.
half_spread_bps+slippage_bpscombine intoCostModel.adverse, applied asopen * (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.
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.
BacktestResult.targetis the raw, pre-clip series. Clipping tomax_leverage/allow_shorthappens on the internaltgtarray only (engine.py:113-114), and the unclipped Series is what gets returned (engine.py:209). Passingtarget=3.0withmax_leverage=1.0returns a.targetof 3.0 while.exposurecorrectly caps at 1.0. Read.exposure, not.target, when you want what was actually traded.- Bar 0 never trades (
i > 0guard). The earliest possible fill is bar 1. - A misaligned
targetindex 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 itssys.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-12guards both the target-change check and the position-flat check. Don't replace it with exact== 0.0comparisons.
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.
EMACrossoverforces the firstslowbars flat. Withadjust=Falseboth 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.buy_and_holddoes not route throughrun_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.ann_turnover_xis total notional over mean equity, annualised byperiods_per_year / n_bars. Per-fill equity in the denominator is defensible and gives 47.78 rather than the published 46.52.
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 namedDate, columnsOpen, High, Low, Close, Volume, all float. Bundled AAPL daily, 1,259 bars, 2013-02-08 → 2018-02-07, extracted fromall_stocks_5yr.csvin the publicplotly/datasetsmirror. Split- but not dividend-adjusted. All loaders return float columns — the lookahead tests scale whole row slices by a float array, which an integerVolumecolumn would reject.load_csv(path)— any CSV withDate, Open, High, Low, Close.load_yfinance(sym, start=...)— optional dep, commented out inrequirements.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 useewm(span=..., adjust=False)over closes only. Firstslowbars forced flat (see calibration note 1). RaisesValueErroriffast >= 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, columnsbefore_costs, after_costs, delta.parameter_grid(prices, fasts, slows, metric="sharpe")→ DataFrame, rows = fasts, cols = slows, may contain NaN wherefast >= slow.walk_forward(prices, fasts, slows, n_splits=5)→(summary, oos). Bars are cut inton_splits + 1blocks vianp.linspace(0, n, n_splits + 2).astype(int); folds runrange(2, n_splits+1), son_splits=5on 1,259 bars yields 4 rows withtrain_bars419/629/839/1049 andtest_bars210.summarycolumns: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.ooscompounds fold growth factors — do not concatenate raw equity levels, which is exactly the bug that produced the old +60.20%.
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.
pip install -r requirements.txt # numpy, pandas, matplotlib, pytestPins 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.
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 regressionRun 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 countsTwo 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 bartmoves nothing untilt+1; scrambling prices after barkleaves signals and positions throughkunchanged; a flat target never trades.test_accounting.py—equity == cash + position * closeexactly at every bar;reconcile_pnlresiduals ≈ 0; frictions are monotonically adverse; buys fill above the open and sells below; a flip is 1 fill and 1 trade; clipping binds on.exposurenot.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.
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.
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.
- 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.