From cd7b26717174fc7ab47e201f0fb5e5fa68f6a4ec Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 18 Nov 2025 20:29:39 +0100 Subject: [PATCH 01/10] feat: record dry-run stop_price price separately --- freqtrade/exchange/exchange.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index a2d1eb859..5a7cc7fc8 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1119,6 +1119,7 @@ class Exchange: leverage: float, params: dict | None = None, stop_loss: bool = False, + stop_price: float | None = None, ) -> CcxtOrder: now = dt_now() order_id = f"dry_run_{side}_{pair}_{now.timestamp()}" @@ -1145,7 +1146,7 @@ class Exchange: } if stop_loss: dry_order["info"] = {"stopPrice": dry_order["price"]} - dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"] + dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"] # Workaround to avoid filling stoploss orders immediately dry_order["ft_order_type"] = "stoploss" orderbook: OrderBook | None = None @@ -1517,8 +1518,9 @@ class Exchange: ordertype, side, amount, - stop_price_norm, + limit_rate or stop_price_norm, stop_loss=True, + stop_price=stop_price_norm, leverage=leverage, ) return dry_order From ca7234e33f787a8805e2549ee3395dfcc14f502c Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 18 Nov 2025 20:33:04 +0100 Subject: [PATCH 02/10] test: fix dry-stop tests --- tests/exchange/test_binance.py | 3 ++- tests/exchange/test_htx.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 2c11fc2f3..59a6c41ae 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker): assert "type" in order assert order["type"] == order_type - assert order["price"] == 220 + assert order["price"] == 217.8 + assert order["stopPrice"] == 220 assert order["amount"] == 1 diff --git a/tests/exchange/test_htx.py b/tests/exchange/test_htx.py index e32d7e85e..bffbde8c0 100644 --- a/tests/exchange/test_htx.py +++ b/tests/exchange/test_htx.py @@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker): assert "type" in order assert order["type"] == order_type - assert order["price"] == 220 + assert order["price"] == 217.8 + assert order["stopPrice"] == 220 assert order["amount"] == 1 From 73b427370b879521ae2e38705e595e769d08ea04 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 19 Nov 2025 19:51:39 +0100 Subject: [PATCH 03/10] test: add tests for dry crossed stoploss --- tests/exchange/test_exchange.py | 34 ++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 63dd0be94..4edecad57 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees( @pytest.mark.parametrize( - "side,limit,offset,expected", + "side,limit,offset,is_stop,expected", [ - ("buy", 46.0, 0.0, True), - ("buy", 26.0, 0.0, True), - ("buy", 25.55, 0.0, False), - ("buy", 1, 0.0, False), # Very far away - ("sell", 25.5, 0.0, True), - ("sell", 50, 0.0, False), # Very far away - ("sell", 25.58, 0.0, False), - ("sell", 25.563, 0.01, False), - ("sell", 5.563, 0.01, True), + ("buy", 46.0, 0.0, False, True), + ("buy", 46.0, 0.0, True, False), + ("buy", 26.0, 0.0, False, True), + ("buy", 26.0, 0.0, True, False), # Stop - didn't trigger + ("buy", 25.55, 0.0, False, False), + ("buy", 25.55, 0.0, True, True), # Stop - triggered + ("buy", 1, 0.0, False, False), # Very far away + ("buy", 1, 0.0, True, True), # Current price is above stop - triggered + ("sell", 25.5, 0.0, False, True), + ("sell", 50, 0.0, False, False), # Very far away + ("sell", 25.58, 0.0, False, False), + ("sell", 25.563, 0.01, False, False), + ("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid + ("sell", 25.566, 0.0, True, True), # stop order - triggered + ("sell", 26, 0.01, True, True), # stop order - triggered + ("sell", 5.563, 0.01, False, True), + ("sell", 5.563, 0.0, True, False), # stop order - not triggered ], ) def test__dry_is_price_crossed_with_orderbook( - default_conf, mocker, order_book_l2_usd, side, limit, offset, expected + default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected ): # Best bid 25.563 # Best ask 25.566 @@ -1134,14 +1142,14 @@ def test__dry_is_price_crossed_with_orderbook( exchange.fetch_l2_order_book = order_book_l2_usd orderbook = order_book_l2_usd.return_value result = exchange._dry_is_price_crossed( - "LTC/USDT", side, limit, orderbook=orderbook, offset=offset + "LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop ) assert result is expected assert order_book_l2_usd.call_count == 0 # Test without passing orderbook order_book_l2_usd.reset_mock() - result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset) + result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop) assert result is expected From ad256367beba9e3350b4d2718794b989ef4c9276 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 19 Nov 2025 19:52:16 +0100 Subject: [PATCH 04/10] feat: dry-is-crossed should support stoploss --- freqtrade/exchange/exchange.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 5a7cc7fc8..1b2e39b0b 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1254,13 +1254,14 @@ class Exchange: limit: float, orderbook: OrderBook | None = None, offset: float = 0.0, + is_stop: bool = False, ) -> bool: if not self.exchange_has("fetchL2OrderBook"): return True if not orderbook: orderbook = self.fetch_l2_order_book(pair, 1) try: - if side == "buy": + if (side == "buy" and not is_stop) or (side == "sell" and is_stop): price = orderbook["asks"][0][0] if limit * (1 - offset) >= price: return True From c83ea0db4fe174b95de0ad605ff3918300739823 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 19 Nov 2025 19:55:39 +0100 Subject: [PATCH 05/10] chore: fix default behavior for crossed mode --- freqtrade/exchange/exchange.py | 3 ++- tests/exchange/test_exchange.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 1b2e39b0b..11e46ca53 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1257,7 +1257,8 @@ class Exchange: is_stop: bool = False, ) -> bool: if not self.exchange_has("fetchL2OrderBook"): - return True + # True unless checking a stoploss order + return not is_stop if not orderbook: orderbook = self.fetch_l2_order_book(pair, 1) try: diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 4edecad57..743f798dd 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1173,7 +1173,10 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker): exchange.fetch_l2_order_book = MagicMock() mocker.patch(f"{EXMS}.exchange_has", return_value=False) assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0) + assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0) assert exchange.fetch_l2_order_book.call_count == 0 + assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True) + assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True) @pytest.mark.parametrize( From 3543e96ec5807dfe85ed5438152319d7f2ee7c40 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 19 Nov 2025 20:04:37 +0100 Subject: [PATCH 06/10] refactor: extract dry-market order slippage from function --- freqtrade/exchange/exchange.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 11e46ca53..b34cad18a 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1164,7 +1164,11 @@ class Exchange: if dry_order["type"] == "market" and not dry_order.get("ft_order_type"): # Update market order pricing - average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook) + slippage = 0.05 + worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage)) + average = self.get_dry_market_fill_price( + pair, side, amount, rate, worst_rate, orderbook + ) dry_order.update( { "average": average, @@ -1204,7 +1208,13 @@ class Exchange: return dry_order def get_dry_market_fill_price( - self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None + self, + pair: str, + side: str, + amount: float, + rate: float, + worst_rate: float, + orderbook: OrderBook | None, ) -> float: """ Get the market order fill price based on orderbook interpolation @@ -1213,8 +1223,6 @@ class Exchange: if not orderbook: orderbook = self.fetch_l2_order_book(pair, 20) ob_type: OBLiteral = "asks" if side == "buy" else "bids" - slippage = 0.05 - max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage)) remaining_amount = amount filled_value = 0.0 @@ -1238,11 +1246,10 @@ class Exchange: forecast_avg_filled_price = max(filled_value, 0) / amount # Limit max. slippage to specified value if side == "buy": - forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val) + forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate) else: - forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val) - + forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate) return self.price_to_precision(pair, forecast_avg_filled_price) return rate From f63484d0b027e4743bfb191e93f8006fadb6d60a Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Nov 2025 06:37:49 +0100 Subject: [PATCH 07/10] feat: add dry-limit check for stoploss orders --- freqtrade/exchange/exchange.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index b34cad18a..1b0946987 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1288,6 +1288,38 @@ class Exchange: """ Check dry-run limit order fill and update fee (if it filled). """ + if order["status"] != "closed" and order.get("ft_order_type") == "stoploss": + pair = order["symbol"] + if not orderbook and self.exchange_has("fetchL2OrderBook"): + orderbook = self.fetch_l2_order_book(pair, 20) + price = order[self._ft_has["stop_price_prop"]] + crossed = self._dry_is_price_crossed( + pair, order["side"], price, orderbook, is_stop=True + ) + if crossed: + average = self.get_dry_market_fill_price( + pair, + order["side"], + order["amount"], + price, + worst_rate=order["price"], + orderbook=orderbook, + ) + order.update( + { + "status": "closed", + "filled": order["amount"], + "remaining": 0, + "average": average, + "cost": order["amount"] * average, + } + ) + self.add_dry_order_fee( + pair, + order, + "taker" if immediate else "maker", + ) + return order if ( order["status"] != "closed" and order["type"] in ["limit"] From fade66afd969db4ce254c525d2db454dc26a0ec3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 21 Nov 2025 06:47:04 +0100 Subject: [PATCH 08/10] fix: ensure we always have a price when checking stops --- freqtrade/exchange/exchange.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 1b0946987..60f4d5fdd 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -104,6 +104,7 @@ from freqtrade.misc import ( deep_merge_dicts, file_dump_json, file_load_json, + safe_value_fallback, safe_value_fallback2, ) from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now @@ -1292,7 +1293,7 @@ class Exchange: pair = order["symbol"] if not orderbook and self.exchange_has("fetchL2OrderBook"): orderbook = self.fetch_l2_order_book(pair, 20) - price = order[self._ft_has["stop_price_prop"]] + price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price") crossed = self._dry_is_price_crossed( pair, order["side"], price, orderbook, is_stop=True ) From 1536c09df38aa0b87e5de2f00b039a5c096c4cc6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 29 Nov 2025 12:07:13 +0100 Subject: [PATCH 09/10] test: improved test naming --- tests/exchange/test_exchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 743f798dd..8f878377f 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1187,7 +1187,7 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker): (False, False, "sell", 1.0, "open", None, 0, None), ], ) -def test_check_dry_limit_order_filled_parametrized( +def test_check_dry_limit_order_filled( default_conf, mocker, crossed, From ac2723c3a0ae0a89d5b1535bca39b2aff232ad48 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 29 Nov 2025 12:23:15 +0100 Subject: [PATCH 10/10] test: add explicit test for dry stoploss order filling --- tests/exchange/test_exchange.py | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 8f878377f..f280ec9d9 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1231,6 +1231,70 @@ def test_check_dry_limit_order_filled( assert fee_mock.call_count == expected_calls +@pytest.mark.parametrize( + "immediate,crossed,expected_status,expected_fee_type", + [ + (True, True, "closed", "taker"), + (False, True, "closed", "maker"), + (True, False, "open", None), + ], +) +def test_check_dry_limit_order_filled_stoploss( + default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd +): + exchange = get_patched_exchange(mocker, default_conf) + mocker.patch.multiple( + EXMS, + exchange_has=MagicMock(return_value=True), + _dry_is_price_crossed=MagicMock(return_value=crossed), + fetch_l2_order_book=order_book_l2_usd, + ) + average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25) + fee_mock = mocker.patch( + f"{EXMS}.add_dry_order_fee", + autospec=True, + side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order, + ) + + amount = 1.75 + order = { + "symbol": "LTC/USDT", + "status": "open", + "type": "limit", + "side": "sell", + "amount": amount, + "filled": 0.0, + "remaining": amount, + "price": 25.0, + "average": 0.0, + "cost": 0.0, + "fee": None, + "ft_order_type": "stoploss", + "stopLossPrice": 24.5, + } + + result = exchange.check_dry_limit_order_filled(order, immediate=immediate) + + assert result["status"] == expected_status + assert order_book_l2_usd.call_count == 1 + if crossed: + assert result["filled"] == amount + assert result["remaining"] == 0 + assert result["average"] == 24.25 + assert result["cost"] == pytest.approx(amount * 24.25) + assert average_mock.call_count == 1 + assert fee_mock.call_count == 1 + assert fee_mock.call_args[0][1] == "LTC/USDT" + assert fee_mock.call_args[0][3] == expected_fee_type + else: + assert result["filled"] == 0.0 + assert result["remaining"] == amount + assert result["average"] == 0.0 + + assert average_mock.call_count == 0 + assert fee_mock.call_count == 0 + + @pytest.mark.parametrize( "side,price,filled,converted", [