Engineering

Stability Gates Will Kill Your Momentum Strategy (Here's the Math)

Your stability gate is silently killing every breakout entry. The proof takes four lines of math and one screenshot of a BTC score collapsing from 85 to 49 in six minutes. This is not a tuning problem. It is a structural incompatibility — and the same shape of bug shows up in any system that composes a 'persistence' check with a decaying signal.

April 7, 2026
9 min read
#engineering#trading-bot#debugging
Stability Gates Will Kill Your Momentum Strategy (Here's the Math)⊕ zoom
Share
Visual Summary
click to expand
Article visual summary

Your stability gate is silently killing every breakout entry your bot makes.

I can prove it in four lines of math and one screenshot of a BTC score going from 85 to 49 in six minutes. This isn't a "tune the threshold" problem. It isn't a "wait for the next release" problem. It's a structural incompatibility between two components that look perfectly correct in isolation and produce a system that mathematically cannot fire when you need it most.

I learned this the expensive way on April 7 2026, watching my Leverage perpetual-futures bot detect a textbook BTC breakout, log a STRONG signal, and then sit on its hands for the entire +3.8% move because the stability gate was waiting for a confirmation that — by construction — could never come.

Here's the math, the receipt, and the fix.

What stability gates do (and why we add them)

Every trading system that runs on noisy data eventually adds a stability gate. The shape is always similar:

"Don't act on a signal until you've seen it twice in a row, or until it persists for N seconds."

The intent is good. You're filtering chop. You're refusing to chase a single flicker. You're saying "I trust patterns, not instants." It's the same instinct behind a debounce in a UI, a confirmation prompt before a destructive action, or a rate limiter that requires multiple intent signals before escalation.

In trading, the canonical implementation looks like this:

# trader.py — simplified
def evaluate(asset, timeframe):
    score, signal = signal_engine.score(asset, timeframe)

    if signal == "STRONG":
        return execute_entry(asset)

    if signal == "MODERATE":
        # stability gate: must repeat next cycle
        last = self._last_signal[asset]
        if last and last.signal == "MODERATE" and last.cycles_ago == 1:
            return execute_entry(asset)
        self._last_signal[asset] = SignalSnapshot(signal, cycle=now)
        return None

Looks reasonable. Is reasonable. In isolation.

What momentum scoring is

Now look at how the score gets built. Leverage uses a multi-factor model — eleven inputs, each scored 0-N, summed into a 0-100 composite. Here are three of those factors:

# signal_engine.py — simplified
score = 0
score += rsi_factor(candle)               # level-based, 0-15
score += trend_alignment_factor(candle)   # level-based, 0-20
score += momentum_factor(candle)          # delta-based, 0-40
# ... 8 more factors

The momentum factor is delta-based. It scores how much the price has moved within the current candle. If the candle is up 0.4% off the open, momentum scores high. If the candle just opened and the price is sitting at the open, momentum scores zero.

Here is the four-line proof I promised.

Let M(t) = momentum factor at time t.
Let c(t) = the index of the current candle at time t.
M(t) = f(price(t) - candle_open(c(t)))
At candle close → c(t+1) = c(t) + 1
                  → candle_open(c(t+1)) = price(t+1)
                  → M(t+1) = f(0) = 0

The momentum factor cannot persist across a candle boundary. The new candle's open is, by definition, the current price. The delta is zero. The factor resets.

Now compose those two correct components.

The composition failure

Score decay over six minutes — BTC 85 to 49 across a candle close that resets the momentum factor to zero⊕ zoom

Here's what happens when the stability gate runs against a momentum-driven score:

  • Cycle T: Big move inside the current candle. Momentum factor = 32. Total score = 85. Signal = STRONG. Stability gate ignores this branch (STRONG fires immediately) — but only if no upstream guard downgraded it. Mine downgraded it, which I covered in The Gate Chain Problem. So we proceed as MODERATE.
  • Cycle T+1 (5 minutes later): New candle has opened. Price hasn't moved much from the new open. Momentum factor = 12. Total score = 65. Signal = MODERATE. Stability gate: "Did MODERATE happen one cycle ago?" → yes. Wait, was the score the same? Some implementations check signal class only; mine checked score within tolerance. The score collapsed enough that the gate refused.
  • Cycle T+2: Price flat. Momentum factor = 0. Total score = 49. Signal = WEAK. Game over.

The stability gate is asking "is the signal still here?" of a score component that, by construction, cannot still be there. It's like a debounce on a button that is wired to reset itself before the debounce window ends. You will never observe the button as held.

WARNING

This is not a tuning problem. There is no value of cycles_required or tolerance that makes a momentum factor survive a candle reset. The factor is zero on the next candle. Math.

The receipt

This is not theory. This is what actually happened on April 7 at 19:00 ET, pulled from the live bot's log file:

Six-minute timeline of log lines from leverage.log showing the score collapse from 85 to 49⊕ zoom
19:00:09  STRONG: BTC/15m LONG | Score 85/100 | Mom=32 ATRExp=15 ADX=43
19:00:09  INDECISION_GUARD: id=0  STRONG → MODERATE
19:00:09  STABILITY_GATE: holding for next-cycle confirmation
19:01:14  BTC/15m re-eval → score 78  (still below STRONG)
19:03:14  BTC/15m re-eval → score 65
19:05:14  candle close → momentum factor reset to 0
19:05:14  BTC/15m re-eval → score 49  (mom=0)
19:06:14  STABILITY_GATE: signal abandoned. zero entries.

BTC moved from $69,300 to $71,950 during this exact window. The bot detected the breakout, downgraded it, waited for confirmation, watched the score decay precisely as the math says it must, and gave up.

Why the math is the proof

The thing I want to drive home is this: the math is the proof that no amount of testing or tuning can fix this.

If I'd shipped a bug fix that said "raise the stability tolerance," I'd be back here in two weeks with a different missed breakout. The structural problem is that the stability gate is a time-domain check imposed on a level-set assumption (signal persistence) when applied to a score that has delta-domain components (momentum) with a reset boundary condition (candle close).

The valid compositions are easy to enumerate.

2x2 matrix — score type vs gate type — showing one BROKEN cell and one FRAGILE cell⊕ zoom
"must repeat next cycle""must persist N seconds"
Level-based score (RSI, trend alignment)OKOK
Delta-based score (momentum, volume burst)BROKEN — resets on candleFRAGILE — decays during the hold

Three out of four cells are workable. One — the one I had in production — is mathematically empty.

The fix: bypass at extreme conviction

The fix shipped in PR #110 is straightforward: let extreme-conviction signals skip the stability gate entirely.

# config.py
BYPASS_EXTREME_SCORE = 85  # was 90, lowered after April 7

# trader.py
if score >= BYPASS_EXTREME_SCORE:
    log.info(f"STABILITY_BYPASSED score={score} reason=extreme_score")
    return execute_entry(asset)

# normal stability gate continues for sub-extreme signals
if signal == "MODERATE":
    last = self._last_signal[asset]
    if last and last.signal == "MODERATE" and last.cycles_ago == 1:
        return execute_entry(asset)
    self._last_signal[asset] = SignalSnapshot(signal, cycle=now)
    return None

The threshold was previously 90 — high enough that it almost never triggered, which is the same as not having a bypass at all. Lowering to 85 means any "this is screaming at me" signal can skip the gate. Sub-85 signals still have to prove themselves.

This is not the prettiest fix in the world. The pretty fix would be to redesign the stability gate around per-factor persistence rules — momentum gets a delta-decay model, RSI gets a level-stability model, ADX gets a slope-stability model. That's a multi-week rebuild. The bypass is the right fix for this week.

The replay validation was small but mandatory:

def test_extreme_score_bypasses_stability():
    state = load_april7_state(asset="BTC", tf="15m", t="19:00:09")
    state.score = 85
    decision = trader.evaluate(state)
    assert decision.action == "ENTER"
    assert decision.bypass_reason == "extreme_score"

def test_sub_extreme_still_gated():
    state = load_april7_state(asset="BTC", tf="15m", t="19:00:09")
    state.score = 84  # one below threshold
    decision = trader.evaluate(state)
    assert decision.action == "WAIT"

Both passed. Merged. Live on Tesseract within an hour.

The wider lesson

Two correct components can compose into a broken whole. Test compositions, not just components.

I've been writing software for sixteen years and this still bites me. Every individual piece in this story is sensible. The momentum factor is a great signal. The stability gate is a great filter. The downgrade-when-InDecision-is-quiet rule is a great safety net. Each one was added by someone (often me) for a specific, justifiable reason. None of them were buggy in isolation.

But the joint distribution of all three on a momentum-led breakout produces a system that is structurally incapable of trading the very setups it was built to trade. Nobody designed it that way. It just emerged from the composition because nobody had walked the math at the boundary.

This shows up beyond trading. Anywhere you have:

  • A debounce on a value that decays
  • A "must be true for N polls" check on a metric that resets per poll
  • A confirmation prompt in front of an action that times out
  • A "stability score" gating publication of an InDecision-style framework signal with momentum components

…you have the same shape of bug waiting for the moment it would have mattered.

The audit is the same every time. List your gates. List your score components. For each gate × component pair, ask: can this component, given its decay and reset behavior, satisfy this gate? If the answer is no for any pair you actually rely on, you have dead code in your "smart" filter.

Stop testing components in isolation

The unit tests for both my stability gate and my momentum scorer pass. They've been passing for months. The composition has been broken for the same months. Nobody noticed because nobody tested them together against a realistic price move.

The integration test is one paragraph of code:

def test_breakout_triggers_entry():
    """A clean +3% candle on BTC must produce an entry within 1 cycle."""
    ticks = synthesize_breakout(asset="BTC", magnitude=0.03, duration_min=15)
    bot = Bot(config=test_config())
    for tick in ticks:
        bot.on_tick(tick)
    assert bot.position_count > 0, "expected at least one entry on a clean breakout"

That test, run against any momentum-led score plus a "must repeat next cycle" stability gate, would have failed loudly months ago. I'm adding it now to every signal path in both bots. It's the simplest test I could have written and the one that would have prevented this whole post.

The math was always going to win. The only question was whether I was paying attention before the live event or after.

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 →