The Gate Chain Problem: Why Your 'Done' Fix Didn't Actually Fix It
Yesterday I shipped 3 PRs to my trading bot. Tests passed. CI green. Merged to main. Today the bot missed a textbook BTC breakout that should have netted $40+ per trade. Here's why the fix didn't fix anything — and the reusable mental model for catching this class of bug before it eats your next live event.
⊕ zoomYesterday I shipped three PRs to my live perpetual-futures bot.
Tests passed. CI green. Code review clean. Merged to main. I closed the laptop feeling pretty good about the new wiring — the bot was finally consuming the new InDecision Framework fields I'd been waiting on for weeks (breakout_context, multi_tf_override, regime, regime_oversold).
Today, April 7 2026 at 19:00 ET, BTC broke out from $69,300 to $71,950 — a textbook +3.8% move over two hours. ETH and SOL went with it. ADX on BTC was 43. ADX on SOL was 50. MACD bullish cross. Volume expansion. The kind of setup the bot was literally built for.
Zero entries. Not one trade.
The bot detected it — I have the log lines to prove it. It just couldn't act on it. And not because the new code I shipped yesterday was broken. The new code never ran.
This is a post about why "tests pass + CI green + merged" is a lie that will eat your live system, and the reusable mental model I now use to keep it from happening again.
The setup: three PRs that looked done
Here's what I shipped on April 6:
- PR #104 wired four new InDecision API fields into the V1 PolyEdge signal engine:
breakout_context.active,multi_tf_override.active,regime, andregime_oversold. The idea: when InDecision was already screaming "this is a breakout," the bot should be more aggressive. - PR #105 used the same fields to apply structural bias boosts in the V2 engine.
- PR #106 added an ETH-specific 3-candle stop-loss cooldown plus a minimum score gate of 80.
The diffs were small. Each PR had unit tests covering the new behavior. The CI pipeline ran 1,100+ regression tests against the change. Everything green. Merged before dinner.
The bot was ready for the next breakout. That was the theory.
The breakout that should have triggered everything
At 19:00:09 ET, the V1 PolyEdge engine did exactly what it was supposed to do. From the actual log file:
19:00:09 STRONG: BTC/15m LONG | Score 85/100 | Mom=32 ATRExp=15 ADX=43
19:00:09 STRONG: SOL/15m LONG | Score 84/100 | Mom=40 ADX=50
19:00:09 STRONG: BTC/1h LONG | Score 84/100
19:00:09 15m confirmed 1h signal for BTC: LONG score=84 — entering
That last line is the kicker. The bot literally logged the word "entering" for a STRONG, multi-timeframe-confirmed BTC long during the cleanest breakout setup I'd seen all week.
Then it didn't enter.
The cascade: three gates fired in sequence
Forensic log analysis (not unit tests — log analysis) showed the signal getting silently strangled by three older guards that ran before any of yesterday's new code.
Gate 1 — The Indecision Guard
Inside signal_engine.py there's a guard that downgrades any STRONG signal when InDecision contributed zero points to the score. The reasoning was sound when I added it months ago: don't trust a STRONG signal that the upstream framework hasn't blessed.
At 19:00:08, the InDecision intra spread was sitting below the cache's noise floor of 5. So id=0. So the guard fired. STRONG → MODERATE. End of story.
The new breakout_context and multi_tf_override fields I'd wired in yesterday? InDecision didn't fire them until 19:02:20 — over two minutes later, when the price had already moved. By the time the new fields became active=true, the signal window was gone and the bot had moved on.
The new code wasn't broken. It was dead code at the moment that mattered.
Gate 2 — The Stability Gate
After the downgrade, the signal was now MODERATE. The stability gate in trader.py requires MODERATE signals to repeat in the next 5-minute eval cycle before allowing entry. Standard "don't act on a flicker" logic.
Except the score is built partially from a momentum factor that resets to zero on every candle close. The 85/100 score had Mom=32 baked into it. Six minutes later, on the next candle, the momentum factor was zero and the total score had collapsed to 49.
19:00:09 BTC/15m Score 85 Mom=32 → MODERATE (downgraded)
19:01:14 BTC/15m Score 78 Mom=24 → still gated
19:03:14 BTC/15m Score 65 Mom=12 → still gated
19:05:14 BTC/15m Score 49 Mom=0 → signal abandoned
The stability gate cannot — mathematically cannot — fire on a momentum-driven score. Two correct components composed into a broken whole. (I wrote a separate post on this exact failure mode, because it deserves its own math: Stability Gates Will Kill Your Momentum Strategy.)
Gate 3 — The V2 Regime Classifier
In parallel, the V2 engine was running its own evaluation. It calls the regime classifier first. The classifier looked at the high ATR percentile (volatility was expanding!) and returned regime=crisis. Every V2 long was skipped with SKIP=regime_crisis.
The classifier was looking at one dimension — volatility — and treating "high vol" as universally dangerous. ADX 43 on a clean trending breakout is the opposite of crisis. It's the signal you want to trade. (Also a separate post: Volatility Is Not Crisis.)
Three gates. Three independent failures. All firing upstream of yesterday's new code.
The investigation: logs, not tests
Here's the part that should haunt every engineer running gated pipelines: the unit tests for yesterday's PRs all still pass.
They pass because they test the new code paths in isolation. They mock the gate decisions or skip the gates entirely to drive the unit under test. That's normal. That's what unit tests do.
What they don't do is answer the question "in a real run with real upstream state, does my new code ever get reached?"
Nothing in the pipeline answered that question. Not the unit tests, not CI, not my code review, not CodeRabbit's pass. The only thing that caught it was me opening TradingView, seeing the BTC breakout, opening the bot logs, and grepping for entering followed by silence.
The diagnosis took 90 minutes. The fix took the rest of the afternoon. The bot was paralyzed for the entire move.
The fix: PRs #110, #111, #322
I shipped three emergency PRs the same day, all merged and validated against a replay of the April 7 19:00 cascade.
PR #110 (Leverage) — Bypass the Indecision Guard and the Stability Gate when any of three conditions hold:
# trader.py:914-922 — and again at :2227 (intra-candle path)
def _should_bypass_indecision_guard(score, breakout_context, multi_tf_override):
if score >= 85:
return "extreme_score"
if breakout_context and breakout_context.get("active"):
return "breakout_active"
if multi_tf_override and multi_tf_override.get("active"):
return "multi_tf_override"
return None
bypass_reason = _should_bypass_indecision_guard(score, bc, mtf)
if bypass_reason:
log.info(f"INDECISION_GUARD_BYPASSED: reason={bypass_reason}")
else:
if id_contribution == 0 and signal == "STRONG":
signal = "MODERATE" # original guard
Critical detail: there are two copies of the Indecision Guard logic in trader.py — one for the candle-boundary path, one for the intra-candle path. I missed the second copy on my first attempt at the fix and the cascade still reproduced. Always grep your codebase before assuming a guard lives in one place.
PR #322 (Foresight) addressed a parallel paralysis on the prediction-market bot, where a stale intra-spread cache was promoting a stale daily bias during ATR expansion. New helper:
def _is_atr_expanding(regime, current_atr):
history = regime._atr_history # deque, MarketRegimeDetector
if len(history) < 5:
return False
sma = sum(history) / len(history)
return current_atr > 1.2 * sma
When ATR is expanding, block daily promotion entirely. Keep intraday primary. Log ATR_EXPANDING: promotion blocked.
PR #111 (Leverage) rebuilt the regime classifier with a directional check (covered in the Volatility post linked above).
All three merged. All three replay-validated. The bot is running new code on Tesseract right now. But the lesson isn't in the diff — it's in the mental model that would have caught this on April 6 instead of April 7.
The principle: trace the gate chain backwards
When you wire new code into a gated pipeline, the question isn't "does my new code do the right thing." It's "does anything upstream silently disable my new code before it runs?" Trace the gate chain backwards from the entry point. Every guard that fires before your reads is a potential silencer.
This is a class of failure I'm now calling gate chain dead code — code that exists, that has tests, that ships in green CI, but is functionally inert because an older guard short-circuits the path.
It's insidious because:
- Unit tests can't catch it. Unit tests drive the unit directly. They bypass the very gates that cause the failure.
- Code review can't catch it. Reviewers see the diff. They don't see the upstream guards from three PRs ago that nobody touched.
- CI can't catch it. Regression suites pass because nothing about the existing gate behavior changed. The new code path is just never reached.
- The system "works." Right up until the one moment it matters.
The same pattern shows up in any gated system: feature flag checks before business logic, rate limiters before handlers, auth middleware before route logic, competitive intelligence signal scoring before publication. Anywhere a sequence of guards runs before your code, you have potential dead code.
The reusable checklist
I now run this checklist before merging any change to trader.py, signal_engine.py, or any other heavily-gated file in either trading bot:
- Locate the entry point. Find the function where your new code is consumed.
- Walk the call stack upward. List every guard, filter, gate, and early-return between the input and your code.
- For each gate, ask three questions. Can it fire on the input I expect? What state does it require? What does it skip on failure?
- Identify the silencers. Any gate that returns or early-exits before your code is a silent killer.
- Add an integration test, not just a unit test. Drive the entry point with realistic state. Assert your code actually runs.
- Add a bypass log line. When a bypass condition triggers, log it. You will need it during the next incident at 11pm on a Sunday.
- Validate against a real historical event. Find a past moment where your new code should have helped. Replay it. Assert the new path fires.
That last step is what saved me with the emergency PRs. I wrote a one-off replay_april7.py script that loaded the actual market state from 19:00 ET and ran it through the new code with real ADX, real ROC, real cache values. Five assertions. All five had to pass before I would merge:
# Excerpt from /tmp/replay_april7.py
def test_indecision_bypass_on_extreme_score():
state = load_april7_state(asset="BTC", tf="15m", t="19:00:09")
assert state.score == 85
decision = run_signal_engine(state)
assert decision.bypass_reason == "extreme_score"
assert decision.signal == "STRONG" # NOT downgraded
def test_regime_classifier_directional():
btc = classify(adx=43, roc=0.0013, atr_pct=92)
assert btc.state == "TREND_UP"
assert btc.allow_trend_following is True
assert btc.size_multiplier == 1.0
When all five passed, I merged. Not before.
The wider lesson
Every system that's been alive long enough has guards. Some of them were added to fix a real bug, some of them were defensive, some of them are now actively wrong. The longer the system has been alive, the more guards there are, and the more likely your new code is downstream of one that will silently kill it.
The finish line is a running system, not a merged PR. A green CI is a hypothesis that the code works. Only the live event proves it.
I lost a $40+ per-trade opportunity on April 7 because I treated yesterday's merge as the finish line. I don't anymore.
If you run a gated pipeline — trading bot, recommendation system, content moderator, anything — go look at your last three merges. For each one, walk backwards from the consuming function and list every guard upstream. If you can't account for what each one does on your input, you have dead code waiting for the moment it would have mattered.
Ship the audit. Then ship the fix.
The engineering patterns in this article are covered in the AI Infrastructure track — persistent platforms that run themselves. 11 lessons.
Start the AI Infrastructure track →Explore the Invictus Labs Ecosystem
Follow the Signal
If this was useful, follow along. Daily intelligence across AI, crypto, and strategy — before the mainstream catches on.

Claude Skills Have Three Layers. Most People Only Build One.
Prompt-engineering is already obsolete. The new unit of work is the skill — a folder with three layers, only one of which most people bother to build. The leverage lives in the layer they skip.

Your Claude Code Sessions Are Stateless. Your Engineering Discipline Shouldn't Be.
Every Claude Code session starts from zero — no memory of your standards, gates, or the three bugs that bit you last sprint. The Skills Library changes that. 19 slash commands. Institutional discipline, without the briefing.

Judgment Debt: The Hidden Cost of Agentic AI
AI coding agents don't just autocomplete — they plan, delegate, and decide. Most engineers haven't noticed the threshold they already crossed.