krakenfutures: simplify order/balance handling and harden error mapping
This commit is contained in:
@@ -6,15 +6,25 @@ from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import ccxt
|
||||
import pytest
|
||||
|
||||
from freqtrade.enums import CandleType, MarginMode, TradingMode
|
||||
from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError
|
||||
from freqtrade.exchange.exchange import Exchange
|
||||
from freqtrade.exceptions import (
|
||||
DDosProtection,
|
||||
ExchangeError,
|
||||
InvalidOrderException,
|
||||
OperationalException,
|
||||
RetryableOrderError,
|
||||
TemporaryError,
|
||||
)
|
||||
from freqtrade.exchange.krakenfutures import Krakenfutures
|
||||
from tests.conftest import EXMS, get_patched_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
|
||||
@@ -34,21 +44,17 @@ def test_krakenfutures_ohlcv_candle_limit_uses_ccxt_limit(mocker, default_conf):
|
||||
assert ex.ohlcv_candle_limit("1m", candle_type=CandleType.FUTURES) == 2000
|
||||
|
||||
|
||||
# --- 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")
|
||||
|
||||
# Mock the unwrapped fetch_order to raise RetryableOrderError
|
||||
mocker.patch.object(
|
||||
Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found")
|
||||
)
|
||||
mocker.patch.object(
|
||||
ex,
|
||||
"exchange_has",
|
||||
side_effect=lambda endpoint: endpoint == "fetchClosedOrders",
|
||||
)
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found"))
|
||||
mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_closed_orders",
|
||||
@@ -66,14 +72,9 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(
|
||||
Exchange.fetch_order, "__wrapped__", side_effect=TemporaryError("UUID string too large")
|
||||
)
|
||||
mocker.patch.object(
|
||||
ex,
|
||||
"exchange_has",
|
||||
side_effect=lambda endpoint: endpoint == "fetchCanceledOrders",
|
||||
)
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.ExchangeError("UUID too large"))
|
||||
mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_canceled_orders",
|
||||
@@ -85,30 +86,105 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default
|
||||
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_reraises_when_no_fallback(mocker, default_conf):
|
||||
"""Re-raise when fallback cannot locate the order."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(
|
||||
Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found")
|
||||
)
|
||||
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(RetryableOrderError):
|
||||
ex.fetch_order("abc", "BTC/USD:USD")
|
||||
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 the exchange does not support order history endpoints."""
|
||||
"""Return None when order is not found in any endpoint."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
mocker.patch.object(ex, "exchange_has", return_value=False)
|
||||
mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
mocker.patch.object(ex._api, "fetch_canceled_orders", return_value=[], create=True)
|
||||
|
||||
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,
|
||||
)
|
||||
closed_fetch = mocker.patch.object(ex._api, "fetch_closed_orders", return_value=[], create=True)
|
||||
canceled_fetch = mocker.patch.object(
|
||||
ex._api, "fetch_canceled_orders", return_value=[], create=True
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -128,14 +204,7 @@ def test_krakenfutures_fetch_order_finds_trigger_order(mocker, default_conf):
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(
|
||||
Exchange.fetch_order, "__wrapped__", side_effect=RetryableOrderError("not found")
|
||||
)
|
||||
mocker.patch.object(
|
||||
ex,
|
||||
"exchange_has",
|
||||
side_effect=lambda endpoint: endpoint in ("fetchOpenOrders", "fetchClosedOrders"),
|
||||
)
|
||||
mocker.patch.object(ex._api, "fetch_order", side_effect=ccxt.OrderNotFound("not found"))
|
||||
# Open orders returns empty, closed orders returns empty for regular,
|
||||
# but returns the trigger order when trigger=True
|
||||
mocker.patch.object(ex._api, "fetch_open_orders", return_value=[], create=True)
|
||||
@@ -153,6 +222,83 @@ def test_krakenfutures_fetch_order_finds_trigger_order(mocker, default_conf):
|
||||
assert res["id"] == "trigger-123"
|
||||
|
||||
|
||||
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"))
|
||||
mocker.patch.object(
|
||||
ex._api, "fetch_open_orders", side_effect=ccxt.ExchangeError("service unavailable")
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -185,196 +331,7 @@ def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_c
|
||||
assert params["reduceOnly"] is True
|
||||
|
||||
|
||||
def test_krakenfutures_validate_stakecurrency_allows_eur(mocker, default_conf):
|
||||
"""Test validate_stakecurrency allows EUR for multi-collateral accounts."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
ex.validate_stakecurrency("EUR")
|
||||
|
||||
|
||||
def test_krakenfutures_validate_stakecurrency_calls_super(mocker, default_conf):
|
||||
"""Test validate_stakecurrency calls the base implementation for non-EUR."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
base_validate = mocker.patch.object(Exchange, "validate_stakecurrency")
|
||||
|
||||
ex.validate_stakecurrency("USD")
|
||||
assert base_validate.call_count == 1
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_synth_usd_from_flex(mocker, default_conf):
|
||||
"""Test get_balances synthesizes USD balance from flex account."""
|
||||
conf = dict(default_conf)
|
||||
conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
sample = {
|
||||
"EUR": {"free": 10.0, "used": 0.0, "total": 10.0},
|
||||
"free": {"EUR": 10.0},
|
||||
"used": {"EUR": 0.0},
|
||||
"total": {"EUR": 10.0},
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"availableMargin": 11.0,
|
||||
"balanceValue": 12.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mocker.patch.object(Exchange, "get_balances", return_value=sample)
|
||||
res = ex.get_balances()
|
||||
assert res["USD"]["free"] == 11.0
|
||||
assert res["USD"]["total"] == 12.0
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_falls_back_to_ccxt_fetch_balance(mocker, default_conf):
|
||||
"""Test get_balances falls back to fetch_balance when no flex in initial response."""
|
||||
conf = dict(default_conf)
|
||||
conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
# Base get_balances returns no info -> forces fallback
|
||||
mocker.patch.object(
|
||||
Exchange, "get_balances", return_value={"free": {}, "used": {}, "total": {}}
|
||||
)
|
||||
|
||||
mocker.patch.object(
|
||||
ex._api,
|
||||
"fetch_balance",
|
||||
return_value={
|
||||
"free": {"EUR": 10.0},
|
||||
"used": {"EUR": 0.0},
|
||||
"total": {"EUR": 10.0},
|
||||
"info": {"accounts": {"flex": {"availableMargin": 11.0, "balanceValue": 12.0}}},
|
||||
},
|
||||
create=True,
|
||||
)
|
||||
|
||||
res = ex.get_balances()
|
||||
assert res["free"]["USD"] == 11.0
|
||||
assert res["total"]["USD"] == 12.0
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_returns_for_non_usd_stake(mocker, default_conf):
|
||||
"""Test get_balances returns early when stake currency is not USD."""
|
||||
conf = dict(default_conf)
|
||||
conf["stake_currency"] = "EUR"
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
sample = {"free": {"EUR": 10.0}, "used": {"EUR": 0.0}, "total": {"EUR": 10.0}}
|
||||
mocker.patch.object(Exchange, "get_balances", return_value=sample)
|
||||
|
||||
res = ex.get_balances()
|
||||
assert res == sample
|
||||
assert "USD" not in res
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_returns_when_flex_missing_or_invalid(mocker, default_conf):
|
||||
"""Return original balances when flex data or USD extraction is missing."""
|
||||
conf = dict(default_conf)
|
||||
conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
base_one = {"free": {}, "used": {}, "total": {}}
|
||||
base_two = {"free": {}, "used": {}, "total": {}}
|
||||
mocker.patch.object(Exchange, "get_balances", side_effect=[base_one, base_two])
|
||||
mocker.patch.object(ex, "_get_flex_account", side_effect=[None, {"availableMargin": 1.0}])
|
||||
mocker.patch.object(ex, "_extract_usd_from_flex", return_value=(None, 1.0))
|
||||
|
||||
res = ex.get_balances()
|
||||
assert res == base_one
|
||||
|
||||
res = ex.get_balances()
|
||||
assert res == base_two
|
||||
|
||||
|
||||
def test_krakenfutures_get_balances_preserves_existing_usd(mocker, default_conf):
|
||||
"""Keep existing USD free balance if higher than flex-derived value."""
|
||||
conf = dict(default_conf)
|
||||
conf["stake_currency"] = "USD"
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
sample = {
|
||||
"USD": {"free": 20.0, "used": 0.0, "total": 20.0},
|
||||
"free": {"USD": 20.0},
|
||||
"used": {"USD": 0.0},
|
||||
"total": {"USD": 20.0},
|
||||
"info": {
|
||||
"accounts": {
|
||||
"flex": {
|
||||
"availableMargin": 11.0,
|
||||
"balanceValue": 12.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mocker.patch.object(Exchange, "get_balances", return_value=sample)
|
||||
res = ex.get_balances()
|
||||
assert res["free"]["USD"] == 20.0
|
||||
|
||||
|
||||
def test_krakenfutures_sum_currencies_value_sums_valid_values(mocker, default_conf):
|
||||
"""Sum currencies values, skipping invalid entries."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
currencies = {
|
||||
"USD": {"value": "10"},
|
||||
"EUR": {"value": 2.5},
|
||||
"BAD": {"value": ""},
|
||||
"NODICT": 3,
|
||||
}
|
||||
|
||||
assert ex._sum_currencies_value(currencies) == 12.5
|
||||
|
||||
|
||||
def test_krakenfutures_sum_currencies_value_returns_none_when_empty(mocker, default_conf):
|
||||
"""Return None when no valid values are found."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
assert ex._sum_currencies_value(["not", "dict"]) is None
|
||||
assert ex._sum_currencies_value({"USD": {"value": ""}}) is None
|
||||
|
||||
|
||||
def test_krakenfutures_get_flex_account_fetch_balance_error(mocker, default_conf):
|
||||
"""Return None when fetch_balance fails while attempting to load flex data."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
mocker.patch.object(ex._api, "fetch_balance", side_effect=Exception("boom"), create=True)
|
||||
res = ex._get_flex_account({"free": {}, "used": {}, "total": {}}, None)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_krakenfutures_extract_flex_from_raw_handles_invalid(mocker, default_conf):
|
||||
"""Return None for malformed flex account structures."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
assert ex._extract_flex_from_raw(None) is None
|
||||
assert ex._extract_flex_from_raw({"info": "bad"}) is None
|
||||
assert ex._extract_flex_from_raw({"info": {"accounts": "bad"}}) is None
|
||||
|
||||
|
||||
def test_krakenfutures_extract_usd_from_flex_fallbacks(mocker, default_conf):
|
||||
"""Use currencies fallback and fill missing USD values."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
usd_free, usd_total = ex._extract_usd_from_flex(
|
||||
{"availableMargin": "5.0", "currencies": {"EUR": {"value": "6.0"}}}
|
||||
)
|
||||
assert usd_free == 5.0
|
||||
assert usd_total == 6.0
|
||||
|
||||
usd_free, usd_total = ex._extract_usd_from_flex({"availableMargin": "7.0"})
|
||||
assert usd_free == 7.0
|
||||
assert usd_total == 7.0
|
||||
|
||||
usd_free, usd_total = ex._extract_usd_from_flex({"balanceValue": "9.0"})
|
||||
assert usd_free == 9.0
|
||||
assert usd_total == 9.0
|
||||
|
||||
|
||||
def test_krakenfutures_safe_float_invalid_returns_none(mocker, default_conf):
|
||||
"""Return None for values that cannot be coerced to float."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
assert ex._safe_float("not-a-number") is None
|
||||
# --- Funding fees tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf):
|
||||
@@ -408,3 +365,309 @@ def test_krakenfutures_get_funding_fees_spot_returns_zero(mocker, default_conf):
|
||||
|
||||
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 cancel tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_cancel_stoploss_order_fixes_id(mocker, default_conf):
|
||||
"""Test cancel_stoploss_order extracts order ID from info when top-level id is None."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
# CCXT returns 'id': None for trigger orders, but orderId is in info.order
|
||||
ccxt_response = {
|
||||
"id": None,
|
||||
"status": "canceled",
|
||||
"info": {
|
||||
"order": {
|
||||
"orderId": "a10258a9-01ea-44c4-a38f-66165678926e",
|
||||
"type": "TRIGGER_ORDER",
|
||||
"symbol": "PF_XBTUSD",
|
||||
},
|
||||
"status": "CANCELLED",
|
||||
},
|
||||
}
|
||||
mocker.patch.object(ex, "cancel_order", return_value=ccxt_response)
|
||||
|
||||
result = ex.cancel_stoploss_order("a10258a9-01ea-44c4-a38f-66165678926e", "BTC/USD:USD")
|
||||
|
||||
# ID should be extracted from info.order.orderId
|
||||
assert result["id"] == "a10258a9-01ea-44c4-a38f-66165678926e"
|
||||
assert result["status"] == "canceled"
|
||||
|
||||
|
||||
def test_krakenfutures_cancel_stoploss_order_preserves_existing_id(mocker, default_conf):
|
||||
"""Test cancel_stoploss_order doesn't overwrite existing id."""
|
||||
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||
|
||||
# Normal response with id already set
|
||||
ccxt_response = {
|
||||
"id": "existing-order-id",
|
||||
"status": "canceled",
|
||||
"info": {},
|
||||
}
|
||||
mocker.patch.object(ex, "cancel_order", return_value=ccxt_response)
|
||||
|
||||
result = ex.cancel_stoploss_order("existing-order-id", "BTC/USD:USD")
|
||||
|
||||
assert result["id"] == "existing-order-id"
|
||||
|
||||
|
||||
# --- Stoploss fetch tests ---
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_stoploss_order_fixes_id(mocker, default_conf):
|
||||
"""Test fetch_stoploss_order extracts order ID from info when top-level id is None."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
# CCXT returns 'id': None for trigger orders, but orderId is in info.order
|
||||
ccxt_response = {
|
||||
"id": None,
|
||||
"status": "open",
|
||||
"info": {
|
||||
"order": {
|
||||
"orderId": "trigger-order-123",
|
||||
"type": "TRIGGER_ORDER",
|
||||
"symbol": "PF_XBTUSD",
|
||||
},
|
||||
},
|
||||
}
|
||||
mocker.patch.object(ex, "fetch_order", return_value=ccxt_response)
|
||||
|
||||
result = ex.fetch_stoploss_order("trigger-order-123", "BTC/USD:USD")
|
||||
|
||||
# ID should be extracted from info.order.orderId
|
||||
assert result["id"] == "trigger-order-123"
|
||||
|
||||
|
||||
def test_krakenfutures_fetch_stoploss_order_passes_trigger_param(mocker, default_conf):
|
||||
"""Test fetch_stoploss_order passes trigger=True to fetch_order."""
|
||||
conf = dict(default_conf)
|
||||
conf["dry_run"] = False
|
||||
ex = get_patched_exchange(mocker, conf, exchange="krakenfutures")
|
||||
|
||||
mock_fetch = mocker.patch.object(
|
||||
ex, "fetch_order", return_value={"id": "order-123", "status": "open", "info": {}}
|
||||
)
|
||||
|
||||
ex.fetch_stoploss_order("order-123", "BTC/USD:USD")
|
||||
|
||||
# Verify trigger=True was passed
|
||||
mock_fetch.assert_called_once()
|
||||
call_params = mock_fetch.call_args[0][2] # third positional arg is params
|
||||
assert call_params.get("trigger") is True
|
||||
|
||||
Reference in New Issue
Block a user