Merge pull request #11120 from freqtrade/feat/BNFCR

Add support for BNFCR
This commit is contained in:
Matthias
2024-12-30 17:31:47 +01:00
committed by GitHub
14 changed files with 179 additions and 36 deletions

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

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