krakenfutures: simplify order/balance handling and harden error mapping
This commit is contained in:
+146
-140
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user