diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index e76f0b279..02507a9f7 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -3,12 +3,10 @@ from __future__ import annotations import logging -import time from typing import Any from freqtrade.enums import MarginMode, PriceType, TradingMode from freqtrade.exceptions import ExchangeError, RetryableOrderError -from freqtrade.exchange.common import retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import FtHas @@ -22,10 +20,8 @@ class Krakenfutures(Exchange): Contains adjustments needed for Freqtrade to work with this exchange. Key differences from spot Kraken: - - CCXT does not implement fetchOrder; we emulate via open/closed/history endpoints - Stop orders use triggerPrice/triggerSignal instead of stopPrice - Multi-collateral accounts require synthetic USD balance from flex account - - OHLCV limit capped at 2000 candles """ _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ @@ -50,17 +46,8 @@ class Krakenfutures(Exchange): PriceType.MARK: "mark", PriceType.INDEX: "index", }, - # override ccxt has-gaps - "exchange_has_overrides": { - "fetchOrder": True, - "createMarketOrder": True, - }, } - @classmethod - def get_ft_has(cls) -> dict[str, Any]: - return cls._ft_has.get("exchange_has_overrides", {}) - def get_balances(self, params: dict | None = None) -> dict[str, Any]: """ Fetch account balances with special handling for Kraken Futures flex accounts. @@ -176,26 +163,25 @@ class Krakenfutures(Exchange): return super().validate_stakecurrency(stake_currency) - @retrier 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]: + """ + Kraken Futures fetchOrder is backed by /orders/status which only returns + open orders or orders closed within the last 5 seconds. + Fall back to fetchClosedOrders for older orders. + """ params = params or {} - try: - emulated = self.fetch_order_emulated(order_id, pair, params) - return self._normalize_fetched_order(emulated) - except RetryableOrderError: - pass - - order = self._fetch_order_from_history(order_id, pair, params) - if order is not None: - return self._normalize_fetched_order(order) - - raise RetryableOrderError(f"Order {order_id} not found on exchange for pair {pair}.") + return super().fetch_order(order_id, pair, params=params) + except RetryableOrderError as err: + if not self.exchange_has("fetchClosedOrders"): + raise + 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) + raise err def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: """CCXT currently does not support Kraken Futures fetchFundingHistory.""" @@ -206,254 +192,6 @@ class Krakenfutures(Exchange): logger.warning(f"Could not update funding fees for {pair}.") return 0.0 - def _strip_history_params(self, params: dict[str, Any]) -> dict[str, Any]: - if not params: - return {} - # These time-range params are only valid for history endpoints. - history_keys = {"since", "before", "from", "to"} - return {k: v for k, v in params.items() if k not in history_keys} - - def fetch_order_emulated( - self, order_id: str, pair: str, params: dict[str, Any] - ) -> dict[str, Any]: - list_params = self._strip_history_params(params) - - try: - open_orders = self.fetch_open_orders(pair, params=list_params) - except Exception: - open_orders = [] - - for o in open_orders: - if self._contains_value(o, order_id): - return self._order_contracts_to_amount(o) - - try: - closed_orders = self.fetch_closed_orders(pair, params=list_params) - except Exception: - closed_orders = [] - - for o in closed_orders: - if self._contains_value(o, order_id): - return self._order_contracts_to_amount(o) - - raise RetryableOrderError(f"Order not found (pair: {pair} id: {order_id}).") - - def _fetch_order_from_history( - self, order_id: str, pair: str, params: dict[str, Any] - ) -> dict[str, Any] | None: - # Kraken Futures has separate history feeds for orders and triggers. - for method_name in ("historyGetOrders", "historyGetTriggers"): - order = self._fetch_order_from_history_method(method_name, order_id, pair, params) - if order is not None: - return order - return None - - def _fetch_order_from_history_method( - self, - method_name: str, - order_id: str, - pair: str, - params: dict[str, Any], - ) -> dict[str, Any] | None: - if not hasattr(self._api, method_name): - return None - - hist_params = dict(params) - if not any(k in hist_params for k in ("since", "before", "from", "to")): - now_ms = int(getattr(self._api, "milliseconds", lambda: int(time.time() * 1000))()) - hist_params["since"] = now_ms - 48 * 60 * 60 * 1000 # 48 hours lookback - - try: - hist = getattr(self._api, method_name)(hist_params) - except Exception: - return None - - elements = self._extract_history_elements(hist) - return self._parse_order_from_history_elements(elements, order_id, pair) - - @staticmethod - def _extract_history_elements(hist: Any) -> list[dict[str, Any]]: - if isinstance(hist, list): - return [x for x in hist if isinstance(x, dict)] - - if not isinstance(hist, dict): - return [] - - if isinstance(hist.get("elements"), list): - return [x for x in hist["elements"] if isinstance(x, dict)] - - result = hist.get("result") - if isinstance(result, dict) and isinstance(result.get("elements"), list): - return [x for x in result["elements"] if isinstance(x, dict)] - - events = hist.get("events") - if isinstance(events, list): - return [x for x in events if isinstance(x, dict)] - - return [] - - def _parse_order_from_history_elements( - self, elements: list[dict[str, Any]], order_id: str, pair: str - ) -> dict[str, Any] | None: - for el in elements: - event = el.get("event") or el.get("events") or {} - if not isinstance(event, dict): - continue - - for event_name, payload in event.items(): - if not isinstance(payload, dict): - continue - - orderish = self._extract_orderish(payload) - uid = self._extract_uid(orderish, payload) - - if uid is None and self._contains_value(payload, order_id): - uid = order_id - - if str(uid) != str(order_id): - continue - - return self._build_ccxt_like_order_from_history( - el, str(event_name), orderish, order_id, pair - ) - - return None - - @staticmethod - def _extract_orderish(payload: dict[str, Any]) -> dict[str, Any]: - for key in ("order", "trigger", "triggerOrder"): - v = payload.get(key) - if isinstance(v, dict): - return v - return payload - - @staticmethod - def _extract_uid(orderish: dict[str, Any], payload: dict[str, Any]) -> Any: - keys = ( - "uid", - "id", - "orderId", - "order_id", - "orderUid", - "triggerId", - "trigger_id", - "triggerUid", - "triggerOrderUid", - ) - for k in keys: - if k in orderish: - return orderish.get(k) - for k in keys: - if k in payload: - return payload.get(k) - return None - - @staticmethod - def _contains_value(obj: Any, needle: str) -> bool: - if isinstance(obj, dict): - return any(Krakenfutures._contains_value(v, needle) for v in obj.values()) - if isinstance(obj, list): - return any(Krakenfutures._contains_value(v, needle) for v in obj) - return str(obj) == str(needle) - - def _build_ccxt_like_order_from_history( - self, - el: dict[str, Any], - event_name: str, - order: dict[str, Any], - order_id: str, - pair: str, - ) -> dict[str, Any]: - status = self._map_history_event_to_status(event_name) - - amount = self._safe_float(order.get("quantity") or order.get("qty")) - filled = self._safe_float( - order.get("filled") or order.get("filledQty") or order.get("filled_qty") - ) - price = self._safe_float(order.get("limitPrice") or order.get("price")) - stop_price = self._safe_float( - order.get("stopPrice") - or order.get("triggerPrice") - or order.get("trigger_price") - or order.get("stop_price") - ) - - side_raw = str(order.get("direction") or order.get("side") or "").lower() - if not side_raw and isinstance(order.get("buy"), bool): - side_raw = "buy" if order["buy"] else "sell" - side = "buy" if side_raw == "buy" else "sell" if side_raw == "sell" else None - - order_type = self._infer_order_type_from_history(order, price) - - remaining = None - if amount is not None and filled is not None: - remaining = max(amount - filled, 0.0) - - ts = order.get("timestamp") or order.get("time") or el.get("timestamp") or el.get("time") - ts_int = int(ts) if ts is not None else None - - reduce_only = order.get("reduceOnly") - if isinstance(reduce_only, str): - reduce_only = reduce_only.lower() == "true" - - result: dict[str, Any] = { - "id": order_id, - "symbol": pair, - "status": status, - "side": side, - "type": order_type, - "price": price, - "amount": amount, - "filled": filled, - "remaining": remaining, - "timestamp": ts_int, - "datetime": self._api.iso8601(ts_int) if ts_int is not None else None, - "info": el, - } - - if stop_price is not None: - result["stopPrice"] = stop_price - - if isinstance(reduce_only, bool): - result["reduceOnly"] = reduce_only - - return result - - @staticmethod - def _infer_order_type_from_history(order: dict[str, Any], price: float | None) -> str | None: - raw = str(order.get("orderType") or order.get("type") or "").lower() - - if raw in ("lmt", "limit", "post", "ioc"): - return "limit" - if raw in ("mkt", "market"): - return "market" - - if raw in ( - "stp", - "stop", - "take_profit", - "takeprofit", - "take-profit", - "trailing_stop", - "trailingstop", - ): - return "limit" if price is not None else "market" - - if price is not None: - return "limit" - return None - - @staticmethod - def _map_history_event_to_status(event_name: str) -> str: - name = (event_name or "").lower() - if "cancel" in name: - return "canceled" - if "reject" in name: - return "rejected" - if "place" in name: - return "open" - return "unknown" - @staticmethod def _safe_float(v: Any) -> float | None: try: @@ -462,69 +200,3 @@ class Krakenfutures(Exchange): return float(v) except (TypeError, ValueError): return None - - @staticmethod - def _find_first_value(obj: Any, keys: set[str]) -> Any | None: - if obj is None: - return None - if isinstance(obj, dict): - for k, v in obj.items(): - if k in keys and v not in (None, ""): - return v - found = Krakenfutures._find_first_value(v, keys) - if found is not None: - return found - return None - if isinstance(obj, list): - for v in obj: - found = Krakenfutures._find_first_value(v, keys) - if found is not None: - return found - return None - return None - - def _normalize_fetched_order(self, order: dict[str, Any]) -> dict[str, Any]: - # 1) Ensure stopPrice exists for trigger orders - if order.get("stopPrice") is None: - raw = ( - order.get("triggerPrice") - or order.get("trigger_price") - or order.get("stop_price") - or self._find_first_value( - order.get("info"), - {"stopPrice", "triggerPrice", "stop_price", "trigger_price"}, - ) - ) - sp = self._safe_float(raw) - if sp is not None: - order["stopPrice"] = sp - - # 2) Fix type when we clearly have a market trigger (no limit price, but has stopPrice) - if ( - order.get("type") in (None, "limit") - and order.get("price") is None - and order.get("stopPrice") is not None - ): - order["type"] = "market" - - return order - - def fetch_open_orders( - self, - pair: str | None = None, - since: int | None = None, - limit: int | None = None, - params: dict[str, Any] | None = None, - ) -> list[dict[str, Any]]: - params = self._strip_history_params(params or {}) - return self._api.fetch_open_orders(pair, since, limit, params) - - def fetch_closed_orders( - self, - pair: str | None = None, - since: int | None = None, - limit: int | None = None, - params: dict[str, Any] | None = None, - ) -> list[dict[str, Any]]: - params = self._strip_history_params(params or {}) - return self._api.fetch_closed_orders(pair, since, limit, params) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 10f56aecb..adf1ee339 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -5,9 +5,6 @@ from __future__ import annotations from copy import deepcopy from unittest.mock import MagicMock -import pytest -from ccxt.base.errors import NotSupported - from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exceptions import RetryableOrderError from freqtrade.exchange.exchange import Exchange @@ -16,12 +13,12 @@ from tests.conftest import EXMS, get_patched_exchange def test_krakenfutures_ft_has_overrides(): - """Test that _ft_has contains correct capability overrides.""" + """Test that _ft_has contains Kraken Futures stoploss settings.""" ft_has = Krakenfutures._ft_has assert ft_has["stoploss_on_exchange"] is True assert ft_has["stoploss_order_types"] == {"limit": "limit", "market": "market"} - assert ft_has["exchange_has_overrides"]["fetchOrder"] is True - assert ft_has["exchange_has_overrides"]["createMarketOrder"] is True + assert ft_has["stop_price_param"] == "triggerPrice" + assert ft_has["stop_price_type_field"] == "triggerSignal" def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): @@ -34,140 +31,23 @@ def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000 -def test_krakenfutures_fetch_order_falls_back_to_open_orders(mocker, default_conf): - """Test fetch_order falls back to open orders when fetchOrder not supported.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object( - ex._api, - "fetch_order", - side_effect=NotSupported("fetchOrder not supported"), - create=True, - ) - mocker.patch.object( - ex._api, - "fetch_open_orders", - return_value=[{"id": "abc", "status": "open"}], - create=True, - ) - mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - - res = ex.fetch_order("abc", "BTC/USD:USD") - assert res["id"] == "abc" - assert res["status"] == "open" - - -def test_krakenfutures_fetch_order_falls_back_when_super_raises_attributeerror( - mocker, default_conf -): - """Test fetch_order handles AttributeError from missing fetch_open_order.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object( - ex._api, - "fetch_order", - side_effect=AttributeError("fetch_open_order missing"), - create=True, - ) - mocker.patch.object( - ex._api, - "fetch_open_orders", - return_value=[{"id": "abc", "status": "open"}], - create=True, - ) - mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - - res = ex.fetch_order("abc", "BTC/USD:USD") - assert res["id"] == "abc" - assert res["status"] == "open" - - def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_conf): - """Test fetch_order falls back to closed orders when not found in open.""" + """Fallback to fetch_closed_orders when fetch_order can't find the order.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") mocker.patch.object( - ex._api, - "fetch_order", - side_effect=NotSupported("fetchOrder not supported"), - create=True, + Exchange, "fetch_order", side_effect=RetryableOrderError("not found") ) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + mocker.patch.object(ex, "exchange_has", return_value=True) mocker.patch.object( ex._api, "fetch_closed_orders", - return_value=[{"id": "def", "status": "closed"}], - create=True, - ) - - res = ex.fetch_order("def", "BTC/USD:USD") - assert res["id"] == "def" - assert res["status"] == "closed" - - -def test_krakenfutures_fetch_order_raises_when_not_found(mocker, default_conf): - """When order is not found anywhere, Krakenfutures raises RetryableOrderError.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object( - ex._api, - "fetch_order", - side_effect=NotSupported("fetchOrder not supported"), - create=True, - ) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) - mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - mocker.patch.object(ex._api, "historyGetOrders", return_value={"elements": []}, create=True) - mocker.patch.object(ex._api, "historyGetTriggers", return_value={"elements": []}, create=True) - - with pytest.raises(RetryableOrderError, match="not found on exchange"): - ex.fetch_order("nope", "BTC/USD:USD") - - -def test_krakenfutures_fetch_order_falls_back_to_history_orders(mocker, default_conf): - """Test fetch_order falls back to historyGetOrders endpoint.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object( - ex._api, - "fetch_order", - side_effect=NotSupported("fetchOrder not supported"), - create=True, - ) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) - mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - - mocker.patch.object( - ex._api, - "historyGetOrders", - return_value={ - "elements": [ - { - "event": { - "OrderCancelled": { - "order": { - "uid": "abc", - "direction": "Buy", - "quantity": "0.0002", - "filled": "0", - "timestamp": 1767962185989, - "limitPrice": "90338", - } - } - } - } - ] - }, + return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "closed"}], create=True, ) res = ex.fetch_order("abc", "BTC/USD:USD") assert res["id"] == "abc" - assert res["status"] == "canceled" - assert res["side"] == "buy" - assert res["type"] == "limit" - assert res["amount"] == 0.0002 - assert res["filled"] == 0.0 def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): @@ -202,110 +82,6 @@ def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_c assert params["reduceOnly"] is True -def test_krakenfutures_fetch_order_falls_back_to_history_triggers(mocker, default_conf): - """Test fetch_order falls back to historyGetTriggers for stop orders.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object( - ex._api, - "fetch_order", - side_effect=NotSupported("fetchOrder not supported"), - create=True, - ) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) - mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - - mocker.patch.object(ex._api, "historyGetOrders", return_value={"elements": []}, create=True) - mocker.patch.object( - ex._api, - "historyGetTriggers", - return_value={ - "elements": [ - { - "event": { - "TriggerCancelled": { - "trigger": { - "uid": "abc", - "direction": "Buy", - "quantity": "0.0002", - "timestamp": 1767962185989, - "triggerPrice": "136238", - "orderType": "stp", - "reduceOnly": False, - } - } - } - } - ] - }, - create=True, - ) - - res = ex.fetch_order("abc", "BTC/USD:USD") - assert res["id"] == "abc" - assert res["status"] == "canceled" - assert res["side"] == "buy" - assert res["type"] == "market" - assert res["stopPrice"] == 136238.0 - - -def test_krakenfutures_fetch_order_normalizes_stopprice_and_type_from_trigger_info( - mocker, default_conf -): - """Test fetch_order normalizes stopPrice from trigger info and fixes order type.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object( - ex._api, - "fetch_order", - side_effect=NotSupported("fetchOrder not supported"), - create=True, - ) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) - mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - - mocker.patch.object(ex._api, "historyGetOrders", return_value={"elements": []}, create=True) - - # stopPrice exists in the payload, but NOT inside the trigger dict that becomes "orderish" - # This forces stopPrice to be picked up via _normalize_fetched_order scanning order["info"]. - mocker.patch.object( - ex._api, - "historyGetTriggers", - return_value={ - "elements": [ - { - "event": { - "TriggerCancelled": { - "trigger": { - "uid": "abc", - "direction": "Buy", - "quantity": "0.0002", - "orderType": "lmt", - }, - "stopPrice": "136983.0", - } - } - } - ] - }, - create=True, - ) - - res = ex.fetch_order("abc", "BTC/USD:USD") - assert res["id"] == "abc" - assert res["status"] == "canceled" - assert res["side"] == "buy" - assert res["stopPrice"] == 136983.0 - assert res["type"] == "market" - - -def test_krakenfutures_exchange_has_create_market_order_override(mocker, default_conf): - """Test exchange_has override returns True for createMarketOrder.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - ex._api.has = {"createMarketOrder": False} - assert ex.exchange_has("createMarketOrder") is True - - def test_krakenfutures_validate_stakecurrency_allows_eur(mocker, default_conf): """Test validate_stakecurrency allows EUR for multi-collateral accounts.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")