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