A freqtrade stop-loss A/B — firing −0.05%, −0.10% and −0.20% into the same quote at the same instant

freqtrade makes you restart the bot to change a stop-loss width, so you can never see two widths side by side. I fired three arms within the same millisecond: t

A freqtrade stop-loss A/B — firing −0.05%, −0.10% and −0.20% into the same quote at the same instant

You've finished this article successfully if you can fire N arms that differ in exactly one field within the same millisecond, and confirm from the records that the difference really came from that field.

What this does Fires several arms that differ in one field only into the same instant and the same quote, pinning the entry to a single point so you read only that field's effect
Who it's for Anyone who has tuned a parameter and then couldn't tell whether the change or the different run produced the difference
Who it isn't for Anyone looking for a P&L difference. With the quote pinned, the P&L does not diverge — what diverges is the trigger and the timing
What you end up with A table with a separate exit_trigger, exit_trigger_px, close_reason and elapsed time per arm, plus the recipe that produces it
Cost No code cost · no model cost · a weltiq pro account. N arms = N units of quota
Time / difficulty 20 minutes · medium (curl, JSON)
Relationship to weltiq This whole article is that — it's a procedure piece, so there's no separate integration section
Test environment / date 2026-09-28 (KST) · isolated server, synthetic quotes (section 5 states the limits plainly)
Disclosure The author is paid by weltiq for this series. The fact that the P&L did not diverge is written down as well

The human-side lines are real commands rewritten as dialogue; the outputs are measured and the numbers are untouched.


1. The blank this tool kept leaving

I ran freqtrade in part 4. There's a passage where I make a stop-loss actually fire, and what got in the way there is where this article starts.

First I set it at −0.2%. It never fired in ten minutes.

Value
stoploss -0.002 (−0.2%)
Entry 85037.82
Stop level 84867.75
Result no trigger in 10 minutes

The reason is interesting — the displayed P&L percentage already contains the 0.2% round-trip fee, so the stop only fires once the displayed figure approaches −0.4%. The worst it reached in ten minutes was −0.24%.

FreqUI's open-position panel. Open rate 85868.2, Current rate 85884.98, and yet **Current profit % −0.18%** in red — the price went up and the P&L is negative. Look at that one red number: it means **the round-trip fee is already loaded in**, and that's where stop-level arithmetic goes wrong

So I changed it to −0.05% and ran again. This time it fired.

INFO - Override strategy 'stoploss' with value from the configuration: -0.0005
21:23:50  BTC/USDT 진입 @ 85198.18  stop = 85155.59 (-0.05%)
21:25:21  Exit for BTC/USDT detected. Reason: stop_loss

The trade record survives too: close_rate 85145.81 · min_rate 85145.81 · exit_reason stop_loss · profit_abs -0.26057537 USDT.

The chart view of the same bot: a one-minute chart with the held position and its P&L. **Put it next to the previous image** — same screen, but a different stop width writes a different ending here. Inside freqtrade you can never see those two endings side by side

But you can't see two widths side by side

That's the blank. As I wrote in part 4, changing the stop-loss width requires restarting the bot. reload_config re-reads the config file but leaves the stop level of an already-open position alone. There is no route to hot-swap the rules of a running position.

So the records for the two widths live in different runs. Even the entry prices differ — 85037.82 and 85198.18. Put those two next to each other and ask which stop is better, and there is no way to tell whether the answer comes from the stop width or the entry price.

The bot's startup log, showing Dry run is enabled and the HTTP server address. Look at the fact that this is a **startup** log at all — every change of stop width means seeing this screen again, and by then the market has moved somewhere else


2. Why this stayed empty

To compare one parameter, everything else has to be the same. The moment you execute, that becomes hard.

Why not backtest it — you can. But a backtest is a replay of the past, so the cost model, the tick grid, the minimum notional and the slippage all behave the way that engine assumes. The thing I found in part 4 — that the 0.2% round-trip fee is already inside the P&L percentage, so the stop doesn't fire — is exactly the kind of thing that surfaces where those assumptions meet real placement rules.

A trial order removes all three at once. It doesn't use the balance, so positions don't mix; the server sets the quantity, so capital is irrelevant; fire in the same millisecond and they see the same quote.

Change one field and fire into the same instant and the same quote, and the difference can only come from that field.


3. The recipe — the thing that actually runs

The recipe is here: 09-trial-order-freqtrade-stoploss-ab.sh.

Step What
⓪ tick Convert the ratio to an absolute level and snap it to the tick grid. Record the direction too
① plant Fire the three arms simultaneously. Same client_tracking_id for all three; metadata.arm separates them
② recover GET /v1/forward-tests?tracking=<run id>
③ execution record GET /v1/forward-tests/orders?format=csv&tracking=… — the whole account in one call
④ join Join to the tool's log on tracking id + client_order_id, and count whether published_at matches across arms

The intent stays with the tool. All that goes to the server is client_order_id, client_tracking_id and string metadata; the strategy name and the reasoning behind the stoploss ratio live only in tool_log.csv. A float in metadata is rejected by canonical_json under its determinism rule and comes back 500, so the ratios went as strings too.

How you show only one field changed — keep client_tracking_id identical across arms and separate them with metadata.arm alone. Then write down changed_field.

{"client_tracking_id": "cf-04-stoploss-width",     // identical across all three arms
 "exit_plan": {"stop_loss": "85155.59", "close_after_seconds": 45},   // ← the only field that differs
 "metadata": {"arm": "variant", "changed_field": "exit_plan.stop_loss"}}

The ratio doesn't go in as-is

What freqtrade emits is not an absolute price but a ratio: {"stoploss": -0.0005}. Ratio × the recorded entry 85198.18 = 85155.590909, which is not a multiple of the 0.01 tick.

