diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 5c9844975..18fbdd2ab 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -13,6 +13,10 @@ "description": "The timeframe to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). \nUsually specified in the strategy and missing in the configuration.", "type": "string" }, + "proxy_coin": { + "description": "Proxy coin - must be used for specific futures modes (e.g. BNFCR)", + "type": "string" + }, "stake_currency": { "description": "Currency used for staking.", "type": "string" diff --git a/docs/exchanges.md b/docs/exchanges.md index ca041346c..d794ef1fe 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -118,7 +118,7 @@ When trading on Binance Futures market, orderbook must be used because there is }, ``` -#### Binance futures settings +#### Binance isolated futures settings Users will also have to have the futures-setting "Position Mode" set to "One-way Mode", and "Asset Mode" set to "Single-Asset Mode". These settings will be checked on startup, and freqtrade will show an error if this setting is wrong. @@ -127,6 +127,27 @@ These settings will be checked on startup, and freqtrade will show an error if t Freqtrade will not attempt to change these settings. +#### Binance BNFCR futures + +BNFCR mode are a special type of futures mode on Binance to work around regulatory issues in Europe. +To use BNFCR futures, you will have to have the following combination of settings: + +``` jsonc +{ + // ... + "trading_mode": "futures", + "margin_mode": "cross", + "proxy_coin": "BNFCR", + "stake_currency": "USDT" // or "USDC" + // ... +} +``` + +The `stake_currency` setting defines the markets the bot will be operating in. This choice is really arbitrary. + +On the exchange, you'll have to use "Multi-asset Mode" - and "Position Mode set to "One-way Mode". +Freqtrade will check these settings on startup, but won't attempt to change them. + ## Bingx BingX supports [time_in_force](configuration.md#understand-order_time_in_force) with settings "GTC" (good till cancelled), "IOC" (immediate-or-cancel) and "PO" (Post only) settings. diff --git a/docs/leverage.md b/docs/leverage.md index 2fbd13145..d1517e2d3 100644 --- a/docs/leverage.md +++ b/docs/leverage.md @@ -82,7 +82,7 @@ Each market(trading pair), keeps collateral in a separate account "margin_mode": "isolated" ``` -#### Cross margin mode (currently unavailable) +#### Cross margin mode One account is used to share collateral between markets (trading pairs). Margin is taken from total account balance to avoid liquidation when needed. diff --git a/freqtrade/configuration/config_schema.py b/freqtrade/configuration/config_schema.py index 9af3f0950..fbd75412f 100644 --- a/freqtrade/configuration/config_schema.py +++ b/freqtrade/configuration/config_schema.py @@ -40,6 +40,10 @@ CONF_SCHEMA = { ), "type": "string", }, + "proxy_coin": { + "description": "Proxy coin - must be used for specific futures modes (e.g. BNFCR)", + "type": "string", + }, "stake_currency": { "description": "Currency used for staking.", "type": "string", diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 74a37b4f6..a5a986d1f 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -48,15 +48,29 @@ class Binance(Exchange): PriceType.MARK: "MARK_PRICE", }, "ws_enabled": False, + "proxy_coin_mapping": { + "BNFCR": "USDC", + "BFUSD": "USDT", + }, } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ # TradingMode.SPOT always supported and not required in this list # (TradingMode.MARGIN, MarginMode.CROSS), - # (TradingMode.FUTURES, MarginMode.CROSS), - (TradingMode.FUTURES, MarginMode.ISOLATED) + (TradingMode.FUTURES, MarginMode.CROSS), + (TradingMode.FUTURES, MarginMode.ISOLATED), ] + def get_proxy_coin(self) -> str: + """ + Get the proxy coin for the given coin + Falls back to the stake currency if no proxy coin is found + :return: Proxy coin or stake currency + """ + if self.margin_mode == MarginMode.CROSS: + return self._config.get("proxy_coin", self._config["stake_currency"]) + return self._config["stake_currency"] + def get_tickers( self, symbols: list[str] | None = None, diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 57d0baee3..354ed176b 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -156,6 +156,7 @@ class Exchange: # Override createMarketBuyOrderRequiresPrice where ccxt has it wrong "marketOrderRequiresPrice": False, "exchange_has_overrides": {}, # Dictionary overriding ccxt's "has". + "proxy_coin_mapping": {}, # Mapping for proxy coins # Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False} "ws_enabled": False, # Set to true for exchanges with tested websocket support } @@ -1863,6 +1864,14 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + def get_proxy_coin(self) -> str: + """ + Get the proxy coin for the given coin + Falls back to the stake currency if no proxy coin is found + :return: Proxy coin or stake currency + """ + return self._config["stake_currency"] + def get_conversion_rate(self, coin: str, currency: str) -> float | None: """ Quick and cached way to get conversion rate one currency to the other. @@ -1872,6 +1881,11 @@ class Exchange: :returns: Conversion rate from coin to currency :raises: ExchangeErrors """ + + if (proxy_coin := self._ft_has["proxy_coin_mapping"].get(coin, None)) is not None: + coin = proxy_coin + if (proxy_currency := self._ft_has["proxy_coin_mapping"].get(currency, None)) is not None: + currency = proxy_currency if coin == currency: return 1.0 tickers = self.get_tickers(cached=True) @@ -1889,7 +1903,7 @@ class Exchange: ) ticker = tickers_other.get(pair, None) if ticker: - rate: float | None = ticker.get("last", None) + rate: float | None = safe_value_fallback2(ticker, ticker, "last", "ask", None) if rate and pair.startswith(currency) and not pair.endswith(currency): rate = 1.0 / rate return rate @@ -2251,13 +2265,11 @@ class Exchange: # If cost is None or 0.0 -> falsy, return None return None try: - for comb in self.get_valid_pair_combination( + fee_to_quote_rate = self.get_conversion_rate( fee_curr, self._config["stake_currency"] - ): - tick = self.fetch_ticker(comb) - fee_to_quote_rate = safe_value_fallback2(tick, tick, "last", "ask") - if tick: - break + ) + if not fee_to_quote_rate: + raise ValueError("Conversion rate not found.") except (ValueError, ExchangeError): fee_to_quote_rate = self._config["exchange"].get("unknown_fee_rate", None) if not fee_to_quote_rate: diff --git a/freqtrade/exchange/exchange_types.py b/freqtrade/exchange/exchange_types.py index 69741dc65..9687057bd 100644 --- a/freqtrade/exchange/exchange_types.py +++ b/freqtrade/exchange/exchange_types.py @@ -47,6 +47,8 @@ class FtHas(TypedDict, total=False): needs_trading_fees: bool order_props_in_contracts: list[Literal["amount", "cost", "filled", "remaining"]] + proxy_coin_mapping: dict[str, str] + # Websocket control ws_enabled: bool diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 52175e687..feb557f89 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -162,6 +162,7 @@ class FreqtradeBot(LoggingMixin): def update(): self.update_funding_fees() + self.update_all_liquidation_prices() self.wallets.update() # This would be more efficient if scheduled in utc time, and performed at each diff --git a/freqtrade/leverage/liquidation_price.py b/freqtrade/leverage/liquidation_price.py index b48190d84..5440da64a 100644 --- a/freqtrade/leverage/liquidation_price.py +++ b/freqtrade/leverage/liquidation_price.py @@ -28,11 +28,11 @@ def update_liquidation_prices( if dry_run: # Parameters only needed for cross margin total_wallet_stake = wallets.get_collateral() + logger.info( + "Updating liquidation price for all open trades. " + f"Collateral {total_wallet_stake} {stake_currency}." + ) - logger.info( - "Updating liquidation price for all open trades. " - f"Collateral {total_wallet_stake} {stake_currency}." - ) open_trades: list[Trade] = Trade.get_open_trades() for t in open_trades: # TODO: This should be done in a batch update diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index ce19299f9..a890eeb21 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -689,9 +689,10 @@ class RPC: ) -> tuple[float, float]: est_stake = 0.0 est_bot_stake = 0.0 - if coin == stake_currency: + is_futures = self._config.get("trading_mode", TradingMode.SPOT) == TradingMode.FUTURES + if coin == self._freqtrade.exchange.get_proxy_coin(): est_stake = balance.total - if self._config.get("trading_mode", TradingMode.SPOT) != TradingMode.SPOT: + if is_futures: # in Futures, "total" includes the locked stake, and therefore all positions est_stake = balance.free est_bot_stake = amount @@ -701,7 +702,7 @@ class RPC: coin, stake_currency ) if rate: - est_stake = rate * balance.total + est_stake = rate * (balance.free if is_futures else balance.total) est_bot_stake = rate * amount return est_stake, est_bot_stake @@ -733,10 +734,15 @@ class RPC: if not balance.total and not balance.free: continue - trade = open_assets.get(coin, None) - is_bot_managed = coin == stake_currency or trade is not None + trade = ( + open_assets.get(coin, None) + if self._freqtrade.trading_mode != TradingMode.FUTURES + else None + ) + is_stake_currency = coin == self._freqtrade.exchange.get_proxy_coin() + is_bot_managed = is_stake_currency or trade is not None trade_amount = trade.amount if trade else 0 - if coin == stake_currency: + if is_stake_currency: trade_amount = self._freqtrade.wallets.get_available_stake_amount() try: diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index e0fb1f556..eea90ce33 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -41,7 +41,8 @@ class Wallets: self._wallets: dict[str, Wallet] = {} self._positions: dict[str, PositionWallet] = {} self._start_cap: dict[str, float] = {} - self._stake_currency = config["stake_currency"] + + self._stake_currency = self._exchange.get_proxy_coin() if isinstance(_start_cap := config["dry_run_wallet"], float | int): self._start_cap[self._stake_currency] = _start_cap diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 260dd8f0e..63d29186c 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -2028,6 +2028,7 @@ def test_get_conversion_rate(default_conf_usdt, mocker, exchange_name): mocker.patch(f"{EXMS}.exchange_has", return_value=True) api_mock.fetch_tickers = MagicMock(side_effect=[tick, tick2]) api_mock.fetch_bids_asks = MagicMock(return_value={}) + default_conf_usdt["trading_mode"] = "futures" exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange=exchange_name) # retrieve original ticker @@ -2045,6 +2046,13 @@ def test_get_conversion_rate(default_conf_usdt, mocker, exchange_name): # Only the call to the "others" market assert api_mock.fetch_tickers.call_count == 1 + if exchange_name == "binance": + # Special binance case of BNFCR matching USDT. + assert exchange.get_conversion_rate("BNFCR", "USDT") is None + assert exchange.get_conversion_rate("BNFCR", "USDC") == 1 + assert exchange.get_conversion_rate("USDT", "BNFCR") is None + assert exchange.get_conversion_rate("USDC", "BNFCR") == 1 + @pytest.mark.parametrize("exchange_name", EXCHANGES) def test_fetch_ticker(default_conf, mocker, exchange_name): @@ -4721,7 +4729,7 @@ def test_extract_cost_curr_rate(mocker, default_conf, order, expected) -> None: ], ) def test_calculate_fee_rate(mocker, default_conf, order, expected, unknown_fee_rate) -> None: - mocker.patch(f"{EXMS}.fetch_ticker", return_value={"last": 0.081}) + mocker.patch(f"{EXMS}.get_tickers", return_value={"NEO/BTC": {"last": 0.081}}) if unknown_fee_rate: default_conf["exchange"]["unknown_fee_rate"] = unknown_fee_rate @@ -4898,7 +4906,7 @@ def test_set_margin_mode(mocker, default_conf, margin_mode): ("okx", TradingMode.FUTURES, MarginMode.ISOLATED, False), # * Remove once implemented ("binance", TradingMode.MARGIN, MarginMode.CROSS, True), - ("binance", TradingMode.FUTURES, MarginMode.CROSS, True), + ("binance", TradingMode.FUTURES, MarginMode.CROSS, False), ("kraken", TradingMode.MARGIN, MarginMode.CROSS, True), ("kraken", TradingMode.FUTURES, MarginMode.CROSS, True), ("gate", TradingMode.MARGIN, MarginMode.CROSS, True), diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index 0ea7e1124..7a30713bc 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -3980,7 +3980,7 @@ def test_get_real_amount_multi( markets["BNB/ETH"] = markets["ETH/USDT"] freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets)) - mocker.patch(f"{EXMS}.fetch_ticker", return_value={"ask": 0.19, "last": 0.2}) + mocker.patch(f"{EXMS}.get_conversion_rate", return_value=0.2) # Amount is reduced by "fee" expected_amount = amount * fee_reduction_amount diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index d0ec44061..4d8618ccc 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -537,7 +537,9 @@ def test_rpc_balance_handle_error(default_conf, mocker): assert all(currency["currency"] != "ETH" for currency in res["currencies"]) -def test_rpc_balance_handle(default_conf_usdt, mocker, tickers): +@pytest.mark.parametrize("proxy_coin", [None, "BNFCR"]) +@pytest.mark.parametrize("margin_mode", ["isolated", "cross"]) +def test_rpc_balance_handle(default_conf_usdt, mocker, tickers, proxy_coin, margin_mode): mock_balance = { "BTC": { "free": 0.01, @@ -562,6 +564,14 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers): "used": 5.0, }, } + if proxy_coin: + default_conf_usdt["proxy_coin"] = proxy_coin + mock_balance[proxy_coin] = { + "free": 1500.0, + "total": 0.0, + "used": 0.0, + } + mock_pos = [ { "symbol": "ETH/USDT:USDT", @@ -605,6 +615,7 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers): ) default_conf_usdt["dry_run"] = False default_conf_usdt["trading_mode"] = "futures" + default_conf_usdt["margin_mode"] = margin_mode freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) @@ -614,21 +625,19 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers): default_conf_usdt["stake_currency"], default_conf_usdt["fiat_display_currency"] ) - assert pytest.approx(result["total"]) == 2824.83464 - assert pytest.approx(result["value"]) == 2824.83464 * 1.2 - assert tickers.call_count == 4 + assert tickers.call_count == 4 if not proxy_coin else 6 assert tickers.call_args_list[0][1]["cached"] is True # Testing futures - so we should get spot tickers assert tickers.call_args_list[-1][1]["market_type"] == "spot" assert "USD" == result["symbol"] - assert result["currencies"] == [ + expected_curr = [ { "currency": "BTC", "free": 0.01, "balance": 0.012, "used": 0.002, "bot_owned": 0, - "est_stake": 103.78464, + "est_stake": 86.4872, "est_stake_bot": 0, "stake": "USDT", "side": "long", @@ -642,7 +651,7 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers): "balance": 5.0, "used": 4.0, "bot_owned": 0, - "est_stake": 2651.05, + "est_stake": 530.21, "est_stake_bot": 0, "stake": "USDT", "side": "long", @@ -692,10 +701,71 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers): "is_position": True, }, ] - assert pytest.approx(result["total_bot"]) == 69.5 - assert pytest.approx(result["total"]) == 2824.83464 # ETH stake is missing. - assert result["starting_capital"] == 50 * default_conf_usdt["tradable_balance_ratio"] - assert result["starting_capital_ratio"] == pytest.approx(0.4040404) + if proxy_coin: + if margin_mode == "cross": + # Insert before ETH - as positions are always last. + expected_curr.insert( + len(expected_curr) - 1, + { + "currency": proxy_coin, + "free": 1500.0, + "balance": 0.0, + "used": 0.0, + "bot_owned": 1485.0, + "est_stake": 1500.0, + "est_stake_bot": 1485.0, + "stake": "USDT", + "side": "long", + "position": 0, + "is_bot_managed": True, + "is_position": False, + }, + ) + expected_curr[-3] = { + "currency": "USDT", + "free": 50.0, + "balance": 100.0, + "used": 5.0, + "bot_owned": 0, + "est_stake": 50.0, + "est_stake_bot": 0, + "stake": "USDT", + "side": "long", + "position": 0, + "is_bot_managed": False, + "is_position": False, + } + else: + expected_curr.insert( + len(expected_curr) - 1, + { + "currency": proxy_coin, + "free": 1500.0, + "balance": 0.0, + "used": 0.0, + "bot_owned": 0.0, + "est_stake": 0, + "est_stake_bot": 0, + "stake": "USDT", + "side": "long", + "position": 0, + "is_bot_managed": False, + "is_position": False, + }, + ) + + assert result["currencies"] == expected_curr + if proxy_coin and margin_mode == "cross": + assert pytest.approx(result["total_bot"]) == 1505.0 + assert pytest.approx(result["total"]) == 2186.6972 # ETH stake is missing. + assert result["starting_capital"] == 1500 * default_conf_usdt["tradable_balance_ratio"] + assert result["starting_capital_ratio"] == pytest.approx(0.013468013468013407) + else: + assert pytest.approx(result["total_bot"]) == 69.5 + assert pytest.approx(result["total"]) == 686.6972 # ETH stake is missing. + assert result["starting_capital"] == 50 * default_conf_usdt["tradable_balance_ratio"] + assert result["starting_capital_ratio"] == pytest.approx(0.4040404) + assert pytest.approx(result["value"]) == result["total"] * 1.2 def test_rpc_start(mocker, default_conf) -> None: