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.", "description": "The timeframe to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). \nUsually specified in the strategy and missing in the configuration.",
"type": "string" "type": "string"
}, },
"proxy_coin": {
"description": "Proxy coin - must be used for specific futures modes (e.g. BNFCR)",
"type": "string"
},
"stake_currency": { "stake_currency": {
"description": "Currency used for staking.", "description": "Currency used for staking.",
"type": "string" "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". 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. 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. 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
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. 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" "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. 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", "type": "string",
}, },
"proxy_coin": {
"description": "Proxy coin - must be used for specific futures modes (e.g. BNFCR)",
"type": "string",
},
"stake_currency": { "stake_currency": {
"description": "Currency used for staking.", "description": "Currency used for staking.",
"type": "string", "type": "string",

View File

@@ -48,15 +48,29 @@ class Binance(Exchange):
PriceType.MARK: "MARK_PRICE", PriceType.MARK: "MARK_PRICE",
}, },
"ws_enabled": False, "ws_enabled": False,
"proxy_coin_mapping": {
"BNFCR": "USDC",
"BFUSD": "USDT",
},
} }
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
# TradingMode.SPOT always supported and not required in this list # TradingMode.SPOT always supported and not required in this list
# (TradingMode.MARGIN, MarginMode.CROSS), # (TradingMode.MARGIN, MarginMode.CROSS),
# (TradingMode.FUTURES, MarginMode.CROSS), (TradingMode.FUTURES, MarginMode.CROSS),
(TradingMode.FUTURES, MarginMode.ISOLATED) (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( def get_tickers(
self, self,
symbols: list[str] | None = None, symbols: list[str] | None = None,

View File

@@ -156,6 +156,7 @@ class Exchange:
# Override createMarketBuyOrderRequiresPrice where ccxt has it wrong # Override createMarketBuyOrderRequiresPrice where ccxt has it wrong
"marketOrderRequiresPrice": False, "marketOrderRequiresPrice": False,
"exchange_has_overrides": {}, # Dictionary overriding ccxt's "has". "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} # Expected to be in the format {"fetchOHLCV": True} or {"fetchOHLCV": False}
"ws_enabled": False, # Set to true for exchanges with tested websocket support "ws_enabled": False, # Set to true for exchanges with tested websocket support
} }
@@ -1863,6 +1864,14 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from 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: def get_conversion_rate(self, coin: str, currency: str) -> float | None:
""" """
Quick and cached way to get conversion rate one currency to the other. 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 :returns: Conversion rate from coin to currency
:raises: ExchangeErrors :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: if coin == currency:
return 1.0 return 1.0
tickers = self.get_tickers(cached=True) tickers = self.get_tickers(cached=True)
@@ -1889,7 +1903,7 @@ class Exchange:
) )
ticker = tickers_other.get(pair, None) ticker = tickers_other.get(pair, None)
if ticker: 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): if rate and pair.startswith(currency) and not pair.endswith(currency):
rate = 1.0 / rate rate = 1.0 / rate
return rate return rate
@@ -2251,13 +2265,11 @@ class Exchange:
# If cost is None or 0.0 -> falsy, return None # If cost is None or 0.0 -> falsy, return None
return None return None
try: try:
for comb in self.get_valid_pair_combination( fee_to_quote_rate = self.get_conversion_rate(
fee_curr, self._config["stake_currency"] fee_curr, self._config["stake_currency"]
): )
tick = self.fetch_ticker(comb) if not fee_to_quote_rate:
fee_to_quote_rate = safe_value_fallback2(tick, tick, "last", "ask") raise ValueError("Conversion rate not found.")
if tick:
break
except (ValueError, ExchangeError): except (ValueError, ExchangeError):
fee_to_quote_rate = self._config["exchange"].get("unknown_fee_rate", None) fee_to_quote_rate = self._config["exchange"].get("unknown_fee_rate", None)
if not fee_to_quote_rate: if not fee_to_quote_rate:

View File

@@ -47,6 +47,8 @@ class FtHas(TypedDict, total=False):
needs_trading_fees: bool needs_trading_fees: bool
order_props_in_contracts: list[Literal["amount", "cost", "filled", "remaining"]] order_props_in_contracts: list[Literal["amount", "cost", "filled", "remaining"]]
proxy_coin_mapping: dict[str, str]
# Websocket control # Websocket control
ws_enabled: bool ws_enabled: bool

View File

@@ -162,6 +162,7 @@ class FreqtradeBot(LoggingMixin):
def update(): def update():
self.update_funding_fees() self.update_funding_fees()
self.update_all_liquidation_prices()
self.wallets.update() self.wallets.update()
# This would be more efficient if scheduled in utc time, and performed at each # 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: if dry_run:
# Parameters only needed for cross margin # Parameters only needed for cross margin
total_wallet_stake = wallets.get_collateral() 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() open_trades: list[Trade] = Trade.get_open_trades()
for t in open_trades: for t in open_trades:
# TODO: This should be done in a batch update # TODO: This should be done in a batch update

View File

