DEV Community

Sam Hartley
Sam Hartley

Posted on

A Single int() Silently Disabled My Entire Risk System

A Single int() Silently Disabled My Entire Risk System

I didn't find this bug by debugging. I found it by answering a question.

Someone asked me what leverage my live trading bot actually runs at. I said "one and a half times, sometimes two, depends on the funding spread" — because that's what the ladder in my code says, and I'd read that code recently. Then I decided to prove it instead of trusting my memory, and the answer was 1. Not 1.5. Not "up to 2". One. Every position, every time, for months.

The fix took four characters. Finding it took longer, because the code was correct — under a config value I'd changed two months earlier.

The ladder

My position sizing has a small leverage ladder. Wide funding spread plus a stable rate, size up; thin spread, stay at 1x.

LEVERAGE_LADDER = [1, 1.25, 1.5, 2]
MAX_LEVERAGE = 1.5

def calc_smart_leverage(rate, stability):
    idx = 0
    if rate > 0.05: idx += 1
    if stability > 0.8: idx += 1
    if rate > 0.15: idx += 2
    leverage = LEVERAGE_LADDER[min(idx, len(LEVERAGE_LADDER) - 1)]
    return int(leverage)   # <- here
Enter fullscreen mode Exit fullscreen mode

The ladder returns floats. int() truncates them. At MAX_LEVERAGE = 2.5 — the value this code was written against — the truncation was invisible:

  • The ladder max is 2.0, the cap is 2.5, so int(2.0) == 2. Nothing got clipped.
  • The only value that could have been clipped was 2.5 itself, and the ladder never returned it.

Then, in July, I lowered the cap to 1.5, because the 2.5x era had produced a drawdown I didn't like. That one-line config change turned a dormant int() into an active bug: int(1.5) == 1. Every rung above 1x collapsed to the bottom of the ladder. The bot was permanently, silently at minimum leverage.

The part that made me sit down

Leverage being 1x instead of 2x wasn't the real problem. The real problem was a guard that read leverage to decide whether the risk logic should run at all:

def check_drawdown(state):
    has_leveraged = any(p["leverage"] > 1 for p in state["positions"])
    if not has_leveraged:
        return "normal"     # no leverage, no risk to compute
    ...  # the actual drawdown math
Enter fullscreen mode Exit fullscreen mode

any(leverage > 1) was never true, because leverage was always 1. So check_drawdown() returned "normal" on the first line, every time, without computing anything. My drawdown breaker was not "not triggering". It was not running. In the live trader and the paper trader, for months.

That's the shape I want to write down: the guard didn't fail. It didn't raise. It returned the reassuring answer, which is the worst possible failure mode for a safety check.

Why my tests never caught it

I had tests. They passed.

def test_leverage_respects_cap():
    lev = calc_smart_leverage(rate=0.3, stability=0.95)
    assert lev <= MAX_LEVERAGE   # 1 <= 1.5, green
Enter fullscreen mode Exit fullscreen mode

This asserts the wrong thing. It checks that leverage is bounded above, which was always true and always irrelevant. It never asserted that the levered path is reachable. When I lowered the cap, the assertion stayed green — correctly! — while the feature it was supposed to protect went to zero.

The invariant I actually cared about wasn't "leverage ≤ cap". It was "a high-conviction signal produces leverage > 1". Those are different claims, and only one of them fails when the code is broken.

How I found the boundary

I didn't want to reason about it. I wanted a number. So I ran both implementations against the same inputs with a stubbed API client — no orders, nothing live — and printed the leverage for a spread that should have maxed the ladder.

Paper: 1. Live: 1.

Then I grepped the trade history for the field. leverage: 2 appeared exactly 6 times out of roughly 440 trades, all of them at the very start, before the cap change. Every trade after that: 1. The history had been telling me for two months. I just hadn't asked it the right question.

What I changed

Three things, none of them clever:

  1. The sizing function returns a float. No truncation. If the ladder says 1.5, the position gets 1.5.
  2. The "should I run risk logic" decision is computed once, explicitly, from the ladder's actual output — not inferred from a value that a truncation bug, a rounding change, or a config edit can quietly flatten.
  3. The tests assert reachability, not bounds: given a maximal signal, assert the levered branch executes. If someone lowers the cap below the lowest rung again, that test goes red.

The general version

Two things I'd offer anyone running scheduled or automated systems:

A guard clause that returns the safe answer is a silent kill switch. if not has_leveraged: return "normal" is the same class of bug as if not errors: return True. It looks defensive. It's the thing that hides the failure.

Config changes can change code semantics. The int() was written when the cap was 2.5. It was correct then. Nothing in the diff that lowered the cap to 1.5 touched that function, and the tests stayed green — because they tested the bound, and the bound still held. When you change a constant that other code reasons about, the code that reasons about it is part of the change, whether the diff says so or not.

I spent a weekend on a four-character bug. The four characters were int(. The weekend was for the two months I didn't know my risk system was off.

If you have a similar one — a "harmless" coercion or rounding that quietly took a whole branch with it — I'd genuinely like to hear it. Drop it in the comments.

Top comments (0)