422 invalid_stop_loss  "85155.590909 is not a multiple of tick 0.01"

Nothing gets silently rounded. The caller has to snap it, and has to record which way it rolled — for a long stop, rounding down lowers the level and delays the trigger; rounding up brings it forward. That choice can make the result.

A side finding: deadline validation runs before price validation. Send an off-tick level with no deadline and you get exit_plan_deadline_required, not invalid_stop_loss. To see the level error you must fill the deadline first.

Where the three arms come from

Arm stoploss Source
recorded −0.0005 (−0.05%) reports/a/04-freqtrade/tight-stop.json · RUN-LOG.md:348-366
synthesized −0.0010 (−0.10%) a value the harness chose, filling the gap between the two records
recorded −0.0020 (−0.20%) docs/tutorials/a/04-freqtrade.md:197 (no trigger in 10 minutes)

The two original runs had different entry prices (85037.82 vs 85198.18), so only the widths were carried over and attached to the same entry price, 85198.18.


Environment for the numbers below: an isolated temporary server · synthetic quotes · not production · the P&L is not a market fact.

4. What came back

The three arms went out between 13:25:56.663 and .678 — all of them within 15 milliseconds.

Arm Stop level Accepted (KST) Entry fill Entry quote published Exit exit_trigger exit_trigger_px close_reason Fees Net P&L Elapsed
04-stop-0005 (recorded) 85155.59 13:25:56.663 85206.7048175 13:25:57.507 85137.2904185 stop_loss 85145.81 stop_loss 0.01362752 −0.019180671920 2.9s
04-stop-0010 (synthesized) 85112.98 13:25:56.671 85206.7048175 13:25:57.507 85137.2904185 — — close_after 0.01362752 −0.019180671920 46.0s
04-stop-0020 (recorded width) 85027.78 13:25:56.678 85206.7048175 13:25:57.507 85137.2904185 — — close_after 0.01362752 −0.019180671920 46.0s

All three entry fills match to the last decimal. And the publication time (published_at) of the quote each fill referenced matches to the millisecond — one published quote filled all three.

Our trial detail with the one-field-apart arms lined up. Look at the **exit_trigger and exit_trigger_px columns** — only the arm whose stop fired carries the trigger quote 85145.81; the deadline closes carry null. Nothing missing is filled in as a zero

Did that field decide the outcome — yes. The why and when

The recorded low of 85145.81 pierced the −0.05% line and not the −0.10% or −0.20% lines. Exactly as the original record said. One arm closed at 2.9 seconds with stop_loss; two closed at 46.0 seconds with close_after.

And yet all three have the same net P&L. −0.019180671920, to the last decimal.

The reason is in the design. The synthetic quote doesn't move after the exit, so the deadline closes landed at the same price. For the stop to separate the P&L too, the quote has to keep moving afterwards — and this design deliberately froze the quote so it measured only "does that one field trigger the condition".

Don't read that as a failure. What this comparison established is that why and when each arm closed is recorded separately per arm — four fields diverged (exit_trigger, exit_trigger_px, close_reason, elapsed time), and those four are what post-mortems are made of. A table that shows only P&L loses all of it.

The export view, where each arm's record comes out as CSV. Look at the column names — exit_trigger, exit_trigger_at, exit_trigger_px and close_reason sit at the end. **Without those four columns, a spreadsheet-based tool has no idea why anything closed**

How many arms, and what it costs

Two more things were measured in the same round.

Measured
6 arms fired at once into one quote 6/6 came back 201, zero failures or rejections, 0.02 seconds total, acceptance times spread over 6.5 milliseconds. No blocking code path
Quota N arms = N units. Part 4's three arms took used from 0 to 3
Resend / rejection / exit 0 units (same body resend 200 · different body 409 · automatic exits are counted: false)

So the budget is plain division. On the pro tier's 1,000 a month, a three-arm comparison runs 333 times a month and a two-arm one 500 times. Retries cost nothing, so mistakes are free.


5. What this combination can't tell you

① One sample says nothing. The above is one set of three arms. It does not mean −0.05% beats −0.20% — it means the low in this one window pierced that one line. You need a distribution.

② A counterfactual measures one side only. What we measure here is "how it closed with that stop level". "What that stop level saved me" is still a judgment — losses avoided by closing early leave no record.

③ We don't say which setting is right or wrong. We measure and record. The table above does not say which stop is better.

④ The P&L is not a market fact. It's arithmetic on synthetic quotes. What stands here is the stop-width comparison design, not a claim about what happens on Binance.

⑤ An unfilled arm never records the quote it saw. All three filled this time, but a limit-price comparison in another round produced an unfilled arm. reference_px is only the reference at acceptance, and published_at exists only after a fill. So an arm that didn't fill leaves no way to read what quote it was looking at. On top of that, an unfilled arm's close_after_seconds clock never even starts.

⑥ "Same quote" is circumstantial, not identification. Quote snapshots have no id. The best available evidence is that published_at matches to the millisecond — strong, but not proof of one identical quote. That's a field we need to open.

⑦ One of the three arms isn't from the tool. −0.10% is a value the harness chose. The tool never emitted that width.

⑧ Recovering an experiment by tracking id opened right after this run. When this round ran there was no cross-lookup on ?tracking=, so what actually recovered the experiment was the tag (?tag=a2-04-04 → 3 cases). That filter landed afterwards, and I did not run the new code. The recipe uses the opened path — the measurements above did not prove it.


6. When to use this, and when not to

Use it when:

Don't use it when:


Evidence for this article: axis B comparison log · per-case request/response originals · transfer verdict · recipe. The original tool run is in part 4, freqtrade.