Lab notebook · chapter 2 of 7 · page 1 of 3

Backtesting and costs

Timing, the shared risk gate, the execution model, and spreads measured from live quotes.

Backtesting (milestone 4)

python backtest.py

Runs on a synthetic random walk, so it works with no data at all. Those numbers are meaningless by construction — the walk has no structure to find.

For real bars without waiting on IBKR entitlements (prices back-adjusted for splits and dividends; volume left raw, see the module docstring):

python fetch_yahoo.py --years 2 --out data/bars.csv

Once IBKR entitlements exist, that is the source of record:

python fetch_bars.py --port 4002 --years 3 --out data/bars.csv
python backtest.py --bars data/bars.csv

Timing convention

A strategy decides at the close of day t using data through day t, and its orders execute against day t+1's bar.

The first half is enforced structurally: MarketView is pinned to one date and has no method that returns a later bar, so look-ahead is not something to remember to avoid. test_no_attribute_exposes_the_full_series fails if a refactor ever hands a strategy the whole series.

The second half falls out of the engine: accepted proposals sit in pending and are only touched on the next iteration. Unfilled day orders expire, which matches the tif="DAY" the live broker sends.

The replay uses the same gate

replay() calls the same evaluate() as the live path, with a DU-REPLAY account id to satisfy the paper-prefix check. A backtest that gates differently from production is testing something other than what will run.

Execution model

Three assumptions, all configurable, ordered by how much they were measured to matter:

Assumption Default Effect
half_spread_bps 1.5bps charged whenever an order takes liquidity
max_participation 10% of bar volume large orders fill partially
slippage_bps 2bps impact, market orders only
require_through_limit True a touch is not a fill

Crossing is not free. A market order, or a limit priced at or through the bar's open, takes liquidity and pays half_spread_bps. A limit resting away from the market pays nothing — it provides liquidity, and only fills when price comes to it. Until this existed, momentum posted marketable limits 0.2% through the market and crossed 6,680 times at exactly zero modelled cost, at ~29x annual turnover.

Sweeping the assumption against equal-weighting the same 111 names:

half-spread return vs SPY vs equal-weight
0.0bps +392.9% +130.2% +68.6%
1.5bps (default) +367.9% +105.3% +43.6%
5.0bps +316.8% +54.1% −7.5%

Momentum's edge over equal-weight is roughly the size of the cost assumption, and inverts by 5bps. Combined with an information ratio of 0.14 (t=0.42 over 9.2 years), the excess over equal-weight is not established. The advantage over SPY is more robust, but SPY is the wrong comparison for a survivorship- biased universe.