From 46e96e20a91b0452770d634788fefc3b3bdb0cae Mon Sep 17 00:00:00 2001 From: matstedt Date: Sun, 11 Jan 2026 12:59:42 +0100 Subject: [PATCH] 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