Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion freqtrade/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Freqtrade bot"""

__version__ = "2026.7-dev"
__version__ = "2026.8-dev"

if "dev" in __version__:
from pathlib import Path
Expand Down
2 changes: 2 additions & 0 deletions freqtrade/exchange/binance.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class Binance(Exchange):
PriceType.MARK: "MARK_PRICE",
},
"ws_enabled": False,
# ccxt maps "total" to assets[].marginBalance (= walletBalance + unrealizedProfit)
"balance_includes_unrealized_pnl": True,
"proxy_coin_mapping": {
"BNFCR": "USDC",
"BFUSD": "USDT",
Expand Down
2 changes: 2 additions & 0 deletions freqtrade/exchange/bitget.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class Bitget(Exchange):
PriceType.LAST: "fill_price",
PriceType.MARK: "mark_price",
},
# ccxt maps "total" to accountEquity, which includes unrealized PnL
"balance_includes_unrealized_pnl": True,
}

_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
Expand Down
11 changes: 11 additions & 0 deletions freqtrade/exchange/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ class Exchange:
"funding_fee_timeframe": "1h",
"ccxt_futures_name": "swap",
"needs_trading_fees": False, # use fetch_trading_fees to cache fees
"balance_includes_unrealized_pnl": False, # ccxt "total" is plain wallet balance
"order_props_in_contracts": ["amount", "filled", "remaining"],
"fetch_orders_limit_minutes": None, # "fetch_orders" is not time-limited by default
# Override createMarketBuyOrderRequiresPrice where ccxt has it wrong
Expand Down Expand Up @@ -987,6 +988,16 @@ def get_option(self, param: str, default: Any | None = None) -> Any:
"""
return self._ft_has.get(param, default)

def balance_includes_unrealized_pnl(self) -> bool:
"""
Whether the stake currency's "total" balance as returned by get_balances() is account
equity (wallet balance + unrealized PnL of open positions) rather than plain wallet
balance. Wallets normalizes this away, so that Wallet.total has one single meaning
across exchanges and between dry-run and live.
Overridable for exchanges where this depends on more than the exchange itself.
"""
return self.get_option("balance_includes_unrealized_pnl", False)

def exchange_has(self, endpoint: str) -> bool:
"""
Checks if exchange implements a specific API endpoint.
Expand Down
4 changes: 4 additions & 0 deletions freqtrade/exchange/exchange_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ class FtHas(TypedDict, total=False):
floor_leverage: bool
uses_leverage_tiers: bool
needs_trading_fees: bool
# True if the balance "total" reported for the stake currency is account equity
# (wallet balance + unrealized PnL of open positions) instead of plain wallet balance.
# See Exchange.balance_includes_unrealized_pnl() for more details.
balance_includes_unrealized_pnl: bool
order_props_in_contracts: list[Literal["amount", "cost", "filled", "remaining"]]

proxy_coin_mapping: dict[str, str]
Expand Down
2 changes: 2 additions & 0 deletions freqtrade/exchange/hyperliquid.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class Hyperliquid(Exchange):
"funding_fee_candle_limit": 500,
"uses_leverage_tiers": False,
"mark_ohlcv_price": "futures",
# ccxt maps "total" to marginSummary.accountValue, which includes unrealized PnL
"balance_includes_unrealized_pnl": True,
}

_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
Expand Down
9 changes: 9 additions & 0 deletions freqtrade/exchange/krakenfutures.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ def get_balances(self, params: dict | None = None) -> CcxtBalances:
except ccxt.BaseError as e:
raise OperationalException(e) from e

def balance_includes_unrealized_pnl(self) -> bool:
"""
Not expressible as a static flag for this exchange.
get_balances() above synthesizes the USD balance from marginEquity, which includes
unrealized PnL. Any other stake currency falls through to ccxt, which reports the
flex account's plain "quantity" (wallet balance) instead.
"""
return str(self._config.get("stake_currency", "")).upper() == "USD"

@staticmethod
def _safe_float(value: Any) -> float | None:
"""Convert value to float, returning None if conversion fails."""
Expand Down
2 changes: 2 additions & 0 deletions freqtrade/exchange/okx.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class Okx(Exchange):
},
"stoploss_blocks_assets": False,
"ws_enabled": True,
# ccxt maps "total" to the currency's "eq" (equity), which includes unrealized PnL
"balance_includes_unrealized_pnl": True,
}

_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
Expand Down
26 changes: 25 additions & 1 deletion freqtrade/wallets.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class PositionWallet(NamedTuple):
leverage: float | None = 0 # Don't use this - it's not guaranteed to be set
collateral: float = 0
side: str = "long"
# Unrealized PnL as reported by the exchange. Always 0 in dry-run
unrealized_pnl: float = 0


class Wallets:
Expand Down Expand Up @@ -208,15 +210,37 @@ def _update_live(self) -> None:
if not leverage:
trade = Trade.get_trades_proxy(is_open=True, pair=symbol)
leverage = trade[0].leverage if trade else None
unrealized_pnl = float(position.get("unrealizedPnl") or 0.0) # type: ignore[arg-type]
_parsed_positions[symbol] = PositionWallet(
symbol,
position=size,
leverage=leverage,
collateral=collateral,
side=position["side"],
unrealized_pnl=unrealized_pnl,
)
self._positions = _parsed_positions
self._wallets = _wallets
self._wallets = self._strip_unrealized_pnl(_wallets, _parsed_positions)

def _strip_unrealized_pnl(
self, wallets: dict[str, Wallet], positions: dict[str, PositionWallet]
) -> dict[str, Wallet]:
"""
Restore the Wallet.total for exchanges reporting account equity.
Their "total" for the stake currency contains the unrealized PnL of open positions,
which would otherwise be counted twice - once in the stake balance, and once more in
each PositionWallet. Uses the exchange's own unrealized PnL, not a rate-derived
estimate.
Not necessary for dry run or backtesting, since we control balance.total there.
"""
if not positions or not self._exchange.balance_includes_unrealized_pnl():
return wallets
upnl = sum(pos.unrealized_pnl for pos in positions.values())
stake_wallet = wallets.get(self._stake_currency)
if not upnl or stake_wallet is None:
return wallets
wallets[self._stake_currency] = stake_wallet._replace(total=stake_wallet.total - upnl)
return wallets

def update(self, require_update: bool = True) -> None:
"""
Expand Down
2 changes: 1 addition & 1 deletion ft_client/freqtrade_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from freqtrade_client.ft_rest_client import FtRestClient


__version__ = "2026.7-dev"
__version__ = "2026.8-dev"

if "dev" in __version__:
from pathlib import Path
Expand Down
21 changes: 21 additions & 0 deletions tests/exchange/test_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -4580,6 +4580,27 @@ def test_merge_ft_has_dict(default_conf, mocker):
assert ex._ft_has["DeadBeef"] == 20


@pytest.mark.parametrize(
"exchange_name,expected",
[
# ccxt reports account equity for these - their "total" carries unrealized PnL
("binance", True),
("hyperliquid", True),
("okx", True),
("bitget", True),
# ccxt reports plain wallet balance for these
("bybit", False),
("gate", False),
("kraken", False),
],
)
def test_balance_includes_unrealized_pnl(default_conf, mocker, exchange_name, expected):
default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated"
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
assert exchange.balance_includes_unrealized_pnl() is expected


def test_get_valid_pair_combination(default_conf, mocker, markets):
mocker.patch.multiple(
EXMS,
Expand Down
20 changes: 20 additions & 0 deletions tests/exchange/test_krakenfutures.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ def test_krakenfutures_ft_has_overrides():
assert ft_has["stop_price_type_field"] == "triggerSignal"


@pytest.mark.parametrize(
"stake_currency,expected",
[
# get_balances() synthesizes USD from marginEquity, which includes unrealized PnL
("USD", True),
# anything else falls through to ccxt's flex "quantity" (plain wallet balance)
("USDT", False),
("BTC", False),
("EUR", False),
],
)
def test_krakenfutures_balance_includes_unrealized_pnl(
mocker, default_conf, stake_currency, expected
):
# Explicit test for krakenfutures - as behavior here is odd at best.
default_conf["stake_currency"] = stake_currency
ex = get_patched_exchange(mocker, default_conf, exchange="krakenfutures")
assert ex.balance_includes_unrealized_pnl() is expected


# --- _adjust_krakenfutures_order average price tests ---


Expand Down
66 changes: 66 additions & 0 deletions tests/test_wallets.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,72 @@ def test_sync_wallet_futures_live(mocker, default_conf):
assert "ETH/USDT:USDT" not in freqtrade.wallets._positions


@pytest.mark.parametrize("includes_upnl", [True, False])
def test_sync_wallet_futures_live_unrealized_pnl(mocker, default_conf_usdt, includes_upnl):

# Wallet.total for the stake currency must never contain the unrealized PnL of open
# positions - exchanges reporting account equity get normalized back to wallet balance.
default_conf_usdt["dry_run"] = False
default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = "isolated"
mock_result = [
{
"symbol": "ETH/USDT:USDT",
"initialMargin": 100.0,
"leverage": 5.0,
"unrealizedPnl": 30.0,
"contracts": 100.0,
"contractSize": 1,
"collateral": 130.0,
"side": "long",
},
{
"symbol": "ADA/USDT:USDT",
"initialMargin": 50.0,
"leverage": 5.0,
"unrealizedPnl": -12.5,
"contracts": 100.0,
"contractSize": 1,
"collateral": 37.5,
"side": "short",
},
]
mocker.patch.multiple(
EXMS,
get_balances=MagicMock(return_value={"USDT": {"free": 850, "used": 150, "total": 1017.5}}),
fetch_positions=MagicMock(return_value=mock_result),
balance_includes_unrealized_pnl=MagicMock(return_value=includes_upnl),
)
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
wallets = freqtrade.wallets

# Position uPnL is taken from the exchange, never from initialMargin/collateral.
assert wallets._positions["ETH/USDT:USDT"].unrealized_pnl == 30.0
assert wallets._positions["ADA/USDT:USDT"].unrealized_pnl == -12.5
assert wallets._positions["ETH/USDT:USDT"].collateral == 100.0

# 1017.5 is equity (wallet balance 1000 + 17.5 uPnL) - strip it only where it's there.
assert wallets.get_total("USDT") == (1000.0 if includes_upnl else 1017.5)
# free/used are untouched either way.
assert wallets.get_free("USDT") == 850
assert wallets.get_used("USDT") == 150


def test_sync_wallet_futures_live_no_positions_unchanged(mocker, default_conf_usdt):
"""Without open positions there is no uPnL to strip, whatever the exchange reports."""
default_conf_usdt["dry_run"] = False
default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = "isolated"
mocker.patch.multiple(
EXMS,
get_balances=MagicMock(return_value={"USDT": {"free": 1000, "used": 0, "total": 1000}}),
fetch_positions=MagicMock(return_value=[]),
balance_includes_unrealized_pnl=MagicMock(return_value=True),
)
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
assert freqtrade.wallets.get_total("USDT") == 1000


def test_sync_wallet_dry(mocker, default_conf_usdt, fee):
default_conf_usdt["dry_run"] = True
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
Expand Down
Loading