mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-12-15 20:31:43 +00:00
547 lines
21 KiB
Python
547 lines
21 KiB
Python
"""Binance exchange subclass"""
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import ccxt
|
|
from cachetools import TTLCache
|
|
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.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,
|
|
}
|
|
_ft_has_futures: FtHas = {
|
|
"funding_fee_candle_limit": 1000,
|
|
"stoploss_order_types": {"limit": "stop", "market": "stop_market"},
|
|
"stoploss_blocks_assets": False, # Stoploss orders do not block assets
|
|
"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",
|
|
},
|
|
}
|
|
|
|
_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: TTLCache = TTLCache(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 (
|
|
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):
|
|
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)
|