diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index 9460b8abf..373b4d65a 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta from math import isnan, nan from unittest.mock import MagicMock +import ccxt import pytest from ccxt import ( DECIMAL_PLACES, @@ -28,8 +29,14 @@ from freqtrade.exchange import ( timeframe_to_resample_freq, timeframe_to_seconds, ) -from freqtrade.exchange.check_exchange import check_exchange -from freqtrade.exchange.exchange_utils import _exchange_has_helper +from freqtrade.exchange import exchange_utils as exchange_utils_mod +from freqtrade.exchange.check_exchange import _get_ft_has_overrides, check_exchange +from freqtrade.exchange.exchange_utils import ( + _build_exchange_list_entry, + _exchange_has_helper, + validate_exchange, +) +from freqtrade.resolvers.exchange_resolver import ExchangeResolver from tests.conftest import log_has_re @@ -426,3 +433,66 @@ def test_exchange__exchange_has_helper(): } missing = _exchange_has_helper(e_mod.has, required) assert set(missing) == {"fetchOHLCV", "fetchMyTrades", "fetchOrder"} + + +def test_validate_exchange_uses_ft_has_overrides(monkeypatch): + class DummyExchange: + def __init__(self) -> None: + self.has = { + "fetchOrder": False, + "fetchL2OrderBook": False, + "fetchTicker": True, + "cancelOrder": True, + "createOrder": True, + "fetchBalance": True, + "fetchOHLCV": False, + } + + monkeypatch.setattr(ccxt.pro, "dummy", DummyExchange, raising=False) + + valid, _, _, _ = validate_exchange("dummy") + assert not valid + + valid, _, _, _ = validate_exchange("dummy", {"fetchOrder": True, "fetchOHLCV": True}) + assert valid + + +def test_build_exchange_list_entry_uses_ft_has_overrides(monkeypatch): + class DummyClass: + _supported_trading_mode_margin_pairs = [] + + @staticmethod + def get_ft_has(): + return {"fetchOrder": True} + + captured = {} + + class DummyExchange: + name = "Dummy" + alias = False + dex = False + + def fake_validate_exchange(exchange, ft_has_overrides=None): + captured["ft_has_overrides"] = ft_has_overrides + return True, "", "", DummyExchange() + + monkeypatch.setattr(exchange_utils_mod, "validate_exchange", fake_validate_exchange) + + res = _build_exchange_list_entry("dummy", {"dummy": {"class": DummyClass}}) + assert captured["ft_has_overrides"] == {"fetchOrder": True} + assert res["classname"] == "dummy" + + +def test_get_ft_has_overrides_returns_override(monkeypatch): + class DummyClass: + @staticmethod + def get_ft_has(): + return {"fetchOrder": True} + + monkeypatch.setattr( + ExchangeResolver, + "search_all_objects", + lambda *args, **kwargs: [{"name": "dummy", "class": DummyClass}], + ) + + assert _get_ft_has_overrides("dummy") == {"fetchOrder": True} diff --git a/tests/exchange/test_krakenfutures.py b/tests/exchange/test_krakenfutures.py index 048c52a3a..b1f5efee1 100644 --- a/tests/exchange/test_krakenfutures.py +++ b/tests/exchange/test_krakenfutures.py @@ -6,8 +6,10 @@ from copy import deepcopy from datetime import UTC, datetime from unittest.mock import MagicMock +import pytest + from freqtrade.enums import CandleType, MarginMode, TradingMode -from freqtrade.exceptions import RetryableOrderError, TemporaryError +from freqtrade.exceptions import ExchangeError, RetryableOrderError, TemporaryError from freqtrade.exchange.exchange import Exchange from freqtrade.exchange.krakenfutures import Krakenfutures from tests.conftest import EXMS, get_patched_exchange @@ -76,6 +78,26 @@ def test_krakenfutures_fetch_order_falls_back_to_canceled_orders(mocker, default assert res["id"] == "def" +def test_krakenfutures_fetch_order_reraises_when_no_fallback(mocker, default_conf): + """Re-raise when fallback cannot locate the order.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + + mocker.patch.object(Exchange, "fetch_order", side_effect=RetryableOrderError("not found")) + mocker.patch.object(ex, "_fetch_order_from_closed_or_canceled", return_value=None) + + with pytest.raises(RetryableOrderError): + ex.fetch_order("abc", "BTC/USD:USD") + + +def test_krakenfutures_fetch_order_from_closed_or_canceled_returns_none(mocker, default_conf): + """Return None when the exchange does not support order history endpoints.""" + ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures") + mocker.patch.object(ex, "exchange_has", return_value=False) + + res = ex._fetch_order_from_closed_or_canceled("abc", "BTC/USD:USD", {}) + assert res is None + + def test_krakenfutures_create_stoploss_uses_trigger_price_type(mocker, default_conf): """Test create_stoploss uses triggerPrice, triggerSignal, and reduceOnly.""" api_mock = MagicMock() @@ -114,6 +136,15 @@ def test_krakenfutures_validate_stakecurrency_allows_eur(mocker, default_conf): 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) @@ -169,6 +200,65 @@ def test_krakenfutures_get_balances_falls_back_to_ccxt_fetch_balance(mocker, def 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") @@ -189,6 +279,49 @@ def test_krakenfutures_sum_currencies_value_returns_none_when_empty(mocker, defa 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 + + def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): """Use funding fee helper in futures mode.""" conf = dict(default_conf) @@ -202,6 +335,17 @@ def test_krakenfutures_get_funding_fees_futures_success(mocker, default_conf): 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")