Skip to content

Commit 250156f

Browse files
authored
Merge pull request freqtrade#12706 from hallonstedt/feature-krakenfutures
Feature krakenfutures
2 parents 1922f15 + 724318c commit 250156f

12 files changed

Lines changed: 1418 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Please read the [exchange-specific notes](https://www.freqtrade.io/en/stable/exc
5050
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
5151
- [X] [OKX](https://okx.com/)
5252
- [X] [Bybit](https://bybit.com/)
53+
- [X] [Kraken](https://www.kraken.com/features/futures)
5354

5455
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.
5556

docs/data-download.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ If `--convert` is also provided, the resample step will happen automatically and
269269
!!! Note "Kraken user"
270270
Kraken users should read [this](exchanges.md#historic-kraken-data) before starting to download data.
271271

272+
Kraken Futures uses standard OHLCV downloads and does not require `--dl-trades`.
273+
272274
Example call:
273275

274276
```bash

docs/exchanges.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,32 @@ freqtrade download-data --exchange kraken --dl-trades -p BTC/EUR BCH/EUR
217217
Please pay attention that rateLimit configuration entry holds delay in milliseconds between requests, NOT requests/sec rate.
218218
So, in order to mitigate Kraken API "Rate limit exceeded" exception, this configuration should be increased, NOT decreased.
219219

220+
## Kraken Futures
221+
222+
Kraken Futures uses the exchange id `krakenfutures` and supports isolated futures mode.
223+
224+
```jsonc
225+
"exchange": {
226+
"name": "krakenfutures",
227+
"key": "your_exchange_key",
228+
"secret": "your_exchange_secret"
229+
},
230+
"trading_mode": "futures",
231+
"margin_mode": "isolated",
232+
"stake_currency": "USD"
233+
```
234+
235+
!!! Tip "Stoploss on Exchange"
236+
Kraken Futures supports `stoploss_on_exchange` with both `limit` and `market` stop orders.
237+
Use `order_types.stoploss_price_type` to select the trigger price source (`mark`, `last`, or `index`).
238+
239+
!!! Note "Collateral"
240+
Kraken Futures is USD-settled. Use USD as your stake currency.
241+
242+
!!! Note "Flex (Multi-collateral) Accounts"
243+
Kraken Futures flex accounts allow collateral in multiple currencies, while trading remains USD-settled.
244+
Freqtrade derives the `USD` balance from Kraken margin fields, so keep `stake_currency` set to `USD`.
245+
220246
## Kucoin
221247

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

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual,
6262
- [X] [Gate.io](https://www.gate.io/ref/6266643)
6363
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
6464
- [X] [OKX](https://okx.com/)
65+
- [X] [Kraken](https://www.kraken.com/features/futures)
6566

6667
Please make sure to read the [exchange specific notes](exchanges.md), as well as the [trading with leverage](leverage.md) documentation before diving in.
6768

freqtrade/exchange/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from freqtrade.exchange.hyperliquid import Hyperliquid
4545
from freqtrade.exchange.idex import Idex
4646
from freqtrade.exchange.kraken import Kraken
47+
from freqtrade.exchange.krakenfutures import Krakenfutures
4748
from freqtrade.exchange.kucoin import Kucoin
4849
from freqtrade.exchange.lbank import Lbank
4950
from freqtrade.exchange.luno import Luno

freqtrade/exchange/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ def _get_logging_mixin():
3939
"bitmex": "Various reasons",
4040
"probit": "Requires additional, regular calls to `signIn()`",
4141
"poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders",
42-
"krakenfutures": "Unsupported futures exchange",
4342
"kucoinfutures": "Unsupported futures exchange",
4443
"poloniexfutures": "Unsupported futures exchange",
4544
"binancecoinm": "Unsupported futures exchange",
@@ -63,6 +62,7 @@ def _get_logging_mixin():
6362
"htx",
6463
"hyperliquid",
6564
"kraken",
65+
"krakenfutures",
6666
"okx",
6767
"myokx",
6868
]
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""Kraken Futures exchange subclass"""
2+
3+
import logging
4+
from datetime import datetime
5+
from typing import Any
6+
7+
import ccxt
8+
9+
from freqtrade.enums import MarginMode, PriceType, TradingMode
10+
from freqtrade.exceptions import (
11+
DDosProtection,
12+
ExchangeError,
13+
InvalidOrderException,
14+
OperationalException,
15+
TemporaryError,
16+
)
17+
from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier
18+
from freqtrade.exchange.exchange import Exchange
19+
from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, FtHas
20+
from freqtrade.misc import safe_value_nested
21+
from freqtrade.util.datetime_helpers import dt_from_ts
22+
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class Krakenfutures(Exchange):
28+
"""Kraken Futures exchange class.
29+
30+
Contains adjustments needed for Freqtrade to work with this exchange.
31+
32+
Key differences from spot Kraken:
33+
- Stop orders use triggerPrice/triggerSignal instead of stopPrice
34+
- Flex (multi-collateral) accounts need USD balance synthesis
35+
"""
36+
37+
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
38+
(TradingMode.FUTURES, MarginMode.ISOLATED),
39+
]
40+
41+
_ft_has: FtHas = {
42+
"tickers_have_quoteVolume": False,
43+
"stoploss_on_exchange": True,
44+
"stoploss_order_types": {
45+
"limit": "limit",
46+
"market": "market",
47+
},
48+
"stoploss_query_requires_stop_flag": True,
49+
"stop_price_param": "triggerPrice",
50+
"stop_price_prop": "stopPrice",
51+
"stop_price_type_field": "triggerSignal",
52+
"stop_price_type_value_mapping": {
53+
PriceType.LAST: "last",
54+
PriceType.MARK: "mark",
55+
PriceType.INDEX: "index",
56+
},
57+
"exchange_has_overrides": {"fetchOrders": False},
58+
}
59+
60+
@retrier
61+
def get_balances(self, params: dict | None = None) -> CcxtBalances:
62+
"""
63+
Fetch balances with USD synthesis for flex (multi-collateral) accounts.
64+
65+
Kraken Futures flex accounts hold multiple currencies as collateral.
66+
CCXT returns per-currency balances but doesn't expose margin values
67+
as a USD balance. This override synthesizes a USD entry from flex account data
68+
when stake_currency is USD.
69+
70+
Field mapping (margin-centric for internal consistency):
71+
- free: availableMargin (margin available for new positions)
72+
- total: marginEquity (haircut-adjusted collateral + unrealized P&L)
73+
- used: total - free (margin currently in use)
74+
75+
Fallback chain for total: marginEquity -> portfolioValue -> balanceValue
76+
"""
77+
try:
78+
balances = self._api.fetch_balance(params or {})
79+
80+
# Only synthesize USD if stake_currency is USD
81+
stake = str(self._config.get("stake_currency", "")).upper()
82+
if stake == "USD":
83+
# Only synthesize if USD stake - flex only applies for these currencies.
84+
# For flex accounts, synthesize USD balance from margin values
85+
info = balances.get("info", {})
86+
accounts = info.get("accounts", {}) if isinstance(info, dict) else {}
87+
flex = accounts.get("flex", {}) if isinstance(accounts, dict) else {}
88+
89+
if flex:
90+
usd_free = self._safe_float(flex.get("availableMargin"))
91+
# Prefer marginEquity for consistency (same basis as availableMargin)
92+
raw_total = (
93+
flex.get("marginEquity")
94+
or flex.get("portfolioValue")
95+
or flex.get("balanceValue")
96+
)
97+
usd_total = self._safe_float(raw_total)
98+
if usd_free is not None or usd_total is not None:
99+
# Use available value for both if only one is present
100+
usd_free_value = usd_free if usd_free is not None else usd_total
101+
usd_total_value = usd_total if usd_total is not None else usd_free
102+
if usd_free_value is not None and usd_total_value is not None:
103+
usd_used = max(0.0, usd_total_value - usd_free_value)
104+
balances["USD"] = {
105+
"free": usd_free_value,
106+
"used": usd_used,
107+
"total": usd_total_value,
108+
}
109+
110+
# Remove additional info from ccxt results (same as base class)
111+
balances.pop("info", None)
112+
balances.pop("free", None)
113+
balances.pop("total", None)
114+
balances.pop("used", None)
115+
116+
self._log_exchange_response("fetch_balance", balances, add_info=params)
117+
return balances
118+
except ccxt.DDoSProtection as e:
119+
raise DDosProtection(e) from e
120+
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
121+
raise TemporaryError(
122+
f"Could not get balance due to {e.__class__.__name__}. Message: {e}"
123+
) from e
124+
except ccxt.BaseError as e:
125+
raise OperationalException(e) from e
126+
127+
@staticmethod
128+
def _safe_float(value: Any) -> float | None:
129+
"""Convert value to float, returning None if conversion fails."""
130+
if value is None:
131+
return None
132+
try:
133+
return float(value)
134+
except (ValueError, TypeError):
135+
return None
136+
137+
def _order_contracts_to_amount(self, order: CcxtOrder) -> CcxtOrder:
138+
"""Normalize order and apply Kraken Futures-specific order corrections."""
139+
order = super()._order_contracts_to_amount(order)
140+
return self._adjust_krakenfutures_order(order)
141+
142+
def _adjust_krakenfutures_order(self, order: CcxtOrder) -> CcxtOrder:
143+
"""Apply Kraken Futures-specific order corrections.
144+
145+
For filled terminal orders, always fetch trades and compute VWAP because
146+
CCXT's average is still unreliable.
147+
148+
See: https://github.com/ccxt/ccxt/issues/27996
149+
"""
150+
if order.get("status") == "canceled" and order.get("filled") is None:
151+
# Workaround for missing filled parsing - https://github.com/ccxt/ccxt/issues/28210
152+
order["filled"] = safe_value_nested(order, "info.order.filled", default_value=None)
153+
154+
filled = self._safe_float(order.get("filled")) or 0.0
155+
if order.get("status") in ("canceled", "closed") and filled > 0:
156+
# Compute VWAP and cost for filled orders.
157+
trades = self.get_trades_for_order(
158+
order["id"], order["symbol"], since=dt_from_ts(order["timestamp"])
159+
)
160+
if trades:
161+
total_amount = sum(t["amount"] for t in trades)
162+
if total_amount:
163+
# Compute VWAP
164+
order["average"] = sum(t["price"] * t["amount"] for t in trades) / total_amount
165+
trade_costs = [t["cost"] for t in trades if t.get("cost") is not None]
166+
if trade_costs:
167+
order["cost"] = sum(trade_costs)
168+
return order
169+
170+
def get_trades_for_order(
171+
self, order_id: str, pair: str, since: datetime, params: dict | None = None
172+
) -> list:
173+
"""Fetch trades and enrich with calculated fees.
174+
175+
Kraken Futures' /fills endpoint does not include fee amounts — only
176+
fillType (maker/taker). This enriches each trade with a calculated fee
177+
using the market's fee schedule so Freqtrade's fee detection works.
178+
"""
179+
trades = super().get_trades_for_order(order_id, pair, since, params)
180+
for trade in trades:
181+
if trade.get("fee") is None or trade["fee"].get("cost") is None:
182+
taker_or_maker = trade.get("takerOrMaker", "taker")
183+
symbol = trade.get("symbol", pair)
184+
market = self.markets.get(symbol, {})
185+
fee_rate = market.get(taker_or_maker, market.get("taker", 0.0005))
186+
cost = trade.get("cost")
187+
if cost is not None and fee_rate is not None:
188+
trade["fee"] = {
189+
"cost": cost * fee_rate,
190+
"currency": market.get("quote", "USD"),
191+
"rate": fee_rate,
192+
}
193+
return trades
194+
195+
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
196+
def fetch_order(
197+
self, order_id: str, pair: str, params: dict[str, Any] | None = None
198+
) -> CcxtOrder:
199+
"""Fetch order with direct CCXT call and fallback to history endpoints."""
200+
if self._config.get("dry_run"):
201+
return self.fetch_dry_run_order(order_id)
202+
203+
params = params or {}
204+
status_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")}
205+
try:
206+
order = self._api.fetch_order(order_id, pair, params=status_params)
207+
self._log_exchange_response("fetch_order", order)
208+
return self._order_contracts_to_amount(order)
209+
except ccxt.OrderNotFound:
210+
# Expected for older Kraken Futures orders not visible in orders/status.
211+
pass
212+
except ccxt.DDoSProtection as e:
213+
raise DDosProtection(e) from e
214+
except ccxt.InvalidOrder as e:
215+
msg = f"Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}"
216+
raise InvalidOrderException(msg) from e
217+
except (ccxt.OperationFailed, ccxt.ExchangeError):
218+
# Fallback to history endpoints for temporary/status endpoint gaps.
219+
pass
220+
except ccxt.BaseError as e:
221+
raise OperationalException(e) from e
222+
223+
order = self._fetch_order_fallback(order_id, pair, params)
224+
if order is not None:
225+
return order
226+
227+
# Order not in status, open, closed, or canceled endpoints - genuinely gone.
228+
# Raise non-retrying InvalidOrderException (Kraken has limited history retention).
229+
raise InvalidOrderException(
230+
f"Order not found in any endpoint (pair: {pair} id: {order_id})"
231+
)
232+
233+
def _fetch_order_fallback(
234+
self, order_id: str, pair: str, params: dict[str, Any]
235+
) -> CcxtOrder | None:
236+
"""Search open, closed, and canceled order endpoints for order_id.
237+
238+
Kraken Futures' orders/status endpoint only returns currently open orders.
239+
Older orders require querying history endpoints (closed/canceled).
240+
For stoploss (trigger) orders, the caller should pass stop=True in params
241+
(handled automatically via stoploss_query_requires_stop_flag in _ft_has)
242+
so that closed/canceled queries hit the trigger history endpoint.
243+
"""
244+
order_id_str = str(order_id)
245+
246+
# Open orders include triggers by default. Avoid passing trigger/stop flags
247+
# to prevent endpoint/filter mismatches.
248+
open_params = {k: v for k, v in params.items() if k not in ("trigger", "stop")}
249+
order = self._find_order_in_list(
250+
self._api.fetch_open_orders, pair, open_params, order_id_str
251+
)
252+
if order is not None:
253+
return order
254+
255+
# Closed/canceled: pass params through (including stop=True for stoploss orders,
256+
# which CCXT maps to the trigger history endpoint).
257+
for fetch_fn in (self._api.fetch_closed_orders, self._api.fetch_canceled_orders):
258+
order = self._find_order_in_list(fetch_fn, pair, params, order_id_str)
259+
if order is not None:
260+
return order
261+
262+
return None
263+
264+
def _find_order_in_list(
265+
self,
266+
fetch_fn,
267+
symbol: str | None,
268+
params: dict[str, Any],
269+
order_id_str: str,
270+
) -> CcxtOrder | None:
271+
"""Fetch orders and return matching order_id, or None."""
272+
try:
273+
orders = fetch_fn(symbol, params=params) or []
274+
self._log_exchange_response(fetch_fn.__name__, orders)
275+
for order in orders:
276+
if str(order.get("id")) == order_id_str:
277+
self._log_exchange_response("fetch_order_fallback", order)
278+
279+
return self._order_contracts_to_amount(order)
280+
except (ccxt.OrderNotFound, ccxt.InvalidOrder) as e:
281+
logger.debug(f"{fetch_fn.__name__} failed: {e}")
282+
return None
283+
except ccxt.DDoSProtection as e:
284+
raise DDosProtection(e) from e
285+
except (ccxt.OperationFailed, ccxt.ExchangeError) as e:
286+
raise TemporaryError(
287+
f"Could not get order due to {e.__class__.__name__}. Message: {e}"
288+
) from e
289+
except ccxt.BaseError as e:
290+
raise OperationalException(e) from e
291+
return None
292+
293+
def get_funding_fees(self, pair: str, amount: float, is_short: bool, open_date) -> float:
294+
"""Fetch funding fees, returning 0.0 if retrieval fails."""
295+
if self.trading_mode == TradingMode.FUTURES:
296+
try:
297+
return self._fetch_and_calculate_funding_fees(pair, amount, is_short, open_date)
298+
except ExchangeError:
299+
logger.warning(f"Could not update funding fees for {pair}.")
300+
return 0.0

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16
77
ta-lib==0.6.8
88
technical==1.5.4
99

10-
ccxt==4.5.43
10+
ccxt==4.5.44
1111
cryptography==46.0.5
1212
aiohttp==3.13.3
1313
SQLAlchemy==2.0.48

0 commit comments

Comments
 (0)