krakenfutures: improve fetch_order fallback

This commit is contained in:
matstedt
2026-01-31 11:43:50 +01:00
committed by Matthias
parent 1d0bcbe234
commit 1c77bd6f5d
2 changed files with 128 additions and 34 deletions
+64 -25
View File
@@ -5,10 +5,12 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
import ccxt
from freqtrade.enums import MarginMode, PriceType, TradingMode from freqtrade.enums import MarginMode, PriceType, TradingMode
from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError
from freqtrade.exchange.exchange import Exchange 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__) logger = logging.getLogger(__name__)
@@ -165,36 +167,73 @@ class Krakenfutures(Exchange):
def fetch_order( def fetch_order(
self, order_id: str, pair: str, params: dict[str, Any] | None = None self, order_id: str, pair: str, params: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> CcxtOrder:
""" """Fetch order with fallback to open/closed/canceled endpoints."""
Kraken Futures fetchOrder is backed by /orders/status which only returns if self._config.get("dry_run"):
open orders or orders closed within the last 5 seconds. return self.fetch_dry_run_order(order_id)
Fall back to closed/canceled order endpoints for older orders.
"""
params = params or {} params = params or {}
try: try:
return super().fetch_order(order_id, pair, params=params) # Bypass retrier; OrderNotFound is expected for older orders.
except (RetryableOrderError, TemporaryError) as err: wrapped = Exchange.fetch_order.__wrapped__ # type: ignore[attr-defined]
order = self._fetch_order_from_closed_or_canceled(order_id, pair, params) 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: if order is not None:
return order return order
raise err
def _fetch_order_from_closed_or_canceled( # Closed/canceled: use pair and optional trigger=True for stoplosses.
self, order_id: str, pair: str, params: dict[str, Any] for has_key, fetch_fn in [
) -> dict[str, Any] | None: ("fetchClosedOrders", self._api.fetch_closed_orders),
if self.exchange_has("fetchClosedOrders"): ("fetchCanceledOrders", self._api.fetch_canceled_orders),
orders = self._api.fetch_closed_orders(pair, params=params) ]:
for order in orders or []: if not self.exchange_has(has_key):
if str(order.get("id")) == str(order_id): 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
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) return self._order_contracts_to_amount(order)
except ccxt.BaseError as e:
if self.exchange_has("fetchCanceledOrders"): logger.debug(f"{fetch_fn.__name__} failed: {e}")
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 return None
def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float:
+64 -9
View File
@@ -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): 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.""" """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( mocker.patch.object(
ex, ex,
"exchange_has", "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): 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.""" """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( 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( mocker.patch.object(
ex, 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): def test_krakenfutures_fetch_order_reraises_when_no_fallback(mocker, default_conf):
"""Re-raise when fallback cannot locate the order.""" """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(
mocker.patch.object(ex, "_fetch_order_from_closed_or_canceled", return_value=None) Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found")
)
mocker.patch.object(ex, "_fetch_order_fallback", return_value=None)
with pytest.raises(RetryableOrderError): with pytest.raises(RetryableOrderError):
ex.fetch_order("abc", "BTC/USD:USD") 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.""" """Return None when the exchange does not support order history endpoints."""
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
mocker.patch.object(ex, "exchange_has", return_value=False) 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 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): def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf):
"""Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly."""
api_mock = MagicMock() api_mock = MagicMock()