Merge pull request #12706 from hallonstedt/feature-krakenfutures
Feature krakenfutures
This commit is contained in:
@@ -50,6 +50,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] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
|
||||||
- [X] [OKX](https://okx.com/)
|
- [X] [OKX](https://okx.com/)
|
||||||
- [X] [Bybit](https://bybit.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.
|
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"
|
!!! Note "Kraken user"
|
||||||
Kraken users should read [this](exchanges.md#historic-kraken-data) before starting to download data.
|
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:
|
Example call:
|
||||||
|
|
||||||
```bash
|
```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.
|
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.
|
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
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual,
|
|||||||
- [X] [Gate.io](https://www.gate.io/ref/6266643)
|
- [X] [Gate.io](https://www.gate.io/ref/6266643)
|
||||||
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
|
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
|
||||||
- [X] [OKX](https://okx.com/)
|
- [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.
|
Please make sure to read the [exchange specific notes](exchanges.md), as well as the [trading with leverage](leverage.md) documentation before diving in.
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from freqtrade.exchange.htx import Htx
|
|||||||
from freqtrade.exchange.hyperliquid import Hyperliquid
|
from freqtrade.exchange.hyperliquid import Hyperliquid
|
||||||
from freqtrade.exchange.idex import Idex
|
from freqtrade.exchange.idex import Idex
|
||||||
from freqtrade.exchange.kraken import Kraken
|
from freqtrade.exchange.kraken import Kraken
|
||||||
|
from freqtrade.exchange.krakenfutures import Krakenfutures
|
||||||
from freqtrade.exchange.kucoin import Kucoin
|
from freqtrade.exchange.kucoin import Kucoin
|
||||||
from freqtrade.exchange.lbank import Lbank
|
from freqtrade.exchange.lbank import Lbank
|
||||||
from freqtrade.exchange.luno import Luno
|
from freqtrade.exchange.luno import Luno
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ BAD_EXCHANGES = {
|
|||||||
"bitmex": "Various reasons",
|
"bitmex": "Various reasons",
|
||||||
"probit": "Requires additional, regular calls to `signIn()`",
|
"probit": "Requires additional, regular calls to `signIn()`",
|
||||||
"poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders",
|
"poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders",
|
||||||
"krakenfutures": "Unsupported futures exchange",
|
|
||||||
"kucoinfutures": "Unsupported futures exchange",
|
"kucoinfutures": "Unsupported futures exchange",
|
||||||
"poloniexfutures": "Unsupported futures exchange",
|
"poloniexfutures": "Unsupported futures exchange",
|
||||||
"binancecoinm": "Unsupported futures exchange",
|
"binancecoinm": "Unsupported futures exchange",
|
||||||
@@ -63,6 +62,7 @@ SUPPORTED_EXCHANGES = [
|
|||||||
"htx",
|
"htx",
|
||||||
"hyperliquid",
|
"hyperliquid",
|
||||||
"kraken",
|
"kraken",
|
||||||
|
"krakenfutures",
|
||||||
"okx",
|
"okx",
|
||||||
"myokx",
|
"myokx",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""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.misc import safe_value_nested
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
"exchange_has_overrides": {"fetchOrders": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 apply Kraken Futures-specific order corrections."""
|
||||||
|
order = super()._order_contracts_to_amount(order)
|
||||||
|
return self._adjust_krakenfutures_order(order)
|
||||||
|
|
||||||
|
def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder:
|
||||||
|
"""Apply Kraken Futures-specific order corrections.
|
||||||
|
|
||||||
|
For filled terminal orders, always fetch trades and compute VWAP because
|
||||||
|
CCXT's average is still unreliable.
|
||||||
|
|
||||||
|
See: https://github.com/ccxt/ccxt/issues/27996
|
||||||
|
"""
|
||||||
|
if order.get("status") == "canceled" and order.get("filled") is None:
|
||||||
|
# Workaround for missing filled parsing - https://github.com/ccxt/ccxt/issues/28210
|
||||||
|
order["filled"] = safe_value_nested(order, "info.order.filled", default_value=None)
|
||||||
|
|
||||||
|
filled = self._safe_float(order.get("filled")) or 0.0
|
||||||
|
if order.get("status") in ("canceled", "closed") and filled > 0:
|
||||||
|
# Compute VWAP and cost for filled orders.
|
||||||
|
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:
|
||||||
|
# Compute VWAP
|
||||||
|
order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount
|
||||||
|
trade_costs = [t["cost"] for t in trades if t.get("cost") is not None]
|
||||||
|
if trade_costs:
|
||||||
|
order["cost"] = sum(trade_costs)
|
||||||
|
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)
|
||||||
|
return self._order_contracts_to_amount(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 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, pair, 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
|
||||||
+1
-1
@@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16
|
|||||||
ta-lib==0.6.8
|
ta-lib==0.6.8
|
||||||
technical==1.5.4
|
technical==1.5.4
|
||||||
|
|
||||||
ccxt==4.5.43
|
ccxt==4.5.44
|
||||||
cryptography==46.0.5
|
cryptography==46.0.5
|
||||||
aiohttp==3.13.3
|
aiohttp==3.13.3
|
||||||
SQLAlchemy==2.0.48
|
SQLAlchemy==2.0.48
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ def test_list_exchanges(capsys):
|
|||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert re.search(r"Exchanges available for Freqtrade.*", captured.out)
|
assert re.search(r"Exchanges available for Freqtrade.*", captured.out)
|
||||||
assert re.search(r".*binance.*", 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):
|
def test_list_timeframes(mocker, capsys):
|
||||||
|
|||||||
@@ -0,0 +1,910 @@
|
|||||||
|
"""Tests for Kraken Futures exchange class"""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.enums import 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_prop"] == "stopPrice"
|
||||||
|
assert ft_has["stop_price_param"] == "triggerPrice"
|
||||||
|
assert ft_has["stop_price_type_field"] == "triggerSignal"
|
||||||
|
|
||||||
|
|
||||||
|
# --- _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)
|
||||||
|
assert result["cost"] == pytest.approx(27.14)
|
||||||
|
|
||||||
|
|
||||||
|
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_handles_none_filled(mocker, default_conf):
|
||||||
|
"""Don't crash or fetch trades when filled is None."""
|
||||||
|
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
|
||||||
|
|
||||||
|
order = {
|
||||||
|
"id": "abc",
|
||||||
|
"symbol": "BTC/USD:USD",
|
||||||
|
"status": "closed",
|
||||||
|
"filled": None,
|
||||||
|
"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_recomputes_existing_average(mocker, default_conf):
|
||||||
|
"""Recompute average from fills even when CCXT already provided one."""
|
||||||
|
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 = [
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
trades_mock = mocker.patch.object(ex, "get_trades_for_order", return_value=trades)
|
||||||
|
|
||||||
|
result = ex._adjust_krakenfutures_order(order)
|
||||||
|
assert result["average"] == pytest.approx(67850.0)
|
||||||
|
assert result["cost"] == pytest.approx(27.14)
|
||||||
|
trades_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
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("BTC/USD:USD", 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
|
timeframe: str
|
||||||
candle_count: int
|
candle_count: int
|
||||||
futures: bool
|
futures: bool
|
||||||
|
futures_only: bool | None
|
||||||
futures_pair: str | None
|
futures_pair: str | None
|
||||||
candle_count_futures: int | None
|
candle_count_futures: int | None
|
||||||
hasQuoteVolumeFutures: bool | None
|
hasQuoteVolumeFutures: bool | None
|
||||||
@@ -559,9 +560,137 @@ EXCHANGES: dict[str, TestExchangeOnlineSetup] = {
|
|||||||
# TODO: re-enable hyperliquid websocket tests
|
# TODO: re-enable hyperliquid websocket tests
|
||||||
"skip_ws_tests": True,
|
"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,
|
||||||
|
"sample_order_futures": [
|
||||||
|
{
|
||||||
|
# Regular market order
|
||||||
|
"exchange_response": {
|
||||||
|
"uid": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e",
|
||||||
|
"accountUid": "cabdb242-5111-4dac-bac-76f33395d76d",
|
||||||
|
"tradeable": "PF_XBTUSD",
|
||||||
|
"direction": "Sell",
|
||||||
|
"quantity": "0",
|
||||||
|
"filled": "0.0004",
|
||||||
|
"timestamp": 1771354195241,
|
||||||
|
"limitPrice": "67164.00",
|
||||||
|
"orderType": "IoC",
|
||||||
|
"clientId": "",
|
||||||
|
"reduceOnly": False,
|
||||||
|
"lastUpdateTimestamp": 1771354195241,
|
||||||
|
"regulatoryExternalUid": "ae198dd6-6be0-4014-8af-ebd472190648",
|
||||||
|
"status": "closed",
|
||||||
|
},
|
||||||
|
"pair": "BTC/USD:USD",
|
||||||
|
"expected": {
|
||||||
|
"symbol": "BTC/USD:USD",
|
||||||
|
"id": "a11a8dc2-0440-4fe1-5212-1bx15c8f1c8e",
|
||||||
|
"timestamp": 1771354195241,
|
||||||
|
"datetime": "2026-02-17T18:49:55.241Z",
|
||||||
|
"price": None,
|
||||||
|
# Average should be None (it's not correct for market orders)
|
||||||
|
"average": None,
|
||||||
|
"status": "closed",
|
||||||
|
"type": "market",
|
||||||
|
"amount": 0.0004,
|
||||||
|
"side": "sell",
|
||||||
|
"triggerPrice": None,
|
||||||
|
"stopPrice": None,
|
||||||
|
"stopLossPrice": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Trigger order
|
||||||
|
"exchange_response": {
|
||||||
|
"order": {
|
||||||
|
"type": "TRIGGER_ORDER",
|
||||||
|
"orderId": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e",
|
||||||
|
"cliOrdId": None,
|
||||||
|
"symbol": "PF_XBTUSD",
|
||||||
|
"side": "buy",
|
||||||
|
"quantity": 0.0004,
|
||||||
|
"limitPrice": 71712,
|
||||||
|
"reduceOnly": True,
|
||||||
|
"timestamp": "2026-02-17T16:26:02.918Z",
|
||||||
|
"lastUpdateTimestamp": "2026-02-17T16:26:02.918Z",
|
||||||
|
"priceTriggerOptions": {
|
||||||
|
"triggerPrice": 71641,
|
||||||
|
"triggerSignal": "LAST_PRICE",
|
||||||
|
"triggerSide": "TRIGGER_ABOVE",
|
||||||
|
"limitPriceOffsetValue": None,
|
||||||
|
"limitPriceOffsetUnit": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status": "TRIGGER_PLACED",
|
||||||
|
"updateReason": None,
|
||||||
|
"error": None,
|
||||||
|
},
|
||||||
|
"pair": "BTC/USD:USD",
|
||||||
|
"expected": {
|
||||||
|
"symbol": "BTC/USD:USD",
|
||||||
|
"id": "a11a8ff3-17f3-5112-8caa-9cbbacfa1c8e",
|
||||||
|
"timestamp": 1771345562918,
|
||||||
|
"datetime": "2026-02-17T16:26:02.918Z",
|
||||||
|
# TODO: re-verify this ...
|
||||||
|
# "price": 71712.0,
|
||||||
|
"price": None,
|
||||||
|
"status": "open",
|
||||||
|
"amount": 0.0004,
|
||||||
|
"side": "buy",
|
||||||
|
"triggerPrice": 71641.0,
|
||||||
|
"stopPrice": 71641.0,
|
||||||
|
# krakenfutures uses stopPrice - so this is fine.
|
||||||
|
"stopLossPrice": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Canceled order
|
||||||
|
"exchange_response": {
|
||||||
|
"order": {
|
||||||
|
"type": "ORDER",
|
||||||
|
"orderId": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e",
|
||||||
|
"cliOrdId": None,
|
||||||
|
"symbol": "PF_XBTUSD",
|
||||||
|
"side": "buy",
|
||||||
|
"quantity": 0.0022,
|
||||||
|
"filled": 0,
|
||||||
|
"limitPrice": 68000,
|
||||||
|
"reduceOnly": False,
|
||||||
|
"timestamp": "2026-03-21T07:32:21.555Z",
|
||||||
|
"lastUpdateTimestamp": "2026-03-21T07:32:21.555Z",
|
||||||
|
},
|
||||||
|
"status": "CANCELLED",
|
||||||
|
"updateReason": "CANCELLED_BY_USER",
|
||||||
|
"error": None,
|
||||||
|
},
|
||||||
|
"pair": "BTC/USD:USD",
|
||||||
|
"expected": {
|
||||||
|
"symbol": "BTC/USD:USD",
|
||||||
|
"id": "a159faef-6a0f-4651-bb78-xxfa4c71ac7e",
|
||||||
|
"timestamp": 1774078341555,
|
||||||
|
"datetime": "2026-03-21T07:32:21.555Z",
|
||||||
|
"price": None,
|
||||||
|
"status": "canceled",
|
||||||
|
# TODO: filled should be 0, not None.
|
||||||
|
"filled": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
EXCHANGES_FUTURES = [exch for exch, params in EXCHANGES.items() if params.get("futures")]
|
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")
|
@pytest.fixture(scope="class")
|
||||||
@@ -591,11 +720,12 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config:
|
|||||||
return 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_params = EXCHANGES[exchange_name]
|
||||||
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
||||||
exchange_conf["exchange"]["name"] = exchange_name
|
exchange_conf["exchange"]["name"] = exchange_name
|
||||||
exchange_conf["stake_currency"] = exchange_params["stake_currency"]
|
exchange_conf["stake_currency"] = exchange_params["stake_currency"]
|
||||||
|
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
|
||||||
exchange = ExchangeResolver.load_exchange(
|
exchange = ExchangeResolver.load_exchange(
|
||||||
exchange_conf, validate=True, load_leverage_tiers=True
|
exchange_conf, validate=True, load_leverage_tiers=True
|
||||||
)
|
)
|
||||||
@@ -608,25 +738,28 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker):
|
|||||||
|
|
||||||
if exchange_params.get("futures") is not True:
|
if exchange_params.get("futures") is not True:
|
||||||
pytest.skip(f"Exchange {exchange_name} does not support futures.")
|
pytest.skip(f"Exchange {exchange_name} does not support futures.")
|
||||||
else:
|
exchange_conf = deepcopy(exchange_conf)
|
||||||
exchange_conf = deepcopy(exchange_conf)
|
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
||||||
exchange_conf = set_test_proxy(exchange_conf, exchange_params.get("use_ci_proxy", False))
|
exchange_conf["exchange"]["name"] = exchange_name
|
||||||
exchange_conf["trading_mode"] = "futures"
|
exchange_conf["stake_currency"] = exchange_params["stake_currency"]
|
||||||
exchange_conf["margin_mode"] = "isolated"
|
exchange_conf["trading_mode"] = "futures"
|
||||||
|
exchange_conf["margin_mode"] = "isolated"
|
||||||
|
|
||||||
class_mocker.patch("freqtrade.exchange.binance.Binance.fill_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}.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)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=EXCHANGES, scope="class")
|
|
||||||
def exchange(request, exchange_conf, class_mocker):
|
|
||||||
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
|
class_mocker.patch(f"{EXMS}.ft_additional_exchange_init")
|
||||||
exchange, name, exchange_params = get_exchange(request.param, exchange_conf)
|
class_mocker.patch(f"{EXMS}.load_cached_leverage_tiers", return_value=None)
|
||||||
|
class_mocker.patch(f"{EXMS}.cache_leverage_tiers")
|
||||||
|
|
||||||
|
exchange = ExchangeResolver.load_exchange(
|
||||||
|
exchange_conf, validate=True, load_leverage_tiers=True
|
||||||
|
)
|
||||||
|
return exchange, exchange_name, exchange_params
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=EXCHANGES_SPOT, scope="class")
|
||||||
|
def exchange(request, exchange_conf, class_mocker):
|
||||||
|
exchange, name, exchange_params = get_exchange(request.param, exchange_conf, class_mocker)
|
||||||
yield exchange, name, exchange_params
|
yield exchange, name, exchange_params
|
||||||
exchange.close()
|
exchange.close()
|
||||||
|
|
||||||
@@ -647,13 +780,12 @@ def exchange_mode(request):
|
|||||||
|
|
||||||
@pytest.fixture(params=EXCHANGES, scope="class")
|
@pytest.fixture(params=EXCHANGES, scope="class")
|
||||||
def exchange_ws(request, exchange_conf, exchange_mode, class_mocker):
|
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_conf["exchange"]["enable_ws"] = True
|
||||||
exchange_param = EXCHANGES[request.param]
|
exchange_param = EXCHANGES[request.param]
|
||||||
if exchange_param.get("skip_ws_tests"):
|
if exchange_param.get("skip_ws_tests"):
|
||||||
pytest.skip(f"{request.param} does not support websocket tests.")
|
pytest.skip(f"{request.param} does not support websocket tests.")
|
||||||
if exchange_mode == "spot":
|
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"]
|
pair = exchange_param["pair"]
|
||||||
elif exchange_param.get("futures"):
|
elif exchange_param.get("futures"):
|
||||||
exchange, name, _ = get_futures_exchange(
|
exchange, name, _ = get_futures_exchange(
|
||||||
|
|||||||
@@ -101,6 +101,30 @@ class TestCCXTExchange:
|
|||||||
else:
|
else:
|
||||||
pytest.skip(f"No sample order available for exchange {exchangename}")
|
pytest.skip(f"No sample order available for exchange {exchangename}")
|
||||||
|
|
||||||
|
def test_ccxt_order_parse_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE):
|
||||||
|
exch, exchangename, exchange_params = exchange_futures
|
||||||
|
if orders := exchange_params.get("sample_order_futures"):
|
||||||
|
for order in orders:
|
||||||
|
pair = order["pair"]
|
||||||
|
exchange_response: dict = order["exchange_response"]
|
||||||
|
|
||||||
|
market = exch._api.markets[pair]
|
||||||
|
po = exch._api.parse_order(exchange_response, market)
|
||||||
|
expected = order["expected"]
|
||||||
|
assert isinstance(po["id"], str)
|
||||||
|
assert po["id"] is not None
|
||||||
|
|
||||||
|
# Generic comparison which works for all fields
|
||||||
|
for key, value in expected.items():
|
||||||
|
assert key in po, f"Expected key {key} not found in parsed order"
|
||||||
|
assert po[key] == value, f"Expected {key} to be {value}, got {po[key]}"
|
||||||
|
assert isinstance(po[key], type(value)), (
|
||||||
|
f"Expected {key} to be of type {type(value)}, got {type(po[key])}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
pytest.skip(f"No sample order available for exchange {exchangename}")
|
||||||
|
|
||||||
def test_ccxt_my_trades_parse(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
def test_ccxt_my_trades_parse(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
||||||
exch, exchangename, exchange_params = exchange
|
exch, exchangename, exchange_params = exchange
|
||||||
if trades := exchange_params.get("sample_my_trades"):
|
if trades := exchange_params.get("sample_my_trades"):
|
||||||
|
|||||||
Reference in New Issue
Block a user