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 instantYou'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.
- Restart the bot and the entry time changes. Different time, different quote; different quote, different entry price
- Run both settings on one account and the positions mix. They share capital and limits
- Split the accounts and now the capital is split, so the quantities differ
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:
- you want to vary one parameter in a tool that requires a restart to change it. freqtrade's stop-loss is exactly that
- the question is "which condition fires first". Trigger, trigger quote and timing are recorded per arm
- you want many arms. Six at once cleared in 0.02 seconds with no blocking code path. The only ceiling is the monthly quota
Don't use it when:
- you want a P&L difference. Pin the quote and the P&L doesn't diverge. To get P&L too you must lengthen the window and let the quote move, which brings that movement in as a variable
- you change two or more fields at once. The comparison collapses right there. In another round, switching venue changed not just the fee but the lot size and minimum notional as well, splitting the quantities by a factor of 13
- the tool emits only ratios and no absolute level, and you plan not to document the conversion. The conversion and the rounding direction make the result
- you want a verdict from one run. N arms = N units and retries are free, so running it repeatedly is where the value is
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.