Skip to content

Commit

Permalink
Merge pull request freqtrade#11120 from freqtrade/feat/BNFCR
Browse files Browse the repository at this point in the history
Add support for BNFCR
  • Loading branch information
xmatthias authored Dec 30, 2024
2 parents d2beb07 + 10063b2 commit 503eb1e
Show file tree
Hide file tree
Showing 14 changed files with 179 additions and 36 deletions.
4 changes: 4 additions & 0 deletions build_helpers/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 22 additions & 1 deletion docs/exchanges.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/leverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions freqtrade/configuration/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
),
"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",
Expand Down
18 changes: 16 additions & 2 deletions freqtrade/exchange/binance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 19 additions & 7 deletions freqtrade/exchange/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -1863,6 +1864,14 @@ def get_tickers(
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.
Expand All @@ -1872,6 +1881,11 @@ def get_conversion_rate(self, coin: str, currency: str) -> float | None:
: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)
Expand All @@ -1889,7 +1903,7 @@ def get_conversion_rate(self, coin: str, currency: str) -> float | None:
)
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
Expand Down Expand Up @@ -2251,13 +2265,11 @@ def calculate_fee_rate(
# 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:
Expand Down
2 changes: 2 additions & 0 deletions freqtrade/exchange/exchange_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions freqtrade/freqtradebot.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def __init__(self, config: Config) -> None:

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
Expand Down
8 changes: 4 additions & 4 deletions freqtrade/leverage/liquidation_price.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions freqtrade/rpc/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,9 +689,10 @@ def __balance_get_est_stake(
) -> 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
Expand All @@ -701,7 +702,7 @@ def __balance_get_est_stake(
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
Expand Down Expand Up @@ -733,10 +734,15 @@ def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> dict:
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:
Expand Down
3 changes: 2 additions & 1 deletion freqtrade/wallets.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def __init__(self, config: Config, exchange: Exchange, is_backtest: bool = False
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
Expand Down
12 changes: 10 additions & 2 deletions tests/exchange/test_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion tests/freqtradebot/test_freqtradebot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 503eb1e

Please sign in to comment.