Engineering

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.

April 7, 2026
11 min read
#engineering#trading-bot#debugging
The Gate Chain Problem: Why Your 'Done' Fix Didn't Actually Fix It⊕ zoom
Share
Visual Summary
click to expand
Article visual summary

Yesterday 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, and regime_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

BTC 15m chart April 7 2026 — breakout from 69300 to 71950 with annotation marking the 19:00 ET signal⊕ zoom

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

Sequence diagram showing the V1 STRONG signal flowing through Indecision Guard, Stability Gate, and Regime Classifier — each one blocking entry⊕ zoom

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

INSIGHT

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:

  1. Unit tests can't catch it. Unit tests drive the unit directly. They bypass the very gates that cause the failure.
  2. Code review can't catch it. Reviewers see the diff. They don't see the upstream guards from three PRs ago that nobody touched.
  3. CI can't catch it. Regression suites pass because nothing about the existing gate behavior changed. The new code path is just never reached.
  4. 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

Seven-step gate chain audit checklist for any change to a gated pipeline⊕ zoom

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:

  1. Locate the entry point. Find the function where your new code is consumed.
  2. Walk the call stack upward. List every guard, filter, gate, and early-return between the input and your code.
  3. 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?
  4. Identify the silencers. Any gate that returns or early-exits before your code is a silent killer.
  5. Add an integration test, not just a unit test. Drive the entry point with realistic state. Assert your code actually runs.
  6. 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.
  7. 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.

Go deeper in the AcademyOperator

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

// Join the Network

Follow the Signal

If this was useful, follow along. Daily intelligence across AI, crypto, and strategy — before the mainstream catches on.

No spam. Unsubscribe anytime.

Share
// More SignalsAll Posts →