krakenfutures: improve fetch_order fallback
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user