DEV Community

王伟
王伟

Posted on

One Missing Call Site Turned -5.7% Into +17.9%

A one-line structural bug, worth 23.6 points of return and most of a 74% win rate.


I want to start with the number, because the number is the point:

The same strategy, before and after

−5.7% when exits are checked every bar. +17.9% when they are only checked on signal bars.

Same strategy, same data, same period, same costs. The difference is where one function was called from.

The bug

My backtester had a loop that looked roughly like this:

for i, bar in enumerate(bars):
    signal = strategy.check(bar)
    if signal:
        strategy.execute(bar)      # <- entry AND exit logic live in here
Enter fullscreen mode Exit fullscreen mode

execute() contained both the entry and the exit check. Putting the exit check inside a function that only gets called on signal bars means the exit is only evaluated on bars where a signal fired.

On every other bar, the position sat there. Stops were not checked. Targets were not checked. Time-based exits were not checked. The strategy effectively had permission to hold through anything, as long as no new signal appeared.

The fix is not a change to the strategy at all:

for i, bar in enumerate(bars):
    signal = strategy.check(bar)
    if signal:
        strategy.execute(bar)
    strategy.manage(bar)           # <- exits, every bar, unconditionally
Enter fullscreen mode Exit fullscreen mode

That is the entire change: move exit management out of the signal branch.

Why it flatters you

This one is directionally biased, and the bias is not subtle.

  • Losers get held longer than they should. A position that would have been stopped out keeps living until the next signal happens to fire. Sometimes it recovers. Sometimes it recovers because you were not checking — which is not a strategy, it is a delay.
  • The win rate inflates. My original run reported a 74.1% win rate. Fixing the call site moved it by 6 to 8 points. That is the difference between "this looks like a professional system" and "this looks like a coin flip with costs".
  • It shows up as fewer trades reaching their stop. If you log exits, you will see stop-loss exits that cluster in time — they fire in bursts on signal bars instead of being spread evenly. That is the fingerprint.

The mechanism is worth stating plainly: you were not simulating a strategy, you were simulating a strategy plus a free option to defer every adverse outcome. That option is worth a lot, and it is worth it in the direction you want.

How to find this in your own code

Three checks, in increasing order of effort:

1. Count exits by type over time. If stop-outs cluster on the same bars as entries, your exits are gated by the signal path. They should be roughly uniform in time, driven by price, not by signal timing.

2. Search for the phrase you are about to write. In almost every backtester, exits end up inside a function the entry path also calls. Grep for execute, handle_bar, on_bar, step — and ask, for each call site, "does this run on every bar or only sometimes?"

3. Print the bar index of every exit and every entry, then diff. If the sets of indices overlap far more than chance, exits are coupled to signals.

4. The structural fix beats the test. Make exit management a function that is called unconditionally, from the main loop, once per bar, and make it impossible to call it from the signal branch. If the API shape permits the bug, the bug will come back.

What it cost me

The strategy was negative before the fix. That is the uncomfortable part: this bug kept a losing strategy alive for months. If I had run it honestly I would have dropped it, and I would have been right to.

Bugs that make results worse get found. Bugs that make results better get shipped. This is the structural asymmetry of backtesting: your attention is drawn to things that look broken, and a false positive looks like progress.

Which is why the check has to be mechanical instead of attentional. I could not have caught this by staring harder at the strategy — the strategy was fine. I caught it by asking a question that had nothing to do with signals: where is this function called from?

The general shape

This belongs to a family I keep running into: the backtester's control flow is itself a modelling assumption.

You think you are modelling a strategy. You are actually modelling a strategy inside a simulation harness, and the harness decides when your code gets to see the world. Every asymmetry in that decision — which bars you get called on, whether you see a bar before or after it closes, whether the exit path is privileged — silently becomes part of the strategy.

So when an audit turns up nothing in the strategy, the next place to look is the harness. Mine was wrong in a way no amount of strategy review could have found.


The call-site check is one of the static checks in a small open-source linter (GitHub, MIT, no dependencies). It can flag exits that cluster with entries; it cannot read your control flow, which is why the structural fix matters more than the check.


There is also a *$0** download on Gumroad that bundles the tool with the demo
logs, if you want something to test against before pointing it at your own data —
name your price, and $0 is a valid answer: https://weiwave23.gumroad.com/l/backtest-lie-detector*

Top comments (0)