← Engineering notes

Your stop is a fraction of your stake, not of the price — and leverage is the conversion

In a strategy document the stop, the timed profit-target ladder and the trailing offset are all fractions of stake, but every comparison inside the bar loop is against price — here is the division that converts them, and what our backtester measured while it was missing.

· 7 min read

A strategy document in this repo says stoploss: -0.15. Ask three people what that means and you get three answers: a 15% adverse price move, a 15% loss of the money committed to the trade, or "15%, of something, surely it doesn't matter". It matters, and which one is right depends on a number that isn't written anywhere near it — the leverage the position runs at.

The document is denominated in stake. The bar loop is denominated in price. Those are different units, and leverage is the conversion factor between them. Everything below is the arithmetic of that conversion, the place in the engine where it happens, and what we measured happening while it was missing.

Two units, one comparison

BacktestParams (konis_core/backtest/engine.py) is the risk envelope applied to a strategy's computed signals — deliberately separate from the strategy, so one signal stream can be replayed at a dozen risk settings without recomputing an indicator:

@dataclass(frozen=True, slots=True)
class BacktestParams:
    stake: float = 100.0
    leverage: float = 1.0
    fee: float = 0.0007
    slippage: float = 0.0005
    # ... the timed profit-target ladder, minutes -> fraction
    stoploss: float | None = None
    trailing_stop: bool = False
    trailing_stop_positive: float | None = None
    trailing_stop_positive_offset: float | None = None

stoploss, the ladder targets and the trailing offset all arrive as fractions of stake. That is the convention the strategy documents were written in, and changing it would silently reinterpret every document in user_data/strategies_v2/.

But _exit_scan walks bars. It has high, low, open, close — prices. Every test it performs is a price test. So the stake numbers have to be converted on the way in, and the engine says so in the comment directly above the conversion:

lev = params.leverage if params.leverage else 1.0
stop_fraction = params.stoploss / lev if params.stoploss is not None else None

Divide by leverage, once, at the top. From that line onward stop_fraction lives in price units and never leaves them.

The table

A -0.15 stop, written once in a document, is a different price level at every leverage:

Leverage stoploss / lev Adverse price move that fires it Loss on stake when it fires
1x -0.15 15.0% 15%
2x -0.075 7.5% 15%
3x -0.05 5.0% 15%
5x -0.03 3.0% 15%
10x -0.015 1.5% 15%

The right-hand column is the point. The stake-denominated number is leverage-invariant by construction — that is what makes it the useful one to write in a document. The price column is the one that changes, and it is the only one the bar loop can act on.

The ladder is the same division in the other direction, at _exit_scan's profit-target branch:

target = target / lev          # stake fraction -> price move
Stake target 2x 3x 5x
3.0% 1.5% 1.0% 0.6%
5.9% 2.95% 1.97% 1.18%
16.2% 8.1% 5.4% 3.24%

The 5.9% and 16.2% rows are the top rungs of real ladders in the repo — ichi-v1.yaml opens at 0: 0.059, ported/bandtastic.yaml at 0: 0.162.

The trailing stop needs both directions at once, because its two parameters point opposite ways:

# `profit` is a price move; the offset is on stake, so it is the
# levered profit that has to clear it.
if offset is None or profit * lev >= offset:
    trailing_armed = True
if trailing_armed:
    trail = -abs(params.trailing_stop_positive) / lev
    stop_fraction = max(stop_fraction or -1.0, profit + trail)

The arming test multiplies the price move up to stake units to compare against the offset; the trail distance is divided down into price units so it can be added to profit, which is a price move. Take bandtastic.yaml: trailing_stop_positive: 0.01, trailing_stop_positive_offset: 0.058. At 2x, the trail arms after a 2.9% price move and then rides 0.5% behind the peak. At 1x it arms at 5.8% and rides 1% behind. Same document, half the distance, because there is twice the leverage.

What happens if you skip the division

This is not a hypothetical; it is what this engine did before that block existed. The asymmetry comes from the fact that leverage was never missing on the profit side. Trade.net_return has always read:

return self.gross_return * self.leverage - (self.funding or 0.0)

So the accounting multiplies the price move by leverage, while an unconverted stop_fraction compares a stake number directly against a price. At 2x, a -0.15 stop then demands a 15% adverse price move — which, when it finally arrives, is a 30% loss of stake. Meanwhile the ladder's first rung, also unconverted, triggers at a 3% price move worth 6% on stake. The upside is levered, the downside is not. The stop isn't wrong by a little: it is roughly twice as far away as the document asked for, at 2x, and lev times as far away in general.

The practical effect is not a crash or an exception. It is that the stop is simply reached far less often. Trades that should have been cut at the documented distance keep running, and some of them come back. A stop that rarely fires looks, in aggregate, like a strategy with excellent staying power.

We have a count for this. The engine's own comment records the check that was run against the engine these strategy documents were ported from, same strategy, 2x, one year:

it took 1 stop where [the reference engine] took 13

One stop against thirteen. That is a measurement of our backtester, and it found our backtester wrong.

There is a second reason the unconverted version describes an account that cannot be opened. Scale the same mistake up: at 10x, a -0.15 stop read as a price move waits for a 15% adverse move, which is 150% of the stake. The margin is gone somewhere around a 10% move. The backtest doesn't complain, because this engine's position walk has no liquidation model at all — the only liquidation_price in konis_core is a field on the live trade schema. So the run completes, produces trades, and reports exits that no exchange would have let the position survive to reach.

The rule

A risk number on a strategy card — a stop, a target, a trailing distance — is not a quantity until you know two things about it: what it is a fraction of, and the leverage it was defined at. -0.15 at 1x and -0.15 at 10x are the same commitment of money and a sixfold difference in how far price has to travel. If a platform shows you the number without the units and the leverage, the number is decoration.

Inside the engine the discipline is narrower and easier to state: pick one unit for the bar loop, convert everything at the boundary, and comment the boundary. stop_fraction is in price. Anything entering it gets divided by lev; anything leaving it to be compared against a stake-denominated setting gets multiplied. The bug was never in the arithmetic — each individual line was correct. It was in a value crossing a unit boundary without anyone noticing there was one.

KONIS is quantitative trading infrastructure. Backtest declarative strategies against years of exchange data, run what survives on a managed runtime, and read the market through analytics while it runs.

Build and backtest free — no card

Technical software and market analytics, not financial, investment or trading advice. Nothing here is a recommendation to buy or sell anything. Backtested results are historical and do not indicate future results. Trading carries substantial risk, including total loss of capital.