Files
freqtrade/freqtrade/exchange/binance.py
T
2026-04-09 06:39:51 +02:00

584 lines
22 KiB
Python

"""Binance exchange subclass"""
import logging
from datetime import UTC, datetime
from pathlib import Path
import ccxt
from pandas import DataFrame
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
from freqtrade.enums import TRADE_MODES, CandleType, MarginMode, PriceType, RunMode, TradingMode
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
from freqtrade.exchange import Exchange
from freqtrade.exchange.binance_public_data import (
concat_safe,
download_archive_ohlcv,
download_archive_trades,
)
from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange_types import FtHas, Tickers
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.util import FtTTLCache
from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts
logger = logging.getLogger(__name__)
class Binance(Exchange):
"""Binance exchange class.
Contains adjustments needed for Freqtrade to work with this exchange.
"""
_ft_has: FtHas = {
"stoploss_on_exchange": True,
"stop_price_param": "stopPrice",
"stop_price_prop": "stopPrice",
"stoploss_order_types": {"limit": "stop_loss_limit"},
"stoploss_blocks_assets": True, # By default stoploss orders block assets
"order_time_in_force": ["GTC", "FOK", "IOC", "PO"],
"trades_pagination": "id",
"trades_pagination_arg": "fromId",
"trades_has_history": True,
"fetch_orders_limit_minutes": None,
"l2_limit_range": [5, 10, 20, 50, 100, 500, 1000],
"ws_enabled": True,
"has_delisting": True,
# Demo trading
# https://www.binance.com/en/support/faq/detail/9be58f73e5e14338809e3b705b9687dd
# Intentionally Disabled as it's a separate market - not a simulated live market.
"supports_demo_trading": False,
}
_ft_has_futures: FtHas = {
"ohlcv_candle_limit": 499,
"funding_fee_candle_limit": 1000,
"stoploss_order_types": {"limit": "stop", "market": "stop_market"},
"stoploss_blocks_assets": False, # Stoploss orders do not block assets
"stoploss_query_requires_stop_flag": True,
"stoploss_algo_order_info_id": "actualOrderId",
"tickers_have_price": False,
"floor_leverage": True,
"fetch_orders_limit_minutes": 7 * 1440, # "fetch_orders" is limited to 7 days
"stop_price_type_field": "workingType",
"order_props_in_contracts": ["amount", "cost", "filled", "remaining"],
"stop_price_type_value_mapping": {
PriceType.LAST: "CONTRACT_PRICE",
PriceType.MARK: "MARK_PRICE",
},
"ws_enabled": False,
"proxy_coin_mapping": {
"BNFCR": "USDC",
"BFUSD": "USDT",
},
}
_can_use_data_download_fast = True
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
(TradingMode.SPOT, MarginMode.NONE),
# (TradingMode.MARGIN, MarginMode.CROSS),
(TradingMode.FUTURES, MarginMode.CROSS),
(TradingMode.FUTURES, MarginMode.ISOLATED),
]
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._spot_delist_schedule_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
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"],
) # type: ignore[return-value]
return self._config["stake_currency"]
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
tickers = super().get_tickers(symbols=symbols, cached=cached, market_type=market_type)
if self.trading_mode == TradingMode.FUTURES:
# Binance's future result has no bid/ask values.
# Therefore we must fetch that from fetch_bids_asks and combine the two results.
bidsasks = self.fetch_bids_asks(symbols, cached=cached)
tickers = deep_merge_dicts(bidsasks, tickers, allow_null_overrides=False)
return tickers
@retrier
def additional_exchange_init(self) -> None:
"""
Additional exchange initialization logic.
.api will be available at this point.
Must be overridden in child methods if required.
"""
try:
if self.trading_mode == TradingMode.FUTURES and not self._config["dry_run"]:
position_side = self._api.fapiPrivateGetPositionSideDual()
self._log_exchange_response("position_side_setting", position_side)
assets_margin = self._api.fapiPrivateGetMultiAssetsMargin()
self._log_exchange_response("multi_asset_margin", assets_margin)
msg = ""
if position_side.get("dualSidePosition") is True:
msg += (
"\nHedge Mode is not supported by freqtrade. "
"Please change 'Position Mode' on your binance futures account."
)
if (
assets_margin.get("multiAssetsMargin") is True
and self.margin_mode != MarginMode.CROSS
):
msg += (
"\nMulti-Asset Mode is not supported by freqtrade. "
"Please change 'Asset Mode' on your binance futures account."
)
if msg:
raise OperationalException(msg)
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 get_historic_ohlcv(
self,
pair: str,
timeframe: str,
since_ms: int,
candle_type: CandleType,
is_new_pair: bool = False,
until_ms: int | None = None,
) -> DataFrame:
"""
Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date
Does not work for other exchanges, which don't return the earliest data when called with "0"
:param candle_type: Any of the enum CandleType (must match trading mode!)
"""
if is_new_pair and candle_type in (CandleType.SPOT, CandleType.FUTURES, CandleType.MARK):
with self._loop_lock:
x = self.loop.run_until_complete(
self._async_get_candle_history(pair, timeframe, candle_type, 0)
)
if x and x[3] and x[3][0] and x[3][0][0] > since_ms:
# Set starting date to first available candle.
since_ms = x[3][0][0]
logger.info(
f"Candle-data for {pair} available starting with "
f"{datetime.fromtimestamp(since_ms // 1000, tz=UTC).isoformat()}."
)
if until_ms and since_ms >= until_ms:
logger.warning(
f"No available candle-data for {pair} before "
f"{dt_from_ts(until_ms).isoformat()}"
)
return DataFrame(columns=DEFAULT_DATAFRAME_COLUMNS)
if (
not self._can_use_data_download_fast
or self._config["exchange"].get("only_from_ccxt", False)
or
# only download timeframes with significant improvements,
# otherwise fall back to rest API
not (
(candle_type == CandleType.SPOT and timeframe in ["1s", "1m", "3m", "5m"])
or (
candle_type == CandleType.FUTURES
and timeframe in ["1m", "3m", "5m", "15m", "30m"]
)
)
):
return super().get_historic_ohlcv(
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
candle_type=candle_type,
is_new_pair=is_new_pair,
until_ms=until_ms,
)
else:
# Download from data.binance.vision
return self.get_historic_ohlcv_fast(
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
candle_type=candle_type,
is_new_pair=is_new_pair,
until_ms=until_ms,
)
def get_historic_ohlcv_fast(
self,
pair: str,
timeframe: str,
since_ms: int,
candle_type: CandleType,
is_new_pair: bool = False,
until_ms: int | None = None,
) -> DataFrame:
"""
Fastly fetch OHLCV data by leveraging https://data.binance.vision.
"""
with self._loop_lock:
df = self.loop.run_until_complete(
download_archive_ohlcv(
candle_type=candle_type,
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
until_ms=until_ms,
markets=self.markets,
)
)
# download the remaining data from rest API
if df.empty:
rest_since_ms = since_ms
else:
rest_since_ms = dt_ts(df.iloc[-1].date) + timeframe_to_msecs(timeframe)
# make sure since <= until
if until_ms and rest_since_ms > until_ms:
rest_df = DataFrame()
else:
rest_df = super().get_historic_ohlcv(
pair=pair,
timeframe=timeframe,
since_ms=rest_since_ms,
candle_type=candle_type,
is_new_pair=is_new_pair,
until_ms=until_ms,
)
all_df = concat_safe([df, rest_df])
return all_df
def funding_fee_cutoff(self, open_date: datetime):
"""
Funding fees are only charged at full hours (usually every 4-8h).
Therefore a trade opening at 10:00:01 will not be charged a funding fee until the next hour.
On binance, this cutoff is 15s.
https://github.com/freqtrade/freqtrade/pull/5779#discussion_r740175931
:param open_date: The open date for a trade
:return: True if the date falls on a full hour, False otherwise
"""
return open_date.minute == 0 and open_date.second < 15
def fetch_funding_rates(self, symbols: list[str] | None = 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,
open_rate: float,
is_short: bool,
amount: float,
stake_amount: float,
leverage: float,
wallet_balance: float,
open_trades: list,
) -> float | None:
"""
Important: Must be fetching data from cached values as this is used by backtesting!
MARGIN: https://www.binance.com/en/support/faq/f6b010588e55413aa58b7d63ee0125ed
PERPETUAL: https://www.binance.com/en/support/faq/b3c689c1f50a44cabb3a84e663b81d93
:param pair: Pair to calculate liquidation price for
:param open_rate: Entry price of position
:param is_short: True if the trade is a short, false otherwise
:param amount: Absolute value of position size incl. leverage (in base currency)
:param stake_amount: Stake amount - Collateral in settle currency.
:param leverage: Leverage used for this position.
: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)
Cross-Margin Mode: Maintenance Margin of all other contracts, excluding Contract 1
Isolated-Margin Mode: 0
:param upnl_ex_1: (UPNL)
Cross-Margin Mode: Unrealized PNL of all other contracts, excluding Contract 1.
Isolated-Margin Mode: 0
:param other
"""
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"
f"for {self.trading_mode}"
)
if self.trading_mode == TradingMode.FUTURES:
return (
(wallet_balance + cross_vars + maintenance_amt) - (side_1 * amount * open_rate)
) / ((amount * mm_ratio) - (side_1 * amount))
else:
raise OperationalException(
"Freqtrade only supports isolated futures for leverage trading"
)
def load_leverage_tiers(self) -> dict[str, list[dict]]:
if self.trading_mode == TradingMode.FUTURES:
if self._config["dry_run"]:
leverage_tiers_path = Path(__file__).parent / "binance_leverage_tiers.json"
with leverage_tiers_path.open() as json_file:
return json_load(json_file)
else:
return self.get_leverage_tiers()
else:
return {}
async def _async_get_trade_history_id_startup(
self, pair: str, since: int
) -> tuple[list[list], str]:
"""
override for initial call
Binance only provides a limited set of historic trades data.
Using from_id=0, we can get the earliest available trades.
So if we don't get any data with the provided "since", we can assume to
download all available data.
"""
t, from_id = await self._async_fetch_trades(pair, since=since)
if not t:
return [], "0"
return t, from_id
async def _async_get_trade_history_id(
self, pair: str, until: int, since: int, from_id: str | None = None
) -> tuple[str, list[list]]:
logger.info(f"Fetching trades for {pair} from Binance, {from_id=}, {since=}, {until=}")
if (
not self._config["exchange"].get("only_from_ccxt", False)
and self._can_use_data_download_fast
):
if from_id is None or not since:
trades = await self._api_async.fetch_trades(
pair,
params={
self._ft_has["trades_pagination_arg"]: "0",
},
limit=5,
)
listing_date: int = trades[0]["timestamp"]
since = max(since, listing_date)
_, res = await download_archive_trades(
CandleType.FUTURES if self.trading_mode == "futures" else CandleType.SPOT,
pair,
since_ms=since,
until_ms=until,
markets=self.markets,
)
if not res:
end_time = since
end_id = from_id
else:
end_time = res[-1][0]
end_id = res[-1][1]
if end_time and end_time >= until:
return pair, res
else:
_, res2 = await super()._async_get_trade_history_id(
pair, until=until, since=end_time, from_id=end_id
)
res.extend(res2)
return pair, res
return await super()._async_get_trade_history_id(
pair, until=until, since=since, from_id=from_id
)
def _check_delisting_futures(self, pair: str) -> datetime | None:
delivery_time = self.markets.get(pair, {}).get("info", {}).get("deliveryDate", None)
if delivery_time:
if isinstance(delivery_time, str) and (delivery_time != ""):
delivery_time = int(delivery_time)
# Binance set a very high delivery time for all perpetuals.
# We compare with delivery time of BTC/USDT:USDT which assumed to never be delisted
btc_delivery_time = (
self.markets.get("BTC/USDT:USDT", {}).get("info", {}).get("deliveryDate", None)
)
if delivery_time == btc_delivery_time:
return None
delivery_time = dt_from_ts(delivery_time)
return delivery_time
def check_delisting_time(self, pair: str) -> datetime | None:
"""
Check if the pair gonna be delisted.
By default, it returns None.
:param pair: Market symbol
:return: Datetime if the pair gonna be delisted, None otherwise
"""
if self._config["runmode"] not in TRADE_MODES:
return None
if self.trading_mode == TradingMode.FUTURES:
return self._check_delisting_futures(pair)
return self._get_spot_pair_delist_time(pair, refresh=False)
def _get_spot_delist_schedule(self):
"""
Get the delisting schedule for spot pairs
Only works in live mode as it requires API keys,
Return sample:
[{
"delistTime": "1759114800000",
"symbols": [
"OMNIBTC",
"OMNIFDUSD",
"OMNITRY",
"OMNIUSDC",
"OMNIUSDT"
]
}]
"""
try:
delist_schedule = self._api.sapi_get_spot_delist_schedule()
return delist_schedule
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.OperationFailed, ccxt.ExchangeError) as e:
raise TemporaryError(
f"Could not get delist schedule {e.__class__.__name__}. Message: {e}"
) from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
def _get_spot_pair_delist_time(self, pair: str, refresh: bool = False) -> datetime | None:
"""
Get the delisting time for a pair if it will be delisted
:param pair: Pair to get the delisting time for
:param refresh: true if you need fresh data
:return: int: delisting time None if not delisting
"""
if not pair or not self._config["runmode"] == RunMode.LIVE:
# Endpoint only works in live mode as it requires API keys
return None
cache = self._spot_delist_schedule_cache
if not refresh:
if delist_time := cache.get(pair, None):
return delist_time
delist_schedule = self._get_spot_delist_schedule()
if delist_schedule is None:
return None
for schedule in delist_schedule:
delist_dt = dt_from_ts(int(schedule["delistTime"]))
for symbol in schedule["symbols"]:
ft_symbol = next(
(
pair
for pair, market in self.markets.items()
if market.get("id", None) == symbol
),
None,
)
if ft_symbol is None:
continue
cache[ft_symbol] = delist_dt
return cache.get(pair, None)
class Binanceusdm(Binance):
"""Binacne USDM Exchange
Same as Binance - only futures trading is supported (via ccxt).
Not actually necessary, binance should be preferred.
"""
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
(TradingMode.FUTURES, MarginMode.CROSS),
(TradingMode.FUTURES, MarginMode.ISOLATED),
]
class Binanceus(Binance):
"""Binance US exchange class.
Minimal adjustment to disable futures trading for the US subsidiary of Binance
"""
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
(TradingMode.SPOT, MarginMode.NONE),
]
# binance vision does not have data for binanceus
_can_use_data_download_fast = False