From 5cb750e2a9325e687ea2a15dcfc4625c7514a2de Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 31 Jan 2026 11:43:50 +0100 Subject: [PATCH] krakenfutures: improve fetch_order fallback --- freqtrade/exchange/krakenfutures.py | 87 ++++++++++++++++++++-------- tests/exchange/test_krakenfutures.py | 73 ++++++++++++++++++++--- 2 files changed, 127 insertions(+), 33 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 441cba75d..b8eedb5e5 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -5,10 +5,12 @@ from __future__ import annotations import logging from typing import Any +import ccxt + from freqtrade.enums import MarginMode, PriceType, TradingMode from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError from freqtrade.exchange.exchange import Exchange -from freqtrade.exchange.exchange_types import FtHas +from freqtrade.exchange.exchange_types import CcxtOrder, FtHas logger = logging.getLogger(__name__) @@ -165,36 +167,73 @@ class Krakenfutures(Exchange): def fetch_order( self, order_id: str, pair: str, params: dict[str, Any] | None = None - ) -> dict[str, Any]: - """ - Kraken Futures fetchOrder is backed by /orders/status which only returns - open orders or orders closed within the last 5 seconds. - Fall back to closed/canceled order endpoints for older orders. - """ + ) -> CcxtOrder: + """Fetch order with fallback to open/closed/canceled endpoints.""" + if self._config.get("dry_run"): + return self.fetch_dry_run_order(order_id) + params = params or {} try: - return super().fetch_order(order_id, pair, params=params) - except (RetryableOrderError, TemporaryError) as err: - order = self._fetch_order_from_closed_or_canceled(order_id, pair, params) + # Bypass retrier; OrderNotFound is expected for older orders. + wrapped = Exchange.fetch_order.__wrapped__ # type: ignore[attr-defined] + return wrapped(self, order_id, pair, params=params) + except (RetryableOrderError, TemporaryError): + pass + + order = self._fetch_order_fallback(order_id, pair, params) + if order is not None: + return order + + raise RetryableOrderError(f"Order not found in any endpoint (pair: {pair} id: {order_id})") + + def _fetch_order_fallback( + self, order_id: str, pair: str, params: dict[str, Any] + ) -> CcxtOrder | None: + """Search open, closed, and canceled order endpoints for order_id.""" + order_id_str = str(order_id) + + # Open orders: Kraken returns all symbols and includes triggers by default. + if self.exchange_has("fetchOpenOrders"): + order = self._find_order_in_list( + self._api.fetch_open_orders, None, params, order_id_str + ) if order is not None: return order - raise err - def _fetch_order_from_closed_or_canceled( - self, order_id: str, pair: str, params: dict[str, Any] - ) -> dict[str, Any] | None: - if self.exchange_has("fetchClosedOrders"): - orders = self._api.fetch_closed_orders(pair, params=params) - for order in orders or []: - if str(order.get("id")) == str(order_id): - return self._order_contracts_to_amount(order) + # Closed/canceled: use pair and optional trigger=True for stoplosses. + for has_key, fetch_fn in [ + ("fetchClosedOrders", self._api.fetch_closed_orders), + ("fetchCanceledOrders", self._api.fetch_canceled_orders), + ]: + if not self.exchange_has(has_key): + continue + order = self._find_order_in_list(fetch_fn, pair, params, order_id_str) + if order is not None: + return order + # Trigger orders (stoplosses) only supported on history endpoints + if not params.get("trigger"): + order = self._find_order_in_list( + fetch_fn, pair, {**params, "trigger": True}, order_id_str + ) + if order is not None: + return order - if self.exchange_has("fetchCanceledOrders"): - orders = self._api.fetch_canceled_orders(pair, params=params) - for order in orders or []: - if str(order.get("id")) == str(order_id): - return self._order_contracts_to_amount(order) + return None + def _find_order_in_list( + self, + fetch_fn, + symbol: str | None, + params: dict[str, Any], + order_id_str: str, + ) -> CcxtOrder | None: + """Fetch orders and return matching order_id, or None.""" + try: + for order in fetch_fn(symbol, params=params) or []: + if str(order.get("id")) == order_id_str: + return self._order_contracts_to_amount(order) + except ccxt.BaseError as e: + logger.debug(f"{fetch_fn.__name__} failed: {e}") return None def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index b1f5efee1..60f1a94a3 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -36,9 +36,14 @@ def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_conf): """Fallback to fetch_closed_orders when fetch_order can't find the order.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - mocker.patch.object(Exchange, "fetch_order", side_effect=RetryableOrderError("not found")) + # Mock the unwrapped fetch_order to raise RetryableOrderError + mocker.patch.object( + Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found") + ) mocker.patch.object( ex, "exchange_has", @@ -57,10 +62,12 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default_conf): """Fallback to fetch_canceled_orders when closed orders don't contain the order.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") mocker.patch.object( - Exchange, "fetch_order", side_effect=TemporaryError("UUID string too large") + Exchange.fetch_order, "__wrapped__", side_effect=TemporaryError("UUID string too large") ) mocker.patch.object( ex, @@ -80,24 +87,72 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default def test_krakenfutures_fetch_order_reraises_when_no_fallback(mocker, default_conf): """Re-raise when fallback cannot locate the order.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - mocker.patch.object(Exchange, "fetch_order", side_effect=RetryableOrderError("not found")) - mocker.patch.object(ex, "_fetch_order_from_closed_or_canceled", return_value=None) + mocker.patch.object( + Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found") + ) + mocker.patch.object(ex, "_fetch_order_fallback", return_value=None) with pytest.raises(RetryableOrderError): ex.fetch_order("abc", "BTC/USD:USD") -def test_krakenfutures_fetch_order_from_closed_or_canceled_returns_none(mocker, default_conf): +def test_krakenfutures_fetch_order_fallback_returns_none(mocker, default_conf): """Return None when the exchange does not support order history endpoints.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") mocker.patch.object(ex, "exchange_has", return_value=False) - res = ex._fetch_order_from_closed_or_canceled("abc", "BTC/USD:USD", {}) + res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {}) assert res is None +def test_krakenfutures_fetch_order_dry_run(mocker, default_conf): + """Test fetch_order uses dry_run order in dry_run mode.""" + conf = dict(default_conf) + conf["dry_run"] = True + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + dry_order = {"id": "dry-123", "status": "open"} + mocker.patch.object(ex, "fetch_dry_run_order", return_value=dry_order) + + res = ex.fetch_order("dry-123", "BTC/USD:USD") + assert res["id"] == "dry-123" + + +def test_krakenfutures_fetch_order_finds_trigger_order(mocker, default_conf): + """Test fetch_order finds trigger orders (stoplosses) via closed orders fallback.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + mocker.patch.object( + Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found") + ) + mocker.patch.object( + ex, + "exchange_has", + side_effect=lambda endpoint: endpoint in ("fetchOpenOrders", "fetchClosedOrders"), + ) + # Open orders returns empty, closed orders returns empty for regular, + # but returns the trigger order when trigger=True + mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + mocker.patch.object( + ex._api, + "fetch_closed_orders", + side_effect=[ + [], # Regular closed orders + [{"id": "trigger-123", "symbol": "BTC/USD:USD", "status": "closed"}], # Trigger orders + ], + create=True, + ) + + res = ex.fetch_order("trigger-123", "BTC/USD:USD") + assert res["id"] == "trigger-123" + + def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" api_mock = MagicMock()