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:
@@ -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).
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user