What I was seeing
I run two paper portfolios side by side. One is an equal-weight baseline (v1_baseline) that just holds everything on my approved list at the same size. The other is a regime-conditional allocator (v2_forge) that reads the current market regime, looks up which strategies have historically done well in that regime, and sizes them larger. The forge is supposed to beat the baseline because it concentrates on the right tool for the current condition.
The baseline had drifted to -18% net of fees. The forge had drifted to -44% net of fees. That's the wrong direction for a strategy that's supposed to be smarter.
Dropping $23 a day for weeks with a paper portfolio is not itself the crisis. The crisis is not knowing why.
Finding the offender
I pulled the last 100 forge trades and grouped by strategy. One row jumped out.
gene: D_long_cci_20_-160_williams_r_-85_tp1.25_b100
last 100 trades: 34 trades, 7 wins, -$90.23
The forge had made 100 trades in the recent window, and one of my ~5 rotating strategies had lost $90 of $125 of aggregate loss. Everything else was net near zero. So really I had one problem, not a portfolio problem.
I pulled that strategy's full record from the research database.
status: graduated
specialist_regime: ranging_low_vol_long
bt_wr: 1.00 -- backtest win rate: 100%
bt_sharpe: 7.67
bt_dsr_prob_fluke: 0.0055 -- Deflated Sharpe said only 0.55% chance of luck
fw_trades: 0 -- forward tester never touched it
The backtest showed 31 wins in 31 attempts. The forward record showed 7 wins in 34 attempts. My promotion gate had said graduated. It had been trading real paper P&L for six days.
So I queried the whole table
SELECT COUNT(*) FROM genes WHERE status='graduated'; -- 5,778
SELECT COUNT(*) FROM genes WHERE status='graduated' AND bt_dsr_prob_fluke=1.0; -- 3,926
SELECT COUNT(*) FROM genes WHERE status='graduated' AND fw_trades=0; -- 5,777
SELECT COUNT(*) FROM genes WHERE status='graduated' AND bt_trades IS NULL; -- 5,772
- 5,778 strategies currently approved
- 3,926 of them have Deflated Sharpe fluke probabilities of exactly 1.0 (meaning: definitely a fluke)
- 5,777 have never had a single forward trade recorded against them
- 5,772 don't even have a backtest trade count on file
Whatever "graduated" meant in this database, it did not mean "we checked this."
How it broke
Every generalist promotion lane in my code required probFluke < 0.5. I'd fixed that months ago after a prior audit. But there was a fourth lane I added later called the specialist path: a strategy could graduate not on aggregate performance but on winning in one specific market regime.
The rationale sounded fine at the time. Some strategies are mean-reversion tools that only work when the market is ranging. Their aggregate walk-forward performance looks mediocre because half the sample is trending and they lose there. But route them by regime and they earn their keep. The specialist lane was supposed to give those tools a home.
The specialist lane checked three things:
- The winning regime had at least 30 trades in validation
- Average P&L in that bucket above 0.5%
- Win rate at least 55%
- A z-score against a 50% coin-flip null of at least 1.645
What it did not check was aggregate Deflated Sharpe. The comment I wrote at the time said "DSR was computed on aggregate, doesn't apply to specialists." That comment is wrong. If your aggregate Sharpe is indistinguishable from noise, a regime-conditional slice of the same noise is not evidence of edge. It is a smaller, luckier bucket of the same noise.
Of the 3,926 fluke-flagged strategies I had approved, 3,925 came through the specialist lane. That is not a leak. That is the entire door.
The 30-trade minimum with z ≥ 1.645 was also too weak. A genuine 55% win-rate strategy has roughly a 30% chance of showing at least 66.7% in 30 trades by luck alone. My gate was accepting one in three of those.
Two other things I found while I was in there
Once I started looking at the metadata, two more bugs surfaced.
bt_trades was never being written. The INSERT statement in my graduation code listed every backtest column except that one. Any downstream check that filtered on trade count was silently no-op-ing on NULL. This is the kind of bug you find by reading your own writes carefully, which I did not do often enough.
Forge trades were not decaying their genes. My daily calibration job pulls forward-test performance from a canonical trades table and updates each strategy's fw_trades, fw_avg_pnl, fw_wr. That job filtered WHERE engine = 'v2_adaptive'. It missed the forge engine entirely, because the forge does not write to the canonical trades table. It keeps its own state file. So even when a strategy accumulated 34 losing paper trades in the forge, the metadata the decay logic reads still said fw_trades = 0. The kill code could not fire. The strategy stayed graduated forever.
Combine those two bugs with the specialist lane leak and the entire feedback loop was open. Fluke backtests graduated, ran in the forge with no feedback into the decay logic, and lived forever.
What I did
Four code changes.
- Added
probFluke < 0.5to the specialist lane. Same threshold as every other lane. No carve-out. - Raised the specialist minimum from 30 trades to 60, and the z-bar from 1.645 to 2.0. At 60 trades and z=2.0 the false-positive rate against a 55% WR null is roughly 2%, versus roughly 30% at the old settings.
- Added
bt_tradesto the graduationINSERT. - Expanded the calibration job's engine filter to include the forge and my personal book, and added a reader for the forge's own state file since it does not go through the canonical trades table.
Then I re-ran the corrected gate against the existing 5,778 approved strategies as a one-shot retirement pass. 4,367 retired. 1,413 remained. The specific losing strategy that started this investigation retired via the specialist-too-thin rule (31 backtest trades is under the new 60 minimum).
Retirement is not deletion. Every retired row has status='retired', killed_at=<now>, and kill_reason='phase30_R1_dsr_fluke+R2_specialist_thin[t=31,z=5.57]' or similar. The audit trail is intact and the row is still queryable.
I also paused the auto-mutation lane (the cron job that generates new candidate strategies) for a verification window. I do not want to be shoveling new candidates into a gate I just changed until I've watched a week of clean data.
What this means if you run a similar system
The bug pattern is the same as one I wrote up in May. A gate I designed drifted from the gate I was running, because I kept adding lanes for good reasons and never audited the union. The specialist lane was individually defensible. It was also the entire failure mode.
Two things helped me find this that I wish I'd done earlier.
The first is counting. Not looking at examples, not looking at the top of the sort. Just SELECT COUNT(*) FROM approved_things WHERE fluke_check_failed. That number is either small (whatever your threshold is) or it is a story. Mine was 68% of the pool.
The second is following a single loss all the way back. The forge told me it was down $47 in a day. I could have adjusted a threshold and moved on. Instead I asked which trades, which strategy, which decision path, which gate, which comment. Six hops back the story was "I made an exception for one lane four months ago, and 3,925 fluke backtests walked through it."
If your promotion gate has any lane that skips a check the other lanes enforce, and there is a comment explaining why, read the comment carefully. Mine did not survive it.