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.