From 72995835ea3dca61bd585f5dfbf608f85146c57c Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:58:59 +0100 Subject: [PATCH 01/91] feat(exchange): enable krakenfutures in supported list --- freqtrade/exchange/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index 7f53c39ef..e7c1fbbbb 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -39,7 +39,6 @@ BAD_EXCHANGES = { "bitmex": "Various reasons", "probit": "Requires additional, regular calls to `signIn()`", "poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders", - "krakenfutures": "Unsupported futures exchange", "kucoinfutures": "Unsupported futures exchange", "poloniexfutures": "Unsupported futures exchange", "binancecoinm": "Unsupported futures exchange", @@ -63,6 +62,7 @@ SUPPORTED_EXCHANGES = [ "htx", "hyperliquid", "kraken", + "krakenfutures", "okx", "myokx", ] From 271ee6d36ea67950e04c03c169a652acbd5cc035 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:59:13 +0100 Subject: [PATCH 02/91] feat(exchange): honor subclass has overrides in exchange validation --- freqtrade/exchange/exchange_utils.py | 39 ++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 43d7acbd6..402fbbb32 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -54,22 +54,25 @@ def available_exchanges(ccxt_module: CcxtModuleType | None = None) -> list[str]: return [x for x in exchanges if validate_exchange(x)[0]] -def _exchange_has_helper(ex_mod: ccxt.Exchange, required: dict[str, list[str]]) -> list[str]: +def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) -> list[str]: """ - Checks availability of methods (or their replacement)s in ex_mod.has - :param ex_mod: ccxt Exchange module + Checks availability of methods (or their replacement)s in a merged has-dict. + :param ex_has: merged "has" dict (ccxt + freqtrade overrides) :param required: dict of required methods, with possible replacement methods as list :return: list of missing required methods """ return [ k for k, v in required.items() - if ex_mod.has.get(k) is not True - and (len(v) == 0 or not (all(ex_mod.has.get(x) for x in v))) + if ex_has.get(k) is not True and (len(v) == 0 or not (all(ex_has.get(x) for x in v))) ] -def validate_exchange(exchange: str) -> tuple[bool, str, str, ccxt.Exchange | None]: +def validate_exchange( + exchange: str, + ft_has_overrides: dict[str, Any] | None = None, +) -> tuple[bool, str, str, ccxt.Exchange | None]: + """ returns: can_use, reason, exchange_object with Reason including both missing and missing_opt @@ -82,17 +85,22 @@ def validate_exchange(exchange: str) -> tuple[bool, str, str, ccxt.Exchange | No if not ex_mod or not ex_mod.has: return False, "", "", None + ex_has = dict(ex_mod.has or {}) + if ft_has_overrides: + ex_has.update(ft_has_overrides) + + result = True reasons = [] reasons_fut = "" - missing = _exchange_has_helper(ex_mod, EXCHANGE_HAS_REQUIRED) + missing = _exchange_has_helper(ex_has, EXCHANGE_HAS_REQUIRED) if missing: result = False reasons.append(f"missing: {', '.join(missing)}") - missing_opt = _exchange_has_helper(ex_mod, EXCHANGE_HAS_OPTIONAL) + missing_opt = _exchange_has_helper(ex_has, EXCHANGE_HAS_OPTIONAL) - missing_futures = _exchange_has_helper(ex_mod, EXCHANGE_HAS_OPTIONAL_FUTURES) + missing_futures = _exchange_has_helper(ex_has, EXCHANGE_HAS_OPTIONAL_FUTURES) if exchange.lower() in BAD_EXCHANGES: result = False @@ -111,8 +119,17 @@ def _build_exchange_list_entry( exchange_name: str, exchangeClasses: dict[str, Any] ) -> ValidExchangesType: exchange_name = exchange_name.lower() - valid, comment, comment_fut, ex_mod = validate_exchange(exchange_name) mapped_exchange_name = MAP_EXCHANGE_CHILDCLASS.get(exchange_name, exchange_name).lower() + + resolved = exchangeClasses.get(mapped_exchange_name) + ft_has_overrides = None + if resolved: + get_ft_has = getattr(resolved["class"], "get_ft_has", None) + if callable(get_ft_has): + ft_has_overrides = get_ft_has() or None + + valid, comment, comment_fut, ex_mod = validate_exchange(exchange_name, ft_has_overrides) + is_alias = getattr(ex_mod, "alias", False) result: ValidExchangesType = { "name": getattr(ex_mod, "name", exchange_name), @@ -128,7 +145,7 @@ def _build_exchange_list_entry( else None, "trade_modes": [{"trading_mode": "spot", "margin_mode": ""}], } - if resolved := exchangeClasses.get(mapped_exchange_name): + if resolved: supported_modes: list[TradeModeType] = [ {"trading_mode": tm.value, "margin_mode": mm.value} for tm, mm in resolved["class"]._supported_trading_mode_margin_pairs From 51588c2ef14a7b2d4bddab771dcacf2168a78ec3 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:59:19 +0100 Subject: [PATCH 03/91] feat(exchange): apply ft_has overrides in check_exchange --- freqtrade/exchange/check_exchange.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/check_exchange.py b/freqtrade/exchange/check_exchange.py index 583868744..d6ed44f66 100644 --- a/freqtrade/exchange/check_exchange.py +++ b/freqtrade/exchange/check_exchange.py @@ -1,15 +1,30 @@ import logging +from typing import Any from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import available_exchanges, is_exchange_known_ccxt, validate_exchange from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS, SUPPORTED_EXCHANGES +from freqtrade.resolvers.exchange_resolver import ExchangeResolver logger = logging.getLogger(__name__) +def _get_ft_has_overrides(exchange_name: str) -> dict[str, Any] | None: + subclassed = {e["name"].lower(): e for e in ExchangeResolver.search_all_objects({}, False)} + mapped = MAP_EXCHANGE_CHILDCLASS.get(exchange_name.lower(), exchange_name.lower()).lower() + resolved = subclassed.get(mapped) + if not resolved: + return None + + get_ft_has = getattr(resolved["class"], "get_ft_has", None) + if callable(get_ft_has): + return get_ft_has() or None + return None + + def check_exchange(config: Config, check_for_bad: bool = True) -> bool: """ Check if the exchange name in the config file is supported by Freqtrade @@ -47,7 +62,8 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool: f"{', '.join(available_exchanges())}" ) - valid, reason, _, _ = validate_exchange(exchange) + ft_has_overrides = _get_ft_has_overrides(exchange) + valid, reason, _, _ = validate_exchange(exchange, ft_has_overrides) if not valid: if check_for_bad: raise OperationalException( @@ -72,3 +88,4 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool: ) return True + From 1a5f4127e08645cd8d46222594af4a8da551f066 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:59:25 +0100 Subject: [PATCH 04/91] feat(exchange): register Kraken Futures exchange --- freqtrade/exchange/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 709738efc..71b8330c5 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -44,6 +44,7 @@ from freqtrade.exchange.htx import Htx from freqtrade.exchange.hyperliquid import Hyperliquid from freqtrade.exchange.idex import Idex from freqtrade.exchange.kraken import Kraken +from freqtrade.exchange.krakenfutures import KrakenFutures, Krakenfutures from freqtrade.exchange.kucoin import Kucoin from freqtrade.exchange.lbank import Lbank from freqtrade.exchange.luno import Luno From 46e96e20a91b0452770d634788fefc3b3bdb0cae Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:59:42 +0100 Subject: [PATCH 05/91] feat(exchange): add Kraken Futures exchange class --- freqtrade/exchange/krakenfutures.py | 611 ++++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 freqtrade/exchange/krakenfutures.py diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py new file mode 100644 index 000000000..2eba10201 --- /dev/null +++ b/freqtrade/exchange/krakenfutures.py @@ -0,0 +1,611 @@ +"""Kraken Futures exchange subclass""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from ccxt.base.errors import NotSupported, OrderNotFound + +from freqtrade.constants import BuySell +from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode +from freqtrade.exceptions import OperationalException +from freqtrade.exchange.common import retrier +from freqtrade.exchange.exchange import Exchange +from freqtrade.exchange.exchange_types import FtHas + + +logger = logging.getLogger(__name__) + + +class KrakenFutures(Exchange): + """Kraken Futures exchange class. + + 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]] = [ + (TradingMode.FUTURES, MarginMode.ISOLATED), + ] + + # Freqtrade uses _ft_has (exchange_has_overrides) for config validation. + _ft_has: FtHas = { + "stoploss_on_exchange": True, + "stoploss_order_types": { + "limit": "limit", + "market": "market", + }, + # request param used by Exchange._get_stop_params + "stop_price_param": "triggerPrice", + # response key used by stoploss_adjust and general stop parsing + "stop_price_prop": "stopPrice", + # optional futures price type mapping (only used if stoploss_price_type is configured) + "stop_price_type_field": "triggerSignal", + "stop_price_type_value_mapping": { + PriceType.LAST: "last", + PriceType.MARK: "mark", + PriceType.INDEX: "index", + }, + # override ccxt has-gaps + "exchange_has_overrides": { + "fetchOrder": True, + "createMarketOrder": True, + }, + } + + @staticmethod + def get_ft_has() -> dict[str, Any]: + # Kept for compatibility with older tests and helper code. + return { + "fetchOrder": True, + "createMarketOrder": True, + "stoploss_on_exchange": True, + "stoploss_order_types": { + "limit": "limit", + "market": "market", + }, + } + + def get_balances(self, params: dict | None = None) -> dict[str, Any]: + balances = super().get_balances(params=params) + + stake = str(self._config.get("stake_currency", "")).upper() + if stake != "USD": + return balances + + flex = self._get_flex_account(balances, params) + if flex is None: + return balances + + usd_free, usd_total = self._extract_usd_from_flex(flex) + if usd_free is None or usd_total is None: + return balances + + # Preserve existing USD if higher (usually dust) + if isinstance(balances.get("free"), dict): + existing = self._safe_float(balances["free"].get("USD")) + if existing is not None: + usd_free = max(existing, usd_free) + + self._apply_usd_balances(balances, usd_free, usd_total) + return balances + + def _get_flex_account( + self, balances: dict[str, Any], params: dict | None + ) -> dict[str, Any] | None: + """Extract flex account from balances or fetch directly.""" + flex = self._extract_flex_from_raw(balances) + if flex is not None: + return flex + + try: + raw = self._api.fetch_balance(params or {}) + except Exception: + return None + return self._extract_flex_from_raw(raw) + + @staticmethod + def _extract_flex_from_raw(raw: dict[str, Any] | None) -> dict[str, Any] | None: + """Navigate raw -> info -> accounts -> flex.""" + if not isinstance(raw, dict): + return None + info = raw.get("info") + if not isinstance(info, dict): + return None + accounts = info.get("accounts") + if not isinstance(accounts, dict): + return None + flex = accounts.get("flex") + return flex if isinstance(flex, dict) else None + + def _extract_usd_from_flex(self, flex: dict[str, Any]) -> tuple[float | None, float | None]: + """Extract USD free and total from flex account.""" + usd_free = self._safe_float(flex.get("availableMargin") or flex.get("available_margin")) + usd_total = self._safe_float( + flex.get("balanceValue") or flex.get("collateralValue") or flex.get("portfolioValue") + ) + + # Fallback: sum currencies[*].value + if usd_total is None: + usd_total = self._sum_currencies_value(flex.get("currencies")) + + # Fill missing with the other if available + if usd_total is None and usd_free is not None: + usd_total = usd_free + if usd_free is None and usd_total is not None: + usd_free = usd_total + + return usd_free, usd_total + + def _sum_currencies_value(self, currencies: Any) -> float | None: + """Sum value fields from currencies dict.""" + if not isinstance(currencies, dict): + return None + total = 0.0 + found = False + for cur in currencies.values(): + if isinstance(cur, dict): + v = self._safe_float(cur.get("value")) + if v is not None: + total += v + found = True + return total if found else None + + @staticmethod + def _apply_usd_balances(balances: dict[str, Any], usd_free: float, usd_total: float) -> None: + """Update balances dict with USD values.""" + balances["USD"] = {"free": usd_free, "used": 0.0, "total": usd_total} + balances.setdefault("free", {}) + balances.setdefault("used", {}) + balances.setdefault("total", {}) + + if isinstance(balances["free"], dict): + balances["free"]["USD"] = usd_free + if isinstance(balances["used"], dict): + balances["used"]["USD"] = 0.0 + if isinstance(balances["total"], dict): + balances["total"]["USD"] = usd_total + + def validate_stakecurrency(self, stake_currency: str) -> None: + # Kraken Futures multi-collateral allows EUR collateral even if markets look USD-settled. + if str(stake_currency).upper() == "EUR": + return + super().validate_stakecurrency(stake_currency) + + @retrier + def fetch_order( + self, + order_id: str, + pair: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + params = params or {} + + order = self._fetch_order_default(order_id, pair, params) + if order is not None: + return self._normalize_fetched_order(order) + + order = self._fetch_order_scan_open_closed(order_id, pair, params) + if order is not None: + return self._normalize_fetched_order(order) + + order = self._fetch_order_from_history(order_id, pair, params) + if order is not None: + return self._normalize_fetched_order(order) + + # Do not crash the bot during startup if the exchange cannot find the order. + now_ms = int(getattr(self._api, "milliseconds", lambda: int(time.time() * 1000))()) + try: + iso = self._api.iso8601(now_ms) + except Exception: + iso = None + + logger.warning( + "Order not found on exchange, returning pseudo order to avoid crash. id=%s pair=%s", + order_id, + pair, + ) + + return { + "id": order_id, + "symbol": pair, + "status": "open", + "side": None, + "type": None, + "price": 0.0, + "amount": 0.0, + "filled": 0.0, + "remaining": 0.0, + "timestamp": now_ms, + "datetime": iso, + "info": {"_ft_note": "Order not found via API, pseudo order returned"}, + } + + def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date): + try: + return super().get_funding_fees(pair, amount, is_short, open_date) + except OperationalException as e: + if "fetch_funding_history" in str(e): + logger.debug("Kraken Futures: fetch_funding_history unsupported, returning 0.") + return 0.0 + raise + + def _fetch_order_default( + self, order_id: str, pair: str, params: dict[str, Any] + ) -> dict[str, Any] | None: + try: + return self._api.fetch_order(order_id, pair, params) + except (NotSupported, AttributeError, OrderNotFound): + return None + + @staticmethod + def _filter_params_for_open_closed(params: dict[str, Any]) -> dict[str, Any]: + if not params: + return {} + blacklist = {"since", "before", "from", "to"} + return {k: v for k, v in params.items() if k not in blacklist} + + def _fetch_order_scan_open_closed( + self, order_id: str, pair: str, params: dict[str, Any] + ) -> dict[str, Any] | None: + list_params = self._filter_params_for_open_closed(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 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 o + + return None + + 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: + if v is None or v == "": + return None + 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._filter_params_for_open_closed(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._filter_params_for_open_closed(params or {}) + return self._api.fetch_closed_orders(pair, since, limit, params) + + def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> dict[str, Any]: + params: dict[str, Any] = super()._get_stop_params( + side=side, ordertype=ordertype, stop_price=stop_price + ) + + # Force Kraken Futures naming + params.setdefault("triggerPrice", stop_price) + + trigger_signal = self._get_trigger_signal() + if trigger_signal is not None: + params.setdefault("triggerSignal", trigger_signal) + + if getattr(self, "trading_mode", None) == TradingMode.FUTURES: + params.setdefault("reduceOnly", True) + + return params + + def _get_trigger_signal(self) -> str | None: + ex_conf = self._config.get("exchange") + if isinstance(ex_conf, dict): + v = ex_conf.get("triggerSignal") or ex_conf.get("trigger_signal") + if isinstance(v, str) and v.strip(): + return v.strip() + return "mark" + + def ohlcv_candle_limit( + self, + timeframe: str, + candle_type: CandleType, + since_ms: int | None = None, + ) -> int: + limit = super().ohlcv_candle_limit( + timeframe, + candle_type=candle_type, + since_ms=since_ms, + ) + return min(limit, 2000) + + +class Krakenfutures(KrakenFutures): + pass From 2f7e77a41ec5ac56cfea3c36c3909a52f04c5962 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:59:47 +0100 Subject: [PATCH 06/91] test(exchange): add Kraken Futures exchange tests --- freqtrade/exchange/exchange_utils.py | 10 +- tests/exchange/test_krakenfutures.py | 355 +++++++++++++++++++++++++++ 2 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 tests/exchange/test_krakenfutures.py diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 402fbbb32..9d13c067d 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -54,13 +54,21 @@ def available_exchanges(ccxt_module: CcxtModuleType | None = None) -> list[str]: return [x for x in exchanges if validate_exchange(x)[0]] -def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) -> list[str]: +def _exchange_has_helper( + ex_has_or_exchange: dict[str, Any] | ccxt.Exchange, + required: dict[str, list[str]], +) -> list[str]: """ Checks availability of methods (or their replacement)s in a merged has-dict. :param ex_has: merged "has" dict (ccxt + freqtrade overrides) :param required: dict of required methods, with possible replacement methods as list :return: list of missing required methods """ + if isinstance(ex_has_or_exchange, dict): + ex_has = ex_has_or_exchange + else: + ex_has = getattr(ex_has_or_exchange, "has", {}) or {} + return [ k for k, v in required.items() diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py new file mode 100644 index 000000000..0c6a21a6e --- /dev/null +++ b/tests/exchange/test_krakenfutures.py @@ -0,0 +1,355 @@ +"""Tests for Kraken Futures exchange class""" + +from __future__ import annotations + +from copy import deepcopy + +from ccxt.base.errors import NotSupported + +from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.exchange.exchange import Exchange +from freqtrade.exchange.krakenfutures import KrakenFutures +from tests.conftest import get_patched_exchange + + +def test_krakenfutures_get_ft_has(): + """Test that get_ft_has returns correct capability flags.""" + ft_has = KrakenFutures.get_ft_has() + assert ft_has["fetchOrder"] is True + assert ft_has["createMarketOrder"] is True + assert ft_has["stoploss_on_exchange"] is True + assert ft_has["stoploss_order_types"] == {"limit": "limit", "market": "market"} + + +def test_krakenfutures_ohlcv_candle_limit_caps_at_2000(mocker, default_conf): + """Test that OHLCV candle limit is capped at 2000.""" + mocker.patch.object(Exchange, "ohlcv_candle_limit", return_value=5000) + + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + assert isinstance(ex, KrakenFutures) + + 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.""" + 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=[{"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_returns_pseudo_order_when_not_found(mocker, default_conf): + """When order is not found anywhere, KrakenFutures returns a pseudo order to avoid crashes.""" + 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) + + res = ex.fetch_order("nope", "BTC/USD:USD") + # Returns a pseudo order with status open and the requested id + assert res["id"] == "nope" + assert res["status"] == "open" + assert res["symbol"] == "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", + } + } + } + } + ] + }, + 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_get_stop_params_adds_triggerprice_signal_and_reduceonly( + mocker, default_conf +): + """Test _get_stop_params adds triggerPrice, triggerSignal, and reduceOnly.""" + conf = deepcopy(default_conf) + conf["trading_mode"] = TradingMode.FUTURES + conf["margin_mode"] = MarginMode.ISOLATED + + if isinstance(conf.get("exchange"), dict): + conf["exchange"]["triggerSignal"] = "mark" + + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + params = ex._get_stop_params(side="sell", ordertype="market", stop_price=90000.0) + + assert params["triggerPrice"] == 90000.0 + assert params["triggerSignal"] == "mark" + 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") + ex.validate_stakecurrency("EUR") + + +def test_krakenfutures_get_balances_synth_usd_from_flex(mocker, default_conf): + """Test get_balances synthesizes USD balance from flex account.""" + conf = dict(default_conf) + conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + sample = { + "EUR": {"free": 10.0, "used": 0.0, "total": 10.0}, + "free": {"EUR": 10.0}, + "used": {"EUR": 0.0}, + "total": {"EUR": 10.0}, + "info": { + "accounts": { + "flex": { + "availableMargin": 11.0, + "balanceValue": 12.0, + } + } + }, + } + + mocker.patch.object(Exchange, "get_balances", return_value=sample) + res = ex.get_balances() + assert res["USD"]["free"] == 11.0 + assert res["USD"]["total"] == 12.0 + + +def test_krakenfutures_get_balances_falls_back_to_ccxt_fetch_balance(mocker, default_conf): + """Test get_balances falls back to fetch_balance when no flex in initial response.""" + conf = dict(default_conf) + conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + # Base get_balances returns no info -> forces fallback + mocker.patch.object( + Exchange, "get_balances", return_value={"free": {}, "used": {}, "total": {}} + ) + + mocker.patch.object( + ex._api, + "fetch_balance", + return_value={ + "free": {"EUR": 10.0}, + "used": {"EUR": 0.0}, + "total": {"EUR": 10.0}, + "info": {"accounts": {"flex": {"availableMargin": 11.0, "balanceValue": 12.0}}}, + }, + create=True, + ) + + res = ex.get_balances() + assert res["free"]["USD"] == 11.0 + assert res["total"]["USD"] == 12.0 From d9629c4d6732cc307162f25d99c41e8d23c246aa Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 13:36:23 +0100 Subject: [PATCH 07/91] docs(exchange): add Kraken Futures notes --- docs/data-download.md | 1 + docs/exchanges.md | 38 ++++++++++++++++++++++++++++ freqtrade/exchange/exchange_utils.py | 10 +------- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/data-download.md b/docs/data-download.md index 4339e9862..5ae1fe560 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -268,6 +268,7 @@ If `--convert` is also provided, the resample step will happen automatically and !!! Note "Kraken user" Kraken users should read [this](exchanges.md#historic-kraken-data) before starting to download data. + Kraken Futures uses standard OHLCV downloads and does not require `--dl-trades`. Example call: diff --git a/docs/exchanges.md b/docs/exchanges.md index 547814bfe..a6eb4c31d 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -217,6 +217,44 @@ freqtrade download-data --exchange kraken --dl-trades -p BTC/EUR BCH/EUR Please pay attention that rateLimit configuration entry holds delay in milliseconds between requests, NOT requests/sec rate. So, in order to mitigate Kraken API "Rate limit exceeded" exception, this configuration should be increased, NOT decreased. +## Kraken Futures + +Kraken Futures uses the exchange id `krakenfutures` and supports isolated futures mode. + +```jsonc +"exchange": { + "name": "krakenfutures", + "key": "your_exchange_key", + "secret": "your_exchange_secret", + "ccxt_config": {"enableRateLimit": true}, + "ccxt_async_config": {"enableRateLimit": true}, + "triggerSignal": "mark" // "mark" (default), "last", or "index" +}, +"trading_mode": "futures", +"margin_mode": "isolated", +"stake_currency": "USD" +``` + +!!! Tip "Stoploss on Exchange" + Kraken Futures supports `stoploss_on_exchange` with both `limit` and `market` stop orders. + Use `exchange.triggerSignal` to select the trigger price source (`mark`, `last`, or `index`). + +!!! Note "Collateral" + Kraken Futures is USD-settled. Kraken allows EUR collateral, but USD is the recommended stake currency. + +!!! Note "Pair format" + Futures pairs use CCXT symbols, for example `BTC/USD:USD`. + +### Data download + +Kraken Futures uses normal OHLCV downloads. + +```bash +freqtrade download-data --exchange krakenfutures --trading-mode futures --pairs BTC/USD:USD --timeframes 1m 5m +``` + +Note: OHLCV requests are capped at 2000 candles per call, so large downloads may take longer. + ## Kucoin Kucoin requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows: diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 9d13c067d..402fbbb32 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -54,21 +54,13 @@ def available_exchanges(ccxt_module: CcxtModuleType | None = None) -> list[str]: return [x for x in exchanges if validate_exchange(x)[0]] -def _exchange_has_helper( - ex_has_or_exchange: dict[str, Any] | ccxt.Exchange, - required: dict[str, list[str]], -) -> list[str]: +def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) -> list[str]: """ Checks availability of methods (or their replacement)s in a merged has-dict. :param ex_has: merged "has" dict (ccxt + freqtrade overrides) :param required: dict of required methods, with possible replacement methods as list :return: list of missing required methods """ - if isinstance(ex_has_or_exchange, dict): - ex_has = ex_has_or_exchange - else: - ex_has = getattr(ex_has_or_exchange, "has", {}) or {} - return [ k for k, v in required.items() From 04ec24e73798dddb77c0cd12ecb2b7f9659ee6b9 Mon Sep 17 00:00:00 2001 From: matstedt Date: Tue, 13 Jan 2026 20:04:08 +0100 Subject: [PATCH 08/91] krakenfutures: move ohlcv cap to _ft_has and support futures-only online tests --- freqtrade/exchange/krakenfutures.py | 15 +------ tests/exchange/test_krakenfutures.py | 4 +- tests/exchange_online/conftest.py | 53 ++++++++++++++++------- tests/exchange_online/test_ccxt_compat.py | 7 ++- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 2eba10201..d3580598e 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -53,6 +53,7 @@ class KrakenFutures(Exchange): PriceType.MARK: "mark", PriceType.INDEX: "index", }, + "ohlcv_candle_limit": 2000, # override ccxt has-gaps "exchange_has_overrides": { "fetchOrder": True, @@ -593,19 +594,5 @@ class KrakenFutures(Exchange): return v.strip() return "mark" - def ohlcv_candle_limit( - self, - timeframe: str, - candle_type: CandleType, - since_ms: int | None = None, - ) -> int: - limit = super().ohlcv_candle_limit( - timeframe, - candle_type=candle_type, - since_ms=since_ms, - ) - return min(limit, 2000) - - class Krakenfutures(KrakenFutures): pass diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 0c6a21a6e..0bad48e26 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -23,11 +23,11 @@ def test_krakenfutures_get_ft_has(): def test_krakenfutures_ohlcv_candle_limit_caps_at_2000(mocker, default_conf): """Test that OHLCV candle limit is capped at 2000.""" - mocker.patch.object(Exchange, "ohlcv_candle_limit", return_value=5000) - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") assert isinstance(ex, KrakenFutures) + mocker.patch.object(ex, "features", return_value=5000) + assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000 diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index efc013297..5d9b0ca67 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -548,6 +548,7 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "hasQuoteVolume": False, "timeframe": "30m", "futures": True, + "futures_only": True, "candle_count": 5000, "orderbook_max_entries": 20, "futures_pair": "BTC/USDC:USDC", @@ -559,6 +560,19 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { # TODO: re-enable hyperliquid websocket tests "skip_ws_tests": True, }, + "krakenfutures": { + "pair": "BTC/USD:USD", + "stake_currency": "USD", + "hasQuoteVolume": False, + "timeframe": "1h", + "futures": True, + "futures_only": True, + "candle_count": 2000, + "futures_pair": "BTC/USD:USD", + "hasQuoteVolumeFutures": False, + "leverage_tiers_public": False, + "leverage_in_spot_market": False, + }, } EXCHANGES_FUTURES = [exch for exch, params in EXCHANGES.items() if params.get("futures")] @@ -591,11 +605,15 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config: return config -def get_exchange(exchange_name, exchange_conf): +def get_exchange(exchange_name, exchange_conf, class_mocker=None): exchange_params = EXCHANGES[exchange_name] + if exchange_params.get("futures_only"): + pytest.skip(f"Exchange {exchange_name} is futures-only, skipping spot tests.") exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) exchange_conf["exchange"]["name"] = exchange_name exchange_conf["stake_currency"] = exchange_params["stake_currency"] + if class_mocker: + class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") exchange = ExchangeResolver.load_exchange( exchange_conf, validate=True, load_leverage_tiers=True ) @@ -608,25 +626,30 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker): if exchange_params.get("futures") is not True: pytest.skip(f"Exchange {exchange_name} does not support futures.") - else: - exchange_conf = deepcopy(exchange_conf) - exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) - exchange_conf["trading_mode"] = "futures" - exchange_conf["margin_mode"] = "isolated" + exchange_conf = deepcopy(exchange_conf) + exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) + exchange_conf["exchange"]["name"] = exchange_name + exchange_conf["stake_currency"] = exchange_params["stake_currency"] + exchange_conf["trading_mode"] = "futures" + exchange_conf["margin_mode"] = "isolated" - class_mocker.patch("freqtrade.exchange.binance.Binance.fill_leverage_tiers") - class_mocker.patch(f"{EXMS}.fetch_trading_fees") - class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") - class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None) - class_mocker.patch(f"{EXMS}.cache_leverage_tiers") + class_mocker.patch("freqtrade.exchange.binance.Binance.fill_leverage_tiers") + class_mocker.patch(f"{EXMS}.fetch_trading_fees") + class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") + class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None) + class_mocker.patch(f"{EXMS}.cache_leverage_tiers") - return get_exchange(exchange_name, exchange_conf) + exchange = ExchangeResolver.load_exchange( + exchange_conf, validate=True, load_leverage_tiers=True + ) + return exchange, exchange_name @pytest.fixture(params=EXCHANGES, scope="class") def exchange(request, exchange_conf, class_mocker): - class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") - exchange, name, exchange_params = get_exchange(request.param, exchange_conf) + exchange, name, exchange_params = get_exchange( + request.param, exchange_conf, class_mocker + ) yield exchange, name, exchange_params exchange.close() @@ -653,7 +676,7 @@ def exchange_ws(request, exchange_conf, exchange_mode, class_mocker): if exchange_param.get("skip_ws_tests"): pytest.skip(f"{request.param} does not support websocket tests.") if exchange_mode == "spot": - exchange, name, _ = get_exchange(request.param, exchange_conf) + exchange, name, _ = get_exchange(request.param, exchange_conf, class_mocker) pair = exchange_param["pair"] elif exchange_param.get("futures"): exchange, name, _ = get_futures_exchange( diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index f33818d3d..a5e1e1679 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -64,7 +64,12 @@ class TestCCXTExchange: if not expected_count: pytest.skip("No expected candle count for exchange") - assert exch.ohlcv_candle_limit("1m", CandleType.FUTURES) == expected_count + candle_type = ( + CandleType.FUTURES + if exchange_params.get("futures_only") + else CandleType.SPOT + ) + assert exch.ohlcv_candle_limit("1m", candle_type) == expected_count def test_load_markets_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): exchange, _, exchange_params = exchange_futures From 6fdeab2747a0699eedc054796d55503c854d5db4 Mon Sep 17 00:00:00 2001 From: matstedt Date: Tue, 13 Jan 2026 21:20:22 +0100 Subject: [PATCH 09/91] krakenfutures: align class name with resolver titlecase and remove unnecessary alias --- freqtrade/exchange/__init__.py | 2 +- freqtrade/exchange/krakenfutures.py | 15 ++++++--------- tests/exchange/test_krakenfutures.py | 8 ++++---- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 71b8330c5..cb60b3f93 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -44,7 +44,7 @@ from freqtrade.exchange.htx import Htx from freqtrade.exchange.hyperliquid import Hyperliquid from freqtrade.exchange.idex import Idex from freqtrade.exchange.kraken import Kraken -from freqtrade.exchange.krakenfutures import KrakenFutures, Krakenfutures +from freqtrade.exchange.krakenfutures import Krakenfutures from freqtrade.exchange.kucoin import Kucoin from freqtrade.exchange.lbank import Lbank from freqtrade.exchange.luno import Luno diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index d3580598e..4529971be 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -9,7 +9,7 @@ from typing import Any from ccxt.base.errors import NotSupported, OrderNotFound from freqtrade.constants import BuySell -from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode +from freqtrade.enums import MarginMode, PriceType, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.exchange.common import retrier from freqtrade.exchange.exchange import Exchange @@ -19,7 +19,7 @@ from freqtrade.exchange.exchange_types import FtHas logger = logging.getLogger(__name__) -class KrakenFutures(Exchange): +class Krakenfutures(Exchange): """Kraken Futures exchange class. Contains adjustments needed for Freqtrade to work with this exchange. @@ -391,9 +391,9 @@ class KrakenFutures(Exchange): @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()) + 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 any(Krakenfutures._contains_value(v, needle) for v in obj) return str(obj) == str(needle) def _build_ccxt_like_order_from_history( @@ -511,13 +511,13 @@ class KrakenFutures(Exchange): for k, v in obj.items(): if k in keys and v not in (None, ""): return v - found = KrakenFutures._find_first_value(v, keys) + 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) + found = Krakenfutures._find_first_value(v, keys) if found is not None: return found return None @@ -593,6 +593,3 @@ class KrakenFutures(Exchange): if isinstance(v, str) and v.strip(): return v.strip() return "mark" - -class Krakenfutures(KrakenFutures): - pass diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 0bad48e26..47d17fc68 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -8,13 +8,13 @@ from ccxt.base.errors import NotSupported from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exchange.exchange import Exchange -from freqtrade.exchange.krakenfutures import KrakenFutures +from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import get_patched_exchange def test_krakenfutures_get_ft_has(): """Test that get_ft_has returns correct capability flags.""" - ft_has = KrakenFutures.get_ft_has() + ft_has = Krakenfutures.get_ft_has() assert ft_has["fetchOrder"] is True assert ft_has["createMarketOrder"] is True assert ft_has["stoploss_on_exchange"] is True @@ -24,7 +24,7 @@ def test_krakenfutures_get_ft_has(): def test_krakenfutures_ohlcv_candle_limit_caps_at_2000(mocker, default_conf): """Test that OHLCV candle limit is capped at 2000.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - assert isinstance(ex, KrakenFutures) + assert isinstance(ex, Krakenfutures) mocker.patch.object(ex, "features", return_value=5000) @@ -103,7 +103,7 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c def test_krakenfutures_fetch_order_returns_pseudo_order_when_not_found(mocker, default_conf): - """When order is not found anywhere, KrakenFutures returns a pseudo order to avoid crashes.""" + """When order is not found anywhere, Krakenfutures returns a pseudo order to avoid crashes.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") mocker.patch.object( From ea5ae9627d55c4d1dcb5e19353f96e682117f365 Mon Sep 17 00:00:00 2001 From: matstedt Date: Tue, 13 Jan 2026 21:20:38 +0100 Subject: [PATCH 10/91] style: ruff format --- tests/exchange_online/test_ccxt_compat.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index a5e1e1679..105ff4353 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -65,9 +65,7 @@ class TestCCXTExchange: pytest.skip("No expected candle count for exchange") candle_type = ( - CandleType.FUTURES - if exchange_params.get("futures_only") - else CandleType.SPOT + CandleType.FUTURES if exchange_params.get("futures_only") else CandleType.SPOT ) assert exch.ohlcv_candle_limit("1m", candle_type) == expected_count From 60f71bbc2b946d554a163604e687b15730a3ffa4 Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 14 Jan 2026 11:12:51 +0100 Subject: [PATCH 11/91] tests: re-enable hyperliquid spot online tests --- tests/exchange_online/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 5d9b0ca67..db8d14064 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -548,7 +548,6 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "hasQuoteVolume": False, "timeframe": "30m", "futures": True, - "futures_only": True, "candle_count": 5000, "orderbook_max_entries": 20, "futures_pair": "BTC/USDC:USDC", From 507ea87e9f09ea6fa1a506f80f99bac1ed3d862f Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 14 Jan 2026 19:25:36 +0100 Subject: [PATCH 12/91] krakenfutures: convert _filter_params_for_open_closed to instance method --- freqtrade/exchange/krakenfutures.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 4529971be..0847cf74f 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -246,8 +246,7 @@ class Krakenfutures(Exchange): except (NotSupported, AttributeError, OrderNotFound): return None - @staticmethod - def _filter_params_for_open_closed(params: dict[str, Any]) -> dict[str, Any]: + def _filter_params_for_open_closed(self, params: dict[str, Any]) -> dict[str, Any]: if not params: return {} blacklist = {"since", "before", "from", "to"} From 42361bc046d2794710c108fdfecc408915ab5b1b Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 14 Jan 2026 20:01:39 +0100 Subject: [PATCH 13/91] krakenfutures: remove redundant get_ft_has method --- freqtrade/exchange/krakenfutures.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 0847cf74f..63e421673 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -61,19 +61,6 @@ class Krakenfutures(Exchange): }, } - @staticmethod - def get_ft_has() -> dict[str, Any]: - # Kept for compatibility with older tests and helper code. - return { - "fetchOrder": True, - "createMarketOrder": True, - "stoploss_on_exchange": True, - "stoploss_order_types": { - "limit": "limit", - "market": "market", - }, - } - def get_balances(self, params: dict | None = None) -> dict[str, Any]: balances = super().get_balances(params=params) From 06e2b8ae0de91c76a9b4dd31ae0d8dc6b6ac9d2f Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 14 Jan 2026 20:01:45 +0100 Subject: [PATCH 14/91] style: ruff format --- freqtrade/exchange/check_exchange.py | 1 - freqtrade/exchange/exchange_utils.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/freqtrade/exchange/check_exchange.py b/freqtrade/exchange/check_exchange.py index d6ed44f66..3795a9f4d 100644 --- a/freqtrade/exchange/check_exchange.py +++ b/freqtrade/exchange/check_exchange.py @@ -88,4 +88,3 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool: ) return True - diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 402fbbb32..ef4c5387b 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -72,7 +72,6 @@ def validate_exchange( exchange: str, ft_has_overrides: dict[str, Any] | None = None, ) -> tuple[bool, str, str, ccxt.Exchange | None]: - """ returns: can_use, reason, exchange_object with Reason including both missing and missing_opt @@ -89,7 +88,6 @@ def validate_exchange( if ft_has_overrides: ex_has.update(ft_has_overrides) - result = True reasons = [] reasons_fut = "" From 7718e8e1e940f51cac81f998010d2adef706ad7c Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 14 Jan 2026 20:10:36 +0100 Subject: [PATCH 15/91] krakenfutures: fix fetch_order to raise exception instead of returning pseudo order --- freqtrade/exchange/krakenfutures.py | 30 ++--------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 63e421673..530e521ff 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -10,7 +10,7 @@ from ccxt.base.errors import NotSupported, OrderNotFound from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, PriceType, TradingMode -from freqtrade.exceptions import OperationalException +from freqtrade.exceptions import InvalidOrderException, OperationalException from freqtrade.exchange.common import retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import FtHas @@ -188,33 +188,7 @@ class Krakenfutures(Exchange): if order is not None: return self._normalize_fetched_order(order) - # Do not crash the bot during startup if the exchange cannot find the order. - now_ms = int(getattr(self._api, "milliseconds", lambda: int(time.time() * 1000))()) - try: - iso = self._api.iso8601(now_ms) - except Exception: - iso = None - - logger.warning( - "Order not found on exchange, returning pseudo order to avoid crash. id=%s pair=%s", - order_id, - pair, - ) - - return { - "id": order_id, - "symbol": pair, - "status": "open", - "side": None, - "type": None, - "price": 0.0, - "amount": 0.0, - "filled": 0.0, - "remaining": 0.0, - "timestamp": now_ms, - "datetime": iso, - "info": {"_ft_note": "Order not found via API, pseudo order returned"}, - } + raise InvalidOrderException(f"Order {order_id} not found on exchange for pair {pair}.") def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date): try: From 2077f34a7882b1899523c89c80c28eb211799ddc Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 15 Jan 2026 08:58:52 +0100 Subject: [PATCH 16/91] krakenfutures: use Bybit/Hyperliquid funding fee calculation --- freqtrade/exchange/krakenfutures.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 530e521ff..ff2a223d4 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -10,7 +10,7 @@ from ccxt.base.errors import NotSupported, OrderNotFound from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, PriceType, TradingMode -from freqtrade.exceptions import InvalidOrderException, OperationalException +from freqtrade.exceptions import ExchangeError, InvalidOrderException from freqtrade.exchange.common import retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import FtHas @@ -190,14 +190,14 @@ class Krakenfutures(Exchange): raise InvalidOrderException(f"Order {order_id} not found on exchange for pair {pair}.") - def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date): - try: - return super().get_funding_fees(pair, amount, is_short, open_date) - except OperationalException as e: - if "fetch_funding_history" in str(e): - logger.debug("Kraken Futures: fetch_funding_history unsupported, returning 0.") - return 0.0 - raise + def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: + """CCXT currently does not support Kraken Futures fetchFundingHistory.""" + if self.trading_mode == TradingMode.FUTURES: + try: + return self._fetch_and_calculate_funding_fees(pair, amount, is_short, open_date) + except ExchangeError: + logger.warning(f"Could not update funding fees for {pair}.") + return 0.0 def _fetch_order_default( self, order_id: str, pair: str, params: dict[str, Any] From 5752e52b0f2614c2b848bc9fd0de1972173fa18e Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 15 Jan 2026 09:16:32 +0100 Subject: [PATCH 17/91] tests: update krakenfutures tests for get_ft_has removal and InvalidOrderException --- tests/exchange/test_krakenfutures.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 47d17fc68..ee175aaeb 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -4,21 +4,23 @@ from __future__ import annotations from copy import deepcopy +import pytest from ccxt.base.errors import NotSupported from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.exceptions import InvalidOrderException from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import get_patched_exchange -def test_krakenfutures_get_ft_has(): - """Test that get_ft_has returns correct capability flags.""" - ft_has = Krakenfutures.get_ft_has() - assert ft_has["fetchOrder"] is True - assert ft_has["createMarketOrder"] is True +def test_krakenfutures_ft_has_overrides(): + """Test that _ft_has contains correct capability overrides.""" + 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 def test_krakenfutures_ohlcv_candle_limit_caps_at_2000(mocker, default_conf): @@ -102,8 +104,8 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c assert res["status"] == "closed" -def test_krakenfutures_fetch_order_returns_pseudo_order_when_not_found(mocker, default_conf): - """When order is not found anywhere, Krakenfutures returns a pseudo order to avoid crashes.""" +def test_krakenfutures_fetch_order_raises_when_not_found(mocker, default_conf): + """When order is not found anywhere, Krakenfutures raises InvalidOrderException.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") mocker.patch.object( @@ -117,11 +119,8 @@ def test_krakenfutures_fetch_order_returns_pseudo_order_when_not_found(mocker, d mocker.patch.object(ex._api, "historyGetOrders", return_value={"elements": []}, create=True) mocker.patch.object(ex._api, "historyGetTriggers", return_value={"elements": []}, create=True) - res = ex.fetch_order("nope", "BTC/USD:USD") - # Returns a pseudo order with status open and the requested id - assert res["id"] == "nope" - assert res["status"] == "open" - assert res["symbol"] == "BTC/USD:USD" + with pytest.raises(InvalidOrderException, 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): From 6c588cce653e3d67260aaf86c3e56ee5a1a444ee Mon Sep 17 00:00:00 2001 From: Mattias Hellerstedt Date: Thu, 15 Jan 2026 16:34:41 +0100 Subject: [PATCH 18/91] docs: clarify flex account handling in Kraken Futures balances --- freqtrade/exchange/krakenfutures.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index ff2a223d4..aa1634609 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -62,6 +62,16 @@ class Krakenfutures(Exchange): } def get_balances(self, params: dict | None = None) -> dict[str, Any]: + """ + Fetch account balances with special handling for Kraken Futures flex accounts. + Kraken Futures supports "flex" (multi-collateral) accounts where users can hold + multiple currencies as margin. CCXT returns these balances per-currency (EUR, etc.) + but does not synthesize a USD balance, which Freqtrade expects as stake_currency. + The flex account fields used: + - availableMargin: USD value available for new positions (-> free) + - balanceValue/portfolioValue: total USD value of account (-> total) + - currencies[*].value: fallback sum if above fields missing + """ balances = super().get_balances(params=params) stake = str(self._config.get("stake_currency", "")).upper() @@ -88,7 +98,7 @@ class Krakenfutures(Exchange): def _get_flex_account( self, balances: dict[str, Any], params: dict | None ) -> dict[str, Any] | None: - """Extract flex account from balances or fetch directly.""" + """Try to get flex account data from cached balances or fetch fresh.""" flex = self._extract_flex_from_raw(balances) if flex is not None: return flex @@ -101,7 +111,7 @@ class Krakenfutures(Exchange): @staticmethod def _extract_flex_from_raw(raw: dict[str, Any] | None) -> dict[str, Any] | None: - """Navigate raw -> info -> accounts -> flex.""" + """Navigate raw -> info -> accounts -> flex (Kraken Futures multi-collateral account).""" if not isinstance(raw, dict): return None info = raw.get("info") @@ -114,7 +124,6 @@ class Krakenfutures(Exchange): return flex if isinstance(flex, dict) else None def _extract_usd_from_flex(self, flex: dict[str, Any]) -> tuple[float | None, float | None]: - """Extract USD free and total from flex account.""" usd_free = self._safe_float(flex.get("availableMargin") or flex.get("available_margin")) usd_total = self._safe_float( flex.get("balanceValue") or flex.get("collateralValue") or flex.get("portfolioValue") From 38d197b3bf8fb8c2f9e7e05ede373959ff352ddf Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 17 Jan 2026 15:44:10 +0100 Subject: [PATCH 19/91] krakenfutures: rely on ft_has stoploss params, update tests/docs --- docs/exchanges.md | 19 ++++++----------- freqtrade/exchange/krakenfutures.py | 26 ----------------------- tests/exchange/test_krakenfutures.py | 31 ++++++++++++++++++++-------- 3 files changed, 28 insertions(+), 48 deletions(-) diff --git a/docs/exchanges.md b/docs/exchanges.md index a6eb4c31d..cd03418d0 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -227,8 +227,11 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future "key": "your_exchange_key", "secret": "your_exchange_secret", "ccxt_config": {"enableRateLimit": true}, - "ccxt_async_config": {"enableRateLimit": true}, - "triggerSignal": "mark" // "mark" (default), "last", or "index" + "ccxt_async_config": {"enableRateLimit": true} +}, +"order_types": { + "stoploss": "market", + "stoploss_price_type": "mark" // "mark" (default), "last", or "index" }, "trading_mode": "futures", "margin_mode": "isolated", @@ -237,7 +240,7 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future !!! Tip "Stoploss on Exchange" Kraken Futures supports `stoploss_on_exchange` with both `limit` and `market` stop orders. - Use `exchange.triggerSignal` to select the trigger price source (`mark`, `last`, or `index`). + Use `order_types.stoploss_price_type` to select the trigger price source (`mark`, `last`, or `index`). !!! Note "Collateral" Kraken Futures is USD-settled. Kraken allows EUR collateral, but USD is the recommended stake currency. @@ -245,16 +248,6 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future !!! Note "Pair format" Futures pairs use CCXT symbols, for example `BTC/USD:USD`. -### Data download - -Kraken Futures uses normal OHLCV downloads. - -```bash -freqtrade download-data --exchange krakenfutures --trading-mode futures --pairs BTC/USD:USD --timeframes 1m 5m -``` - -Note: OHLCV requests are capped at 2000 candles per call, so large downloads may take longer. - ## Kucoin Kucoin requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows: diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index aa1634609..9f6167188 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -8,7 +8,6 @@ from typing import Any from ccxt.base.errors import NotSupported, OrderNotFound -from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, PriceType, TradingMode from freqtrade.exceptions import ExchangeError, InvalidOrderException from freqtrade.exchange.common import retrier @@ -537,28 +536,3 @@ class Krakenfutures(Exchange): ) -> list[dict[str, Any]]: params = self._filter_params_for_open_closed(params or {}) return self._api.fetch_closed_orders(pair, since, limit, params) - - def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> dict[str, Any]: - params: dict[str, Any] = super()._get_stop_params( - side=side, ordertype=ordertype, stop_price=stop_price - ) - - # Force Kraken Futures naming - params.setdefault("triggerPrice", stop_price) - - trigger_signal = self._get_trigger_signal() - if trigger_signal is not None: - params.setdefault("triggerSignal", trigger_signal) - - if getattr(self, "trading_mode", None) == TradingMode.FUTURES: - params.setdefault("reduceOnly", True) - - return params - - def _get_trigger_signal(self) -> str | None: - ex_conf = self._config.get("exchange") - if isinstance(ex_conf, dict): - v = ex_conf.get("triggerSignal") or ex_conf.get("trigger_signal") - if isinstance(v, str) and v.strip(): - return v.strip() - return "mark" diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index ee175aaeb..1ea765197 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -3,6 +3,7 @@ from __future__ import annotations from copy import deepcopy +from unittest.mock import MagicMock import pytest from ccxt.base.errors import NotSupported @@ -11,7 +12,7 @@ from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exceptions import InvalidOrderException from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.krakenfutures import Krakenfutures -from tests.conftest import get_patched_exchange +from tests.conftest import EXMS, get_patched_exchange def test_krakenfutures_ft_has_overrides(): @@ -169,20 +170,32 @@ def test_krakenfutures_fetch_order_falls_back_to_history_orders(mocker, default_ assert res["filled"] == 0.0 -def test_krakenfutures_get_stop_params_adds_triggerprice_signal_and_reduceonly( - mocker, default_conf -): - """Test _get_stop_params adds triggerPrice, triggerSignal, and reduceOnly.""" +def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): + """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" + api_mock = MagicMock() + api_mock.create_order = MagicMock(return_value={"id": "order-id", "info": {"foo": "bar"}}) + conf = deepcopy(default_conf) + conf["dry_run"] = False conf["trading_mode"] = TradingMode.FUTURES conf["margin_mode"] = MarginMode.ISOLATED - if isinstance(conf.get("exchange"), dict): - conf["exchange"]["triggerSignal"] = "mark" + mocker.patch(f"{EXMS}.amount_to_precision", lambda s, x, y: y) + mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y) - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + ex = get_patched_exchange(mocker, conf, api_mock, exchange="krakenfutures") - params = ex._get_stop_params(side="sell", ordertype="market", stop_price=90000.0) + ex.create_stoploss( + pair="ETH/BTC", + amount=1, + stop_price=90000.0, + side="sell", + order_types={"stoploss": "market", "stoploss_price_type": "mark"}, + leverage=1.0, + ) + + call_args = api_mock.create_order.call_args + params = call_args[1].get("params") if call_args[1] else call_args[0][5] assert params["triggerPrice"] == 90000.0 assert params["triggerSignal"] == "mark" From 2ed3db330a6012fb2e9abded1eb7fcab49f1d7d8 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 17 Jan 2026 16:41:53 +0100 Subject: [PATCH 20/91] krakenfutures: use fetch_order_emulated with history fallback --- freqtrade/exchange/krakenfutures.py | 34 +++++++++------------------- tests/exchange/test_krakenfutures.py | 6 ++--- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 9f6167188..70b5a57df 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -6,10 +6,8 @@ import logging import time from typing import Any -from ccxt.base.errors import NotSupported, OrderNotFound - from freqtrade.enums import MarginMode, PriceType, TradingMode -from freqtrade.exceptions import ExchangeError, InvalidOrderException +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 @@ -184,19 +182,17 @@ class Krakenfutures(Exchange): ) -> dict[str, Any]: params = params or {} - order = self._fetch_order_default(order_id, pair, params) - if order is not None: - return self._normalize_fetched_order(order) - - order = self._fetch_order_scan_open_closed(order_id, pair, params) - if order is not None: + try: + order = self.fetch_order_emulated(order_id, pair, params) return self._normalize_fetched_order(order) + 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 InvalidOrderException(f"Order {order_id} not found on exchange for pair {pair}.") + raise RetryableOrderError(f"Order {order_id} not found on exchange for pair {pair}.") def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: """CCXT currently does not support Kraken Futures fetchFundingHistory.""" @@ -207,23 +203,15 @@ class Krakenfutures(Exchange): logger.warning(f"Could not update funding fees for {pair}.") return 0.0 - def _fetch_order_default( - self, order_id: str, pair: str, params: dict[str, Any] - ) -> dict[str, Any] | None: - try: - return self._api.fetch_order(order_id, pair, params) - except (NotSupported, AttributeError, OrderNotFound): - return None - def _filter_params_for_open_closed(self, params: dict[str, Any]) -> dict[str, Any]: if not params: return {} blacklist = {"since", "before", "from", "to"} return {k: v for k, v in params.items() if k not in blacklist} - def _fetch_order_scan_open_closed( + def fetch_order_emulated( self, order_id: str, pair: str, params: dict[str, Any] - ) -> dict[str, Any] | None: + ) -> dict[str, Any]: list_params = self._filter_params_for_open_closed(params) try: @@ -233,7 +221,7 @@ class Krakenfutures(Exchange): for o in open_orders: if self._contains_value(o, order_id): - return o + return self._order_contracts_to_amount(o) try: closed_orders = self.fetch_closed_orders(pair, params=list_params) @@ -242,9 +230,9 @@ class Krakenfutures(Exchange): for o in closed_orders: if self._contains_value(o, order_id): - return o + return self._order_contracts_to_amount(o) - return None + 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] diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 1ea765197..96164fa64 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -9,7 +9,7 @@ import pytest from ccxt.base.errors import NotSupported from freqtrade.enums import CandleType, MarginMode, TradingMode -from freqtrade.exceptions import InvalidOrderException +from freqtrade.exceptions import RetryableOrderError from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import EXMS, get_patched_exchange @@ -106,7 +106,7 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c def test_krakenfutures_fetch_order_raises_when_not_found(mocker, default_conf): - """When order is not found anywhere, Krakenfutures raises InvalidOrderException.""" + """When order is not found anywhere, Krakenfutures raises RetryableOrderError.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") mocker.patch.object( @@ -120,7 +120,7 @@ def test_krakenfutures_fetch_order_raises_when_not_found(mocker, default_conf): 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(InvalidOrderException, match="not found on exchange"): + with pytest.raises(RetryableOrderError, match="not found on exchange"): ex.fetch_order("nope", "BTC/USD:USD") From 272771af38eb416b4b3fec79750cb4b07b8a2ac2 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 17 Jan 2026 16:44:04 +0100 Subject: [PATCH 21/91] docs(exchanges): remove redundant text --- docs/exchanges.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/exchanges.md b/docs/exchanges.md index cd03418d0..9c900d128 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -245,9 +245,6 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future !!! Note "Collateral" Kraken Futures is USD-settled. Kraken allows EUR collateral, but USD is the recommended stake currency. -!!! Note "Pair format" - Futures pairs use CCXT symbols, for example `BTC/USD:USD`. - ## Kucoin Kucoin requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows: From 35706eb9ec6f412b676fbc9af8c037fec7fa2d54 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 18 Jan 2026 11:50:29 +0100 Subject: [PATCH 22/91] krakenfutures: restore get_ft_has so exchange validation sees fetchOrder/createMarketOrder overrides until CCXT is updated --- freqtrade/exchange/krakenfutures.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 70b5a57df..8811f2a62 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -58,6 +58,10 @@ class Krakenfutures(Exchange): }, } + @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. From 4b23e9ccce198cf84d6861bd502bbc0058c7d61d Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 18 Jan 2026 13:55:48 +0100 Subject: [PATCH 23/91] krakenfutures: strip history-only params from open/closed order calls --- freqtrade/exchange/krakenfutures.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 8811f2a62..536bc7316 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -207,16 +207,17 @@ class Krakenfutures(Exchange): logger.warning(f"Could not update funding fees for {pair}.") return 0.0 - def _filter_params_for_open_closed(self, params: dict[str, Any]) -> dict[str, Any]: + def _strip_history_params(self, params: dict[str, Any]) -> dict[str, Any]: if not params: return {} - blacklist = {"since", "before", "from", "to"} - return {k: v for k, v in params.items() if k not in blacklist} + # 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._filter_params_for_open_closed(params) + list_params = self._strip_history_params(params) try: open_orders = self.fetch_open_orders(pair, params=list_params) @@ -516,7 +517,7 @@ class Krakenfutures(Exchange): limit: int | None = None, params: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - params = self._filter_params_for_open_closed(params or {}) + params = self._strip_history_params(params or {}) return self._api.fetch_open_orders(pair, since, limit, params) def fetch_closed_orders( @@ -526,5 +527,5 @@ class Krakenfutures(Exchange): limit: int | None = None, params: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - params = self._filter_params_for_open_closed(params or {}) + params = self._strip_history_params(params or {}) return self._api.fetch_closed_orders(pair, since, limit, params) From 15359f57a8b3ad99a92d7a889a6c05adfb4ba46a Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 18 Jan 2026 14:40:31 +0100 Subject: [PATCH 24/91] krakenfutures: avoid mypy optional assignment in fetch_order --- freqtrade/exchange/krakenfutures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 536bc7316..d2f553b0d 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -187,8 +187,8 @@ class Krakenfutures(Exchange): params = params or {} try: - order = self.fetch_order_emulated(order_id, pair, params) - return self._normalize_fetched_order(order) + emulated = self.fetch_order_emulated(order_id, pair, params) + return self._normalize_fetched_order(emulated) except RetryableOrderError: pass From ca9d4ed0268702f31a0901fbf506bf5b651f7e9e Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 18 Jan 2026 16:55:09 +0100 Subject: [PATCH 25/91] tests: fix _exchange_has_helper input in exchange_utils test --- tests/exchange/test_exchange_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index d0d35a9dd..9460b8abf 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -404,7 +404,7 @@ def test_exchange__exchange_has_helper(): "fetchMyTrades": ["fetchTrades"], "fetchOrder": ["fetchOpenOrder", "fetchClosedOrder"], } - missing = _exchange_has_helper(e_mod, required) + missing = _exchange_has_helper(e_mod.has, required) assert set(missing) == {"fetchOHLCV"} e_mod.has = { @@ -414,7 +414,7 @@ def test_exchange__exchange_has_helper(): "fetchMyTrades": False, "fetchOrder": True, } - missing = _exchange_has_helper(e_mod, required) + missing = _exchange_has_helper(e_mod.has, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades"} e_mod.has = { @@ -424,5 +424,5 @@ def test_exchange__exchange_has_helper(): "fetchMyTrades": False, "fetchOrder": False, } - missing = _exchange_has_helper(e_mod, required) + missing = _exchange_has_helper(e_mod.has, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades", "fetchOrder"} From 5af4147eb37451072848c415423cde87f0262fe9 Mon Sep 17 00:00:00 2001 From: matstedt Date: Mon, 19 Jan 2026 14:49:59 +0100 Subject: [PATCH 26/91] tests: adjust list-exchanges futures assertions for krakenfutures --- tests/commands/test_commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 7deec18ca..9c0cfaa1d 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -210,7 +210,8 @@ def test_list_exchanges(capsys): captured = capsys.readouterr() assert re.search(r"Exchanges available for Freqtrade.*", captured.out) assert re.search(r".*binance.*", captured.out) - assert not re.search(r".*kraken.*", captured.out) + assert re.search(r"\bkrakenfutures\b", captured.out) + assert not re.search(r"\bkraken\b", captured.out) def test_list_timeframes(mocker, capsys): From cef63725aae35138f253043a7c0a876ecad4803b Mon Sep 17 00:00:00 2001 From: matstedt Date: Mon, 19 Jan 2026 14:52:17 +0100 Subject: [PATCH 27/91] exchange: drop krakenfutures ohlcv limit override --- freqtrade/exchange/krakenfutures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index d2f553b0d..e76f0b279 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -50,7 +50,6 @@ class Krakenfutures(Exchange): PriceType.MARK: "mark", PriceType.INDEX: "index", }, - "ohlcv_candle_limit": 2000, # override ccxt has-gaps "exchange_has_overrides": { "fetchOrder": True, From 1254bd5bab27782b11e673fdd534f419c91ac241 Mon Sep 17 00:00:00 2001 From: matstedt Date: Mon, 19 Jan 2026 14:57:52 +0100 Subject: [PATCH 28/91] tests: align krakenfutures ohlcv limit check --- tests/exchange/test_krakenfutures.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 96164fa64..10f56aecb 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -24,12 +24,12 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["exchange_has_overrides"]["createMarketOrder"] is True -def test_krakenfutures_ohlcv_candle_limit_caps_at_2000(mocker, default_conf): - """Test that OHLCV candle limit is capped at 2000.""" +def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): + """Test that OHLCV candle limit follows CCXT feature limit.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") assert isinstance(ex, Krakenfutures) - mocker.patch.object(ex, "features", return_value=5000) + mocker.patch.object(ex, "features", return_value=2000) assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000 From f2780c7c682f3fb9123bd05ba76ccaf437431811 Mon Sep 17 00:00:00 2001 From: matstedt Date: Mon, 19 Jan 2026 16:07:46 +0100 Subject: [PATCH 29/91] exchange: drop ccxt workarounds; keep closed-order fallback --- freqtrade/exchange/krakenfutures.py | 358 ++------------------------- tests/exchange/test_krakenfutures.py | 238 +----------------- 2 files changed, 22 insertions(+), 574 deletions(-) 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") From 1345cfdbee6347d5cde087c1f39463e9dd597fc0 Mon Sep 17 00:00:00 2001 From: matstedt Date: Mon, 19 Jan 2026 17:08:05 +0100 Subject: [PATCH 30/91] exchange: fallback to canceled orders for krakenfutures fetch_order; add tests --- freqtrade/exchange/krakenfutures.py | 26 +++++++++++++++++------ tests/exchange/test_krakenfutures.py | 31 +++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 02507a9f7..441cba75d 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -6,7 +6,7 @@ import logging from typing import Any from freqtrade.enums import MarginMode, PriceType, TradingMode -from freqtrade.exceptions import ExchangeError, RetryableOrderError +from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import FtHas @@ -169,19 +169,33 @@ class Krakenfutures(Exchange): """ 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. + Fall back to closed/canceled order endpoints for older orders. """ params = params or {} try: return super().fetch_order(order_id, pair, params=params) - except RetryableOrderError as err: - if not self.exchange_has("fetchClosedOrders"): - raise + except (RetryableOrderError, TemporaryError) as err: + order = self._fetch_order_from_closed_or_canceled(order_id, pair, params) + 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) - raise err + + 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 get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: """CCXT currently does not support Kraken Futures fetchFundingHistory.""" diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index adf1ee339..171b0ab0b 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -6,7 +6,7 @@ from copy import deepcopy from unittest.mock import MagicMock from freqtrade.enums import CandleType, MarginMode, TradingMode -from freqtrade.exceptions import RetryableOrderError +from freqtrade.exceptions import RetryableOrderError, TemporaryError from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import EXMS, get_patched_exchange @@ -35,10 +35,12 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c """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(Exchange, "fetch_order", side_effect=RetryableOrderError("not found")) mocker.patch.object( - Exchange, "fetch_order", side_effect=RetryableOrderError("not found") + ex, + "exchange_has", + side_effect=lambda endpoint: endpoint == "fetchClosedOrders", ) - mocker.patch.object(ex, "exchange_has", return_value=True) mocker.patch.object( ex._api, "fetch_closed_orders", @@ -50,6 +52,29 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c assert res["id"] == "abc" +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") + + mocker.patch.object( + Exchange, "fetch_order", side_effect=TemporaryError("UUID string too large") + ) + mocker.patch.object( + ex, + "exchange_has", + side_effect=lambda endpoint: endpoint == "fetchCanceledOrders", + ) + mocker.patch.object( + ex._api, + "fetch_canceled_orders", + return_value=[{"id": "def", "symbol": "BTC/USD:USD", "status": "canceled"}], + create=True, + ) + + res = ex.fetch_order("def", "BTC/USD:USD") + assert res["id"] == "def" + + def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" api_mock = MagicMock() From 3f6e7ca4c12720543651aae193995c92012d0256 Mon Sep 17 00:00:00 2001 From: matstedt Date: Tue, 20 Jan 2026 08:46:06 +0100 Subject: [PATCH 31/91] tests: cover krakenfutures currency sum and funding fees --- tests/exchange/test_krakenfutures.py | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 171b0ab0b..048c52a3a 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -3,6 +3,7 @@ from __future__ import annotations from copy import deepcopy +from datetime import UTC, datetime from unittest.mock import MagicMock from freqtrade.enums import CandleType, MarginMode, TradingMode @@ -166,3 +167,45 @@ def test_krakenfutures_get_balances_falls_back_to_ccxt_fetch_balance(mocker, def res = ex.get_balances() assert res["free"]["USD"] == 11.0 assert res["total"]["USD"] == 12.0 + + +def test_krakenfutures_sum_currencies_value_sums_valid_values(mocker, default_conf): + """Sum currencies values, skipping invalid entries.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + currencies = { + "USD": {"value": "10"}, + "EUR": {"value": 2.5}, + "BAD": {"value": ""}, + "NODICT": 3, + } + + assert ex._sum_currencies_value(currencies) == 12.5 + + +def test_krakenfutures_sum_currencies_value_returns_none_when_empty(mocker, default_conf): + """Return None when no valid values are found.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + assert ex._sum_currencies_value(["not", "dict"]) is None + assert ex._sum_currencies_value({"USD": {"value": ""}}) is None + + +def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): + """Use funding fee helper in futures mode.""" + conf = dict(default_conf) + conf["trading_mode"] = TradingMode.FUTURES + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + helper = mocker.patch.object(ex, "_fetch_and_calculate_funding_fees", return_value=1.23) + open_date = datetime.now(UTC) + + assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, open_date) == 1.23 + helper.assert_called_once_with("BTC/USD:USD", 0.1, False, open_date) + + +def test_krakenfutures_get_funding_fees_spot_returns_zero(mocker, default_conf): + """Return 0.0 outside futures mode without calling the helper.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + helper = mocker.patch.object(ex, "_fetch_and_calculate_funding_fees") + + assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, None) == 0.0 + helper.assert_not_called() From 7ee4338b1f307080408f6e09b12b3edeb1f81a9a Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 21 Jan 2026 15:16:05 +0100 Subject: [PATCH 32/91] tests: increase coverage - krakenfutures edge paths and ft_has overrides --- tests/exchange/test_exchange_utils.py | 74 ++++++++++++- tests/exchange/test_krakenfutures.py | 146 +++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 3 deletions(-) diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 9460b8abf..373b4d65a 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta from math import isnan, nan from unittest.mock import MagicMock +import ccxt import pytest from ccxt import ( DECIMAL_PLACES, @@ -28,8 +29,14 @@ from freqtrade.exchange import ( timeframe_to_resample_freq, timeframe_to_seconds, ) -from freqtrade.exchange.check_exchange import check_exchange -from freqtrade.exchange.exchange_utils import _exchange_has_helper +from freqtrade.exchange import exchange_utils as exchange_utils_mod +from freqtrade.exchange.check_exchange import _get_ft_has_overrides, check_exchange +from freqtrade.exchange.exchange_utils import ( + _build_exchange_list_entry, + _exchange_has_helper, + validate_exchange, +) +from freqtrade.resolvers.exchange_resolver import ExchangeResolver from tests.conftest import log_has_re @@ -426,3 +433,66 @@ def test_exchange__exchange_has_helper(): } missing = _exchange_has_helper(e_mod.has, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades", "fetchOrder"} + + +def test_validate_exchange_uses_ft_has_overrides(monkeypatch): + class DummyExchange: + def __init__(self) -> None: + self.has = { + "fetchOrder": False, + "fetchL2OrderBook": False, + "fetchTicker": True, + "cancelOrder": True, + "createOrder": True, + "fetchBalance": True, + "fetchOHLCV": False, + } + + monkeypatch.setattr(ccxt.pro, "dummy", DummyExchange, raising=False) + + valid, _, _, _ = validate_exchange("dummy") + assert not valid + + valid, _, _, _ = validate_exchange("dummy", {"fetchOrder": True, "fetchOHLCV": True}) + assert valid + + +def test_build_exchange_list_entry_uses_ft_has_overrides(monkeypatch): + class DummyClass: + _supported_trading_mode_margin_pairs = [] + + @staticmethod + def get_ft_has(): + return {"fetchOrder": True} + + captured = {} + + class DummyExchange: + name = "Dummy" + alias = False + dex = False + + def fake_validate_exchange(exchange, ft_has_overrides=None): + captured["ft_has_overrides"] = ft_has_overrides + return True, "", "", DummyExchange() + + monkeypatch.setattr(exchange_utils_mod, "validate_exchange", fake_validate_exchange) + + res = _build_exchange_list_entry("dummy", {"dummy": {"class": DummyClass}}) + assert captured["ft_has_overrides"] == {"fetchOrder": True} + assert res["classname"] == "dummy" + + +def test_get_ft_has_overrides_returns_override(monkeypatch): + class DummyClass: + @staticmethod + def get_ft_has(): + return {"fetchOrder": True} + + monkeypatch.setattr( + ExchangeResolver, + "search_all_objects", + lambda *args, **kwargs: [{"name": "dummy", "class": DummyClass}], + ) + + assert _get_ft_has_overrides("dummy") == {"fetchOrder": True} diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 048c52a3a..b1f5efee1 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -6,8 +6,10 @@ from copy import deepcopy from datetime import UTC, datetime from unittest.mock import MagicMock +import pytest + from freqtrade.enums import CandleType, MarginMode, TradingMode -from freqtrade.exceptions import RetryableOrderError, TemporaryError +from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import EXMS, get_patched_exchange @@ -76,6 +78,26 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default assert res["id"] == "def" +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") + + mocker.patch.object(Exchange, "fetch_order", side_effect=RetryableOrderError("not found")) + mocker.patch.object(ex, "_fetch_order_from_closed_or_canceled", 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): + """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", {}) + assert res is None + + def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" api_mock = MagicMock() @@ -114,6 +136,15 @@ def test_krakenfutures_validate_stakecurrency_allows_eur(mocker, default_conf): ex.validate_stakecurrency("EUR") +def test_krakenfutures_validate_stakecurrency_calls_super(mocker, default_conf): + """Test validate_stakecurrency calls the base implementation for non-EUR.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + base_validate = mocker.patch.object(Exchange, "validate_stakecurrency") + + ex.validate_stakecurrency("USD") + assert base_validate.call_count == 1 + + def test_krakenfutures_get_balances_synth_usd_from_flex(mocker, default_conf): """Test get_balances synthesizes USD balance from flex account.""" conf = dict(default_conf) @@ -169,6 +200,65 @@ def test_krakenfutures_get_balances_falls_back_to_ccxt_fetch_balance(mocker, def assert res["total"]["USD"] == 12.0 +def test_krakenfutures_get_balances_returns_for_non_usd_stake(mocker, default_conf): + """Test get_balances returns early when stake currency is not USD.""" + conf = dict(default_conf) + conf["stake_currency"] = "EUR" + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + sample = {"free": {"EUR": 10.0}, "used": {"EUR": 0.0}, "total": {"EUR": 10.0}} + mocker.patch.object(Exchange, "get_balances", return_value=sample) + + res = ex.get_balances() + assert res == sample + assert "USD" not in res + + +def test_krakenfutures_get_balances_returns_when_flex_missing_or_invalid(mocker, default_conf): + """Return original balances when flex data or USD extraction is missing.""" + conf = dict(default_conf) + conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + base_one = {"free": {}, "used": {}, "total": {}} + base_two = {"free": {}, "used": {}, "total": {}} + mocker.patch.object(Exchange, "get_balances", side_effect=[base_one, base_two]) + mocker.patch.object(ex, "_get_flex_account", side_effect=[None, {"availableMargin": 1.0}]) + mocker.patch.object(ex, "_extract_usd_from_flex", return_value=(None, 1.0)) + + res = ex.get_balances() + assert res == base_one + + res = ex.get_balances() + assert res == base_two + + +def test_krakenfutures_get_balances_preserves_existing_usd(mocker, default_conf): + """Keep existing USD free balance if higher than flex-derived value.""" + conf = dict(default_conf) + conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + sample = { + "USD": {"free": 20.0, "used": 0.0, "total": 20.0}, + "free": {"USD": 20.0}, + "used": {"USD": 0.0}, + "total": {"USD": 20.0}, + "info": { + "accounts": { + "flex": { + "availableMargin": 11.0, + "balanceValue": 12.0, + } + } + }, + } + + mocker.patch.object(Exchange, "get_balances", return_value=sample) + res = ex.get_balances() + assert res["free"]["USD"] == 20.0 + + def test_krakenfutures_sum_currencies_value_sums_valid_values(mocker, default_conf): """Sum currencies values, skipping invalid entries.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") @@ -189,6 +279,49 @@ def test_krakenfutures_sum_currencies_value_returns_none_when_empty(mocker, defa assert ex._sum_currencies_value({"USD": {"value": ""}}) is None +def test_krakenfutures_get_flex_account_fetch_balance_error(mocker, default_conf): + """Return None when fetch_balance fails while attempting to load flex data.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + mocker.patch.object(ex._api, "fetch_balance", side_effect=Exception("boom"), create=True) + res = ex._get_flex_account({"free": {}, "used": {}, "total": {}}, None) + assert res is None + + +def test_krakenfutures_extract_flex_from_raw_handles_invalid(mocker, default_conf): + """Return None for malformed flex account structures.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + assert ex._extract_flex_from_raw(None) is None + assert ex._extract_flex_from_raw({"info": "bad"}) is None + assert ex._extract_flex_from_raw({"info": {"accounts": "bad"}}) is None + + +def test_krakenfutures_extract_usd_from_flex_fallbacks(mocker, default_conf): + """Use currencies fallback and fill missing USD values.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + usd_free, usd_total = ex._extract_usd_from_flex( + {"availableMargin": "5.0", "currencies": {"EUR": {"value": "6.0"}}} + ) + assert usd_free == 5.0 + assert usd_total == 6.0 + + usd_free, usd_total = ex._extract_usd_from_flex({"availableMargin": "7.0"}) + assert usd_free == 7.0 + assert usd_total == 7.0 + + usd_free, usd_total = ex._extract_usd_from_flex({"balanceValue": "9.0"}) + assert usd_free == 9.0 + assert usd_total == 9.0 + + +def test_krakenfutures_safe_float_invalid_returns_none(mocker, default_conf): + """Return None for values that cannot be coerced to float.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + assert ex._safe_float("not-a-number") is None + + def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): """Use funding fee helper in futures mode.""" conf = dict(default_conf) @@ -202,6 +335,17 @@ def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): helper.assert_called_once_with("BTC/USD:USD", 0.1, False, open_date) +def test_krakenfutures_get_funding_fees_futures_exchange_error(mocker, default_conf): + """Return 0.0 when funding fee retrieval fails.""" + conf = dict(default_conf) + conf["trading_mode"] = TradingMode.FUTURES + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + mocker.patch.object(ex, "_fetch_and_calculate_funding_fees", side_effect=ExchangeError("fail")) + + assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, None) == 0.0 + + def test_krakenfutures_get_funding_fees_spot_returns_zero(mocker, default_conf): """Return 0.0 outside futures mode without calling the helper.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") From e5a23f5afd6dedfac3c272bf3477752269a051d9 Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 22 Jan 2026 12:46:31 +0100 Subject: [PATCH 33/91] test: fix import order in exchange utils test --- tests/exchange/test_exchange_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 373b4d65a..7853595db 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -21,6 +21,9 @@ from freqtrade.exchange import ( amount_to_contract_precision, amount_to_precision, date_minus_candles, +) +from freqtrade.exchange import exchange_utils as exchange_utils_mod +from freqtrade.exchange import ( price_to_precision, timeframe_to_minutes, timeframe_to_msecs, @@ -29,7 +32,6 @@ from freqtrade.exchange import ( timeframe_to_resample_freq, timeframe_to_seconds, ) -from freqtrade.exchange import exchange_utils as exchange_utils_mod from freqtrade.exchange.check_exchange import _get_ft_has_overrides, check_exchange from freqtrade.exchange.exchange_utils import ( _build_exchange_list_entry, From b4daabe491354efbd22315a242435ece893d0995 Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 22 Jan 2026 14:53:37 +0100 Subject: [PATCH 34/91] test: re-organize imports --- tests/exchange/test_exchange_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 7853595db..373b4d65a 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -21,9 +21,6 @@ from freqtrade.exchange import ( amount_to_contract_precision, amount_to_precision, date_minus_candles, -) -from freqtrade.exchange import exchange_utils as exchange_utils_mod -from freqtrade.exchange import ( price_to_precision, timeframe_to_minutes, timeframe_to_msecs, @@ -32,6 +29,7 @@ from freqtrade.exchange import ( timeframe_to_resample_freq, timeframe_to_seconds, ) +from freqtrade.exchange import exchange_utils as exchange_utils_mod from freqtrade.exchange.check_exchange import _get_ft_has_overrides, check_exchange from freqtrade.exchange.exchange_utils import ( _build_exchange_list_entry, From 06abc95b2e3fa3eda9eba81180fbc336a3e69f66 Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 22 Jan 2026 15:12:01 +0100 Subject: [PATCH 35/91] test: fix contradictory sorting by ruff and isort 7 --- tests/exchange/test_exchange_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 373b4d65a..685e5923d 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -15,6 +15,7 @@ from ccxt import ( TRUNCATE, ) +import freqtrade.exchange.exchange_utils as exchange_utils_mod from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import ( @@ -29,7 +30,6 @@ from freqtrade.exchange import ( timeframe_to_resample_freq, timeframe_to_seconds, ) -from freqtrade.exchange import exchange_utils as exchange_utils_mod from freqtrade.exchange.check_exchange import _get_ft_has_overrides, check_exchange from freqtrade.exchange.exchange_utils import ( _build_exchange_list_entry, From 9ddf464c03fac13049a202e73051df12fce7ed02 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 23 Jan 2026 17:07:51 +0100 Subject: [PATCH 36/91] fix(exchange): drop unused get_ft_has override plumbing --- freqtrade/exchange/check_exchange.py | 18 +------ freqtrade/exchange/exchange_utils.py | 10 +--- tests/exchange/test_exchange_utils.py | 70 +-------------------------- 3 files changed, 3 insertions(+), 95 deletions(-) diff --git a/freqtrade/exchange/check_exchange.py b/freqtrade/exchange/check_exchange.py index 3795a9f4d..583868744 100644 --- a/freqtrade/exchange/check_exchange.py +++ b/freqtrade/exchange/check_exchange.py @@ -1,30 +1,15 @@ import logging -from typing import Any from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import available_exchanges, is_exchange_known_ccxt, validate_exchange from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS, SUPPORTED_EXCHANGES -from freqtrade.resolvers.exchange_resolver import ExchangeResolver logger = logging.getLogger(__name__) -def _get_ft_has_overrides(exchange_name: str) -> dict[str, Any] | None: - subclassed = {e["name"].lower(): e for e in ExchangeResolver.search_all_objects({}, False)} - mapped = MAP_EXCHANGE_CHILDCLASS.get(exchange_name.lower(), exchange_name.lower()).lower() - resolved = subclassed.get(mapped) - if not resolved: - return None - - get_ft_has = getattr(resolved["class"], "get_ft_has", None) - if callable(get_ft_has): - return get_ft_has() or None - return None - - def check_exchange(config: Config, check_for_bad: bool = True) -> bool: """ Check if the exchange name in the config file is supported by Freqtrade @@ -62,8 +47,7 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool: f"{', '.join(available_exchanges())}" ) - ft_has_overrides = _get_ft_has_overrides(exchange) - valid, reason, _, _ = validate_exchange(exchange, ft_has_overrides) + valid, reason, _, _ = validate_exchange(exchange) if not valid: if check_for_bad: raise OperationalException( diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index ef4c5387b..1840e0def 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -70,7 +70,6 @@ def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) def validate_exchange( exchange: str, - ft_has_overrides: dict[str, Any] | None = None, ) -> tuple[bool, str, str, ccxt.Exchange | None]: """ returns: can_use, reason, exchange_object @@ -85,8 +84,6 @@ def validate_exchange( return False, "", "", None ex_has = dict(ex_mod.has or {}) - if ft_has_overrides: - ex_has.update(ft_has_overrides) result = True reasons = [] @@ -120,13 +117,8 @@ def _build_exchange_list_entry( mapped_exchange_name = MAP_EXCHANGE_CHILDCLASS.get(exchange_name, exchange_name).lower() resolved = exchangeClasses.get(mapped_exchange_name) - ft_has_overrides = None - if resolved: - get_ft_has = getattr(resolved["class"], "get_ft_has", None) - if callable(get_ft_has): - ft_has_overrides = get_ft_has() or None - valid, comment, comment_fut, ex_mod = validate_exchange(exchange_name, ft_has_overrides) + valid, comment, comment_fut, ex_mod = validate_exchange(exchange_name) is_alias = getattr(ex_mod, "alias", False) result: ValidExchangesType = { diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 685e5923d..67cfdd0a5 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -3,7 +3,6 @@ from datetime import UTC, datetime, timedelta from math import isnan, nan from unittest.mock import MagicMock -import ccxt import pytest from ccxt import ( DECIMAL_PLACES, @@ -15,7 +14,6 @@ from ccxt import ( TRUNCATE, ) -import freqtrade.exchange.exchange_utils as exchange_utils_mod from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import ( @@ -30,13 +28,10 @@ from freqtrade.exchange import ( timeframe_to_resample_freq, timeframe_to_seconds, ) -from freqtrade.exchange.check_exchange import _get_ft_has_overrides, check_exchange +from freqtrade.exchange.check_exchange import check_exchange from freqtrade.exchange.exchange_utils import ( - _build_exchange_list_entry, _exchange_has_helper, - validate_exchange, ) -from freqtrade.resolvers.exchange_resolver import ExchangeResolver from tests.conftest import log_has_re @@ -433,66 +428,3 @@ def test_exchange__exchange_has_helper(): } missing = _exchange_has_helper(e_mod.has, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades", "fetchOrder"} - - -def test_validate_exchange_uses_ft_has_overrides(monkeypatch): - class DummyExchange: - def __init__(self) -> None: - self.has = { - "fetchOrder": False, - "fetchL2OrderBook": False, - "fetchTicker": True, - "cancelOrder": True, - "createOrder": True, - "fetchBalance": True, - "fetchOHLCV": False, - } - - monkeypatch.setattr(ccxt.pro, "dummy", DummyExchange, raising=False) - - valid, _, _, _ = validate_exchange("dummy") - assert not valid - - valid, _, _, _ = validate_exchange("dummy", {"fetchOrder": True, "fetchOHLCV": True}) - assert valid - - -def test_build_exchange_list_entry_uses_ft_has_overrides(monkeypatch): - class DummyClass: - _supported_trading_mode_margin_pairs = [] - - @staticmethod - def get_ft_has(): - return {"fetchOrder": True} - - captured = {} - - class DummyExchange: - name = "Dummy" - alias = False - dex = False - - def fake_validate_exchange(exchange, ft_has_overrides=None): - captured["ft_has_overrides"] = ft_has_overrides - return True, "", "", DummyExchange() - - monkeypatch.setattr(exchange_utils_mod, "validate_exchange", fake_validate_exchange) - - res = _build_exchange_list_entry("dummy", {"dummy": {"class": DummyClass}}) - assert captured["ft_has_overrides"] == {"fetchOrder": True} - assert res["classname"] == "dummy" - - -def test_get_ft_has_overrides_returns_override(monkeypatch): - class DummyClass: - @staticmethod - def get_ft_has(): - return {"fetchOrder": True} - - monkeypatch.setattr( - ExchangeResolver, - "search_all_objects", - lambda *args, **kwargs: [{"name": "dummy", "class": DummyClass}], - ) - - assert _get_ft_has_overrides("dummy") == {"fetchOrder": True} From 0cab0e8ac5aa1b995df0b0c5efcb6bb0ef72a7e8 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 23 Jan 2026 17:17:08 +0100 Subject: [PATCH 37/91] fix(tests): use myokx for non-futures list-exchanges check --- tests/commands/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 9c0cfaa1d..3f5e846c7 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -211,7 +211,7 @@ def test_list_exchanges(capsys): assert re.search(r"Exchanges available for Freqtrade.*", captured.out) assert re.search(r".*binance.*", captured.out) assert re.search(r"\bkrakenfutures\b", captured.out) - assert not re.search(r"\bkraken\b", captured.out) + assert not re.search(r"\bmyokx\b", captured.out) def test_list_timeframes(mocker, capsys): From 0d4dadd63fa668d77a14305e953d8cfdf09cd751 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 23 Jan 2026 17:24:36 +0100 Subject: [PATCH 38/91] docs: add Kraken to the list of supported Futures Exchanges --- README.md | 1 + docs/index.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 5dbcdb25a..a7345796d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Please read the [exchange-specific notes](https://www.freqtrade.io/en/stable/exc - [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX) - [X] [OKX](https://okx.com/) - [X] [Bybit](https://bybit.com/) +- [X] [Kraken](https://eu.kraken.com/features/derivatives) Please make sure to read the [exchange specific notes](https://www.freqtrade.io/en/stable/exchanges/), as well as the [trading with leverage](https://www.freqtrade.io/en/stable/leverage/) documentation before diving in. diff --git a/docs/index.md b/docs/index.md index 8ecfd4320..fdbda93fb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual, - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX) - [X] [OKX](https://okx.com/) +- [X] [Kraken](https://eu.kraken.com/features/derivatives) Please make sure to read the [exchange specific notes](exchanges.md), as well as the [trading with leverage](leverage.md) documentation before diving in. From 6ed470884b9ad08b812160435d141977d6a25450 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 23 Jan 2026 18:53:08 +0100 Subject: [PATCH 39/91] krakenfutures: backfill leverage tier notionals --- freqtrade/exchange/krakenfutures.py | 56 ++++++++++++++++++++++++++++ tests/exchange/test_krakenfutures.py | 41 ++++++++++++++++++++ tests/exchange_online/conftest.py | 2 +- 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 441cba75d..703fc9725 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -206,6 +206,62 @@ class Krakenfutures(Exchange): logger.warning(f"Could not update funding fees for {pair}.") return 0.0 + def get_leverage_tiers(self) -> dict[str, list[dict]]: + """ + Kraken Futures returns leverage tiers with contract-based thresholds. + CCXT maps tiers to min/maxNotional using "numNonContractUnits", but many markets + only expose "contracts". Fill missing min/maxNotional from contract data and + maxPositionSize to keep get_max_leverage functional. + """ + tiers = super().get_leverage_tiers() + if not tiers: + return tiers + + for pair, pair_tiers in tiers.items(): + self._fill_leverage_tier_notionals(pair, pair_tiers) + + return tiers + + def _fill_leverage_tier_notionals(self, pair: str, pair_tiers: list[dict]) -> None: + if not pair_tiers: + return + self._fill_missing_min_notional(pair_tiers) + self._fill_missing_max_notional(pair, pair_tiers) + + def _fill_missing_min_notional(self, pair_tiers: list[dict]) -> None: + for tier in pair_tiers: + if tier.get("minNotional") is None: + info = tier.get("info") or {} + contracts = self._safe_float(info.get("contracts")) + if contracts is not None: + tier["minNotional"] = contracts + + def _fill_missing_max_notional(self, pair: str, pair_tiers: list[dict]) -> None: + for i in range(len(pair_tiers) - 1): + if pair_tiers[i].get("maxNotional") is None: + next_min = pair_tiers[i + 1].get("minNotional") + if next_min is not None: + pair_tiers[i]["maxNotional"] = next_min + + last = pair_tiers[-1] + if last.get("maxNotional") is None: + max_notional = self._max_notional_from_market(pair) + if max_notional is not None: + last["maxNotional"] = max_notional + elif last.get("minNotional") is not None: + # Avoid None values, even if we cannot infer the real max. + last["maxNotional"] = last["minNotional"] + + def _max_notional_from_market(self, pair: str) -> float | None: + market = self.markets.get(pair) + if not market: + return None + max_pos = self._safe_float(market.get("info", {}).get("maxPositionSize")) + if max_pos is None: + return None + contract_size = self._safe_float(market.get("contractSize")) or 1.0 + return max_pos * contract_size + @staticmethod def _safe_float(v: Any) -> float | None: try: diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index b1f5efee1..d77f21285 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -24,6 +24,47 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stop_price_type_field"] == "triggerSignal" +def test_krakenfutures_get_leverage_tiers_fills_contracts(mocker, default_conf): + """Fill missing min/maxNotional from contracts/maxPositionSize in leverage tiers.""" + mock_markets = { + "BTC/USD:USD": { + "info": {"maxPositionSize": 1000000}, + "contractSize": 1.0, + } + } + ex = get_patched_exchange( + mocker, default_conf, exchange="krakenfutures", mock_markets=mock_markets + ) + assert isinstance(ex, Krakenfutures) + + sample_tiers = { + "BTC/USD:USD": [ + { + "minNotional": None, + "maxNotional": None, + "maintenanceMarginRate": 0.01, + "maxLeverage": 50.0, + "info": {"contracts": 0}, + }, + { + "minNotional": None, + "maxNotional": None, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": {"contracts": 500000}, + }, + ] + } + + mocker.patch.object(Exchange, "get_leverage_tiers", return_value=sample_tiers) + tiers = ex.get_leverage_tiers() + pair_tiers = tiers["BTC/USD:USD"] + assert pair_tiers[0]["minNotional"] == 0.0 + assert pair_tiers[0]["maxNotional"] == 500000.0 + assert pair_tiers[1]["minNotional"] == 500000.0 + assert pair_tiers[1]["maxNotional"] == 1000000.0 + + def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): """Test that OHLCV candle limit follows CCXT feature limit.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index db8d14064..37c1f6c76 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -569,7 +569,7 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "candle_count": 2000, "futures_pair": "BTC/USD:USD", "hasQuoteVolumeFutures": False, - "leverage_tiers_public": False, + "leverage_tiers_public": True, "leverage_in_spot_market": False, }, } From a8d7a72cecefb01c0f2cb78c0d3f1f10526a41ba Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 23 Jan 2026 18:55:15 +0100 Subject: [PATCH 40/91] tests: drop unused krakenfutures spot leverage flag --- tests/exchange_online/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 37c1f6c76..68b7ed5c3 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -570,7 +570,6 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "futures_pair": "BTC/USD:USD", "hasQuoteVolumeFutures": False, "leverage_tiers_public": True, - "leverage_in_spot_market": False, }, } From ba80329eda81408809263c6b9688e766e0fb19f1 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 23 Jan 2026 20:46:28 +0100 Subject: [PATCH 41/91] =?UTF-8?q?krakenfutures:=20guard=20maxNotional=20fo?= =?UTF-8?q?r=20non=E2=80=91contract=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- freqtrade/exchange/krakenfutures.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 703fc9725..befc1ac87 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -225,18 +225,25 @@ class Krakenfutures(Exchange): def _fill_leverage_tier_notionals(self, pair: str, pair_tiers: list[dict]) -> None: if not pair_tiers: return - self._fill_missing_min_notional(pair_tiers) - self._fill_missing_max_notional(pair, pair_tiers) + contract_based = self._fill_missing_min_notional(pair_tiers) + self._fill_missing_max_notional(pair, pair_tiers, contract_based) - def _fill_missing_min_notional(self, pair_tiers: list[dict]) -> None: + def _fill_missing_min_notional(self, pair_tiers: list[dict]) -> bool: + contract_based = False for tier in pair_tiers: if tier.get("minNotional") is None: info = tier.get("info") or {} contracts = self._safe_float(info.get("contracts")) if contracts is not None: tier["minNotional"] = contracts + contract_based = True + elif (tier.get("info") or {}).get("contracts") is not None: + contract_based = True + return contract_based - def _fill_missing_max_notional(self, pair: str, pair_tiers: list[dict]) -> None: + def _fill_missing_max_notional( + self, pair: str, pair_tiers: list[dict], contract_based: bool + ) -> None: for i in range(len(pair_tiers) - 1): if pair_tiers[i].get("maxNotional") is None: next_min = pair_tiers[i + 1].get("minNotional") @@ -245,12 +252,19 @@ class Krakenfutures(Exchange): last = pair_tiers[-1] if last.get("maxNotional") is None: - max_notional = self._max_notional_from_market(pair) + max_notional = self._max_notional_from_market(pair) if contract_based else None + min_notional = last.get("minNotional") + if ( + max_notional is not None + and min_notional is not None + and max_notional <= min_notional + ): + max_notional = None if max_notional is not None: last["maxNotional"] = max_notional - elif last.get("minNotional") is not None: + elif min_notional is not None: # Avoid None values, even if we cannot infer the real max. - last["maxNotional"] = last["minNotional"] + last["maxNotional"] = min_notional + 1.0 def _max_notional_from_market(self, pair: str) -> float | None: market = self.markets.get(pair) From 445a22222a26dc3309e4c0afd98e24f65c60401e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 25 Jan 2026 10:48:19 +0100 Subject: [PATCH 42/91] chore: remove leverage tier fixing code --- freqtrade/exchange/krakenfutures.py | 70 ----------------------------- 1 file changed, 70 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index befc1ac87..441cba75d 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -206,76 +206,6 @@ class Krakenfutures(Exchange): logger.warning(f"Could not update funding fees for {pair}.") return 0.0 - def get_leverage_tiers(self) -> dict[str, list[dict]]: - """ - Kraken Futures returns leverage tiers with contract-based thresholds. - CCXT maps tiers to min/maxNotional using "numNonContractUnits", but many markets - only expose "contracts". Fill missing min/maxNotional from contract data and - maxPositionSize to keep get_max_leverage functional. - """ - tiers = super().get_leverage_tiers() - if not tiers: - return tiers - - for pair, pair_tiers in tiers.items(): - self._fill_leverage_tier_notionals(pair, pair_tiers) - - return tiers - - def _fill_leverage_tier_notionals(self, pair: str, pair_tiers: list[dict]) -> None: - if not pair_tiers: - return - contract_based = self._fill_missing_min_notional(pair_tiers) - self._fill_missing_max_notional(pair, pair_tiers, contract_based) - - def _fill_missing_min_notional(self, pair_tiers: list[dict]) -> bool: - contract_based = False - for tier in pair_tiers: - if tier.get("minNotional") is None: - info = tier.get("info") or {} - contracts = self._safe_float(info.get("contracts")) - if contracts is not None: - tier["minNotional"] = contracts - contract_based = True - elif (tier.get("info") or {}).get("contracts") is not None: - contract_based = True - return contract_based - - def _fill_missing_max_notional( - self, pair: str, pair_tiers: list[dict], contract_based: bool - ) -> None: - for i in range(len(pair_tiers) - 1): - if pair_tiers[i].get("maxNotional") is None: - next_min = pair_tiers[i + 1].get("minNotional") - if next_min is not None: - pair_tiers[i]["maxNotional"] = next_min - - last = pair_tiers[-1] - if last.get("maxNotional") is None: - max_notional = self._max_notional_from_market(pair) if contract_based else None - min_notional = last.get("minNotional") - if ( - max_notional is not None - and min_notional is not None - and max_notional <= min_notional - ): - max_notional = None - if max_notional is not None: - last["maxNotional"] = max_notional - elif min_notional is not None: - # Avoid None values, even if we cannot infer the real max. - last["maxNotional"] = min_notional + 1.0 - - def _max_notional_from_market(self, pair: str) -> float | None: - market = self.markets.get(pair) - if not market: - return None - max_pos = self._safe_float(market.get("info", {}).get("maxPositionSize")) - if max_pos is None: - return None - contract_size = self._safe_float(market.get("contractSize")) or 1.0 - return max_pos * contract_size - @staticmethod def _safe_float(v: Any) -> float | None: try: From 1d0bcbe2344a229198bfb0998e54ef968d5ad3ec Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 25 Jan 2026 11:33:30 +0100 Subject: [PATCH 43/91] test: remove no longer necessary test --- tests/exchange/test_krakenfutures.py | 41 ---------------------------- 1 file changed, 41 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index d77f21285..b1f5efee1 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -24,47 +24,6 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stop_price_type_field"] == "triggerSignal" -def test_krakenfutures_get_leverage_tiers_fills_contracts(mocker, default_conf): - """Fill missing min/maxNotional from contracts/maxPositionSize in leverage tiers.""" - mock_markets = { - "BTC/USD:USD": { - "info": {"maxPositionSize": 1000000}, - "contractSize": 1.0, - } - } - ex = get_patched_exchange( - mocker, default_conf, exchange="krakenfutures", mock_markets=mock_markets - ) - assert isinstance(ex, Krakenfutures) - - sample_tiers = { - "BTC/USD:USD": [ - { - "minNotional": None, - "maxNotional": None, - "maintenanceMarginRate": 0.01, - "maxLeverage": 50.0, - "info": {"contracts": 0}, - }, - { - "minNotional": None, - "maxNotional": None, - "maintenanceMarginRate": 0.02, - "maxLeverage": 25.0, - "info": {"contracts": 500000}, - }, - ] - } - - mocker.patch.object(Exchange, "get_leverage_tiers", return_value=sample_tiers) - tiers = ex.get_leverage_tiers() - pair_tiers = tiers["BTC/USD:USD"] - assert pair_tiers[0]["minNotional"] == 0.0 - assert pair_tiers[0]["maxNotional"] == 500000.0 - assert pair_tiers[1]["minNotional"] == 500000.0 - assert pair_tiers[1]["maxNotional"] == 1000000.0 - - def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): """Test that OHLCV candle limit follows CCXT feature limit.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") From 1c77bd6f5d40f12a945960197374ae186ad71124 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 31 Jan 2026 11:43:50 +0100 Subject: [PATCH 44/91] krakenfutures: improve fetch_order fallback --- freqtrade/exchange/krakenfutures.py | 89 ++++++++++++++++++++-------- tests/exchange/test_krakenfutures.py | 73 ++++++++++++++++++++--- 2 files changed, 128 insertions(+), 34 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): + # 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 + + 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) - - 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) - + 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() From cb6a1267b6ed577b9d0ee4c587ec8f41c63d3b53 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 1 Feb 2026 14:32:07 +0100 Subject: [PATCH 45/91] chore: remove pointless comments --- freqtrade/exchange/krakenfutures.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index b8eedb5e5..27a2d0f69 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -30,18 +30,14 @@ class Krakenfutures(Exchange): (TradingMode.FUTURES, MarginMode.ISOLATED), ] - # Freqtrade uses _ft_has (exchange_has_overrides) for config validation. _ft_has: FtHas = { "stoploss_on_exchange": True, "stoploss_order_types": { "limit": "limit", "market": "market", }, - # request param used by Exchange._get_stop_params "stop_price_param": "triggerPrice", - # response key used by stoploss_adjust and general stop parsing "stop_price_prop": "stopPrice", - # optional futures price type mapping (only used if stoploss_price_type is configured) "stop_price_type_field": "triggerSignal", "stop_price_type_value_mapping": { PriceType.LAST: "last", From 712fd8ac140ca8c7bd29014597ce1f5e963a48f2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 1 Feb 2026 14:32:30 +0100 Subject: [PATCH 46/91] chore: import formatting --- tests/exchange/test_exchange_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 67cfdd0a5..9460b8abf 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -29,9 +29,7 @@ from freqtrade.exchange import ( timeframe_to_seconds, ) from freqtrade.exchange.check_exchange import check_exchange -from freqtrade.exchange.exchange_utils import ( - _exchange_has_helper, -) +from freqtrade.exchange.exchange_utils import _exchange_has_helper from tests.conftest import log_has_re From c68c6896d7bbfcad95127281727d305ecc0813ed Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 1 Feb 2026 14:33:44 +0100 Subject: [PATCH 47/91] chore: revert odd, unnecessary modifications --- freqtrade/exchange/exchange_utils.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 1840e0def..eaa6a7f42 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -57,7 +57,7 @@ def available_exchanges(ccxt_module: CcxtModuleType | None = None) -> list[str]: def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) -> list[str]: """ Checks availability of methods (or their replacement)s in a merged has-dict. - :param ex_has: merged "has" dict (ccxt + freqtrade overrides) + :param ex_has: merged "has" dict (ccxt.has) :param required: dict of required methods, with possible replacement methods as list :return: list of missing required methods """ @@ -68,9 +68,7 @@ def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) ] -def validate_exchange( - exchange: str, -) -> tuple[bool, str, str, ccxt.Exchange | None]: +def validate_exchange(exchange: str) -> tuple[bool, str, str, ccxt.Exchange | None]: """ returns: can_use, reason, exchange_object with Reason including both missing and missing_opt @@ -114,12 +112,8 @@ def _build_exchange_list_entry( exchange_name: str, exchangeClasses: dict[str, Any] ) -> ValidExchangesType: exchange_name = exchange_name.lower() - mapped_exchange_name = MAP_EXCHANGE_CHILDCLASS.get(exchange_name, exchange_name).lower() - - resolved = exchangeClasses.get(mapped_exchange_name) - valid, comment, comment_fut, ex_mod = validate_exchange(exchange_name) - + mapped_exchange_name = MAP_EXCHANGE_CHILDCLASS.get(exchange_name, exchange_name).lower() is_alias = getattr(ex_mod, "alias", False) result: ValidExchangesType = { "name": getattr(ex_mod, "name", exchange_name), @@ -135,7 +129,7 @@ def _build_exchange_list_entry( else None, "trade_modes": [{"trading_mode": "spot", "margin_mode": ""}], } - if resolved: + if resolved := exchangeClasses.get(mapped_exchange_name): supported_modes: list[TradeModeType] = [ {"trading_mode": tm.value, "margin_mode": mm.value} for tm, mm in resolved["class"]._supported_trading_mode_margin_pairs From 756f178ec332e10dc51eadb34019947e08ca8873 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 6 Feb 2026 19:28:45 +0100 Subject: [PATCH 48/91] exchange_utils: use ccxt exchange.has directly in capability checks --- freqtrade/exchange/exchange_utils.py | 17 ++++++++--------- tests/exchange/test_exchange_utils.py | 6 +++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index eaa6a7f42..43d7acbd6 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -54,17 +54,18 @@ def available_exchanges(ccxt_module: CcxtModuleType | None = None) -> list[str]: return [x for x in exchanges if validate_exchange(x)[0]] -def _exchange_has_helper(ex_has: dict[str, Any], required: dict[str, list[str]]) -> list[str]: +def _exchange_has_helper(ex_mod: ccxt.Exchange, required: dict[str, list[str]]) -> list[str]: """ - Checks availability of methods (or their replacement)s in a merged has-dict. - :param ex_has: merged "has" dict (ccxt.has) + Checks availability of methods (or their replacement)s in ex_mod.has + :param ex_mod: ccxt Exchange module :param required: dict of required methods, with possible replacement methods as list :return: list of missing required methods """ return [ k for k, v in required.items() - if ex_has.get(k) is not True and (len(v) == 0 or not (all(ex_has.get(x) for x in v))) + if ex_mod.has.get(k) is not True + and (len(v) == 0 or not (all(ex_mod.has.get(x) for x in v))) ] @@ -81,19 +82,17 @@ def validate_exchange(exchange: str) -> tuple[bool, str, str, ccxt.Exchange | No if not ex_mod or not ex_mod.has: return False, "", "", None - ex_has = dict(ex_mod.has or {}) - result = True reasons = [] reasons_fut = "" - missing = _exchange_has_helper(ex_has, EXCHANGE_HAS_REQUIRED) + missing = _exchange_has_helper(ex_mod, EXCHANGE_HAS_REQUIRED) if missing: result = False reasons.append(f"missing: {', '.join(missing)}") - missing_opt = _exchange_has_helper(ex_has, EXCHANGE_HAS_OPTIONAL) + missing_opt = _exchange_has_helper(ex_mod, EXCHANGE_HAS_OPTIONAL) - missing_futures = _exchange_has_helper(ex_has, EXCHANGE_HAS_OPTIONAL_FUTURES) + missing_futures = _exchange_has_helper(ex_mod, EXCHANGE_HAS_OPTIONAL_FUTURES) if exchange.lower() in BAD_EXCHANGES: result = False diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 9460b8abf..d0d35a9dd 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -404,7 +404,7 @@ def test_exchange__exchange_has_helper(): "fetchMyTrades": ["fetchTrades"], "fetchOrder": ["fetchOpenOrder", "fetchClosedOrder"], } - missing = _exchange_has_helper(e_mod.has, required) + missing = _exchange_has_helper(e_mod, required) assert set(missing) == {"fetchOHLCV"} e_mod.has = { @@ -414,7 +414,7 @@ def test_exchange__exchange_has_helper(): "fetchMyTrades": False, "fetchOrder": True, } - missing = _exchange_has_helper(e_mod.has, required) + missing = _exchange_has_helper(e_mod, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades"} e_mod.has = { @@ -424,5 +424,5 @@ def test_exchange__exchange_has_helper(): "fetchMyTrades": False, "fetchOrder": False, } - missing = _exchange_has_helper(e_mod.has, required) + missing = _exchange_has_helper(e_mod, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades", "fetchOrder"} From f81e335b27f2f9d8591ef4312d4c69f3f7241f6e Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 6 Feb 2026 19:28:50 +0100 Subject: [PATCH 49/91] krakenfutures: simplify order/balance handling and harden error mapping --- freqtrade/exchange/krakenfutures.py | 286 +++++------ tests/exchange/test_krakenfutures.py | 709 ++++++++++++++++++--------- 2 files changed, 632 insertions(+), 363 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 27a2d0f69..73b685961 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -1,16 +1,22 @@ """Kraken Futures exchange subclass""" -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.exceptions import ( + DDosProtection, + ExchangeError, + InvalidOrderException, + OperationalException, + RetryableOrderError, + TemporaryError, +) +from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange -from freqtrade.exchange.exchange_types import CcxtOrder, FtHas +from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas logger = logging.getLogger(__name__) @@ -23,7 +29,7 @@ class Krakenfutures(Exchange): Key differences from spot Kraken: - Stop orders use triggerPrice/triggerSignal instead of stopPrice - - Multi-collateral accounts require synthetic USD balance from flex account + - Flex (multi-collateral) accounts need USD balance synthesis """ _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ @@ -46,135 +52,112 @@ class Krakenfutures(Exchange): }, } - def get_balances(self, params: dict | None = None) -> dict[str, Any]: + @retrier + def get_balances(self, params: dict | None = None) -> CcxtBalances: """ - Fetch account balances with special handling for Kraken Futures flex accounts. - Kraken Futures supports "flex" (multi-collateral) accounts where users can hold - multiple currencies as margin. CCXT returns these balances per-currency (EUR, etc.) - but does not synthesize a USD balance, which Freqtrade expects as stake_currency. - The flex account fields used: - - availableMargin: USD value available for new positions (-> free) - - balanceValue/portfolioValue: total USD value of account (-> total) - - currencies[*].value: fallback sum if above fields missing + Fetch balances with USD synthesis for flex (multi-collateral) accounts. + + Kraken Futures flex accounts hold multiple currencies as collateral. + CCXT returns per-currency balances but doesn't expose margin values + as a USD balance. This override synthesizes a USD entry from flex account data + when stake_currency is USD. + + Field mapping (margin-centric for internal consistency): + - free: availableMargin (margin available for new positions) + - total: marginEquity (haircut-adjusted collateral + unrealized P&L) + - used: total - free (margin currently in use) + + Fallback chain for total: marginEquity -> portfolioValue -> balanceValue """ - balances = super().get_balances(params=params) - - stake = str(self._config.get("stake_currency", "")).upper() - if stake != "USD": - return balances - - flex = self._get_flex_account(balances, params) - if flex is None: - return balances - - usd_free, usd_total = self._extract_usd_from_flex(flex) - if usd_free is None or usd_total is None: - return balances - - # Preserve existing USD if higher (usually dust) - if isinstance(balances.get("free"), dict): - existing = self._safe_float(balances["free"].get("USD")) - if existing is not None: - usd_free = max(existing, usd_free) - - self._apply_usd_balances(balances, usd_free, usd_total) - return balances - - def _get_flex_account( - self, balances: dict[str, Any], params: dict | None - ) -> dict[str, Any] | None: - """Try to get flex account data from cached balances or fetch fresh.""" - flex = self._extract_flex_from_raw(balances) - if flex is not None: - return flex - try: - raw = self._api.fetch_balance(params or {}) - except Exception: - return None - return self._extract_flex_from_raw(raw) + balances = self._api.fetch_balance(params or {}) + + # Only synthesize USD if stake_currency is USD + stake = str(self._config.get("stake_currency", "")).upper() + if stake != "USD": + # Skip USD synthesis for non-USD stake currencies + balances.pop("info", None) + balances.pop("free", None) + balances.pop("total", None) + balances.pop("used", None) + self._log_exchange_response("fetch_balance", balances, add_info=params) + return balances + + # For flex accounts, synthesize USD balance from margin values + info = balances.get("info", {}) + accounts = info.get("accounts", {}) if isinstance(info, dict) else {} + flex = accounts.get("flex", {}) if isinstance(accounts, dict) else {} + + if flex: + usd_free = self._safe_float(flex.get("availableMargin")) + # Prefer marginEquity for consistency (same basis as availableMargin) + raw_total = ( + flex.get("marginEquity") + or flex.get("portfolioValue") + or flex.get("balanceValue") + ) + usd_total = self._safe_float(raw_total) + if usd_free is not None or usd_total is not None: + # Use available value for both if only one is present + usd_free = usd_free if usd_free is not None else usd_total + usd_total = usd_total if usd_total is not None else usd_free + # Both values are guaranteed to be present after fallback. + usd_used = max(0.0, usd_total - usd_free) + balances["USD"] = {"free": usd_free, "used": usd_used, "total": usd_total} + + # Remove additional info from ccxt results (same as base class) + balances.pop("info", None) + balances.pop("free", None) + balances.pop("total", None) + balances.pop("used", None) + + self._log_exchange_response("fetch_balance", balances, add_info=params) + return balances + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.OperationFailed, ccxt.ExchangeError) as e: + raise TemporaryError( + f"Could not get balance due to {e.__class__.__name__}. Message: {e}" + ) from e + except ccxt.BaseError as e: + raise OperationalException(e) from e @staticmethod - def _extract_flex_from_raw(raw: dict[str, Any] | None) -> dict[str, Any] | None: - """Navigate raw -> info -> accounts -> flex (Kraken Futures multi-collateral account).""" - if not isinstance(raw, dict): + def _safe_float(value: Any) -> float | None: + """Convert value to float, returning None if conversion fails.""" + if value is None: return None - info = raw.get("info") - if not isinstance(info, dict): + try: + return float(value) + except (ValueError, TypeError): return None - accounts = info.get("accounts") - if not isinstance(accounts, dict): - return None - flex = accounts.get("flex") - return flex if isinstance(flex, dict) else None - - def _extract_usd_from_flex(self, flex: dict[str, Any]) -> tuple[float | None, float | None]: - usd_free = self._safe_float(flex.get("availableMargin") or flex.get("available_margin")) - usd_total = self._safe_float( - flex.get("balanceValue") or flex.get("collateralValue") or flex.get("portfolioValue") - ) - - # Fallback: sum currencies[*].value - if usd_total is None: - usd_total = self._sum_currencies_value(flex.get("currencies")) - - # Fill missing with the other if available - if usd_total is None and usd_free is not None: - usd_total = usd_free - if usd_free is None and usd_total is not None: - usd_free = usd_total - - return usd_free, usd_total - - def _sum_currencies_value(self, currencies: Any) -> float | None: - """Sum value fields from currencies dict.""" - if not isinstance(currencies, dict): - return None - total = 0.0 - found = False - for cur in currencies.values(): - if isinstance(cur, dict): - v = self._safe_float(cur.get("value")) - if v is not None: - total += v - found = True - return total if found else None - - @staticmethod - def _apply_usd_balances(balances: dict[str, Any], usd_free: float, usd_total: float) -> None: - """Update balances dict with USD values.""" - balances["USD"] = {"free": usd_free, "used": 0.0, "total": usd_total} - balances.setdefault("free", {}) - balances.setdefault("used", {}) - balances.setdefault("total", {}) - - if isinstance(balances["free"], dict): - balances["free"]["USD"] = usd_free - if isinstance(balances["used"], dict): - balances["used"]["USD"] = 0.0 - if isinstance(balances["total"], dict): - balances["total"]["USD"] = usd_total - - def validate_stakecurrency(self, stake_currency: str) -> None: - # Kraken Futures multi-collateral allows EUR collateral even if markets look USD-settled. - if str(stake_currency).upper() == "EUR": - return - super().validate_stakecurrency(stake_currency) + @retrier(retries=API_FETCH_ORDER_RETRY_COUNT) def fetch_order( self, order_id: str, pair: str, params: dict[str, Any] | None = None ) -> CcxtOrder: - """Fetch order with fallback to open/closed/canceled endpoints.""" + """Fetch order with direct CCXT call and fallback to history endpoints.""" if self._config.get("dry_run"): return self.fetch_dry_run_order(order_id) params = params or {} try: - # 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): + order = self._api.fetch_order(order_id, pair, params=params) + self._log_exchange_response("fetch_order", order) + return self._order_contracts_to_amount(order) + except ccxt.OrderNotFound: + # Expected for older Kraken Futures orders not visible in orders/status. pass + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except ccxt.InvalidOrder as e: + msg = f"Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}" + raise InvalidOrderException(msg) from e + except (ccxt.OperationFailed, ccxt.ExchangeError): + # Fallback to history endpoints for temporary/status endpoint gaps. + pass + except ccxt.BaseError as e: + raise OperationalException(e) from e order = self._fetch_order_fallback(order_id, pair, params) if order is not None: @@ -189,20 +172,12 @@ class Krakenfutures(Exchange): 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 + order = self._find_order_in_list(self._api.fetch_open_orders, None, params, order_id_str) + if order is not None: + return 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 + for fetch_fn in (self._api.fetch_closed_orders, self._api.fetch_canceled_orders): order = self._find_order_in_list(fetch_fn, pair, params, order_id_str) if order is not None: return order @@ -228,24 +203,55 @@ class Krakenfutures(Exchange): 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: + except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e: logger.debug(f"{fetch_fn.__name__} failed: {e}") + return None + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.OperationFailed, ccxt.ExchangeError) as e: + raise TemporaryError( + f"Could not get order due to {e.__class__.__name__}. Message: {e}" + ) from e + except ccxt.BaseError as e: + raise OperationalException(e) from e return None + @staticmethod + def _fix_trigger_order_id(order: dict) -> dict: + """ + Fix CCXT trigger order response where top-level 'id' is None. + + Kraken Futures trigger orders return 'id': None in CCXT responses, + but the actual order ID is in info.order.orderId. Extract and set it. + """ + if order.get("id") is None: + info = order.get("info", {}) + inner_order = info.get("order", {}) if isinstance(info, dict) else {} + if isinstance(inner_order, dict) and inner_order.get("orderId"): + order["id"] = inner_order["orderId"] + return order + + def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict: + """Cancel stoploss order and fix CCXT response for trigger orders.""" + params = params or {} + params["trigger"] = True + order = self.cancel_order(order_id, pair, params) + return self._fix_trigger_order_id(order) + + def fetch_stoploss_order( + self, order_id: str, pair: str, params: dict | None = None + ) -> CcxtOrder: + """Fetch stoploss order and fix CCXT response for trigger orders.""" + params = params or {} + params["trigger"] = True + order = self.fetch_order(order_id, pair, params) + return self._fix_trigger_order_id(order) + def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: - """CCXT currently does not support Kraken Futures fetchFundingHistory.""" + """Fetch funding fees, returning 0.0 if retrieval fails.""" if self.trading_mode == TradingMode.FUTURES: try: return self._fetch_and_calculate_funding_fees(pair, amount, is_short, open_date) except ExchangeError: logger.warning(f"Could not update funding fees for {pair}.") return 0.0 - - @staticmethod - def _safe_float(v: Any) -> float | None: - try: - if v is None or v == "": - return None - return float(v) - except (TypeError, ValueError): - return None diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 60f1a94a3..0501bcebf 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -6,15 +6,25 @@ from copy import deepcopy from datetime import UTC, datetime from unittest.mock import MagicMock +import ccxt import pytest from freqtrade.enums import CandleType, MarginMode, TradingMode -from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError -from freqtrade.exchange.exchange import Exchange +from freqtrade.exceptions import ( + DDosProtection, + ExchangeError, + InvalidOrderException, + OperationalException, + RetryableOrderError, + TemporaryError, +) from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import EXMS, get_patched_exchange +# --- _ft_has and OHLCV tests --- + + def test_krakenfutures_ft_has_overrides(): """Test that _ft_has contains Kraken Futures stoploss settings.""" ft_has = Krakenfutures._ft_has @@ -34,21 +44,17 @@ def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000 +# --- fetch_order fallback tests --- + + 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.""" conf = dict(default_conf) conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - # 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", - side_effect=lambda endpoint: endpoint == "fetchClosedOrders", - ) + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) + mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) mocker.patch.object( ex._api, "fetch_closed_orders", @@ -66,14 +72,9 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - mocker.patch.object( - Exchange.fetch_order, "__wrapped__", side_effect=TemporaryError("UUID string too large") - ) - mocker.patch.object( - ex, - "exchange_has", - side_effect=lambda endpoint: endpoint == "fetchCanceledOrders", - ) + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.ExchangeError("UUID too large")) + 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, "fetch_canceled_orders", @@ -85,30 +86,105 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default assert res["id"] == "def" +def test_krakenfutures_fetch_order_returns_direct_ccxt_result(mocker, default_conf): + """Use direct CCXT fetch_order result when available.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + ccxt_order = {"id": "live-123", "symbol": "BTC/USD:USD", "status": "open"} + converted = {"id": "live-123", "status": "open"} + mocker.patch.object(ex._api, "fetch_order", return_value=ccxt_order) + converter = mocker.patch.object(ex, "_order_contracts_to_amount", return_value=converted) + fallback = mocker.patch.object(ex, "_fetch_order_fallback") + + res = ex.fetch_order("live-123", "BTC/USD:USD") + + assert res == converted + converter.assert_called_once_with(ccxt_order) + fallback.assert_not_called() + + def test_krakenfutures_fetch_order_reraises_when_no_fallback(mocker, default_conf): """Re-raise when fallback cannot locate the order.""" 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._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) mocker.patch.object(ex, "_fetch_order_fallback", return_value=None) with pytest.raises(RetryableOrderError): - ex.fetch_order("abc", "BTC/USD:USD") + ex.fetch_order("abc", "BTC/USD:USD", count=0) + + +def test_krakenfutures_fetch_order_invalid_order_maps_exception(mocker, default_conf): + """Map ccxt.InvalidOrder to InvalidOrderException.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.InvalidOrder("bad order")) + + with pytest.raises(InvalidOrderException, match="bad order"): + ex.fetch_order("abc", "BTC/USD:USD", count=0) + + +def test_krakenfutures_fetch_order_ddos_maps_exception(mocker, default_conf): + """Map ccxt.DDoSProtection to DDosProtection.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.DDoSProtection("ratelimit")) + + with pytest.raises(DDosProtection): + ex.fetch_order("abc", "BTC/USD:USD", count=0) + + +def test_krakenfutures_fetch_order_baseerror_maps_exception(mocker, default_conf): + """Map generic ccxt.BaseError to OperationalException.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.BaseError("unexpected")) + + with pytest.raises(OperationalException): + ex.fetch_order("abc", "BTC/USD:USD", count=0) 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 order is not found in any endpoint.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - mocker.patch.object(ex, "exchange_has", return_value=False) + 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, "fetch_canceled_orders", return_value=[], create=True) res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {}) assert res is None +def test_krakenfutures_fetch_order_fallback_returns_open_order_first(mocker, default_conf): + """Return immediately when order is found in open orders.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + open_fetch = mocker.patch.object( + ex._api, + "fetch_open_orders", + return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "open"}], + create=True, + ) + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + + res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {}) + + assert res is not None + assert res["id"] == "abc" + open_fetch.assert_called_once() + closed_fetch.assert_not_called() + canceled_fetch.assert_not_called() + + 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) @@ -128,14 +204,7 @@ def test_krakenfutures_fetch_order_finds_trigger_order(mocker, 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"), - ) + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) # 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) @@ -153,6 +222,83 @@ def test_krakenfutures_fetch_order_finds_trigger_order(mocker, default_conf): assert res["id"] == "trigger-123" +def test_krakenfutures_fetch_order_propagates_exchange_errors_from_fallback(mocker, default_conf): + """Fallback list fetch should not hide exchange-level failures.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) + mocker.patch.object( + ex._api, "fetch_open_orders", side_effect=ccxt.ExchangeError("service unavailable") + ) + + with pytest.raises(TemporaryError): + ex.fetch_order("abc", "BTC/USD:USD", count=0) + + +def test_krakenfutures_fetch_order_exchangeerror_uses_fallback(mocker, default_conf): + """ExchangeError from fetch_order should trigger fallback lookup.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + fallback_order = {"id": "abc", "symbol": "BTC/USD:USD", "status": "closed"} + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.ExchangeError("temporary")) + fallback = mocker.patch.object(ex, "_fetch_order_fallback", return_value=fallback_order) + + result = ex.fetch_order("abc", "BTC/USD:USD", count=0) + + assert result == fallback_order + fallback.assert_called_once_with("abc", "BTC/USD:USD", {}) + + +def test_krakenfutures_find_order_in_list_handles_ordernotfound(mocker, default_conf): + """OrderNotFound in list fetch is treated as a missing order.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + def raise_order_not_found(_symbol, params=None): + raise ccxt.OrderNotFound("missing") + + assert ex._find_order_in_list(raise_order_not_found, "BTC/USD:USD", {}, "abc") is None + + +def test_krakenfutures_find_order_in_list_maps_ddos(mocker, default_conf): + """DDoS errors from list fetch are mapped to DDosProtection.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + def raise_ddos(_symbol, params=None): + raise ccxt.DDoSProtection("ratelimit") + + with pytest.raises(DDosProtection): + ex._find_order_in_list(raise_ddos, "BTC/USD:USD", {}, "abc") + + +def test_krakenfutures_find_order_in_list_maps_temporary(mocker, default_conf): + """OperationFailed/ExchangeError from list fetch map to TemporaryError.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + def raise_temp(_symbol, params=None): + raise ccxt.OperationFailed("temporary") + + with pytest.raises(TemporaryError): + ex._find_order_in_list(raise_temp, "BTC/USD:USD", {}, "abc") + + +def test_krakenfutures_find_order_in_list_maps_operational(mocker, default_conf): + """Unexpected BaseError from list fetch maps to OperationalException.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + def raise_base(_symbol, params=None): + raise ccxt.BaseError("unexpected") + + with pytest.raises(OperationalException): + ex._find_order_in_list(raise_base, "BTC/USD:USD", {}, "abc") + + +# --- Stoploss tests --- + + def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" api_mock = MagicMock() @@ -185,196 +331,7 @@ def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_c assert params["reduceOnly"] 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") - ex.validate_stakecurrency("EUR") - - -def test_krakenfutures_validate_stakecurrency_calls_super(mocker, default_conf): - """Test validate_stakecurrency calls the base implementation for non-EUR.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - base_validate = mocker.patch.object(Exchange, "validate_stakecurrency") - - ex.validate_stakecurrency("USD") - assert base_validate.call_count == 1 - - -def test_krakenfutures_get_balances_synth_usd_from_flex(mocker, default_conf): - """Test get_balances synthesizes USD balance from flex account.""" - conf = dict(default_conf) - conf["stake_currency"] = "USD" - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - - sample = { - "EUR": {"free": 10.0, "used": 0.0, "total": 10.0}, - "free": {"EUR": 10.0}, - "used": {"EUR": 0.0}, - "total": {"EUR": 10.0}, - "info": { - "accounts": { - "flex": { - "availableMargin": 11.0, - "balanceValue": 12.0, - } - } - }, - } - - mocker.patch.object(Exchange, "get_balances", return_value=sample) - res = ex.get_balances() - assert res["USD"]["free"] == 11.0 - assert res["USD"]["total"] == 12.0 - - -def test_krakenfutures_get_balances_falls_back_to_ccxt_fetch_balance(mocker, default_conf): - """Test get_balances falls back to fetch_balance when no flex in initial response.""" - conf = dict(default_conf) - conf["stake_currency"] = "USD" - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - - # Base get_balances returns no info -> forces fallback - mocker.patch.object( - Exchange, "get_balances", return_value={"free": {}, "used": {}, "total": {}} - ) - - mocker.patch.object( - ex._api, - "fetch_balance", - return_value={ - "free": {"EUR": 10.0}, - "used": {"EUR": 0.0}, - "total": {"EUR": 10.0}, - "info": {"accounts": {"flex": {"availableMargin": 11.0, "balanceValue": 12.0}}}, - }, - create=True, - ) - - res = ex.get_balances() - assert res["free"]["USD"] == 11.0 - assert res["total"]["USD"] == 12.0 - - -def test_krakenfutures_get_balances_returns_for_non_usd_stake(mocker, default_conf): - """Test get_balances returns early when stake currency is not USD.""" - conf = dict(default_conf) - conf["stake_currency"] = "EUR" - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - - sample = {"free": {"EUR": 10.0}, "used": {"EUR": 0.0}, "total": {"EUR": 10.0}} - mocker.patch.object(Exchange, "get_balances", return_value=sample) - - res = ex.get_balances() - assert res == sample - assert "USD" not in res - - -def test_krakenfutures_get_balances_returns_when_flex_missing_or_invalid(mocker, default_conf): - """Return original balances when flex data or USD extraction is missing.""" - conf = dict(default_conf) - conf["stake_currency"] = "USD" - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - - base_one = {"free": {}, "used": {}, "total": {}} - base_two = {"free": {}, "used": {}, "total": {}} - mocker.patch.object(Exchange, "get_balances", side_effect=[base_one, base_two]) - mocker.patch.object(ex, "_get_flex_account", side_effect=[None, {"availableMargin": 1.0}]) - mocker.patch.object(ex, "_extract_usd_from_flex", return_value=(None, 1.0)) - - res = ex.get_balances() - assert res == base_one - - res = ex.get_balances() - assert res == base_two - - -def test_krakenfutures_get_balances_preserves_existing_usd(mocker, default_conf): - """Keep existing USD free balance if higher than flex-derived value.""" - conf = dict(default_conf) - conf["stake_currency"] = "USD" - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - - sample = { - "USD": {"free": 20.0, "used": 0.0, "total": 20.0}, - "free": {"USD": 20.0}, - "used": {"USD": 0.0}, - "total": {"USD": 20.0}, - "info": { - "accounts": { - "flex": { - "availableMargin": 11.0, - "balanceValue": 12.0, - } - } - }, - } - - mocker.patch.object(Exchange, "get_balances", return_value=sample) - res = ex.get_balances() - assert res["free"]["USD"] == 20.0 - - -def test_krakenfutures_sum_currencies_value_sums_valid_values(mocker, default_conf): - """Sum currencies values, skipping invalid entries.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - currencies = { - "USD": {"value": "10"}, - "EUR": {"value": 2.5}, - "BAD": {"value": ""}, - "NODICT": 3, - } - - assert ex._sum_currencies_value(currencies) == 12.5 - - -def test_krakenfutures_sum_currencies_value_returns_none_when_empty(mocker, default_conf): - """Return None when no valid values are found.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - assert ex._sum_currencies_value(["not", "dict"]) is None - assert ex._sum_currencies_value({"USD": {"value": ""}}) is None - - -def test_krakenfutures_get_flex_account_fetch_balance_error(mocker, default_conf): - """Return None when fetch_balance fails while attempting to load flex data.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object(ex._api, "fetch_balance", side_effect=Exception("boom"), create=True) - res = ex._get_flex_account({"free": {}, "used": {}, "total": {}}, None) - assert res is None - - -def test_krakenfutures_extract_flex_from_raw_handles_invalid(mocker, default_conf): - """Return None for malformed flex account structures.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - assert ex._extract_flex_from_raw(None) is None - assert ex._extract_flex_from_raw({"info": "bad"}) is None - assert ex._extract_flex_from_raw({"info": {"accounts": "bad"}}) is None - - -def test_krakenfutures_extract_usd_from_flex_fallbacks(mocker, default_conf): - """Use currencies fallback and fill missing USD values.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - usd_free, usd_total = ex._extract_usd_from_flex( - {"availableMargin": "5.0", "currencies": {"EUR": {"value": "6.0"}}} - ) - assert usd_free == 5.0 - assert usd_total == 6.0 - - usd_free, usd_total = ex._extract_usd_from_flex({"availableMargin": "7.0"}) - assert usd_free == 7.0 - assert usd_total == 7.0 - - usd_free, usd_total = ex._extract_usd_from_flex({"balanceValue": "9.0"}) - assert usd_free == 9.0 - assert usd_total == 9.0 - - -def test_krakenfutures_safe_float_invalid_returns_none(mocker, default_conf): - """Return None for values that cannot be coerced to float.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - assert ex._safe_float("not-a-number") is None +# --- Funding fees tests --- def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): @@ -408,3 +365,309 @@ def test_krakenfutures_get_funding_fees_spot_returns_zero(mocker, default_conf): assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, None) == 0.0 helper.assert_not_called() + + +# --- Balance tests (flex account USD synthesis) --- + + +def test_krakenfutures_get_balances_flex_account_synthesizes_usd(mocker, default_conf): + """Test that flex account availableMargin/portfolioValue are synthesized as USD balance.""" + default_conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + flex_response = { + "EUR": {"free": 100.0, "used": 0.0, "total": 100.0}, + "info": { + "accounts": { + "flex": { + "availableMargin": "950.50", + "marginEquity": "1000.00", + "portfolioValue": "1050.00", # Should be ignored, marginEquity preferred + "currencies": {"EUR": {"quantity": "100", "value": "105.00"}}, + } + } + }, + "free": {"EUR": 100.0}, + "used": {"EUR": 0.0}, + "total": {"EUR": 100.0}, + } + mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response) + + balances = ex.get_balances() + + # USD should be synthesized from flex account + assert "USD" in balances + assert balances["USD"]["free"] == 950.50 + assert balances["USD"]["total"] == 1000.00 + # used = total - free = 1000.00 - 950.50 = 49.50 + assert balances["USD"]["used"] == 49.50 + # EUR should still be present + assert "EUR" in balances + # info, free, total, used dicts should be removed + assert "info" not in balances + assert "free" not in balances + assert "total" not in balances + assert "used" not in balances + + +def test_krakenfutures_get_balances_no_flex_account(mocker, default_conf): + """Test that non-flex accounts work without USD synthesis.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + standard_response = { + "USD": {"free": 500.0, "used": 100.0, "total": 600.0}, + "info": {"type": "cashAccount"}, + "free": {"USD": 500.0}, + "used": {"USD": 100.0}, + "total": {"USD": 600.0}, + } + mocker.patch.object(ex._api, "fetch_balance", return_value=standard_response) + + balances = ex.get_balances() + + # USD should be preserved as-is + assert balances["USD"]["free"] == 500.0 + assert balances["USD"]["total"] == 600.0 + # info, free, total, used dicts should be removed + assert "info" not in balances + + +def test_krakenfutures_get_balances_flex_fallback_chain(mocker, default_conf): + """Test fallback chain: marginEquity -> portfolioValue -> balanceValue.""" + default_conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + # Test fallback to balanceValue (no marginEquity or portfolioValue) + flex_response = { + "info": { + "accounts": { + "flex": { + "availableMargin": "800.00", + "balanceValue": "850.00", + } + } + }, + "free": {}, + "used": {}, + "total": {}, + } + mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response) + + balances = ex.get_balances() + + assert balances["USD"]["free"] == 800.00 + assert balances["USD"]["total"] == 850.00 + # used = total - free = 850.00 - 800.00 = 50.00 + assert balances["USD"]["used"] == 50.00 + + +def test_krakenfutures_get_balances_flex_zero_free_calculates_used(mocker, default_conf): + """Test used margin is correct when availableMargin is 0.0.""" + default_conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + flex_response = { + "info": { + "accounts": { + "flex": { + "availableMargin": "0.00", + "marginEquity": "125.00", + } + } + }, + "free": {}, + "used": {}, + "total": {}, + } + mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response) + + balances = ex.get_balances() + + assert balances["USD"]["free"] == 0.00 + assert balances["USD"]["total"] == 125.00 + assert balances["USD"]["used"] == 125.00 + + +def test_krakenfutures_get_balances_flex_missing_free_uses_total(mocker, default_conf): + """When availableMargin is missing, free falls back to total and used is 0.0.""" + default_conf["stake_currency"] = "USD" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + flex_response = { + "info": { + "accounts": { + "flex": { + "marginEquity": "250.00", + } + } + }, + "free": {}, + "used": {}, + "total": {}, + } + mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response) + + balances = ex.get_balances() + + assert balances["USD"]["free"] == 250.00 + assert balances["USD"]["total"] == 250.00 + assert balances["USD"]["used"] == 0.00 + + +def test_krakenfutures_get_balances_skips_synthesis_for_non_usd_stake(mocker, default_conf): + """Test that USD synthesis is skipped when stake_currency is not USD.""" + default_conf["stake_currency"] = "EUR" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + flex_response = { + "EUR": {"free": 100.0, "used": 0.0, "total": 100.0}, + "info": { + "accounts": { + "flex": { + "availableMargin": "950.50", + "portfolioValue": "1000.00", + } + } + }, + "free": {"EUR": 100.0}, + "used": {"EUR": 0.0}, + "total": {"EUR": 100.0}, + } + mocker.patch.object(ex._api, "fetch_balance", return_value=flex_response) + + balances = ex.get_balances() + + # USD should NOT be synthesized since stake_currency is EUR + assert "USD" not in balances + # EUR should still be present + assert "EUR" in balances + assert balances["EUR"]["free"] == 100.0 + + +def test_krakenfutures_get_balances_maps_ddos(mocker, default_conf): + """Map ccxt.DDoSProtection from fetch_balance to DDosProtection.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + mocker.patch.object(ex._api, "fetch_balance", side_effect=ccxt.DDoSProtection("ratelimit")) + + with pytest.raises(DDosProtection): + ex.get_balances(count=0) + + +def test_krakenfutures_get_balances_maps_temporary(mocker, default_conf): + """Map ccxt.OperationFailed/ExchangeError from fetch_balance to TemporaryError.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + mocker.patch.object(ex._api, "fetch_balance", side_effect=ccxt.OperationFailed("temporary")) + + with pytest.raises(TemporaryError): + ex.get_balances(count=0) + + +def test_krakenfutures_get_balances_maps_operational(mocker, default_conf): + """Map unexpected ccxt.BaseError from fetch_balance to OperationalException.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + mocker.patch.object(ex._api, "fetch_balance", side_effect=ccxt.BaseError("unexpected")) + + with pytest.raises(OperationalException): + ex.get_balances(count=0) + + +def test_krakenfutures_safe_float(): + """Test _safe_float handles various input types.""" + assert Krakenfutures._safe_float("123.45") == 123.45 + assert Krakenfutures._safe_float(100) == 100.0 + assert Krakenfutures._safe_float(None) is None + assert Krakenfutures._safe_float("invalid") is None + assert Krakenfutures._safe_float({}) is None + + +# --- Stoploss cancel tests --- + + +def test_krakenfutures_cancel_stoploss_order_fixes_id(mocker, default_conf): + """Test cancel_stoploss_order extracts order ID from info when top-level id is None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + # CCXT returns 'id': None for trigger orders, but orderId is in info.order + ccxt_response = { + "id": None, + "status": "canceled", + "info": { + "order": { + "orderId": "a10258a9-01ea-44c4-a38f-66165678926e", + "type": "TRIGGER_ORDER", + "symbol": "PF_XBTUSD", + }, + "status": "CANCELLED", + }, + } + mocker.patch.object(ex, "cancel_order", return_value=ccxt_response) + + result = ex.cancel_stoploss_order("a10258a9-01ea-44c4-a38f-66165678926e", "BTC/USD:USD") + + # ID should be extracted from info.order.orderId + assert result["id"] == "a10258a9-01ea-44c4-a38f-66165678926e" + assert result["status"] == "canceled" + + +def test_krakenfutures_cancel_stoploss_order_preserves_existing_id(mocker, default_conf): + """Test cancel_stoploss_order doesn't overwrite existing id.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + # Normal response with id already set + ccxt_response = { + "id": "existing-order-id", + "status": "canceled", + "info": {}, + } + mocker.patch.object(ex, "cancel_order", return_value=ccxt_response) + + result = ex.cancel_stoploss_order("existing-order-id", "BTC/USD:USD") + + assert result["id"] == "existing-order-id" + + +# --- Stoploss fetch tests --- + + +def test_krakenfutures_fetch_stoploss_order_fixes_id(mocker, default_conf): + """Test fetch_stoploss_order extracts order ID from info when top-level id is None.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + # CCXT returns 'id': None for trigger orders, but orderId is in info.order + ccxt_response = { + "id": None, + "status": "open", + "info": { + "order": { + "orderId": "trigger-order-123", + "type": "TRIGGER_ORDER", + "symbol": "PF_XBTUSD", + }, + }, + } + mocker.patch.object(ex, "fetch_order", return_value=ccxt_response) + + result = ex.fetch_stoploss_order("trigger-order-123", "BTC/USD:USD") + + # ID should be extracted from info.order.orderId + assert result["id"] == "trigger-order-123" + + +def test_krakenfutures_fetch_stoploss_order_passes_trigger_param(mocker, default_conf): + """Test fetch_stoploss_order passes trigger=True to fetch_order.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + mock_fetch = mocker.patch.object( + ex, "fetch_order", return_value={"id": "order-123", "status": "open", "info": {}} + ) + + ex.fetch_stoploss_order("order-123", "BTC/USD:USD") + + # Verify trigger=True was passed + mock_fetch.assert_called_once() + call_params = mock_fetch.call_args[0][2] # third positional arg is params + assert call_params.get("trigger") is True From 4f8291384d3afe09ea34aa7251d035e816cb4e27 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 6 Feb 2026 19:29:02 +0100 Subject: [PATCH 50/91] docs: align Kraken Futures config and collateral guidance --- docs/data-download.md | 1 + docs/exchanges.md | 14 ++++++-------- docs/index.md | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/data-download.md b/docs/data-download.md index 5ae1fe560..47e9db5e9 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -268,6 +268,7 @@ If `--convert` is also provided, the resample step will happen automatically and !!! Note "Kraken user" Kraken users should read [this](exchanges.md#historic-kraken-data) before starting to download data. + Kraken Futures uses standard OHLCV downloads and does not require `--dl-trades`. Example call: diff --git a/docs/exchanges.md b/docs/exchanges.md index 9c900d128..14edc11c6 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -225,13 +225,7 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future "exchange": { "name": "krakenfutures", "key": "your_exchange_key", - "secret": "your_exchange_secret", - "ccxt_config": {"enableRateLimit": true}, - "ccxt_async_config": {"enableRateLimit": true} -}, -"order_types": { - "stoploss": "market", - "stoploss_price_type": "mark" // "mark" (default), "last", or "index" + "secret": "your_exchange_secret" }, "trading_mode": "futures", "margin_mode": "isolated", @@ -243,7 +237,11 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future Use `order_types.stoploss_price_type` to select the trigger price source (`mark`, `last`, or `index`). !!! Note "Collateral" - Kraken Futures is USD-settled. Kraken allows EUR collateral, but USD is the recommended stake currency. + Kraken Futures is USD-settled. Use USD as your stake currency. + +!!! Note "Flex (Multi-collateral) Accounts" + Kraken Futures flex accounts allow collateral in multiple currencies, while trading remains USD-settled. + Freqtrade derives the `USD` balance from Kraken margin fields, so keep `stake_currency` set to `USD`. ## Kucoin diff --git a/docs/index.md b/docs/index.md index fdbda93fb..2e1672e0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,7 +62,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual, - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX) - [X] [OKX](https://okx.com/) -- [X] [Kraken](https://eu.kraken.com/features/derivatives) +- [X] [Kraken](https://www.kraken.com/features/futures) Please make sure to read the [exchange specific notes](exchanges.md), as well as the [trading with leverage](leverage.md) documentation before diving in. From cfa4924b8ef0e6241eb3b6dc053b74425db8fc4f Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 6 Feb 2026 19:32:00 +0100 Subject: [PATCH 51/91] krakenfutures: satisfy mypy narrowing in flex USD used calculation --- freqtrade/exchange/krakenfutures.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 73b685961..0b1fd69c6 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -101,6 +101,8 @@ class Krakenfutures(Exchange): # Use available value for both if only one is present usd_free = usd_free if usd_free is not None else usd_total usd_total = usd_total if usd_total is not None else usd_free + # Type narrowing for mypy: both values are set after fallback. + assert usd_free is not None and usd_total is not None # Both values are guaranteed to be present after fallback. usd_used = max(0.0, usd_total - usd_free) balances["USD"] = {"free": usd_free, "used": usd_used, "total": usd_total} From bb2f19deaa5f8cfc8fceef5bedea9d51ffebca56 Mon Sep 17 00:00:00 2001 From: matstedt Date: Fri, 6 Feb 2026 19:34:20 +0100 Subject: [PATCH 52/91] krakenfutures: replace assert with typed None-guard for ruff S101 --- freqtrade/exchange/krakenfutures.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 0b1fd69c6..c2e92eebe 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -99,13 +99,15 @@ class Krakenfutures(Exchange): usd_total = self._safe_float(raw_total) if usd_free is not None or usd_total is not None: # Use available value for both if only one is present - usd_free = usd_free if usd_free is not None else usd_total - usd_total = usd_total if usd_total is not None else usd_free - # Type narrowing for mypy: both values are set after fallback. - assert usd_free is not None and usd_total is not None - # Both values are guaranteed to be present after fallback. - usd_used = max(0.0, usd_total - usd_free) - balances["USD"] = {"free": usd_free, "used": usd_used, "total": usd_total} + usd_free_value = usd_free if usd_free is not None else usd_total + usd_total_value = usd_total if usd_total is not None else usd_free + if usd_free_value is not None and usd_total_value is not None: + usd_used = max(0.0, usd_total_value - usd_free_value) + balances["USD"] = { + "free": usd_free_value, + "used": usd_used, + "total": usd_total_value, + } # Remove additional info from ccxt results (same as base class) balances.pop("info", None) From bfbae5d04033b157a7a876590e53fd972c1c1408 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 7 Feb 2026 15:44:48 +0100 Subject: [PATCH 53/91] fix(krakenfutures): re-introduce improved stoploss lookup for trigger orders --- freqtrade/exchange/krakenfutures.py | 23 ++++++---- tests/exchange/test_krakenfutures.py | 63 ++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index c2e92eebe..41fbce19d 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -11,7 +11,6 @@ from freqtrade.exceptions import ( ExchangeError, InvalidOrderException, OperationalException, - RetryableOrderError, TemporaryError, ) from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier @@ -145,8 +144,9 @@ class Krakenfutures(Exchange): return self.fetch_dry_run_order(order_id) params = params or {} + status_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} try: - order = self._api.fetch_order(order_id, pair, params=params) + order = self._api.fetch_order(order_id, pair, params=status_params) self._log_exchange_response("fetch_order", order) return self._order_contracts_to_amount(order) except ccxt.OrderNotFound: @@ -167,7 +167,11 @@ class Krakenfutures(Exchange): if order is not None: return order - raise RetryableOrderError(f"Order not found in any endpoint (pair: {pair} id: {order_id})") + # Order not in status, open, closed, or canceled endpoints - genuinely gone. + # Raise non-retrying InvalidOrderException (Kraken has limited history retention). + raise InvalidOrderException( + 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] @@ -175,8 +179,12 @@ class Krakenfutures(Exchange): """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. - order = self._find_order_in_list(self._api.fetch_open_orders, None, params, order_id_str) + # Open orders include triggers by default. Avoid passing trigger flags here + # to prevent endpoint/filter mismatches. + open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} + order = self._find_order_in_list( + self._api.fetch_open_orders, None, open_params, order_id_str + ) if order is not None: return order @@ -205,8 +213,9 @@ class Krakenfutures(Exchange): """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) + fixed_order = self._fix_trigger_order_id(order) + if str(fixed_order.get("id")) == order_id_str: + return self._order_contracts_to_amount(fixed_order) except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e: logger.debug(f"{fetch_fn.__name__} failed: {e}") return None diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 0501bcebf..356598cc0 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -15,7 +15,6 @@ from freqtrade.exceptions import ( ExchangeError, InvalidOrderException, OperationalException, - RetryableOrderError, TemporaryError, ) from freqtrade.exchange.krakenfutures import Krakenfutures @@ -105,8 +104,23 @@ def test_krakenfutures_fetch_order_returns_direct_ccxt_result(mocker, default_co fallback.assert_not_called() -def test_krakenfutures_fetch_order_reraises_when_no_fallback(mocker, default_conf): - """Re-raise when fallback cannot locate the order.""" +def test_krakenfutures_fetch_stoploss_order_strips_trigger_from_status_query(mocker, default_conf): + """Direct fetch_order status lookup should not receive trigger params.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + ccxt_order = {"id": "trigger-raw-1", "symbol": "BTC/USD:USD", "status": "open"} + fetch_order = mocker.patch.object(ex._api, "fetch_order", return_value=ccxt_order) + + res = ex.fetch_stoploss_order("trigger-raw-1", "BTC/USD:USD") + + assert res["id"] == "trigger-raw-1" + fetch_order.assert_called_once_with("trigger-raw-1", "BTC/USD:USD", params={}) + + +def test_krakenfutures_fetch_order_raises_invalid_when_not_found(mocker, default_conf): + """Raise InvalidOrderException (non-retrying) when order is not in any endpoint.""" conf = dict(default_conf) conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") @@ -114,7 +128,7 @@ def test_krakenfutures_fetch_order_reraises_when_no_fallback(mocker, default_con mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) mocker.patch.object(ex, "_fetch_order_fallback", return_value=None) - with pytest.raises(RetryableOrderError): + with pytest.raises(InvalidOrderException, match="Order not found in any endpoint"): ex.fetch_order("abc", "BTC/USD:USD", count=0) @@ -222,6 +236,47 @@ def test_krakenfutures_fetch_order_finds_trigger_order(mocker, default_conf): assert res["id"] == "trigger-123" +def test_krakenfutures_fetch_stoploss_order_prefers_open_orders_without_trigger_param( + mocker, default_conf +): + """Stoploss lookup should query open orders without trigger flags and match nested orderId.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + trigger_id = "trigger-open-123" + + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) + + def fetch_open(symbol, params=None): + assert symbol is None + assert params == {} + return [ + { + "id": None, + "symbol": "BTC/USD:USD", + "status": "open", + "info": {"order": {"orderId": trigger_id}}, + } + ] + + open_fetch = mocker.patch.object( + ex._api, "fetch_open_orders", side_effect=fetch_open, create=True + ) + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + + res = ex.fetch_stoploss_order(trigger_id, "BTC/USD:USD") + + assert res["id"] == trigger_id + assert res["status"] == "open" + open_fetch.assert_called_once() + closed_fetch.assert_not_called() + canceled_fetch.assert_not_called() + + def test_krakenfutures_fetch_order_propagates_exchange_errors_from_fallback(mocker, default_conf): """Fallback list fetch should not hide exchange-level failures.""" conf = dict(default_conf) From a831a6ee341a4fc4105ff12b4880b921f4043760 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 12 Feb 2026 19:28:36 +0100 Subject: [PATCH 54/91] docs: align krakenfutures link in Readme.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7345796d..60a6afaa6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Please read the [exchange-specific notes](https://www.freqtrade.io/en/stable/exc - [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX) - [X] [OKX](https://okx.com/) - [X] [Bybit](https://bybit.com/) -- [X] [Kraken](https://eu.kraken.com/features/derivatives) +- [X] [Kraken](https://www.kraken.com/features/futures) Please make sure to read the [exchange specific notes](https://www.freqtrade.io/en/stable/exchanges/), as well as the [trading with leverage](https://www.freqtrade.io/en/stable/leverage/) documentation before diving in. From b190079aa81d35be0cc107b040f8d795738b4ea6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 12 Feb 2026 19:33:50 +0100 Subject: [PATCH 55/91] test: Simplify live test setup --- tests/exchange_online/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 68b7ed5c3..2c735046b 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -18,6 +18,7 @@ class TestExchangeOnlineSetup(TypedDict): timeframe: str candle_count: int futures: bool + futures_only: bool | None futures_pair: str | None candle_count_futures: int | None hasQuoteVolumeFutures: bool | None @@ -574,6 +575,7 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { } EXCHANGES_FUTURES = [exch for exch, params in EXCHANGES.items() if params.get("futures")] +EXCHANGES_SPOT = [exch for exch, params in EXCHANGES.items() if not params.get("futures_only")] @pytest.fixture(scope="class") @@ -643,7 +645,7 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker): return exchange, exchange_name -@pytest.fixture(params=EXCHANGES, scope="class") +@pytest.fixture(params=EXCHANGES_SPOT, scope="class") def exchange(request, exchange_conf, class_mocker): exchange, name, exchange_params = get_exchange( request.param, exchange_conf, class_mocker From b25defb8b918b77fbac0ea34d4d871fbf95e9d2d Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 12 Feb 2026 19:51:26 +0100 Subject: [PATCH 56/91] refactor: invert balance logic to simplify function --- freqtrade/exchange/krakenfutures.py | 59 +++++++++++++---------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 41fbce19d..dd06eec2e 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -73,40 +73,33 @@ class Krakenfutures(Exchange): # Only synthesize USD if stake_currency is USD stake = str(self._config.get("stake_currency", "")).upper() - if stake != "USD": - # Skip USD synthesis for non-USD stake currencies - balances.pop("info", None) - balances.pop("free", None) - balances.pop("total", None) - balances.pop("used", None) - self._log_exchange_response("fetch_balance", balances, add_info=params) - return balances + if stake == "USD": + # Only synthesize if USD stake - flex only applies for these currencies. + # For flex accounts, synthesize USD balance from margin values + info = balances.get("info", {}) + accounts = info.get("accounts", {}) if isinstance(info, dict) else {} + flex = accounts.get("flex", {}) if isinstance(accounts, dict) else {} - # For flex accounts, synthesize USD balance from margin values - info = balances.get("info", {}) - accounts = info.get("accounts", {}) if isinstance(info, dict) else {} - flex = accounts.get("flex", {}) if isinstance(accounts, dict) else {} - - if flex: - usd_free = self._safe_float(flex.get("availableMargin")) - # Prefer marginEquity for consistency (same basis as availableMargin) - raw_total = ( - flex.get("marginEquity") - or flex.get("portfolioValue") - or flex.get("balanceValue") - ) - usd_total = self._safe_float(raw_total) - if usd_free is not None or usd_total is not None: - # Use available value for both if only one is present - usd_free_value = usd_free if usd_free is not None else usd_total - usd_total_value = usd_total if usd_total is not None else usd_free - if usd_free_value is not None and usd_total_value is not None: - usd_used = max(0.0, usd_total_value - usd_free_value) - balances["USD"] = { - "free": usd_free_value, - "used": usd_used, - "total": usd_total_value, - } + if flex: + usd_free = self._safe_float(flex.get("availableMargin")) + # Prefer marginEquity for consistency (same basis as availableMargin) + raw_total = ( + flex.get("marginEquity") + or flex.get("portfolioValue") + or flex.get("balanceValue") + ) + usd_total = self._safe_float(raw_total) + if usd_free is not None or usd_total is not None: + # Use available value for both if only one is present + usd_free_value = usd_free if usd_free is not None else usd_total + usd_total_value = usd_total if usd_total is not None else usd_free + if usd_free_value is not None and usd_total_value is not None: + usd_used = max(0.0, usd_total_value - usd_free_value) + balances["USD"] = { + "free": usd_free_value, + "used": usd_used, + "total": usd_total_value, + } # Remove additional info from ccxt results (same as base class) balances.pop("info", None) From 68c8dd4e1856e6d827c8c4e89098aacf4c39ce51 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 12 Feb 2026 20:06:43 +0100 Subject: [PATCH 57/91] chore: krakenfutures tickers don't have volume --- freqtrade/exchange/krakenfutures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index dd06eec2e..e6d638f1b 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -36,6 +36,7 @@ class Krakenfutures(Exchange): ] _ft_has: FtHas = { + "tickers_have_quoteVolume": False, "stoploss_on_exchange": True, "stoploss_order_types": { "limit": "limit", From 83c2ca5a908805c3dd1550a285e2457a17884261 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 12 Feb 2026 20:13:49 +0100 Subject: [PATCH 58/91] test: krakenfutures has no websockets at the moment --- tests/exchange_online/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 2c735046b..3650088b4 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -564,6 +564,7 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "pair": "BTC/USD:USD", "stake_currency": "USD", "hasQuoteVolume": False, + "skip_ws_tests": True, "timeframe": "1h", "futures": True, "futures_only": True, From 086e1d8bade1d5b7e5c827536db8f3fae1dc018d Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 14 Feb 2026 17:56:27 +0100 Subject: [PATCH 59/91] refactor(krakenfutures): simplify stoploss handling after ccxt fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stoploss_query_requires_stop_flag to _ft_has so the base class handles fetch_stoploss_order and cancel_stoploss_order (adds stop=True which CCXT maps to trigger history endpoints). - Remove _fix_trigger_order_id — ccxt 4.5.38 now parses order IDs correctly in all response formats. - Remove cancel_stoploss_order override — base class handles it. - Remove fetch_stoploss_order override — base class handles it. - Simplify _fetch_order_fallback — stop=True flows from base class, no need to auto-retry with trigger=True. Addresses review comments from freqtrade/freqtrade#12706. --- freqtrade/exchange/krakenfutures.py | 58 +++++++---------------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index e6d638f1b..a756c9490 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -42,6 +42,7 @@ class Krakenfutures(Exchange): "limit": "limit", "market": "market", }, + "stoploss_query_requires_stop_flag": True, "stop_price_param": "triggerPrice", "stop_price_prop": "stopPrice", "stop_price_type_field": "triggerSignal", @@ -170,10 +171,17 @@ class Krakenfutures(Exchange): 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.""" + """Search open, closed, and canceled order endpoints for order_id. + + Kraken Futures' orders/status endpoint only returns currently open orders. + Older orders require querying history endpoints (closed/canceled). + For stoploss (trigger) orders, the caller should pass stop=True in params + (handled automatically via stoploss_query_requires_stop_flag in _ft_has) + so that closed/canceled queries hit the trigger history endpoint. + """ order_id_str = str(order_id) - # Open orders include triggers by default. Avoid passing trigger flags here + # Open orders include triggers by default. Avoid passing trigger/stop flags # to prevent endpoint/filter mismatches. open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} order = self._find_order_in_list( @@ -182,18 +190,12 @@ class Krakenfutures(Exchange): if order is not None: return order - # Closed/canceled: use pair and optional trigger=True for stoplosses. + # Closed/canceled: pass params through (including stop=True for stoploss orders, + # which CCXT maps to the trigger history endpoint). for fetch_fn in (self._api.fetch_closed_orders, self._api.fetch_canceled_orders): 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 @@ -207,9 +209,8 @@ class Krakenfutures(Exchange): """Fetch orders and return matching order_id, or None.""" try: for order in fetch_fn(symbol, params=params) or []: - fixed_order = self._fix_trigger_order_id(order) - if str(fixed_order.get("id")) == order_id_str: - return self._order_contracts_to_amount(fixed_order) + if str(order.get("id")) == order_id_str: + return self._order_contracts_to_amount(order) except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e: logger.debug(f"{fetch_fn.__name__} failed: {e}") return None @@ -223,37 +224,6 @@ class Krakenfutures(Exchange): raise OperationalException(e) from e return None - @staticmethod - def _fix_trigger_order_id(order: dict) -> dict: - """ - Fix CCXT trigger order response where top-level 'id' is None. - - Kraken Futures trigger orders return 'id': None in CCXT responses, - but the actual order ID is in info.order.orderId. Extract and set it. - """ - if order.get("id") is None: - info = order.get("info", {}) - inner_order = info.get("order", {}) if isinstance(info, dict) else {} - if isinstance(inner_order, dict) and inner_order.get("orderId"): - order["id"] = inner_order["orderId"] - return order - - def cancel_stoploss_order(self, order_id: str, pair: str, params: dict | None = None) -> dict: - """Cancel stoploss order and fix CCXT response for trigger orders.""" - params = params or {} - params["trigger"] = True - order = self.cancel_order(order_id, pair, params) - return self._fix_trigger_order_id(order) - - def fetch_stoploss_order( - self, order_id: str, pair: str, params: dict | None = None - ) -> CcxtOrder: - """Fetch stoploss order and fix CCXT response for trigger orders.""" - params = params or {} - params["trigger"] = True - order = self.fetch_order(order_id, pair, params) - return self._fix_trigger_order_id(order) - def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: """Fetch funding fees, returning 0.0 if retrieval fails.""" if self.trading_mode == TradingMode.FUTURES: From f0aedc8d232dc33881dfb99b1ed5cded957adf2d Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 14 Feb 2026 17:56:33 +0100 Subject: [PATCH 60/91] test(krakenfutures): align tests with simplified stoploss handling - Add test for stoploss_query_requires_stop_flag in _ft_has. - Replace _fix_trigger_order_id tests with base class integration tests (verify stop=True is passed by fetch_stoploss_order/cancel_stoploss_order). - Add tests for stop param flow: passed to history endpoints, stripped from open orders query. - Remove obsolete cancel_stoploss_order/fetch_stoploss_order override tests. --- tests/exchange/test_krakenfutures.py | 188 +++++++++------------------ 1 file changed, 65 insertions(+), 123 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 356598cc0..864da0ef0 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -29,6 +29,7 @@ def test_krakenfutures_ft_has_overrides(): 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["stoploss_query_requires_stop_flag"] is True assert ft_has["stop_price_param"] == "triggerPrice" assert ft_has["stop_price_type_field"] == "triggerSignal" @@ -104,19 +105,20 @@ def test_krakenfutures_fetch_order_returns_direct_ccxt_result(mocker, default_co fallback.assert_not_called() -def test_krakenfutures_fetch_stoploss_order_strips_trigger_from_status_query(mocker, default_conf): - """Direct fetch_order status lookup should not receive trigger params.""" +def test_krakenfutures_fetch_order_strips_stop_from_status_query(mocker, default_conf): + """Direct CCXT fetch_order status lookup should not receive stop/trigger params.""" conf = dict(default_conf) conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - ccxt_order = {"id": "trigger-raw-1", "symbol": "BTC/USD:USD", "status": "open"} + ccxt_order = {"id": "order-1", "symbol": "BTC/USD:USD", "status": "open"} fetch_order = mocker.patch.object(ex._api, "fetch_order", return_value=ccxt_order) - res = ex.fetch_stoploss_order("trigger-raw-1", "BTC/USD:USD") + # Simulate call from base fetch_stoploss_order which adds stop=True + ex.fetch_order("order-1", "BTC/USD:USD", params={"stop": True}) - assert res["id"] == "trigger-raw-1" - fetch_order.assert_called_once_with("trigger-raw-1", "BTC/USD:USD", params={}) + # stop should be stripped from the direct CCXT status call + fetch_order.assert_called_once_with("order-1", "BTC/USD:USD", params={}) def test_krakenfutures_fetch_order_raises_invalid_when_not_found(mocker, default_conf): @@ -212,69 +214,63 @@ def test_krakenfutures_fetch_order_dry_run(mocker, default_conf): 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.""" +def test_krakenfutures_fetch_order_finds_stoploss_via_stop_param(mocker, default_conf): + """Test fetch_order finds stoploss orders via closed orders fallback with stop=True.""" conf = dict(default_conf) conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) - # 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) + # With stop=True, CCXT queries trigger history endpoint mocker.patch.object( ex._api, "fetch_closed_orders", - side_effect=[ - [], # Regular closed orders - [{"id": "trigger-123", "symbol": "BTC/USD:USD", "status": "closed"}], # Trigger orders + return_value=[ + {"id": "trigger-123", "symbol": "BTC/USD:USD", "status": "closed"}, ], create=True, ) - res = ex.fetch_order("trigger-123", "BTC/USD:USD") + # Simulate what base class fetch_stoploss_order does (adds stop=True) + res = ex.fetch_order("trigger-123", "BTC/USD:USD", params={"stop": True}) assert res["id"] == "trigger-123" -def test_krakenfutures_fetch_stoploss_order_prefers_open_orders_without_trigger_param( - mocker, default_conf -): - """Stoploss lookup should query open orders without trigger flags and match nested orderId.""" - conf = dict(default_conf) - conf["dry_run"] = False - ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") +def test_krakenfutures_fetch_order_fallback_passes_stop_to_history(mocker, default_conf): + """Stoploss query (stop=True) should pass through to closed/canceled endpoints.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - trigger_id = "trigger-open-123" + mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) - mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) - - def fetch_open(symbol, params=None): - assert symbol is None - assert params == {} - return [ - { - "id": None, - "symbol": "BTC/USD:USD", - "status": "open", - "info": {"order": {"orderId": trigger_id}}, - } - ] - - open_fetch = mocker.patch.object( - ex._api, "fetch_open_orders", side_effect=fetch_open, create=True - ) - closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) - canceled_fetch = mocker.patch.object( - ex._api, "fetch_canceled_orders", return_value=[], create=True + closed_order = {"id": "sl-123", "symbol": "BTC/USD:USD", "status": "closed"} + closed_fetch = mocker.patch.object( + ex._api, + "fetch_closed_orders", + return_value=[closed_order], + create=True, ) - res = ex.fetch_stoploss_order(trigger_id, "BTC/USD:USD") + res = ex._fetch_order_fallback("sl-123", "BTC/USD:USD", {"stop": True}) - assert res["id"] == trigger_id - assert res["status"] == "open" - open_fetch.assert_called_once() - closed_fetch.assert_not_called() - canceled_fetch.assert_not_called() + assert res is not None + assert res["id"] == "sl-123" + # Verify stop=True was passed to closed orders (CCXT maps stop→trigger) + closed_fetch.assert_called_once_with("BTC/USD:USD", params={"stop": True}) + + +def test_krakenfutures_fetch_order_fallback_strips_stop_from_open_orders(mocker, default_conf): + """Open orders query should not receive stop/trigger flags.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + open_fetch = 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, "fetch_canceled_orders", return_value=[], create=True) + + ex._fetch_order_fallback("abc", "BTC/USD:USD", {"stop": True}) + + # stop should be stripped from open orders call + open_fetch.assert_called_once_with(None, params={}) def test_krakenfutures_fetch_order_propagates_exchange_errors_from_fallback(mocker, default_conf): @@ -635,94 +631,40 @@ def test_krakenfutures_safe_float(): assert Krakenfutures._safe_float({}) is None -# --- Stoploss cancel tests --- +# --- Stoploss via base class (stoploss_query_requires_stop_flag) --- -def test_krakenfutures_cancel_stoploss_order_fixes_id(mocker, default_conf): - """Test cancel_stoploss_order extracts order ID from info when top-level id is None.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - # CCXT returns 'id': None for trigger orders, but orderId is in info.order - ccxt_response = { - "id": None, - "status": "canceled", - "info": { - "order": { - "orderId": "a10258a9-01ea-44c4-a38f-66165678926e", - "type": "TRIGGER_ORDER", - "symbol": "PF_XBTUSD", - }, - "status": "CANCELLED", - }, - } - mocker.patch.object(ex, "cancel_order", return_value=ccxt_response) - - result = ex.cancel_stoploss_order("a10258a9-01ea-44c4-a38f-66165678926e", "BTC/USD:USD") - - # ID should be extracted from info.order.orderId - assert result["id"] == "a10258a9-01ea-44c4-a38f-66165678926e" - assert result["status"] == "canceled" - - -def test_krakenfutures_cancel_stoploss_order_preserves_existing_id(mocker, default_conf): - """Test cancel_stoploss_order doesn't overwrite existing id.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - # Normal response with id already set - ccxt_response = { - "id": "existing-order-id", - "status": "canceled", - "info": {}, - } - mocker.patch.object(ex, "cancel_order", return_value=ccxt_response) - - result = ex.cancel_stoploss_order("existing-order-id", "BTC/USD:USD") - - assert result["id"] == "existing-order-id" - - -# --- Stoploss fetch tests --- - - -def test_krakenfutures_fetch_stoploss_order_fixes_id(mocker, default_conf): - """Test fetch_stoploss_order extracts order ID from info when top-level id is None.""" +def test_krakenfutures_fetch_stoploss_order_uses_base_class(mocker, default_conf): + """Base class fetch_stoploss_order should add stop=True and delegate to fetch_order.""" conf = dict(default_conf) conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - # CCXT returns 'id': None for trigger orders, but orderId is in info.order - ccxt_response = { - "id": None, - "status": "open", - "info": { - "order": { - "orderId": "trigger-order-123", - "type": "TRIGGER_ORDER", - "symbol": "PF_XBTUSD", - }, - }, - } - mocker.patch.object(ex, "fetch_order", return_value=ccxt_response) + expected_order = {"id": "sl-order-1", "status": "open", "info": {}} + fetch_order = mocker.patch.object(ex, "fetch_order", return_value=expected_order) - result = ex.fetch_stoploss_order("trigger-order-123", "BTC/USD:USD") + result = ex.fetch_stoploss_order("sl-order-1", "BTC/USD:USD") - # ID should be extracted from info.order.orderId - assert result["id"] == "trigger-order-123" + assert result["id"] == "sl-order-1" + # Base class should pass stop=True + fetch_order.assert_called_once() + call_params = fetch_order.call_args[0][2] if len(fetch_order.call_args[0]) > 2 else {} + assert call_params.get("stop") is True -def test_krakenfutures_fetch_stoploss_order_passes_trigger_param(mocker, default_conf): - """Test fetch_stoploss_order passes trigger=True to fetch_order.""" +def test_krakenfutures_cancel_stoploss_order_uses_base_class(mocker, default_conf): + """Base class cancel_stoploss_order should add stop=True and delegate to cancel_order.""" conf = dict(default_conf) conf["dry_run"] = False ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") - mock_fetch = mocker.patch.object( - ex, "fetch_order", return_value={"id": "order-123", "status": "open", "info": {}} - ) + expected_order = {"id": "sl-cancel-1", "status": "canceled"} + cancel_order = mocker.patch.object(ex, "cancel_order", return_value=expected_order) - ex.fetch_stoploss_order("order-123", "BTC/USD:USD") + result = ex.cancel_stoploss_order("sl-cancel-1", "BTC/USD:USD") - # Verify trigger=True was passed - mock_fetch.assert_called_once() - call_params = mock_fetch.call_args[0][2] # third positional arg is params - assert call_params.get("trigger") is True + assert result["id"] == "sl-cancel-1" + # Base class should pass stop=True + cancel_order.assert_called_once() + call_params = cancel_order.call_args[0][2] if len(cancel_order.call_args[0]) > 2 else {} + assert call_params.get("stop") is True From cb2e50abcf05ed21bd4d00ab6f40b9308094141d Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 14 Feb 2026 20:16:08 +0100 Subject: [PATCH 61/91] fix(tests): restore exchange_params return in get_futures_exchange --- tests/exchange_online/conftest.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 3650088b4..34e725c17 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -643,14 +643,12 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker): exchange = ExchangeResolver.load_exchange( exchange_conf, validate=True, load_leverage_tiers=True ) - return exchange, exchange_name + return exchange, exchange_name, exchange_params @pytest.fixture(params=EXCHANGES_SPOT, scope="class") def exchange(request, exchange_conf, class_mocker): - exchange, name, exchange_params = get_exchange( - request.param, exchange_conf, class_mocker - ) + exchange, name, exchange_params = get_exchange(request.param, exchange_conf, class_mocker) yield exchange, name, exchange_params exchange.close() From 0dc26584cc974408ae212ece1b45212b7f752ad6 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 14 Feb 2026 20:34:59 +0100 Subject: [PATCH 62/91] krakenfutures: add funding_fee_candle_limit for limited history Kraken Futures retains only ~29 days of hourly funding rate history. Set funding_fee_candle_limit to 700 so ohlcv_candle_limit returns the correct cap for CandleType.FUNDING_RATE instead of the general OHLCV limit. --- freqtrade/exchange/krakenfutures.py | 2 ++ tests/exchange/test_krakenfutures.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index a756c9490..5751ce59a 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -51,6 +51,8 @@ class Krakenfutures(Exchange): PriceType.MARK: "mark", PriceType.INDEX: "index", }, + # Kraken Futures retains only 29 days of hourly funding rate history. + "funding_fee_candle_limit": 700, } @retrier diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 864da0ef0..1f5087bfd 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -32,6 +32,8 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stoploss_query_requires_stop_flag"] is True assert ft_has["stop_price_param"] == "triggerPrice" assert ft_has["stop_price_type_field"] == "triggerSignal" + # Kraken retains only ~29 days of hourly funding rate history + assert ft_has["funding_fee_candle_limit"] == 700 def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): @@ -44,6 +46,15 @@ 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_ohlcv_candle_limit_funding_rate(mocker, default_conf): + """Funding rate candle limit is capped to reflect Kraken's limited history retention.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + mocker.patch.object(ex, "features", return_value=2000) + + assert ex.ohlcv_candle_limit("1h", candle_type=CandleType.FUNDING_RATE) == 700 + + # --- fetch_order fallback tests --- From eaa35d7b47e4bb23369c8a43675a1d32567ddb93 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sat, 14 Feb 2026 20:36:38 +0100 Subject: [PATCH 63/91] style: ruff format --- tests/exchange_online/test_ccxt_compat.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index 105ff4353..8196cf6ba 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -64,9 +64,7 @@ class TestCCXTExchange: if not expected_count: pytest.skip("No expected candle count for exchange") - candle_type = ( - CandleType.FUTURES if exchange_params.get("futures_only") else CandleType.SPOT - ) + candle_type = CandleType.FUTURES if exchange_params.get("futures_only") else CandleType.SPOT assert exch.ohlcv_candle_limit("1m", candle_type) == expected_count def test_load_markets_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): From 8c1674b4152e78df5076cc5f7b33e0b0a2581a17 Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 15 Feb 2026 12:10:19 +0100 Subject: [PATCH 64/91] fix(tests): use CandleType.FUTURES in test_ohlcv_limit_futures The futures_only ternary incorrectly used CandleType.SPOT for exchanges that support both spot and futures (binance, gate), causing candle limit mismatch against candle_count_futures. --- tests/exchange_online/test_ccxt_compat.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index 8196cf6ba..f33818d3d 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -64,8 +64,7 @@ class TestCCXTExchange: if not expected_count: pytest.skip("No expected candle count for exchange") - candle_type = CandleType.FUTURES if exchange_params.get("futures_only") else CandleType.SPOT - assert exch.ohlcv_candle_limit("1m", candle_type) == expected_count + assert exch.ohlcv_candle_limit("1m", CandleType.FUTURES) == expected_count def test_load_markets_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): exchange, _, exchange_params = exchange_futures From 027ac3d66cda92da41cd29703ee60204c2e26856 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 16 Feb 2026 07:03:49 +0100 Subject: [PATCH 65/91] test: simplify longrun tests --- tests/exchange_online/conftest.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 34e725c17..7abd6b4b8 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -606,15 +606,12 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config: return config -def get_exchange(exchange_name, exchange_conf, class_mocker=None): +def get_exchange(exchange_name, exchange_conf, class_mocker): exchange_params = EXCHANGES[exchange_name] - if exchange_params.get("futures_only"): - pytest.skip(f"Exchange {exchange_name} is futures-only, skipping spot tests.") exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) exchange_conf["exchange"]["name"] = exchange_name exchange_conf["stake_currency"] = exchange_params["stake_currency"] - if class_mocker: - class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") + class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") exchange = ExchangeResolver.load_exchange( exchange_conf, validate=True, load_leverage_tiers=True ) @@ -669,7 +666,6 @@ def exchange_mode(request): @pytest.fixture(params=EXCHANGES, scope="class") def exchange_ws(request, exchange_conf, exchange_mode, class_mocker): - class_mocker.patch("freqtrade.exchange.bybit.Bybit.additional_exchange_init") exchange_conf["exchange"]["enable_ws"] = True exchange_param = EXCHANGES[request.param] if exchange_param.get("skip_ws_tests"): From d6e6c9d0383b52f47c6d20d414b0f9418af791cf Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Feb 2026 19:49:20 +0100 Subject: [PATCH 66/91] chore: log fetch_order responses for better tracability --- freqtrade/exchange/krakenfutures.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 5751ce59a..a774ba288 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -210,8 +210,12 @@ class Krakenfutures(Exchange): ) -> CcxtOrder | None: """Fetch orders and return matching order_id, or None.""" try: - for order in fetch_fn(symbol, params=params) or []: + orders = fetch_fn(symbol, params=params) or [] + self._log_exchange_response(fetch_fn.__name__, orders) + for order in orders: if str(order.get("id")) == order_id_str: + self._log_exchange_response("fetch_order_fallback", order) + return self._order_contracts_to_amount(order) except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e: logger.debug(f"{fetch_fn.__name__} failed: {e}") From 3c4b07ce4658e12d0e2335f26f8e58833fe2e783 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Feb 2026 20:36:24 +0100 Subject: [PATCH 67/91] test: update krakenfutures_tests for usage of __name__ --- tests/exchange/test_krakenfutures.py | 51 ++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 1f5087bfd..00c4afd79 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -65,13 +65,15 @@ def test_krakenfutures_fetch_order_falls_back_to_closed_orders(mocker, default_c ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) - mocker.patch.object( + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object( ex._api, "fetch_closed_orders", return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "closed"}], create=True, ) + closed_fetch.__name__ = "fetch_closed_orders" res = ex.fetch_order("abc", "BTC/USD:USD") assert res["id"] == "abc" @@ -84,14 +86,17 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.ExchangeError("UUID too large")) - 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( + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( ex._api, "fetch_canceled_orders", return_value=[{"id": "def", "symbol": "BTC/USD:USD", "status": "canceled"}], create=True, ) + canceled_fetch.__name__ = "fetch_canceled_orders" res = ex.fetch_order("def", "BTC/USD:USD") assert res["id"] == "def" @@ -181,9 +186,14 @@ def test_krakenfutures_fetch_order_baseerror_maps_exception(mocker, default_conf def test_krakenfutures_fetch_order_fallback_returns_none(mocker, default_conf): """Return None when order is not found in any endpoint.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - 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, "fetch_canceled_orders", return_value=[], create=True) + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + canceled_fetch.__name__ = "fetch_canceled_orders" res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {}) assert res is None @@ -198,10 +208,13 @@ def test_krakenfutures_fetch_order_fallback_returns_open_order_first(mocker, def return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "open"}], create=True, ) + open_fetch.__name__ = "fetch_open_orders" closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" canceled_fetch = mocker.patch.object( ex._api, "fetch_canceled_orders", return_value=[], create=True ) + canceled_fetch.__name__ = "fetch_canceled_orders" res = ex._fetch_order_fallback("abc", "BTC/USD:USD", {}) @@ -232,9 +245,10 @@ def test_krakenfutures_fetch_order_finds_stoploss_via_stop_param(mocker, default ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" # With stop=True, CCXT queries trigger history endpoint - mocker.patch.object( + closed_fetch = mocker.patch.object( ex._api, "fetch_closed_orders", return_value=[ @@ -242,6 +256,7 @@ def test_krakenfutures_fetch_order_finds_stoploss_via_stop_param(mocker, default ], create=True, ) + closed_fetch.__name__ = "fetch_closed_orders" # Simulate what base class fetch_stoploss_order does (adds stop=True) res = ex.fetch_order("trigger-123", "BTC/USD:USD", params={"stop": True}) @@ -252,7 +267,8 @@ def test_krakenfutures_fetch_order_fallback_passes_stop_to_history(mocker, defau """Stoploss query (stop=True) should pass through to closed/canceled endpoints.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" closed_order = {"id": "sl-123", "symbol": "BTC/USD:USD", "status": "closed"} closed_fetch = mocker.patch.object( @@ -261,6 +277,7 @@ def test_krakenfutures_fetch_order_fallback_passes_stop_to_history(mocker, defau return_value=[closed_order], create=True, ) + closed_fetch.__name__ = "fetch_closed_orders" res = ex._fetch_order_fallback("sl-123", "BTC/USD:USD", {"stop": True}) @@ -275,8 +292,13 @@ def test_krakenfutures_fetch_order_fallback_strips_stop_from_open_orders(mocker, ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") open_fetch = 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, "fetch_canceled_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + canceled_fetch.__name__ = "fetch_canceled_orders" ex._fetch_order_fallback("abc", "BTC/USD:USD", {"stop": True}) @@ -291,9 +313,10 @@ def test_krakenfutures_fetch_order_propagates_exchange_errors_from_fallback(mock ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) - mocker.patch.object( + open_fetch = mocker.patch.object( ex._api, "fetch_open_orders", side_effect=ccxt.ExchangeError("service unavailable") ) + open_fetch.__name__ = "fetch_open_orders" with pytest.raises(TemporaryError): ex.fetch_order("abc", "BTC/USD:USD", count=0) From 0dfd7324bda3a370e95548fdb0fdb79792e5832e Mon Sep 17 00:00:00 2001 From: matstedt Date: Tue, 17 Feb 2026 20:33:27 +0100 Subject: [PATCH 68/91] fix: extract triggerPrice from priceTriggerOptions for stoploss orders CCXT's krakenfutures parse_order misses triggerPrice when the /orders/status endpoint nests it inside priceTriggerOptions. Override _order_contracts_to_amount to populate triggerPrice and stopPrice from info.order.priceTriggerOptions.triggerPrice. --- freqtrade/exchange/krakenfutures.py | 19 ++++++++ tests/exchange/test_krakenfutures.py | 69 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index a774ba288..a3eee37c7 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -132,6 +132,25 @@ class Krakenfutures(Exchange): except (ValueError, TypeError): return None + def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder: + """Normalize order and fix missing trigger price from CCXT parsing. + + CCXT's krakenfutures parse_order reads triggerPrice from the top level of + the order details, but the /orders/status endpoint nests it inside + priceTriggerOptions.triggerPrice. This extracts it so stopPrice/triggerPrice + are populated correctly for stoploss order handling. + """ + order = super()._order_contracts_to_amount(order) + if order.get("triggerPrice") is None and order.get("stopPrice") is None: + info = order.get("info", {}) + inner = info.get("order", {}) if isinstance(info, dict) else {} + opts = inner.get("priceTriggerOptions", {}) if isinstance(inner, dict) else {} + trigger = self._safe_float(opts.get("triggerPrice")) if isinstance(opts, dict) else None + if trigger is not None: + order["triggerPrice"] = trigger + order["stopPrice"] = trigger + return order + @retrier(retries=API_FETCH_ORDER_RETRY_COUNT) def fetch_order( self, order_id: str, pair: str, params: dict[str, Any] | None = None diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 00c4afd79..e62f33b4f 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -55,6 +55,75 @@ def test_krakenfutures_ohlcv_candle_limit_funding_rate(mocker, default_conf): assert ex.ohlcv_candle_limit("1h", candle_type=CandleType.FUNDING_RATE) == 700 +# --- _order_contracts_to_amount trigger price fix tests --- + + +def test_krakenfutures_order_contracts_fixes_missing_trigger_price(mocker, default_conf): + """Extract triggerPrice from info.order.priceTriggerOptions when CCXT misses it.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "triggerPrice": None, + "stopPrice": None, + "info": { + "order": { + "type": "TRIGGER_ORDER", + "priceTriggerOptions": { + "triggerPrice": 71641, + "triggerSignal": "LAST_PRICE", + }, + }, + "status": "TRIGGER_PLACED", + }, + } + result = ex._order_contracts_to_amount(order) + assert result["triggerPrice"] == 71641.0 + assert result["stopPrice"] == 71641.0 + + +def test_krakenfutures_order_contracts_preserves_existing_trigger_price(mocker, default_conf): + """Don't overwrite triggerPrice when CCXT already parsed it correctly.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "triggerPrice": 70000.0, + "stopPrice": 70000.0, + "info": { + "order": { + "priceTriggerOptions": { + "triggerPrice": 71641, + }, + }, + }, + } + result = ex._order_contracts_to_amount(order) + assert result["triggerPrice"] == 70000.0 + assert result["stopPrice"] == 70000.0 + + +def test_krakenfutures_order_contracts_no_trigger_options(mocker, default_conf): + """Regular (non-trigger) orders should pass through unchanged.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "triggerPrice": None, + "stopPrice": None, + "info": { + "order": { + "type": "lmt", + "orderId": "abc", + }, + "status": "placed", + }, + } + result = ex._order_contracts_to_amount(order) + assert result["triggerPrice"] is None + assert result["stopPrice"] is None + + # --- fetch_order fallback tests --- From a1e9b6bea89a468efe734fe662d11a8777af7a9d Mon Sep 17 00:00:00 2001 From: matstedt Date: Tue, 17 Feb 2026 21:03:06 +0100 Subject: [PATCH 69/91] fix: compute average price from trades and enrich fees for Kraken Futures Kraken Futures' /orders/status returns limitPrice (not fill price) and /fills omits fee amounts (only fillType). This adds: - _adjust_krakenfutures_order: fetches trades for closed/filled orders with average=None and computes VWAP average price. - get_trades_for_order override: enriches trades with calculated fees from the market's maker/taker fee schedule. Tests: 7 new tests covering VWAP computation, fee enrichment with maker/taker rates, and preservation of existing values. --- freqtrade/exchange/krakenfutures.py | 54 +++++++- tests/exchange/test_krakenfutures.py | 191 ++++++++++++++++++++++++++- 2 files changed, 242 insertions(+), 3 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index a3eee37c7..d9e2315b2 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -1,6 +1,7 @@ """Kraken Futures exchange subclass""" import logging +from datetime import datetime from typing import Any import ccxt @@ -16,6 +17,7 @@ from freqtrade.exceptions import ( from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas +from freqtrade.util.datetime_helpers import dt_from_ts logger = logging.getLogger(__name__) @@ -151,6 +153,53 @@ class Krakenfutures(Exchange): order["stopPrice"] = trigger return order + def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: + """Fix missing average price on filled orders by fetching trades. + + Kraken Futures' /orders/status endpoint does not include execution data, + so CCXT sets price/average to the limitPrice (the order's limit, not the + actual fill price). For closed/filled orders we fetch trades from /fills + and compute the VWAP average. + """ + if ( + order.get("average") is None + and order.get("status") in ("canceled", "closed") + and order.get("filled", 0) > 0 + ): + trades = self.get_trades_for_order( + order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) + ) + if trades: + total_amount = sum(t["amount"] for t in trades) + if total_amount: + order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount + return order + + def get_trades_for_order( + self, order_id: str, pair: str, since: datetime, params: dict | None = None + ) -> list: + """Fetch trades and enrich with calculated fees. + + Kraken Futures' /fills endpoint does not include fee amounts — only + fillType (maker/taker). This enriches each trade with a calculated fee + using the market's fee schedule so Freqtrade's fee detection works. + """ + trades = super().get_trades_for_order(order_id, pair, since, params) + for trade in trades: + if trade.get("fee") is None or trade["fee"].get("cost") is None: + taker_or_maker = trade.get("takerOrMaker", "taker") + symbol = trade.get("symbol", pair) + market = self.markets.get(symbol, {}) + fee_rate = market.get(taker_or_maker, market.get("taker", 0.0005)) + cost = trade.get("cost") + if cost is not None and fee_rate is not None: + trade["fee"] = { + "cost": cost * fee_rate, + "currency": market.get("quote", "USD"), + "rate": fee_rate, + } + return trades + @retrier(retries=API_FETCH_ORDER_RETRY_COUNT) def fetch_order( self, order_id: str, pair: str, params: dict[str, Any] | None = None @@ -164,7 +213,8 @@ class Krakenfutures(Exchange): try: order = self._api.fetch_order(order_id, pair, params=status_params) self._log_exchange_response("fetch_order", order) - return self._order_contracts_to_amount(order) + order = self._order_contracts_to_amount(order) + return self._adjust_krakenfutures_order(order) except ccxt.OrderNotFound: # Expected for older Kraken Futures orders not visible in orders/status. pass @@ -181,7 +231,7 @@ class Krakenfutures(Exchange): order = self._fetch_order_fallback(order_id, pair, params) if order is not None: - return order + return self._adjust_krakenfutures_order(order) # Order not in status, open, closed, or canceled endpoints - genuinely gone. # Raise non-retrying InvalidOrderException (Kraken has limited history retention). diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index e62f33b4f..77e63ba83 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -4,7 +4,7 @@ from __future__ import annotations from copy import deepcopy from datetime import UTC, datetime -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock import ccxt import pytest @@ -21,6 +21,9 @@ from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import EXMS, get_patched_exchange +ExchangeBase = Krakenfutures.__mro__[1] # freqtrade.exchange.exchange.Exchange + + # --- _ft_has and OHLCV tests --- @@ -124,6 +127,192 @@ def test_krakenfutures_order_contracts_no_trigger_options(mocker, default_conf): assert result["stopPrice"] is None +# --- _adjust_krakenfutures_order average price tests --- + + +def test_krakenfutures_adjust_order_computes_average_from_trades(mocker, default_conf): + """Compute VWAP average price from trades when CCXT returns None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": 0.0004, + "average": None, + "timestamp": 1771354195241, + } + trades = [ + { + "amount": 0.0002, + "price": 67800.0, + "cost": 13.56, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + { + "amount": 0.0002, + "price": 67900.0, + "cost": 13.58, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + ] + mocker.patch.object(ex, "get_trades_for_order", return_value=trades) + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] == pytest.approx(67850.0) + + +def test_krakenfutures_adjust_order_skips_open_orders(mocker, default_conf): + """Don't fetch trades for open orders.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "open", + "filled": 0, + "average": None, + "timestamp": 1771354195241, + } + trades_mock = mocker.patch.object(ex, "get_trades_for_order") + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] is None + trades_mock.assert_not_called() + + +def test_krakenfutures_adjust_order_preserves_existing_average(mocker, default_conf): + """Don't overwrite average when already present.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": 0.0004, + "average": 67843.0, + "timestamp": 1771354195241, + } + trades_mock = mocker.patch.object(ex, "get_trades_for_order") + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] == 67843.0 + trades_mock.assert_not_called() + + +def test_krakenfutures_adjust_order_no_trades_found(mocker, default_conf): + """Leave average as None when no trades are found.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": 0.0004, + "average": None, + "timestamp": 1771354195241, + } + mocker.patch.object(ex, "get_trades_for_order", return_value=[]) + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] is None + + +# --- get_trades_for_order fee enrichment tests --- + + +def test_krakenfutures_get_trades_enriches_fees(mocker, default_conf): + """Calculate fees from market fee schedule when CCXT returns fee: None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + raw_trades = [ + { + "amount": 0.0004, + "price": 67843.0, + "cost": 27.14, + "order": "abc", + "symbol": "BTC/USD:USD", + "takerOrMaker": "taker", + "fee": {"cost": None, "currency": None}, + }, + ] + mocker.patch.object( + ExchangeBase, + "get_trades_for_order", + return_value=raw_trades, + ) + # Re-patch markets property with fee rates for BTC/USD:USD + kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}} + mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets)) + + result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock()) + assert len(result) == 1 + assert result[0]["fee"]["cost"] == pytest.approx(27.14 * 0.0005) + assert result[0]["fee"]["currency"] == "USD" + assert result[0]["fee"]["rate"] == 0.0005 + + +def test_krakenfutures_get_trades_uses_maker_rate(mocker, default_conf): + """Use maker fee rate when fillType is maker.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + raw_trades = [ + { + "amount": 0.0004, + "price": 67843.0, + "cost": 27.14, + "order": "abc", + "symbol": "BTC/USD:USD", + "takerOrMaker": "maker", + "fee": None, + }, + ] + mocker.patch.object( + ExchangeBase, + "get_trades_for_order", + return_value=raw_trades, + ) + kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}} + mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets)) + + result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock()) + assert result[0]["fee"]["cost"] == pytest.approx(27.14 * 0.0002) + assert result[0]["fee"]["rate"] == 0.0002 + + +def test_krakenfutures_get_trades_preserves_existing_fees(mocker, default_conf): + """Don't overwrite fees if CCXT already provided them.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + existing_fee = {"cost": 0.01, "currency": "USD", "rate": 0.0005} + raw_trades = [ + { + "amount": 0.0004, + "price": 67843.0, + "cost": 27.14, + "order": "abc", + "symbol": "BTC/USD:USD", + "takerOrMaker": "taker", + "fee": existing_fee, + }, + ] + mocker.patch.object( + ExchangeBase, + "get_trades_for_order", + return_value=raw_trades, + ) + kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}} + mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets)) + + result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock()) + # Should keep existing fee, not recalculate + assert result[0]["fee"] == existing_fee + + # --- fetch_order fallback tests --- From c533e483106aac243f362d49934cb7f0e86e41e1 Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 19 Feb 2026 14:25:50 +0100 Subject: [PATCH 70/91] refactor: remove funding_fee_candle_limit override Kraken Futures now provides 1 year of hourly funding rate history. The previous limit of 700 candles (29 days) was a workaround for limited API retention. Freqtrade paginates funding rate fetches automatically, so the CCXT default (2000) works correctly. --- freqtrade/exchange/krakenfutures.py | 2 -- tests/exchange/test_krakenfutures.py | 11 ----------- 2 files changed, 13 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index d9e2315b2..0ff1d239b 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -53,8 +53,6 @@ class Krakenfutures(Exchange): PriceType.MARK: "mark", PriceType.INDEX: "index", }, - # Kraken Futures retains only 29 days of hourly funding rate history. - "funding_fee_candle_limit": 700, } @retrier diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 77e63ba83..472c9a798 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -35,8 +35,6 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stoploss_query_requires_stop_flag"] is True assert ft_has["stop_price_param"] == "triggerPrice" assert ft_has["stop_price_type_field"] == "triggerSignal" - # Kraken retains only ~29 days of hourly funding rate history - assert ft_has["funding_fee_candle_limit"] == 700 def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): @@ -49,15 +47,6 @@ 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_ohlcv_candle_limit_funding_rate(mocker, default_conf): - """Funding rate candle limit is capped to reflect Kraken's limited history retention.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - - mocker.patch.object(ex, "features", return_value=2000) - - assert ex.ohlcv_candle_limit("1h", candle_type=CandleType.FUNDING_RATE) == 700 - - # --- _order_contracts_to_amount trigger price fix tests --- From d25b2ea10aaed4e9539c2123ffcf08744310b251 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 24 Feb 2026 20:24:32 +0100 Subject: [PATCH 71/91] fix: crash on comparison of filled=None --- freqtrade/exchange/krakenfutures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 0ff1d239b..5ff54799e 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -17,6 +17,7 @@ from freqtrade.exceptions import ( from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas +from freqtrade.misc import safe_value_fallback from freqtrade.util.datetime_helpers import dt_from_ts @@ -162,7 +163,7 @@ class Krakenfutures(Exchange): if ( order.get("average") is None and order.get("status") in ("canceled", "closed") - and order.get("filled", 0) > 0 + and safe_value_fallback(order, "filled", default_value=0) > 0 ): trades = self.get_trades_for_order( order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) From b1ef0bf58d5456b0c7379d35a1a580c7e5f13661 Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 25 Feb 2026 22:20:30 +0100 Subject: [PATCH 72/91] krakenfutures: use safe_value_nested for trigger price extraction --- freqtrade/exchange/krakenfutures.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 5ff54799e..971fbe8cb 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -17,7 +17,7 @@ from freqtrade.exceptions import ( from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas -from freqtrade.misc import safe_value_fallback +from freqtrade.misc import safe_value_fallback, safe_value_nested from freqtrade.util.datetime_helpers import dt_from_ts @@ -143,13 +143,12 @@ class Krakenfutures(Exchange): """ order = super()._order_contracts_to_amount(order) if order.get("triggerPrice") is None and order.get("stopPrice") is None: - info = order.get("info", {}) - inner = info.get("order", {}) if isinstance(info, dict) else {} - opts = inner.get("priceTriggerOptions", {}) if isinstance(inner, dict) else {} - trigger = self._safe_float(opts.get("triggerPrice")) if isinstance(opts, dict) else None + trigger = safe_value_nested(order, "info.order.priceTriggerOptions.triggerPrice") if trigger is not None: - order["triggerPrice"] = trigger - order["stopPrice"] = trigger + trigger_float = self._safe_float(trigger) + if trigger_float is not None: + order["triggerPrice"] = trigger_float + order["stopPrice"] = trigger_float return order def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: From bfd758d353cd3ea19b877ad0eb8f09fe6772513c Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 25 Feb 2026 22:20:49 +0100 Subject: [PATCH 73/91] krakenfutures: recompute average and cost from fills for terminal orders --- freqtrade/exchange/krakenfutures.py | 18 +++++----- tests/exchange/test_krakenfutures.py | 49 +++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 971fbe8cb..16e9b6ac4 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -17,7 +17,7 @@ from freqtrade.exceptions import ( from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas -from freqtrade.misc import safe_value_fallback, safe_value_nested +from freqtrade.misc import safe_value_nested from freqtrade.util.datetime_helpers import dt_from_ts @@ -156,14 +156,13 @@ class Krakenfutures(Exchange): Kraken Futures' /orders/status endpoint does not include execution data, so CCXT sets price/average to the limitPrice (the order's limit, not the - actual fill price). For closed/filled orders we fetch trades from /fills - and compute the VWAP average. + actual fill price). For closed/filled orders we ALWAYS fetch trades and + compute VWAP because CCXT's average field is unreliable. + + See: https://github.com/ccxt/ccxt/issues/27979 """ - if ( - order.get("average") is None - and order.get("status") in ("canceled", "closed") - and safe_value_fallback(order, "filled", default_value=0) > 0 - ): + filled = self._safe_float(order.get("filled")) or 0.0 + if order.get("status") in ("canceled", "closed") and filled > 0: trades = self.get_trades_for_order( order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) ) @@ -171,6 +170,9 @@ class Krakenfutures(Exchange): total_amount = sum(t["amount"] for t in trades) if total_amount: order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount + trade_costs = [t["cost"] for t in trades if t.get("cost") is not None] + if trade_costs: + order["cost"] = sum(trade_costs) return order def get_trades_for_order( diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 472c9a798..efdad60a3 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -153,6 +153,7 @@ def test_krakenfutures_adjust_order_computes_average_from_trades(mocker, default result = ex._adjust_krakenfutures_order(order) assert result["average"] == pytest.approx(67850.0) + assert result["cost"] == pytest.approx(27.14) def test_krakenfutures_adjust_order_skips_open_orders(mocker, default_conf): @@ -174,8 +175,27 @@ def test_krakenfutures_adjust_order_skips_open_orders(mocker, default_conf): trades_mock.assert_not_called() -def test_krakenfutures_adjust_order_preserves_existing_average(mocker, default_conf): - """Don't overwrite average when already present.""" +def test_krakenfutures_adjust_order_handles_none_filled(mocker, default_conf): + """Don't crash or fetch trades when filled is None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": None, + "average": None, + "timestamp": 1771354195241, + } + trades_mock = mocker.patch.object(ex, "get_trades_for_order") + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] is None + trades_mock.assert_not_called() + + +def test_krakenfutures_adjust_order_recomputes_existing_average(mocker, default_conf): + """Recompute average from fills even when CCXT already provided one.""" ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") order = { @@ -186,11 +206,30 @@ def test_krakenfutures_adjust_order_preserves_existing_average(mocker, default_c "average": 67843.0, "timestamp": 1771354195241, } - trades_mock = mocker.patch.object(ex, "get_trades_for_order") + trades = [ + { + "amount": 0.0002, + "price": 67800.0, + "cost": 13.56, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + { + "amount": 0.0002, + "price": 67900.0, + "cost": 13.58, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + ] + trades_mock = mocker.patch.object(ex, "get_trades_for_order", return_value=trades) result = ex._adjust_krakenfutures_order(order) - assert result["average"] == 67843.0 - trades_mock.assert_not_called() + assert result["average"] == pytest.approx(67850.0) + assert result["cost"] == pytest.approx(27.14) + trades_mock.assert_called_once() def test_krakenfutures_adjust_order_no_trades_found(mocker, default_conf): From 1e79c1961ba51ed09e680943d4d99990fcf80382 Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 25 Feb 2026 22:21:06 +0100 Subject: [PATCH 74/91] krakenfutures: aggregate order fees in _adjust_krakenfutures_order --- freqtrade/exchange/krakenfutures.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 16e9b6ac4..e0de6814f 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -152,13 +152,16 @@ class Krakenfutures(Exchange): return order def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: - """Fix missing average price on filled orders by fetching trades. + """Fix missing average price and aggregate fees for filled orders. Kraken Futures' /orders/status endpoint does not include execution data, so CCXT sets price/average to the limitPrice (the order's limit, not the actual fill price). For closed/filled orders we ALWAYS fetch trades and compute VWAP because CCXT's average field is unreliable. + We also aggregate fees here to avoid a redundant get_trades_for_order call + from fee_detection_from_trades. + See: https://github.com/ccxt/ccxt/issues/27979 """ filled = self._safe_float(order.get("filled")) or 0.0 @@ -169,10 +172,23 @@ class Krakenfutures(Exchange): if trades: total_amount = sum(t["amount"] for t in trades) if total_amount: + # Compute VWAP order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount trade_costs = [t["cost"] for t in trades if t.get("cost") is not None] if trade_costs: order["cost"] = sum(trade_costs) + # Aggregate fees to avoid redundant get_trades_for_order call + total_fee = sum( + t["fee"]["cost"] + for t in trades + if t.get("fee") and t["fee"].get("cost") is not None + ) + if total_fee: + order["fee"] = { + "cost": total_fee, + "currency": self.get_pair_quote_currency(order["symbol"]), + "rate": None, + } return order def get_trades_for_order( From 35d812a27163f4bb795e6ef35c1a53a12462f304 Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 25 Feb 2026 22:21:50 +0100 Subject: [PATCH 75/91] krakenfutures: run order adjustments via _order_contracts_to_amount --- freqtrade/exchange/krakenfutures.py | 45 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index e0de6814f..4ccbf9c69 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -134,14 +134,29 @@ class Krakenfutures(Exchange): return None def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder: - """Normalize order and fix missing trigger price from CCXT parsing. + """Normalize order and apply Kraken Futures-specific fixes. - CCXT's krakenfutures parse_order reads triggerPrice from the top level of - the order details, but the /orders/status endpoint nests it inside - priceTriggerOptions.triggerPrice. This extracts it so stopPrice/triggerPrice - are populated correctly for stoploss order handling. + This override applies all CCXT workarounds by calling _adjust_krakenfutures_order + after the base class normalization. This ensures all orders (including those + from create_order that fill immediately) get correct prices and fees. """ order = super()._order_contracts_to_amount(order) + return self._adjust_krakenfutures_order(order) + + def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: + """Apply Kraken Futures-specific order corrections. + + Fixes CCXT parsing issues: + 1. triggerPrice nested in info.order.priceTriggerOptions (not extracted) + 2. average set to limitPrice instead of actual fill price + + For filled terminal orders, we ALWAYS fetch trades and compute VWAP because + CCXT's average is unreliable. We also aggregate fees to avoid a + redundant get_trades_for_order call from fee_detection_from_trades. + + See: https://github.com/ccxt/ccxt/issues/27979 + """ + # Fix 1: Extract nested triggerPrice for stoploss orders if order.get("triggerPrice") is None and order.get("stopPrice") is None: trigger = safe_value_nested(order, "info.order.priceTriggerOptions.triggerPrice") if trigger is not None: @@ -149,23 +164,10 @@ class Krakenfutures(Exchange): if trigger_float is not None: order["triggerPrice"] = trigger_float order["stopPrice"] = trigger_float - return order - def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: - """Fix missing average price and aggregate fees for filled orders. - - Kraken Futures' /orders/status endpoint does not include execution data, - so CCXT sets price/average to the limitPrice (the order's limit, not the - actual fill price). For closed/filled orders we ALWAYS fetch trades and - compute VWAP because CCXT's average field is unreliable. - - We also aggregate fees here to avoid a redundant get_trades_for_order call - from fee_detection_from_trades. - - See: https://github.com/ccxt/ccxt/issues/27979 - """ filled = self._safe_float(order.get("filled")) or 0.0 if order.get("status") in ("canceled", "closed") and filled > 0: + # Fix 2: Compute VWAP and aggregate fees for filled orders trades = self.get_trades_for_order( order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) ) @@ -229,8 +231,7 @@ class Krakenfutures(Exchange): try: order = self._api.fetch_order(order_id, pair, params=status_params) self._log_exchange_response("fetch_order", order) - order = self._order_contracts_to_amount(order) - return self._adjust_krakenfutures_order(order) + return self._order_contracts_to_amount(order) except ccxt.OrderNotFound: # Expected for older Kraken Futures orders not visible in orders/status. pass @@ -247,7 +248,7 @@ class Krakenfutures(Exchange): order = self._fetch_order_fallback(order_id, pair, params) if order is not None: - return self._adjust_krakenfutures_order(order) + return order # Order not in status, open, closed, or canceled endpoints - genuinely gone. # Raise non-retrying InvalidOrderException (Kraken has limited history retention). From 5096ec8a506a89e7174000089f5a5d1a25170e95 Mon Sep 17 00:00:00 2001 From: matstedt Date: Wed, 25 Feb 2026 23:59:28 +0100 Subject: [PATCH 76/91] krakenfutures: defer order-level fee aggregation --- freqtrade/exchange/krakenfutures.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 4ccbf9c69..4b67891e1 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -151,8 +151,7 @@ class Krakenfutures(Exchange): 2. average set to limitPrice instead of actual fill price For filled terminal orders, we ALWAYS fetch trades and compute VWAP because - CCXT's average is unreliable. We also aggregate fees to avoid a - redundant get_trades_for_order call from fee_detection_from_trades. + CCXT's average is unreliable. See: https://github.com/ccxt/ccxt/issues/27979 """ @@ -167,7 +166,7 @@ class Krakenfutures(Exchange): filled = self._safe_float(order.get("filled")) or 0.0 if order.get("status") in ("canceled", "closed") and filled > 0: - # Fix 2: Compute VWAP and aggregate fees for filled orders + # Fix 2: Compute VWAP and cost for filled orders trades = self.get_trades_for_order( order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) ) @@ -179,18 +178,6 @@ class Krakenfutures(Exchange): trade_costs = [t["cost"] for t in trades if t.get("cost") is not None] if trade_costs: order["cost"] = sum(trade_costs) - # Aggregate fees to avoid redundant get_trades_for_order call - total_fee = sum( - t["fee"]["cost"] - for t in trades - if t.get("fee") and t["fee"].get("cost") is not None - ) - if total_fee: - order["fee"] = { - "cost": total_fee, - "currency": self.get_pair_quote_currency(order["symbol"]), - "rate": None, - } return order def get_trades_for_order( From 7f9b1a9f904c263bb4ae24164350c9a6b9a2493f Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 26 Feb 2026 00:10:28 +0100 Subject: [PATCH 77/91] krakenfutures: correct ccxt issue link to 27996 --- freqtrade/exchange/krakenfutures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 4b67891e1..459e1f303 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -153,7 +153,7 @@ class Krakenfutures(Exchange): For filled terminal orders, we ALWAYS fetch trades and compute VWAP because CCXT's average is unreliable. - See: https://github.com/ccxt/ccxt/issues/27979 + See: https://github.com/ccxt/ccxt/issues/27996 """ # Fix 1: Extract nested triggerPrice for stoploss orders if order.get("triggerPrice") is None and order.get("stopPrice") is None: From 1f4c188dbfc59f314c671fa0b4777dbe24a74664 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 6 Mar 2026 20:38:05 +0100 Subject: [PATCH 78/91] test: add futures market-parsing test --- tests/exchange_online/conftest.py | 44 +++++++++++++++++++++++ tests/exchange_online/test_ccxt_compat.py | 24 +++++++++++++ 2 files changed, 68 insertions(+) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 7abd6b4b8..9a39b2090 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -572,6 +572,50 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "futures_pair": "BTC/USD:USD", "hasQuoteVolumeFutures": False, "leverage_tiers_public": True, + "sample_order_futures": [ + { + # Trigger order + "exchange_response": { + "order": { + "type": "TRIGGER_ORDER", + "orderId": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e", + "cliOrdId": None, + "symbol": "PF_XBTUSD", + "side": "buy", + "quantity": 0.0004, + "limitPrice": 71712, + "reduceOnly": True, + "timestamp": "2026-02-17T16:26:02.918Z", + "lastUpdateTimestamp": "2026-02-17T16:26:02.918Z", + "priceTriggerOptions": { + "triggerPrice": 71641, + "triggerSignal": "LAST_PRICE", + "triggerSide": "TRIGGER_ABOVE", + "limitPriceOffsetValue": None, + "limitPriceOffsetUnit": None, + }, + }, + "status": "TRIGGER_PLACED", + "updateReason": None, + "error": None, + }, + "pair": "BTC/USD:USD", + "expected": { + "symbol": "BTC/USD:USD", + "id": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e", + "timestamp": 1771345562918, + "datetime": "2026-02-17T16:26:02.918Z", + "price": 71712.0, + "status": "open", + "amount": 0.0004, + "side": "buy", + # TODO: this should work if stoploss is supposed to work. + # "triggerPrice": 71641.0, + # "stopPrice": 71641.0, + # "stopLossPrice": 71641.0, + }, + }, + ], }, } diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index f33818d3d..e2988cc61 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -101,6 +101,30 @@ class TestCCXTExchange: else: pytest.skip(f"No sample order available for exchange {exchangename}") + def test_ccxt_order_parse_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): + exch, exchangename, exchange_params = exchange_futures + if orders := exchange_params.get("sample_order_futures"): + for order in orders: + pair = order["pair"] + exchange_response: dict = order["exchange_response"] + + market = exch._api.markets[pair] + po = exch._api.parse_order(exchange_response, market) + expected = order["expected"] + assert isinstance(po["id"], str) + assert po["id"] is not None + + # Generic comparison which works for all fields + for key, value in expected.items(): + assert key in po, f"Expected key {key} not found in parsed order" + assert isinstance(po[key], type(value)), ( + f"Expected {key} to be of type {type(value)}, got {type(po[key])}" + ) + assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}" + + else: + pytest.skip(f"No sample order available for exchange {exchangename}") + def test_ccxt_my_trades_parse(self, exchange: EXCHANGE_FIXTURE_TYPE): exch, exchangename, exchange_params = exchange if trades := exchange_params.get("sample_my_trades"): From 3a28c91635f8b3231a259d2ac27d07c4cfbd0b73 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 6 Mar 2026 20:46:45 +0100 Subject: [PATCH 79/91] test: add regular market order --- tests/exchange_online/conftest.py | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 9a39b2090..815628d6e 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -573,6 +573,43 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "hasQuoteVolumeFutures": False, "leverage_tiers_public": True, "sample_order_futures": [ + { + # Regular market order + "exchange_response": { + "uid": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e", + "accountUid": "cabdb242-5111-4dac-bac-76f33395d76d", + "tradeable": "PF_XBTUSD", + "direction": "Sell", + "quantity": "0", + "filled": "0.0004", + "timestamp": 1771354195241, + "limitPrice": "67164.00", + "orderType": "IoC", + "clientId": "", + "reduceOnly": False, + "lastUpdateTimestamp": 1771354195241, + "regulatoryExternalUid": "ae198dd6-6be0-4014-8af-ebd472190648", + "status": "closed", + }, + "pair": "BTC/USD:USD", + "expected": { + "symbol": "BTC/USD:USD", + "id": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e", + "timestamp": 1771354195241, + "datetime": "2026-02-17T18:49:55.241Z", + "price": 67164.0, + # Average should be None (it's not correct for market orders), but we have a + # workaround for this in place. + "average": 67164.0, + "status": "closed", + "type": "market", + "amount": 0.0004, + "side": "sell", + "triggerPrice": None, + "stopPrice": None, + "stopLossPrice": None, + }, + }, { # Trigger order "exchange_response": { From 437d0a264c4078ed7cf3a4e3a8b512d3e5f0b8ac Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 6 Mar 2026 20:47:21 +0100 Subject: [PATCH 80/91] test: Improve test sequence (simplifies analysis) --- tests/exchange_online/test_ccxt_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index e2988cc61..c44bb216a 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -117,10 +117,10 @@ class TestCCXTExchange: # Generic comparison which works for all fields for key, value in expected.items(): assert key in po, f"Expected key {key} not found in parsed order" + assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}" assert isinstance(po[key], type(value)), ( f"Expected {key} to be of type {type(value)}, got {type(po[key])}" ) - assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}" else: pytest.skip(f"No sample order available for exchange {exchangename}") From 57c7edfe2611db08a1d0e9cf32a2e069d0c28474 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 16 Mar 2026 20:22:04 +0100 Subject: [PATCH 81/91] test: update krakenfutures live test --- tests/exchange_online/conftest.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 815628d6e..0c7e84517 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -642,14 +642,16 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "id": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e", "timestamp": 1771345562918, "datetime": "2026-02-17T16:26:02.918Z", - "price": 71712.0, + # TODO: re-verify this ... + # "price": 71712.0, + "price": None, "status": "open", "amount": 0.0004, "side": "buy", - # TODO: this should work if stoploss is supposed to work. - # "triggerPrice": 71641.0, - # "stopPrice": 71641.0, - # "stopLossPrice": 71641.0, + "triggerPrice": 71641.0, + "stopPrice": 71641.0, + # krakenfutures uses stopPrice - so this is fine. + "stopLossPrice": None, }, }, ], From 0fda77a39c6ae75a22e37ff3a719b62b023c8413 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 16 Mar 2026 20:43:39 +0100 Subject: [PATCH 82/91] chore: filter open orders by pair --- freqtrade/exchange/krakenfutures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 459e1f303..1a871788d 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -260,7 +260,7 @@ class Krakenfutures(Exchange): # to prevent endpoint/filter mismatches. open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} order = self._find_order_in_list( - self._api.fetch_open_orders, None, open_params, order_id_str + self._api.fetch_open_orders, pair, open_params, order_id_str ) if order is not None: return order From f8b99c6e6665624c2f3446dc1c036c5bcedf20eb Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Mar 2026 06:38:54 +0100 Subject: [PATCH 83/91] test(krakenfutures): update stop strip test --- tests/exchange/test_krakenfutures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index efdad60a3..6aae384a2 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -589,7 +589,7 @@ def test_krakenfutures_fetch_order_fallback_strips_stop_from_open_orders(mocker, ex._fetch_order_fallback("abc", "BTC/USD:USD", {"stop": True}) # stop should be stripped from open orders call - open_fetch.assert_called_once_with(None, params={}) + open_fetch.assert_called_once_with("BTC/USD:USD", params={}) def test_krakenfutures_fetch_order_propagates_exchange_errors_from_fallback(mocker, default_conf): From 32fecd7b15e59a00b10d68b1764263a763469ec0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Mar 2026 07:20:55 +0100 Subject: [PATCH 84/91] test(krakenfutures): improve _ft_has test --- tests/exchange/test_krakenfutures.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 6aae384a2..f557adac2 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -1,7 +1,5 @@ """Tests for Kraken Futures exchange class""" -from __future__ import annotations - from copy import deepcopy from datetime import UTC, datetime from unittest.mock import MagicMock, PropertyMock @@ -33,6 +31,7 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stoploss_on_exchange"] is True assert ft_has["stoploss_order_types"] == {"limit": "limit", "market": "market"} assert ft_has["stoploss_query_requires_stop_flag"] is True + assert ft_has["stop_price_prop"] == "stopPrice" assert ft_has["stop_price_param"] == "triggerPrice" assert ft_has["stop_price_type_field"] == "triggerSignal" From 200b9da2a2cef4e720dbd769882907a88b936f07 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 17 Mar 2026 07:23:44 +0100 Subject: [PATCH 85/91] test: remove unnecessary test ccxt limit is tested as part of test_ohlcv_limit_futures in compat testing --- tests/exchange/test_krakenfutures.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index f557adac2..cf48d89ab 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -36,16 +36,6 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stop_price_type_field"] == "triggerSignal" -def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf): - """Test that OHLCV candle limit follows CCXT feature limit.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - assert isinstance(ex, Krakenfutures) - - mocker.patch.object(ex, "features", return_value=2000) - - assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000 - - # --- _order_contracts_to_amount trigger price fix tests --- From bd9697e6be60b0fb06e50b1105f9e6e20e6391ee Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 18 Mar 2026 20:05:58 +0100 Subject: [PATCH 86/91] test: fix krakenfutures tests --- tests/exchange/test_krakenfutures.py | 2 +- tests/exchange_online/conftest.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index cf48d89ab..9742807fd 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, PropertyMock import ccxt import pytest -from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.enums import MarginMode, TradingMode from freqtrade.exceptions import ( DDosProtection, ExchangeError, diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 0c7e84517..b68e7eeca 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -597,10 +597,9 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "id": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e", "timestamp": 1771354195241, "datetime": "2026-02-17T18:49:55.241Z", - "price": 67164.0, - # Average should be None (it's not correct for market orders), but we have a - # workaround for this in place. - "average": 67164.0, + "price": None, + # Average should be None (it's not correct for market orders) + "average": None, "status": "closed", "type": "market", "amount": 0.0004, From 6bf1901f9bd80ae6c5e33c5aba39438ac6ce3441 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 18 Mar 2026 20:06:20 +0100 Subject: [PATCH 87/91] chore: bump ccxt to 4.5.44 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3ae4b2ddc..166505a08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16 ta-lib==0.6.8 technical==1.5.4 -ccxt==4.5.43 +ccxt==4.5.44 cryptography==46.0.5 aiohttp==3.13.3 SQLAlchemy==2.0.48 From eba9c24510f8de9bcf4aff473aeb490f7a2c75ba Mon Sep 17 00:00:00 2001 From: matstedt Date: Thu, 19 Mar 2026 18:42:23 +0100 Subject: [PATCH 88/91] refactor(krakenfutures): drop resolved trigger price workaround --- freqtrade/exchange/krakenfutures.py | 27 ++--------- tests/exchange/test_krakenfutures.py | 69 ---------------------------- 2 files changed, 4 insertions(+), 92 deletions(-) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 1a871788d..802e1bd66 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -17,7 +17,6 @@ from freqtrade.exceptions import ( from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas -from freqtrade.misc import safe_value_nested from freqtrade.util.datetime_helpers import dt_from_ts @@ -134,39 +133,21 @@ class Krakenfutures(Exchange): return None def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder: - """Normalize order and apply Kraken Futures-specific fixes. - - This override applies all CCXT workarounds by calling _adjust_krakenfutures_order - after the base class normalization. This ensures all orders (including those - from create_order that fill immediately) get correct prices and fees. - """ + """Normalize order and apply Kraken Futures-specific order corrections.""" order = super()._order_contracts_to_amount(order) return self._adjust_krakenfutures_order(order) def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: """Apply Kraken Futures-specific order corrections. - Fixes CCXT parsing issues: - 1. triggerPrice nested in info.order.priceTriggerOptions (not extracted) - 2. average set to limitPrice instead of actual fill price - - For filled terminal orders, we ALWAYS fetch trades and compute VWAP because - CCXT's average is unreliable. + For filled terminal orders, always fetch trades and compute VWAP because + CCXT's average is still unreliable. See: https://github.com/ccxt/ccxt/issues/27996 """ - # Fix 1: Extract nested triggerPrice for stoploss orders - if order.get("triggerPrice") is None and order.get("stopPrice") is None: - trigger = safe_value_nested(order, "info.order.priceTriggerOptions.triggerPrice") - if trigger is not None: - trigger_float = self._safe_float(trigger) - if trigger_float is not None: - order["triggerPrice"] = trigger_float - order["stopPrice"] = trigger_float - filled = self._safe_float(order.get("filled")) or 0.0 if order.get("status") in ("canceled", "closed") and filled > 0: - # Fix 2: Compute VWAP and cost for filled orders + # Compute VWAP and cost for filled orders. trades = self.get_trades_for_order( order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) ) diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 9742807fd..5b0b3a813 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -36,75 +36,6 @@ def test_krakenfutures_ft_has_overrides(): assert ft_has["stop_price_type_field"] == "triggerSignal" -# --- _order_contracts_to_amount trigger price fix tests --- - - -def test_krakenfutures_order_contracts_fixes_missing_trigger_price(mocker, default_conf): - """Extract triggerPrice from info.order.priceTriggerOptions when CCXT misses it.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - order = { - "id": "abc", - "symbol": "BTC/USD:USD", - "triggerPrice": None, - "stopPrice": None, - "info": { - "order": { - "type": "TRIGGER_ORDER", - "priceTriggerOptions": { - "triggerPrice": 71641, - "triggerSignal": "LAST_PRICE", - }, - }, - "status": "TRIGGER_PLACED", - }, - } - result = ex._order_contracts_to_amount(order) - assert result["triggerPrice"] == 71641.0 - assert result["stopPrice"] == 71641.0 - - -def test_krakenfutures_order_contracts_preserves_existing_trigger_price(mocker, default_conf): - """Don't overwrite triggerPrice when CCXT already parsed it correctly.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - order = { - "id": "abc", - "symbol": "BTC/USD:USD", - "triggerPrice": 70000.0, - "stopPrice": 70000.0, - "info": { - "order": { - "priceTriggerOptions": { - "triggerPrice": 71641, - }, - }, - }, - } - result = ex._order_contracts_to_amount(order) - assert result["triggerPrice"] == 70000.0 - assert result["stopPrice"] == 70000.0 - - -def test_krakenfutures_order_contracts_no_trigger_options(mocker, default_conf): - """Regular (non-trigger) orders should pass through unchanged.""" - ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") - order = { - "id": "abc", - "symbol": "BTC/USD:USD", - "triggerPrice": None, - "stopPrice": None, - "info": { - "order": { - "type": "lmt", - "orderId": "abc", - }, - "status": "placed", - }, - } - result = ex._order_contracts_to_amount(order) - assert result["triggerPrice"] is None - assert result["stopPrice"] is None - - # --- _adjust_krakenfutures_order average price tests --- From 1bd0404503b99ef8b2a91ac0c3b843dd8eb2c74c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 21 Mar 2026 08:44:57 +0100 Subject: [PATCH 89/91] fix: workaround for missing "filled" parsing --- freqtrade/exchange/krakenfutures.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 802e1bd66..5a4b64fb9 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -17,6 +17,7 @@ from freqtrade.exceptions import ( from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas +from freqtrade.misc import safe_value_nested from freqtrade.util.datetime_helpers import dt_from_ts @@ -145,6 +146,10 @@ class Krakenfutures(Exchange): See: https://github.com/ccxt/ccxt/issues/27996 """ + if order.get("status") == "canceled" and order.get("filled") is None: + # Workaround for missing filled parsing - https://github.com/ccxt/ccxt/issues/28210 + order["filled"] = safe_value_nested(order, "info.order.filled", default_value=None) + filled = self._safe_float(order.get("filled")) or 0.0 if order.get("status") in ("canceled", "closed") and filled > 0: # Compute VWAP and cost for filled orders. From 37dbaae8aa4b84f2504c2412c872cfaf7c1e69d2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 21 Mar 2026 08:50:12 +0100 Subject: [PATCH 90/91] feat(krakenfutures): exchange_override fetchOrders to false to force fallback to individual orders --- freqtrade/exchange/krakenfutures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index 5a4b64fb9..755cdfadd 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -54,6 +54,7 @@ class Krakenfutures(Exchange): PriceType.MARK: "mark", PriceType.INDEX: "index", }, + "exchange_has_overrides": {"fetchOrders": False}, } @retrier From 724318c43e3acf2ffdb44c754cae2d1415b6e06d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 21 Mar 2026 11:32:18 +0100 Subject: [PATCH 91/91] test(krakenfutures): Add canceled order test --- tests/exchange_online/conftest.py | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index b68e7eeca..ba04598cd 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -653,6 +653,38 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { "stopLossPrice": None, }, }, + { + # Canceled order + "exchange_response": { + "order": { + "type": "ORDER", + "orderId": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e", + "cliOrdId": None, + "symbol": "PF_XBTUSD", + "side": "buy", + "quantity": 0.0022, + "filled": 0, + "limitPrice": 68000, + "reduceOnly": False, + "timestamp": "2026-03-21T07:32:21.555Z", + "lastUpdateTimestamp": "2026-03-21T07:32:21.555Z", + }, + "status": "CANCELLED", + "updateReason": "CANCELLED_BY_USER", + "error": None, + }, + "pair": "BTC/USD:USD", + "expected": { + "symbol": "BTC/USD:USD", + "id": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e", + "timestamp": 1774078341555, + "datetime": "2026-03-21T07:32:21.555Z", + "price": None, + "status": "canceled", + # TODO: filled should be 0, not None. + "filled": None, + }, + }, ], }, }