pull/12706/merge
matstedt 1 day ago committed by GitHub
commit 3c70749231
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -49,6 +49,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.

@ -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

@ -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:

@ -60,6 +60,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.

@ -43,6 +43,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

@ -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",
]

@ -158,11 +158,11 @@ class Kraken(Exchange):
time_in_ratio: float | None = None,
) -> float:
"""
# ! This method will always error when run by Freqtrade because time_in_ratio is never
# ! passed to _get_funding_fee. For kraken futures to work in dry run and backtesting
# ! functionality must be added that passes the parameter time_in_ratio to
# ! _get_funding_fee when using Kraken
calculates the sum of all funding fees that occurred for a pair during a futures trade
Kraken uses a ratio-adjusted funding calculation and therefore requires
`time_in_ratio` to be provided by the caller.
:param df: Dataframe containing combined funding and mark rates
as `open_fund` and `open_mark`.
:param amount: The quantity of the trade

@ -0,0 +1,309 @@
"""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.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",
},
# Kraken Futures retains only 29 days of hourly funding rate history.
"funding_fee_candle_limit": 700,
}
@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 fix missing trigger price from CCXT parsing.
CCXT's krakenfutures parse_order reads triggerPrice from the top level of
the order details, but the /orders/status endpoint nests it inside
priceTriggerOptions.triggerPrice. This extracts it so stopPrice/triggerPrice
are populated correctly for stoploss order handling.
"""
order = super()._order_contracts_to_amount(order)
if order.get("triggerPrice") is None and order.get("stopPrice") is None:
info = order.get("info", {})
inner = info.get("order", {}) if isinstance(info, dict) else {}
opts = inner.get("priceTriggerOptions", {}) if isinstance(inner, dict) else {}
trigger = self._safe_float(opts.get("triggerPrice")) if isinstance(opts, dict) else None
if trigger is not None:
order["triggerPrice"] = trigger
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
) -> 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)
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
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 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).
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, None, 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

@ -186,7 +186,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):

@ -0,0 +1,962 @@
"""Tests for Kraken Futures exchange class"""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from unittest.mock import MagicMock, PropertyMock
import ccxt
import pytest
from freqtrade.enums import CandleType, 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_param"] == "triggerPrice"
assert ft_has["stop_price_type_field"] == "triggerSignal"
# Kraken retains only ~29 days of hourly funding rate history
assert ft_has["funding_fee_candle_limit"] == 700
def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf):
"""Test that OHLCV candle limit follows CCXT feature limit."""
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
assert isinstance(ex, Krakenfutures)
mocker.patch.object(ex, "features", return_value=2000)
assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000
def test_krakenfutures_ohlcv_candle_limit_funding_rate(mocker, default_conf):
"""Funding rate candle limit is capped to reflect Kraken's limited history retention."""
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
mocker.patch.object(ex, "features", return_value=2000)
assert ex.ohlcv_candle_limit("1h", candle_type=CandleType.FUNDING_RATE) == 700
# --- _order_contracts_to_amount trigger price fix tests ---
def test_krakenfutures_order_contracts_fixes_missing_trigger_price(mocker, default_conf):
"""Extract triggerPrice from info.order.priceTriggerOptions when CCXT misses it."""
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
order = {
"id": "abc",
"symbol": "BTC/USD:USD",
"triggerPrice": None,
"stopPrice": None,
"info": {
"order": {
"type": "TRIGGER_ORDER",
"priceTriggerOptions": {
"triggerPrice": 71641,
"triggerSignal": "LAST_PRICE",
},
},
"status": "TRIGGER_PLACED",
},
}
result = ex._order_contracts_to_amount(order)
assert result["triggerPrice"] == 71641.0
assert result["stopPrice"] == 71641.0
def test_krakenfutures_order_contracts_preserves_existing_trigger_price(mocker, default_conf):
"""Don't overwrite triggerPrice when CCXT already parsed it correctly."""
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
order = {
"id": "abc",
"symbol": "BTC/USD:USD",
"triggerPrice": 70000.0,
"stopPrice": 70000.0,
"info": {
"order": {
"priceTriggerOptions": {
"triggerPrice": 71641,
},
},
},
}
result = ex._order_contracts_to_amount(order)
assert result["triggerPrice"] == 70000.0
assert result["stopPrice"] == 70000.0
def test_krakenfutures_order_contracts_no_trigger_options(mocker, default_conf):
"""Regular (non-trigger) orders should pass through unchanged."""
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
order = {
"id": "abc",
"symbol": "BTC/USD:USD",
"triggerPrice": None,
"stopPrice": None,
"info": {
"order": {
"type": "lmt",
"orderId": "abc",
},
"status": "placed",
},
}
result = ex._order_contracts_to_amount(order)
assert result["triggerPrice"] 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 ---
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(None, 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

@ -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
@ -552,9 +553,23 @@ 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,
},
}
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")
@ -584,11 +599,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
)
@ -601,25 +617,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")
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)
exchange = ExchangeResolver.load_exchange(
exchange_conf, validate=True, load_leverage_tiers=True
)
return exchange, exchange_name, exchange_params
@pytest.fixture(params=EXCHANGES, scope="class")
@pytest.fixture(params=EXCHANGES_SPOT, scope="class")
def exchange(request, exchange_conf, class_mocker):
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
exchange, name, exchange_params = get_exchange(request.param, exchange_conf)
exchange, name, exchange_params = get_exchange(request.param, exchange_conf, class_mocker)
yield exchange, name, exchange_params
exchange.close()
@ -640,13 +659,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(

Loading…
Cancel
Save