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.