fix: compute average price from trades and enrich fees for Kraken Futures

Kraken Futures' /orders/status returns limitPrice (not fill price) and
/fills omits fee amounts (only fillType). This adds:

- _adjust_krakenfutures_order: fetches trades for closed/filled orders
  with average=None and computes VWAP average price.
- get_trades_for_order override: enriches trades with calculated fees
  from the market's maker/taker fee schedule.

Tests: 7 new tests covering VWAP computation, fee enrichment with
maker/taker rates, and preservation of existing values.
This commit is contained in:
matstedt
2026-02-17 21:03:06 +01:00
committed by Matthias
parent 0dfd7324bd
commit a1e9b6bea8
2 changed files with 242 additions and 3 deletions
+52 -2
View File
@@ -1,6 +1,7 @@
"""Kraken Futures exchange subclass""" """Kraken Futures exchange subclass"""
import logging import logging
from datetime import datetime
from typing import Any from typing import Any
import ccxt import ccxt
@@ -16,6 +17,7 @@ from freqtrade.exceptions import (
from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier
from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.exchange import Exchange
from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas
from freqtrade.util.datetime_helpers import dt_from_ts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -151,6 +153,53 @@ class Krakenfutures(Exchange):
order["stopPrice"] = trigger order["stopPrice"] = trigger
return order 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) @retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
def fetch_order( def fetch_order(
self, order_id: str, pair: str, params: dict[str, Any] | None = None self, order_id: str, pair: str, params: dict[str, Any] | None = None
@@ -164,7 +213,8 @@ class Krakenfutures(Exchange):
try: try:
order = self._api.fetch_order(order_id, pair, params=status_params) order = self._api.fetch_order(order_id, pair, params=status_params)
self._log_exchange_response("fetch_order", order) 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: except ccxt.OrderNotFound:
# Expected for older Kraken Futures orders not visible in orders/status. # Expected for older Kraken Futures orders not visible in orders/status.
pass pass
@@ -181,7 +231,7 @@ class Krakenfutures(Exchange):
order = self._fetch_order_fallback(order_id, pair, params) order = self._fetch_order_fallback(order_id, pair, params)
if order is not None: if order is not None:
return order return self._adjust_krakenfutures_order(order)
# Order not in status, open, closed, or canceled endpoints - genuinely gone. # Order not in status, open, closed, or canceled endpoints - genuinely gone.
# Raise non-retrying InvalidOrderException (Kraken has limited history retention). # Raise non-retrying InvalidOrderException (Kraken has limited history retention).
+190 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import MagicMock from unittest.mock import MagicMock, PropertyMock
import ccxt import ccxt
import pytest import pytest
@@ -21,6 +21,9 @@ from freqtrade.exchange.krakenfutures import Krakenfutures
from tests.conftest import EXMS, get_patched_exchange from tests.conftest import EXMS, get_patched_exchange
ExchangeBase = Krakenfutures.__mro__[1] # freqtrade.exchange.exchange.Exchange
# --- _ft_has and OHLCV tests --- # --- _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 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 --- # --- fetch_order fallback tests ---