Sprint 120 removed a safety rail. 41 trades later, I got the bill.
Sprint 120 removed a safety rail. 41 trades later, I got the bill.
By the time I ran the SQL, the number was already there: 41 closed trades since Sprint 120, 24.4% win rate, minus $217 net. Before that month, the system had been running 114 trades at 41.2% and +$963 profit. Nothing on the surface looked that different. The code I'd changed was eight lines.
The rule I'd removed was the one that stopped my trading system from going long when Bitcoin sat more than 2.5% above its 50-day moving average. My argument at the time: it was "blocking the most profitable zone." My data at the time: none. The code review didn't ask for data either, because I was both the author and the reviewer, which is what happens when you're one person.
What actually happened: the 2.5-4% band opened up. Eight long trades piled into it. Zero winners. Minus $292 straight to the floor. Pre-Sprint-120 that bucket did not exist in the data at all, because the rule had been blocking it. I had taken a working filter offline to find out what it was filtering. The answer was: losing trades.
The short side took a smaller hit. Win rate 48.3% down to 35.0%, but still net-positive. The long side collapsed direction-specifically: 33.9% to 14.3%. When I finally binned the trades by direction and distance bucket, the single removed rule was right there in the data. The column it had been protecting me against was now full of zeros.
I wrote the fix. Restored the 2.5% floor, for longs only this time, since the shorts weren't broken. Shipped it. Panel-reviewed across five rounds by three reviewer personas (Quant, Crypto Trader, Architect). It went out cleanly.
Then I started on the next sprint. A different bug: trailing stops that sometimes silently failed after hitting breakeven. Same review pattern, five more rounds. The spec said: fix it by using trade.risk_amount / trade.quantity instead of the current broken calculation. Ship it.
I nearly did.
Before hitting the button, I ran a pre-flight query against the production database. First on the list: SELECT COUNT(*) FROM trades WHERE risk_amount IS NULL. It came back:
sqlite3.OperationalError: no such column: risk_amount
The column didn't exist. Neither did quantity. The actual column is size. Five review rounds, three reviewer personas, and all of them had evaluated the logic flow of a fix that referenced fields that were not there. If I'd shipped that spec, trade.risk_amount would have returned None, the truthy guard would have skipped the fix, and trailing stop activation would have failed 100% of the time instead of the occasional silent failure it was supposed to solve. The fix would have made the bug worse.
Both of today's bugs had the same shape. One was a rule I'd removed on instinct. One was a fix I'd written against a model I hadn't checked. Both were assumptions that would have cost me something real if they'd reached production unchecked.
The lesson here is not "be more careful." That's useless advice. The actual point is this: every assumption about a live system has a verification cost, and the cost of checking is small compared to the cost of the assumption being wrong.
Before removing a rule, run the SQL that tells you what the rule has been doing. It's one query. In my case it would have been roughly SELECT COUNT(*), SUM(pnl) FROM trades WHERE direction = 'LONG' AND btc_sma50_distance >= 2.5 AND closed_at IS NOT NULL, and pre-Sprint-120 it would have returned zero rows, because the rule had been blocking those trades. That is itself an answer. Zero rows means you do not know what removing the rule does. "We've never seen this path open" is not the same as "this path is profitable."
Before shipping a fix that touches model attributes, grep for the field. Two seconds. If it returns nothing, the field doesn't exist. That's it. You've caught a class of bug (the "code references attribute that is not on the model") which is the same shape as two earlier sprints of mine. One missed five trailing-stop fields on the trade model. Another referenced opened_at when the column is created_at. Three strikes of the same mistake is the system telling you the check should be permanent.
I've now added a standing pre-flight step to the spec review template: before the logic review, do an existence check. Grep the model file. Run PRAGMA table_info on the table. Confirm the things you're about to rely on actually exist in the artefact that will run the code. Five minutes of work. Stops you shipping the opposite of what you intended.
If you're building something where a single bad assumption can put you sideways for a month, a trading system, a fraud detector, a billing engine, anything with a tight feedback loop between decisions and money, the discipline is not "be careful." It's "cheap verification before expensive commitment." The SQL is cheap. The grep is cheap. The bill for skipping them is not.