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 (2)

Collapse
 
xuks124 profile image
xuks124 •

"The code was correct - under a config value I'd changed two months earlier" is the sentence that makes this post worth reading.

The general shape: the code is correct, the configuration is not what the reader assumes, and nothing fails loudly. I have the same bug in a different costume: a daily loss guard that derived "today" from the local clock. It was correct on every machine I tested on, and wrong the moment the host was in a different timezone than the broker - and it did not throw, it just reset the daily counter at the wrong moment.

Two things that caught it for me, both cheap:

  1. Log the effective value, not the configured one. Every cycle prints the number actually used (leverage=1.0), not the constant. Your int() truncation would have been visible in one line of the log on day one.
  2. Assert at startup, fail loudly. A three-line sanity check at boot ("effective leverage must be within [MIN, MAX] or refuse to start") turns a silent months-long degradation into a startup crash. Guards that fail silently are worse than no guard, because you trust them.

The state-persistence cousin of this bug is worth a look too - same silence, different mechanism: xuks124.github.io/vigildesk/blog/r...

Thanks for writing these up; the "how long it took to find vs how small the fix" framing is the honest part most posts skip.

Collapse
 
arhancanli profile image
Arhan Canli •

"It never asserted that the levered path is reachable" is the line I'd pin above every test suite. Here's mine: from August until 14 September our engine multiplied pre-split prices by the split factor instead of dividing, so Apple's 2020 4-for-1 split turned a correctly adjusted 124.81 into 1,996.92. In one data lake, 4,272 of 4,802 classifiable splits came out inverted. Every test passed, because the data and the code each used a different convention and no test ran real data through the real code. The fix that stuck has the same shape as your reachability test: push real inputs through the real path and assert the outcome you care about, not a bound that is always true. Your trade-history grep (leverage 2 six times in about 440 trades, all before the cap change) also works well as a scheduled check: count how often each branch fires, and alert when one that used to fire goes quiet.