← Engineering notes

Read the exit-reason histogram before you read the headline number

How to read a backtest's exit-reason breakdown — including the end_of_data bucket for positions still open when the sample ran out — and the six ratios worth demanding beside any summary card.

· 11 min read

Schematic drawing: a large flat histogram of a handful of wide bars of differing heights beside a small blank summary card, with a magnifying loupe held over the histogram rather than the card

A backtest summary is a compression. Metrics (konis_core/backtest/metrics.py:59) carries fifteen fields, and almost all of them are a single scalar standing in for a whole distribution. One field is not: exit_reasons, a dict[str, int]. It is the only thing on the card that tells you how the run closed its positions rather than what the closes were worth — and it is the fastest way to find out that a headline is partly an unrealised mark.

It is also the cheapest field to compute, which is why it is never rounded, weighted or filtered:

# metrics.py:202
reasons: dict[str, int] = {}
for t in trades:
    reasons[t.reason] = reasons.get(t.reason, 0) + 1

One trade, one increment. The values sum to trades. That is the first check on any card you are handed: if they don't sum, you are looking at two runs stitched together.

The five reasons, in the order they are tested

Every reason string comes from one function — _exit_scan (engine.py:154), which walks forward bar by bar from the entry until something closes the position. The order of the tests inside the loop is the whole story, because the first branch that fires is the one that gets counted:

# engine.py:191, structure only
for i in range(start, len(candles)):
    held = (index[i] - index[start]).total_seconds() / 60.0

    # 1. trailing stop: updates the level, never exits by itself
    # 2. stop level hit?      -> "stoploss" or "trailing_stop"
    # 3. profit target hit?   -> profit-target reason  (guarded by i > start)
    # 4. exit flag on bar i?  -> "exit_signal", filled at opens[i + 1]

last = len(candles) - 1
return last, float(closes[last]), "end_of_data"

Five outcomes: stoploss, trailing_stop, the profit-target key, exit_signal, end_of_data. (The profit-target key is the three-letter abbreviation the ladder field on BacktestParams uses; it is the one string of the five this post does not print verbatim. Grep that field and you will have it.)

Now walk them, and ask what a large share of each actually means.

end_of_data — the position that never closed

This is the fall-through at engine.py:240. The scan reached the last bar without anything firing, so the trade is closed at closes[last] and reported. The docstring says why:

Falls out at the last bar with reason "end_of_data" so an open position at the end of the sample is reported rather than silently dropped — an unclosed winner that vanishes is how a backtest hides its worst trade.

Dropping it is the alternative, and it is strictly worse: a strategy that opens a position and sits in it for the last third of the sample would show a shorter, tidier set of closed trades and no trace of the exposure that produced them.

So the row is there, and it is an unrealised mark. Three consequences worth doing the arithmetic on.

It has a ceiling, per pair. backtest_pair holds at most one position at a time, and after the scan it advances i = max(exit_bar, entry_bar) (engine.py:305). When the scan falls through, exit_bar is the last index, so the while i < n - 1 loop terminates. Exactly one end_of_data row is possible per pair, at most. If your run covers four pairs, the bucket cannot exceed 4 — and the number that matters is its share. Suppose a card reports 38 trades across those four pairs and end_of_data: 3. That is 3/38 ≈ 7.9% of the sample's outcome carried by positions that were still open when the data stopped.

It lands at the end of the equity curve, by construction. _equity_curve sorts by (closed_at, pair) (metrics.py:96), and total_return is read off the curve's final point: (curve[-1] - start) / start (metrics.py:211). An end_of_data trade closes at the last timestamp in the frame, so it is always among the last terms of the curve. The unrealised mark is not buried in the middle of the headline — it is the tail of it.

It is charged the full round-trip cost anyway. The exit still goes through _fill (engine.py:287), which applies fee and slippage against the trade. With the defaults — fee = 0.0007, slippage = 0.0005 (engine.py:49) — that is 0.0012 of price on each side. A position marked out at the last close is being marked as though it were liquidated into that close.

Also note the boundary case: the exit-flag branch requires i + 1 < len(candles), because it fills at the next open. An exit flag on the final bar therefore cannot produce exit_signal; the loop ends and the trade is labelled end_of_data.

Profit target — is the ladder choosing, or the strategy?

_roi_target (engine.py:130) reads the ladder produced by roi_ladder(), which sorts by deadline with the loosest last (engine.py:71). Take a ladder of {0: 0.05, 30: 0.02, 60: 0.0}. Sorted it becomes ((60, 0.0), (30, 0.02), (0, 0.05)), and the first rung whose deadline has elapsed wins:

  • held 10 min → 0.05
  • held 45 min → 0.02
  • held 70 min → 0.0

That last rung is not a target, it is a timeout. Target 0.0 sets roi_price equal to entry_price, and entry_price already has the entry cost folded into it. The trade closes on the next bar whose high merely touches the price it was opened at. So a card whose profit-target bucket dominates, with average_duration_minutes sitting just past the loosest rung's deadline, is not describing a strategy that found its targets. It is describing a ladder that ran out of patience — the exits were chosen by the risk settings, which live on BacktestParams, not on the strategy at all.

Work the cost through that timeout rung: entry fills at p × 1.0012, the target price is that same number, the exit fill multiplies by 0.9988. The gross price move booked is 0.9988 - 1 = -0.0012. The entry cost disappeared into the reference price; only the exit side remains visible. Targets on this ladder are net of the entry cost and gross of the exit cost, and at leverage the difference scales.

