diff --git a/freqtrade/exchange/krakenfutures.py b/freqtrade/exchange/krakenfutures.py index a3eee37c7..d9e2315b2 100644 --- a/freqtrade/exchange/krakenfutures.py +++ b/freqtrade/exchange/krakenfutures.py @@ -1,6 +1,7 @@ """Kraken Futures exchange subclass""" import logging +from datetime import datetime from typing import Any import ccxt @@ -16,6 +17,7 @@ from freqtrade.exceptions import ( 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.util.datetime_helpers import dt_from_ts logger = logging.getLogger(__name__) @@ -151,6 +153,53 @@ class Krakenfutures(Exchange): order["stopPrice"] = trigger return order + def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder: + """Fix missing average price on filled orders by fetching trades. + + Kraken Futures' /orders/status endpoint does not include execution data, + so CCXT sets price/average to the limitPrice (the order's limit, not the + actual fill price). For closed/filled orders we fetch trades from /fills + and compute the VWAP average. + """ + if ( + order.get("average") is None + and order.get("status") in ("canceled", "closed") + and order.get("filled", 0) > 0 + ): + 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: + order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount + 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 @@ -164,7 +213,8 @@ class Krakenfutures(Exchange): 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) + order = self._order_contracts_to_amount(order) + return self._adjust_krakenfutures_order(order) except ccxt.OrderNotFound: # Expected for older Kraken Futures orders not visible in orders/status. pass @@ -181,7 +231,7 @@ class Krakenfutures(Exchange): order = self._fetch_order_fallback(order_id, pair, params) if order is not None: - return order + return self._adjust_krakenfutures_order(order) # Order not in status, open, closed, or canceled endpoints - genuinely gone. # Raise non-retrying InvalidOrderException (Kraken has limited history retention). diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index e62f33b4f..77e63ba83 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -4,7 +4,7 @@ from __future__ import annotations from copy import deepcopy from datetime import UTC, datetime -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock import ccxt import pytest @@ -21,6 +21,9 @@ 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 --- @@ -124,6 +127,192 @@ def test_krakenfutures_order_contracts_no_trigger_options(mocker, default_conf): assert result["stopPrice"] is None +# --- _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) + + +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_preserves_existing_average(mocker, default_conf): + """Don't overwrite average when already present.""" + 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_mock = mocker.patch.object(ex, "get_trades_for_order") + + result = ex._adjust_krakenfutures_order(order) + assert result["average"] == 67843.0 + trades_mock.assert_not_called() + + +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 ---