diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index a2d1eb859..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 @@ -1119,6 +1120,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 +1147,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 @@ -1163,7 +1165,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, @@ -1203,7 +1209,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 @@ -1212,8 +1224,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 @@ -1237,11 +1247,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 @@ -1253,13 +1262,15 @@ 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 + # True unless checking a stoploss order + return not is_stop 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 @@ -1278,6 +1289,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 = 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 + ) + 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"] @@ -1517,8 +1560,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 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_exchange.py b/tests/exchange/test_exchange.py index 63dd0be94..f280ec9d9 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 @@ -1165,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( @@ -1176,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, @@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized( 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", [ 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