One more thing to check before reading the bucket at all: leverage. Ladder rungs and the stop are fractions of stake, and everything in the scan compares against price, so both are divided first (engine.py:186 and :225). At 2×, a -0.15 stop is a 7.5% adverse price move, and a 0.04 rung is a 2% one. Two runs of the same strategy at different leverage do not have comparable histograms.

exit_signal with a long hold — the stop is decorative

The stop is tested on every bar before the target and before the exit flag. If the stoploss + trailing_stop buckets are empty or near-empty, there are only two explanations, and both are checkable. Either stop_fraction was None throughout — params.stoploss unset and trailing not configured, in which case the branch at engine.py:209 never even evaluates and a zero count is structural, not empirical — or the level was set wide enough that no bar's low (or high, short side) ever reached it.

Either way, a histogram dominated by exit_signal with a large average_duration_minutes says the position size was controlled by nothing but the strategy's own exit condition for the whole holding period. The stop was present in the config and absent from the outcome. That is a fact about the run, not a prediction about the next one — but it does tell you which line of the config to change if you want the risk setting to have any effect on what you are measuring.

trailing_stop vs stoploss — one branch, two labels

Both labels come out of the same return (engine.py:220):

return i, float(fill), "stoploss" if not trailing_armed else "trailing_stop"

The label splits on whether trailing had ever armed, not on which level was binding at the moment of the hit. And the level is a ratchet: stop_fraction = max(stop_fraction or -1.0, profit + trail) (engine.py:206) — it never loosens. If the trail has fallen back below the original stop, the original stop is what binds, and the row still says trailing_stop. So the trailing_stop count is an upper bound on exits actually caused by the trail.

Arming has its own trap. The condition is offset is None or profit * lev >= offset (engine.py:202). With trailing_stop_positive_offset unset, it arms on the first bar of the position, at zero profit. A run where every single stop exit is labelled trailing_stop may be telling you nothing about the trail and everything about an unset offset.

The ordering bias is deliberate, and it is in your favour

Within one bar, the stop is tested before the profit target. The module docstring states the convention up front (engine.py:18): without tick data there is no way to know which price came first inside the bar, and the pessimistic choice is the only one that cannot flatter the result.

There is a second asymmetry in the same direction, easy to miss: the profit-target branch is guarded by i > start (engine.py:224), so on the entry bar itself the target cannot fire — but the stop can. And a bar that opens already through the stop fills at the open, not at the stop price (engine.py:217), which is the worse of the two.

The practical consequence for reading the histogram: for every bar whose range covered both levels, the count sits in the stop bucket. The breakdown you are reading is already the pessimistic resolution of every ambiguous bar. A stop-heavy histogram is partly an artefact of that convention — which is fine, as long as you know it is there when comparing against an engine that resolved ties the other way.

The sixth number: rejected

The five reasons only describe trades that happened. PortfolioResult.rejected (portfolio.py:37) counts the ones that did not:

# portfolio.py:135
for candidate in candidates:
    if _fits(candidate, open_trades, max_open_trades):
        accepted.append(candidate)
        open_trades.append(candidate)
    else:
        rejected += 1

_fits asks whether a slot was free at the moment the trade would have opened. The tie-break is stated in the module docstring: earliest first, ties broken by pair name — deterministic, and a real edge, because a strategy whose entries cluster will have candidates dropped and which ones get dropped changes the result. Arrival order decides, not quality.

Rejected candidates never reach summarise, so they appear in no bucket at all. They are also excluded from funding, which is charged after the capacity filter (portfolio.py:142) precisely so that trades which never opened don't subsidise the ones that did. The number survives into the serialised result as rejected_for_capacity (portfolio.py:57) — which is where to look for it, since it is not a Metrics field and will not be on the card unless someone put it there.

The ratio you want is rejected / (trades + rejected). When it is large, the histogram above it describes a subset of the strategy's behaviour selected by clock order under max_open_trades — default 10 (portfolio.py:91).

The checklist

Alongside any summary card, demand these. All of them are arithmetic on numbers the run already produced.

  1. sum(exit_reasons.values()) == trades. If not, the card is a merge of two runs, or stale.
  2. end_of_data / trades, with the pair count beside it. The bucket cannot exceed one per pair; the share is how much of the result is an unrealised mark sitting at the end of the equity curve. Check per_pair (portfolio.py:151) — each pair carries its own histogram, so you can see exactly which pair was still holding.
  3. profit-target share, read against average_duration_minutes and the ladder's loosest rung. A hold time just past the last deadline means the ladder timed the exits, not the strategy.
  4. (stoploss + trailing_stop) / trades. Zero is not a result until you have confirmed params.stoploss is not None; otherwise the branch never ran.
  5. trailing_stop / (stoploss + trailing_stop), with trailing_stop_positive_offset in hand. Unset offset arms the trail at zero profit, which relabels ordinary stop exits.
  6. exit_signal / trades, read against average_duration_minutes. A high share with a long hold means the stop never bound in this sample.
  7. rejected / (trades + rejected) from rejected_for_capacity. Signals that never became trades at all.
  8. exposure and span_minutes (metrics.py:77). Both are on Metrics already, and anything annualised — the Sharpe divisor is the calendar span, not the average hold — is uninterpretable without them.

None of these say whether a strategy is any good. They say whether the number above them is measuring what you assumed it measured, which is a different question and the one you can actually answer from the data in front of you.

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.