diff --git a/README.md b/README.md index 5dbcdb25a..60a6afaa6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Please read the [exchange-specific notes](https://www.freqtrade.io/en/stable/exc - [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX) - [X] [OKX](https://okx.com/) - [X] [Bybit](https://bybit.com/) +- [X] [Kraken](https://www.kraken.com/features/futures) Please make sure to read the [exchange specific notes](https://www.freqtrade.io/en/stable/exchanges/), as well as the [trading with leverage](https://www.freqtrade.io/en/stable/leverage/) documentation before diving in. diff --git a/docs/data-download.md b/docs/data-download.md index 4339e9862..47e9db5e9 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -269,6 +269,8 @@ If `--convert` is also provided, the resample step will happen automatically and !!! Note "Kraken user" Kraken users should read [this](exchanges.md#historic-kraken-data) before starting to download data. + Kraken Futures uses standard OHLCV downloads and does not require `--dl-trades`. + Example call: ```bash diff --git a/docs/exchanges.md b/docs/exchanges.md index 547814bfe..14edc11c6 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -217,6 +217,32 @@ freqtrade download-data --exchange kraken --dl-trades -p BTC/EUR BCH/EUR Please pay attention that rateLimit configuration entry holds delay in milliseconds between requests, NOT requests/sec rate. So, in order to mitigate Kraken API "Rate limit exceeded" exception, this configuration should be increased, NOT decreased. +## Kraken Futures + +Kraken Futures uses the exchange id `krakenfutures` and supports isolated futures mode. + +```jsonc +"exchange": { + "name": "krakenfutures", + "key": "your_exchange_key", + "secret": "your_exchange_secret" +}, +"trading_mode": "futures", +"margin_mode": "isolated", +"stake_currency": "USD" +``` + +!!! Tip "Stoploss on Exchange" + Kraken Futures supports `stoploss_on_exchange` with both `limit` and `market` stop orders. + Use `order_types.stoploss_price_type` to select the trigger price source (`mark`, `last`, or `index`). + +!!! Note "Collateral" + Kraken Futures is USD-settled. Use USD as your stake currency. + +!!! Note "Flex (Multi-collateral) Accounts" + Kraken Futures flex accounts allow collateral in multiple currencies, while trading remains USD-settled. + Freqtrade derives the `USD` balance from Kraken margin fields, so keep `stake_currency` set to `USD`. + ## Kucoin Kucoin requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows: diff --git a/docs/index.md b/docs/index.md index 8ecfd4320..2e1672e0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual, - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX) - [X] [OKX](https://okx.com/) +- [X] [Kraken](https://www.kraken.com/features/futures) Please make sure to read the [exchange specific notes](exchanges.md), as well as the [trading with leverage](leverage.md) documentation before diving in. diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 709738efc..cb60b3f93 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -44,6 +44,7 @@ from freqtrade.exchange.htx import Htx from freqtrade.exchange.hyperliquid import Hyperliquid from freqtrade.exchange.idex import Idex from freqtrade.exchange.kraken import Kraken +from freqtrade.exchange.krakenfutures import Krakenfutures from freqtrade.exchange.kucoin import Kucoin from freqtrade.exchange.lbank import Lbank from freqtrade.exchange.luno import Luno diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index 7f53c39ef..e7c1fbbbb 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -39,7 +39,6 @@ BAD_EXCHANGES = { "bitmex": "Various reasons", "probit": "Requires additional, regular calls to `signIn()`", "poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders", - "krakenfutures": "Unsupported futures exchange", "kucoinfutures": "Unsupported futures exchange", "poloniexfutures": "Unsupported futures exchange", "binancecoinm": "Unsupported futures exchange", @@ -63,6 +62,7 @@ SUPPORTED_EXCHANGES = [ "htx", "hyperliquid", "kraken", + "krakenfutures", "okx", "myokx", ] diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py new file mode 100644 index 000000000..755cdfadd --- /dev/null +++ b/freqtrade/exchange/krakenfutures.py @@ -0,0 +1,300 @@ +"""Kraken Futures exchange subclass""" + +import logging +from datetime import datetime +from typing import Any + +import ccxt + +from freqtrade.enums import MarginMode, PriceType, TradingMode +from freqtrade.exceptions import ( + DDosProtection, + ExchangeError, + InvalidOrderException, + OperationalException, + TemporaryError, +) +from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier +from freqtrade.exchange.exchange import Exchange +from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas +from freqtrade.misc import safe_value_nested +from freqtrade.util.datetime_helpers import dt_from_ts + + +logger = logging.getLogger(__name__) + + +class Krakenfutures(Exchange): + """Kraken Futures exchange class. + + Contains adjustments needed for Freqtrade to work with this exchange. + + Key differences from spot Kraken: + - Stop orders use triggerPrice/triggerSignal instead of stopPrice + - Flex (multi-collateral) accounts need USD balance synthesis + """ + + _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ + (TradingMode.FUTURES, MarginMode.ISOLATED), + ] + + _ft_has: FtHas = { + "tickers_have_quoteVolume": False, + "stoploss_on_exchange": True, + "stoploss_order_types": { + "limit": "limit", + "market": "market", + }, + "stoploss_query_requires_stop_flag": True, + "stop_price_param": "triggerPrice", + "stop_price_prop": "stopPrice", + "stop_price_type_field": "triggerSignal", + "stop_price_type_value_mapping": { + PriceType.LAST: "last", + PriceType.MARK: "mark", + PriceType.INDEX: "index", + }, + "exchange_has_overrides": {"fetchOrders": False}, + } + + @retrier + def get_balances(self, params: dict | None = None) -> CcxtBalances: + """ + 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 + """ + try: + 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": + # Only synthesize if USD stake - flex only applies for these currencies. + # 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_value = usd_free if usd_free is not None else usd_total + usd_total_value = usd_total if usd_total is not None else usd_free + if usd_free_value is not None and usd_total_value is not None: + usd_used = max(0.0, usd_total_value - usd_free_value) + balances["USD"] = { + "free": usd_free_value, + "used": usd_used, + "total": usd_total_value, + } + + # 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 _safe_float(value: Any) -> float | None: + """Convert value to float, returning None if conversion fails.""" + if value is None: + return None + try: + return float(value) + except (ValueError, TypeError): + return None + + def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder: + """Normalize order and apply Kraken Futures-specific order corrections.""" + order = super()._order_contracts_to_amount(order) + return self._adjust_krakenfutures_order(order) + + def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: + """Apply Kraken Futures-specific order corrections. + + For filled terminal orders, always fetch trades and compute VWAP because + CCXT's average is still unreliable. + + See: https://github.com/ccxt/ccxt/issues/27996 + """ + if order.get("status") == "canceled" and order.get("filled") is None: + # Workaround for missing filled parsing - https://github.com/ccxt/ccxt/issues/28210 + order["filled"] = safe_value_nested(order, "info.order.filled", default_value=None) + + filled = self._safe_float(order.get("filled")) or 0.0 + if order.get("status") in ("canceled", "closed") and filled > 0: + # Compute VWAP and cost for filled orders. + trades = self.get_trades_for_order( + order["id"], order["symbol"], since=dt_from_ts(order["timestamp"]) + ) + if trades: + total_amount = sum(t["amount"] for t in trades) + if total_amount: + # Compute VWAP + order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount + trade_costs = [t["cost"] for t in trades if t.get("cost") is not None] + if trade_costs: + order["cost"] = sum(trade_costs) + return order + + def get_trades_for_order( + self, order_id: str, pair: str, since: datetime, params: dict | None = None + ) -> list: + """Fetch trades and enrich with calculated fees. + + Kraken Futures' /fills endpoint does not include fee amounts — only + fillType (maker/taker). This enriches each trade with a calculated fee + using the market's fee schedule so Freqtrade's fee detection works. + """ + trades = super().get_trades_for_order(order_id, pair, since, params) + for trade in trades: + if trade.get("fee") is None or trade["fee"].get("cost") is None: + taker_or_maker = trade.get("takerOrMaker", "taker") + symbol = trade.get("symbol", pair) + market = self.markets.get(symbol, {}) + fee_rate = market.get(taker_or_maker, market.get("taker", 0.0005)) + cost = trade.get("cost") + if cost is not None and fee_rate is not None: + trade["fee"] = { + "cost": cost * fee_rate, + "currency": market.get("quote", "USD"), + "rate": fee_rate, + } + return trades + + @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 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 {} + status_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} + try: + order = self._api.fetch_order(order_id, pair, params=status_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: + return order + + # Order not in status, open, closed, or canceled endpoints - genuinely gone. + # Raise non-retrying InvalidOrderException (Kraken has limited history retention). + raise InvalidOrderException( + f"Order not found in any endpoint (pair: {pair} id: {order_id})" + ) + + def _fetch_order_fallback( + self, order_id: str, pair: str, params: dict[str, Any] + ) -> CcxtOrder | None: + """Search open, closed, and canceled order endpoints for order_id. + + Kraken Futures' orders/status endpoint only returns currently open orders. + Older orders require querying history endpoints (closed/canceled). + For stoploss (trigger) orders, the caller should pass stop=True in params + (handled automatically via stoploss_query_requires_stop_flag in _ft_has) + so that closed/canceled queries hit the trigger history endpoint. + """ + order_id_str = str(order_id) + + # Open orders include triggers by default. Avoid passing trigger/stop flags + # to prevent endpoint/filter mismatches. + open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")} + order = self._find_order_in_list( + self._api.fetch_open_orders, pair, open_params, order_id_str + ) + if order is not None: + return order + + # Closed/canceled: pass params through (including stop=True for stoploss orders, + # which CCXT maps to the trigger history endpoint). + 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 + + return None + + def _find_order_in_list( + self, + fetch_fn, + symbol: str | None, + params: dict[str, Any], + order_id_str: str, + ) -> CcxtOrder | None: + """Fetch orders and return matching order_id, or None.""" + try: + orders = fetch_fn(symbol, params=params) or [] + self._log_exchange_response(fetch_fn.__name__, orders) + for order in orders: + if str(order.get("id")) == order_id_str: + self._log_exchange_response("fetch_order_fallback", order) + + return self._order_contracts_to_amount(order) + 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 + + def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float: + """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 diff --git a/requirements.txt b/requirements.txt index 3ae4b2ddc..166505a08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16 ta-lib==0.6.8 technical==1.5.4 -ccxt==4.5.43 +ccxt==4.5.44 cryptography==46.0.5 aiohttp==3.13.3 SQLAlchemy==2.0.48 diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 7deec18ca..3f5e846c7 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -210,7 +210,8 @@ def test_list_exchanges(capsys): captured = capsys.readouterr() assert re.search(r"Exchanges available for Freqtrade.*", captured.out) assert re.search(r".*binance.*", captured.out) - assert not re.search(r".*kraken.*", captured.out) + assert re.search(r"\bkrakenfutures\b", captured.out) + assert not re.search(r"\bmyokx\b", captured.out) def test_list_timeframes(mocker, capsys): diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py new file mode 100644 index 000000000..5b0b3a813 --- /dev/null +++ b/tests/exchange/test_krakenfutures.py @@ -0,0 +1,910 @@ +"""Tests for Kraken Futures exchange class""" + +from copy import deepcopy +from datetime import UTC, datetime +from unittest.mock import MagicMock, PropertyMock + +import ccxt +import pytest + +from freqtrade.enums import MarginMode, TradingMode +from freqtrade.exceptions import ( + DDosProtection, + ExchangeError, + InvalidOrderException, + OperationalException, + TemporaryError, +) +from freqtrade.exchange.krakenfutures import Krakenfutures +from tests.conftest import EXMS, get_patched_exchange + + +ExchangeBase = Krakenfutures.__mro__[1] # freqtrade.exchange.exchange.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 + assert ft_has["stoploss_on_exchange"] is True + assert ft_has["stoploss_order_types"] == {"limit": "limit", "market": "market"} + assert ft_has["stoploss_query_requires_stop_flag"] is True + assert ft_has["stop_price_prop"] == "stopPrice" + assert ft_has["stop_price_param"] == "triggerPrice" + assert ft_has["stop_price_type_field"] == "triggerSignal" + + +# --- _adjust_krakenfutures_order average price tests --- + + +def test_krakenfutures_adjust_order_computes_average_from_trades(mocker, default_conf): + """Compute VWAP average price from trades when CCXT returns None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": 0.0004, + "average": None, + "timestamp": 1771354195241, + } + trades = [ + { + "amount": 0.0002, + "price": 67800.0, + "cost": 13.56, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + { + "amount": 0.0002, + "price": 67900.0, + "cost": 13.58, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + ] + mocker.patch.object(ex, "get_trades_for_order", return_value=trades) + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] == pytest.approx(67850.0) + assert result["cost"] == pytest.approx(27.14) + + +def test_krakenfutures_adjust_order_skips_open_orders(mocker, default_conf): + """Don't fetch trades for open orders.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "open", + "filled": 0, + "average": None, + "timestamp": 1771354195241, + } + trades_mock = mocker.patch.object(ex, "get_trades_for_order") + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] is None + trades_mock.assert_not_called() + + +def test_krakenfutures_adjust_order_handles_none_filled(mocker, default_conf): + """Don't crash or fetch trades when filled is None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": None, + "average": None, + "timestamp": 1771354195241, + } + trades_mock = mocker.patch.object(ex, "get_trades_for_order") + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] is None + trades_mock.assert_not_called() + + +def test_krakenfutures_adjust_order_recomputes_existing_average(mocker, default_conf): + """Recompute average from fills even when CCXT already provided one.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": 0.0004, + "average": 67843.0, + "timestamp": 1771354195241, + } + trades = [ + { + "amount": 0.0002, + "price": 67800.0, + "cost": 13.56, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + { + "amount": 0.0002, + "price": 67900.0, + "cost": 13.58, + "takerOrMaker": "taker", + "symbol": "BTC/USD:USD", + "fee": None, + }, + ] + trades_mock = mocker.patch.object(ex, "get_trades_for_order", return_value=trades) + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] == pytest.approx(67850.0) + assert result["cost"] == pytest.approx(27.14) + trades_mock.assert_called_once() + + +def test_krakenfutures_adjust_order_no_trades_found(mocker, default_conf): + """Leave average as None when no trades are found.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + order = { + "id": "abc", + "symbol": "BTC/USD:USD", + "status": "closed", + "filled": 0.0004, + "average": None, + "timestamp": 1771354195241, + } + mocker.patch.object(ex, "get_trades_for_order", return_value=[]) + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] is None + + +# --- get_trades_for_order fee enrichment tests --- + + +def test_krakenfutures_get_trades_enriches_fees(mocker, default_conf): + """Calculate fees from market fee schedule when CCXT returns fee: None.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + raw_trades = [ + { + "amount": 0.0004, + "price": 67843.0, + "cost": 27.14, + "order": "abc", + "symbol": "BTC/USD:USD", + "takerOrMaker": "taker", + "fee": {"cost": None, "currency": None}, + }, + ] + mocker.patch.object( + ExchangeBase, + "get_trades_for_order", + return_value=raw_trades, + ) + # Re-patch markets property with fee rates for BTC/USD:USD + kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}} + mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets)) + + result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock()) + assert len(result) == 1 + assert result[0]["fee"]["cost"] == pytest.approx(27.14 * 0.0005) + assert result[0]["fee"]["currency"] == "USD" + assert result[0]["fee"]["rate"] == 0.0005 + + +def test_krakenfutures_get_trades_uses_maker_rate(mocker, default_conf): + """Use maker fee rate when fillType is maker.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + raw_trades = [ + { + "amount": 0.0004, + "price": 67843.0, + "cost": 27.14, + "order": "abc", + "symbol": "BTC/USD:USD", + "takerOrMaker": "maker", + "fee": None, + }, + ] + mocker.patch.object( + ExchangeBase, + "get_trades_for_order", + return_value=raw_trades, + ) + kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}} + mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets)) + + result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock()) + assert result[0]["fee"]["cost"] == pytest.approx(27.14 * 0.0002) + assert result[0]["fee"]["rate"] == 0.0002 + + +def test_krakenfutures_get_trades_preserves_existing_fees(mocker, default_conf): + """Don't overwrite fees if CCXT already provided them.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + existing_fee = {"cost": 0.01, "currency": "USD", "rate": 0.0005} + raw_trades = [ + { + "amount": 0.0004, + "price": 67843.0, + "cost": 27.14, + "order": "abc", + "symbol": "BTC/USD:USD", + "takerOrMaker": "taker", + "fee": existing_fee, + }, + ] + mocker.patch.object( + ExchangeBase, + "get_trades_for_order", + return_value=raw_trades, + ) + kf_markets = {"BTC/USD:USD": {"taker": 0.0005, "maker": 0.0002, "quote": "USD"}} + mocker.patch.object(type(ex), "markets", PropertyMock(return_value=kf_markets)) + + result = ex.get_trades_for_order("abc", "BTC/USD:USD", since=MagicMock()) + # Should keep existing fee, not recalculate + assert result[0]["fee"] == existing_fee + + +# --- 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") + + mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found")) + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object( + ex._api, + "fetch_closed_orders", + return_value=[{"id": "abc", "symbol": "BTC/USD:USD", "status": "closed"}], + create=True, + ) + closed_fetch.__name__ = "fetch_closed_orders" + + res = ex.fetch_order("abc", "BTC/USD:USD") + assert res["id"] == "abc" + + +def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default_conf): + """Fallback to fetch_canceled_orders when closed orders don't contain the order.""" + 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.ExchangeError("UUID too large")) + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( + ex._api, + "fetch_canceled_orders", + return_value=[{"id": "def", "symbol": "BTC/USD:USD", "status": "canceled"}], + create=True, + ) + canceled_fetch.__name__ = "fetch_canceled_orders" + + res = ex.fetch_order("def", "BTC/USD:USD") + 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_strips_stop_from_status_query(mocker, default_conf): + """Direct CCXT fetch_order status lookup should not receive stop/trigger params.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + ccxt_order = {"id": "order-1", "symbol": "BTC/USD:USD", "status": "open"} + fetch_order = mocker.patch.object(ex._api, "fetch_order", return_value=ccxt_order) + + # Simulate call from base fetch_stoploss_order which adds stop=True + ex.fetch_order("order-1", "BTC/USD:USD", params={"stop": True}) + + # stop should be stripped from the direct CCXT status call + fetch_order.assert_called_once_with("order-1", "BTC/USD:USD", params={}) + + +def test_krakenfutures_fetch_order_raises_invalid_when_not_found(mocker, default_conf): + """Raise InvalidOrderException (non-retrying) when order is not in any endpoint.""" + 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, "_fetch_order_fallback", return_value=None) + + with pytest.raises(InvalidOrderException, match="Order not found in any endpoint"): + 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 order is not found in any endpoint.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + canceled_fetch.__name__ = "fetch_canceled_orders" + + 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, + ) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + canceled_fetch.__name__ = "fetch_canceled_orders" + + 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) + conf["dry_run"] = True + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + dry_order = {"id": "dry-123", "status": "open"} + mocker.patch.object(ex, "fetch_dry_run_order", return_value=dry_order) + + res = ex.fetch_order("dry-123", "BTC/USD:USD") + assert res["id"] == "dry-123" + + +def test_krakenfutures_fetch_order_finds_stoploss_via_stop_param(mocker, default_conf): + """Test fetch_order finds stoploss orders via closed orders fallback with stop=True.""" + 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")) + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + # With stop=True, CCXT queries trigger history endpoint + closed_fetch = mocker.patch.object( + ex._api, + "fetch_closed_orders", + return_value=[ + {"id": "trigger-123", "symbol": "BTC/USD:USD", "status": "closed"}, + ], + create=True, + ) + closed_fetch.__name__ = "fetch_closed_orders" + + # Simulate what base class fetch_stoploss_order does (adds stop=True) + res = ex.fetch_order("trigger-123", "BTC/USD:USD", params={"stop": True}) + assert res["id"] == "trigger-123" + + +def test_krakenfutures_fetch_order_fallback_passes_stop_to_history(mocker, default_conf): + """Stoploss query (stop=True) should pass through to closed/canceled endpoints.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + + closed_order = {"id": "sl-123", "symbol": "BTC/USD:USD", "status": "closed"} + closed_fetch = mocker.patch.object( + ex._api, + "fetch_closed_orders", + return_value=[closed_order], + create=True, + ) + closed_fetch.__name__ = "fetch_closed_orders" + + res = ex._fetch_order_fallback("sl-123", "BTC/USD:USD", {"stop": True}) + + assert res is not None + assert res["id"] == "sl-123" + # Verify stop=True was passed to closed orders (CCXT maps stop→trigger) + closed_fetch.assert_called_once_with("BTC/USD:USD", params={"stop": True}) + + +def test_krakenfutures_fetch_order_fallback_strips_stop_from_open_orders(mocker, default_conf): + """Open orders query should not receive stop/trigger flags.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + open_fetch = mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True) + open_fetch.__name__ = "fetch_open_orders" + closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True) + closed_fetch.__name__ = "fetch_closed_orders" + canceled_fetch = mocker.patch.object( + ex._api, "fetch_canceled_orders", return_value=[], create=True + ) + canceled_fetch.__name__ = "fetch_canceled_orders" + + ex._fetch_order_fallback("abc", "BTC/USD:USD", {"stop": True}) + + # stop should be stripped from open orders call + open_fetch.assert_called_once_with("BTC/USD:USD", params={}) + + +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")) + open_fetch = mocker.patch.object( + ex._api, "fetch_open_orders", side_effect=ccxt.ExchangeError("service unavailable") + ) + open_fetch.__name__ = "fetch_open_orders" + + 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() + api_mock.create_order = MagicMock(return_value={"id": "order-id", "info": {"foo": "bar"}}) + + conf = deepcopy(default_conf) + conf["dry_run"] = False + conf["trading_mode"] = TradingMode.FUTURES + conf["margin_mode"] = MarginMode.ISOLATED + + mocker.patch(f"{EXMS}.amount_to_precision", lambda s, x, y: y) + mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y) + + ex = get_patched_exchange(mocker, conf, api_mock, exchange="krakenfutures") + + ex.create_stoploss( + pair="ETH/BTC", + amount=1, + stop_price=90000.0, + side="sell", + order_types={"stoploss": "market", "stoploss_price_type": "mark"}, + leverage=1.0, + ) + + call_args = api_mock.create_order.call_args + params = call_args[1].get("params") if call_args[1] else call_args[0][5] + + assert params["triggerPrice"] == 90000.0 + assert params["triggerSignal"] == "mark" + assert params["reduceOnly"] is True + + +# --- Funding fees tests --- + + +def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): + """Use funding fee helper in futures mode.""" + conf = dict(default_conf) + conf["trading_mode"] = TradingMode.FUTURES + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + helper = mocker.patch.object(ex, "_fetch_and_calculate_funding_fees", return_value=1.23) + open_date = datetime.now(UTC) + + assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, open_date) == 1.23 + helper.assert_called_once_with("BTC/USD:USD", 0.1, False, open_date) + + +def test_krakenfutures_get_funding_fees_futures_exchange_error(mocker, default_conf): + """Return 0.0 when funding fee retrieval fails.""" + conf = dict(default_conf) + conf["trading_mode"] = TradingMode.FUTURES + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + mocker.patch.object(ex, "_fetch_and_calculate_funding_fees", side_effect=ExchangeError("fail")) + + assert ex.get_funding_fees("BTC/USD:USD", 0.1, False, None) == 0.0 + + +def test_krakenfutures_get_funding_fees_spot_returns_zero(mocker, default_conf): + """Return 0.0 outside futures mode without calling the helper.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + helper = mocker.patch.object(ex, "_fetch_and_calculate_funding_fees") + + 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 via base class (stoploss_query_requires_stop_flag) --- + + +def test_krakenfutures_fetch_stoploss_order_uses_base_class(mocker, default_conf): + """Base class fetch_stoploss_order should add stop=True and delegate to fetch_order.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + expected_order = {"id": "sl-order-1", "status": "open", "info": {}} + fetch_order = mocker.patch.object(ex, "fetch_order", return_value=expected_order) + + result = ex.fetch_stoploss_order("sl-order-1", "BTC/USD:USD") + + assert result["id"] == "sl-order-1" + # Base class should pass stop=True + fetch_order.assert_called_once() + call_params = fetch_order.call_args[0][2] if len(fetch_order.call_args[0]) > 2 else {} + assert call_params.get("stop") is True + + +def test_krakenfutures_cancel_stoploss_order_uses_base_class(mocker, default_conf): + """Base class cancel_stoploss_order should add stop=True and delegate to cancel_order.""" + conf = dict(default_conf) + conf["dry_run"] = False + ex = get_patched_exchange(mocker, conf, exchange="krakenfutures") + + expected_order = {"id": "sl-cancel-1", "status": "canceled"} + cancel_order = mocker.patch.object(ex, "cancel_order", return_value=expected_order) + + result = ex.cancel_stoploss_order("sl-cancel-1", "BTC/USD:USD") + + assert result["id"] == "sl-cancel-1" + # Base class should pass stop=True + cancel_order.assert_called_once() + call_params = cancel_order.call_args[0][2] if len(cancel_order.call_args[0]) > 2 else {} + assert call_params.get("stop") is True diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index efc013297..ba04598cd 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -18,6 +18,7 @@ class TestExchangeOnlineSetup(TypedDict): timeframe: str candle_count: int futures: bool + futures_only: bool | None futures_pair: str | None candle_count_futures: int | None hasQuoteVolumeFutures: bool | None @@ -559,9 +560,137 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = { # TODO: re-enable hyperliquid websocket tests "skip_ws_tests": True, }, + "krakenfutures": { + "pair": "BTC/USD:USD", + "stake_currency": "USD", + "hasQuoteVolume": False, + "skip_ws_tests": True, + "timeframe": "1h", + "futures": True, + "futures_only": True, + "candle_count": 2000, + "futures_pair": "BTC/USD:USD", + "hasQuoteVolumeFutures": False, + "leverage_tiers_public": True, + "sample_order_futures": [ + { + # Regular market order + "exchange_response": { + "uid": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e", + "accountUid": "cabdb242-5111-4dac-bac-76f33395d76d", + "tradeable": "PF_XBTUSD", + "direction": "Sell", + "quantity": "0", + "filled": "0.0004", + "timestamp": 1771354195241, + "limitPrice": "67164.00", + "orderType": "IoC", + "clientId": "", + "reduceOnly": False, + "lastUpdateTimestamp": 1771354195241, + "regulatoryExternalUid": "ae198dd6-6be0-4014-8af-ebd472190648", + "status": "closed", + }, + "pair": "BTC/USD:USD", + "expected": { + "symbol": "BTC/USD:USD", + "id": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e", + "timestamp": 1771354195241, + "datetime": "2026-02-17T18:49:55.241Z", + "price": None, + # Average should be None (it's not correct for market orders) + "average": None, + "status": "closed", + "type": "market", + "amount": 0.0004, + "side": "sell", + "triggerPrice": None, + "stopPrice": None, + "stopLossPrice": None, + }, + }, + { + # Trigger order + "exchange_response": { + "order": { + "type": "TRIGGER_ORDER", + "orderId": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e", + "cliOrdId": None, + "symbol": "PF_XBTUSD", + "side": "buy", + "quantity": 0.0004, + "limitPrice": 71712, + "reduceOnly": True, + "timestamp": "2026-02-17T16:26:02.918Z", + "lastUpdateTimestamp": "2026-02-17T16:26:02.918Z", + "priceTriggerOptions": { + "triggerPrice": 71641, + "triggerSignal": "LAST_PRICE", + "triggerSide": "TRIGGER_ABOVE", + "limitPriceOffsetValue": None, + "limitPriceOffsetUnit": None, + }, + }, + "status": "TRIGGER_PLACED", + "updateReason": None, + "error": None, + }, + "pair": "BTC/USD:USD", + "expected": { + "symbol": "BTC/USD:USD", + "id": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e", + "timestamp": 1771345562918, + "datetime": "2026-02-17T16:26:02.918Z", + # TODO: re-verify this ... + # "price": 71712.0, + "price": None, + "status": "open", + "amount": 0.0004, + "side": "buy", + "triggerPrice": 71641.0, + "stopPrice": 71641.0, + # krakenfutures uses stopPrice - so this is fine. + "stopLossPrice": None, + }, + }, + { + # Canceled order + "exchange_response": { + "order": { + "type": "ORDER", + "orderId": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e", + "cliOrdId": None, + "symbol": "PF_XBTUSD", + "side": "buy", + "quantity": 0.0022, + "filled": 0, + "limitPrice": 68000, + "reduceOnly": False, + "timestamp": "2026-03-21T07:32:21.555Z", + "lastUpdateTimestamp": "2026-03-21T07:32:21.555Z", + }, + "status": "CANCELLED", + "updateReason": "CANCELLED_BY_USER", + "error": None, + }, + "pair": "BTC/USD:USD", + "expected": { + "symbol": "BTC/USD:USD", + "id": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e", + "timestamp": 1774078341555, + "datetime": "2026-03-21T07:32:21.555Z", + "price": None, + "status": "canceled", + # TODO: filled should be 0, not None. + "filled": None, + }, + }, + ], + }, } EXCHANGES_FUTURES = [exch for exch, params in EXCHANGES.items() if params.get("futures")] +EXCHANGES_SPOT = [exch for exch, params in EXCHANGES.items() if not params.get("futures_only")] @pytest.fixture(scope="class") @@ -591,11 +720,12 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config: return config -def get_exchange(exchange_name, exchange_conf): +def get_exchange(exchange_name, exchange_conf, class_mocker): exchange_params = EXCHANGES[exchange_name] exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) exchange_conf["exchange"]["name"] = exchange_name exchange_conf["stake_currency"] = exchange_params["stake_currency"] + class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") exchange = ExchangeResolver.load_exchange( exchange_conf, validate=True, load_leverage_tiers=True ) @@ -608,25 +738,28 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker): if exchange_params.get("futures") is not True: pytest.skip(f"Exchange {exchange_name} does not support futures.") - else: - exchange_conf = deepcopy(exchange_conf) - exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) - exchange_conf["trading_mode"] = "futures" - exchange_conf["margin_mode"] = "isolated" + exchange_conf = deepcopy(exchange_conf) + exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False)) + exchange_conf["exchange"]["name"] = exchange_name + exchange_conf["stake_currency"] = exchange_params["stake_currency"] + exchange_conf["trading_mode"] = "futures" + exchange_conf["margin_mode"] = "isolated" - class_mocker.patch("freqtrade.exchange.binance.Binance.fill_leverage_tiers") - class_mocker.patch(f"{EXMS}.fetch_trading_fees") - class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") - class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None) - class_mocker.patch(f"{EXMS}.cache_leverage_tiers") - - return get_exchange(exchange_name, exchange_conf) - - -@pytest.fixture(params=EXCHANGES, scope="class") -def exchange(request, exchange_conf, class_mocker): + class_mocker.patch("freqtrade.exchange.binance.Binance.fill_leverage_tiers") + class_mocker.patch(f"{EXMS}.fetch_trading_fees") class_mocker.patch(f"{EXMS}.ft_additional_exchange_init") - exchange, name, exchange_params = get_exchange(request.param, exchange_conf) + class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None) + class_mocker.patch(f"{EXMS}.cache_leverage_tiers") + + exchange = ExchangeResolver.load_exchange( + exchange_conf, validate=True, load_leverage_tiers=True + ) + return exchange, exchange_name, exchange_params + + +@pytest.fixture(params=EXCHANGES_SPOT, scope="class") +def exchange(request, exchange_conf, class_mocker): + exchange, name, exchange_params = get_exchange(request.param, exchange_conf, class_mocker) yield exchange, name, exchange_params exchange.close() @@ -647,13 +780,12 @@ def exchange_mode(request): @pytest.fixture(params=EXCHANGES, scope="class") def exchange_ws(request, exchange_conf, exchange_mode, class_mocker): - class_mocker.patch("freqtrade.exchange.bybit.Bybit.additional_exchange_init") exchange_conf["exchange"]["enable_ws"] = True exchange_param = EXCHANGES[request.param] if exchange_param.get("skip_ws_tests"): pytest.skip(f"{request.param} does not support websocket tests.") if exchange_mode == "spot": - exchange, name, _ = get_exchange(request.param, exchange_conf) + exchange, name, _ = get_exchange(request.param, exchange_conf, class_mocker) pair = exchange_param["pair"] elif exchange_param.get("futures"): exchange, name, _ = get_futures_exchange( diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index f33818d3d..c44bb216a 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -101,6 +101,30 @@ class TestCCXTExchange: else: pytest.skip(f"No sample order available for exchange {exchangename}") + def test_ccxt_order_parse_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): + exch, exchangename, exchange_params = exchange_futures + if orders := exchange_params.get("sample_order_futures"): + for order in orders: + pair = order["pair"] + exchange_response: dict = order["exchange_response"] + + market = exch._api.markets[pair] + po = exch._api.parse_order(exchange_response, market) + expected = order["expected"] + assert isinstance(po["id"], str) + assert po["id"] is not None + + # Generic comparison which works for all fields + for key, value in expected.items(): + assert key in po, f"Expected key {key} not found in parsed order" + assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}" + assert isinstance(po[key], type(value)), ( + f"Expected {key} to be of type {type(value)}, got {type(po[key])}" + ) + + else: + pytest.skip(f"No sample order available for exchange {exchangename}") + def test_ccxt_my_trades_parse(self, exchange: EXCHANGE_FIXTURE_TYPE): exch, exchangename, exchange_params = exchange if trades := exchange_params.get("sample_my_trades"):