|
| 1 | +"""Kraken Futures exchange subclass""" |
| 2 | + |
| 3 | +import logging |
| 4 | +from datetime import datetime |
| 5 | +from typing import Any |
| 6 | + |
| 7 | +import ccxt |
| 8 | + |
| 9 | +from freqtrade.enums import MarginMode, PriceType, TradingMode |
| 10 | +from freqtrade.exceptions import ( |
| 11 | + DDosProtection, |
| 12 | + ExchangeError, |
| 13 | + InvalidOrderException, |
| 14 | + OperationalException, |
| 15 | + TemporaryError, |
| 16 | +) |
| 17 | +from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier |
| 18 | +from freqtrade.exchange.exchange import Exchange |
| 19 | +from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas |
| 20 | +from freqtrade.misc import safe_value_nested |
| 21 | +from freqtrade.util.datetime_helpers import dt_from_ts |
| 22 | + |
| 23 | + |
| 24 | +logger = logging.getLogger(__name__) |
| 25 | + |
| 26 | + |
| 27 | +class Krakenfutures(Exchange): |
| 28 | + """Kraken Futures exchange class. |
| 29 | +
|
| 30 | + Contains adjustments needed for Freqtrade to work with this exchange. |
| 31 | +
|
| 32 | + Key differences from spot Kraken: |
| 33 | + - Stop orders use triggerPrice/triggerSignal instead of stopPrice |
| 34 | + - Flex (multi-collateral) accounts need USD balance synthesis |
| 35 | + """ |
| 36 | + |
| 37 | + _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ |
| 38 | + (TradingMode.FUTURES, MarginMode.ISOLATED), |
| 39 | + ] |
| 40 | + |
| 41 | + _ft_has: FtHas = { |
| 42 | + "tickers_have_quoteVolume": False, |
| 43 | + "stoploss_on_exchange": True, |
| 44 | + "stoploss_order_types": { |
| 45 | + "limit": "limit", |
| 46 | + "market": "market", |
| 47 | + }, |
| 48 | + "stoploss_query_requires_stop_flag": True, |
| 49 | + "stop_price_param": "triggerPrice", |
| 50 | + "stop_price_prop": "stopPrice", |
| 51 | + "stop_price_type_field": "triggerSignal", |
| 52 | + "stop_price_type_value_mapping": { |
| 53 | + PriceType.LAST: "last", |
| 54 | + PriceType.MARK: "mark", |
| 55 | + PriceType.INDEX: "index", |
| 56 | + }, |
| 57 | + "exchange_has_overrides": {"fetchOrders": False}, |
| 58 | + } |
| 59 | + |
| 60 | + @retrier |
| 61 | + def get_balances(self, params: dict | None = None) -> CcxtBalances: |
| 62 | + """ |
| 63 | + Fetch balances with USD synthesis for flex (multi-collateral) accounts. |
| 64 | +
|
| 65 | + Kraken Futures flex accounts hold multiple currencies as collateral. |
| 66 | + CCXT returns per-currency balances but doesn't expose margin values |
| 67 | + as a USD balance. This override synthesizes a USD entry from flex account data |
| 68 | + when stake_currency is USD. |
| 69 | +
|
| 70 | + Field mapping (margin-centric for internal consistency): |
| 71 | + - free: availableMargin (margin available for new positions) |
| 72 | + - total: marginEquity (haircut-adjusted collateral + unrealized P&L) |
| 73 | + - used: total - free (margin currently in use) |
| 74 | +
|
| 75 | + Fallback chain for total: marginEquity -> portfolioValue -> balanceValue |
| 76 | + """ |
| 77 | + try: |
| 78 | + balances = self._api.fetch_balance(params or {}) |
| 79 | + |
| 80 | + # Only synthesize USD if stake_currency is USD |
| 81 | + stake = str(self._config.get("stake_currency", "")).upper() |
| 82 | + if stake == "USD": |
| 83 | + # Only synthesize if USD stake - flex only applies for these currencies. |
| 84 | + # For flex accounts, synthesize USD balance from margin values |
| 85 | + info = balances.get("info", {}) |
| 86 | + accounts = info.get("accounts", {}) if isinstance(info, dict) else {} |
| 87 | + flex = accounts.get("flex", {}) if isinstance(accounts, dict) else {} |
| 88 | + |
| 89 | + if flex: |
| 90 | + usd_free = self._safe_float(flex.get("availableMargin")) |
| 91 | + # Prefer marginEquity for consistency (same basis as availableMargin) |
| 92 | + raw_total = ( |
| 93 | + flex.get("marginEquity") |
| 94 | + or flex.get("portfolioValue") |
| 95 | + or flex.get("balanceValue") |
| 96 | + ) |
| 97 | + usd_total = self._safe_float(raw_total) |
| 98 | + if usd_free is not None or usd_total is not None: |
| 99 | + # Use available value for both if only one is present |
| 100 | + usd_free_value = usd_free if usd_free is not None else usd_total |
| 101 | + usd_total_value = usd_total if usd_total is not None else usd_free |
| 102 | + if usd_free_value is not None and usd_total_value is not None: |
| 103 | + usd_used = max(0.0, usd_total_value - usd_free_value) |
| 104 | + balances["USD"] = { |
| 105 | + "free": usd_free_value, |
| 106 | + "used": usd_used, |
| 107 | + "total": usd_total_value, |
| 108 | + } |
| 109 | + |
| 110 | + # Remove additional info from ccxt results (same as base class) |
| 111 | + balances.pop("info", None) |
| 112 | + balances.pop("free", None) |
| 113 | + balances.pop("total", None) |
| 114 | + balances.pop("used", None) |
| 115 | + |
| 116 | + self._log_exchange_response("fetch_balance", balances, add_info=params) |
| 117 | + return balances |
| 118 | + except ccxt.DDoSProtection as e: |
| 119 | + raise DDosProtection(e) from e |
| 120 | + except (ccxt.OperationFailed, ccxt.ExchangeError) as e: |
| 121 | + raise TemporaryError( |
| 122 | + f"Could not get balance due to {e.__class__.__name__}. Message: {e}" |
| 123 | + ) from e |
| 124 | + except ccxt.BaseError as e: |
| 125 | + raise OperationalException(e) from e |
| 126 | + |
| 127 | + @staticmethod |
| 128 | + def _safe_float(value: Any) -> float | None: |
| 129 | + """Convert value to float, returning None if conversion fails.""" |
| 130 | + if value is None: |
| 131 | + return None |
| 132 | + try: |
| 133 | + return float(value) |
| 134 | + except (ValueError, TypeError): |
| 135 | + return None |
| 136 | + |
| 137 | + def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder: |
| 138 | + """Normalize order and apply Kraken Futures-specific order corrections.""" |
| 139 | + order = super()._order_contracts_to_amount(order) |
| 140 | + return self._adjust_krakenfutures_order(order) |
| 141 | + |
| 142 | + def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: |
| 143 | + """Apply Kraken Futures-specific order corrections. |
| 144 | +
|
| 145 | + For filled terminal orders, always fetch trades and compute VWAP because |
| 146 | + CCXT's average is still unreliable. |
| 147 | +
|
| 148 | + See: https://github.com/ccxt/ccxt/issues/27996 |
| 149 | + """ |
| 150 | + if order.get("status") == "canceled" and order.get("filled") is None: |
| 151 | + # Workaround for missing filled parsing - https://github.com/ccxt/ccxt/issues/28210 |
| 152 | + order["filled"] = safe_value_nested(order, "info.order.filled", default_value=None) |
| 153 | + |
| 154 | + filled = self._safe_float(order.get("filled")) or 0.0 |
| 155 | + if order.get("status") in ("canceled", "closed") and filled > 0: |
| 156 | + # Compute VWAP and cost for filled orders. |
| 157 | + trades = self.get_trades_for_order( |
| 158 | + order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) |
| 159 | + ) |
| 160 | + if trades: |
| 161 | + total_amount = sum(t["amount"] for t in trades) |
| 162 | + if total_amount: |
| 163 | + # Compute VWAP |
| 164 | + order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount |
| 165 | + trade_costs = [t["cost"] for t in trades if t.get("cost") is not None] |
| 166 | + if trade_costs: |
| 167 | + order["cost"] = sum(trade_costs) |
| 168 | + return order |
| 169 | + |
| 170 | + def get_trades_for_order( |
| 171 | + self, order_id: str, pair: str, since: datetime, params: dict | None = None |
| 172 | + ) -> list: |
| 173 | + """Fetch trades and enrich with calculated fees. |
| 174 | +
|
| 175 | + Kraken Futures' /fills endpoint does not include fee amounts — only |
| 176 | + fillType (maker/taker). This enriches each trade with a calculated fee |
| 177 | + using the market's fee schedule so Freqtrade's fee detection works. |
| 178 | + """ |
| 179 | + trades = super().get_trades_for_order(order_id, pair, since, params) |
| 180 | + for trade in trades: |
| 181 | + if trade.get("fee") is None or trade["fee"].get("cost") is None: |
| 182 | + taker_or_maker = trade.get("takerOrMaker", "taker") |
| 183 | + symbol = trade.get("symbol", pair) |
| 184 | + market = self.markets.get(symbol, {}) |
| 185 | + fee_rate = market.get(taker_or_maker, market.get("taker", 0.0005)) |
| 186 | + cost = trade.get("cost") |
| 187 | + if cost is not None and fee_rate is not None: |
| 188 | + trade["fee"] = { |
| 189 | + "cost": cost * fee_rate, |
| 190 | + "currency": market.get("quote", "USD"), |
| 191 | + "rate": fee_rate, |
| 192 | + } |
| 193 | + return trades |
| 194 | + |
| 195 | + @retrier(retries=API_FETCH_ORDER_RETRY_COUNT) |
| 196 | + def fetch_order( |
| 197 | + self, order_id: str, pair: str, params: dict[str, Any] | None = None |
| 198 | + ) -> CcxtOrder: |
| 199 | + """Fetch order with direct CCXT call and fallback to history endpoints.""" |
| 200 | + if self._config.get("dry_run"): |
| 201 | + return self.fetch_dry_run_order(order_id) |
| 202 | + |
| 203 | + params = params or {} |
| 204 | + status_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} |
| 205 | + try: |
| 206 | + order = self._api.fetch_order(order_id, pair, params=status_params) |
| 207 | + self._log_exchange_response("fetch_order", order) |
| 208 | + return self._order_contracts_to_amount(order) |
| 209 | + except ccxt.OrderNotFound: |
| 210 | + # Expected for older Kraken Futures orders not visible in orders/status. |
| 211 | + pass |
| 212 | + except ccxt.DDoSProtection as e: |
| 213 | + raise DDosProtection(e) from e |
| 214 | + except ccxt.InvalidOrder as e: |
| 215 | + msg = f"Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}" |
| 216 | + raise InvalidOrderException(msg) from e |
| 217 | + except (ccxt.OperationFailed, ccxt.ExchangeError): |
| 218 | + # Fallback to history endpoints for temporary/status endpoint gaps. |
| 219 | + pass |
| 220 | + except ccxt.BaseError as e: |
| 221 | + raise OperationalException(e) from e |
| 222 | + |
| 223 | + order = self._fetch_order_fallback(order_id, pair, params) |
| 224 | + if order is not None: |
| 225 | + return order |
| 226 | + |
| 227 | + # Order not in status, open, closed, or canceled endpoints - genuinely gone. |
| 228 | + # Raise non-retrying InvalidOrderException (Kraken has limited history retention). |
| 229 | + raise InvalidOrderException( |
| 230 | + f"Order not found in any endpoint (pair: {pair} id: {order_id})" |
| 231 | + ) |
| 232 | + |
| 233 | + def _fetch_order_fallback( |
| 234 | + self, order_id: str, pair: str, params: dict[str, Any] |
| 235 | + ) -> CcxtOrder | None: |
| 236 | + """Search open, closed, and canceled order endpoints for order_id. |
| 237 | +
|
| 238 | + Kraken Futures' orders/status endpoint only returns currently open orders. |
| 239 | + Older orders require querying history endpoints (closed/canceled). |
| 240 | + For stoploss (trigger) orders, the caller should pass stop=True in params |
| 241 | + (handled automatically via stoploss_query_requires_stop_flag in _ft_has) |
| 242 | + so that closed/canceled queries hit the trigger history endpoint. |
| 243 | + """ |
| 244 | + order_id_str = str(order_id) |
| 245 | + |
| 246 | + # Open orders include triggers by default. Avoid passing trigger/stop flags |
| 247 | + # to prevent endpoint/filter mismatches. |
| 248 | + open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} |
| 249 | + order = self._find_order_in_list( |
| 250 | + self._api.fetch_open_orders, pair, open_params, order_id_str |
| 251 | + ) |
| 252 | + if order is not None: |
| 253 | + return order |
| 254 | + |
| 255 | + # Closed/canceled: pass params through (including stop=True for stoploss orders, |
| 256 | + # which CCXT maps to the trigger history endpoint). |
| 257 | + for fetch_fn in (self._api.fetch_closed_orders, self._api.fetch_canceled_orders): |
| 258 | + order = self._find_order_in_list(fetch_fn, pair, params, order_id_str) |
| 259 | + if order is not None: |
| 260 | + return order |
| 261 | + |
| 262 | + return None |
| 263 | + |
| 264 | + def _find_order_in_list( |
| 265 | + self, |
| 266 | + fetch_fn, |
| 267 | + symbol: str | None, |
| 268 | + params: dict[str, Any], |
| 269 | + order_id_str: str, |
| 270 | + ) -> CcxtOrder | None: |
| 271 | + """Fetch orders and return matching order_id, or None.""" |
| 272 | + try: |
| 273 | + orders = fetch_fn(symbol, params=params) or [] |
| 274 | + self._log_exchange_response(fetch_fn.__name__, orders) |
| 275 | + for order in orders: |
| 276 | + if str(order.get("id")) == order_id_str: |
| 277 | + self._log_exchange_response("fetch_order_fallback", order) |
| 278 | + |
| 279 | + return self._order_contracts_to_amount(order) |
| 280 | + except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e: |
| 281 | + logger.debug(f"{fetch_fn.__name__} failed: {e}") |
| 282 | + return None |
| 283 | + except ccxt.DDoSProtection as e: |
| 284 | + raise DDosProtection(e) from e |
| 285 | + except (ccxt.OperationFailed, ccxt.ExchangeError) as e: |
| 286 | + raise TemporaryError( |
| 287 | + f"Could not get order due to {e.__class__.__name__}. Message: {e}" |
| 288 | + ) from e |
| 289 | + except ccxt.BaseError as e: |
| 290 | + raise OperationalException(e) from e |
| 291 | + return None |
| 292 | + |
| 293 | + def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: |
| 294 | + """Fetch funding fees, returning 0.0 if retrieval fails.""" |
| 295 | + if self.trading_mode == TradingMode.FUTURES: |
| 296 | + try: |
| 297 | + return self._fetch_and_calculate_funding_fees(pair, amount, is_short, open_date) |
| 298 | + except ExchangeError: |
| 299 | + logger.warning(f"Could not update funding fees for {pair}.") |
| 300 | + return 0.0 |
0 commit comments