diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index d7fb0a353..13582b183 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -144,6 +144,29 @@ class Binance(Exchange): """ return open_date.minute == 0 and open_date.second < 15 + def fetch_funding_rates( + self, symbols: Optional[List[str]] = None + ) -> Dict[str, Dict[str, float]]: + """ + Fetch funding rates for the given symbols. + :param symbols: List of symbols to fetch funding rates for + :return: Dict of funding rates for the given symbols + """ + try: + if self.trading_mode == TradingMode.FUTURES: + rates = self._api.fetch_funding_rates(symbols) + return rates + return {} + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.OperationFailed, ccxt.ExchangeError) as e: + raise TemporaryError( + f"Error in additional_exchange_init due to {e.__class__.__name__}. Message: {e}" + ) from e + + except ccxt.BaseError as e: + raise OperationalException(e) from e + def dry_run_liquidation_price( self, pair: str, @@ -153,8 +176,7 @@ class Binance(Exchange): stake_amount: float, leverage: float, wallet_balance: float, # Or margin balance - mm_ex_1: float = 0.0, # (Binance) Cross only - upnl_ex_1: float = 0.0, # (Binance) Cross only + open_trades: list, ) -> Optional[float]: """ Important: Must be fetching data from cached values as this is used by backtesting! @@ -172,6 +194,7 @@ class Binance(Exchange): :param wallet_balance: Amount of margin_mode in the wallet being used to trade Cross-Margin Mode: crossWalletBalance Isolated-Margin Mode: isolatedWalletBalance + :param open_trades: List of open trades in the same wallet # * Only required for Cross :param mm_ex_1: (TMM) @@ -180,15 +203,41 @@ class Binance(Exchange): :param upnl_ex_1: (UPNL) Cross-Margin Mode: Unrealized PNL of all other contracts, excluding Contract 1. Isolated-Margin Mode: 0 + :param other """ - - side_1 = -1 if is_short else 1 - cross_vars = upnl_ex_1 - mm_ex_1 if self.margin_mode == MarginMode.CROSS else 0.0 + cross_vars: float = 0.0 # mm_ratio: Binance's formula specifies maintenance margin rate which is mm_ratio * 100% # maintenance_amt: (CUM) Maintenance Amount of position mm_ratio, maintenance_amt = self.get_maintenance_ratio_and_amt(pair, stake_amount) + if self.margin_mode == MarginMode.CROSS: + mm_ex_1: float = 0.0 + upnl_ex_1: float = 0.0 + pairs = [trade.pair for trade in open_trades] + if self._config["runmode"] in ("live", "dry_run"): + funding_rates = self.fetch_funding_rates(pairs) + for trade in open_trades: + if trade.pair == pair: + # Only "other" trades are considered + continue + if self._config["runmode"] in ("live", "dry_run"): + mark_price = funding_rates[trade.pair]["markPrice"] + else: + # Fall back to open rate for backtesting + mark_price = trade.open_rate + mm_ratio1, maint_amnt1 = self.get_maintenance_ratio_and_amt( + trade.pair, trade.stake_amount + ) + maint_margin = trade.amount * mark_price * mm_ratio1 - maint_amnt1 + mm_ex_1 += maint_margin + + upnl_ex_1 += trade.amount * mark_price - trade.amount * trade.open_rate + + cross_vars = upnl_ex_1 - mm_ex_1 + + side_1 = -1 if is_short else 1 + if maintenance_amt is None: raise OperationalException( "Parameter maintenance_amt is required by Binance.liquidation_price" diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index 719c64dc3..967c80b7d 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -147,8 +147,7 @@ class Bybit(Exchange): stake_amount: float, leverage: float, wallet_balance: float, # Or margin balance - mm_ex_1: float = 0.0, # (Binance) Cross only - upnl_ex_1: float = 0.0, # (Binance) Cross only + open_trades: list, ) -> Optional[float]: """ Important: Must be fetching data from cached values as this is used by backtesting! @@ -178,6 +177,7 @@ class Bybit(Exchange): :param wallet_balance: Amount of margin_mode in the wallet being used to trade Cross-Margin Mode: crossWalletBalance Isolated-Margin Mode: isolatedWalletBalance + :param open_trades: List of other open trades in the same wallet """ market = self.markets[pair] diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 9d84f59e4..c0b4a72e9 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -3532,8 +3532,7 @@ class Exchange: stake_amount: float, leverage: float, wallet_balance: float, - mm_ex_1: float = 0.0, # (Binance) Cross only - upnl_ex_1: float = 0.0, # (Binance) Cross only + open_trades: Optional[list] = None, ) -> Optional[float]: """ Set's the margin mode on the exchange to cross or isolated for a specific pair @@ -3555,8 +3554,7 @@ class Exchange: leverage=leverage, stake_amount=stake_amount, wallet_balance=wallet_balance, - mm_ex_1=mm_ex_1, - upnl_ex_1=upnl_ex_1, + open_trades=open_trades or [], ) else: positions = self.fetch_positions(pair) @@ -3582,8 +3580,7 @@ class Exchange: stake_amount: float, leverage: float, wallet_balance: float, # Or margin balance - mm_ex_1: float = 0.0, # (Binance) Cross only - upnl_ex_1: float = 0.0, # (Binance) Cross only + open_trades: list, ) -> Optional[float]: """ Important: Must be fetching data from cached values as this is used by backtesting! @@ -3608,10 +3605,7 @@ class Exchange: :param wallet_balance: Amount of margin_mode in the wallet being used to trade Cross-Margin Mode: crossWalletBalance Isolated-Margin Mode: isolatedWalletBalance - - # * Not required by Gate or OKX - :param mm_ex_1: - :param upnl_ex_1: + :param open_trades: List of other open trades in the same wallet """ market = self.markets[pair] diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index fe270f670..ac132bea5 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -43,6 +43,7 @@ from freqtrade.exchange import ( timeframe_to_next_date, timeframe_to_seconds, ) +from freqtrade.leverage.liquidation_price import update_liquidation_prices from freqtrade.misc import safe_value_fallback, safe_value_fallback2 from freqtrade.mixins import LoggingMixin from freqtrade.persistence import Order, PairLocks, Trade, init_db @@ -241,6 +242,7 @@ class FreqtradeBot(LoggingMixin): # Only update open orders on startup # This will update the database after the initial migration self.startup_update_open_orders() + self.update_all_liquidation_prices() self.update_funding_fees() def process(self) -> None: @@ -357,6 +359,16 @@ class FreqtradeBot(LoggingMixin): open_trades = Trade.get_open_trade_count() return max(0, self.config["max_open_trades"] - open_trades) + def update_all_liquidation_prices(self) -> None: + if self.trading_mode == TradingMode.FUTURES and self.margin_mode == MarginMode.CROSS: + # Update liquidation prices for all trades in cross margin mode + update_liquidation_prices( + exchange=self.exchange, + wallets=self.wallets, + stake_currency=self.config["stake_currency"], + dry_run=self.config["dry_run"], + ) + def update_funding_fees(self) -> None: if self.trading_mode == TradingMode.FUTURES: trades: List[Trade] = Trade.get_open_trades() @@ -2233,20 +2245,13 @@ class FreqtradeBot(LoggingMixin): # Must also run for partial exits # TODO: Margin will need to use interest_rate as well. # interest_rate = self.exchange.get_interest_rate() - try: - trade.set_liquidation_price( - self.exchange.get_liquidation_price( - pair=trade.pair, - open_rate=trade.open_rate, - is_short=trade.is_short, - amount=trade.amount, - stake_amount=trade.stake_amount, - leverage=trade.leverage, - wallet_balance=trade.stake_amount, - ) - ) - except DependencyException: - logger.warning("Unable to calculate liquidation price") + update_liquidation_prices( + trade, + exchange=self.exchange, + wallets=self.wallets, + stake_currency=self.config["stake_currency"], + dry_run=self.config["dry_run"], + ) if self.strategy.use_custom_stoploss: current_rate = self.exchange.get_rate( trade.pair, side="exit", is_short=trade.is_short, refresh=True diff --git a/freqtrade/leverage/liquidation_price.py b/freqtrade/leverage/liquidation_price.py new file mode 100644 index 000000000..af6ef0d44 --- /dev/null +++ b/freqtrade/leverage/liquidation_price.py @@ -0,0 +1,66 @@ +import logging +from typing import Optional + +from freqtrade.enums import MarginMode +from freqtrade.exceptions import DependencyException +from freqtrade.exchange import Exchange +from freqtrade.persistence import LocalTrade, Trade +from freqtrade.wallets import Wallets + + +logger = logging.getLogger(__name__) + + +def update_liquidation_prices( + trade: Optional[LocalTrade] = None, + *, + exchange: Exchange, + wallets: Wallets, + stake_currency: str, + dry_run: bool = False, +): + """ + Update trade liquidation price in isolated margin mode. + Updates liquidation price for all trades in cross margin mode. + """ + try: + if exchange.margin_mode == MarginMode.CROSS: + total_wallet_stake = 0.0 + if dry_run: + # Parameters only needed for cross margin + total_wallet_stake = wallets.get_total(stake_currency) + + logger.info("Updating liquidation price for all open trades.") + open_trades = Trade.get_open_trades() + for t in open_trades: + # TODO: This should be done in a batch update + t.set_liquidation_price( + exchange.get_liquidation_price( + pair=t.pair, + open_rate=t.open_rate, + is_short=t.is_short, + amount=t.amount, + stake_amount=t.stake_amount, + leverage=t.leverage, + wallet_balance=total_wallet_stake, + open_trades=open_trades, + ) + ) + elif trade: + trade.set_liquidation_price( + exchange.get_liquidation_price( + pair=trade.pair, + open_rate=trade.open_rate, + is_short=trade.is_short, + amount=trade.amount, + stake_amount=trade.stake_amount, + leverage=trade.leverage, + wallet_balance=trade.stake_amount, + ) + ) + else: + raise DependencyException( + "Trade object is required for updating liquidation price in isolated margin mode." + ) + except DependencyException: + logger.warning("Unable to calculate liquidation price") diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 75c0ac075..7d0428939 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -26,6 +26,7 @@ from freqtrade.enums import ( CandleType, ExitCheckTuple, ExitType, + MarginMode, RunMode, TradingMode, ) @@ -37,6 +38,7 @@ from freqtrade.exchange import ( ) from freqtrade.exchange.exchange import Exchange from freqtrade.ft_types import BacktestResultType, get_BacktestResultType_default +from freqtrade.leverage.liquidation_price import update_liquidation_prices from freqtrade.mixins import LoggingMixin from freqtrade.optimize.backtest_caching import get_strategy_run_id from freqtrade.optimize.bt_progress import BTProgress @@ -206,6 +208,7 @@ class Backtesting: self.required_startup = self.dataprovider.get_required_startup(self.timeframe) self.trading_mode: TradingMode = config.get("trading_mode", TradingMode.SPOT) + self.margin_mode: MarginMode = config.get("margin_mode", MarginMode.ISOLATED) # strategies which define "can_short=True" will fail to load in Spot mode. self._can_short = self.trading_mode != TradingMode.SPOT self._position_stacking: bool = self.config.get("position_stacking", False) @@ -698,21 +701,20 @@ class Backtesting: current_time=current_date, ) - if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount): - # trade is still open - trade.set_liquidation_price( - self.exchange.get_liquidation_price( - pair=trade.pair, - open_rate=trade.open_rate, - is_short=trade.is_short, - amount=trade.amount, - stake_amount=trade.stake_amount, - leverage=trade.leverage, - wallet_balance=trade.stake_amount, - ) + if self.margin_mode == MarginMode.CROSS or not ( + order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount + ): + # trade is still open or we are in cross margin mode and + # must update all liquidation prices + update_liquidation_prices( + trade, + exchange=self.exchange, + wallets=self.wallets, + stake_currency=self.config["stake_currency"], + dry_run=self.config["dry_run"], ) + if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount): self._call_adjust_stop(current_date, trade, order.ft_price) - # pass return True return False diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 698e9721c..5ff42ed64 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -760,7 +760,7 @@ class LocalTrade: Method you should use to set self.liquidation price. Assures stop_loss is not passed the liquidation price """ - if not liquidation_price: + if liquidation_price is None: return self.liquidation_price = liquidation_price diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 7b0831520..623a9f17a 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -7,6 +7,7 @@ import pytest from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exceptions import DependencyException, InvalidOrderException, OperationalException +from freqtrade.persistence import Trade from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has_re from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -171,59 +172,101 @@ def test_stoploss_adjust_binance(mocker, default_conf, sl1, sl2, sl3, side): @pytest.mark.parametrize( - "is_short, trading_mode, margin_mode, wallet_balance, " - "mm_ex_1, upnl_ex_1, maintenance_amt, amount, open_rate, " + "pair, is_short, trading_mode, margin_mode, wallet_balance, " + "maintenance_amt, amount, open_rate, open_trades," "mm_ratio, expected", [ ( + "ETH/USDT:USDT", False, "futures", "isolated", 1535443.01, - 0.0, - 0.0, 135365.00, 3683.979, 1456.84, + [], 0.10, 1114.78, ), ( + "ETH/USDT:USDT", False, "futures", "isolated", 1535443.01, - 0.0, - 0.0, 16300.000, 109.488, 32481.980, + [], 0.025, 18778.73, ), ( + "ETH/USDT:USDT", False, "futures", "cross", 1535443.01, - 71200.81144, - -56354.57, 135365.00, - 3683.979, - 1456.84, + 3683.979, # amount + 1456.84, # open_rate + [ + { + # From calc example + "pair": "BTC/USDT:USDT", + "open_rate": 32481.98, + "amount": 109.488, + "stake_amount": 3556387.02624, # open_rate * amount + "mark_price": 31967.27, + "mm_ratio": 0.025, + "maintenance_amt": 16300.0, + }, + { + # From calc example + "pair": "ETH/USDT:USDT", + "open_rate": 1456.84, + "amount": 3683.979, + "stake_amount": 5366967.96, + "mark_price": 1335.18, + "mm_ratio": 0.10, + "maintenance_amt": 135365.00, + }, + ], 0.10, 1153.26, ), ( + "BTC/USDT:USDT", False, "futures", "cross", 1535443.01, - 356512.508, - -448192.89, - 16300.000, - 109.488, - 32481.980, + 16300.0, + 109.488, # amount + 32481.980, # open_rate + [ + { + # From calc example + "pair": "BTC/USDT:USDT", + "open_rate": 32481.98, + "amount": 109.488, + "stake_amount": 3556387.02624, # open_rate * amount + "mark_price": 31967.27, + "mm_ratio": 0.025, + "maintenance_amt": 16300.0, + }, + { + # From calc example + "pair": "ETH/USDT:USDT", + "open_rate": 1456.84, + "amount": 3683.979, + "stake_amount": 5366967.96, + "mark_price": 1335.18, + "mm_ratio": 0.10, + "maintenance_amt": 135365.00, + }, + ], 0.025, 26316.89, ), @@ -232,15 +275,15 @@ def test_stoploss_adjust_binance(mocker, default_conf, sl1, sl2, sl3, side): def test_liquidation_price_binance( mocker, default_conf, - open_rate, + pair, is_short, trading_mode, margin_mode, wallet_balance, - mm_ex_1, - upnl_ex_1, maintenance_amt, amount, + open_rate, + open_trades, mm_ratio, expected, ): @@ -248,20 +291,48 @@ def test_liquidation_price_binance( default_conf["margin_mode"] = margin_mode default_conf["liquidation_buffer"] = 0.0 exchange = get_patched_exchange(mocker, default_conf, exchange="binance") - exchange.get_maintenance_ratio_and_amt = MagicMock(return_value=(mm_ratio, maintenance_amt)) + + def get_maint_ratio(pair_, stake_amount): + if pair_ != pair: + oc = [c for c in open_trades if c["pair"] == pair_][0] + return oc["mm_ratio"], oc["maintenance_amt"] + return mm_ratio, maintenance_amt + + def fetch_funding_rates(*args, **kwargs): + return { + t["pair"]: { + "symbol": t["pair"], + "markPrice": t["mark_price"], + } + for t in open_trades + } + + exchange.get_maintenance_ratio_and_amt = get_maint_ratio + exchange.fetch_funding_rates = fetch_funding_rates + + open_trade_objects = [ + Trade( + pair=t["pair"], + open_rate=t["open_rate"], + amount=t["amount"], + stake_amount=t["stake_amount"], + fee_open=0, + ) + for t in open_trades + ] + assert ( pytest.approx( round( exchange.get_liquidation_price( - pair="DOGE/USDT", + pair=pair, open_rate=open_rate, is_short=is_short, wallet_balance=wallet_balance, - mm_ex_1=mm_ex_1, - upnl_ex_1=upnl_ex_1, amount=amount, stake_amount=open_rate * amount, leverage=5, + open_trades=open_trade_objects, ), 2, ) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index d71d2062e..35d0b18dd 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -5524,8 +5524,6 @@ def test_liquidation_price_is_none( stake_amount=open_rate * 71200.81144, leverage=5, wallet_balance=-56354.57, - mm_ex_1=0.10, - upnl_ex_1=0.0, ) is None ) @@ -6011,6 +6009,7 @@ def test_get_liquidation_price1(mocker, default_conf): stake_amount=18.884 * 0.8, leverage=leverage, wallet_balance=18.884 * 0.8, + open_trades=[], ) @@ -6141,6 +6140,7 @@ def test_get_liquidation_price( wallet_balance=amount * open_rate / leverage, leverage=leverage, is_short=is_short, + open_trades=[], ) if expected_liq is None: assert liq is None diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index fbb6bca05..32133d8e5 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -457,6 +457,7 @@ class TestCCXTExchange: stake_amount=100, leverage=5, wallet_balance=100, + open_trades=[], ) assert isinstance(liquidation_price, float) assert liquidation_price >= 0.0 @@ -469,6 +470,7 @@ class TestCCXTExchange: stake_amount=100, leverage=5, wallet_balance=100, + open_trades=[], ) assert isinstance(liquidation_price, float) assert liquidation_price >= 0.0 diff --git a/tests/leverage/test_update_liquidation_price.py b/tests/leverage/test_update_liquidation_price.py new file mode 100644 index 000000000..1b25babd0 --- /dev/null +++ b/tests/leverage/test_update_liquidation_price.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock + +import pytest + +from freqtrade.enums.marginmode import MarginMode +from freqtrade.leverage.liquidation_price import update_liquidation_prices + + +@pytest.mark.parametrize("dry_run", [False, True]) +@pytest.mark.parametrize("margin_mode", [MarginMode.CROSS, MarginMode.ISOLATED]) +def test_update_liquidation_prices(mocker, margin_mode, dry_run): + # Heavily mocked test - Only testing the logic of the function + # update liquidation price for trade in isolated mode + # update liquidation price for all trades in cross mode + exchange = MagicMock() + exchange.margin_mode = margin_mode + wallets = MagicMock() + trade_mock = MagicMock() + + mocker.patch("freqtrade.persistence.Trade.get_open_trades", return_value=[trade_mock]) + + update_liquidation_prices( + trade=trade_mock, + exchange=exchange, + wallets=wallets, + stake_currency="USDT", + dry_run=dry_run, + ) + + assert trade_mock.set_liquidation_price.call_count == 1 + + assert wallets.get_total.call_count == ( + 0 if margin_mode == MarginMode.ISOLATED or not dry_run else 1 + ) + + # Test with multiple trades + trade_mock.reset_mock() + trade_mock_2 = MagicMock() + + mocker.patch( + "freqtrade.persistence.Trade.get_open_trades", return_value=[trade_mock, trade_mock_2] + ) + + update_liquidation_prices( + trade=trade_mock, + exchange=exchange, + wallets=wallets, + stake_currency="USDT", + dry_run=dry_run, + ) + # Trade2 is only updated in cross mode + assert trade_mock_2.set_liquidation_price.call_count == ( + 1 if margin_mode == MarginMode.CROSS else 0 + ) + assert trade_mock.set_liquidation_price.call_count == 1 + + assert wallets.call_count == 0 if not dry_run else 1