krakenfutures: simplify order/balance handling and harden error mapping

This commit is contained in:
matstedt
2026-02-06 19:28:50 +01:00
committed by Matthias
parent 756f178ec3
commit f81e335b27
2 changed files with 632 additions and 363 deletions
+146 -140
View File
@@ -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
+486 -223
View File
@@ -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