@@ -689,9 +689,10 @@ class RPC:
) -> tuple[float, float]: ) -> tuple[float, float]:
est_stake = 0.0 est_stake = 0.0
est_bot_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 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 # in Futures, "total" includes the locked stake, and therefore all positions
est_stake = balance.free est_stake = balance.free
est_bot_stake = amount est_bot_stake = amount
@@ -701,7 +702,7 @@ class RPC:
coin, stake_currency coin, stake_currency
) )
if rate: if rate:
est_stake = rate * balance.total est_stake = rate * (balance.free if is_futures else balance.total)
est_bot_stake = rate * amount est_bot_stake = rate * amount
return est_stake, est_bot_stake return est_stake, est_bot_stake
@@ -733,10 +734,15 @@ class RPC:
if not balance.total and not balance.free: if not balance.total and not balance.free:
continue continue
trade = open_assets.get(coin, None) trade = (
is_bot_managed = coin == stake_currency or trade is not None 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 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() trade_amount = self._freqtrade.wallets.get_available_stake_amount()
try: try:

View File

@@ -41,7 +41,8 @@ class Wallets:
self._wallets: dict[str, Wallet] = {} self._wallets: dict[str, Wallet] = {}
self._positions: dict[str, PositionWallet] = {} self._positions: dict[str, PositionWallet] = {}
self._start_cap: dict[str, float] = {} 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): if isinstance(_start_cap := config["dry_run_wallet"], float | int):
self._start_cap[self._stake_currency] = _start_cap 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) mocker.patch(f"{EXMS}.exchange_has", return_value=True)
api_mock.fetch_tickers = MagicMock(side_effect=[tick, tick2]) api_mock.fetch_tickers = MagicMock(side_effect=[tick, tick2])
api_mock.fetch_bids_asks = MagicMock(return_value={}) 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) exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange=exchange_name)
# retrieve original ticker # 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 # Only the call to the "others" market
assert api_mock.fetch_tickers.call_count == 1 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) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_ticker(default_conf, mocker, exchange_name): 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: 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: if unknown_fee_rate:
default_conf["exchange"]["unknown_fee_rate"] = 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), ("okx", TradingMode.FUTURES, MarginMode.ISOLATED, False),
# * Remove once implemented # * Remove once implemented
("binance", TradingMode.MARGIN, MarginMode.CROSS, True), ("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.MARGIN, MarginMode.CROSS, True),
("kraken", TradingMode.FUTURES, MarginMode.CROSS, True), ("kraken", TradingMode.FUTURES, MarginMode.CROSS, True),
("gate", TradingMode.MARGIN, 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"] markets["BNB/ETH"] = markets["ETH/USDT"]
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets)) 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" # Amount is reduced by "fee"
expected_amount = amount * fee_reduction_amount 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"]) 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 = { mock_balance = {
"BTC": { "BTC": {
"free": 0.01, "free": 0.01,
@@ -562,6 +564,14 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
"used": 5.0, "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 = [ mock_pos = [
{ {
"symbol": "ETH/USDT:USDT", "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["dry_run"] = False
default_conf_usdt["trading_mode"] = "futures" default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = margin_mode
freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt)
patch_get_signal(freqtradebot) patch_get_signal(freqtradebot)
rpc = RPC(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"] default_conf_usdt["stake_currency"], default_conf_usdt["fiat_display_currency"]
) )
assert pytest.approx(result["total"]) == 2824.83464 assert tickers.call_count == 4 if not proxy_coin else 6
assert pytest.approx(result["value"]) == 2824.83464 * 1.2
assert tickers.call_count == 4
assert tickers.call_args_list[0][1]["cached"] is True assert tickers.call_args_list[0][1]["cached"] is True
# Testing futures - so we should get spot tickers # Testing futures - so we should get spot tickers
assert tickers.call_args_list[-1][1]["market_type"] == "spot" assert tickers.call_args_list[-1][1]["market_type"] == "spot"
assert "USD" == result["symbol"] assert "USD" == result["symbol"]
assert result["currencies"] == [ expected_curr = [
{ {
"currency": "BTC", "currency": "BTC",
"free": 0.01, "free": 0.01,
"balance": 0.012, "balance": 0.012,
"used": 0.002, "used": 0.002,
"bot_owned": 0, "bot_owned": 0,
"est_stake": 103.78464, "est_stake": 86.4872,
"est_stake_bot": 0, "est_stake_bot": 0,
"stake": "USDT", "stake": "USDT",
"side": "long", "side": "long",
@@ -642,7 +651,7 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
"balance": 5.0, "balance": 5.0,
"used": 4.0, "used": 4.0,
"bot_owned": 0, "bot_owned": 0,
"est_stake": 2651.05, "est_stake": 530.21,
"est_stake_bot": 0, "est_stake_bot": 0,
"stake": "USDT", "stake": "USDT",
"side": "long", "side": "long",
@@ -692,10 +701,71 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers):
"is_position": True, "is_position": True,
}, },
] ]
assert pytest.approx(result["total_bot"]) == 69.5 if proxy_coin:
assert pytest.approx(result["total"]) == 2824.83464 # ETH stake is missing. if margin_mode == "cross":
assert result["starting_capital"] == 50 * default_conf_usdt["tradable_balance_ratio"] # Insert before ETH - as positions are always last.
assert result["starting_capital_ratio"] == pytest.approx(0.4040404) 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: def test_rpc_start(mocker, default_conf) -> None: