diff --git a/freqtrade/enums/exittype.py b/freqtrade/enums/exittype.py index 7610b8c94..1607de643 100644 --- a/freqtrade/enums/exittype.py +++ b/freqtrade/enums/exittype.py @@ -13,6 +13,7 @@ class ExitType(Enum): FORCE_SELL = "force_sell" EMERGENCY_SELL = "emergency_sell" CUSTOM_SELL = "custom_sell" + PARTIAL_SELL = "partial_sell" NONE = "" def __str__(self): diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 6cadbb46e..e77c48abc 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1428,8 +1428,9 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e - def get_rate(self, pair: str, refresh: bool, - side: Literal['entry', 'exit'], is_short: bool, order_book: Optional[dict] = None, ticker: Optional[dict] = Non) -> float: + def get_rate(self, pair: str, refresh: bool, # noqa: max-complexity: 13 + side: Literal['entry', 'exit'], is_short: bool, + order_book: Optional[dict] = None, ticker: Optional[dict] = None) -> float: """ Calculates bid/ask target bid rate - between current ask price and last price @@ -1533,7 +1534,8 @@ class Exchange: if not entry_rate: entry_rate = self.get_rate(pair, refresh, 'entry', is_short, ticker=ticker) if not exit_rate: - exit_rate = self.get_rate(pair, refresh, 'exit', order_book=order_book, ticker=ticker) + exit_rate = self.get_rate(pair, refresh, 'exit', + is_short, order_book=order_book, ticker=ticker) return entry_rate, exit_rate # Fee handling diff --git a/freqtrade/exchange/exchange.py.orig b/freqtrade/exchange/exchange.py.orig deleted file mode 100644 index fe59d0ed7..000000000 --- a/freqtrade/exchange/exchange.py.orig +++ /dev/null @@ -1,2734 +0,0 @@ -# pragma pylint: disable=W0603 -""" -Cryptocurrency Exchanges support -""" -import asyncio -import http -import inspect -import logging -from copy import deepcopy -from datetime import datetime, timedelta, timezone -from math import ceil -from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union - -import arrow -import ccxt -import ccxt.async_support as ccxt_async -from cachetools import TTLCache -from ccxt.base.decimal_to_precision import (ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, - decimal_to_precision) -from pandas import DataFrame - -from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, - ListPairsWithTimeframes, PairWithTimeframe) -from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list -from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode -from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, - InvalidOrderException, OperationalException, PricingError, - RetryableOrderError, TemporaryError) -from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, BAD_EXCHANGES, - EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED, - SUPPORTED_EXCHANGES, remove_credentials, retrier, - retrier_async) -from freqtrade.misc import chunks, deep_merge_dicts, safe_value_fallback2 -from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist - - -CcxtModuleType = Any - - -logger = logging.getLogger(__name__) - - -# Workaround for adding samesite support to pre 3.8 python -# Only applies to python3.7, and only on certain exchanges (kraken) -# Replicates the fix from starlette (which is actually causing this problem) -http.cookies.Morsel._reserved["samesite"] = "SameSite" # type: ignore - - -class Exchange: - - # Parameters to add directly to buy/sell calls (like agreeing to trading agreement) - _params: Dict = {} - - # Additional headers - added to the ccxt object - _headers: Dict = {} - - # Dict to specify which options each exchange implements - # This defines defaults, which can be selectively overridden by subclasses using _ft_has - # or by specifying them in the configuration. - _ft_has_default: Dict = { - "stoploss_on_exchange": False, - "order_time_in_force": ["gtc"], - "time_in_force_parameter": "timeInForce", - "ohlcv_params": {}, - "ohlcv_candle_limit": 500, - "ohlcv_partial_candle": True, - # Check https://github.com/ccxt/ccxt/issues/10767 for removal of ohlcv_volume_currency - "ohlcv_volume_currency": "base", # "base" or "quote" - "tickers_have_quoteVolume": True, - "tickers_have_price": True, - "trades_pagination": "time", # Possible are "time" or "id" - "trades_pagination_arg": "since", - "l2_limit_range": None, - "l2_limit_range_required": True, # Allow Empty L2 limit (kucoin) - "mark_ohlcv_price": "mark", - "mark_ohlcv_timeframe": "8h", - "ccxt_futures_name": "swap", - "needs_trading_fees": False, # use fetch_trading_fees to cache fees - } - _ft_has: Dict = {} - _ft_has_futures: Dict = {} - - _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ - # TradingMode.SPOT always supported and not required in this list - ] - - def __init__(self, config: Dict[str, Any], validate: bool = True) -> None: - """ - Initializes this module with the given config, - it does basic validation whether the specified exchange and pairs are valid. - :return: None - """ - self._api: ccxt.Exchange = None - self._api_async: ccxt_async.Exchange = None - self._markets: Dict = {} - self._trading_fees: Dict[str, Any] = {} - self._leverage_tiers: Dict[str, List[Dict]] = {} - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - self._config: Dict = {} - - self._config.update(config) - - # Holds last candle refreshed time of each pair - self._pairs_last_refresh_time: Dict[PairWithTimeframe, int] = {} - # Timestamp of last markets refresh - self._last_markets_refresh: int = 0 - - # Cache for 10 minutes ... - self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=2, ttl=60 * 10) - # Cache values for 1800 to avoid frequent polling of the exchange for prices - # Caching only applies to RPC methods, so prices for open trades are still - # refreshed once every iteration. - self._exit_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=1800) - self._entry_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=1800) - - # Holds candles - self._klines: Dict[PairWithTimeframe, DataFrame] = {} - - # Holds all open sell orders for dry_run - self._dry_run_open_orders: Dict[str, Any] = {} - remove_credentials(config) - - if config['dry_run']: - logger.info('Instance is running with dry_run enabled') - logger.info(f"Using CCXT {ccxt.__version__}") - exchange_config = config['exchange'] - self.log_responses = exchange_config.get('log_responses', False) - - # Leverage properties - self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) - self.margin_mode: MarginMode = ( - MarginMode(config.get('margin_mode')) - if config.get('margin_mode') - else MarginMode.NONE - ) - self.liquidation_buffer = config.get('liquidation_buffer', 0.05) - - # Deep merge ft_has with default ft_has options - self._ft_has = deep_merge_dicts(self._ft_has, deepcopy(self._ft_has_default)) - if self.trading_mode == TradingMode.FUTURES: - self._ft_has = deep_merge_dicts(self._ft_has_futures, self._ft_has) - if exchange_config.get('_ft_has_params'): - self._ft_has = deep_merge_dicts(exchange_config.get('_ft_has_params'), - self._ft_has) - logger.info("Overriding exchange._ft_has with config params, result: %s", self._ft_has) - - # Assign this directly for easy access - self._ohlcv_partial_candle = self._ft_has['ohlcv_partial_candle'] - - self._trades_pagination = self._ft_has['trades_pagination'] - self._trades_pagination_arg = self._ft_has['trades_pagination_arg'] - - # Initialize ccxt objects - ccxt_config = self._ccxt_config - ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), ccxt_config) - ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_sync_config', {}), ccxt_config) - - self._api = self._init_ccxt(exchange_config, ccxt_kwargs=ccxt_config) - - ccxt_async_config = self._ccxt_config - ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), - ccxt_async_config) - ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_async_config', {}), - ccxt_async_config) - self._api_async = self._init_ccxt( - exchange_config, ccxt_async, ccxt_kwargs=ccxt_async_config) - - logger.info('Using Exchange "%s"', self.name) - - if validate: - # Check if timeframe is available - self.validate_timeframes(config.get('timeframe')) - - # Initial markets load - self._load_markets() - - # Check if all pairs are available - self.validate_stakecurrency(config['stake_currency']) - if not exchange_config.get('skip_pair_validation'): - self.validate_pairs(config['exchange']['pair_whitelist']) - self.validate_ordertypes(config.get('order_types', {})) - self.validate_order_time_in_force(config.get('order_time_in_force', {})) - self.required_candle_call_count = self.validate_required_startup_candles( - config.get('startup_candle_count', 0), config.get('timeframe', '')) - self.validate_trading_mode_and_margin_mode(self.trading_mode, self.margin_mode) - self.validate_pricing(config['exit_pricing']) - self.validate_pricing(config['entry_pricing']) - - # Converts the interval provided in minutes in config to seconds - self.markets_refresh_interval: int = exchange_config.get( - "markets_refresh_interval", 60) * 60 - - if self.trading_mode != TradingMode.SPOT: - self.fill_leverage_tiers() - - def __del__(self): - """ - Destructor - clean up async stuff - """ - self.close() - - def close(self): - logger.debug("Exchange object destroyed, closing async loop") - if (self._api_async and inspect.iscoroutinefunction(self._api_async.close) - and self._api_async.session): - logger.info("Closing async ccxt session.") - self.loop.run_until_complete(self._api_async.close()) - - def _init_ccxt(self, exchange_config: Dict[str, Any], ccxt_module: CcxtModuleType = ccxt, - ccxt_kwargs: Dict = {}) -> ccxt.Exchange: - """ - Initialize ccxt with given config and return valid - ccxt instance. - """ - # Find matching class for the given exchange name - name = exchange_config['name'] - - if not is_exchange_known_ccxt(name, ccxt_module): - raise OperationalException(f'Exchange {name} is not supported by ccxt') - - ex_config = { - 'apiKey': exchange_config.get('key'), - 'secret': exchange_config.get('secret'), - 'password': exchange_config.get('password'), - 'uid': exchange_config.get('uid', ''), - } - if ccxt_kwargs: - logger.info('Applying additional ccxt config: %s', ccxt_kwargs) - if self._headers: - # Inject static headers after the above output to not confuse users. - ccxt_kwargs = deep_merge_dicts({'headers': self._headers}, ccxt_kwargs) - if ccxt_kwargs: - ex_config.update(ccxt_kwargs) - try: - - api = getattr(ccxt_module, name.lower())(ex_config) - except (KeyError, AttributeError) as e: - raise OperationalException(f'Exchange {name} is not supported') from e - except ccxt.BaseError as e: - raise OperationalException(f"Initialization of ccxt failed. Reason: {e}") from e - - self.set_sandbox(api, exchange_config, name) - - return api - - @property - def _ccxt_config(self) -> Dict: - # Parameters to add directly to ccxt sync/async initialization. - if self.trading_mode == TradingMode.MARGIN: - return { - "options": { - "defaultType": "margin" - } - } - elif self.trading_mode == TradingMode.FUTURES: - return { - "options": { - "defaultType": self._ft_has["ccxt_futures_name"] - } - } - else: - return {} - - @property - def name(self) -> str: - """exchange Name (from ccxt)""" - return self._api.name - - @property - def id(self) -> str: - """exchange ccxt id""" - return self._api.id - - @property - def timeframes(self) -> List[str]: - return list((self._api.timeframes or {}).keys()) - - @property - def markets(self) -> Dict: - """exchange ccxt markets""" - if not self._markets: - logger.info("Markets were not loaded. Loading them now..") - self._load_markets() - return self._markets - - @property - def precisionMode(self) -> str: - """exchange ccxt precisionMode""" - return self._api.precisionMode - - def _log_exchange_response(self, endpoint, response) -> None: - """ Log exchange responses """ - if self.log_responses: - logger.info(f"API {endpoint}: {response}") - - def ohlcv_candle_limit(self, timeframe: str) -> int: - """ - Exchange ohlcv candle limit - Uses ohlcv_candle_limit_per_timeframe if the exchange has different limits - per timeframe (e.g. bittrex), otherwise falls back to ohlcv_candle_limit - :param timeframe: Timeframe to check - :return: Candle limit as integer - """ - return int(self._ft_has.get('ohlcv_candle_limit_per_timeframe', {}).get( - timeframe, self._ft_has.get('ohlcv_candle_limit'))) - - def get_markets(self, base_currencies: List[str] = None, quote_currencies: List[str] = None, - spot_only: bool = False, margin_only: bool = False, futures_only: bool = False, - tradable_only: bool = True, - active_only: bool = False) -> Dict[str, Any]: - """ - Return exchange ccxt markets, filtered out by base currency and quote currency - if this was requested in parameters. - """ - markets = self.markets - if not markets: - raise OperationalException("Markets were not loaded.") - - if base_currencies: - markets = {k: v for k, v in markets.items() if v['base'] in base_currencies} - if quote_currencies: - markets = {k: v for k, v in markets.items() if v['quote'] in quote_currencies} - if tradable_only: - markets = {k: v for k, v in markets.items() if self.market_is_tradable(v)} - if spot_only: - markets = {k: v for k, v in markets.items() if self.market_is_spot(v)} - if margin_only: - markets = {k: v for k, v in markets.items() if self.market_is_margin(v)} - if futures_only: - markets = {k: v for k, v in markets.items() if self.market_is_future(v)} - if active_only: - markets = {k: v for k, v in markets.items() if market_is_active(v)} - return markets - - def get_quote_currencies(self) -> List[str]: - """ - Return a list of supported quote currencies - """ - markets = self.markets - return sorted(set([x['quote'] for _, x in markets.items()])) - - def get_pair_quote_currency(self, pair: str) -> str: - """ - Return a pair's quote currency - """ - return self.markets.get(pair, {}).get('quote', '') - - def get_pair_base_currency(self, pair: str) -> str: - """ - Return a pair's base currency - """ - return self.markets.get(pair, {}).get('base', '') - - def market_is_future(self, market: Dict[str, Any]) -> bool: - return ( - market.get(self._ft_has["ccxt_futures_name"], False) is True and - market.get('linear', False) is True - ) - - def market_is_spot(self, market: Dict[str, Any]) -> bool: - return market.get('spot', False) is True - - def market_is_margin(self, market: Dict[str, Any]) -> bool: - return market.get('margin', False) is True - - def market_is_tradable(self, market: Dict[str, Any]) -> bool: - """ - Check if the market symbol is tradable by Freqtrade. - Ensures that Configured mode aligns to - """ - return ( - market.get('quote', None) is not None - and market.get('base', None) is not None - and ((self.trading_mode == TradingMode.SPOT and self.market_is_spot(market)) - or (self.trading_mode == TradingMode.MARGIN and self.market_is_margin(market)) - or (self.trading_mode == TradingMode.FUTURES and self.market_is_future(market))) - ) - - def klines(self, pair_interval: PairWithTimeframe, copy: bool = True) -> DataFrame: - if pair_interval in self._klines: - return self._klines[pair_interval].copy() if copy else self._klines[pair_interval] - else: - return DataFrame() - - def _get_contract_size(self, pair: str) -> float: - if self.trading_mode == TradingMode.FUTURES: - market = self.markets[pair] - contract_size: float = 1.0 - if market['contractSize'] is not None: - # ccxt has contractSize in markets as string - contract_size = float(market['contractSize']) - return contract_size - else: - return 1 - - def _trades_contracts_to_amount(self, trades: List) -> List: - if len(trades) > 0 and 'symbol' in trades[0]: - contract_size = self._get_contract_size(trades[0]['symbol']) - if contract_size != 1: - for trade in trades: - trade['amount'] = trade['amount'] * contract_size - return trades - - def _order_contracts_to_amount(self, order: Dict) -> Dict: - if 'symbol' in order and order['symbol'] is not None: - contract_size = self._get_contract_size(order['symbol']) - if contract_size != 1: - for prop in ['amount', 'cost', 'filled', 'remaining']: - if prop in order and order[prop] is not None: - order[prop] = order[prop] * contract_size - return order - - def _amount_to_contracts(self, pair: str, amount: float) -> float: - - contract_size = self._get_contract_size(pair) - if contract_size and contract_size != 1: - return amount / contract_size - else: - return amount - - def _contracts_to_amount(self, pair: str, num_contracts: float) -> float: - - contract_size = self._get_contract_size(pair) - if contract_size and contract_size != 1: - return num_contracts * contract_size - else: - return num_contracts - - def set_sandbox(self, api: ccxt.Exchange, exchange_config: dict, name: str) -> None: - if exchange_config.get('sandbox'): - if api.urls.get('test'): - api.urls['api'] = api.urls['test'] - logger.info("Enabled Sandbox API on %s", name) - else: - logger.warning( - f"No Sandbox URL in CCXT for {name}, exiting. Please check your config.json") - raise OperationalException(f'Exchange {name} does not provide a sandbox api') - - def _load_async_markets(self, reload: bool = False) -> None: - try: - if self._api_async: - self.loop.run_until_complete( - self._api_async.load_markets(reload=reload)) - - except (asyncio.TimeoutError, ccxt.BaseError) as e: - logger.warning('Could not load async markets. Reason: %s', e) - return - - def _load_markets(self) -> None: - """ Initialize markets both sync and async """ - try: - self._markets = self._api.load_markets() - self._load_async_markets() - self._last_markets_refresh = arrow.utcnow().int_timestamp - if self._ft_has['needs_trading_fees']: - self._trading_fees = self.fetch_trading_fees() - - except ccxt.BaseError: - logger.exception('Unable to initialize markets.') - - def reload_markets(self) -> None: - """Reload markets both sync and async if refresh interval has passed """ - # Check whether markets have to be reloaded - if (self._last_markets_refresh > 0) and ( - self._last_markets_refresh + self.markets_refresh_interval - > arrow.utcnow().int_timestamp): - return None - logger.debug("Performing scheduled market reload..") - try: - self._markets = self._api.load_markets(reload=True) - # Also reload async markets to avoid issues with newly listed pairs - self._load_async_markets(reload=True) - self._last_markets_refresh = arrow.utcnow().int_timestamp - self.fill_leverage_tiers() - except ccxt.BaseError: - logger.exception("Could not reload markets.") - - def validate_stakecurrency(self, stake_currency: str) -> None: - """ - Checks stake-currency against available currencies on the exchange. - Only runs on startup. If markets have not been loaded, there's been a problem with - the connection to the exchange. - :param stake_currency: Stake-currency to validate - :raise: OperationalException if stake-currency is not available. - """ - if not self._markets: - raise OperationalException( - 'Could not load markets, therefore cannot start. ' - 'Please investigate the above error for more details.' - ) - quote_currencies = self.get_quote_currencies() - if stake_currency not in quote_currencies: - raise OperationalException( - f"{stake_currency} is not available as stake on {self.name}. " - f"Available currencies are: {', '.join(quote_currencies)}") - - def validate_pairs(self, pairs: List[str]) -> None: - """ - Checks if all given pairs are tradable on the current exchange. - :param pairs: list of pairs - :raise: OperationalException if one pair is not available - :return: None - """ - - if not self.markets: - logger.warning('Unable to validate pairs (assuming they are correct).') - return - extended_pairs = expand_pairlist(pairs, list(self.markets), keep_invalid=True) - invalid_pairs = [] - for pair in extended_pairs: - # Note: ccxt has BaseCurrency/QuoteCurrency format for pairs - if self.markets and pair not in self.markets: - raise OperationalException( - f'Pair {pair} is not available on {self.name} {self.trading_mode.value}. ' - f'Please remove {pair} from your whitelist.') - - # From ccxt Documentation: - # markets.info: An associative array of non-common market properties, - # including fees, rates, limits and other general market information. - # The internal info array is different for each particular market, - # its contents depend on the exchange. - # It can also be a string or similar ... so we need to verify that first. - elif (isinstance(self.markets[pair].get('info', None), dict) - and self.markets[pair].get('info', {}).get('prohibitedIn', False)): - # Warn users about restricted pairs in whitelist. - # We cannot determine reliably if Users are affected. - logger.warning(f"Pair {pair} is restricted for some users on this exchange." - f"Please check if you are impacted by this restriction " - f"on the exchange and eventually remove {pair} from your whitelist.") - if (self._config['stake_currency'] and - self.get_pair_quote_currency(pair) != self._config['stake_currency']): - invalid_pairs.append(pair) - if invalid_pairs: - raise OperationalException( - f"Stake-currency '{self._config['stake_currency']}' not compatible with " - f"pair-whitelist. Please remove the following pairs: {invalid_pairs}") - - def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> str: - """ - Get valid pair combination of curr_1 and curr_2 by trying both combinations. - """ - for pair in [f"{curr_1}/{curr_2}", f"{curr_2}/{curr_1}"]: - if pair in self.markets and self.markets[pair].get('active'): - return pair - raise ExchangeError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.") - - def validate_timeframes(self, timeframe: Optional[str]) -> None: - """ - Check if timeframe from config is a supported timeframe on the exchange - """ - if not hasattr(self._api, "timeframes") or self._api.timeframes is None: - # If timeframes attribute is missing (or is None), the exchange probably - # has no fetchOHLCV method. - # Therefore we also show that. - raise OperationalException( - f"The ccxt library does not provide the list of timeframes " - f"for the exchange \"{self.name}\" and this exchange " - f"is therefore not supported. ccxt fetchOHLCV: {self.exchange_has('fetchOHLCV')}") - - if timeframe and (timeframe not in self.timeframes): - raise OperationalException( - f"Invalid timeframe '{timeframe}'. This exchange supports: {self.timeframes}") - - if timeframe and timeframe_to_minutes(timeframe) < 1: - raise OperationalException("Timeframes < 1m are currently not supported by Freqtrade.") - - def validate_ordertypes(self, order_types: Dict) -> None: - """ - Checks if order-types configured in strategy/config are supported - """ - if any(v == 'market' for k, v in order_types.items()): - if not self.exchange_has('createMarketOrder'): - raise OperationalException( - f'Exchange {self.name} does not support market orders.') - - if (order_types.get("stoploss_on_exchange") - and not self._ft_has.get("stoploss_on_exchange", False)): - raise OperationalException( - f'On exchange stoploss is not supported for {self.name}.' - ) - - def validate_pricing(self, pricing: Dict) -> None: - if pricing.get('use_order_book', False) and not self.exchange_has('fetchL2OrderBook'): - raise OperationalException(f'Orderbook not available for {self.name}.') - if (not pricing.get('use_order_book', False) and ( - not self.exchange_has('fetchTicker') - or not self._ft_has['tickers_have_price'])): - raise OperationalException(f'Ticker pricing not available for {self.name}.') - - def validate_order_time_in_force(self, order_time_in_force: Dict) -> None: - """ - Checks if order time in force configured in strategy/config are supported - """ - if any(v not in self._ft_has["order_time_in_force"] - for k, v in order_time_in_force.items()): - raise OperationalException( - f'Time in force policies are not supported for {self.name} yet.') - - def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> int: - """ - Checks if required startup_candles is more than ohlcv_candle_limit(). - Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default. - """ - candle_limit = self.ohlcv_candle_limit(timeframe) - # Require one more candle - to account for the still open candle. - candle_count = startup_candles + 1 - # Allow 5 calls to the exchange per pair - required_candle_call_count = int( - (candle_count / candle_limit) + (0 if candle_count % candle_limit == 0 else 1)) - - if required_candle_call_count > 5: - # Only allow 5 calls per pair to somewhat limit the impact - raise OperationalException( - f"This strategy requires {startup_candles} candles to start, which is more than 5x " - f"the amount of candles {self.name} provides for {timeframe}.") - - if required_candle_call_count > 1: - logger.warning(f"Using {required_candle_call_count} calls to get OHLCV. " - f"This can result in slower operations for the bot. Please check " - f"if you really need {startup_candles} candles for your strategy") - return required_candle_call_count - - def validate_trading_mode_and_margin_mode( - self, - trading_mode: TradingMode, - margin_mode: Optional[MarginMode] # Only None when trading_mode = TradingMode.SPOT - ): - """ - Checks if freqtrade can perform trades using the configured - trading mode(Margin, Futures) and MarginMode(Cross, Isolated) - Throws OperationalException: - If the trading_mode/margin_mode type are not supported by freqtrade on this exchange - """ - if trading_mode != TradingMode.SPOT and ( - (trading_mode, margin_mode) not in self._supported_trading_mode_margin_pairs - ): - mm_value = margin_mode and margin_mode.value - raise OperationalException( - f"Freqtrade does not support {mm_value} {trading_mode.value} on {self.name}" - ) - - def exchange_has(self, endpoint: str) -> bool: - """ - Checks if exchange implements a specific API endpoint. - Wrapper around ccxt 'has' attribute - :param endpoint: Name of endpoint (e.g. 'fetchOHLCV', 'fetchTickers') - :return: bool - """ - return endpoint in self._api.has and self._api.has[endpoint] - - def amount_to_precision(self, pair: str, amount: float) -> float: - """ - Returns the amount to buy or sell to a precision the Exchange accepts - Re-implementation of ccxt internal methods - ensuring we can test the result is correct - based on our definitions. - """ - if self.markets[pair]['precision']['amount']: - amount = float(decimal_to_precision(amount, rounding_mode=TRUNCATE, - precision=self.markets[pair]['precision']['amount'], - counting_mode=self.precisionMode, - )) - - return amount - - def price_to_precision(self, pair: str, price: float) -> float: - """ - Returns the price rounded up to the precision the Exchange accepts. - Partial Re-implementation of ccxt internal method decimal_to_precision(), - which does not support rounding up - TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and - align with amount_to_precision(). - Rounds up - """ - if self.markets[pair]['precision']['price']: - # price = float(decimal_to_precision(price, rounding_mode=ROUND, - # precision=self.markets[pair]['precision']['price'], - # counting_mode=self.precisionMode, - # )) - if self.precisionMode == TICK_SIZE: - precision = self.markets[pair]['precision']['price'] - missing = price % precision - if missing != 0: - price = round(price - missing + precision, 10) - else: - symbol_prec = self.markets[pair]['precision']['price'] - big_price = price * pow(10, symbol_prec) - price = ceil(big_price) / pow(10, symbol_prec) - return price - - def price_get_one_pip(self, pair: str, price: float) -> float: - """ - Get's the "1 pip" value for this pair. - Used in PriceFilter to calculate the 1pip movements. - """ - precision = self.markets[pair]['precision']['price'] - if self.precisionMode == TICK_SIZE: - return precision - else: - return 1 / pow(10, precision) - - def get_min_pair_stake_amount( - self, - pair: str, - price: float, - stoploss: float, - leverage: Optional[float] = 1.0 - ) -> Optional[float]: - return self._get_stake_amount_limit(pair, price, stoploss, 'min', leverage) - - def get_max_pair_stake_amount(self, pair: str, price: float, leverage: float = 1.0) -> float: - max_stake_amount = self._get_stake_amount_limit(pair, price, 0.0, 'max') - if max_stake_amount is None: - # * Should never be executed - raise OperationalException(f'{self.name}.get_max_pair_stake_amount should' - 'never set max_stake_amount to None') - return max_stake_amount / leverage - - def _get_stake_amount_limit( - self, - pair: str, - price: float, - stoploss: float, - limit: Literal['min', 'max'], - leverage: Optional[float] = 1.0 - ) -> Optional[float]: - - isMin = limit == 'min' - - try: - market = self.markets[pair] - except KeyError: - raise ValueError(f"Can't get market information for symbol {pair}") - - stake_limits = [] - limits = market['limits'] - if (limits['cost'][limit] is not None): - stake_limits.append( - self._contracts_to_amount( - pair, - limits['cost'][limit] - ) - ) - - if (limits['amount'][limit] is not None): - stake_limits.append( - self._contracts_to_amount( - pair, - limits['amount'][limit] * price - ) - ) - - if not stake_limits: - return None if isMin else float('inf') - - # reserve some percent defined in config (5% default) + stoploss - amount_reserve_percent = 1.0 + self._config.get('amount_reserve_percent', - DEFAULT_AMOUNT_RESERVE_PERCENT) - amount_reserve_percent = ( - amount_reserve_percent / (1 - abs(stoploss)) if abs(stoploss) != 1 else 1.5 - ) - # it should not be more than 50% - amount_reserve_percent = max(min(amount_reserve_percent, 1.5), 1) - - # The value returned should satisfy both limits: for amount (base currency) and - # for cost (quote, stake currency), so max() is used here. - # See also #2575 at github. - return self._get_stake_amount_considering_leverage( - max(stake_limits) * amount_reserve_percent, - leverage or 1.0 - ) if isMin else min(stake_limits) - - def _get_stake_amount_considering_leverage(self, stake_amount: float, leverage: float) -> float: - """ - Takes the minimum stake amount for a pair with no leverage and returns the minimum - stake amount when leverage is considered - :param stake_amount: The stake amount for a pair before leverage is considered - :param leverage: The amount of leverage being used on the current trade - """ - return stake_amount / leverage - - # Dry-run methods - - def create_dry_run_order(self, pair: str, ordertype: str, side: str, amount: float, - rate: float, leverage: float, params: Dict = {}, - stop_loss: bool = False) -> Dict[str, Any]: - order_id = f'dry_run_{side}_{datetime.now().timestamp()}' - _amount = self.amount_to_precision(pair, amount) - dry_order: Dict[str, Any] = { - 'id': order_id, - 'symbol': pair, - 'price': rate, - 'average': rate, - 'amount': _amount, - 'cost': _amount * rate / leverage, - 'type': ordertype, - 'side': side, - 'filled': 0, - 'remaining': _amount, - 'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'), - 'timestamp': arrow.utcnow().int_timestamp * 1000, - 'status': "closed" if ordertype == "market" and not stop_loss else "open", - 'fee': None, - 'info': {}, - 'leverage': leverage - } - if stop_loss: - dry_order["info"] = {"stopPrice": dry_order["price"]} - dry_order["stopPrice"] = dry_order["price"] - # Workaround to avoid filling stoploss orders immediately - dry_order["ft_order_type"] = "stoploss" - - if dry_order["type"] == "market" and not dry_order.get("ft_order_type"): - # Update market order pricing - average = self.get_dry_market_fill_price(pair, side, amount, rate) - dry_order.update({ - 'average': average, - 'filled': _amount, - 'cost': (dry_order['amount'] * average) / leverage - }) - dry_order = self.add_dry_order_fee(pair, dry_order) - - dry_order = self.check_dry_limit_order_filled(dry_order) - - self._dry_run_open_orders[dry_order["id"]] = dry_order - # Copy order and close it - so the returned order is open unless it's a market order - return dry_order - - def add_dry_order_fee(self, pair: str, dry_order: Dict[str, Any]) -> Dict[str, Any]: - dry_order.update({ - 'fee': { - 'currency': self.get_pair_quote_currency(pair), - 'cost': dry_order['cost'] * self.get_fee(pair), - 'rate': self.get_fee(pair) - } - }) - return dry_order - - def get_dry_market_fill_price(self, pair: str, side: str, amount: float, rate: float) -> float: - """ - Get the market order fill price based on orderbook interpolation - """ - if self.exchange_has('fetchL2OrderBook'): - ob = self.fetch_l2_order_book(pair, 20) - ob_type = 'asks' if side == 'buy' else 'bids' - slippage = 0.05 - max_slippage_val = rate * ((1 + slippage) if side == 'buy' else (1 - slippage)) - - remaining_amount = amount - filled_amount = 0.0 - book_entry_price = 0.0 - for book_entry in ob[ob_type]: - book_entry_price = book_entry[0] - book_entry_coin_volume = book_entry[1] - if remaining_amount > 0: - if remaining_amount < book_entry_coin_volume: - # Orderbook at this slot bigger than remaining amount - filled_amount += remaining_amount * book_entry_price - break - else: - filled_amount += book_entry_coin_volume * book_entry_price - remaining_amount -= book_entry_coin_volume - else: - break - else: - # If remaining_amount wasn't consumed completely (break was not called) - filled_amount += remaining_amount * book_entry_price - forecast_avg_filled_price = max(filled_amount, 0) / amount - # Limit max. slippage to specified value - if side == 'buy': - forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val) - - else: - forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val) - - return self.price_to_precision(pair, forecast_avg_filled_price) - - return rate - - def _is_dry_limit_order_filled(self, pair: str, side: str, limit: float) -> bool: - if not self.exchange_has('fetchL2OrderBook'): - return True - ob = self.fetch_l2_order_book(pair, 1) - try: - if side == 'buy': - price = ob['asks'][0][0] - logger.debug(f"{pair} checking dry buy-order: price={price}, limit={limit}") - if limit >= price: - return True - else: - price = ob['bids'][0][0] - logger.debug(f"{pair} checking dry sell-order: price={price}, limit={limit}") - if limit <= price: - return True - except IndexError: - # Ignore empty orderbooks when filling - can be filled with the next iteration. - pass - return False - - def check_dry_limit_order_filled(self, order: Dict[str, Any]) -> Dict[str, Any]: - """ - Check dry-run limit order fill and update fee (if it filled). - """ - if (order['status'] != "closed" - and order['type'] in ["limit"] - and not order.get('ft_order_type')): - pair = order['symbol'] - if self._is_dry_limit_order_filled(pair, order['side'], order['price']): - order.update({ - 'status': 'closed', - 'filled': order['amount'], - 'remaining': 0, - }) - self.add_dry_order_fee(pair, order) - - return order - - def fetch_dry_run_order(self, order_id) -> Dict[str, Any]: - """ - Return dry-run order - Only call if running in dry-run mode. - """ - try: - order = self._dry_run_open_orders[order_id] - order = self.check_dry_limit_order_filled(order) - return order - except KeyError as e: - # Gracefully handle errors with dry-run orders. - raise InvalidOrderException( - f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e - - # Order handling - - def _lev_prep(self, pair: str, leverage: float, side: str): - if self.trading_mode != TradingMode.SPOT: - self.set_margin_mode(pair, self.margin_mode) - self._set_leverage(leverage, pair) - - def _get_params( - self, - ordertype: str, - leverage: float, - reduceOnly: bool, - time_in_force: str = 'gtc', - ) -> Dict: - params = self._params.copy() - if time_in_force != 'gtc' and ordertype != 'market': - param = self._ft_has.get('time_in_force_parameter', '') - params.update({param: time_in_force}) - if reduceOnly: - params.update({'reduceOnly': True}) - return params - - def create_order( - self, - *, - pair: str, - ordertype: str, - side: str, - amount: float, - rate: float, - leverage: float, - reduceOnly: bool = False, - time_in_force: str = 'gtc', - ) -> Dict: - if self._config['dry_run']: - dry_order = self.create_dry_run_order(pair, ordertype, side, amount, rate, leverage) - return dry_order - - params = self._get_params(ordertype, leverage, reduceOnly, time_in_force) - - try: - # Set the precision for amount and price(rate) as accepted by the exchange - amount = self.amount_to_precision(pair, self._amount_to_contracts(pair, amount)) - needs_price = (ordertype != 'market' - or self._api.options.get("createMarketBuyOrderRequiresPrice", False)) - rate_for_order = self.price_to_precision(pair, rate) if needs_price else None - - if not reduceOnly: - self._lev_prep(pair, leverage, side) - - order = self._api.create_order( - pair, - ordertype, - side, - amount, - rate_for_order, - params, - ) - self._log_exchange_response('create_order', order) - order = self._order_contracts_to_amount(order) - return order - - except ccxt.InsufficientFunds as e: - raise InsufficientFundsError( - f'Insufficient funds to create {ordertype} {side} order on market {pair}. ' - f'Tried to {side} amount {amount} at rate {rate}.' - f'Message: {e}') from e - except ccxt.InvalidOrder as e: - raise ExchangeError( - f'Could not create {ordertype} {side} order on market {pair}. ' - f'Tried to {side} amount {amount} at rate {rate}. ' - f'Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not place {side} order due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool: - """ - Verify stop_loss against stoploss-order value (limit or price) - Returns True if adjustment is necessary. - """ - raise OperationalException(f"stoploss is not implemented for {self.name}.") - - def _get_stop_order_type(self, user_order_type) -> Tuple[str, str]: - - available_order_Types: Dict[str, str] = self._ft_has["stoploss_order_types"] - - if user_order_type in available_order_Types.keys(): - ordertype = available_order_Types[user_order_type] - else: - # Otherwise pick only one available - ordertype = list(available_order_Types.values())[0] - user_order_type = list(available_order_Types.keys())[0] - return ordertype, user_order_type - - def _get_stop_limit_rate(self, stop_price: float, order_types: Dict, side: str) -> float: - # Limit price threshold: As limit price should always be below stop-price - limit_price_pct = order_types.get('stoploss_on_exchange_limit_ratio', 0.99) - if side == "sell": - limit_rate = stop_price * limit_price_pct - else: - limit_rate = stop_price * (2 - limit_price_pct) - - bad_stop_price = ((stop_price <= limit_rate) if side == - "sell" else (stop_price >= limit_rate)) - # Ensure rate is less than stop price - if bad_stop_price: - raise OperationalException( - 'In stoploss limit order, stop price should be more than limit price') - return limit_rate - - def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict: - params = self._params.copy() - # Verify if stopPrice works for your exchange! - params.update({'stopPrice': stop_price}) - return params - - @retrier(retries=0) - def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict, - side: str, leverage: float) -> Dict: - """ - creates a stoploss order. - requires `_ft_has['stoploss_order_types']` to be set as a dict mapping limit and market - to the corresponding exchange type. - - The precise ordertype is determined by the order_types dict or exchange default. - - The exception below should never raise, since we disallow - starting the bot in validate_ordertypes() - - This may work with a limited number of other exchanges, but correct working - needs to be tested individually. - WARNING: setting `stoploss_on_exchange` to True will NOT auto-enable stoploss on exchange. - `stoploss_adjust` must still be implemented for this to work. - """ - if not self._ft_has['stoploss_on_exchange']: - raise OperationalException(f"stoploss is not implemented for {self.name}.") - - user_order_type = order_types.get('stoploss', 'market') - ordertype, user_order_type = self._get_stop_order_type(user_order_type) - - stop_price_norm = self.price_to_precision(pair, stop_price) - limit_rate = None - if user_order_type == 'limit': - limit_rate = self._get_stop_limit_rate(stop_price, order_types, side) - limit_rate = self.price_to_precision(pair, limit_rate) - - if self._config['dry_run']: - dry_order = self.create_dry_run_order( - pair, - ordertype, - side, - amount, - stop_price_norm, - stop_loss=True, - leverage=leverage, - ) - return dry_order - - try: - params = self._get_stop_params(ordertype=ordertype, stop_price=stop_price_norm) - if self.trading_mode == TradingMode.FUTURES: - params['reduceOnly'] = True - - amount = self.amount_to_precision(pair, self._amount_to_contracts(pair, amount)) - - self._lev_prep(pair, leverage, side) - order = self._api.create_order(symbol=pair, type=ordertype, side=side, - amount=amount, price=limit_rate, params=params) - self._log_exchange_response('create_stoploss_order', order) - order = self._order_contracts_to_amount(order) - logger.info(f"stoploss {user_order_type} order added for {pair}. " - f"stop price: {stop_price}. limit: {limit_rate}") - return order - except ccxt.InsufficientFunds as e: - raise InsufficientFundsError( - f'Insufficient funds to create {ordertype} sell order on market {pair}. ' - f'Tried to sell amount {amount} at rate {limit_rate}. ' - f'Message: {e}') from e - except ccxt.InvalidOrder as e: - # Errors: - # `Order would trigger immediately.` - raise InvalidOrderException( - f'Could not create {ordertype} sell order on market {pair}. ' - f'Tried to sell amount {amount} at rate {limit_rate}. ' - f'Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f"Could not place stoploss order due to {e.__class__.__name__}. " - f"Message: {e}") from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier(retries=API_FETCH_ORDER_RETRY_COUNT) - def fetch_order(self, order_id: str, pair: str, params={}) -> Dict: - if self._config['dry_run']: - return self.fetch_dry_run_order(order_id) - try: - order = self._api.fetch_order(order_id, pair, params=params) - self._log_exchange_response('fetch_order', order) - order = self._order_contracts_to_amount(order) - return order - except ccxt.OrderNotFound as e: - raise RetryableOrderError( - f'Order not found (pair: {pair} id: {order_id}). Message: {e}') from e - except ccxt.InvalidOrder as e: - raise InvalidOrderException( - f'Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - # Assign method to fetch_stoploss_order to allow easy overriding in other classes - fetch_stoploss_order = fetch_order - - def fetch_order_or_stoploss_order(self, order_id: str, pair: str, - stoploss_order: bool = False) -> Dict: - """ - Simple wrapper calling either fetch_order or fetch_stoploss_order depending on - the stoploss_order parameter - :param order_id: OrderId to fetch order - :param pair: Pair corresponding to order_id - :param stoploss_order: If true, uses fetch_stoploss_order, otherwise fetch_order. - """ - if stoploss_order: - return self.fetch_stoploss_order(order_id, pair) - return self.fetch_order(order_id, pair) - - def check_order_canceled_empty(self, order: Dict) -> bool: - """ - Verify if an order has been cancelled without being partially filled - :param order: Order dict as returned from fetch_order() - :return: True if order has been cancelled without being filled, False otherwise. - """ - return (order.get('status') in NON_OPEN_EXCHANGE_STATES - and order.get('filled') == 0.0) - - @retrier - def cancel_order(self, order_id: str, pair: str, params={}) -> Dict: - if self._config['dry_run']: - try: - order = self.fetch_dry_run_order(order_id) - - order.update({'status': 'canceled', 'filled': 0.0, 'remaining': order['amount']}) - return order - except InvalidOrderException: - return {} - - try: - order = self._api.cancel_order(order_id, pair, params=params) - self._log_exchange_response('cancel_order', order) - order = self._order_contracts_to_amount(order) - return order - except ccxt.InvalidOrder as e: - raise InvalidOrderException( - f'Could not cancel order. Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - # Assign method to cancel_stoploss_order to allow easy overriding in other classes - cancel_stoploss_order = cancel_order - - def is_cancel_order_result_suitable(self, corder) -> bool: - if not isinstance(corder, dict): - return False - - required = ('fee', 'status', 'amount') - return all(k in corder for k in required) - - def cancel_order_with_result(self, order_id: str, pair: str, amount: float) -> Dict: - """ - Cancel order returning a result. - Creates a fake result if cancel order returns a non-usable result - and fetch_order does not work (certain exchanges don't return cancelled orders) - :param order_id: Orderid to cancel - :param pair: Pair corresponding to order_id - :param amount: Amount to use for fake response - :return: Result from either cancel_order if usable, or fetch_order - """ - try: - corder = self.cancel_order(order_id, pair) - if self.is_cancel_order_result_suitable(corder): - return corder - except InvalidOrderException: - logger.warning(f"Could not cancel order {order_id} for {pair}.") - try: - order = self.fetch_order(order_id, pair) - except InvalidOrderException: - logger.warning(f"Could not fetch cancelled order {order_id}.") - order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}} - - return order - - def cancel_stoploss_order_with_result(self, order_id: str, pair: str, amount: float) -> Dict: - """ - Cancel stoploss order returning a result. - Creates a fake result if cancel order returns a non-usable result - and fetch_order does not work (certain exchanges don't return cancelled orders) - :param order_id: stoploss-order-id to cancel - :param pair: Pair corresponding to order_id - :param amount: Amount to use for fake response - :return: Result from either cancel_order if usable, or fetch_order - """ - corder = self.cancel_stoploss_order(order_id, pair) - if self.is_cancel_order_result_suitable(corder): - return corder - try: - order = self.fetch_stoploss_order(order_id, pair) - except InvalidOrderException: - logger.warning(f"Could not fetch cancelled stoploss order {order_id}.") - order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}} - - return order - - @retrier - def get_balances(self) -> dict: - - try: - balances = self._api.fetch_balance() - # Remove additional info from ccxt results - balances.pop("info", None) - balances.pop("free", None) - balances.pop("total", None) - balances.pop("used", None) - - return balances - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier - def fetch_positions(self) -> List[Dict]: - if self._config['dry_run'] or self.trading_mode != TradingMode.FUTURES: - return [] - try: - positions: List[Dict] = self._api.fetch_positions() - self._log_exchange_response('fetch_positions', positions) - return positions - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get positions due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier - def fetch_trading_fees(self) -> Dict[str, Any]: - """ - Fetch user account trading fees - Can be cached, should not update often. - """ - if (self._config['dry_run'] or self.trading_mode != TradingMode.FUTURES - or not self.exchange_has('fetchTradingFees')): - return {} - try: - trading_fees: Dict[str, Any] = self._api.fetch_trading_fees() - self._log_exchange_response('fetch_trading_fees', trading_fees) - return trading_fees - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not fetch trading fees due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier - def fetch_bids_asks(self, symbols: List[str] = None, cached: bool = False) -> Dict: - """ - :param cached: Allow cached result - :return: fetch_tickers result - """ - if not self.exchange_has('fetchBidsAsks'): - return {} - if cached: - tickers = self._fetch_tickers_cache.get('fetch_bids_asks') - if tickers: - return tickers - try: - tickers = self._api.fetch_bids_asks(symbols) - self._fetch_tickers_cache['fetch_bids_asks'] = tickers - return tickers - except ccxt.NotSupported as e: - raise OperationalException( - f'Exchange {self._api.name} does not support fetching bids/asks in batch. ' - f'Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not load bids/asks due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier - def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict: - """ - :param cached: Allow cached result - :return: fetch_tickers result - """ - if cached: - tickers = self._fetch_tickers_cache.get('fetch_tickers') - if tickers: - return tickers - try: - tickers = self._api.fetch_tickers(symbols) - self._fetch_tickers_cache['fetch_tickers'] = tickers - return tickers - except ccxt.NotSupported as e: - raise OperationalException( - f'Exchange {self._api.name} does not support fetching tickers in batch. ' - f'Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not load tickers due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - # Pricing info - - @retrier - def fetch_ticker(self, pair: str) -> dict: - try: - if (pair not in self.markets or - self.markets[pair].get('active', False) is False): - raise ExchangeError(f"Pair {pair} not available") - data = self._api.fetch_ticker(pair) - return data - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not load ticker due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @staticmethod - def get_next_limit_in_list(limit: int, limit_range: Optional[List[int]], - range_required: bool = True): - """ - Get next greater value in the list. - Used by fetch_l2_order_book if the api only supports a limited range - """ - if not limit_range: - return limit - - result = min([x for x in limit_range if limit <= x] + [max(limit_range)]) - if not range_required and limit > result: - # Range is not required - we can use None as parameter. - return None - return result - - @retrier - def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict: - """ - Get L2 order book from exchange. - Can be limited to a certain amount (if supported). - Returns a dict in the format - {'asks': [price, volume], 'bids': [price, volume]} - """ - limit1 = self.get_next_limit_in_list(limit, self._ft_has['l2_limit_range'], - self._ft_has['l2_limit_range_required']) - try: - - return self._api.fetch_l2_order_book(pair, limit1) - except ccxt.NotSupported as e: - raise OperationalException( - f'Exchange {self._api.name} does not support fetching order book.' - f'Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get order book due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - -<<<<<<< HEAD - def get_rate(self, pair: str, refresh: bool, side: str, - order_book: Optional[dict] = None, ticker: Optional[dict] = None) -> float: -======= - def get_rate(self, pair: str, refresh: bool, - side: Literal['entry', 'exit'], is_short: bool) -> float: ->>>>>>> develop - """ - Calculates bid/ask target - bid rate - between current ask price and last price - ask rate - either using ticker bid or first bid based on orderbook - or remain static in any other case since it's not updating. - :param pair: Pair to get rate for - :param refresh: allow cached data - :param side: "buy" or "sell" - :return: float: Price - :raises PricingError if orderbook price could not be determined. - """ - name = side.capitalize() - strat_name = 'entry_pricing' if side == "entry" else 'exit_pricing' - - cache_rate: TTLCache = self._entry_rate_cache if side == "entry" else self._exit_rate_cache - if not refresh: - rate = cache_rate.get(pair) - # Check if cache has been invalidated - if rate: - logger.debug(f"Using cached {side} rate for {pair}.") - return rate - - conf_strategy = self._config.get(strat_name, {}) - -<<<<<<< HEAD -======= - price_side = conf_strategy['price_side'] - - if price_side in ('same', 'other'): - price_map = { - ('entry', 'long', 'same'): 'bid', - ('entry', 'long', 'other'): 'ask', - ('entry', 'short', 'same'): 'ask', - ('entry', 'short', 'other'): 'bid', - ('exit', 'long', 'same'): 'ask', - ('exit', 'long', 'other'): 'bid', - ('exit', 'short', 'same'): 'bid', - ('exit', 'short', 'other'): 'ask', - } - price_side = price_map[(side, 'short' if is_short else 'long', price_side)] - - price_side_word = price_side.capitalize() - ->>>>>>> develop - if conf_strategy.get('use_order_book', False): - - order_book_top = conf_strategy.get('order_book_top', 1) - if order_book is None: - order_book = self.fetch_l2_order_book(pair, order_book_top) - logger.debug('order_book %s', order_book) - # top 1 = index 0 - try: - rate = order_book[f"{price_side}s"][order_book_top - 1][0] - except (IndexError, KeyError) as e: - logger.warning( - f"{pair} - {name} Price at location {order_book_top} from orderbook " - f"could not be determined. Orderbook: {order_book}" - ) - raise PricingError from e -<<<<<<< HEAD - price_side = {conf_strategy['price_side'].capitalize()} - logger.debug(f"{pair} - {name} price from orderbook {price_side}" - f"side - top {order_book_top} order book {side} rate {rate:.8f}") - else: - logger.debug(f"Using Last {conf_strategy['price_side'].capitalize()} / Last Price") - if ticker is None: - ticker = self.fetch_ticker(pair) - ticker_rate = ticker[conf_strategy['price_side']] -======= - logger.debug(f"{name} price from orderbook {price_side_word}" - f"side - top {order_book_top} order book {side} rate {rate:.8f}") - else: - logger.debug(f"Using Last {price_side_word} / Last Price") - ticker = self.fetch_ticker(pair) - ticker_rate = ticker[price_side] ->>>>>>> develop - if ticker['last'] and ticker_rate: - if side == 'entry' and ticker_rate > ticker['last']: - balance = conf_strategy.get('price_last_balance', 0.0) - ticker_rate = ticker_rate + balance * (ticker['last'] - ticker_rate) - elif side == 'exit' and ticker_rate < ticker['last']: - balance = conf_strategy.get('price_last_balance', 0.0) - ticker_rate = ticker_rate - balance * (ticker_rate - ticker['last']) - rate = ticker_rate - - if rate is None: - raise PricingError(f"{name}-Rate for {pair} was empty.") - cache_rate[pair] = rate - - return rate - - def get_rates(self, pair: str, refresh: bool) -> Tuple[float, float]: - buy_rate = None - sell_rate = None - if not refresh: - buy_rate = self._buy_rate_cache.get(pair) - sell_rate = self._sell_rate_cache.get(pair) - if buy_rate: - logger.debug(f"Using cached buy rate for {pair}.") - if sell_rate: - logger.debug(f"Using cached sell rate for {pair}.") - - bid_strategy = self._config.get('bid_strategy', {}) - ask_strategy = self._config.get('ask_strategy', {}) - order_book = ticker = None - if bid_strategy.get('use_order_book', False): - order_book_top = max(bid_strategy.get('order_book_top', 1), - ask_strategy.get('order_book_top', 1)) - order_book = self.fetch_l2_order_book(pair, order_book_top) - if not buy_rate: - buy_rate = self.get_rate(pair, refresh, 'buy', order_book=order_book) - else: - ticker = self.fetch_ticker(pair) - if not buy_rate: - buy_rate = self.get_rate(pair, refresh, 'buy', ticker=ticker) - if not sell_rate: - sell_rate = self.get_rate(pair, refresh, 'sell', order_book=order_book, ticker=ticker) - return buy_rate, sell_rate - - # Fee handling - - @retrier - def get_trades_for_order(self, order_id: str, pair: str, since: datetime, - params: Optional[Dict] = None) -> List: - """ - Fetch Orders using the "fetch_my_trades" endpoint and filter them by order-id. - The "since" argument passed in is coming from the database and is in UTC, - as timezone-native datetime object. - From the python documentation: - > Naive datetime instances are assumed to represent local time - Therefore, calling "since.timestamp()" will get the UTC timestamp, after applying the - transformation from local timezone to UTC. - This works for timezones UTC+ since then the result will contain trades from a few hours - instead of from the last 5 seconds, however fails for UTC- timezones, - since we're then asking for trades with a "since" argument in the future. - - :param order_id order_id: Order-id as given when creating the order - :param pair: Pair the order is for - :param since: datetime object of the order creation time. Assumes object is in UTC. - """ - if self._config['dry_run']: - return [] - if not self.exchange_has('fetchMyTrades'): - return [] - try: - # Allow 5s offset to catch slight time offsets (discovered in #1185) - # since needs to be int in milliseconds - _params = params if params else {} - my_trades = self._api.fetch_my_trades( - pair, int((since.replace(tzinfo=timezone.utc).timestamp() - 5) * 1000), - params=_params) - matched_trades = [trade for trade in my_trades if trade['order'] == order_id] - - self._log_exchange_response('get_trades_for_order', matched_trades) - - matched_trades = self._trades_contracts_to_amount(matched_trades) - - return matched_trades - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get trades due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - def get_order_id_conditional(self, order: Dict[str, Any]) -> str: - return order['id'] - - @retrier - def get_fee(self, symbol: str, type: str = '', side: str = '', amount: float = 1, - price: float = 1, taker_or_maker: str = 'maker') -> float: - try: - if self._config['dry_run'] and self._config.get('fee', None) is not None: - return self._config['fee'] - # validate that markets are loaded before trying to get fee - if self._api.markets is None or len(self._api.markets) == 0: - self._api.load_markets() - - return self._api.calculate_fee(symbol=symbol, type=type, side=side, amount=amount, - price=price, takerOrMaker=taker_or_maker)['rate'] - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get fee info due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @staticmethod - def order_has_fee(order: Dict) -> bool: - """ - Verifies if the passed in order dict has the needed keys to extract fees, - and that these keys (currency, cost) are not empty. - :param order: Order or trade (one trade) dict - :return: True if the fee substructure contains currency and cost, false otherwise - """ - if not isinstance(order, dict): - return False - return ('fee' in order and order['fee'] is not None - and (order['fee'].keys() >= {'currency', 'cost'}) - and order['fee']['currency'] is not None - and order['fee']['cost'] is not None - ) - - def calculate_fee_rate(self, order: Dict) -> Optional[float]: - """ - Calculate fee rate if it's not given by the exchange. - :param order: Order or trade (one trade) dict - """ - if order['fee'].get('rate') is not None: - return order['fee'].get('rate') - fee_curr = order['fee']['currency'] - # Calculate fee based on order details - if fee_curr in self.get_pair_base_currency(order['symbol']): - # Base currency - divide by amount - return round( - order['fee']['cost'] / safe_value_fallback2(order, order, 'filled', 'amount'), 8) - elif fee_curr in self.get_pair_quote_currency(order['symbol']): - # Quote currency - divide by cost - return round(order['fee']['cost'] / order['cost'], 8) if order['cost'] else None - else: - # If Fee currency is a different currency - if not order['cost']: - # If cost is None or 0.0 -> falsy, return None - return None - try: - comb = self.get_valid_pair_combination(fee_curr, self._config['stake_currency']) - tick = self.fetch_ticker(comb) - - fee_to_quote_rate = safe_value_fallback2(tick, tick, 'last', 'ask') - except ExchangeError: - fee_to_quote_rate = self._config['exchange'].get('unknown_fee_rate', None) - if not fee_to_quote_rate: - return None - return round((order['fee']['cost'] * fee_to_quote_rate) / order['cost'], 8) - - def extract_cost_curr_rate(self, order: Dict) -> Tuple[float, str, Optional[float]]: - """ - Extract tuple of cost, currency, rate. - Requires order_has_fee to run first! - :param order: Order or trade (one trade) dict - :return: Tuple with cost, currency, rate of the given fee dict - """ - return (order['fee']['cost'], - order['fee']['currency'], - self.calculate_fee_rate(order)) - - # Historic data - - def get_historic_ohlcv(self, pair: str, timeframe: str, - since_ms: int, candle_type: CandleType, - is_new_pair: bool = False) -> List: - """ - Get candle history using asyncio and returns the list of candles. - Handles all async work for this. - Async over one pair, assuming we get `self.ohlcv_candle_limit()` candles per call. - :param pair: Pair to download - :param timeframe: Timeframe to get data for - :param since_ms: Timestamp in milliseconds to get history from - :param candle_type: '', mark, index, premiumIndex, or funding_rate - :return: List with candle (OHLCV) data - """ - pair, _, _, data = self.loop.run_until_complete( - self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe, - since_ms=since_ms, is_new_pair=is_new_pair, - candle_type=candle_type)) - logger.info(f"Downloaded data for {pair} with length {len(data)}.") - return data - - def get_historic_ohlcv_as_df(self, pair: str, timeframe: str, - since_ms: int, candle_type: CandleType) -> DataFrame: - """ - Minimal wrapper around get_historic_ohlcv - converting the result into a dataframe - :param pair: Pair to download - :param timeframe: Timeframe to get data for - :param since_ms: Timestamp in milliseconds to get history from - :param candle_type: Any of the enum CandleType (must match trading mode!) - :return: OHLCV DataFrame - """ - ticks = self.get_historic_ohlcv(pair, timeframe, since_ms=since_ms, candle_type=candle_type) - return ohlcv_to_dataframe(ticks, timeframe, pair=pair, fill_missing=True, - drop_incomplete=self._ohlcv_partial_candle) - - async def _async_get_historic_ohlcv(self, pair: str, timeframe: str, - since_ms: int, candle_type: CandleType, - is_new_pair: bool = False, raise_: bool = False, - ) -> Tuple[str, str, str, List]: - """ - Download historic ohlcv - :param is_new_pair: used by binance subclass to allow "fast" new pair downloading - :param candle_type: Any of the enum CandleType (must match trading mode!) - """ - - one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) - logger.debug( - "one_call: %s msecs (%s)", - one_call, - arrow.utcnow().shift(seconds=one_call // 1000).humanize(only_distance=True) - ) - input_coroutines = [self._async_get_candle_history( - pair, timeframe, candle_type, since) for since in - range(since_ms, arrow.utcnow().int_timestamp * 1000, one_call)] - - data: List = [] - # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling - for input_coro in chunks(input_coroutines, 100): - - results = await asyncio.gather(*input_coro, return_exceptions=True) - for res in results: - if isinstance(res, Exception): - logger.warning(f"Async code raised an exception: {repr(res)}") - if raise_: - raise - continue - else: - # Deconstruct tuple if it's not an exception - p, _, c, new_data = res - if p == pair and c == candle_type: - data.extend(new_data) - # Sort data again after extending the result - above calls return in "async order" - data = sorted(data, key=lambda x: x[0]) - return pair, timeframe, candle_type, data - - def _build_coroutine(self, pair: str, timeframe: str, candle_type: CandleType, - since_ms: Optional[int]) -> Coroutine: - - if not since_ms and self.required_candle_call_count > 1: - # Multiple calls for one pair - to get more history - one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) - move_to = one_call * self.required_candle_call_count - now = timeframe_to_next_date(timeframe) - since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000) - - if since_ms: - return self._async_get_historic_ohlcv( - pair, timeframe, since_ms=since_ms, raise_=True, candle_type=candle_type) - else: - # One call ... "regular" refresh - return self._async_get_candle_history( - pair, timeframe, since_ms=since_ms, candle_type=candle_type) - - def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *, - since_ms: Optional[int] = None, cache: bool = True, - drop_incomplete: bool = None - ) -> Dict[PairWithTimeframe, DataFrame]: - """ - Refresh in-memory OHLCV asynchronously and set `_klines` with the result - Loops asynchronously over pair_list and downloads all pairs async (semi-parallel). - Only used in the dataprovider.refresh() method. - :param pair_list: List of 2 element tuples containing pair, interval to refresh - :param since_ms: time since when to download, in milliseconds - :param cache: Assign result to _klines. Usefull for one-off downloads like for pairlists - :param drop_incomplete: Control candle dropping. - Specifying None defaults to _ohlcv_partial_candle - :return: Dict of [{(pair, timeframe): Dataframe}] - """ - logger.debug("Refreshing candle (OHLCV) data for %d pairs", len(pair_list)) - drop_incomplete = self._ohlcv_partial_candle if drop_incomplete is None else drop_incomplete - input_coroutines = [] - cached_pairs = [] - # Gather coroutines to run - for pair, timeframe, candle_type in set(pair_list): - if (timeframe not in self.timeframes - and candle_type in (CandleType.SPOT, CandleType.FUTURES)): - logger.warning( - f"Cannot download ({pair}, {timeframe}) combination as this timeframe is " - f"not available on {self.name}. Available timeframes are " - f"{', '.join(self.timeframes)}.") - continue - if ((pair, timeframe, candle_type) not in self._klines or not cache - or self._now_is_time_to_refresh(pair, timeframe, candle_type)): - input_coroutines.append(self._build_coroutine( - pair, timeframe, candle_type=candle_type, since_ms=since_ms)) - - else: - logger.debug( - f"Using cached candle (OHLCV) data for {pair}, {timeframe}, {candle_type} ..." - ) - cached_pairs.append((pair, timeframe, candle_type)) - - results_df = {} - # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling - for input_coro in chunks(input_coroutines, 100): - async def gather_stuff(): - return await asyncio.gather(*input_coro, return_exceptions=True) - - results = self.loop.run_until_complete(gather_stuff()) - - for res in results: - if isinstance(res, Exception): - logger.warning(f"Async code raised an exception: {repr(res)}") - continue - # Deconstruct tuple (has 4 elements) - pair, timeframe, c_type, ticks = res - # keeping last candle time as last refreshed time of the pair - if ticks: - self._pairs_last_refresh_time[(pair, timeframe, c_type)] = ticks[-1][0] // 1000 - # keeping parsed dataframe in cache - ohlcv_df = ohlcv_to_dataframe( - ticks, timeframe, pair=pair, fill_missing=True, - drop_incomplete=drop_incomplete) - results_df[(pair, timeframe, c_type)] = ohlcv_df - if cache: - self._klines[(pair, timeframe, c_type)] = ohlcv_df - # Return cached klines - for pair, timeframe, c_type in cached_pairs: - results_df[(pair, timeframe, c_type)] = self.klines( - (pair, timeframe, c_type), - copy=False - ) - - return results_df - - def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool: - # Timeframe in seconds - interval_in_sec = timeframe_to_seconds(timeframe) - - return not ( - (self._pairs_last_refresh_time.get( - (pair, timeframe, candle_type), - 0 - ) + interval_in_sec) >= arrow.utcnow().int_timestamp - ) - - @retrier_async - async def _async_get_candle_history( - self, - pair: str, - timeframe: str, - candle_type: CandleType, - since_ms: Optional[int] = None, - ) -> Tuple[str, str, str, List]: - """ - Asynchronously get candle history data using fetch_ohlcv - :param candle_type: '', mark, index, premiumIndex, or funding_rate - returns tuple: (pair, timeframe, ohlcv_list) - """ - try: - # Fetch OHLCV asynchronously - s = '(' + arrow.get(since_ms // 1000).isoformat() + ') ' if since_ms is not None else '' - logger.debug( - "Fetching pair %s, interval %s, since %s %s...", - pair, timeframe, since_ms, s - ) - params = deepcopy(self._ft_has.get('ohlcv_params', {})) - if candle_type != CandleType.SPOT: - params.update({'price': candle_type}) - if candle_type != CandleType.FUNDING_RATE: - data = await self._api_async.fetch_ohlcv( - pair, timeframe=timeframe, since=since_ms, - limit=self.ohlcv_candle_limit(timeframe), params=params) - else: - # Funding rate - data = await self._api_async.fetch_funding_rate_history( - pair, since=since_ms, - limit=self.ohlcv_candle_limit(timeframe)) - # Convert funding rate to candle pattern - data = [[x['timestamp'], x['fundingRate'], 0, 0, 0, 0] for x in data] - # Some exchanges sort OHLCV in ASC order and others in DESC. - # Ex: Bittrex returns the list of OHLCV in ASC order (oldest first, newest last) - # while GDAX returns the list of OHLCV in DESC order (newest first, oldest last) - # Only sort if necessary to save computing time - try: - if data and data[0][0] > data[-1][0]: - data = sorted(data, key=lambda x: x[0]) - except IndexError: - logger.exception("Error loading %s. Result was %s.", pair, data) - return pair, timeframe, candle_type, [] - logger.debug("Done fetching pair %s, interval %s ...", pair, timeframe) - return pair, timeframe, candle_type, data - - except ccxt.NotSupported as e: - raise OperationalException( - f'Exchange {self._api.name} does not support fetching historical ' - f'candle (OHLCV) data. Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError(f'Could not fetch historical candle (OHLCV) data ' - f'for pair {pair} due to {e.__class__.__name__}. ' - f'Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(f'Could not fetch historical candle (OHLCV) data ' - f'for pair {pair}. Message: {e}') from e - - # Fetch historic trades - - @retrier_async - async def _async_fetch_trades(self, pair: str, - since: Optional[int] = None, - params: Optional[dict] = None) -> List[List]: - """ - Asyncronously gets trade history using fetch_trades. - Handles exchange errors, does one call to the exchange. - :param pair: Pair to fetch trade data for - :param since: Since as integer timestamp in milliseconds - returns: List of dicts containing trades - """ - try: - # fetch trades asynchronously - if params: - logger.debug("Fetching trades for pair %s, params: %s ", pair, params) - trades = await self._api_async.fetch_trades(pair, params=params, limit=1000) - else: - logger.debug( - "Fetching trades for pair %s, since %s %s...", - pair, since, - '(' + arrow.get(since // 1000).isoformat() + ') ' if since is not None else '' - ) - trades = await self._api_async.fetch_trades(pair, since=since, limit=1000) - trades = self._trades_contracts_to_amount(trades) - return trades_dict_to_list(trades) - except ccxt.NotSupported as e: - raise OperationalException( - f'Exchange {self._api.name} does not support fetching historical trade data.' - f'Message: {e}') from e - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError(f'Could not load trade history due to {e.__class__.__name__}. ' - f'Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(f'Could not fetch trade data. Msg: {e}') from e - - async def _async_get_trade_history_id(self, pair: str, - until: int, - since: Optional[int] = None, - from_id: Optional[str] = None) -> Tuple[str, List[List]]: - """ - Asyncronously gets trade history using fetch_trades - use this when exchange uses id-based iteration (check `self._trades_pagination`) - :param pair: Pair to fetch trade data for - :param since: Since as integer timestamp in milliseconds - :param until: Until as integer timestamp in milliseconds - :param from_id: Download data starting with ID (if id is known). Ignores "since" if set. - returns tuple: (pair, trades-list) - """ - - trades: List[List] = [] - - if not from_id: - # Fetch first elements using timebased method to get an ID to paginate on - # Depending on the Exchange, this can introduce a drift at the start of the interval - # of up to an hour. - # e.g. Binance returns the "last 1000" candles within a 1h time interval - # - so we will miss the first trades. - t = await self._async_fetch_trades(pair, since=since) - # DEFAULT_TRADES_COLUMNS: 0 -> timestamp - # DEFAULT_TRADES_COLUMNS: 1 -> id - from_id = t[-1][1] - trades.extend(t[:-1]) - while True: - t = await self._async_fetch_trades(pair, - params={self._trades_pagination_arg: from_id}) - if t: - # Skip last id since its the key for the next call - trades.extend(t[:-1]) - if from_id == t[-1][1] or t[-1][0] > until: - logger.debug(f"Stopping because from_id did not change. " - f"Reached {t[-1][0]} > {until}") - # Reached the end of the defined-download period - add last trade as well. - trades.extend(t[-1:]) - break - - from_id = t[-1][1] - else: - break - - return (pair, trades) - - async def _async_get_trade_history_time(self, pair: str, until: int, - since: Optional[int] = None) -> Tuple[str, List[List]]: - """ - Asyncronously gets trade history using fetch_trades, - when the exchange uses time-based iteration (check `self._trades_pagination`) - :param pair: Pair to fetch trade data for - :param since: Since as integer timestamp in milliseconds - :param until: Until as integer timestamp in milliseconds - returns tuple: (pair, trades-list) - """ - - trades: List[List] = [] - # DEFAULT_TRADES_COLUMNS: 0 -> timestamp - # DEFAULT_TRADES_COLUMNS: 1 -> id - while True: - t = await self._async_fetch_trades(pair, since=since) - if t: - since = t[-1][0] - trades.extend(t) - # Reached the end of the defined-download period - if until and t[-1][0] > until: - logger.debug( - f"Stopping because until was reached. {t[-1][0]} > {until}") - break - else: - break - - return (pair, trades) - - async def _async_get_trade_history(self, pair: str, - since: Optional[int] = None, - until: Optional[int] = None, - from_id: Optional[str] = None) -> Tuple[str, List[List]]: - """ - Async wrapper handling downloading trades using either time or id based methods. - """ - - logger.debug(f"_async_get_trade_history(), pair: {pair}, " - f"since: {since}, until: {until}, from_id: {from_id}") - - if until is None: - until = ccxt.Exchange.milliseconds() - logger.debug(f"Exchange milliseconds: {until}") - - if self._trades_pagination == 'time': - return await self._async_get_trade_history_time( - pair=pair, since=since, until=until) - elif self._trades_pagination == 'id': - return await self._async_get_trade_history_id( - pair=pair, since=since, until=until, from_id=from_id - ) - else: - raise OperationalException(f"Exchange {self.name} does use neither time, " - f"nor id based pagination") - - def get_historic_trades(self, pair: str, - since: Optional[int] = None, - until: Optional[int] = None, - from_id: Optional[str] = None) -> Tuple[str, List]: - """ - Get trade history data using asyncio. - Handles all async work and returns the list of candles. - Async over one pair, assuming we get `self.ohlcv_candle_limit()` candles per call. - :param pair: Pair to download - :param since: Timestamp in milliseconds to get history from - :param until: Timestamp in milliseconds. Defaults to current timestamp if not defined. - :param from_id: Download data starting with ID (if id is known) - :returns List of trade data - """ - if not self.exchange_has("fetchTrades"): - raise OperationalException("This exchange does not support downloading Trades.") - - return self.loop.run_until_complete( - self._async_get_trade_history(pair=pair, since=since, - until=until, from_id=from_id)) - - @retrier - def _get_funding_fees_from_exchange(self, pair: str, since: Union[datetime, int]) -> float: - """ - Returns the sum of all funding fees that were exchanged for a pair within a timeframe - Dry-run handling happens as part of _calculate_funding_fees. - :param pair: (e.g. ADA/USDT) - :param since: The earliest time of consideration for calculating funding fees, - in unix time or as a datetime - """ - if not self.exchange_has("fetchFundingHistory"): - raise OperationalException( - f"fetch_funding_history() is not available using {self.name}" - ) - - if type(since) is datetime: - since = int(since.timestamp()) * 1000 # * 1000 for ms - - try: - funding_history = self._api.fetch_funding_history( - symbol=pair, - since=since - ) - return sum(fee['amount'] for fee in funding_history) - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not get funding fees due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier - def get_leverage_tiers(self) -> Dict[str, List[Dict]]: - try: - return self._api.fetch_leverage_tiers() - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not load leverage tiers due to {e.__class__.__name__}. Message: {e}' - ) from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - @retrier - def get_market_leverage_tiers(self, symbol) -> List[Dict]: - try: - return self._api.fetch_market_leverage_tiers(symbol) - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not load leverage tiers for {symbol}' - f' due to {e.__class__.__name__}. Message: {e}' - ) from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - def load_leverage_tiers(self) -> Dict[str, List[Dict]]: - if self.trading_mode == TradingMode.FUTURES: - if self.exchange_has('fetchLeverageTiers'): - # Fetch all leverage tiers at once - return self.get_leverage_tiers() - elif self.exchange_has('fetchMarketLeverageTiers'): - # Must fetch the leverage tiers for each market separately - # * This is slow(~45s) on Okx, makes ~90 api calls to load all linear swap markets - markets = self.markets - symbols = [] - - for symbol, market in markets.items(): - if (self.market_is_future(market) - and market['quote'] == self._config['stake_currency']): - symbols.append(symbol) - - tiers: Dict[str, List[Dict]] = {} - - # Be verbose here, as this delays startup by ~1 minute. - logger.info( - f"Initializing leverage_tiers for {len(symbols)} markets. " - "This will take about a minute.") - - for symbol in sorted(symbols): - tiers[symbol] = self.get_market_leverage_tiers(symbol) - - logger.info(f"Done initializing {len(symbols)} markets.") - - return tiers - else: - return {} - else: - return {} - - def fill_leverage_tiers(self) -> None: - """ - Assigns property _leverage_tiers to a dictionary of information about the leverage - allowed on each pair - """ - leverage_tiers = self.load_leverage_tiers() - for pair, tiers in leverage_tiers.items(): - pair_tiers = [] - for tier in tiers: - pair_tiers.append(self.parse_leverage_tier(tier)) - self._leverage_tiers[pair] = pair_tiers - - def parse_leverage_tier(self, tier) -> Dict: - info = tier.get('info', {}) - return { - 'min': tier['notionalFloor'], - 'max': tier['notionalCap'], - 'mmr': tier['maintenanceMarginRate'], - 'lev': tier['maxLeverage'], - 'maintAmt': float(info['cum']) if 'cum' in info else None, - } - - def get_max_leverage(self, pair: str, stake_amount: Optional[float]) -> float: - """ - Returns the maximum leverage that a pair can be traded at - :param pair: The base/quote currency pair being traded - :stake_amount: The total value of the traders margin_mode in quote currency - """ - - if self.trading_mode == TradingMode.SPOT: - return 1.0 - - if self.trading_mode == TradingMode.FUTURES: - - # Checks and edge cases - if stake_amount is None: - raise OperationalException( - f'{self.name}.get_max_leverage requires argument stake_amount' - ) - - if pair not in self._leverage_tiers: - # Maybe raise exception because it can't be traded on futures? - return 1.0 - - pair_tiers = self._leverage_tiers[pair] - - if stake_amount == 0: - return self._leverage_tiers[pair][0]['lev'] # Max lev for lowest amount - - for tier_index in range(len(pair_tiers)): - - tier = pair_tiers[tier_index] - lev = tier['lev'] - - if tier_index < len(pair_tiers) - 1: - next_tier = pair_tiers[tier_index+1] - next_floor = next_tier['min'] / next_tier['lev'] - if next_floor > stake_amount: # Next tier min too high for stake amount - return min((tier['max'] / stake_amount), lev) - # - # With the two leverage tiers below, - # - a stake amount of 150 would mean a max leverage of (10000 / 150) = 66.66 - # - stakes below 133.33 = max_lev of 75 - # - stakes between 133.33-200 = max_lev of 10000/stake = 50.01-74.99 - # - stakes from 200 + 1000 = max_lev of 50 - # - # { - # "min": 0, # stake = 0.0 - # "max": 10000, # max_stake@75 = 10000/75 = 133.33333333333334 - # "lev": 75, - # }, - # { - # "min": 10000, # stake = 200.0 - # "max": 50000, # max_stake@50 = 50000/50 = 1000.0 - # "lev": 50, - # } - # - - else: # if on the last tier - if stake_amount > tier['max']: # If stake is > than max tradeable amount - raise InvalidOrderException(f'Amount {stake_amount} too high for {pair}') - else: - return tier['lev'] - - raise OperationalException( - 'Looped through all tiers without finding a max leverage. Should never be reached' - ) - - elif self.trading_mode == TradingMode.MARGIN: # Search markets.limits for max lev - market = self.markets[pair] - if market['limits']['leverage']['max'] is not None: - return market['limits']['leverage']['max'] - else: - return 1.0 # Default if max leverage cannot be found - else: - return 1.0 - - @retrier - def _set_leverage( - self, - leverage: float, - pair: Optional[str] = None, - trading_mode: Optional[TradingMode] = None - ): - """ - Set's the leverage before making a trade, in order to not - have the same leverage on every trade - """ - if self._config['dry_run'] or not self.exchange_has("setLeverage"): - # Some exchanges only support one margin_mode type - return - - try: - self._api.set_leverage(symbol=pair, leverage=leverage) - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - def get_interest_rate(self) -> float: - """ - Retrieve interest rate - necessary for Margin trading. - Should not call the exchange directly when used from backtesting. - """ - return 0.0 - - def get_liquidation_price( - self, - pair: str, - open_rate: float, - amount: float, # quote currency, includes leverage - leverage: float, - is_short: bool - ) -> Optional[float]: - - if self.trading_mode in TradingMode.SPOT: - return None - elif ( - self.margin_mode == MarginMode.ISOLATED and - self.trading_mode == TradingMode.FUTURES - ): - wallet_balance = (amount * open_rate) / leverage - isolated_liq = self.get_or_calculate_liquidation_price( - pair=pair, - open_rate=open_rate, - is_short=is_short, - position=amount, - wallet_balance=wallet_balance, - mm_ex_1=0.0, - upnl_ex_1=0.0, - ) - return isolated_liq - else: - raise OperationalException( - "Freqtrade only supports isolated futures for leverage trading") - - def funding_fee_cutoff(self, open_date: datetime): - """ - :param open_date: The open date for a trade - :return: The cutoff open time for when a funding fee is charged - """ - return open_date.minute > 0 or open_date.second > 0 - - @retrier - def set_margin_mode(self, pair: str, margin_mode: MarginMode, params: dict = {}): - """ - Set's the margin mode on the exchange to cross or isolated for a specific pair - :param pair: base/quote currency pair (e.g. "ADA/USDT") - """ - if self._config['dry_run'] or not self.exchange_has("setMarginMode"): - # Some exchanges only support one margin_mode type - return - - try: - self._api.set_margin_mode(margin_mode.value, pair, params) - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not set margin mode due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - def _fetch_and_calculate_funding_fees( - self, - pair: str, - amount: float, - is_short: bool, - open_date: datetime, - close_date: Optional[datetime] = None - ) -> float: - """ - Fetches and calculates the sum of all funding fees that occurred for a pair - during a futures trade. - Only used during dry-run or if the exchange does not provide a funding_rates endpoint. - :param pair: The quote/base pair of the trade - :param amount: The quantity of the trade - :param is_short: trade direction - :param open_date: The date and time that the trade started - :param close_date: The date and time that the trade ended - """ - - if self.funding_fee_cutoff(open_date): - open_date += timedelta(hours=1) - timeframe = self._ft_has['mark_ohlcv_timeframe'] - timeframe_ff = self._ft_has.get('funding_fee_timeframe', - self._ft_has['mark_ohlcv_timeframe']) - - if not close_date: - close_date = datetime.now(timezone.utc) - open_timestamp = int(timeframe_to_prev_date(timeframe, open_date).timestamp()) * 1000 - # close_timestamp = int(close_date.timestamp()) * 1000 - - mark_comb: PairWithTimeframe = ( - pair, timeframe, CandleType.from_string(self._ft_has["mark_ohlcv_price"])) - - funding_comb: PairWithTimeframe = (pair, timeframe_ff, CandleType.FUNDING_RATE) - candle_histories = self.refresh_latest_ohlcv( - [mark_comb, funding_comb], - since_ms=open_timestamp, - cache=False, - drop_incomplete=False, - ) - funding_rates = candle_histories[funding_comb] - mark_rates = candle_histories[mark_comb] - funding_mark_rates = self.combine_funding_and_mark( - funding_rates=funding_rates, mark_rates=mark_rates) - - return self.calculate_funding_fees( - funding_mark_rates, - amount=amount, - is_short=is_short, - open_date=open_date, - close_date=close_date - ) - - @staticmethod - def combine_funding_and_mark(funding_rates: DataFrame, mark_rates: DataFrame) -> DataFrame: - """ - Combine funding-rates and mark-rates dataframes - :param funding_rates: Dataframe containing Funding rates (Type FUNDING_RATE) - :param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price) - """ - - return funding_rates.merge(mark_rates, on='date', how="inner", suffixes=["_fund", "_mark"]) - - def calculate_funding_fees( - self, - df: DataFrame, - amount: float, - is_short: bool, - open_date: datetime, - close_date: Optional[datetime] = None, - time_in_ratio: Optional[float] = None - ) -> float: - """ - calculates the sum of all funding fees that occurred for a pair during a futures trade - :param df: Dataframe containing combined funding and mark rates - as `open_fund` and `open_mark`. - :param amount: The quantity of the trade - :param is_short: trade direction - :param open_date: The date and time that the trade started - :param close_date: The date and time that the trade ended - :param time_in_ratio: Not used by most exchange classes - """ - fees: float = 0 - - if not df.empty: - df = df[(df['date'] >= open_date) & (df['date'] <= close_date)] - fees = sum(df['open_fund'] * df['open_mark'] * amount) - - # Negate fees for longs as funding_fees expects it this way based on live endpoints. - return fees if is_short else -fees - - def get_funding_fees( - self, pair: str, amount: float, is_short: bool, open_date: datetime) -> float: - """ - Fetch funding fees, either from the exchange (live) or calculates them - based on funding rate/mark price history - :param pair: The quote/base pair of the trade - :param is_short: trade direction - :param amount: Trade amount - :param open_date: Open date of the trade - """ - if self.trading_mode == TradingMode.FUTURES: - if self._config['dry_run']: - funding_fees = self._fetch_and_calculate_funding_fees( - pair, amount, is_short, open_date) - else: - funding_fees = self._get_funding_fees_from_exchange(pair, open_date) - return funding_fees - else: - return 0.0 - - @retrier - def get_or_calculate_liquidation_price( - self, - pair: str, - # Dry-run - open_rate: float, # Entry price of position - is_short: bool, - position: float, # Absolute value of position size - wallet_balance: float, # Or margin balance - mm_ex_1: float = 0.0, # (Binance) Cross only - upnl_ex_1: float = 0.0, # (Binance) Cross only - ) -> Optional[float]: - """ - Set's the margin mode on the exchange to cross or isolated for a specific pair - :param pair: base/quote currency pair (e.g. "ADA/USDT") - """ - if self.trading_mode == TradingMode.SPOT: - return None - elif (self.trading_mode != TradingMode.FUTURES and self.margin_mode != MarginMode.ISOLATED): - raise OperationalException( - f"{self.name} does not support {self.margin_mode.value} {self.trading_mode.value}") - - if self._config['dry_run'] or not self.exchange_has("fetchPositions"): - - isolated_liq = self.dry_run_liquidation_price( - pair=pair, - open_rate=open_rate, - is_short=is_short, - position=position, - wallet_balance=wallet_balance, - mm_ex_1=mm_ex_1, - upnl_ex_1=upnl_ex_1 - ) - else: - try: - positions = self._api.fetch_positions([pair]) - if len(positions) > 0: - pos = positions[0] - isolated_liq = pos['liquidationPrice'] - else: - return None - except ccxt.DDoSProtection as e: - raise DDosProtection(e) from e - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not set margin mode due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - - if isolated_liq: - buffer_amount = abs(open_rate - isolated_liq) * self.liquidation_buffer - isolated_liq = ( - isolated_liq - buffer_amount - if is_short else - isolated_liq + buffer_amount - ) - return isolated_liq - else: - return None - - def dry_run_liquidation_price( - self, - pair: str, - open_rate: float, # Entry price of position - is_short: bool, - position: float, # Absolute value of position size - wallet_balance: float, # Or margin balance - mm_ex_1: float = 0.0, # (Binance) Cross only - upnl_ex_1: float = 0.0, # (Binance) Cross only - ) -> Optional[float]: - """ - PERPETUAL: - gateio: https://www.gate.io/help/futures/perpetual/22160/calculation-of-liquidation-price - okex: https://www.okex.com/support/hc/en-us/articles/ - 360053909592-VI-Introduction-to-the-isolated-mode-of-Single-Multi-currency-Portfolio-margin - Important: Must be fetching data from cached values as this is used by backtesting! - - :param exchange_name: - :param open_rate: Entry price of position - :param is_short: True if the trade is a short, false otherwise - :param position: Absolute value of position size incl. leverage (in base currency) - :param trading_mode: SPOT, MARGIN, FUTURES, etc. - :param margin_mode: Either ISOLATED or CROSS - :param wallet_balance: Amount of margin_mode in the wallet being used to trade - Cross-Margin Mode: crossWalletBalance - Isolated-Margin Mode: isolatedWalletBalance - - # * Not required by Gateio or OKX - :param mm_ex_1: - :param upnl_ex_1: - """ - - market = self.markets[pair] - taker_fee_rate = market['taker'] - mm_ratio, _ = self.get_maintenance_ratio_and_amt(pair, position) - - if self.trading_mode == TradingMode.FUTURES and self.margin_mode == MarginMode.ISOLATED: - - if market['inverse']: - raise OperationalException( - "Freqtrade does not yet support inverse contracts") - - value = wallet_balance / position - - mm_ratio_taker = (mm_ratio + taker_fee_rate) - if is_short: - return (open_rate + value) / (1 + mm_ratio_taker) - else: - return (open_rate - value) / (1 - mm_ratio_taker) - else: - raise OperationalException( - "Freqtrade only supports isolated futures for leverage trading") - - def get_maintenance_ratio_and_amt( - self, - pair: str, - nominal_value: float = 0.0, - ) -> Tuple[float, Optional[float]]: - """ - Important: Must be fetching data from cached values as this is used by backtesting! - :param pair: Market symbol - :param nominal_value: The total trade amount in quote currency including leverage - maintenance amount only on Binance - :return: (maintenance margin ratio, maintenance amount) - """ - - if (self._config.get('runmode') in OPTIMIZE_MODES - or self.exchange_has('fetchLeverageTiers') - or self.exchange_has('fetchMarketLeverageTiers')): - - if pair not in self._leverage_tiers: - raise InvalidOrderException( - f"Maintenance margin rate for {pair} is unavailable for {self.name}" - ) - - pair_tiers = self._leverage_tiers[pair] - - for tier in reversed(pair_tiers): - if nominal_value >= tier['min']: - return (tier['mmr'], tier['maintAmt']) - - raise OperationalException("nominal value can not be lower than 0") - # The lowest notional_floor for any pair in fetch_leverage_tiers is always 0 because it - # describes the min amt for a tier, and the lowest tier will always go down to 0 - else: - raise OperationalException(f"Cannot get maintenance ratio using {self.name}") - - -def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool: - return exchange_name in ccxt_exchanges(ccxt_module) - - -def is_exchange_officially_supported(exchange_name: str) -> bool: - return exchange_name in SUPPORTED_EXCHANGES - - -def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]: - """ - Return the list of all exchanges known to ccxt - """ - return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges - - -def available_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]: - """ - Return exchanges available to the bot, i.e. non-bad exchanges in the ccxt list - """ - exchanges = ccxt_exchanges(ccxt_module) - return [x for x in exchanges if validate_exchange(x)[0]] - - -def validate_exchange(exchange: str) -> Tuple[bool, str]: - ex_mod = getattr(ccxt, exchange.lower())() - if not ex_mod or not ex_mod.has: - return False, '' - missing = [k for k in EXCHANGE_HAS_REQUIRED if ex_mod.has.get(k) is not True] - if missing: - return False, f"missing: {', '.join(missing)}" - - missing_opt = [k for k in EXCHANGE_HAS_OPTIONAL if not ex_mod.has.get(k)] - - if exchange.lower() in BAD_EXCHANGES: - return False, BAD_EXCHANGES.get(exchange.lower(), '') - if missing_opt: - return True, f"missing opt: {', '.join(missing_opt)}" - - return True, '' - - -def validate_exchanges(all_exchanges: bool) -> List[Tuple[str, bool, str]]: - """ - :return: List of tuples with exchangename, valid, reason. - """ - exchanges = ccxt_exchanges() if all_exchanges else available_exchanges() - exchanges_valid = [ - (e, *validate_exchange(e)) for e in exchanges - ] - return exchanges_valid - - -def timeframe_to_seconds(timeframe: str) -> int: - """ - Translates the timeframe interval value written in the human readable - form ('1m', '5m', '1h', '1d', '1w', etc.) to the number - of seconds for one timeframe interval. - """ - return ccxt.Exchange.parse_timeframe(timeframe) - - -def timeframe_to_minutes(timeframe: str) -> int: - """ - Same as timeframe_to_seconds, but returns minutes. - """ - return ccxt.Exchange.parse_timeframe(timeframe) // 60 - - -def timeframe_to_msecs(timeframe: str) -> int: - """ - Same as timeframe_to_seconds, but returns milliseconds. - """ - return ccxt.Exchange.parse_timeframe(timeframe) * 1000 - - -def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime: - """ - Use Timeframe and determine last possible candle. - :param timeframe: timeframe in string format (e.g. "5m") - :param date: date to use. Defaults to utcnow() - :returns: date of previous candle (with utc timezone) - """ - if not date: - date = datetime.now(timezone.utc) - - new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000, - ROUND_DOWN) // 1000 - return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) - - -def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime: - """ - Use Timeframe and determine next candle. - :param timeframe: timeframe in string format (e.g. "5m") - :param date: date to use. Defaults to utcnow() - :returns: date of next candle (with utc timezone) - """ - if not date: - date = datetime.now(timezone.utc) - new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000, - ROUND_UP) // 1000 - return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) - - -def market_is_active(market: Dict) -> bool: - """ - Return True if the market is active. - """ - # "It's active, if the active flag isn't explicitly set to false. If it's missing or - # true then it's true. If it's undefined, then it's most likely true, but not 100% )" - # See https://github.com/ccxt/ccxt/issues/4874, - # https://github.com/ccxt/ccxt/issues/4075#issuecomment-434760520 - return market.get('active', True) is not False diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 8ac4a7180..39989a0d4 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -459,7 +459,6 @@ class FreqtradeBot(LoggingMixin): if signal: stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge) - bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {}) if ((bid_check_dom.get('enabled', False)) and (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): @@ -505,7 +504,8 @@ class FreqtradeBot(LoggingMixin): If the strategy triggers the adjustment, a new order gets issued. Once that completes, the existing trade is modified to match new data. """ - current_entry_rate, current_exit_rate = self.exchange.get_rates(trade.pair, True, is_short) + current_entry_rate, current_exit_rate = self.exchange.get_rates( + trade.pair, True, trade.is_short) current_entry_profit = trade.calc_profit_ratio(current_entry_rate) current_exit_profit = trade.calc_profit_ratio(current_exit_rate) @@ -528,7 +528,8 @@ class FreqtradeBot(LoggingMixin): current_entry_rate=current_entry_rate, current_exit_rate=current_exit_rate, current_entry_profit=current_entry_profit, current_exit_profit=current_exit_profit, min_entry_stake=min_entry_stake, min_exit_stake=min_exit_stake, -max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_exit_stake, stake_available), + max_entry_stake=min(max_entry_stake, stake_available), + max_exit_stake=min(max_exit_stake, stake_available) ) if stake_amount is not None and stake_amount > 0.0: @@ -540,7 +541,8 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex return else: logger.debug("Max adjustment entries is set to unlimited.") - self.execute_entry(trade.pair, stake_amount, current_entry_rate, trade=trade, is_short=trade.is_short) + self.execute_entry(trade.pair, stake_amount, current_entry_rate, + trade=trade, is_short=trade.is_short) if stake_amount is not None and stake_amount < 0.0: # We should decrease our position @@ -553,8 +555,8 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex logger.info( f"Adjusting amount to trade.amount as it is higher. {amount} > {trade.amount}") amount = trade.amount - self.execute_trade_exit(trade, current_exit_rate, sell_reason=SellCheckTuple( - sell_type=SellType.CUSTOM_SELL), sub_trade_amt=amount) + self.execute_trade_exit(trade, current_exit_rate, exit_check=ExitCheckTuple( + exit_type=ExitType.PARTIAL_SELL), sub_trade_amt=amount) def _check_depth_of_market(self, pair: str, conf: Dict, side: SignalDirection) -> bool: """ @@ -628,7 +630,6 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex amount = (stake_amount / enter_limit_requested) * leverage order_type = ordertype or self.strategy.order_types['entry'] - if not pos_adjust and not strategy_safe_wrapper( self.strategy.confirm_trade_entry, default_retval=True)( pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, @@ -648,7 +649,7 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex ) order_obj = Order.parse_from_ccxt_object(order, pair, side) order_id = order['id'] - order_status = order.get('status', None) + order_status = order.get('status') logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.") # we assume the order is executed at the price requested @@ -744,8 +745,8 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex else: logger.info(f"DCA order {order_status}, will wait for resolution: {trade}") - # Update fees if order is closed - if order_status == 'closed': + # Update fees if order is non-opened + if order_status in constants.NON_OPEN_EXCHANGE_STATES: self.update_trade_state(trade, order_id, order) return True @@ -1384,7 +1385,7 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex # if stoploss is on exchange and we are on dry_run mode, # we consider the sell price stop price if (self.config['dry_run'] and exit_type == 'stoploss' - and self.strategy.order_types['stoploss_on_exchange']): + and self.strategy.order_types['stoploss_on_exchange']): limit = trade.stop_loss # set custom_exit_price if available @@ -1471,7 +1472,7 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex profit_rate = order.safe_price if not fill: - trade.process_sell_sub_trade(order, is_closed=False) + trade.process_exit_sub_trade(order, is_closed=False) profit_ratio = trade.close_profit profit = trade.close_profit_abs @@ -1637,12 +1638,12 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex # Updating wallets when order is closed self.wallets.update() + sub_trade = not isclose(order_obj.safe_amount_after_fee, + trade.amount, abs_tol=constants.MATH_CLOSE_PREC) if not trade.is_open: - self.handle_protections(trade.pair) - sub_trade = order_obj.safe_amount_after_fee != trade.amount - if order.get('side', None) == 'sell': if send_msg and not stoploss_order and not trade.open_order_id: self._notify_exit(trade, '', True, sub_trade=sub_trade, order=order_obj) + self.handle_protections(trade.pair) elif send_msg and not trade.open_order_id: # Enter fill self._notify_enter(trade, order_obj, fill=True, sub_trade=sub_trade) @@ -1794,4 +1795,4 @@ max_entry_stake=min(max_entry_stake, stake_available), max_exit_stake=min(max_ex # Bracket between min_custom_price_allowed and max_custom_price_allowed return max( min(valid_custom_price, max_custom_price_allowed), - min_custom_price_allowed) \ No newline at end of file + min_custom_price_allowed) diff --git a/freqtrade/freqtradebot.py.orig b/freqtrade/freqtradebot.py.orig deleted file mode 100644 index 02da5fc62..000000000 --- a/freqtrade/freqtradebot.py.orig +++ /dev/null @@ -1,1872 +0,0 @@ -""" -Freqtrade is the main module of this bot. It contains the class Freqtrade() -""" -import copy -import logging -import traceback -<<<<<<< HEAD -from datetime import datetime, timezone -from decimal import Decimal -======= -from datetime import datetime, time, timezone ->>>>>>> develop -from math import isclose -from threading import Lock -from typing import Any, Dict, List, Literal, Optional, Tuple - -from schedule import Scheduler - -from freqtrade import __version__, constants -from freqtrade.configuration import validate_config_consistency -from freqtrade.data.converter import order_book_to_dataframe -from freqtrade.data.dataprovider import DataProvider -from freqtrade.edge import Edge -from freqtrade.enums import (ExitCheckTuple, ExitType, RPCMessageType, RunMode, SignalDirection, - State, TradingMode) -from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, - InvalidOrderException, PricingError) -from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds -from freqtrade.misc import safe_value_fallback, safe_value_fallback2 -from freqtrade.mixins import LoggingMixin -from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db -from freqtrade.plugins.pairlistmanager import PairListManager -from freqtrade.plugins.protectionmanager import ProtectionManager -from freqtrade.resolvers import ExchangeResolver, StrategyResolver -from freqtrade.rpc import RPCManager -from freqtrade.strategy.interface import IStrategy -from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper -from freqtrade.wallets import Wallets - - -logger = logging.getLogger(__name__) - - -class FreqtradeBot(LoggingMixin): - """ - Freqtrade is the main class of the bot. - This is from here the bot start its logic. - """ - - def __init__(self, config: Dict[str, Any]) -> None: - """ - Init all variables and objects the bot needs to work - :param config: configuration dict, you can use Configuration.get_config() - to get the config dict. - """ - self.active_pair_whitelist: List[str] = [] - - logger.info('Starting freqtrade %s', __version__) - - # Init bot state - self.state = State.STOPPED - - # Init objects - self.config = config - - self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) - - # Check config consistency here since strategies can set certain options - validate_config_consistency(config) - - self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) - - init_db(self.config.get('db_url', None), clean_open_orders=self.config['dry_run']) - - self.wallets = Wallets(self.config, self.exchange) - - PairLocks.timeframe = self.config['timeframe'] - - self.protections = ProtectionManager(self.config, self.strategy.protections) - - # RPC runs in separate threads, can start handling external commands just after - # initialization, even before Freqtradebot has a chance to start its throttling, - # so anything in the Freqtradebot instance should be ready (initialized), including - # the initial state of the bot. - # Keep this at the end of this initialization method. - self.rpc: RPCManager = RPCManager(self) - - self.pairlists = PairListManager(self.exchange, self.config) - - self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) - - # Attach Dataprovider to strategy instance - self.strategy.dp = self.dataprovider - # Attach Wallets to strategy instance - self.strategy.wallets = self.wallets - - # Initializing Edge only if enabled - self.edge = Edge(self.config, self.exchange, self.strategy) if \ - self.config.get('edge', {}).get('enabled', False) else None - - self.active_pair_whitelist = self._refresh_active_whitelist() - - # Set initial bot state from config - initial_state = self.config.get('initial_state') - self.state = State[initial_state.upper()] if initial_state else State.STOPPED - - # Protect exit-logic from forcesell and vice versa - self._exit_lock = Lock() - LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe)) - - self.trading_mode: TradingMode = self.config.get('trading_mode', TradingMode.SPOT) - - self._schedule = Scheduler() - - if self.trading_mode == TradingMode.FUTURES: - - def update(): - self.update_funding_fees() - self.wallets.update() - - # TODO: This would be more efficient if scheduled in utc time, and performed at each - # TODO: funding interval, specified by funding_fee_times on the exchange classes - for time_slot in range(0, 24): - for minutes in [0, 15, 30, 45]: - t = str(time(time_slot, minutes, 2)) - self._schedule.every().day.at(t).do(update) - self.last_process = datetime(1970, 1, 1, tzinfo=timezone.utc) - - def notify_status(self, msg: str) -> None: - """ - Public method for users of this class (worker, etc.) to send notifications - via RPC about changes in the bot status. - """ - self.rpc.send_msg({ - 'type': RPCMessageType.STATUS, - 'status': msg - }) - - def cleanup(self) -> None: - """ - Cleanup pending resources on an already stopped bot - :return: None - """ - logger.info('Cleaning up modules ...') - - if self.config['cancel_open_orders_on_exit']: - self.cancel_all_open_orders() - - self.check_for_open_trades() - - self.rpc.cleanup() - cleanup_db() - self.exchange.close() - - def startup(self) -> None: - """ - Called on startup and after reloading the bot - triggers notifications and - performs startup tasks - """ - self.rpc.startup_messages(self.config, self.pairlists, self.protections) - if not self.edge: - # Adjust stoploss if it was changed - Trade.stoploss_reinitialization(self.strategy.stoploss) - - # Only update open orders on startup - # This will update the database after the initial migration - self.startup_update_open_orders() - - def process(self) -> None: - """ - Queries the persistence layer for open trades and handles them, - otherwise a new trade is created. - :return: True if one or more trades has been created or closed, False otherwise - """ - - # Check whether markets have to be reloaded and reload them when it's needed - self.exchange.reload_markets() - - self.update_closed_trades_without_assigned_fees() - - # Query trades from persistence layer - trades = Trade.get_open_trades() - - self.active_pair_whitelist = self._refresh_active_whitelist(trades) - - # Refreshing candles - self.dataprovider.refresh(self.pairlists.create_pair_list(self.active_pair_whitelist), - self.strategy.gather_informative_pairs()) - - strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - - self.strategy.analyze(self.active_pair_whitelist) - - with self._exit_lock: - # Check and handle any timed out open orders - self.check_handle_timedout() - - # Protect from collisions with forceexit. - # Without this, freqtrade my try to recreate stoploss_on_exchange orders - # while exiting is in process, since telegram messages arrive in an different thread. - with self._exit_lock: - trades = Trade.get_open_trades() - # First process current opened trades (positions) - self.exit_positions(trades) - - # Check if we need to adjust our current positions before attempting to buy new trades. - if self.strategy.position_adjustment_enable: - with self._exit_lock: - self.process_open_trade_positions() - - # Then looking for buy opportunities - if self.get_free_open_trades(): - self.enter_positions() - if self.trading_mode == TradingMode.FUTURES: - self._schedule.run_pending() - Trade.commit() - self.last_process = datetime.now(timezone.utc) - - def process_stopped(self) -> None: - """ - Close all orders that were left open - """ - if self.config['cancel_open_orders_on_exit']: - self.cancel_all_open_orders() - - def check_for_open_trades(self): - """ - Notify the user when the bot is stopped (not reloaded) - and there are still open trades active. - """ - open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all() - - if len(open_trades) != 0 and self.state != State.RELOAD_CONFIG: - msg = { - 'type': RPCMessageType.WARNING, - 'status': - f"{len(open_trades)} open trades active.\n\n" - f"Handle these trades manually on {self.exchange.name}, " - f"or '/start' the bot again and use '/stopbuy' " - f"to handle open trades gracefully. \n" - f"{'Note: Trades are simulated (dry run).' if self.config['dry_run'] else ''}", - } - self.rpc.send_msg(msg) - - def _refresh_active_whitelist(self, trades: List[Trade] = []) -> List[str]: - """ - Refresh active whitelist from pairlist or edge and extend it with - pairs that have open trades. - """ - # Refresh whitelist - self.pairlists.refresh_pairlist() - _whitelist = self.pairlists.whitelist - - # Calculating Edge positioning - if self.edge: - self.edge.calculate(_whitelist) - _whitelist = self.edge.adjust(_whitelist) - - if trades: - # Extend active-pair whitelist with pairs of open trades - # It ensures that candle (OHLCV) data are downloaded for open trades as well - _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) - return _whitelist - - def get_free_open_trades(self) -> int: - """ - Return the number of free open trades slots or 0 if - max number of open trades reached - """ - open_trades = len(Trade.get_open_trades()) - return max(0, self.config['max_open_trades'] - open_trades) - - def update_funding_fees(self): - if self.trading_mode == TradingMode.FUTURES: - trades = Trade.get_open_trades() - for trade in trades: - funding_fees = self.exchange.get_funding_fees( - pair=trade.pair, - amount=trade.amount, - is_short=trade.is_short, - open_date=trade.open_date_utc - ) - trade.funding_fees = funding_fees - else: - return 0.0 - - def startup_update_open_orders(self): - """ - Updates open orders based on order list kept in the database. - Mainly updates the state of orders - but may also close trades - """ - if self.config['dry_run'] or self.config['exchange'].get('skip_open_order_update', False): - # Updating open orders in dry-run does not make sense and will fail. - return - - orders = Order.get_open_orders() - logger.info(f"Updating {len(orders)} open orders.") - for order in orders: - try: - fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair, - order.ft_order_side == 'stoploss') - - self.update_trade_state(order.trade, order.order_id, fo) - - except ExchangeError as e: - - logger.warning(f"Error updating Order {order.order_id} due to {e}") - - if self.trading_mode == TradingMode.FUTURES: - self._schedule.run_pending() - - def update_closed_trades_without_assigned_fees(self): - """ - Update closed trades without close fees assigned. - Only acts when Orders are in the database, otherwise the last order-id is unknown. - """ - if self.config['dry_run']: - # Updating open orders in dry-run does not make sense and will fail. - return - - trades: List[Trade] = Trade.get_closed_trades_without_assigned_fees() - for trade in trades: - if not trade.is_open and not trade.fee_updated(trade.exit_side): - # Get sell fee - order = trade.select_order(trade.exit_side, False) - if order: - logger.info( - f"Updating {trade.exit_side}-fee on trade {trade}" - f"for order {order.order_id}." - ) - self.update_trade_state(trade, order.order_id, - stoploss_order=order.ft_order_side == 'stoploss', - send_msg=False) - - trades: List[Trade] = Trade.get_open_trades_without_assigned_fees() - for trade in trades: - if trade.is_open and not trade.fee_updated(trade.enter_side): - order = trade.select_order(trade.enter_side, False) - open_order = trade.select_order(trade.enter_side, True) - if order and open_order is None: - logger.info( - f"Updating {trade.enter_side}-fee on trade {trade}" - f"for order {order.order_id}." - ) - self.update_trade_state(trade, order.order_id, send_msg=False) - - def handle_insufficient_funds(self, trade: Trade): - """ - Try refinding a lost trade. - Only used when InsufficientFunds appears on exit orders (stoploss or long sell/short buy). - Tries to walk the stored orders and sell them off eventually. - """ - logger.info(f"Trying to refind lost order for {trade}") - for order in trade.orders: - logger.info(f"Trying to refind {order}") - fo = None - if not order.ft_is_open: - logger.debug(f"Order {order} is no longer open.") - continue - try: - fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair, - order.ft_order_side == 'stoploss') - if order.ft_order_side == 'stoploss': - if fo and fo['status'] == 'open': - # Assume this as the open stoploss order - trade.stoploss_order_id = order.order_id - elif order.ft_order_side == trade.exit_side: - if fo and fo['status'] == 'open': - # Assume this as the open order - trade.open_order_id = order.order_id - elif order.ft_order_side == trade.enter_side: - if fo and fo['status'] == 'open': - trade.open_order_id = order.order_id - if fo: - logger.info(f"Found {order} for trade {trade}.") - self.update_trade_state(trade, order.order_id, fo, - stoploss_order=order.ft_order_side == 'stoploss') - - except ExchangeError: - logger.warning(f"Error updating {order.order_id}.") - -# -# BUY / enter positions / open trades logic and methods -# - - def enter_positions(self) -> int: - """ - Tries to execute entry orders for new trades (positions) - """ - trades_created = 0 - - whitelist = copy.deepcopy(self.active_pair_whitelist) - if not whitelist: - logger.info("Active pair whitelist is empty.") - return trades_created - # Remove pairs for currently opened trades from the whitelist - for trade in Trade.get_open_trades(): - if trade.pair in whitelist: - whitelist.remove(trade.pair) - logger.debug('Ignoring %s in pair whitelist', trade.pair) - - if not whitelist: - logger.info("No currency pair in active pair whitelist, " - "but checking to exit open trades.") - return trades_created - if PairLocks.is_global_lock(): - lock = PairLocks.get_pair_longest_lock('*') - if lock: - self.log_once(f"Global pairlock active until " - f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)}. " - f"Not creating new trades, reason: {lock.reason}.", logger.info) - else: - self.log_once("Global pairlock active. Not creating new trades.", logger.info) - return trades_created - # Create entity and execute trade for each pair from whitelist - for pair in whitelist: - try: - trades_created += self.create_trade(pair) - except DependencyException as exception: - logger.warning('Unable to create trade for %s: %s', pair, exception) - - if not trades_created: - logger.debug("Found no enter signals for whitelisted currencies. Trying again...") - - return trades_created - - def create_trade(self, pair: str) -> bool: - """ - Check the implemented trading strategy for buy signals. - - If the pair triggers the buy signal a new trade record gets created - and the buy-order opening the trade gets issued towards the exchange. - - :return: True if a trade has been created. - """ - logger.debug(f"create_trade for pair {pair}") - - analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) - nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None - if self.strategy.is_pair_locked(pair, nowtime): - lock = PairLocks.get_pair_longest_lock(pair, nowtime) - if lock: - self.log_once(f"Pair {pair} is still locked until " - f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} " - f"due to {lock.reason}.", - logger.info) - else: - self.log_once(f"Pair {pair} is still locked.", logger.info) - return False - - # get_free_open_trades is checked before create_trade is called - # but it is still used here to prevent opening too many trades within one iteration - if not self.get_free_open_trades(): - logger.debug(f"Can't open a new trade for {pair}: max number of trades is reached.") - return False - - # running get_signal on historical data fetched - (signal, enter_tag) = self.strategy.get_entry_signal( - pair, - self.strategy.timeframe, - analyzed_df - ) - - if signal: - stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge) - - bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {}) - if ((bid_check_dom.get('enabled', False)) and - (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): - if self._check_depth_of_market(pair, bid_check_dom, side=signal): - return self.execute_entry( - pair, - stake_amount, - enter_tag=enter_tag, - is_short=(signal == SignalDirection.SHORT) - ) - else: - return False - - return self.execute_entry( - pair, - stake_amount, - enter_tag=enter_tag, - is_short=(signal == SignalDirection.SHORT) - ) - else: - return False - -# -# BUY / increase positions / DCA logic and methods -# - def process_open_trade_positions(self): - """ - Tries to execute additional buy or sell orders for open trades (positions) - """ - # Walk through each pair and check if it needs changes - for trade in Trade.get_open_trades(): - # If there is any open orders, wait for them to finish. - if trade.open_order_id is None: - try: - self.check_and_call_adjust_trade_position(trade) - except DependencyException as exception: - logger.warning( - f"Unable to adjust position of trade for {trade.pair}: {exception}") - - def check_and_call_adjust_trade_position(self, trade: Trade): - """ - Check the implemented trading strategy for adjustment command. - If the strategy triggers the adjustment, a new order gets issued. - Once that completes, the existing trade is modified to match new data. - """ -<<<<<<< HEAD - current_entry_rate, current_exit_rate = self.exchange.get_rates(trade.pair, True) - - current_entry_profit = trade.calc_profit_ratio(current_entry_rate) - current_exit_profit = trade.calc_profit_ratio(current_exit_rate) - - min_entry_stake = self.exchange.get_min_pair_stake_amount(trade.pair, - current_entry_rate, - self.strategy.stoploss) - min_exit_stake = self.exchange.get_min_pair_stake_amount(trade.pair, - current_exit_rate, - self.strategy.stoploss) - max_stake = self.wallets.get_available_stake_amount() - logger.debug(f"Calling adjust_trade_position for pair {trade.pair}") - stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, - default_retval=None)( - trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_entry_rate, - current_profit=current_entry_profit, min_stake=min_entry_stake, - max_stake=max_stake, - current_entry_rate=current_entry_rate, current_exit_rate=current_exit_rate, - current_entry_profit=current_entry_profit, current_exit_profit=current_exit_profit, - min_entry_stake=min_entry_stake, min_exit_stake=min_exit_stake - ) - - if stake_amount is not None and stake_amount > 0.0: - # We should increase our position - if self.strategy.max_entry_position_adjustment > -1: - count_of_buys = trade.nr_of_successful_buys - if count_of_buys > self.strategy.max_entry_position_adjustment: - logger.debug(f"Max adjustment entries for {trade.pair} has been reached.") - return - else: - logger.debug("Max adjustment entries is set to unlimited.") - self.execute_entry(trade.pair, stake_amount, current_entry_rate, trade=trade) -======= - if self.strategy.max_entry_position_adjustment > -1: - count_of_buys = trade.nr_of_successful_entries - if count_of_buys > self.strategy.max_entry_position_adjustment: - logger.debug(f"Max adjustment entries for {trade.pair} has been reached.") - return - else: - logger.debug("Max adjustment entries is set to unlimited.") - current_rate = self.exchange.get_rate( - trade.pair, side='entry', is_short=trade.is_short, refresh=True) - current_profit = trade.calc_profit_ratio(current_rate) - - min_stake_amount = self.exchange.get_min_pair_stake_amount(trade.pair, - current_rate, - self.strategy.stoploss) - max_stake_amount = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate) - stake_available = self.wallets.get_available_stake_amount() - logger.debug(f"Calling adjust_trade_position for pair {trade.pair}") - stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, - default_retval=None)( - trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_rate, - current_profit=current_profit, min_stake=min_stake_amount, - max_stake=min(max_stake_amount, stake_available)) - - if stake_amount is not None and stake_amount > 0.0: - # We should increase our position - self.execute_entry(trade.pair, stake_amount, trade=trade, is_short=trade.is_short) ->>>>>>> develop - - if stake_amount is not None and stake_amount < 0.0: - # We should decrease our position - # Strategy should return value as Decimal for accuracy. - amount = abs(float(Decimal(stake_amount) / Decimal(current_exit_rate))) - if trade.amount - amount < min_exit_stake: - logger.info('Remaining amount would be too small') - return - if amount > trade.amount: - logger.info( - f"Adjusting amount to trade.amount as it is higher. {amount} > {trade.amount}") - amount = trade.amount - self.execute_trade_exit(trade, current_exit_rate, sell_reason=SellCheckTuple( - sell_type=SellType.CUSTOM_SELL), sub_trade_amt=amount) - - def _check_depth_of_market(self, pair: str, conf: Dict, side: SignalDirection) -> bool: - """ - Checks depth of market before executing a buy - """ - conf_bids_to_ask_delta = conf.get('bids_to_ask_delta', 0) - logger.info(f"Checking depth of market for {pair} ...") - order_book = self.exchange.fetch_l2_order_book(pair, 1000) - order_book_data_frame = order_book_to_dataframe(order_book['bids'], order_book['asks']) - order_book_bids = order_book_data_frame['b_size'].sum() - order_book_asks = order_book_data_frame['a_size'].sum() - - enter_side = order_book_bids if side == SignalDirection.LONG else order_book_asks - exit_side = order_book_asks if side == SignalDirection.LONG else order_book_bids - bids_ask_delta = enter_side / exit_side - - bids = f"Bids: {order_book_bids}" - asks = f"Asks: {order_book_asks}" - delta = f"Delta: {bids_ask_delta}" - - logger.info( - f"{bids}, {asks}, {delta}, Direction: {side.value}" - f"Bid Price: {order_book['bids'][0][0]}, Ask Price: {order_book['asks'][0][0]}, " - f"Immediate Bid Quantity: {order_book['bids'][0][1]}, " - f"Immediate Ask Quantity: {order_book['asks'][0][1]}." - ) - if bids_ask_delta >= conf_bids_to_ask_delta: - logger.info(f"Bids to asks delta for {pair} DOES satisfy condition.") - return True - else: - logger.info(f"Bids to asks delta for {pair} does not satisfy condition.") - return False - - def execute_entry( - self, - pair: str, - stake_amount: float, - price: Optional[float] = None, - *, - is_short: bool = False, - ordertype: Optional[str] = None, - enter_tag: Optional[str] = None, - trade: Optional[Trade] = None, - ) -> bool: - """ - Executes a limit buy for the given pair - :param pair: pair for which we want to create a LIMIT_BUY - :param stake_amount: amount of stake-currency for the pair - :param leverage: amount of leverage applied to this trade - :return: True if a buy order is created, false if it fails. - """ - time_in_force = self.strategy.order_time_in_force['entry'] - - [side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long'] - trade_side: Literal['long', 'short'] = 'short' if is_short else 'long' - pos_adjust = trade is not None - - enter_limit_requested, stake_amount, leverage = self.get_valid_enter_price_and_stake( - pair, price, stake_amount, trade_side, enter_tag, trade) - - if not stake_amount: - return False - - if pos_adjust: - logger.info(f"Position adjust: about to create a new order for {pair} with stake: " - f"{stake_amount} for {trade}") - else: - logger.info( - f"{name} signal found: about create a new trade for {pair} with stake_amount: " - f"{stake_amount} ...") - - amount = (stake_amount / enter_limit_requested) * leverage - order_type = ordertype or self.strategy.order_types['entry'] - - if not pos_adjust and not strategy_safe_wrapper( - self.strategy.confirm_trade_entry, default_retval=True)( - pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, - time_in_force=time_in_force, current_time=datetime.now(timezone.utc), - entry_tag=enter_tag, side=trade_side): - logger.info(f"User requested abortion of buying {pair}") - return False - order = self.exchange.create_order( - pair=pair, - ordertype=order_type, - side=side, - amount=amount, - rate=enter_limit_requested, - reduceOnly=False, - time_in_force=time_in_force, - leverage=leverage - ) - order_obj = Order.parse_from_ccxt_object(order, pair, side) - order_id = order['id'] - order_status = order.get('status', None) - logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.") - - # we assume the order is executed at the price requested - enter_limit_filled_price = enter_limit_requested - amount_requested = amount - - if order_status == 'expired' or order_status == 'rejected': - - # return false if the order is not filled - if float(order['filled']) == 0: - logger.warning(f'{name} {time_in_force} order with time in force {order_type} ' - f'for {pair} is {order_status} by {self.exchange.name}.' - ' zero amount is fulfilled.') - return False - else: - # the order is partially fulfilled - # in case of IOC orders we can check immediately - # if the order is fulfilled fully or partially - logger.warning('%s %s order with time in force %s for %s is %s by %s.' - ' %s amount fulfilled out of %s (%s remaining which is canceled).', - name, time_in_force, order_type, pair, order_status, - self.exchange.name, order['filled'], order['amount'], - order['remaining'] - ) - amount = safe_value_fallback(order, 'filled', 'amount') - enter_limit_filled_price = safe_value_fallback(order, 'average', 'price') - - # in case of FOK the order may be filled immediately and fully - elif order_status == 'closed': - amount = safe_value_fallback(order, 'filled', 'amount') - enter_limit_filled_price = safe_value_fallback(order, 'average', 'price') - - # TODO: this might be unnecessary, as we're calling it in update_trade_state. - isolated_liq = self.exchange.get_liquidation_price( - leverage=leverage, - pair=pair, - amount=amount, - open_rate=enter_limit_filled_price, - is_short=is_short - ) - interest_rate = self.exchange.get_interest_rate() - - # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL - fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') - open_date = datetime.now(timezone.utc) - funding_fees = self.exchange.get_funding_fees( - pair=pair, amount=amount, is_short=is_short, open_date=open_date) - # This is a new trade - if trade is None: - trade = Trade( - pair=pair, - stake_amount=stake_amount, - amount=amount, - is_open=True, - amount_requested=amount_requested, - fee_open=fee, - fee_close=fee, - open_rate=enter_limit_filled_price, - open_rate_requested=enter_limit_requested, - open_date=open_date, - exchange=self.exchange.id, - open_order_id=order_id, - strategy=self.strategy.get_strategy_name(), - enter_tag=enter_tag, - timeframe=timeframe_to_minutes(self.config['timeframe']), - leverage=leverage, - is_short=is_short, - interest_rate=interest_rate, - liquidation_price=isolated_liq, - trading_mode=self.trading_mode, - funding_fees=funding_fees - ) - else: - # This is additional buy, we reset fee_open_currency so timeout checking can work - trade.is_open = True - trade.fee_open_currency = None - trade.open_rate_requested = enter_limit_requested - trade.open_order_id = order_id - - trade.orders.append(order_obj) - Trade.query.session.add(trade) - Trade.commit() - - # Updating wallets - self.wallets.update() - - self._notify_enter(trade, order_obj, order_type, sub_trade=pos_adjust) - - if pos_adjust: - if order_status == 'closed': - logger.info(f"DCA order closed, trade should be up to date: {trade}") - trade = self.cancel_stoploss_on_exchange(trade) - else: - logger.info(f"DCA order {order_status}, will wait for resolution: {trade}") - - # Update fees if order is closed - if order_status == 'closed': - self.update_trade_state(trade, order_id, order) - - return True - - def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade: - # First cancelling stoploss on exchange ... - if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: - try: - logger.info(f"Canceling stoploss on exchange for {trade}") - co = self.exchange.cancel_stoploss_order_with_result( - trade.stoploss_order_id, trade.pair, trade.amount) - trade.update_order(co) - except InvalidOrderException: - logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") - return trade - - def get_valid_enter_price_and_stake( - self, pair: str, price: Optional[float], stake_amount: float, - trade_side: Literal['long', 'short'], - entry_tag: Optional[str], - trade: Optional[Trade] - ) -> Tuple[float, float, float]: - - if price: - enter_limit_requested = price - else: - # Calculate price - proposed_enter_rate = self.exchange.get_rate( - pair, side='entry', is_short=(trade_side == 'short'), refresh=True) - custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price, - default_retval=proposed_enter_rate)( - pair=pair, current_time=datetime.now(timezone.utc), - proposed_rate=proposed_enter_rate, entry_tag=entry_tag) - - enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate) - - if not enter_limit_requested: - raise PricingError('Could not determine entry price.') - - if trade is None: - max_leverage = self.exchange.get_max_leverage(pair, stake_amount) - leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)( - pair=pair, - current_time=datetime.now(timezone.utc), - current_rate=enter_limit_requested, - proposed_leverage=1.0, - max_leverage=max_leverage, - side=trade_side, - ) if self.trading_mode != TradingMode.SPOT else 1.0 - # Cap leverage between 1.0 and max_leverage. - leverage = min(max(leverage, 1.0), max_leverage) - else: - # Changing leverage currently not possible - leverage = trade.leverage if trade else 1.0 - - # Min-stake-amount should actually include Leverage - this way our "minimal" - # stake- amount might be higher than necessary. - # We do however also need min-stake to determine leverage, therefore this is ignored as - # edge-case for now. - min_stake_amount = self.exchange.get_min_pair_stake_amount( - pair, enter_limit_requested, self.strategy.stoploss, leverage) - max_stake_amount = self.exchange.get_max_pair_stake_amount( - pair, enter_limit_requested, leverage) - - if not self.edge and trade is None: - stake_available = self.wallets.get_available_stake_amount() - stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, - default_retval=stake_amount)( - pair=pair, current_time=datetime.now(timezone.utc), - current_rate=enter_limit_requested, proposed_stake=stake_amount, - min_stake=min_stake_amount, max_stake=min(max_stake_amount, stake_available), - entry_tag=entry_tag, side=trade_side - ) - - stake_amount = self.wallets.validate_stake_amount( - pair=pair, - stake_amount=stake_amount, - min_stake_amount=min_stake_amount, - max_stake_amount=max_stake_amount, - ) - - return enter_limit_requested, stake_amount, leverage - - def _notify_enter(self, trade: Trade, order: Order, order_type: Optional[str] = None, - fill: bool = False, sub_trade: bool = False) -> None: - """ - Sends rpc notification when a entry order occurred. - """ -<<<<<<< HEAD - open_rate = order.safe_price - -======= - if fill: - msg_type = RPCMessageType.SHORT_FILL if trade.is_short else RPCMessageType.BUY_FILL - else: - msg_type = RPCMessageType.SHORT if trade.is_short else RPCMessageType.BUY - open_rate = safe_value_fallback(order, 'average', 'price') ->>>>>>> develop - if open_rate is None: - open_rate = trade.open_rate - - current_rate = trade.open_rate_requested - if self.dataprovider.runmode in (RunMode.DRY_RUN, RunMode.LIVE): - current_rate = self.exchange.get_rate( - trade.pair, side='entry', is_short=trade.is_short, refresh=False) - - msg = { - 'trade_id': trade.id, -<<<<<<< HEAD - 'type': RPCMessageType.BUY_FILL if fill else RPCMessageType.BUY, - 'buy_tag': trade.buy_tag, - 'exchange': trade.exchange.capitalize(), -======= - 'type': msg_type, - 'buy_tag': trade.enter_tag, - 'enter_tag': trade.enter_tag, - 'exchange': self.exchange.name.capitalize(), ->>>>>>> develop - 'pair': trade.pair, - 'leverage': trade.leverage if trade.leverage else None, - 'direction': 'Short' if trade.is_short else 'Long', - 'limit': open_rate, # Deprecated (?) - 'open_rate': open_rate, - 'order_type': order_type, - 'stake_amount': trade.stake_amount, - 'stake_currency': self.config['stake_currency'], - 'fiat_currency': self.config.get('fiat_display_currency', None), - 'amount': order.safe_amount_after_fee, - 'open_date': trade.open_date or datetime.utcnow(), - 'current_rate': current_rate, - 'sub_trade': sub_trade, - } - - # Send the message - self.rpc.send_msg(msg) - - def _notify_enter_cancel(self, trade: Trade, order_type: str, reason: str, - sub_trade: bool = False) -> None: - """ - Sends rpc notification when a entry order cancel occurred. - """ - current_rate = self.exchange.get_rate( - trade.pair, side='entry', is_short=trade.is_short, refresh=False) - msg_type = RPCMessageType.SHORT_CANCEL if trade.is_short else RPCMessageType.BUY_CANCEL - msg = { - 'trade_id': trade.id, -<<<<<<< HEAD - 'type': RPCMessageType.BUY_CANCEL, - 'buy_tag': trade.buy_tag, - 'exchange': trade.exchange.capitalize(), -======= - 'type': msg_type, - 'buy_tag': trade.enter_tag, - 'enter_tag': trade.enter_tag, - 'exchange': self.exchange.name.capitalize(), ->>>>>>> develop - 'pair': trade.pair, - 'leverage': trade.leverage, - 'direction': 'Short' if trade.is_short else 'Long', - 'limit': trade.open_rate, - 'order_type': order_type, - 'stake_amount': trade.stake_amount, - 'stake_currency': self.config['stake_currency'], - 'fiat_currency': self.config.get('fiat_display_currency', None), - 'amount': trade.amount, - 'open_date': trade.open_date, - 'current_rate': current_rate, - 'reason': reason, - 'sub_trade': sub_trade, - } - - # Send the message - self.rpc.send_msg(msg) - -# -# SELL / exit positions / close trades logic and methods -# - - def exit_positions(self, trades: List[Any]) -> int: - """ - Tries to execute exit orders for open trades (positions) - """ - trades_closed = 0 - for trade in trades: - try: - - if (self.strategy.order_types.get('stoploss_on_exchange') and - self.handle_stoploss_on_exchange(trade)): - trades_closed += 1 - Trade.commit() - continue - # Check if we can sell our current pair - if trade.open_order_id is None and trade.is_open and self.handle_trade(trade): - trades_closed += 1 - - except DependencyException as exception: - logger.warning(f'Unable to exit trade {trade.pair}: {exception}') - - # Updating wallets if any trade occurred - if trades_closed: - self.wallets.update() - - return trades_closed - - def handle_trade(self, trade: Trade) -> bool: - """ - Sells/exits_short the current pair if the threshold is reached and updates the trade record. - :return: True if trade has been sold/exited_short, False otherwise - """ - if not trade.is_open: - raise DependencyException(f'Attempt to handle closed trade: {trade}') - - logger.debug('Handling %s ...', trade) - - (enter, exit_) = (False, False) - exit_tag = None - exit_signal_type = "exit_short" if trade.is_short else "exit_long" - - if (self.config.get('use_sell_signal', True) or - self.config.get('ignore_roi_if_buy_signal', False)): - analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair, - self.strategy.timeframe) - - (enter, exit_, exit_tag) = self.strategy.get_exit_signal( - trade.pair, - self.strategy.timeframe, - analyzed_df, - is_short=trade.is_short - ) - - logger.debug('checking exit') - exit_rate = self.exchange.get_rate( - trade.pair, side='exit', is_short=trade.is_short, refresh=True) - if self._check_and_execute_exit(trade, exit_rate, enter, exit_, exit_tag): - return True - - logger.debug(f'Found no {exit_signal_type} signal for %s.', trade) - return False - - def create_stoploss_order(self, trade: Trade, stop_price: float) -> bool: - """ - Abstracts creating stoploss orders from the logic. - Handles errors and updates the trade database object. - Force-sells the pair (using EmergencySell reason) in case of Problems creating the order. - :return: True if the order succeeded, and False in case of problems. - """ - try: - stoploss_order = self.exchange.stoploss( - pair=trade.pair, - amount=trade.amount, - stop_price=stop_price, - order_types=self.strategy.order_types, - side=trade.exit_side, - leverage=trade.leverage - ) - - order_obj = Order.parse_from_ccxt_object(stoploss_order, trade.pair, 'stoploss') - trade.orders.append(order_obj) - trade.stoploss_order_id = str(stoploss_order['id']) - return True - except InsufficientFundsError as e: - logger.warning(f"Unable to place stoploss order {e}.") - # Try to figure out what went wrong - self.handle_insufficient_funds(trade) - - except InvalidOrderException as e: - trade.stoploss_order_id = None - logger.error(f'Unable to place a stoploss order on exchange. {e}') - logger.warning('Exiting the trade forcefully') - self.execute_trade_exit(trade, trade.stop_loss, exit_check=ExitCheckTuple( - exit_type=ExitType.EMERGENCY_SELL)) - - except ExchangeError: - trade.stoploss_order_id = None - logger.exception('Unable to place a stoploss order on exchange.') - return False - - def handle_stoploss_on_exchange(self, trade: Trade) -> bool: - """ - Check if trade is fulfilled in which case the stoploss - on exchange should be added immediately if stoploss on exchange - is enabled. - # TODO: liquidation price always on exchange, even without stoploss_on_exchange - # Therefore fetching account liquidations for open pairs may make sense. - """ - - logger.debug('Handling stoploss on exchange %s ...', trade) - - stoploss_order = None - - try: - # First we check if there is already a stoploss on exchange - stoploss_order = self.exchange.fetch_stoploss_order( - trade.stoploss_order_id, trade.pair) if trade.stoploss_order_id else None - except InvalidOrderException as exception: - logger.warning('Unable to fetch stoploss order: %s', exception) - - if stoploss_order: - trade.update_order(stoploss_order) - - # We check if stoploss order is fulfilled - if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'): - trade.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value - self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order, - stoploss_order=True) - # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), - reason='Auto lock') - self._notify_exit(trade, "stoploss") - return True - - if trade.open_order_id or not trade.is_open: - # Trade has an open Buy or Sell order, Stoploss-handling can't happen in this case - # as the Amount on the exchange is tied up in another trade. - # The trade can be closed already (sell-order fill confirmation came in this iteration) - return False - - # If enter order is fulfilled but there is no stoploss, we add a stoploss on exchange - if not stoploss_order: - stoploss = ( - self.edge.stoploss(pair=trade.pair) - if self.edge else - self.strategy.stoploss / trade.leverage - ) - if trade.is_short: - stop_price = trade.open_rate * (1 - stoploss) - else: - stop_price = trade.open_rate * (1 + stoploss) - - if self.create_stoploss_order(trade=trade, stop_price=stop_price): - # The above will return False if the placement failed and the trade was force-sold. - # in which case the trade will be closed - which we must check below. - trade.stoploss_last_update = datetime.utcnow() - return False - - # If stoploss order is canceled for some reason we add it - if (trade.is_open - and stoploss_order - and stoploss_order['status'] in ('canceled', 'cancelled')): - if self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss): - return False - else: - trade.stoploss_order_id = None - logger.warning('Stoploss order was cancelled, but unable to recreate one.') - - # Finally we check if stoploss on exchange should be moved up because of trailing. - # Triggered Orders are now real orders - so don't replace stoploss anymore - if ( - trade.is_open and stoploss_order - and stoploss_order.get('status_stop') != 'triggered' - and (self.config.get('trailing_stop', False) - or self.config.get('use_custom_stoploss', False)) - ): - # if trailing stoploss is enabled we check if stoploss value has changed - # in which case we cancel stoploss order and put another one with new - # value immediately - self.handle_trailing_stoploss_on_exchange(trade, stoploss_order) - - return False - - def handle_trailing_stoploss_on_exchange(self, trade: Trade, order: Dict) -> None: - """ - Check to see if stoploss on exchange should be updated - in case of trailing stoploss on exchange - :param trade: Corresponding Trade - :param order: Current on exchange stoploss order - :return: None - """ - stoploss_norm = self.exchange.price_to_precision(trade.pair, trade.stop_loss) - - if self.exchange.stoploss_adjust(stoploss_norm, order, side=trade.exit_side): - # we check if the update is necessary - update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60) - if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat: - # cancelling the current stoploss on exchange first - logger.info(f"Cancelling current stoploss on exchange for pair {trade.pair} " - f"(orderid:{order['id']}) in order to add another one ...") - try: - co = self.exchange.cancel_stoploss_order_with_result(order['id'], trade.pair, - trade.amount) - trade.update_order(co) - except InvalidOrderException: - logger.exception(f"Could not cancel stoploss order {order['id']} " - f"for pair {trade.pair}") - - # Create new stoploss order - if not self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss): - logger.warning(f"Could not create trailing stoploss order " - f"for pair {trade.pair}.") - - def _check_and_execute_exit(self, trade: Trade, exit_rate: float, - enter: bool, exit_: bool, exit_tag: Optional[str]) -> bool: - """ - Check and execute trade exit - """ - should_exit: ExitCheckTuple = self.strategy.should_exit( - trade, - exit_rate, - datetime.now(timezone.utc), - enter=enter, - exit_=exit_, - force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0 - ) - - if should_exit.exit_flag: - logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}' - f'Tag: {exit_tag if exit_tag is not None else "None"}') - self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag) - return True - return False - - def check_handle_timedout(self) -> None: - """ - Check if any orders are timed out and cancel if necessary - :param timeoutvalue: Number of minutes until order is considered timed out - :return: None - """ - - for trade in Trade.get_open_order_trades(): - try: - if not trade.open_order_id: - continue - order = self.exchange.fetch_order(trade.open_order_id, trade.pair) - except (ExchangeError): - logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) - continue - - fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order) - is_entering = order['side'] == trade.enter_side - not_closed = order['status'] == 'open' or fully_cancelled - max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) - - order_obj = trade.select_order_by_order_id(trade.open_order_id) - - if not_closed and (fully_cancelled or (order_obj and self.strategy.ft_check_timed_out( - trade, order_obj, datetime.now(timezone.utc))) - ): - if is_entering: - self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) - else: - canceled = self.handle_cancel_exit( - trade, order, constants.CANCEL_REASON['TIMEOUT']) - canceled_count = trade.get_exit_order_count() - max_timeouts = self.config.get( - 'unfilledtimeout', {}).get('exit_timeout_count', 0) - if canceled and max_timeouts > 0 and canceled_count >= max_timeouts: - logger.warning(f'Emergency exiting trade {trade}, as the exit order ' - f'timed out {max_timeouts} times.') - try: - self.execute_trade_exit( - trade, order.get('price'), - exit_check=ExitCheckTuple(exit_type=ExitType.EMERGENCY_SELL)) - except DependencyException as exception: - logger.warning( - f'Unable to emergency sell trade {trade.pair}: {exception}') - - def cancel_all_open_orders(self) -> None: - """ - Cancel all orders that are currently open - :return: None - """ - - for trade in Trade.get_open_order_trades(): - try: - order = self.exchange.fetch_order(trade.open_order_id, trade.pair) - except (ExchangeError): - logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) - continue - - if order['side'] == trade.enter_side: - self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) - - elif order['side'] == trade.exit_side: - self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) - Trade.commit() - - def handle_cancel_enter(self, trade: Trade, order: Dict, reason: str) -> bool: - """ - Buy cancel - cancel order - :return: True if order was fully cancelled - """ - was_trade_fully_canceled = False - - # Cancelled orders may have the status of 'canceled' or 'closed' - if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: - filled_val: float = order.get('filled', 0.0) or 0.0 - filled_stake = filled_val * trade.open_rate - minstake = self.exchange.get_min_pair_stake_amount( - trade.pair, trade.open_rate, self.strategy.stoploss) - - if filled_val > 0 and minstake and filled_stake < minstake: - logger.warning( - f"Order {trade.open_order_id} for {trade.pair} not cancelled, " - f"as the filled amount of {filled_val} would result in an unexitable trade.") - return False - corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, - trade.amount) - # Avoid race condition where the order could not be cancelled coz its already filled. - # Simply bailing here is the only safe way - as this order will then be - # handled in the next iteration. - if corder.get('status') not in constants.NON_OPEN_EXCHANGE_STATES: - logger.warning(f"Order {trade.open_order_id} for {trade.pair} not cancelled.") - return False - else: - # Order was cancelled already, so we can reuse the existing dict - corder = order - reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - - side = trade.enter_side.capitalize() - logger.info('%s order %s for %s.', side, reason, trade) - - # Using filled to determine the filled amount - filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') - if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): - logger.info(f'{side} order fully cancelled. Removing {trade} from database.') - # if trade is not partially completed and it's the only order, just delete the trade - if len(trade.orders) <= 1: - trade.delete() - was_trade_fully_canceled = True - reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" - else: - # FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below. - self.update_trade_state(trade, trade.open_order_id, corder) - trade.open_order_id = None - logger.info(f'Partial {side} order timeout for {trade}.') - else: - # if trade is partially complete, edit the stake details for the trade - # and close the order - # cancel_order may not contain the full order dict, so we need to fallback - # to the order dict acquired before cancelling. - # we need to fall back to the values from order if corder does not contain these keys. - trade.amount = filled_amount - # * Check edge cases, we don't want to make leverage > 1.0 if we don't have to - # * (for leverage modes which aren't isolated futures) - - trade.stake_amount = trade.amount * trade.open_rate / trade.leverage - self.update_trade_state(trade, trade.open_order_id, corder) - - trade.open_order_id = None - logger.info(f'Partial {trade.enter_side} order timeout for {trade}.') - reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" - - self.wallets.update() - self._notify_enter_cancel(trade, order_type=self.strategy.order_types['entry'], - reason=reason) - return was_trade_fully_canceled - - def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> bool: - """ - exit order cancel - cancel order and update trade - :return: True if exit order was cancelled, false otherwise - """ - cancelled = False - # if trade is not partially completed, just cancel the order - if order['remaining'] == order['amount'] or order.get('filled') == 0.0: - if not self.exchange.check_order_canceled_empty(order): - try: - # if trade is not partially completed, just delete the order - co = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, - trade.amount) - trade.update_order(co) - except InvalidOrderException: - logger.exception( - f"Could not cancel {trade.exit_side} order {trade.open_order_id}") - return False - logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) - else: - reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) - trade.update_order(order) - - trade.close_rate = None - trade.close_rate_requested = None - trade.close_profit = None - trade.close_profit_abs = None - trade.close_date = None - trade.is_open = True - trade.open_order_id = None - trade.exit_reason = None - cancelled = True - self.wallets.update() - else: - # TODO: figure out how to handle partially complete sell orders - reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] - cancelled = False - - order_obj = Order.parse_from_ccxt_object(order, trade.pair, 'sell') - sub_trade = order_obj.amount != trade.amount - self._notify_exit_cancel( - trade, -<<<<<<< HEAD - order_type=self.strategy.order_types['sell'], - reason=reason, order=order_obj, sub_trade=sub_trade -======= - order_type=self.strategy.order_types['exit'], - reason=reason ->>>>>>> develop - ) - return cancelled - - def _safe_exit_amount(self, pair: str, amount: float) -> float: - """ - Get sellable amount. - Should be trade.amount - but will fall back to the available amount if necessary. - This should cover cases where get_real_amount() was not able to update the amount - for whatever reason. - :param pair: Pair we're trying to sell - :param amount: amount we expect to be available - :return: amount to sell - :raise: DependencyException: if available balance is not within 2% of the available amount. - """ - # Update wallets to ensure amounts tied up in a stoploss is now free! - self.wallets.update() - if self.trading_mode == TradingMode.FUTURES: - return amount - - trade_base_currency = self.exchange.get_pair_base_currency(pair) - wallet_amount = self.wallets.get_free(trade_base_currency) - logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}") - if wallet_amount >= amount: - # A safe exit amount isn't needed for futures, you can just exit/close the position - return amount - elif wallet_amount > amount * 0.98: - logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.") - return wallet_amount - else: - raise DependencyException( - f"Not enough amount to exit trade. Trade-amount: {amount}, Wallet: {wallet_amount}") - - def execute_trade_exit( - self, - trade: Trade, - limit: float, - exit_check: ExitCheckTuple, - *, - exit_tag: Optional[str] = None, - ordertype: Optional[str] = None, -<<<<<<< HEAD - sub_trade_amt: float = None, -======= ->>>>>>> develop - ) -> bool: - """ - Executes a trade exit for the given trade and limit - :param trade: Trade instance - :param limit: limit rate for the sell order - :param exit_check: CheckTuple with signal and reason - :return: True if it succeeds (supported) False (not supported) - """ - trade.funding_fees = self.exchange.get_funding_fees( - pair=trade.pair, - amount=trade.amount, - is_short=trade.is_short, - open_date=trade.open_date_utc, - ) - exit_type = 'exit' - if exit_check.exit_type in (ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS): - exit_type = 'stoploss' - - # if stoploss is on exchange and we are on dry_run mode, - # we consider the sell price stop price - if (self.config['dry_run'] and exit_type == 'stoploss' - and self.strategy.order_types['stoploss_on_exchange']): - limit = trade.stop_loss - - # set custom_exit_price if available - proposed_limit_rate = limit - current_profit = trade.calc_profit_ratio(limit) - custom_exit_price = strategy_safe_wrapper(self.strategy.custom_exit_price, - default_retval=proposed_limit_rate)( - pair=trade.pair, trade=trade, - current_time=datetime.now(timezone.utc), - proposed_rate=proposed_limit_rate, current_profit=current_profit) - - limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) - - # First cancelling stoploss on exchange ... - trade = self.cancel_stoploss_on_exchange(trade) - - order_type = ordertype or self.strategy.order_types[exit_type] - if exit_check.exit_type == ExitType.EMERGENCY_SELL: - # Emergency sells (default to market!) - order_type = self.strategy.order_types.get("emergencyexit", "market") - -<<<<<<< HEAD - amount = self._safe_exit_amount(trade.pair, sub_trade_amt or trade.amount) - time_in_force = self.strategy.order_time_in_force['sell'] -======= - amount = self._safe_exit_amount(trade.pair, trade.amount) - time_in_force = self.strategy.order_time_in_force['exit'] ->>>>>>> develop - - if not sub_trade_amt and not strategy_safe_wrapper( - self.strategy.confirm_trade_exit, default_retval=True)( - pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit, - time_in_force=time_in_force, exit_reason=exit_check.exit_reason, - sell_reason=exit_check.exit_reason, # sellreason -> compatibility - current_time=datetime.now(timezone.utc)): - logger.info(f"User requested abortion of exiting {trade.pair}") - return False - - try: - # Execute sell and update trade record - order = self.exchange.create_order( - pair=trade.pair, - ordertype=order_type, - side=trade.exit_side, - amount=amount, - rate=limit, - leverage=trade.leverage, - reduceOnly=self.trading_mode == TradingMode.FUTURES, - time_in_force=time_in_force - ) - except InsufficientFundsError as e: - logger.warning(f"Unable to place order {e}.") - # Try to figure out what went wrong - self.handle_insufficient_funds(trade) - return False - - order_obj = Order.parse_from_ccxt_object(order, trade.pair, trade.exit_side) - trade.orders.append(order_obj) - - trade.open_order_id = order['id'] - trade.exit_order_status = '' - trade.close_rate_requested = limit - trade.exit_reason = exit_tag or exit_check.exit_reason - - # Lock pair for one candle to prevent immediate re-trading - self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), - reason='Auto lock') - - self._notify_exit(trade, order_type, sub_trade=bool(sub_trade_amt), order=order_obj) - # In case of market sell orders the order can be closed immediately - if order.get('status', 'unknown') in ('closed', 'expired'): - self.update_trade_state(trade, trade.open_order_id, order) - Trade.commit() - - return True - - def _notify_exit(self, trade: Trade, order_type: str, fill: bool = False, - sub_trade: bool = False, order: Order = None) -> None: - """ - Sends rpc notification when a sell occurred. - """ - # Use cached rates here - it was updated seconds ago. - current_rate = self.exchange.get_rate( -<<<<<<< HEAD - trade.pair, refresh=False, side="sell") if not fill else None - - # second condition is for mypy only; order will always be passed during sub trade - if sub_trade and order is not None: - amount = order.safe_filled - profit_rate = order.safe_price - - if not fill: - trade.process_sell_sub_trade(order, is_closed=False) - - profit_ratio = trade.close_profit - profit = trade.close_profit_abs - else: - profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested - profit = trade.calc_profit(rate=profit_rate) - profit_ratio = trade.calc_profit_ratio(profit_rate) - amount = trade.amount -======= - trade.pair, side='exit', is_short=trade.is_short, refresh=False) if not fill else None - profit_ratio = trade.calc_profit_ratio(profit_rate) ->>>>>>> develop - gain = "profit" if profit_ratio > 0 else "loss" - - msg = { - 'type': (RPCMessageType.SELL_FILL if fill - else RPCMessageType.SELL), - 'trade_id': trade.id, - 'exchange': trade.exchange.capitalize(), - 'pair': trade.pair, - 'leverage': trade.leverage, - 'direction': 'Short' if trade.is_short else 'Long', - 'gain': gain, - 'limit': profit_rate, - 'order_type': order_type, - 'amount': amount, - 'open_rate': trade.open_rate, - 'close_rate': profit_rate, - 'current_rate': current_rate, - 'profit_amount': profit, - 'profit_ratio': profit_ratio, - 'buy_tag': trade.enter_tag, - 'enter_tag': trade.enter_tag, - 'sell_reason': trade.exit_reason, # Deprecated - 'exit_reason': trade.exit_reason, - 'open_date': trade.open_date, - 'close_date': trade.close_date or datetime.utcnow(), - 'stake_amount': trade.stake_amount, - 'stake_currency': self.config['stake_currency'], - 'fiat_currency': self.config.get('fiat_display_currency', None), - 'sub_trade': sub_trade, - } - if sub_trade: - msg['cumulative_profit'] = trade.realized_profit - - # Send the message - self.rpc.send_msg(msg) - - def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str, - order: Order, sub_trade: bool = False) -> None: - """ - Sends rpc notification when a sell cancel occurred. - """ - if trade.exit_order_status == reason: - return - else: - trade.exit_order_status = reason - - profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested - profit_trade = trade.calc_profit(rate=profit_rate) - current_rate = self.exchange.get_rate( - trade.pair, side='exit', is_short=trade.is_short, refresh=False) - profit_ratio = trade.calc_profit_ratio(profit_rate) - gain = "profit" if profit_ratio > 0 else "loss" - - msg = { - 'type': RPCMessageType.SELL_CANCEL, - 'trade_id': trade.id, - 'exchange': trade.exchange.capitalize(), - 'pair': trade.pair, - 'leverage': trade.leverage, - 'direction': 'Short' if trade.is_short else 'Long', - 'gain': gain, - 'limit': profit_rate or 0, - 'order_type': order_type, - 'amount': order.safe_amount_after_fee, - 'open_rate': trade.open_rate, - 'current_rate': current_rate, - 'profit_amount': profit_trade, - 'profit_ratio': profit_ratio, - 'buy_tag': trade.enter_tag, - 'enter_tag': trade.enter_tag, - 'sell_reason': trade.exit_reason, # Deprecated - 'exit_reason': trade.exit_reason, - 'open_date': trade.open_date, - 'close_date': trade.close_date or datetime.now(timezone.utc), - 'stake_currency': self.config['stake_currency'], - 'fiat_currency': self.config.get('fiat_display_currency', None), - 'reason': reason, - 'sub_trade': sub_trade, - 'stake_amount': trade.stake_amount, - } - - if 'fiat_display_currency' in self.config: - msg.update({ - 'fiat_currency': self.config['fiat_display_currency'], - }) - - # Send the message - self.rpc.send_msg(msg) - -# -# Common update trade state methods -# - - def update_trade_state(self, trade: Trade, order_id: str, action_order: Dict[str, Any] = None, - stoploss_order: bool = False, send_msg: bool = True) -> bool: - """ - Checks trades with open orders and updates the amount if necessary - Handles closing both buy and sell orders. - :param trade: Trade object of the trade we're analyzing - :param order_id: Order-id of the order we're analyzing - :param action_order: Already acquired order object - :param send_msg: Send notification - should always be True except in "recovery" methods - :return: True if order has been cancelled without being filled partially, False otherwise - """ - if not order_id: - logger.warning(f'Orderid for trade {trade} is empty.') - return False - - # Update trade with order values - logger.info(f'Found open order for {trade}') - try: - order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id, - trade.pair, - stoploss_order) - except InvalidOrderException as exception: - logger.warning('Unable to fetch order %s: %s', order_id, exception) - return False - - trade.update_order(order) - - if self.exchange.check_order_canceled_empty(order): - # Trade has been cancelled on exchange - # Handling of this will happen in check_handle_timedout. - return True - - order_obj = trade.select_order_by_order_id(order_id) - if not order_obj: - raise DependencyException( - f"Order_obj not found for {order_id}. This should not have happened.") - - self.handle_order_fee(trade, order_obj, order) - - trade.update_trade(order_obj) - Trade.commit() - -<<<<<<< HEAD - if order.get('status') in constants.NON_OPEN_EXCHANGE_STATES: - # If a buy order was closed, force update on stoploss on exchange - if order.get('side', None) == 'buy': -======= - if order['status'] in constants.NON_OPEN_EXCHANGE_STATES: - # If a entry order was closed, force update on stoploss on exchange - if order.get('side', None) == trade.enter_side: ->>>>>>> develop - trade = self.cancel_stoploss_on_exchange(trade) - # TODO: Margin will need to use interest_rate as well. - # interest_rate = self.exchange.get_interest_rate() - trade.set_isolated_liq(self.exchange.get_liquidation_price( - - leverage=trade.leverage, - pair=trade.pair, - amount=trade.amount, - open_rate=trade.open_rate, - is_short=trade.is_short - )) - if not self.edge: - # TODO: should shorting/leverage be supported by Edge, - # then this will need to be fixed. - trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) - - # Updating wallets when order is closed - self.wallets.update() - - if not trade.is_open: - self.handle_protections(trade.pair) - sub_trade = order_obj.safe_amount_after_fee != trade.amount - if order.get('side', None) == 'sell': - if send_msg and not stoploss_order and not trade.open_order_id: - self._notify_exit(trade, '', True, sub_trade=sub_trade, order=order_obj) - elif send_msg and not trade.open_order_id: -<<<<<<< HEAD - # Buy fill - self._notify_enter(trade, order_obj, fill=True, sub_trade=sub_trade) -======= - # Enter fill - self._notify_enter(trade, order, fill=True) ->>>>>>> develop - - return False - - def handle_protections(self, pair: str) -> None: - prot_trig = self.protections.stop_per_pair(pair) - if prot_trig: - msg = {'type': RPCMessageType.PROTECTION_TRIGGER, } - msg.update(prot_trig.to_json()) - self.rpc.send_msg(msg) - - prot_trig_glb = self.protections.global_stop() - if prot_trig_glb: - msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, } - msg.update(prot_trig_glb.to_json()) - self.rpc.send_msg(msg) - - def apply_fee_conditional(self, trade: Trade, trade_base_currency: str, - amount: float, fee_abs: float) -> float: - """ - Applies the fee to amount (either from Order or from Trades). - Can eat into dust if more than the required asset is available. - Can't happen in Futures mode - where Fees are always in settlement currency, - never in base currency. - """ - self.wallets.update() - if fee_abs != 0 and self.wallets.get_free(trade_base_currency) >= amount: - # Eat into dust if we own more than base currency - logger.info(f"Fee amount for {trade} was in base currency - " - f"Eating Fee {fee_abs} into dust.") - elif fee_abs != 0: - real_amount = self.exchange.amount_to_precision(trade.pair, amount - fee_abs) - logger.info(f"Applying fee on amount for {trade} " - f"(from {amount} to {real_amount}).") - return real_amount - return amount - - def handle_order_fee(self, trade: Trade, order_obj: Order, order: Dict[str, Any]) -> None: - # Try update amount (binance-fix) - try: - new_amount = self.get_real_amount(trade, order, order_obj) - if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount, - abs_tol=constants.MATH_CLOSE_PREC): - order_obj.ft_fee_base = trade.amount - new_amount - except DependencyException as exception: - logger.warning("Could not update trade amount: %s", exception) - - def get_real_amount(self, trade: Trade, order: Dict, order_obj: Order) -> float: - """ - Detect and update trade fee. - Calls trade.update_fee() upon correct detection. - Returns modified amount if the fee was taken from the destination currency. - Necessary for exchanges which charge fees in base currency (e.g. binance) - :return: identical (or new) amount for the trade - """ - # Init variables - order_amount = safe_value_fallback(order, 'filled', 'amount') - # Only run for closed orders - if trade.fee_updated(order.get('side', '')) or order['status'] == 'open': - return order_amount - - trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) - # use fee from order-dict if possible - if self.exchange.order_has_fee(order): - fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order) - logger.info(f"Fee for Trade {trade} [{order_obj.ft_order_side}]: " - f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}") - if fee_rate is None or fee_rate < 0.02: - # Reject all fees that report as > 2%. - # These are most likely caused by a parsing bug in ccxt - # due to multiple trades (https://github.com/ccxt/ccxt/issues/8025) - trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) - if trade_base_currency == fee_currency: - # Apply fee to amount - return self.apply_fee_conditional(trade, trade_base_currency, - amount=order_amount, fee_abs=fee_cost) - return order_amount - return self.fee_detection_from_trades( - trade, order, order_obj, order_amount, order.get('trades', [])) - - def fee_detection_from_trades(self, trade: Trade, order: Dict, order_obj: Order, - order_amount: float, trades: List) -> float: - """ - fee-detection fallback to Trades. - Either uses provided trades list or the result of fetch_my_trades to get correct fee. - """ - if not trades: - trades = self.exchange.get_trades_for_order( - self.exchange.get_order_id_conditional(order), trade.pair, order_obj.order_date) - - if len(trades) == 0: - logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade) - return order_amount - fee_currency = None - amount = 0 - fee_abs = 0.0 - fee_cost = 0.0 - trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) - fee_rate_array: List[float] = [] - for exectrade in trades: - amount += exectrade['amount'] - if self.exchange.order_has_fee(exectrade): - fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(exectrade) - fee_cost += fee_cost_ - if fee_rate_ is not None: - fee_rate_array.append(fee_rate_) - # only applies if fee is in quote currency! - if trade_base_currency == fee_currency: - fee_abs += fee_cost_ - # Ensure at least one trade was found: - if fee_currency: - # fee_rate should use mean - fee_rate = sum(fee_rate_array) / float(len(fee_rate_array)) if fee_rate_array else None - if fee_rate is not None and fee_rate < 0.02: - # Only update if fee-rate is < 2% - trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) - - if not isclose(amount, order_amount, abs_tol=constants.MATH_CLOSE_PREC): - # * Leverage could be a cause for this warning - logger.warning(f"Amount {amount} does not match amount {trade.amount}") - raise DependencyException("Half bought? Amounts don't match") - - if fee_abs != 0: - return self.apply_fee_conditional(trade, trade_base_currency, - amount=amount, fee_abs=fee_abs) - else: - return amount - - def get_valid_price(self, custom_price: float, proposed_price: float) -> float: - """ - Return the valid price. - Check if the custom price is of the good type if not return proposed_price - :return: valid price for the order - """ - if custom_price: - try: - valid_custom_price = float(custom_price) - except ValueError: - valid_custom_price = proposed_price - else: - valid_custom_price = proposed_price - - cust_p_max_dist_r = self.config.get('custom_price_max_distance_ratio', 0.02) - min_custom_price_allowed = proposed_price - (proposed_price * cust_p_max_dist_r) - max_custom_price_allowed = proposed_price + (proposed_price * cust_p_max_dist_r) - - # Bracket between min_custom_price_allowed and max_custom_price_allowed - return max( - min(valid_custom_price, max_custom_price_allowed), - min_custom_price_allowed) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 9a554bd79..3d59a8edc 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -480,10 +480,11 @@ class Backtesting: stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, default_retval=None)( trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=current_rate, - current_profit=current_profit, min_stake=min_stake, max_stake=min(max_stake, stake_available), + current_profit=current_profit, min_stake=min_stake, + max_stake=min(max_stake, stake_available), current_entry_rate=current_rate, current_exit_rate=current_rate, -max_entry_stake=min(max_stake, stake_available), -max_exit_stake=min(max_stake, stake_available)) + max_entry_stake=min(max_stake, stake_available), + max_exit_stake=min(max_stake, stake_available)) # Check if we should increase our position if stake_amount is not None and stake_amount > 0.0: @@ -586,7 +587,7 @@ max_exit_stake=min(max_stake, stake_available)) close_rate: float, amount: float = None) -> Optional[LocalTrade]: self.order_id_counter += 1 sell_candle_time = sell_row[DATE_IDX].to_pydatetime() - order_type = self.strategy.order_types['sell'] + order_type = self.strategy.order_types['exit'] amount = amount or trade.amount order = Order( id=self.order_id_counter, @@ -905,7 +906,7 @@ max_exit_stake=min(max_stake, stake_available)) return None return row - def backtest(self, processed: Dict, + def backtest(self, processed: Dict, # noqa: max-complexity: 13 start_date: datetime, end_date: datetime, max_open_trades: int = 0, position_stacking: bool = False, enable_protections: bool = False) -> Dict[str, Any]: @@ -1007,7 +1008,7 @@ max_exit_stake=min(max_stake, stake_available)) sub_trade = order.safe_amount_after_fee != trade.amount if sub_trade: order.close_bt_order(current_time) - trade.process_sell_sub_trade(order) + trade.process_exit_sub_trade(order) trade.recalc_trade_from_orders() else: trade.close_date = current_time diff --git a/freqtrade/optimize/backtesting.py.orig b/freqtrade/optimize/backtesting.py.orig deleted file mode 100644 index 6236a45c8..000000000 --- a/freqtrade/optimize/backtesting.py.orig +++ /dev/null @@ -1,1215 +0,0 @@ -# pragma pylint: disable=missing-docstring, W0212, too-many-arguments - -""" -This module contains the backtesting logic -""" -import logging -from collections import defaultdict -from copy import deepcopy -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple - -from numpy import nan -from pandas import DataFrame - -from freqtrade import constants -from freqtrade.configuration import TimeRange, validate_config_consistency -from freqtrade.constants import DATETIME_PRINT_FORMAT -from freqtrade.data import history -from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe -from freqtrade.data.converter import trim_dataframe, trim_dataframes -from freqtrade.data.dataprovider import DataProvider -from freqtrade.enums import BacktestState, CandleType, ExitCheckTuple, ExitType, TradingMode -from freqtrade.exceptions import DependencyException, OperationalException -from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds -from freqtrade.misc import get_strategy_run_id -from freqtrade.mixins import LoggingMixin -from freqtrade.optimize.bt_progress import BTProgress -from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results, - store_backtest_stats) -from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade -from freqtrade.plugins.pairlistmanager import PairListManager -from freqtrade.plugins.protectionmanager import ProtectionManager -from freqtrade.resolvers import ExchangeResolver, StrategyResolver -from freqtrade.strategy.interface import IStrategy -from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper -from freqtrade.wallets import Wallets - - -logger = logging.getLogger(__name__) - -# Indexes for backtest tuples -DATE_IDX = 0 -OPEN_IDX = 1 -HIGH_IDX = 2 -LOW_IDX = 3 -CLOSE_IDX = 4 -LONG_IDX = 5 -ELONG_IDX = 6 # Exit long -SHORT_IDX = 7 -ESHORT_IDX = 8 # Exit short -ENTER_TAG_IDX = 9 -EXIT_TAG_IDX = 10 - - -class Backtesting: - """ - Backtesting class, this class contains all the logic to run a backtest - - To run a backtest: - backtesting = Backtesting(config) - backtesting.start() - """ - - def __init__(self, config: Dict[str, Any]) -> None: - - LoggingMixin.show_output = False - self.config = config - self.results: Dict[str, Any] = {} - self.trade_id_counter: int = 0 - self.order_id_counter: int = 0 - - config['dry_run'] = True - self.run_ids: Dict[str, str] = {} - self.strategylist: List[IStrategy] = [] - self.all_results: Dict[str, Dict] = {} - self._exchange_name = self.config['exchange']['name'] - self.exchange = ExchangeResolver.load_exchange(self._exchange_name, self.config) - self.dataprovider = DataProvider(self.config, self.exchange) - - if self.config.get('strategy_list', None): - for strat in list(self.config['strategy_list']): - stratconf = deepcopy(self.config) - stratconf['strategy'] = strat - self.strategylist.append(StrategyResolver.load_strategy(stratconf)) - validate_config_consistency(stratconf) - - else: - # No strategy list specified, only one strategy - self.strategylist.append(StrategyResolver.load_strategy(self.config)) - validate_config_consistency(self.config) - - if "timeframe" not in self.config: - raise OperationalException("Timeframe needs to be set in either " - "configuration or as cli argument `--timeframe 5m`") - self.timeframe = str(self.config.get('timeframe')) - self.timeframe_min = timeframe_to_minutes(self.timeframe) - self.init_backtest_detail() - self.pairlists = PairListManager(self.exchange, self.config) - if 'VolumePairList' in self.pairlists.name_list: - raise OperationalException("VolumePairList not allowed for backtesting. " - "Please use StaticPairlist instead.") - if 'PerformanceFilter' in self.pairlists.name_list: - raise OperationalException("PerformanceFilter not allowed for backtesting.") - - if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list: - raise OperationalException( - "PrecisionFilter not allowed for backtesting multiple strategies." - ) - - self.dataprovider.add_pairlisthandler(self.pairlists) - self.pairlists.refresh_pairlist() - - if len(self.pairlists.whitelist) == 0: - raise OperationalException("No pair in whitelist.") - - if config.get('fee', None) is not None: - self.fee = config['fee'] - else: - self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0]) - - self.timerange = TimeRange.parse_timerange( - None if self.config.get('timerange') is None else str(self.config.get('timerange'))) - - # Get maximum required startup period - self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) - # Add maximum startup candle count to configuration for informative pairs support - self.config['startup_candle_count'] = self.required_startup - self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) - - self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) - # strategies which define "can_short=True" will fail to load in Spot mode. - self._can_short = self.trading_mode != TradingMode.SPOT - - self.init_backtest() - - def __del__(self): - self.cleanup() - - @staticmethod - def cleanup(): - LoggingMixin.show_output = True - PairLocks.use_db = True - Trade.use_db = True - - def init_backtest_detail(self): - # Load detail timeframe if specified - self.timeframe_detail = str(self.config.get('timeframe_detail', '')) - if self.timeframe_detail: - self.timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail) - if self.timeframe_min <= self.timeframe_detail_min: - raise OperationalException( - "Detail timeframe must be smaller than strategy timeframe.") - - else: - self.timeframe_detail_min = 0 - self.detail_data: Dict[str, DataFrame] = {} - self.futures_data: Dict[str, DataFrame] = {} - - def init_backtest(self): - - self.prepare_backtest(False) - - self.wallets = Wallets(self.config, self.exchange, log=False) - - self.progress = BTProgress() - self.abort = False - - def _set_strategy(self, strategy: IStrategy): - """ - Load strategy into backtesting - """ - self.strategy: IStrategy = strategy - strategy.dp = self.dataprovider - # Attach Wallets to Strategy baseclass - strategy.wallets = self.wallets - # Set stoploss_on_exchange to false for backtesting, - # since a "perfect" stoploss-sell is assumed anyway - # And the regular "stoploss" function would not apply to that case - self.strategy.order_types['stoploss_on_exchange'] = False - - def _load_protections(self, strategy: IStrategy): - if self.config.get('enable_protections', False): - conf = self.config - if hasattr(strategy, 'protections'): - conf = deepcopy(conf) - conf['protections'] = strategy.protections - self.protections = ProtectionManager(self.config, strategy.protections) - - def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]: - """ - Loads backtest data and returns the data combined with the timerange - as tuple. - """ - self.progress.init_step(BacktestState.DATALOAD, 1) - - data = history.load_data( - datadir=self.config['datadir'], - pairs=self.pairlists.whitelist, - timeframe=self.timeframe, - timerange=self.timerange, - startup_candles=self.required_startup, - fail_without_data=True, - data_format=self.config.get('dataformat_ohlcv', 'json'), - candle_type=self.config.get('candle_type_def', CandleType.SPOT) - ) - - min_date, max_date = history.get_timerange(data) - - logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' - f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' - f'({(max_date - min_date).days} days).') - - # Adjust startts forward if not enough data is available - self.timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe), - self.required_startup, min_date) - - self.progress.set_new_value(1) - return data, self.timerange - - def load_bt_data_detail(self) -> None: - """ - Loads backtest detail data (smaller timeframe) if necessary. - """ - if self.timeframe_detail: - self.detail_data = history.load_data( - datadir=self.config['datadir'], - pairs=self.pairlists.whitelist, - timeframe=self.timeframe_detail, - timerange=self.timerange, - startup_candles=0, - fail_without_data=True, - data_format=self.config.get('dataformat_ohlcv', 'json'), - candle_type=self.config.get('candle_type_def', CandleType.SPOT) - ) - else: - self.detail_data = {} - if self.trading_mode == TradingMode.FUTURES: - # Load additional futures data. - funding_rates_dict = history.load_data( - datadir=self.config['datadir'], - pairs=self.pairlists.whitelist, - timeframe=self.exchange._ft_has['mark_ohlcv_timeframe'], - timerange=self.timerange, - startup_candles=0, - fail_without_data=True, - data_format=self.config.get('dataformat_ohlcv', 'json'), - candle_type=CandleType.FUNDING_RATE - ) - - # For simplicity, assign to CandleType.Mark (might contian index candles!) - mark_rates_dict = history.load_data( - datadir=self.config['datadir'], - pairs=self.pairlists.whitelist, - timeframe=self.exchange._ft_has['mark_ohlcv_timeframe'], - timerange=self.timerange, - startup_candles=0, - fail_without_data=True, - data_format=self.config.get('dataformat_ohlcv', 'json'), - candle_type=CandleType.from_string(self.exchange._ft_has["mark_ohlcv_price"]) - ) - # Combine data to avoid combining the data per trade. - for pair in self.pairlists.whitelist: - self.futures_data[pair] = funding_rates_dict[pair].merge( - mark_rates_dict[pair], on='date', how="inner", suffixes=["_fund", "_mark"]) - - else: - self.futures_data = {} - - def prepare_backtest(self, enable_protections): - """ - Backtesting setup method - called once for every call to "backtest()". - """ - PairLocks.use_db = False - PairLocks.timeframe = self.config['timeframe'] - Trade.use_db = False - PairLocks.reset_locks() - Trade.reset_trades() - self.rejected_trades = 0 - self.timedout_entry_orders = 0 - self.timedout_exit_orders = 0 - self.dataprovider.clear_cache() - if enable_protections: - self._load_protections(self.strategy) - - def check_abort(self): - """ - Check if abort was requested, raise DependencyException if that's the case - Only applies to Interactive backtest mode (webserver mode) - """ - if self.abort: - self.abort = False - raise DependencyException("Stop requested") - - def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]: - """ - Helper function to convert a processed dataframes into lists for performance reasons. - - Used by backtest() - so keep this optimized for performance. - - :param processed: a processed dictionary with format {pair, data}, which gets cleared to - optimize memory usage! - """ - # Every change to this headers list must evaluate further usages of the resulting tuple - # and eventually change the constants for indexes at the top - headers = ['date', 'open', 'high', 'low', 'close', 'enter_long', 'exit_long', - 'enter_short', 'exit_short', 'enter_tag', 'exit_tag'] - data: Dict = {} - self.progress.init_step(BacktestState.CONVERT, len(processed)) - - # Create dict with data - for pair in processed.keys(): - pair_data = processed[pair] - self.check_abort() - self.progress.increment() - - if not pair_data.empty: - # Cleanup from prior runs - pair_data.drop(headers[5:] + ['buy', 'sell'], axis=1, errors='ignore') - - df_analyzed = self.strategy.advise_exit( - self.strategy.advise_entry(pair_data, {'pair': pair}), - {'pair': pair} - ).copy() - # Trim startup period from analyzed dataframe - df_analyzed = processed[pair] = pair_data = trim_dataframe( - df_analyzed, self.timerange, startup_candles=self.required_startup) - # Update dataprovider cache - self.dataprovider._set_cached_df( - pair, self.timeframe, df_analyzed, self.config['candle_type_def']) - - # Create a copy of the dataframe before shifting, that way the buy signal/tag - # remains on the correct candle for callbacks. - df_analyzed = df_analyzed.copy() - - # To avoid using data from future, we use buy/sell signals shifted - # from the previous candle - for col in headers[5:]: - tag_col = col in ('enter_tag', 'exit_tag') - if col in df_analyzed.columns: - df_analyzed.loc[:, col] = df_analyzed.loc[:, col].replace( - [nan], [0 if not tag_col else None]).shift(1) - elif not df_analyzed.empty: - df_analyzed.loc[:, col] = 0 if not tag_col else None - - df_analyzed = df_analyzed.drop(df_analyzed.head(1).index) - - # Convert from Pandas to list for performance reasons - # (Looping Pandas is slow.) - data[pair] = df_analyzed[headers].values.tolist() if not df_analyzed.empty else [] - return data - - def _get_close_rate(self, sell_row: Tuple, trade: LocalTrade, sell: ExitCheckTuple, - trade_dur: int) -> float: - """ - Get close rate for backtesting result - """ - # Special handling if high or low hit STOP_LOSS or ROI - if sell.exit_type in (ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS): - return self._get_close_rate_for_stoploss(sell_row, trade, sell, trade_dur) - elif sell.exit_type == (ExitType.ROI): - return self._get_close_rate_for_roi(sell_row, trade, sell, trade_dur) - else: - return sell_row[OPEN_IDX] - - def _get_close_rate_for_stoploss(self, sell_row: Tuple, trade: LocalTrade, sell: ExitCheckTuple, - trade_dur: int) -> float: - # our stoploss was already lower than candle high, - # possibly due to a cancelled trade exit. - # sell at open price. - is_short = trade.is_short or False - leverage = trade.leverage or 1.0 - side_1 = -1 if is_short else 1 - if is_short: - if trade.stop_loss < sell_row[LOW_IDX]: - return sell_row[OPEN_IDX] - else: - if trade.stop_loss > sell_row[HIGH_IDX]: - return sell_row[OPEN_IDX] - - # Special case: trailing triggers within same candle as trade opened. Assume most - # pessimistic price movement, which is moving just enough to arm stoploss and - # immediately going down to stop price. - if sell.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0: - if ( - not self.strategy.use_custom_stoploss and self.strategy.trailing_stop - and self.strategy.trailing_only_offset_is_reached - and self.strategy.trailing_stop_positive_offset is not None - and self.strategy.trailing_stop_positive - ): - # Worst case: price reaches stop_positive_offset and dives down. - stop_rate = (sell_row[OPEN_IDX] * - (1 + side_1 * abs(self.strategy.trailing_stop_positive_offset) - - side_1 * abs(self.strategy.trailing_stop_positive / leverage))) - else: - # Worst case: price ticks tiny bit above open and dives down. - stop_rate = sell_row[OPEN_IDX] * (1 - - side_1 * abs(trade.stop_loss_pct / leverage)) - if is_short: - assert stop_rate > sell_row[LOW_IDX] - else: - assert stop_rate < sell_row[HIGH_IDX] - - # Limit lower-end to candle low to avoid sells below the low. - # This still remains "worst case" - but "worst realistic case". - if is_short: - return min(sell_row[HIGH_IDX], stop_rate) - else: - return max(sell_row[LOW_IDX], stop_rate) - - # Set close_rate to stoploss - return trade.stop_loss - - def _get_close_rate_for_roi(self, sell_row: Tuple, trade: LocalTrade, sell: ExitCheckTuple, - trade_dur: int) -> float: - is_short = trade.is_short or False - leverage = trade.leverage or 1.0 - side_1 = -1 if is_short else 1 - roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur) - if roi is not None and roi_entry is not None: - if roi == -1 and roi_entry % self.timeframe_min == 0: - # When forceselling with ROI=-1, the roi time will always be equal to trade_dur. - # If that entry is a multiple of the timeframe (so on candle open) - # - we'll use open instead of close - return sell_row[OPEN_IDX] - - # - (Expected abs profit - open_rate - open_fee) / (fee_close -1) - roi_rate = trade.open_rate * roi / leverage - open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open) - close_rate = -(roi_rate + open_fee_rate) / (trade.fee_close - side_1 * 1) - if is_short: - is_new_roi = sell_row[OPEN_IDX] < close_rate - else: - is_new_roi = sell_row[OPEN_IDX] > close_rate - if (trade_dur > 0 and trade_dur == roi_entry - and roi_entry % self.timeframe_min == 0 - and is_new_roi): - # new ROI entry came into effect. - # use Open rate if open_rate > calculated sell rate - return sell_row[OPEN_IDX] - - if (trade_dur == 0 and ( - ( - is_short - # Red candle (for longs) - and sell_row[OPEN_IDX] < sell_row[CLOSE_IDX] # Red candle - and trade.open_rate > sell_row[OPEN_IDX] # trade-open above open_rate - and close_rate < sell_row[CLOSE_IDX] # closes below close - ) - or - ( - not is_short - # green candle (for shorts) - and sell_row[OPEN_IDX] > sell_row[CLOSE_IDX] # green candle - and trade.open_rate < sell_row[OPEN_IDX] # trade-open below open_rate - and close_rate > sell_row[CLOSE_IDX] # closes above close - ) - )): - # ROI on opening candles with custom pricing can only - # trigger if the entry was at Open or lower wick. - # details: https: // github.com/freqtrade/freqtrade/issues/6261 - # If open_rate is < open, only allow sells below the close on red candles. - raise ValueError("Opening candle ROI on red candles.") - - # Use the maximum between close_rate and low as we - # cannot sell outside of a candle. - # Applies when a new ROI setting comes in place and the whole candle is above that. - return min(max(close_rate, sell_row[LOW_IDX]), sell_row[HIGH_IDX]) - - else: - # This should not be reached... - return sell_row[OPEN_IDX] - - def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple - ) -> LocalTrade: -<<<<<<< HEAD - current_rate = row[OPEN_IDX] - - current_profit = trade.calc_profit_ratio(current_rate) - min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1) - max_stake = self.wallets.get_available_stake_amount() - stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, - default_retval=None)( - trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=current_rate, - current_profit=current_profit, min_stake=min_stake, max_stake=max_stake, - current_entry_rate=current_rate, current_exit_rate=current_rate) -======= - current_profit = trade.calc_profit_ratio(row[OPEN_IDX]) - min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, row[OPEN_IDX], -0.1) - max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, row[OPEN_IDX]) - stake_available = self.wallets.get_available_stake_amount() - stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, - default_retval=None)( - trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX], - current_profit=current_profit, min_stake=min_stake, - max_stake=min(max_stake, stake_available)) ->>>>>>> develop - - # Check if we should increase our position - if stake_amount is not None and stake_amount > 0.0: - - pos_trade = self._enter_trade( - trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade) - if pos_trade is not None: - self.wallets.update() - return pos_trade - - if stake_amount is not None and stake_amount < 0.0: - amount = abs(stake_amount) / current_rate - if amount > trade.amount: - return trade - pos_trade = self._exit_trade(trade, row, current_rate, amount) - if pos_trade is not None: - self.wallets.update() - return pos_trade - - return trade - - def _get_order_filled(self, rate: float, row: Tuple) -> bool: - """ Rate is within candle, therefore filled""" - return row[LOW_IDX] <= rate <= row[HIGH_IDX] - - def _get_sell_trade_entry_for_candle(self, trade: LocalTrade, - sell_row: Tuple) -> Optional[LocalTrade]: - - # Check if we need to adjust our current positions - if self.strategy.position_adjustment_enable: - check_adjust_entry = True - if self.strategy.max_entry_position_adjustment > -1: - entry_count = trade.nr_of_successful_entries - check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment) - if check_adjust_entry: - trade = self._get_adjust_trade_entry_for_candle(trade, sell_row) - - sell_candle_time: datetime = sell_row[DATE_IDX].to_pydatetime() - enter = sell_row[SHORT_IDX] if trade.is_short else sell_row[LONG_IDX] - exit_ = sell_row[ESHORT_IDX] if trade.is_short else sell_row[ELONG_IDX] - sell = self.strategy.should_exit( - trade, sell_row[OPEN_IDX], sell_candle_time, # type: ignore - enter=enter, exit_=exit_, - low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX] - ) - - if sell.exit_flag: - trade.close_date = sell_candle_time - - trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60) - try: - close_rate = self._get_close_rate(sell_row, trade, sell, trade_dur) - except ValueError: - return None -<<<<<<< HEAD - # call the custom exit price,with default value as previous close_rate - current_profit = trade.calc_profit_ratio(close_rate) - order_type = self.strategy.order_types['sell'] - if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL): -======= - # call the custom exit price,with default value as previous closerate - current_profit = trade.calc_profit_ratio(closerate) - order_type = self.strategy.order_types['exit'] - if sell.exit_type in (ExitType.SELL_SIGNAL, ExitType.CUSTOM_SELL): ->>>>>>> develop - # Custom exit pricing only for sell-signals - if order_type == 'limit': - close_rate = strategy_safe_wrapper(self.strategy.custom_exit_price, - default_retval=close_rate)( - pair=trade.pair, trade=trade, - current_time=sell_candle_time, - proposed_rate=close_rate, current_profit=current_profit) - # We can't place orders lower than current low. - # freqtrade does not support this in live, and the order would fill immediately -<<<<<<< HEAD - close_rate = max(close_rate, sell_row[LOW_IDX]) -======= - if trade.is_short: - closerate = min(closerate, sell_row[HIGH_IDX]) - else: - closerate = max(closerate, sell_row[LOW_IDX]) ->>>>>>> develop - # Confirm trade exit: - time_in_force = self.strategy.order_time_in_force['exit'] - - if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( - pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount, - rate=close_rate, - time_in_force=time_in_force, - sell_reason=sell.exit_reason, # deprecated - exit_reason=sell.exit_reason, - current_time=sell_candle_time): - return None - - trade.exit_reason = sell.exit_reason - - # Checks and adds an exit tag, after checking that the length of the - # sell_row has the length for an exit tag column - if( - len(sell_row) > EXIT_TAG_IDX - and sell_row[EXIT_TAG_IDX] is not None - and len(sell_row[EXIT_TAG_IDX]) > 0 - ): - trade.exit_reason = sell_row[EXIT_TAG_IDX] - -<<<<<<< HEAD - return self._exit_trade(trade, sell_row, close_rate) -======= - self.order_id_counter += 1 - order = Order( - id=self.order_id_counter, - ft_trade_id=trade.id, - order_date=sell_candle_time, - order_update_date=sell_candle_time, - ft_is_open=True, - ft_pair=trade.pair, - order_id=str(self.order_id_counter), - symbol=trade.pair, - ft_order_side=trade.exit_side, - side=trade.exit_side, - order_type=order_type, - status="open", - price=closerate, - average=closerate, - amount=trade.amount, - filled=0, - remaining=trade.amount, - cost=trade.amount * closerate, - ) - trade.orders.append(order) - return trade ->>>>>>> develop - - return None - - def _exit_trade(self, trade: LocalTrade, sell_row: Tuple, - close_rate: float, amount: float = None) -> Optional[LocalTrade]: - self.order_id_counter += 1 - sell_candle_time = sell_row[DATE_IDX].to_pydatetime() - order_type = self.strategy.order_types['sell'] - amount = amount or trade.amount - order = Order( - id=self.order_id_counter, - ft_trade_id=trade.id, - order_date=sell_candle_time, - order_update_date=sell_candle_time, - ft_is_open=True, - ft_pair=trade.pair, - order_id=str(self.order_id_counter), - symbol=trade.pair, - ft_order_side="sell", - side="sell", - order_type=order_type, - status="open", - price=close_rate, - average=close_rate, - amount=amount, - filled=0, - remaining=amount, - cost=amount * close_rate, - ) - trade.orders.append(order) - return trade - - def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]: - sell_candle_time: datetime = sell_row[DATE_IDX].to_pydatetime() - - if self.trading_mode == TradingMode.FUTURES: - trade.funding_fees = self.exchange.calculate_funding_fees( - self.futures_data[trade.pair], - amount=trade.amount, - is_short=trade.is_short, - open_date=trade.open_date_utc, - close_date=sell_candle_time, - ) - - if self.timeframe_detail and trade.pair in self.detail_data: - sell_candle_end = sell_candle_time + timedelta(minutes=self.timeframe_min) - - detail_data = self.detail_data[trade.pair] - detail_data = detail_data.loc[ - (detail_data['date'] >= sell_candle_time) & - (detail_data['date'] < sell_candle_end) - ].copy() - if len(detail_data) == 0: - # Fall back to "regular" data if no detail data was found for this candle - return self._get_sell_trade_entry_for_candle(trade, sell_row) - detail_data.loc[:, 'enter_long'] = sell_row[LONG_IDX] - detail_data.loc[:, 'exit_long'] = sell_row[ELONG_IDX] - detail_data.loc[:, 'enter_short'] = sell_row[SHORT_IDX] - detail_data.loc[:, 'exit_short'] = sell_row[ESHORT_IDX] - detail_data.loc[:, 'enter_tag'] = sell_row[ENTER_TAG_IDX] - detail_data.loc[:, 'exit_tag'] = sell_row[EXIT_TAG_IDX] - headers = ['date', 'open', 'high', 'low', 'close', 'enter_long', 'exit_long', - 'enter_short', 'exit_short', 'enter_tag', 'exit_tag'] - for det_row in detail_data[headers].values.tolist(): - res = self._get_sell_trade_entry_for_candle(trade, det_row) - if res: - return res - - return None - - else: - return self._get_sell_trade_entry_for_candle(trade, sell_row) - - def get_valid_price_and_stake( - self, pair: str, row: Tuple, propose_rate: float, stake_amount: Optional[float], - direction: str, current_time: datetime, entry_tag: Optional[str], - trade: Optional[LocalTrade], order_type: str - ) -> Tuple[float, float, float, float]: - - if order_type == 'limit': - propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, - default_retval=propose_rate)( - pair=pair, current_time=current_time, - proposed_rate=propose_rate, entry_tag=entry_tag) # default value is the open rate - # We can't place orders higher than current high (otherwise it'd be a stop limit buy) - # which freqtrade does not support in live. - if direction == "short": - propose_rate = max(propose_rate, row[LOW_IDX]) - else: - propose_rate = min(propose_rate, row[HIGH_IDX]) - - pos_adjust = trade is not None - leverage = trade.leverage if trade else 1.0 - if not pos_adjust: - try: - stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False) - except DependencyException: - return 0, 0, 0, 0 - - max_leverage = self.exchange.get_max_leverage(pair, stake_amount) - leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)( - pair=pair, - current_time=current_time, - current_rate=row[OPEN_IDX], - proposed_leverage=1.0, - max_leverage=max_leverage, - side=direction, - ) if self._can_short else 1.0 - # Cap leverage between 1.0 and max_leverage. - leverage = min(max(leverage, 1.0), max_leverage) - - min_stake_amount = self.exchange.get_min_pair_stake_amount( - pair, propose_rate, -0.05, leverage=leverage) or 0 - max_stake_amount = self.exchange.get_max_pair_stake_amount( - pair, propose_rate, leverage=leverage) - stake_available = self.wallets.get_available_stake_amount() - - if not pos_adjust: - stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, - default_retval=stake_amount)( - pair=pair, current_time=current_time, current_rate=propose_rate, - proposed_stake=stake_amount, min_stake=min_stake_amount, - max_stake=min(stake_available, max_stake_amount), - entry_tag=entry_tag, side=direction) - - stake_amount_val = self.wallets.validate_stake_amount( - pair=pair, - stake_amount=stake_amount, - min_stake_amount=min_stake_amount, - max_stake_amount=max_stake_amount, - ) - - return propose_rate, stake_amount_val, leverage, min_stake_amount - - def _enter_trade(self, pair: str, row: Tuple, direction: str, - stake_amount: Optional[float] = None, - trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]: - - current_time = row[DATE_IDX].to_pydatetime() - entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None - # let's call the custom entry price, using the open price as default price - order_type = self.strategy.order_types['entry'] - pos_adjust = trade is not None - - propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake( - pair, row, row[OPEN_IDX], stake_amount, direction, current_time, entry_tag, trade, - order_type - ) - - if not stake_amount: - # In case of pos adjust, still return the original trade - # If not pos adjust, trade is None - return trade - time_in_force = self.strategy.order_time_in_force['entry'] - - if not pos_adjust: - # Confirm trade entry: - if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( - pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate, - time_in_force=time_in_force, current_time=current_time, - entry_tag=entry_tag, side=direction): - return trade - - if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount): - self.order_id_counter += 1 - amount = round((stake_amount / propose_rate) * leverage, 8) - is_short = (direction == 'short') - # Necessary for Margin trading. Disabled until support is enabled. - # interest_rate = self.exchange.get_interest_rate() - - if trade is None: - # Enter trade - self.trade_id_counter += 1 - trade = LocalTrade( - id=self.trade_id_counter, - open_order_id=self.order_id_counter, - pair=pair, - open_rate=propose_rate, - open_rate_requested=propose_rate, - open_date=current_time, - stake_amount=stake_amount, - amount=amount, - amount_requested=amount, - fee_open=self.fee, - fee_close=self.fee, - is_open=True, - enter_tag=entry_tag, - exchange=self._exchange_name, - is_short=is_short, - trading_mode=self.trading_mode, - leverage=leverage, - # interest_rate=interest_rate, - orders=[], - ) - - trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) - - trade.set_isolated_liq(self.exchange.get_liquidation_price( - pair=pair, - open_rate=propose_rate, - amount=amount, - leverage=leverage, - is_short=is_short, - )) - - order = Order( - id=self.order_id_counter, - ft_trade_id=trade.id, - ft_is_open=True, - ft_pair=trade.pair, - order_id=str(self.order_id_counter), - symbol=trade.pair, - ft_order_side=trade.enter_side, - side=trade.enter_side, - order_type=order_type, - status="open", - order_date=current_time, - order_filled_date=current_time, - order_update_date=current_time, - price=propose_rate, - average=propose_rate, - amount=amount, - filled=0, - remaining=amount, - cost=stake_amount + trade.fee_open, - ) - if pos_adjust and self._get_order_filled(order.price, row): - order.close_bt_order(current_time) - else: - trade.open_order_id = str(self.order_id_counter) - trade.orders.append(order) - trade.recalc_trade_from_orders() - - return trade - - def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]], - data: Dict[str, List[Tuple]]) -> List[LocalTrade]: - """ - Handling of left open trades at the end of backtesting - """ - trades = [] - for pair in open_trades.keys(): - if len(open_trades[pair]) > 0: - for trade in open_trades[pair]: - if trade.open_order_id and trade.nr_of_successful_entries == 0: - # Ignore trade if buy-order did not fill yet - continue - sell_row = data[pair][-1] - - trade.close_date = sell_row[DATE_IDX].to_pydatetime() - trade.exit_reason = ExitType.FORCE_SELL.value - trade.close(sell_row[OPEN_IDX], show_msg=False) - LocalTrade.close_bt_trade(trade) - # Deepcopy object to have wallets update correctly - trade1 = deepcopy(trade) - trade1.is_open = True - trades.append(trade1) - return trades - - def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool: - # Always allow trades when max_open_trades is enabled. - if max_open_trades <= 0 or open_trade_count < max_open_trades: - return True - # Rejected trade - self.rejected_trades += 1 - return False - - def check_for_trade_entry(self, row) -> Optional[str]: - enter_long = row[LONG_IDX] == 1 - exit_long = row[ELONG_IDX] == 1 - enter_short = self._can_short and row[SHORT_IDX] == 1 - exit_short = self._can_short and row[ESHORT_IDX] == 1 - - if enter_long == 1 and not any([exit_long, enter_short]): - # Long - return 'long' - if enter_short == 1 and not any([exit_short, enter_long]): - # Short - return 'short' - return None - - def run_protections(self, enable_protections, pair: str, current_time: datetime): - if enable_protections: - self.protections.stop_per_pair(pair, current_time) - self.protections.global_stop(current_time) - - def check_order_cancel(self, trade: LocalTrade, current_time) -> bool: - """ - Check if an order has been canceled. - Returns True if the trade should be Deleted (initial order was canceled). - """ - for order in [o for o in trade.orders if o.ft_is_open]: - - timedout = self.strategy.ft_check_timed_out(trade, order, current_time) - if timedout: - if order.side == trade.enter_side: - self.timedout_entry_orders += 1 - if trade.nr_of_successful_entries == 0: - # Remove trade due to entry timeout expiration. - return True - else: - # Close additional buy order - del trade.orders[trade.orders.index(order)] - if order.side == trade.exit_side: - self.timedout_exit_orders += 1 - # Close exit order and retry exiting on next signal. - del trade.orders[trade.orders.index(order)] - - return False - - def validate_row( - self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]: - try: - # Row is treated as "current incomplete candle". - # Buy / sell signals are shifted by 1 to compensate for this. - row = data[pair][row_index] - except IndexError: - # missing Data for one pair at the end. - # Warnings for this are shown during data loading - return None - - # Waits until the time-counter reaches the start of the data for this pair. - if row[DATE_IDX] > current_time: - return None - return row - - def backtest(self, processed: Dict, - start_date: datetime, end_date: datetime, - max_open_trades: int = 0, position_stacking: bool = False, - enable_protections: bool = False) -> Dict[str, Any]: - """ - Implement backtesting functionality - - NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized. - Of course try to not have ugly code. By some accessor are sometime slower than functions. - Avoid extensive logging in this method and functions it calls. - - :param processed: a processed dictionary with format {pair, data}, which gets cleared to - optimize memory usage! - :param start_date: backtesting timerange start datetime - :param end_date: backtesting timerange end datetime - :param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited - :param position_stacking: do we allow position stacking? - :param enable_protections: Should protections be enabled? - :return: DataFrame with trades (results of backtesting) - """ - trades: List[LocalTrade] = [] - self.prepare_backtest(enable_protections) - # Ensure wallets are uptodate (important for --strategy-list) - self.wallets.update() - # Use dict of lists with data for performance - # (looping lists is a lot faster than pandas DataFrames) - data: Dict = self._get_ohlcv_as_lists(processed) - - # Indexes per pair, so some pairs are allowed to have a missing start. - indexes: Dict = defaultdict(int) - current_time = start_date + timedelta(minutes=self.timeframe_min) - - open_trades: Dict[str, List[LocalTrade]] = defaultdict(list) - open_trade_count = 0 - - self.progress.init_step(BacktestState.BACKTEST, int( - (end_date - start_date) / timedelta(minutes=self.timeframe_min))) - - # Loop timerange and get candle for each pair at that point in time - while current_time <= end_date: - open_trade_count_start = open_trade_count - self.check_abort() - for i, pair in enumerate(data): - row_index = indexes[pair] - row = self.validate_row(data, pair, row_index, current_time) - if not row: - continue - - row_index += 1 - indexes[pair] = row_index - self.dataprovider._set_dataframe_max_index(row_index) - - for t in list(open_trades[pair]): - # 1. Cancel expired buy/sell orders. - if self.check_order_cancel(t, current_time): - # Close trade due to buy timeout expiration. - open_trade_count -= 1 - open_trades[pair].remove(t) - self.wallets.update() - - # 2. Process buys. - # without positionstacking, we can only have one open trade per pair. - # max_open_trades must be respected - # don't open on the last row - trade_dir = self.check_for_trade_entry(row) - if ( - (position_stacking or len(open_trades[pair]) == 0) - and self.trade_slot_available(max_open_trades, open_trade_count_start) - and current_time != end_date - and trade_dir is not None - and not PairLocks.is_pair_locked(pair, row[DATE_IDX]) - ): - trade = self._enter_trade(pair, row, trade_dir) - if trade: - # TODO: hacky workaround to avoid opening > max_open_trades - # This emulates previous behavior - not sure if this is correct - # Prevents buying if the trade-slot was freed in this candle - open_trade_count_start += 1 - open_trade_count += 1 - # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.") - open_trades[pair].append(trade) - - for trade in list(open_trades[pair]): - # 3. Process entry orders. - order = trade.select_order(trade.enter_side, is_open=True) - if order and self._get_order_filled(order.price, row): - order.close_bt_order(current_time) - trade.open_order_id = None - LocalTrade.add_bt_trade(trade) - self.wallets.update() - - # 4. Create sell orders (if any) - if not trade.open_order_id: - self._get_sell_trade_entry(trade, row) # Place sell order if necessary - - # 5. Process sell orders. - order = trade.select_order(trade.exit_side, is_open=True) - if order and self._get_order_filled(order.price, row): - trade.open_order_id = None - sub_trade = order.safe_amount_after_fee != trade.amount - if sub_trade: - order.close_bt_order(current_time) - trade.process_sell_sub_trade(order) - trade.recalc_trade_from_orders() - else: - trade.close_date = current_time - trade.close(order.price, show_msg=False) - - # logger.debug(f"{pair} - Backtesting sell {trade}") - open_trade_count -= 1 - open_trades[pair].remove(trade) - LocalTrade.close_bt_trade(trade) - trades.append(trade) - self.run_protections(enable_protections, pair, current_time) - self.wallets.update() - - # Move time one configured time_interval ahead. - self.progress.increment() - current_time += timedelta(minutes=self.timeframe_min) - - trades += self.handle_left_open(open_trades, data=data) - self.wallets.update() - - results = trade_list_to_dataframe(trades) - return { - 'results': results, - 'config': self.strategy.config, - 'locks': PairLocks.get_all_locks(), - 'rejected_signals': self.rejected_trades, - 'timedout_entry_orders': self.timedout_entry_orders, - 'timedout_exit_orders': self.timedout_exit_orders, - 'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']), - } - - def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame], - timerange: TimeRange): - self.progress.init_step(BacktestState.ANALYZE, 0) - - logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) - backtest_start_time = datetime.now(timezone.utc) - self._set_strategy(strat) - - strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - - # Use max_open_trades in backtesting, except --disable-max-market-positions is set - if self.config.get('use_max_market_positions', True): - # Must come from strategy config, as the strategy may modify this setting. - max_open_trades = self.strategy.config['max_open_trades'] - else: - logger.info( - 'Ignoring max_open_trades (--disable-max-market-positions was used) ...') - max_open_trades = 0 - - # need to reprocess data every time to populate signals - preprocessed = self.strategy.advise_all_indicators(data) - - # Trim startup period from analyzed dataframe - preprocessed_tmp = trim_dataframes(preprocessed, timerange, self.required_startup) - - if not preprocessed_tmp: - raise OperationalException( - "No data left after adjusting for startup candles.") - - # Use preprocessed_tmp for date generation (the trimmed dataframe). - # Backtesting will re-trim the dataframes after buy/sell signal generation. - min_date, max_date = history.get_timerange(preprocessed_tmp) - logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' - f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' - f'({(max_date - min_date).days} days).') - # Execute backtest and store results - results = self.backtest( - processed=preprocessed, - start_date=min_date, - end_date=max_date, - max_open_trades=max_open_trades, - position_stacking=self.config.get('position_stacking', False), - enable_protections=self.config.get('enable_protections', False), - ) - backtest_end_time = datetime.now(timezone.utc) - results.update({ - 'run_id': self.run_ids.get(strat.get_strategy_name(), ''), - 'backtest_start_time': int(backtest_start_time.timestamp()), - 'backtest_end_time': int(backtest_end_time.timestamp()), - }) - self.all_results[self.strategy.get_strategy_name()] = results - - return min_date, max_date - - def _get_min_cached_backtest_date(self): - min_backtest_date = None - backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT) - if self.timerange.stopts == 0 or datetime.fromtimestamp( - self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc): - logger.warning('Backtest result caching disabled due to use of open-ended timerange.') - elif backtest_cache_age == 'day': - min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1) - elif backtest_cache_age == 'week': - min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1) - elif backtest_cache_age == 'month': - min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4) - return min_backtest_date - - def load_prior_backtest(self): - self.run_ids = { - strategy.get_strategy_name(): get_strategy_run_id(strategy) - for strategy in self.strategylist - } - - # Load previous result that will be updated incrementally. - # This can be circumvented in certain instances in combination with downloading more data - min_backtest_date = self._get_min_cached_backtest_date() - if min_backtest_date is not None: - self.results = find_existing_backtest_stats( - self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date) - - def start(self) -> None: - """ - Run backtesting end-to-end - :return: None - """ - data: Dict[str, Any] = {} - - data, timerange = self.load_bt_data() - self.load_bt_data_detail() - logger.info("Dataload complete. Calculating indicators") - - self.load_prior_backtest() - - for strat in self.strategylist: - if self.results and strat.get_strategy_name() in self.results['strategy']: - # When previous result hash matches - reuse that result and skip backtesting. - logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}') - continue - min_date, max_date = self.backtest_one_strategy(strat, data, timerange) - - # Update old results with new ones. - if len(self.all_results) > 0: - results = generate_backtest_stats( - data, self.all_results, min_date=min_date, max_date=max_date) - if self.results: - self.results['metadata'].update(results['metadata']) - self.results['strategy'].update(results['strategy']) - self.results['strategy_comparison'].extend(results['strategy_comparison']) - else: - self.results = results - - if self.config.get('export', 'none') == 'trades': - store_backtest_stats(self.config['exportfilename'], self.results) - - # Results may be mixed up now. Sort them so they follow --strategy-list order. - if 'strategy_list' in self.config and len(self.results) > 0: - self.results['strategy_comparison'] = sorted( - self.results['strategy_comparison'], - key=lambda c: self.config['strategy_list'].index(c['key'])) - self.results['strategy'] = dict( - sorted(self.results['strategy'].items(), - key=lambda kv: self.config['strategy_list'].index(kv[0]))) - - if len(self.strategylist) > 0: - # Show backtest results - show_backtest_results(self.config, self.results) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 04614b44b..698d864c8 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -4,6 +4,7 @@ This module contains the class to persist trades into SQLite import logging from datetime import datetime, timedelta, timezone from decimal import Decimal +from math import isclose from typing import Any, Dict, List, Optional from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, @@ -13,7 +14,7 @@ from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session from sqlalchemy.pool import StaticPool from sqlalchemy.sql.schema import UniqueConstraint -from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES +from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES, MATH_CLOSE_PREC from freqtrade.enums import ExitType, TradingMode from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.leverage import interest @@ -193,7 +194,7 @@ class Order(_DECL_BASE): self.order_filled_date = datetime.now(timezone.utc) self.order_update_date = datetime.now(timezone.utc) - def to_json(self, entry_side: str) -> Dict[str, Any]: + def to_json(self, enter_side: str) -> Dict[str, Any]: return { 'pair': self.ft_pair, 'order_id': self.order_id, @@ -215,7 +216,7 @@ class Order(_DECL_BASE): tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None, 'order_type': self.order_type, 'price': self.price, - 'ft_is_entry': self.ft_order_side == entry_side, + 'ft_is_entry': self.ft_order_side == enter_side, 'remaining': self.remaining, } @@ -359,7 +360,7 @@ class LocalTrade(): if self.has_no_leverage: return 0.0 elif not self.is_short: - return (self.amount * self.open_rate) * ((self.leverage-1)/self.leverage) + return (self.amount * self.open_rate) * ((self.leverage - 1) / self.leverage) else: return self.amount @@ -613,6 +614,9 @@ class LocalTrade(): # condition to avoid reset value when updating fees if self.open_order_id == order.order_id: self.open_order_id = None + else: + logger.warning( + f'Got different open_order_id {self.open_order_id} != {order.order_id}') self.recalc_trade_from_orders() elif order.ft_order_side == self.exit_side: if self.is_open: @@ -622,10 +626,16 @@ class LocalTrade(): # condition to avoid reset value when updating fees if self.open_order_id == order.order_id: self.open_order_id = None - if self.amount == order.safe_amount_after_fee: + else: + logger.warning( + f'Got different open_order_id {self.open_order_id} != {order.order_id}') + if isclose(order.safe_amount_after_fee, + self.amount, abs_tol=MATH_CLOSE_PREC): self.close(order.safe_price) else: - self.process_sell_sub_trade(order) + logger.info((self.amount, self.to_json(), order.to_json( + self.enter_side), order.safe_amount_after_fee)) + self.process_exit_sub_trade(order) elif order.ft_order_side == 'stoploss': self.stoploss_order_id = None self.close_rate_requested = self.stop_loss @@ -637,25 +647,29 @@ class LocalTrade(): raise ValueError(f'Unknown order type: {order.order_type}') Trade.commit() - def process_sell_sub_trade(self, order: Order, is_closed: bool = True) -> None: - sell_amount = order.safe_amount_after_fee - sell_rate = order.safe_price - sell_stake_amount = sell_rate * sell_amount * (1 - self.fee_close) - profit = self.calc_profit2(self.open_rate, sell_rate, sell_amount) + def process_exit_sub_trade(self, order: Order, is_closed: bool = True) -> None: + exit_amount = order.safe_amount_after_fee + exit_rate = order.safe_price + exit_stake_amount = exit_rate * exit_amount * (1 - self.fee_close) + profit = self.calc_profit2(self.open_rate, exit_rate, exit_amount) if is_closed: - self.amount -= sell_amount + self.amount -= exit_amount self.stake_amount = self.open_rate * self.amount self.realized_profit += profit + logger.info( + 'Processed exit sub trade for %s', + self + ) self.close_profit_abs = profit - self.close_profit = sell_stake_amount / (sell_stake_amount - profit) - 1 + self.close_profit = exit_stake_amount / (exit_stake_amount - profit) - 1 self.recalc_open_trade_value() def calc_profit2(self, open_rate: float, close_rate: float, amount: float) -> float: - return float(Decimal(amount) * - (Decimal(1 - self.fee_close) * Decimal(close_rate) - - Decimal(1 + self.fee_open) * Decimal(open_rate))) + return float(Decimal(amount) + * (Decimal(1 - self.fee_close) * Decimal(close_rate) + - Decimal(1 + self.fee_open) * Decimal(open_rate))) def close(self, rate: float, *, show_msg: bool = True) -> None: """ @@ -729,7 +743,7 @@ class LocalTrade(): def recalc_open_trade_value(self) -> None: """ Recalculate open_trade_value. - Must be called whenever open_rate, fee_open or is_short is changed. + Must be called whenever open_rate, fee_open is changed. """ self.open_trade_value = self._calc_open_trade_value() @@ -747,7 +761,7 @@ class LocalTrade(): now = (self.close_date or datetime.now(timezone.utc)).replace(tzinfo=None) sec_per_hour = Decimal(3600) total_seconds = Decimal((now - open_date).total_seconds()) - hours = total_seconds/sec_per_hour or zero + hours = total_seconds / sec_per_hour or zero rate = Decimal(interest_rate or self.interest_rate) borrowed = Decimal(self.borrowed) @@ -861,9 +875,9 @@ class LocalTrade(): return 0.0 else: if self.is_short: - profit_ratio = (1 - (close_trade_value/self.open_trade_value)) * leverage + profit_ratio = (1 - (close_trade_value / self.open_trade_value)) * leverage else: - profit_ratio = ((close_trade_value/self.open_trade_value) - 1) * leverage + profit_ratio = ((close_trade_value / self.open_trade_value) - 1) * leverage return float(f"{profit_ratio:.8f}") @@ -878,11 +892,11 @@ class LocalTrade(): tmp_amount = o.safe_amount_after_fee tmp_price = o.safe_price - is_sell = o.ft_order_side != 'buy' - side = -1 if is_sell else 1 + is_exit = o.ft_order_side != self.enter_side + side = -1 if is_exit else 1 if tmp_amount > 0.0 and tmp_price is not None: total_amount += tmp_amount * side - price = avg_price if is_sell else tmp_price + price = avg_price if is_exit else tmp_price total_stake += price * tmp_amount * side if total_amount > 0: avg_price = total_stake / total_amount @@ -932,9 +946,9 @@ class LocalTrade(): :return: array of Order objects """ return [o for o in self.orders if ((o.ft_order_side == order_side) or (order_side is None)) - and o.ft_is_open is False and - (o.filled or 0) > 0 and - o.status in NON_OPEN_EXCHANGE_STATES] + and o.ft_is_open is False + and o.filled + and o.status in NON_OPEN_EXCHANGE_STATES] @property def nr_of_successful_entries(self) -> int: diff --git a/freqtrade/persistence/models.py.orig b/freqtrade/persistence/models.py.orig deleted file mode 100644 index cd1e2696e..000000000 --- a/freqtrade/persistence/models.py.orig +++ /dev/null @@ -1,1499 +0,0 @@ -""" -This module contains the class to persist trades into SQLite -""" -import logging -from datetime import datetime, timedelta, timezone -from decimal import Decimal -from typing import Any, Dict, List, Optional - -from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, - create_engine, desc, func, inspect) -from sqlalchemy.exc import NoSuchModuleError -from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker -from sqlalchemy.pool import StaticPool -from sqlalchemy.sql.schema import UniqueConstraint - -from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES -from freqtrade.enums import ExitType, TradingMode -from freqtrade.exceptions import DependencyException, OperationalException -from freqtrade.leverage import interest -from freqtrade.persistence.migrations import check_migrate - - -logger = logging.getLogger(__name__) - - -_DECL_BASE: Any = declarative_base() -_SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls' - - -def init_db(db_url: str, clean_open_orders: bool = False) -> None: - """ - Initializes this module with the given config, - registers all known command handlers - and starts polling for message updates - :param db_url: Database to use - :param clean_open_orders: Remove open orders from the database. - Useful for dry-run or if all orders have been reset on the exchange. - :return: None - """ - kwargs = {} - - if db_url == 'sqlite:///': - raise OperationalException( - f'Bad db-url {db_url}. For in-memory database, please use `sqlite://`.') - if db_url == 'sqlite://': - kwargs.update({ - 'poolclass': StaticPool, - }) - # Take care of thread ownership - if db_url.startswith('sqlite://'): - kwargs.update({ - 'connect_args': {'check_same_thread': False}, - }) - - try: - engine = create_engine(db_url, future=True, **kwargs) - except NoSuchModuleError: - raise OperationalException(f"Given value for db_url: '{db_url}' " - f"is no valid database URL! (See {_SQL_DOCS_URL})") - - # https://docs.sqlalchemy.org/en/13/orm/contextual.html#thread-local-scope - # Scoped sessions proxy requests to the appropriate thread-local session. - # We should use the scoped_session object - not a seperately initialized version - Trade._session = scoped_session(sessionmaker(bind=engine, autoflush=True)) - Trade.query = Trade._session.query_property() - Order.query = Trade._session.query_property() - PairLock.query = Trade._session.query_property() - - previous_tables = inspect(engine).get_table_names() - _DECL_BASE.metadata.create_all(engine) - check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables) - - # Clean dry_run DB if the db is not in-memory - if clean_open_orders and db_url != 'sqlite://': - clean_dry_run_db() - - -def cleanup_db() -> None: - """ - Flushes all pending operations to disk. - :return: None - """ - Trade.commit() - - -def clean_dry_run_db() -> None: - """ - Remove open_order_id from a Dry_run DB - :return: None - """ - for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all(): - # Check we are updating only a dry_run order not a prod one - if 'dry_run' in trade.open_order_id: - trade.open_order_id = None - Trade.commit() - - -class Order(_DECL_BASE): - """ - Order database model - Keeps a record of all orders placed on the exchange - - One to many relationship with Trades: - - One trade can have many orders - - One Order can only be associated with one Trade - - Mirrors CCXT Order structure - """ - __tablename__ = 'orders' - # Uniqueness should be ensured over pair, order_id - # its likely that order_id is unique per Pair on some exchanges. - __table_args__ = (UniqueConstraint('ft_pair', 'order_id', name="_order_pair_order_id"),) - - id = Column(Integer, primary_key=True) - ft_trade_id = Column(Integer, ForeignKey('trades.id'), index=True) - - trade = relationship("Trade", back_populates="orders") - - # order_side can only be 'buy', 'sell' or 'stoploss' - ft_order_side: str = Column(String(25), nullable=False) - ft_pair: str = Column(String(25), nullable=False) - ft_is_open = Column(Boolean, nullable=False, default=True, index=True) - - order_id: str = Column(String(255), nullable=False, index=True) - status = Column(String(255), nullable=True) - symbol = Column(String(25), nullable=True) - order_type: str = Column(String(50), nullable=True) - side = Column(String(25), nullable=True) - price = Column(Float, nullable=True) - average = Column(Float, nullable=True) - amount = Column(Float, nullable=True) - filled = Column(Float, nullable=True) - remaining = Column(Float, nullable=True) - cost = Column(Float, nullable=True) - order_date = Column(DateTime, nullable=True, default=datetime.utcnow) - order_filled_date = Column(DateTime, nullable=True) - order_update_date = Column(DateTime, nullable=True) - - ft_fee_base = Column(Float, nullable=True) - - @property - def order_date_utc(self) -> datetime: - """ Order-date with UTC timezoneinfo""" - return self.order_date.replace(tzinfo=timezone.utc) - - @property - def safe_price(self) -> float: - return self.average or self.price - - @property - def safe_filled(self) -> float: - return self.filled or self.amount or 0.0 - - @property - def safe_fee_base(self) -> float: - return self.ft_fee_base or 0.0 - - @property - def safe_amount_after_fee(self) -> float: - return self.safe_filled - self.safe_fee_base - - def __repr__(self): - - return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, ' - f'side={self.side}, order_type={self.order_type}, status={self.status})') - - def update_from_ccxt_object(self, order): - """ - Update Order from ccxt response - Only updates if fields are available from ccxt - - """ - if self.order_id != str(order['id']): - raise DependencyException("Order-id's don't match") - - self.status = order.get('status', self.status) - self.symbol = order.get('symbol', self.symbol) - self.order_type = order.get('type', self.order_type) - self.side = order.get('side', self.side) - self.price = order.get('price', self.price) - self.amount = order.get('amount', self.amount) - self.filled = order.get('filled', self.filled) - self.average = order.get('average', self.average) - self.remaining = order.get('remaining', self.remaining) - self.cost = order.get('cost', self.cost) - - if 'timestamp' in order and order['timestamp'] is not None: - self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc) - - self.ft_is_open = True - if self.status in NON_OPEN_EXCHANGE_STATES: - self.ft_is_open = False - if (order.get('filled', 0.0) or 0.0) > 0: - self.order_filled_date = datetime.now(timezone.utc) - self.order_update_date = datetime.now(timezone.utc) - - def to_json(self, entry_side: str) -> Dict[str, Any]: - return { - 'pair': self.ft_pair, - 'order_id': self.order_id, - 'status': self.status, - 'amount': self.amount, - 'average': round(self.average, 8) if self.average else 0, - 'safe_price': self.safe_price, - 'cost': self.cost if self.cost else 0, - 'filled': self.filled, - 'ft_order_side': self.ft_order_side, - 'is_open': self.ft_is_open, - 'order_date': self.order_date.strftime(DATETIME_PRINT_FORMAT) - if self.order_date else None, - 'order_timestamp': int(self.order_date.replace( - tzinfo=timezone.utc).timestamp() * 1000) if self.order_date else None, - 'order_filled_date': self.order_filled_date.strftime(DATETIME_PRINT_FORMAT) - if self.order_filled_date else None, - 'order_filled_timestamp': int(self.order_filled_date.replace( - tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None, - 'order_type': self.order_type, - 'price': self.price, - 'ft_is_entry': self.ft_order_side == entry_side, - 'remaining': self.remaining, - } - - def close_bt_order(self, close_date: datetime): - self.order_filled_date = close_date - self.filled = self.amount - self.status = 'closed' - self.ft_is_open = False - - @staticmethod - def update_orders(orders: List['Order'], order: Dict[str, Any]): - """ - Get all non-closed orders - useful when trying to batch-update orders - """ - if not isinstance(order, dict): - logger.warning(f"{order} is not a valid response object.") - return - - filtered_orders = [o for o in orders if o.order_id == order.get('id')] - if filtered_orders: - oobj = filtered_orders[0] - oobj.update_from_ccxt_object(order) - Order.query.session.commit() - else: - logger.warning(f"Did not find order for {order}.") - - @staticmethod - def parse_from_ccxt_object(order: Dict[str, Any], pair: str, side: str) -> 'Order': - """ - Parse an order from a ccxt object and return a new order Object. - """ - o = Order(order_id=str(order['id']), ft_order_side=side, ft_pair=pair) - - o.update_from_ccxt_object(order) - return o - - @staticmethod - def get_open_orders() -> List['Order']: - """ - Retrieve open orders from the database - :return: List of open orders - """ - return Order.query.filter(Order.ft_is_open.is_(True)).all() - - -class LocalTrade(): - """ - Trade database model. - Used in backtesting - must be aligned to Trade model! - - """ - use_db: bool = False - # Trades container for backtesting - trades: List['LocalTrade'] = [] - trades_open: List['LocalTrade'] = [] - total_profit: float = 0 - realized_profit: float = 0 - - id: int = 0 - - orders: List[Order] = [] - - exchange: str = '' - pair: str = '' - is_open: bool = True - fee_open: float = 0.0 - fee_open_cost: Optional[float] = None - fee_open_currency: str = '' - fee_close: float = 0.0 - fee_close_cost: Optional[float] = None - fee_close_currency: str = '' - open_rate: float = 0.0 - open_rate_requested: Optional[float] = None - # open_trade_value - calculated via _calc_open_trade_value - open_trade_value: float = 0.0 - close_rate: Optional[float] = None - close_rate_requested: Optional[float] = None - close_profit: Optional[float] = None - close_profit_abs: Optional[float] = None - stake_amount: float = 0.0 - amount: float = 0.0 - amount_requested: Optional[float] = None - open_date: datetime - close_date: Optional[datetime] = None - open_order_id: Optional[str] = None - # absolute value of the stop loss - stop_loss: float = 0.0 - # percentage value of the stop loss - stop_loss_pct: float = 0.0 - # absolute value of the initial stop loss - initial_stop_loss: float = 0.0 - # percentage value of the initial stop loss - initial_stop_loss_pct: Optional[float] = None - # stoploss order id which is on exchange - stoploss_order_id: Optional[str] = None - # last update time of the stoploss order on exchange - stoploss_last_update: Optional[datetime] = None - # absolute value of the highest reached price - max_rate: float = 0.0 - # Lowest price reached - min_rate: float = 0.0 - exit_reason: str = '' - exit_order_status: str = '' - strategy: str = '' - enter_tag: Optional[str] = None - timeframe: Optional[int] = None - - trading_mode: TradingMode = TradingMode.SPOT - - # Leverage trading properties - liquidation_price: Optional[float] = None - is_short: bool = False - leverage: float = 1.0 - - # Margin trading properties - interest_rate: float = 0.0 - - # Futures properties - funding_fees: Optional[float] = None - - @property - def buy_tag(self) -> Optional[str]: - """ - Compatibility between buy_tag (old) and enter_tag (new) - Consider buy_tag deprecated - """ - return self.enter_tag - - @property - def has_no_leverage(self) -> bool: - """Returns true if this is a non-leverage, non-short trade""" - return ((self.leverage == 1.0 or self.leverage is None) and not self.is_short) - - @property - def borrowed(self) -> float: - """ - The amount of currency borrowed from the exchange for leverage trades - If a long trade, the amount is in base currency - If a short trade, the amount is in the other currency being traded - """ - if self.has_no_leverage: - return 0.0 - elif not self.is_short: - return (self.amount * self.open_rate) * ((self.leverage-1)/self.leverage) - else: - return self.amount - - @property - def open_date_utc(self): - return self.open_date.replace(tzinfo=timezone.utc) - - @property - def close_date_utc(self): - return self.close_date.replace(tzinfo=timezone.utc) - - @property - def enter_side(self) -> str: - if self.is_short: - return "sell" - else: - return "buy" - - @property - def exit_side(self) -> str: - if self.is_short: - return "buy" - else: - return "sell" - - @property - def trade_direction(self) -> str: - if self.is_short: - return "short" - else: - return "long" - - def __init__(self, **kwargs): - for key in kwargs: - setattr(self, key, kwargs[key]) - self.recalc_open_trade_value() - if self.trading_mode == TradingMode.MARGIN and self.interest_rate is None: - raise OperationalException( - f"{self.trading_mode.value} trading requires param interest_rate on trades") - - def __repr__(self): - open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed' - leverage = self.leverage or 1.0 - is_short = self.is_short or False - - return ( - f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, ' - f'is_short={is_short}, leverage={leverage}, ' - f'open_rate={self.open_rate:.8f}, open_since={open_since})' - ) - - def to_json(self) -> Dict[str, Any]: - filled_orders = self.select_filled_orders() - orders = [order.to_json(self.enter_side) for order in filled_orders] - - return { - 'trade_id': self.id, - 'pair': self.pair, - 'is_open': self.is_open, - 'exchange': self.exchange, - 'amount': round(self.amount, 8), - 'amount_requested': round(self.amount_requested, 8) if self.amount_requested else None, - 'stake_amount': round(self.stake_amount, 8), - 'strategy': self.strategy, - 'buy_tag': self.enter_tag, - 'enter_tag': self.enter_tag, - 'timeframe': self.timeframe, - - 'fee_open': self.fee_open, - 'fee_open_cost': self.fee_open_cost, - 'fee_open_currency': self.fee_open_currency, - 'fee_close': self.fee_close, - 'fee_close_cost': self.fee_close_cost, - 'fee_close_currency': self.fee_close_currency, - - 'open_date': self.open_date.strftime(DATETIME_PRINT_FORMAT), - 'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000), - 'open_rate': self.open_rate, - 'open_rate_requested': self.open_rate_requested, - 'open_trade_value': round(self.open_trade_value, 8), - - 'close_date': (self.close_date.strftime(DATETIME_PRINT_FORMAT) - if self.close_date else None), - 'close_timestamp': int(self.close_date.replace( - tzinfo=timezone.utc).timestamp() * 1000) if self.close_date else None, - 'realized_profit': self.realized_profit or 0.0, - 'close_rate': self.close_rate, - 'close_rate_requested': self.close_rate_requested, - 'close_profit': self.close_profit, # Deprecated - 'close_profit_pct': round(self.close_profit * 100, 2) if self.close_profit else None, - 'close_profit_abs': self.close_profit_abs, # Deprecated - - 'trade_duration_s': (int((self.close_date_utc - self.open_date_utc).total_seconds()) - if self.close_date else None), - 'trade_duration': (int((self.close_date_utc - self.open_date_utc).total_seconds() // 60) - if self.close_date else None), - - 'profit_ratio': self.close_profit, - 'profit_pct': round(self.close_profit * 100, 2) if self.close_profit else None, - 'profit_abs': self.close_profit_abs, - - 'sell_reason': self.exit_reason, # Deprecated - 'exit_reason': self.exit_reason, - 'exit_order_status': self.exit_order_status, - 'stop_loss_abs': self.stop_loss, - 'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None, - 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, - 'stoploss_order_id': self.stoploss_order_id, - 'stoploss_last_update': (self.stoploss_last_update.strftime(DATETIME_PRINT_FORMAT) - if self.stoploss_last_update else None), - 'stoploss_last_update_timestamp': int(self.stoploss_last_update.replace( - tzinfo=timezone.utc).timestamp() * 1000) if self.stoploss_last_update else None, - 'initial_stop_loss_abs': self.initial_stop_loss, - 'initial_stop_loss_ratio': (self.initial_stop_loss_pct - if self.initial_stop_loss_pct else None), - 'initial_stop_loss_pct': (self.initial_stop_loss_pct * 100 - if self.initial_stop_loss_pct else None), - 'min_rate': self.min_rate, - 'max_rate': self.max_rate, - - 'leverage': self.leverage, - 'interest_rate': self.interest_rate, - 'liquidation_price': self.liquidation_price, - 'is_short': self.is_short, - 'trading_mode': self.trading_mode, - 'funding_fees': self.funding_fees, - 'open_order_id': self.open_order_id, - 'orders': orders, - } - - @staticmethod - def reset_trades() -> None: - """ - Resets all trades. Only active for backtesting mode. - """ - LocalTrade.trades = [] - LocalTrade.trades_open = [] - LocalTrade.total_profit = 0 - - def adjust_min_max_rates(self, current_price: float, current_price_low: float) -> None: - """ - Adjust the max_rate and min_rate. - """ - self.max_rate = max(current_price, self.max_rate or self.open_rate) - self.min_rate = min(current_price_low, self.min_rate or self.open_rate) - - def set_isolated_liq(self, liquidation_price: Optional[float]): - """ - Method you should use to set self.liquidation price. - Assures stop_loss is not passed the liquidation price - """ - if not liquidation_price: - return - self.liquidation_price = liquidation_price - - def _set_stop_loss(self, stop_loss: float, percent: float): - """ - Method you should use to set self.stop_loss. - Assures stop_loss is not passed the liquidation price - """ - if self.liquidation_price is not None: - if self.is_short: - sl = min(stop_loss, self.liquidation_price) - else: - sl = max(stop_loss, self.liquidation_price) - else: - sl = stop_loss - - if not self.stop_loss: - self.initial_stop_loss = sl - self.stop_loss = sl - - self.stop_loss_pct = -1 * abs(percent) - self.stoploss_last_update = datetime.utcnow() - - def adjust_stop_loss(self, current_price: float, stoploss: float, - initial: bool = False) -> None: - """ - This adjusts the stop loss to it's most recently observed setting - :param current_price: Current rate the asset is traded - :param stoploss: Stoploss as factor (sample -0.05 -> -5% below current price). - :param initial: Called to initiate stop_loss. - Skips everything if self.stop_loss is already set. - """ - if initial and not (self.stop_loss is None or self.stop_loss == 0): - # Don't modify if called with initial and nothing to do - return - - leverage = self.leverage or 1.0 - if self.is_short: - new_loss = float(current_price * (1 + abs(stoploss / leverage))) - # If trading with leverage, don't set the stoploss below the liquidation price - if self.liquidation_price: - new_loss = min(self.liquidation_price, new_loss) - else: - new_loss = float(current_price * (1 - abs(stoploss / leverage))) - # If trading with leverage, don't set the stoploss below the liquidation price - if self.liquidation_price: - new_loss = max(self.liquidation_price, new_loss) - - # no stop loss assigned yet - if self.initial_stop_loss_pct is None: - logger.debug(f"{self.pair} - Assigning new stoploss...") - self._set_stop_loss(new_loss, stoploss) - self.initial_stop_loss = new_loss - self.initial_stop_loss_pct = -1 * abs(stoploss) - - # evaluate if the stop loss needs to be updated - else: - - higher_stop = new_loss > self.stop_loss - lower_stop = new_loss < self.stop_loss - - # stop losses only walk up, never down!, - # ? But adding more to a leveraged trade would create a lower liquidation price, - # ? decreasing the minimum stoploss - if (higher_stop and not self.is_short) or (lower_stop and self.is_short): - logger.debug(f"{self.pair} - Adjusting stoploss...") - self._set_stop_loss(new_loss, stoploss) - else: - logger.debug(f"{self.pair} - Keeping current stoploss...") - - logger.debug( - f"{self.pair} - Stoploss adjusted. current_price={current_price:.8f}, " - f"open_rate={self.open_rate:.8f}, max_rate={self.max_rate or self.open_rate:.8f}, " - f"initial_stop_loss={self.initial_stop_loss:.8f}, " - f"stop_loss={self.stop_loss:.8f}. " - f"Trailing stoploss saved us: " - f"{float(self.stop_loss) - float(self.initial_stop_loss):.8f}.") - - def update_trade(self, order: Order) -> None: - """ - Updates this entity with amount and actual open/close rates. - :param order: order retrieved by exchange.fetch_order() - :return: None - """ - - # Ignore open and cancelled orders - if order.status == 'open' or order.safe_price is None: - return - - logger.info(f'Updating trade (id={self.id}) ...') - - if order.ft_order_side == self.enter_side: - # Update open rate and actual amount - self.open_rate = order.safe_price - self.amount = order.safe_amount_after_fee - if self.is_open: -<<<<<<< HEAD - logger.info(f'{order.order_type.upper()}_BUY has been fulfilled for {self}.') - # condition to avoid reset value when updating fees - if self.open_order_id == order.order_id: - self.open_order_id = None -======= - payment = "SELL" if self.is_short else "BUY" - logger.info(f'{order.order_type.upper()}_{payment} has been fulfilled for {self}.') - self.open_order_id = None ->>>>>>> develop - self.recalc_trade_from_orders() - elif order.ft_order_side == self.exit_side: - if self.is_open: -<<<<<<< HEAD - logger.info(f'{order.order_type.upper()}_SELL has been fulfilled for {self}.') - # condition to avoid reset value when updating fees - if self.open_order_id == order.order_id: - self.open_order_id = None - if self.amount == order.safe_amount_after_fee: - self.close(order.safe_price) - else: - self.process_sell_sub_trade(order) -======= - payment = "BUY" if self.is_short else "SELL" - # * On margin shorts, you buy a little bit more than the amount (amount + interest) - logger.info(f'{order.order_type.upper()}_{payment} has been fulfilled for {self}.') - self.close(order.safe_price) ->>>>>>> develop - elif order.ft_order_side == 'stoploss': - self.stoploss_order_id = None - self.close_rate_requested = self.stop_loss - self.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value - if self.is_open: - logger.info(f'{order.order_type.upper()} is hit for {self}.') - self.close(order.safe_price) - else: - raise ValueError(f'Unknown order type: {order.order_type}') - Trade.commit() - - def process_sell_sub_trade(self, order: Order, is_closed: bool = True) -> None: - sell_amount = order.safe_amount_after_fee - sell_rate = order.safe_price - sell_stake_amount = sell_rate * sell_amount * (1 - self.fee_close) - profit = self.calc_profit2(self.open_rate, sell_rate, sell_amount) - if is_closed: - self.amount -= sell_amount - self.stake_amount = self.open_rate * self.amount - self.realized_profit += profit - - self.close_profit_abs = profit - self.close_profit = sell_stake_amount / (sell_stake_amount - profit) - 1 - self.recalc_open_trade_value() - - def calc_profit2(self, open_rate: float, close_rate: float, - amount: float) -> float: - return float(Decimal(amount) * - (Decimal(1 - self.fee_close) * Decimal(close_rate) - - Decimal(1 + self.fee_open) * Decimal(open_rate))) - - def close(self, rate: float, *, show_msg: bool = True) -> None: - """ - Sets close_rate to the given rate, calculates total profit - and marks trade as closed - """ - self.close_rate = rate - self.close_date = self.close_date or datetime.utcnow() - self.close_profit = self.calc_profit_ratio() -<<<<<<< HEAD - self.close_profit_abs = self.calc_profit() + self.realized_profit - self.close_date = self.close_date or datetime.utcnow() -======= - self.close_profit_abs = self.calc_profit() ->>>>>>> develop - self.is_open = False - self.exit_order_status = 'closed' - self.open_order_id = None - if show_msg: - logger.info( - 'Marking %s as closed as the trade is fulfilled and found no open orders for it.', - self - ) - - def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float], - side: str) -> None: - """ - Update Fee parameters. Only acts once per side - """ - if self.enter_side == side and self.fee_open_currency is None: - self.fee_open_cost = fee_cost - self.fee_open_currency = fee_currency - if fee_rate is not None: - self.fee_open = fee_rate - # Assume close-fee will fall into the same fee category and take an educated guess - self.fee_close = fee_rate - elif self.exit_side == side and self.fee_close_currency is None: - self.fee_close_cost = fee_cost - self.fee_close_currency = fee_currency - if fee_rate is not None: - self.fee_close = fee_rate - - def fee_updated(self, side: str) -> bool: - """ - Verify if this side (buy / sell) has already been updated - """ - if self.enter_side == side: - return self.fee_open_currency is not None - elif self.exit_side == side: - return self.fee_close_currency is not None - else: - return False - - def update_order(self, order: Dict) -> None: - Order.update_orders(self.orders, order) - - def get_exit_order_count(self) -> int: - """ - Get amount of failed exiting orders - assumes full exits. - """ - return len([o for o in self.orders if o.ft_order_side == self.exit_side]) - - def _calc_open_trade_value(self) -> float: - """ - Calculate the open_rate including open_fee. - :return: Price in of the open trade incl. Fees - """ - open_trade = Decimal(self.amount) * Decimal(self.open_rate) - fees = open_trade * Decimal(self.fee_open) - if self.is_short: - return float(open_trade - fees) - else: - return float(open_trade + fees) - - def recalc_open_trade_value(self) -> None: - """ - Recalculate open_trade_value. - Must be called whenever open_rate, fee_open or is_short is changed. - """ - self.open_trade_value = self._calc_open_trade_value() - - def calculate_interest(self, interest_rate: Optional[float] = None) -> Decimal: - """ - :param interest_rate: interest_charge for borrowing this coin(optional). - If interest_rate is not set self.interest_rate will be used - """ - zero = Decimal(0.0) - # If nothing was borrowed - if self.trading_mode != TradingMode.MARGIN or self.has_no_leverage: - return zero - - open_date = self.open_date.replace(tzinfo=None) - now = (self.close_date or datetime.now(timezone.utc)).replace(tzinfo=None) - sec_per_hour = Decimal(3600) - total_seconds = Decimal((now - open_date).total_seconds()) - hours = total_seconds/sec_per_hour or zero - - rate = Decimal(interest_rate or self.interest_rate) - borrowed = Decimal(self.borrowed) - - return interest(exchange_name=self.exchange, borrowed=borrowed, rate=rate, hours=hours) - - def _calc_base_close(self, amount: Decimal, rate: Optional[float] = None, - fee: Optional[float] = None) -> Decimal: - - close_trade = Decimal(amount) * Decimal(rate or self.close_rate) # type: ignore - fees = close_trade * Decimal(fee or self.fee_close) - - if self.is_short: - return close_trade + fees - else: - return close_trade - fees - - def calc_close_trade_value(self, rate: Optional[float] = None, - fee: Optional[float] = None, - interest_rate: Optional[float] = None) -> float: - """ - Calculate the close_rate including fee - :param fee: fee to use on the close rate (optional). - If rate is not set self.fee will be used - :param rate: rate to compare with (optional). - If rate is not set self.close_rate will be used - :param interest_rate: interest_charge for borrowing this coin (optional). - If interest_rate is not set self.interest_rate will be used - :return: Price in BTC of the open trade - """ - if rate is None and not self.close_rate: - return 0.0 - - amount = Decimal(self.amount) - trading_mode = self.trading_mode or TradingMode.SPOT - - if trading_mode == TradingMode.SPOT: - return float(self._calc_base_close(amount, rate, fee)) - - elif (trading_mode == TradingMode.MARGIN): - - total_interest = self.calculate_interest(interest_rate) - - if self.is_short: - amount = amount + total_interest - return float(self._calc_base_close(amount, rate, fee)) - else: - # Currency already owned for longs, no need to purchase - return float(self._calc_base_close(amount, rate, fee) - total_interest) - - elif (trading_mode == TradingMode.FUTURES): - funding_fees = self.funding_fees or 0.0 - # Positive funding_fees -> Trade has gained from fees. - # Negative funding_fees -> Trade had to pay the fees. - if self.is_short: - return float(self._calc_base_close(amount, rate, fee)) - funding_fees - else: - return float(self._calc_base_close(amount, rate, fee)) + funding_fees - else: - raise OperationalException( - f"{self.trading_mode.value} trading is not yet available using freqtrade") - - def calc_profit(self, rate: Optional[float] = None, - fee: Optional[float] = None, - interest_rate: Optional[float] = None) -> float: - """ - Calculate the absolute profit in stake currency between Close and Open trade - :param fee: fee to use on the close rate (optional). - If fee is not set self.fee will be used - :param rate: close rate to compare with (optional). - If rate is not set self.close_rate will be used - :param interest_rate: interest_charge for borrowing this coin (optional). - If interest_rate is not set self.interest_rate will be used - :return: profit in stake currency as float - """ - close_trade_value = self.calc_close_trade_value( - rate=(rate or self.close_rate), - fee=(fee or self.fee_close), - interest_rate=(interest_rate or self.interest_rate) - ) - - if self.is_short: - profit = self.open_trade_value - close_trade_value - else: - profit = close_trade_value - self.open_trade_value - return float(f"{profit:.8f}") - - def calc_profit_ratio(self, rate: Optional[float] = None, - fee: Optional[float] = None, - interest_rate: Optional[float] = None) -> float: - """ - Calculates the profit as ratio (including fee). - :param rate: rate to compare with (optional). - If rate is not set self.close_rate will be used - :param fee: fee to use on the close rate (optional). - :param interest_rate: interest_charge for borrowing this coin (optional). - If interest_rate is not set self.interest_rate will be used - :return: profit ratio as float - """ - close_trade_value = self.calc_close_trade_value( - rate=(rate or self.close_rate), - fee=(fee or self.fee_close), - interest_rate=(interest_rate or self.interest_rate) - ) - - short_close_zero = (self.is_short and close_trade_value == 0.0) - long_close_zero = (not self.is_short and self.open_trade_value == 0.0) - leverage = self.leverage or 1.0 - - if (short_close_zero or long_close_zero): - return 0.0 - else: - if self.is_short: - profit_ratio = (1 - (close_trade_value/self.open_trade_value)) * leverage - else: - profit_ratio = ((close_trade_value/self.open_trade_value) - 1) * leverage - - return float(f"{profit_ratio:.8f}") - - def recalc_trade_from_orders(self): -<<<<<<< HEAD -======= - # We need at least 2 entry orders for averaging amounts and rates. - # TODO: this condition could probably be removed - if len(self.select_filled_orders(self.enter_side)) < 2: - self.stake_amount = self.amount * self.open_rate / self.leverage - - # Just in case, still recalc open trade value - self.recalc_open_trade_value() - return - ->>>>>>> develop - total_amount = 0.0 - total_stake = 0.0 - avg_price = None - - for o in self.orders: -<<<<<<< HEAD - if o.ft_is_open or not o.filled: -======= - if (o.ft_is_open or - (o.ft_order_side != self.enter_side) or - (o.status not in NON_OPEN_EXCHANGE_STATES)): ->>>>>>> develop - continue - - tmp_amount = o.safe_amount_after_fee - tmp_price = o.safe_price - is_sell = o.ft_order_side != 'buy' - side = -1 if is_sell else 1 - if tmp_amount > 0.0 and tmp_price is not None: - total_amount += tmp_amount * side - price = avg_price if is_sell else tmp_price - total_stake += price * tmp_amount * side - if total_amount > 0: - avg_price = total_stake / total_amount - - if total_amount > 0: - # Leverage not updated, as we don't allow changing leverage through DCA at the moment. - self.open_rate = total_stake / total_amount - self.stake_amount = total_stake / (self.leverage or 1.0) - self.amount = total_amount - self.fee_open_cost = self.fee_open * self.stake_amount - self.recalc_open_trade_value() - if self.stop_loss_pct is not None and self.open_rate is not None: - self.adjust_stop_loss(self.open_rate, self.stop_loss_pct) - - def select_order_by_order_id(self, order_id: str) -> Optional[Order]: - """ - Finds order object by Order id. - :param order_id: Exchange order id - """ - for o in self.orders: - if o.order_id == order_id: - return o - return None - - def select_order( - self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]: - """ - Finds latest order for this orderside and status - :param order_side: ft_order_side of the order (either 'buy', 'sell' or 'stoploss') - :param is_open: Only search for open orders? - :return: latest Order object if it exists, else None - """ - orders = self.orders - if order_side: - orders = [o for o in orders if o.ft_order_side == order_side] - if is_open is not None: - orders = [o for o in orders if o.ft_is_open == is_open] - if len(orders) > 0: - return orders[-1] - else: - return None - - def select_filled_orders(self, order_side: Optional[str] = None) -> List['Order']: - """ - Finds filled orders for this orderside. - :param order_side: Side of the order (either 'buy', 'sell', or None) - :return: array of Order objects - """ - return [o for o in self.orders if ((o.ft_order_side == order_side) or (order_side is None)) - and o.ft_is_open is False and - (o.filled or 0) > 0 and - o.status in NON_OPEN_EXCHANGE_STATES] - - @property - def nr_of_successful_entries(self) -> int: - """ - Helper function to count the number of entry orders that have been filled. - :return: int count of entry orders that have been filled for this trade. - """ - - return len(self.select_filled_orders(self.enter_side)) - - @property - def nr_of_successful_exits(self) -> int: - """ - Helper function to count the number of exit orders that have been filled. - :return: int count of exit orders that have been filled for this trade. - """ - return len(self.select_filled_orders(self.exit_side)) - - @property - def nr_of_successful_buys(self) -> int: - """ - Helper function to count the number of buy orders that have been filled. - WARNING: Please use nr_of_successful_entries for short support. - :return: int count of buy orders that have been filled for this trade. - """ - - return len(self.select_filled_orders('buy')) - - @property - def nr_of_successful_sells(self) -> int: - """ - Helper function to count the number of sell orders that have been filled. - WARNING: Please use nr_of_successful_exits for short support. - :return: int count of sell orders that have been filled for this trade. - """ - return len(self.select_filled_orders('sell')) - - @property - def sell_reason(self) -> str: - """ DEPRECATED! Please use exit_reason instead.""" - return self.exit_reason - - @staticmethod - def get_trades_proxy(*, pair: str = None, is_open: bool = None, - open_date: datetime = None, close_date: datetime = None, - ) -> List['LocalTrade']: - """ - Helper function to query Trades. - Returns a List of trades, filtered on the parameters given. - In live mode, converts the filter to a database query and returns all rows - In Backtest mode, uses filters on Trade.trades to get the result. - - :return: unsorted List[Trade] - """ - - # Offline mode - without database - if is_open is not None: - if is_open: - sel_trades = LocalTrade.trades_open - else: - sel_trades = LocalTrade.trades - - else: - # Not used during backtesting, but might be used by a strategy - sel_trades = list(LocalTrade.trades + LocalTrade.trades_open) - - if pair: - sel_trades = [trade for trade in sel_trades if trade.pair == pair] - if open_date: - sel_trades = [trade for trade in sel_trades if trade.open_date > open_date] - if close_date: - sel_trades = [trade for trade in sel_trades if trade.close_date - and trade.close_date > close_date] - - return sel_trades - - @staticmethod - def close_bt_trade(trade): - LocalTrade.trades_open.remove(trade) - LocalTrade.trades.append(trade) - LocalTrade.total_profit += trade.close_profit_abs - - @staticmethod - def add_bt_trade(trade): - if trade.is_open: - LocalTrade.trades_open.append(trade) - else: - LocalTrade.trades.append(trade) - - @staticmethod - def get_open_trades() -> List[Any]: - """ - Query trades from persistence layer - """ - return Trade.get_trades_proxy(is_open=True) - - @staticmethod - def stoploss_reinitialization(desired_stoploss): - """ - Adjust initial Stoploss to desired stoploss for all open trades. - """ - for trade in Trade.get_open_trades(): - logger.info("Found open trade: %s", trade) - - # skip case if trailing-stop changed the stoploss already. - if (trade.stop_loss == trade.initial_stop_loss - and trade.initial_stop_loss_pct != desired_stoploss): - # Stoploss value got changed - - logger.info(f"Stoploss for {trade} needs adjustment...") - # Force reset of stoploss - trade.stop_loss = None - trade.initial_stop_loss_pct = None - trade.adjust_stop_loss(trade.open_rate, desired_stoploss) - logger.info(f"New stoploss: {trade.stop_loss}.") - - -class Trade(_DECL_BASE, LocalTrade): - """ - Trade database model. - Also handles updating and querying trades - - Note: Fields must be aligned with LocalTrade class - """ - __tablename__ = 'trades' - - use_db: bool = True - - id = Column(Integer, primary_key=True) - - orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan", lazy="joined") - - exchange = Column(String(25), nullable=False) - pair = Column(String(25), nullable=False, index=True) - is_open = Column(Boolean, nullable=False, default=True, index=True) - fee_open = Column(Float, nullable=False, default=0.0) - fee_open_cost = Column(Float, nullable=True) - fee_open_currency = Column(String(25), nullable=True) - fee_close = Column(Float, nullable=False, default=0.0) - fee_close_cost = Column(Float, nullable=True) - fee_close_currency = Column(String(25), nullable=True) - open_rate: float = Column(Float) - open_rate_requested = Column(Float) - # open_trade_value - calculated via _calc_open_trade_value - open_trade_value = Column(Float) - close_rate: Optional[float] = Column(Float) - close_rate_requested = Column(Float) - realized_profit = Column(Float, default=0.0) - close_profit = Column(Float) - close_profit_abs = Column(Float) - stake_amount = Column(Float, nullable=False) - amount = Column(Float) - amount_requested = Column(Float) - open_date = Column(DateTime, nullable=False, default=datetime.utcnow) - close_date = Column(DateTime) - open_order_id = Column(String(255)) - # absolute value of the stop loss - stop_loss = Column(Float, nullable=True, default=0.0) - # percentage value of the stop loss - stop_loss_pct = Column(Float, nullable=True) - # absolute value of the initial stop loss - initial_stop_loss = Column(Float, nullable=True, default=0.0) - # percentage value of the initial stop loss - initial_stop_loss_pct = Column(Float, nullable=True) - # stoploss order id which is on exchange - stoploss_order_id = Column(String(255), nullable=True, index=True) - # last update time of the stoploss order on exchange - stoploss_last_update = Column(DateTime, nullable=True) - # absolute value of the highest reached price - max_rate = Column(Float, nullable=True, default=0.0) - # Lowest price reached - min_rate = Column(Float, nullable=True) - exit_reason = Column(String(100), nullable=True) - exit_order_status = Column(String(100), nullable=True) - strategy = Column(String(100), nullable=True) - enter_tag = Column(String(100), nullable=True) - timeframe = Column(Integer, nullable=True) - - trading_mode = Column(Enum(TradingMode), nullable=True) - - # Leverage trading properties - leverage = Column(Float, nullable=True, default=1.0) - is_short = Column(Boolean, nullable=False, default=False) - liquidation_price = Column(Float, nullable=True) - - # Margin Trading Properties - interest_rate = Column(Float, nullable=False, default=0.0) - - # Futures properties - funding_fees = Column(Float, nullable=True, default=None) - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.realized_profit = 0 - self.recalc_open_trade_value() - - def delete(self) -> None: - - for order in self.orders: - Order.query.session.delete(order) - - Trade.query.session.delete(self) - Trade.commit() - - @staticmethod - def commit(): - Trade.query.session.commit() - - @staticmethod - def get_trades_proxy(*, pair: str = None, is_open: bool = None, - open_date: datetime = None, close_date: datetime = None, - ) -> List['LocalTrade']: - """ - Helper function to query Trades.j - Returns a List of trades, filtered on the parameters given. - In live mode, converts the filter to a database query and returns all rows - In Backtest mode, uses filters on Trade.trades to get the result. - - :return: unsorted List[Trade] - """ - if Trade.use_db: - trade_filter = [] - if pair: - trade_filter.append(Trade.pair == pair) - if open_date: - trade_filter.append(Trade.open_date > open_date) - if close_date: - trade_filter.append(Trade.close_date > close_date) - if is_open is not None: - trade_filter.append(Trade.is_open.is_(is_open)) - return Trade.get_trades(trade_filter).all() - else: - return LocalTrade.get_trades_proxy( - pair=pair, is_open=is_open, - open_date=open_date, - close_date=close_date - ) - - @staticmethod - def get_trades(trade_filter=None) -> Query: - """ - Helper function to query Trades using filters. - NOTE: Not supported in Backtesting. - :param trade_filter: Optional filter to apply to trades - Can be either a Filter object, or a List of filters - e.g. `(trade_filter=[Trade.id == trade_id, Trade.is_open.is_(True),])` - e.g. `(trade_filter=Trade.id == trade_id)` - :return: unsorted query object - """ - if not Trade.use_db: - raise NotImplementedError('`Trade.get_trades()` not supported in backtesting mode.') - if trade_filter is not None: - if not isinstance(trade_filter, list): - trade_filter = [trade_filter] - return Trade.query.filter(*trade_filter) - else: - return Trade.query - - @staticmethod - def get_open_order_trades() -> List['Trade']: - """ - Returns all open trades - NOTE: Not supported in Backtesting. - """ - return Trade.get_trades(Trade.open_order_id.isnot(None)).all() - - @staticmethod - def get_open_trades_without_assigned_fees(): - """ - Returns all open trades which don't have open fees set correctly - NOTE: Not supported in Backtesting. - """ - return Trade.get_trades([Trade.fee_open_currency.is_(None), - Trade.orders.any(), - Trade.is_open.is_(True), - ]).all() - - @staticmethod - def get_closed_trades_without_assigned_fees(): - """ - Returns all closed trades which don't have fees set correctly - NOTE: Not supported in Backtesting. - """ - return Trade.get_trades([Trade.fee_close_currency.is_(None), - Trade.orders.any(), - Trade.is_open.is_(False), - ]).all() - - @staticmethod - def get_total_closed_profit() -> float: - """ - Retrieves total realized profit - """ - if Trade.use_db: - total_profit = Trade.query.with_entities( - func.sum(Trade.close_profit_abs)).filter(Trade.is_open.is_(False)).scalar() - else: - total_profit = sum( - t.close_profit_abs for t in LocalTrade.get_trades_proxy(is_open=False)) - return total_profit or 0 - - @staticmethod - def total_open_trades_stakes() -> float: - """ - Calculates total invested amount in open trades - in stake currency - """ - if Trade.use_db: - total_open_stake_amount = Trade.query.with_entities( - func.sum(Trade.stake_amount)).filter(Trade.is_open.is_(True)).scalar() - else: - total_open_stake_amount = sum( - t.stake_amount for t in LocalTrade.get_trades_proxy(is_open=True)) - return total_open_stake_amount or 0 - - @staticmethod - def get_overall_performance(minutes=None) -> List[Dict[str, Any]]: - """ - Returns List of dicts containing all Trades, including profit and trade count - NOTE: Not supported in Backtesting. - """ - filters = [Trade.is_open.is_(False)] - if minutes: - start_date = datetime.now(timezone.utc) - timedelta(minutes=minutes) - filters.append(Trade.close_date >= start_date) - pair_rates = Trade.query.with_entities( - Trade.pair, - func.sum(Trade.close_profit).label('profit_sum'), - func.sum(Trade.close_profit_abs).label('profit_sum_abs'), - func.count(Trade.pair).label('count') - ).filter(*filters)\ - .group_by(Trade.pair) \ - .order_by(desc('profit_sum_abs')) \ - .all() - return [ - { - 'pair': pair, - 'profit_ratio': profit, - 'profit': round(profit * 100, 2), # Compatibility mode - 'profit_pct': round(profit * 100, 2), - 'profit_abs': profit_abs, - 'count': count - } - for pair, profit, profit_abs, count in pair_rates - ] - - @staticmethod - def get_enter_tag_performance(pair: Optional[str]) -> List[Dict[str, Any]]: - """ - Returns List of dicts containing all Trades, based on buy tag performance - Can either be average for all pairs or a specific pair provided - NOTE: Not supported in Backtesting. - """ - - filters = [Trade.is_open.is_(False)] - if(pair is not None): - filters.append(Trade.pair == pair) - - enter_tag_perf = Trade.query.with_entities( - Trade.enter_tag, - func.sum(Trade.close_profit).label('profit_sum'), - func.sum(Trade.close_profit_abs).label('profit_sum_abs'), - func.count(Trade.pair).label('count') - ).filter(*filters)\ - .group_by(Trade.enter_tag) \ - .order_by(desc('profit_sum_abs')) \ - .all() - - return [ - { - 'enter_tag': enter_tag if enter_tag is not None else "Other", - 'profit_ratio': profit, - 'profit_pct': round(profit * 100, 2), - 'profit_abs': profit_abs, - 'count': count - } - for enter_tag, profit, profit_abs, count in enter_tag_perf - ] - - @staticmethod - def get_exit_reason_performance(pair: Optional[str]) -> List[Dict[str, Any]]: - """ - Returns List of dicts containing all Trades, based on exit reason performance - Can either be average for all pairs or a specific pair provided - NOTE: Not supported in Backtesting. - """ - - filters = [Trade.is_open.is_(False)] - if(pair is not None): - filters.append(Trade.pair == pair) - - sell_tag_perf = Trade.query.with_entities( - Trade.exit_reason, - func.sum(Trade.close_profit).label('profit_sum'), - func.sum(Trade.close_profit_abs).label('profit_sum_abs'), - func.count(Trade.pair).label('count') - ).filter(*filters)\ - .group_by(Trade.exit_reason) \ - .order_by(desc('profit_sum_abs')) \ - .all() - - return [ - { - 'exit_reason': exit_reason if exit_reason is not None else "Other", - 'profit_ratio': profit, - 'profit_pct': round(profit * 100, 2), - 'profit_abs': profit_abs, - 'count': count - } - for exit_reason, profit, profit_abs, count in sell_tag_perf - ] - - @staticmethod - def get_mix_tag_performance(pair: Optional[str]) -> List[Dict[str, Any]]: - """ - Returns List of dicts containing all Trades, based on entry_tag + exit_reason performance - Can either be average for all pairs or a specific pair provided - NOTE: Not supported in Backtesting. - """ - - filters = [Trade.is_open.is_(False)] - if(pair is not None): - filters.append(Trade.pair == pair) - - mix_tag_perf = Trade.query.with_entities( - Trade.id, - Trade.enter_tag, - Trade.exit_reason, - func.sum(Trade.close_profit).label('profit_sum'), - func.sum(Trade.close_profit_abs).label('profit_sum_abs'), - func.count(Trade.pair).label('count') - ).filter(*filters)\ - .group_by(Trade.id) \ - .order_by(desc('profit_sum_abs')) \ - .all() - - return_list: List[Dict] = [] - for id, enter_tag, exit_reason, profit, profit_abs, count in mix_tag_perf: - enter_tag = enter_tag if enter_tag is not None else "Other" - exit_reason = exit_reason if exit_reason is not None else "Other" - - if(exit_reason is not None and enter_tag is not None): - mix_tag = enter_tag + " " + exit_reason - i = 0 - if not any(item["mix_tag"] == mix_tag for item in return_list): - return_list.append({'mix_tag': mix_tag, - 'profit': profit, - 'profit_pct': round(profit * 100, 2), - 'profit_abs': profit_abs, - 'count': count}) - else: - while i < len(return_list): - if return_list[i]["mix_tag"] == mix_tag: - return_list[i] = { - 'mix_tag': mix_tag, - 'profit': profit + return_list[i]["profit"], - 'profit_pct': round(profit + return_list[i]["profit"] * 100, 2), - 'profit_abs': profit_abs + return_list[i]["profit_abs"], - 'count': 1 + return_list[i]["count"]} - i += 1 - - return return_list - - @staticmethod - def get_best_pair(start_date: datetime = datetime.fromtimestamp(0)): - """ - Get best pair with closed trade. - NOTE: Not supported in Backtesting. - :returns: Tuple containing (pair, profit_sum) - """ - best_pair = Trade.query.with_entities( - Trade.pair, func.sum(Trade.close_profit).label('profit_sum') - ).filter(Trade.is_open.is_(False) & (Trade.close_date >= start_date)) \ - .group_by(Trade.pair) \ - .order_by(desc('profit_sum')).first() - return best_pair - - -class PairLock(_DECL_BASE): - """ - Pair Locks database model. - """ - __tablename__ = 'pairlocks' - - id = Column(Integer, primary_key=True) - - pair = Column(String(25), nullable=False, index=True) - reason = Column(String(255), nullable=True) - # Time the pair was locked (start time) - lock_time = Column(DateTime, nullable=False) - # Time until the pair is locked (end time) - lock_end_time = Column(DateTime, nullable=False, index=True) - - active = Column(Boolean, nullable=False, default=True, index=True) - - def __repr__(self): - lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT) - lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT) - return (f'PairLock(id={self.id}, pair={self.pair}, lock_time={lock_time}, ' - f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})') - - @staticmethod - def query_pair_locks(pair: Optional[str], now: datetime) -> Query: - """ - Get all currently active locks for this pair - :param pair: Pair to check for. Returns all current locks if pair is empty - :param now: Datetime object (generated via datetime.now(timezone.utc)). - """ - filters = [PairLock.lock_end_time > now, - # Only active locks - PairLock.active.is_(True), ] - if pair: - filters.append(PairLock.pair == pair) - return PairLock.query.filter( - *filters - ) - - def to_json(self) -> Dict[str, Any]: - return { - 'id': self.id, - 'pair': self.pair, - 'lock_time': self.lock_time.strftime(DATETIME_PRINT_FORMAT), - 'lock_timestamp': int(self.lock_time.replace(tzinfo=timezone.utc).timestamp() * 1000), - 'lock_end_time': self.lock_end_time.strftime(DATETIME_PRINT_FORMAT), - 'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc - ).timestamp() * 1000), - 'reason': self.reason, - 'active': self.active, - } diff --git a/freqtrade/rpc/telegram.py.orig b/freqtrade/rpc/telegram.py.orig deleted file mode 100644 index e40c4b950..000000000 --- a/freqtrade/rpc/telegram.py.orig +++ /dev/null @@ -1,1653 +0,0 @@ -# pragma pylint: disable=unused-argument, unused-variable, protected-access, invalid-name - -""" -This module manage Telegram communication -""" -import json -import logging -import re -from datetime import date, datetime, timedelta -from functools import partial -from html import escape -from itertools import chain -from math import isnan -from typing import Any, Callable, Dict, List, Optional, Union - -import arrow -from tabulate import tabulate -from telegram import (CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, - ParseMode, ReplyKeyboardMarkup, Update) -from telegram.error import BadRequest, NetworkError, TelegramError -from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater -from telegram.utils.helpers import escape_markdown - -from freqtrade.__init__ import __version__ -from freqtrade.constants import DUST_PER_COIN -from freqtrade.enums import RPCMessageType, SignalDirection, TradingMode -from freqtrade.exceptions import OperationalException -from freqtrade.misc import chunks, plural, round_coin_value -from freqtrade.persistence import Trade -from freqtrade.rpc import RPC, RPCException, RPCHandler - - -logger = logging.getLogger(__name__) - -logger.debug('Included module rpc.telegram ...') - -MAX_TELEGRAM_MESSAGE_LENGTH = 4096 - - -def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: - """ - Decorator to check if the message comes from the correct chat_id - :param command_handler: Telegram CommandHandler - :return: decorated function - """ - - def wrapper(self, *args, **kwargs): - """ Decorator logic """ - update = kwargs.get('update') or args[0] - - # Reject unauthorized messages - if update.callback_query: - cchat_id = int(update.callback_query.message.chat.id) - else: - cchat_id = int(update.message.chat_id) - - chat_id = int(self._config['telegram']['chat_id']) - if cchat_id != chat_id: - logger.info( - 'Rejected unauthorized message from: %s', - update.message.chat_id - ) - return wrapper - # Rollback session to avoid getting data stored in a transaction. - Trade.query.session.rollback() - logger.debug( - 'Executing handler: %s for chat_id: %s', - command_handler.__name__, - chat_id - ) - try: - return command_handler(self, *args, **kwargs) - except BaseException: - logger.exception('Exception occurred within Telegram module') - - return wrapper - - -class Telegram(RPCHandler): - """ This class handles all telegram communication """ - - def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: - """ - Init the Telegram call, and init the super class RPCHandler - :param rpc: instance of RPC Helper class - :param config: Configuration object - :return: None - """ - super().__init__(rpc, config) - - self._updater: Updater - self._init_keyboard() - self._init() - - def _init_keyboard(self) -> None: - """ - Validates the keyboard configuration from telegram config - section. - """ - self._keyboard: List[List[Union[str, KeyboardButton]]] = [ - ['/daily', '/profit', '/balance'], - ['/status', '/status table', '/performance'], - ['/count', '/start', '/stop', '/help'] - ] - # do not allow commands with mandatory arguments and critical cmds - # like /forcesell and /forcebuy - # TODO: DRY! - its not good to list all valid cmds here. But otherwise - # this needs refactoring of the whole telegram module (same - # problem in _help()). - valid_keys: List[str] = [r'/start$', r'/stop$', r'/status$', r'/status table$', - r'/trades$', r'/performance$', r'/buys', r'/entries', - r'/sells', r'/exits', r'/mix_tags', - r'/daily$', r'/daily \d+$', r'/profit$', r'/profit \d+', - r'/stats$', r'/count$', r'/locks$', r'/balance$', - r'/stopbuy$', r'/reload_config$', r'/show_config$', - r'/logs$', r'/whitelist$', r'/blacklist$', r'/bl_delete$', - r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$', - r'/forcebuy$', r'/forcelong$', r'/forceshort$', - r'/edge$', r'/health$', r'/help$', r'/version$'] - # Create keys for generation - valid_keys_print = [k.replace('$', '') for k in valid_keys] - - # custom keyboard specified in config.json - cust_keyboard = self._config['telegram'].get('keyboard', []) - if cust_keyboard: - combined = "(" + ")|(".join(valid_keys) + ")" - # check for valid shortcuts - invalid_keys = [b for b in chain.from_iterable(cust_keyboard) - if not re.match(combined, b)] - if len(invalid_keys): - err_msg = ('config.telegram.keyboard: Invalid commands for ' - f'custom Telegram keyboard: {invalid_keys}' - f'\nvalid commands are: {valid_keys_print}') - raise OperationalException(err_msg) - else: - self._keyboard = cust_keyboard - logger.info('using custom keyboard from ' - f'config.json: {self._keyboard}') - - def _init(self) -> None: - """ - Initializes this module with the given config, - registers all known command handlers - and starts polling for message updates - """ - self._updater = Updater(token=self._config['telegram']['token'], workers=0, - use_context=True) - - # Register command handler and start telegram message polling - handles = [ - CommandHandler('status', self._status), - CommandHandler('profit', self._profit), - CommandHandler('balance', self._balance), - CommandHandler('start', self._start), - CommandHandler('stop', self._stop), - CommandHandler(['forcesell', 'forceexit'], self._forceexit), - CommandHandler(['forcebuy', 'forcelong'], partial( - self._forceenter, order_side=SignalDirection.LONG)), - CommandHandler('forceshort', partial( - self._forceenter, order_side=SignalDirection.SHORT)), - CommandHandler('trades', self._trades), - CommandHandler('delete', self._delete_trade), - CommandHandler('performance', self._performance), - CommandHandler(['buys', 'entries'], self._enter_tag_performance), - CommandHandler(['sells', 'exits'], self._exit_reason_performance), - CommandHandler('mix_tags', self._mix_tag_performance), - CommandHandler('stats', self._stats), - CommandHandler('daily', self._daily), - CommandHandler('weekly', self._weekly), - CommandHandler('monthly', self._monthly), - CommandHandler('count', self._count), - CommandHandler('locks', self._locks), - CommandHandler(['unlock', 'delete_locks'], self._delete_locks), - CommandHandler(['reload_config', 'reload_conf'], self._reload_config), - CommandHandler(['show_config', 'show_conf'], self._show_config), - CommandHandler('stopbuy', self._stopbuy), - CommandHandler('whitelist', self._whitelist), - CommandHandler('blacklist', self._blacklist), - CommandHandler(['blacklist_delete', 'bl_delete'], self._blacklist_delete), - CommandHandler('logs', self._logs), - CommandHandler('edge', self._edge), - CommandHandler('health', self._health), - CommandHandler('help', self._help), - CommandHandler('version', self._version), - ] - callbacks = [ - CallbackQueryHandler(self._status_table, pattern='update_status_table'), - CallbackQueryHandler(self._daily, pattern='update_daily'), - CallbackQueryHandler(self._weekly, pattern='update_weekly'), - CallbackQueryHandler(self._monthly, pattern='update_monthly'), - CallbackQueryHandler(self._profit, pattern='update_profit'), - CallbackQueryHandler(self._balance, pattern='update_balance'), - CallbackQueryHandler(self._performance, pattern='update_performance'), - CallbackQueryHandler(self._enter_tag_performance, - pattern='update_enter_tag_performance'), - CallbackQueryHandler(self._exit_reason_performance, - pattern='update_exit_reason_performance'), - CallbackQueryHandler(self._mix_tag_performance, pattern='update_mix_tag_performance'), - CallbackQueryHandler(self._count, pattern='update_count'), - CallbackQueryHandler(self._forceenter_inline), - ] - for handle in handles: - self._updater.dispatcher.add_handler(handle) - - for callback in callbacks: - self._updater.dispatcher.add_handler(callback) - - self._updater.start_polling( - bootstrap_retries=-1, - timeout=20, - read_latency=60, # Assumed transmission latency - drop_pending_updates=True, - ) - logger.info( - 'rpc.telegram is listening for following commands: %s', - [h.command for h in handles] - ) - - def cleanup(self) -> None: - """ - Stops all running telegram threads. - :return: None - """ - # This can take up to `timeout` from the call to `start_polling`. - self._updater.stop() - - def _format_buy_msg(self, msg: Dict[str, Any]) -> str: - if self._rpc._fiat_converter: - msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount( - msg['stake_amount'], msg['stake_currency'], msg['fiat_currency']) - else: - msg['stake_amount_fiat'] = 0 - is_fill = msg['type'] in [RPCMessageType.BUY_FILL, RPCMessageType.SHORT_FILL] - emoji = '\N{CHECK MARK}' if is_fill else '\N{LARGE BLUE CIRCLE}' - - enter_side = ({'enter': 'Long', 'entered': 'Longed'} if msg['type'] - in [RPCMessageType.BUY_FILL, RPCMessageType.BUY] - else {'enter': 'Short', 'entered': 'Shorted'}) - message = ( - f"{emoji} *{msg['exchange']}:*" - f" {enter_side['entered'] if is_fill else enter_side['enter']} {msg['pair']}" - f" (#{msg['trade_id']})\n" -<<<<<<< HEAD - ) - message += f"*Buy Tag:* `{msg['buy_tag']}`\n" if msg.get('buy_tag', None) else "" -======= - ) - message += f"*Enter Tag:* `{msg['enter_tag']}`\n" if msg.get('enter_tag', None) else "" ->>>>>>> develop - message += f"*Amount:* `{msg['amount']:.8f}`\n" - if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0: - message += f"*Leverage:* `{msg['leverage']}`\n" - - if msg['type'] in [RPCMessageType.BUY_FILL, RPCMessageType.SHORT_FILL]: - message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n" -<<<<<<< HEAD - total = msg['amount'] * msg['open_rate'] - - elif msg['type'] == RPCMessageType.BUY: -======= - elif msg['type'] in [RPCMessageType.BUY, RPCMessageType.SHORT]: ->>>>>>> develop - message += f"*Open Rate:* `{msg['limit']:.8f}`\n"\ - f"*Current Rate:* `{msg['current_rate']:.8f}`\n" - total = msg['amount'] * msg['limit'] - if self._rpc._fiat_converter: - total_fiat = self._rpc._fiat_converter.convert_amount( - total, msg['stake_currency'], msg['fiat_currency']) - else: - total_fiat = 0 - message += f"*Total:* `({round_coin_value(total, msg['stake_currency'])}" - - if msg.get('fiat_currency', None): - message += f", {round_coin_value(total_fiat, msg['fiat_currency'])}" - - message += ")`" - if msg.get('sub_trade'): - bal = round_coin_value(msg['stake_amount'], msg['stake_currency']) - message += f"\n*Balance:* `({bal}" - - if msg.get('fiat_currency', None): - message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" - - message += ")`" - return message - - def _format_sell_msg(self, msg: Dict[str, Any]) -> str: - msg['amount'] = round(msg['amount'], 8) - msg['profit_percent'] = round(msg['profit_ratio'] * 100, 2) - msg['duration'] = msg['close_date'].replace( - microsecond=0) - msg['open_date'].replace(microsecond=0) - msg['duration_min'] = msg['duration'].total_seconds() / 60 - - msg['enter_tag'] = msg['enter_tag'] if "enter_tag" in msg.keys() else None - msg['emoji'] = self._get_sell_emoji(msg) - msg['leverage_text'] = (f"*Leverage:* `{msg['leverage']:.1f}`\n" - if msg.get('leverage', None) and msg.get('leverage', 1.0) != 1.0 - else "") - - # Check if all sell properties are available. - # This might not be the case if the message origin is triggered by /forcesell - if (all(prop in msg for prop in ['gain', 'fiat_currency', 'stake_currency']) - and self._rpc._fiat_converter): - msg['profit_fiat'] = self._rpc._fiat_converter.convert_amount( - msg['profit_amount'], msg['stake_currency'], msg['fiat_currency']) - msg['profit_extra'] = ( - f" / {msg['profit_fiat']:.3f} {msg['fiat_currency']}") - else: - msg['profit_extra'] = '' - msg['profit_extra'] = ( - f" ({msg['gain']}: {msg['profit_amount']:.8f} {msg['stake_currency']}" - f"{msg['profit_extra']})") - is_fill = msg['type'] == RPCMessageType.SELL_FILL - is_sub_trade = msg.get('sub_trade') - is_sub_profit = msg['profit_amount'] != msg.get('cumulative_profit') - profit_prefix = ('Sub ' if is_sub_profit - else 'Cumulative ') if is_sub_trade else '' - if is_sub_profit and is_sub_trade: - if self._rpc._fiat_converter: - cp_fiat = self._rpc._fiat_converter.convert_amount( - msg['cumulative_profit'], msg['stake_currency'], msg['fiat_currency']) - cp_extra = f" / {cp_fiat:.3f} {msg['fiat_currency']}" - else: - cp_extra = '' - cp_extra = f"*Cumulative Profit:* (`{msg['cumulative_profit']:.8f} " \ - f"{msg['stake_currency']}{cp_extra}`)\n" - else: - cp_extra = '' - message = ( - f"{msg['emoji']} *{msg['exchange']}:* " -<<<<<<< HEAD - f"{'Sold' if is_fill else 'Selling'} {msg['pair']} (#{msg['trade_id']})\n" - f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* " - f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n" - f"{cp_extra}" - f"*Buy Tag:* `{msg['buy_tag']}`\n" - f"*Sell Reason:* `{msg['sell_reason']}`\n" -======= - f"{'Exited' if is_fill else 'Exiting'} {msg['pair']} (#{msg['trade_id']})\n" - f"*{'Profit' if is_fill else 'Unrealized Profit'}:* " - f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n" - f"*Enter Tag:* `{msg['enter_tag']}`\n" - f"*Exit Reason:* `{msg['exit_reason']}`\n" - f"*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`\n" - f"*Direction:* `{msg['direction']}`\n" - f"{msg['leverage_text']}" ->>>>>>> develop - f"*Amount:* `{msg['amount']:.8f}`\n" - f"*Open Rate:* `{msg['open_rate']:.8f}`\n" - ) - if msg['type'] == RPCMessageType.SELL: - message += (f"*Current Rate:* `{msg['current_rate']:.8f}`\n" - f"*Close Rate:* `{msg['limit']:.8f}`") - - elif msg['type'] == RPCMessageType.SELL_FILL: - message += f"*Close Rate:* `{msg['close_rate']:.8f}`" - if msg.get('sub_trade'): - if self._rpc._fiat_converter: - msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount( - msg['stake_amount'], msg['stake_currency'], msg['fiat_currency']) - else: - msg['stake_amount_fiat'] = 0 - rem = round_coin_value(msg['stake_amount'], msg['stake_currency']) - message += f"\n*Remaining:* `({rem}" - - if msg.get('fiat_currency', None): - message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" - - message += ")`" - else: - message += f"\n*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`" - return message - - def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> str: - if msg_type in [RPCMessageType.BUY, RPCMessageType.BUY_FILL, RPCMessageType.SHORT, - RPCMessageType.SHORT_FILL]: - message = self._format_buy_msg(msg) - - elif msg_type in [RPCMessageType.SELL, RPCMessageType.SELL_FILL]: - message = self._format_sell_msg(msg) - - elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SHORT_CANCEL, - RPCMessageType.SELL_CANCEL): - msg['message_side'] = 'enter' if msg_type in [RPCMessageType.BUY_CANCEL, - RPCMessageType.SHORT_CANCEL] else 'exit' - message = ("\N{WARNING SIGN} *{exchange}:* " - "Cancelling {message_side} Order for {pair} (#{trade_id}). " - "Reason: {reason}.".format(**msg)) - - elif msg_type == RPCMessageType.PROTECTION_TRIGGER: - message = ( - "*Protection* triggered due to {reason}. " - "`{pair}` will be locked until `{lock_end_time}`." - ).format(**msg) - - elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL: - message = ( - "*Protection* triggered due to {reason}. " - "*All pairs* will be locked until `{lock_end_time}`." - ).format(**msg) - - elif msg_type == RPCMessageType.STATUS: - message = '*Status:* `{status}`'.format(**msg) - - elif msg_type == RPCMessageType.WARNING: - message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg) - - elif msg_type == RPCMessageType.STARTUP: - message = '{status}'.format(**msg) - - else: - raise NotImplementedError('Unknown message type: {}'.format(msg_type)) - return message - - def send_msg(self, msg: Dict[str, Any]) -> None: - """ Send a message to telegram channel """ - - default_noti = 'on' - - msg_type = msg['type'] - noti = '' - if msg_type == RPCMessageType.SELL: - sell_noti = self._config['telegram'] \ - .get('notification_settings', {}).get(str(msg_type), {}) - # For backward compatibility sell still can be string - if isinstance(sell_noti, str): - noti = sell_noti - else: - noti = sell_noti.get(str(msg['exit_reason']), default_noti) - else: - noti = self._config['telegram'] \ - .get('notification_settings', {}).get(str(msg_type), default_noti) - - if noti == 'off': - logger.info(f"Notification '{msg_type}' not sent.") - # Notification disabled - return - - message = self.compose_message(msg, msg_type) - - self._send_msg(message, disable_notification=(noti == 'silent')) - - def _get_sell_emoji(self, msg): - """ - Get emoji for sell-side - """ - - if float(msg['profit_percent']) >= 5.0: - return "\N{ROCKET}" - elif float(msg['profit_percent']) >= 0.0: - return "\N{EIGHT SPOKED ASTERISK}" - elif msg['exit_reason'] == "stop_loss": - return "\N{WARNING SIGN}" - else: - return "\N{CROSS MARK}" - - def _prepare_entry_details(self, filled_orders: List, base_currency: str, is_open: bool): - """ - Prepare details of trade with entry adjustment enabled - """ - lines: List[str] = [] - if len(filled_orders) > 0: - first_avg = filled_orders[0]["safe_price"] - - for x, order in enumerate(filled_orders): - if not order['ft_is_entry']: - continue - cur_entry_datetime = arrow.get(order["order_filled_date"]) - cur_entry_amount = order["filled"] or order["amount"] - cur_entry_average = order["safe_price"] - lines.append(" ") - if x == 0: - lines.append(f"*Entry #{x+1}:*") - lines.append( - f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})") - lines.append(f"*Average Entry Price:* {cur_entry_average}") - else: - sumA = 0 - sumB = 0 - for y in range(x): - amount = filled_orders[y]["filled"] or filled_orders[y]["amount"] - sumA += amount * filled_orders[y]["safe_price"] - sumB += amount - prev_avg_price = sumA / sumB - price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg) - minus_on_entry = 0 - if prev_avg_price: - minus_on_entry = (cur_entry_average - prev_avg_price) / prev_avg_price - - dur_entry = cur_entry_datetime - arrow.get(filled_orders[x-1]["order_filled_date"]) - days = dur_entry.days - hours, remainder = divmod(dur_entry.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - lines.append(f"*Entry #{x+1}:* at {minus_on_entry:.2%} avg profit") - if is_open: - lines.append("({})".format(cur_entry_datetime - .humanize(granularity=["day", "hour", "minute"]))) - lines.append( - f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})") - lines.append(f"*Average Entry Price:* {cur_entry_average} " - f"({price_to_1st_entry:.2%} from 1st entry rate)") - lines.append(f"*Order filled at:* {order['order_filled_date']}") - lines.append(f"({days}d {hours}h {minutes}m {seconds}s from previous entry)") - return lines - - @authorized_only - def _status(self, update: Update, context: CallbackContext) -> None: # noqa: max-complexity: 13 - """ - Handler for /status. - Returns the current TradeThread status - :param bot: telegram bot - :param update: message update - :return: None - """ - - if context.args and 'table' in context.args: - self._status_table(update, context) - return - - try: - - # Check if there's at least one numerical ID provided. - # If so, try to get only these trades. - trade_ids = [] - if context.args and len(context.args) > 0: - trade_ids = [int(i) for i in context.args if i.isnumeric()] - - results = self._rpc._rpc_trade_status(trade_ids=trade_ids) - position_adjust = self._config.get('position_adjustment_enable', False) - max_entries = self._config.get('max_entry_position_adjustment', -1) - messages = [] - for r in results: - r['open_date_hum'] = arrow.get(r['open_date']).humanize() - r['num_entries'] = len([o for o in r['orders'] if o['ft_is_entry']]) - r['exit_reason'] = r.get('exit_reason', "") - lines = [ - "*Trade ID:* `{trade_id}`" + - ("` (since {open_date_hum})`" if r['is_open'] else ""), - "*Current Pair:* {pair}", - "*Direction:* " + ("`Short`" if r.get('is_short') else "`Long`"), - "*Leverage:* `{leverage}`" if r.get('leverage') else "", - "*Amount:* `{amount} ({stake_amount} {base_currency})`", - "*Enter Tag:* `{enter_tag}`" if r['enter_tag'] else "", - "*Exit Reason:* `{exit_reason}`" if r['exit_reason'] else "", - ] - - if position_adjust: - max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "") - lines.append("*Number of Entries:* `{num_entries}`" + max_buy_str) - - lines.extend([ - "*Open Rate:* `{open_rate:.8f}`", - "*Close Rate:* `{close_rate:.8f}`" if r['close_rate'] else "", - "*Open Date:* `{open_date}`", - "*Close Date:* `{close_date}`" if r['close_date'] else "", - "*Current Rate:* `{current_rate:.8f}`" if r['is_open'] else "", - ("*Current Profit:* " if r['is_open'] else "*Close Profit: *") - + "`{profit_ratio:.2%}`", - ]) - - if r['is_open']: - if r.get('realized_profit'): - lines.append("*Realized Profit:* `{realized_profit:.8f}`") - if (r['stop_loss_abs'] != r['initial_stop_loss_abs'] - and r['initial_stop_loss_ratio'] is not None): - # Adding initial stoploss only if it is different from stoploss - lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` " - "`({initial_stop_loss_ratio:.2%})`") - - # Adding stoploss and stoploss percentage only if it is not None - lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " + - ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else "")) - lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` " - "`({stoploss_current_dist_ratio:.2%})`") - if r['open_order']: - if r['exit_order_status']: - lines.append("*Open Order:* `{open_order}` - `{exit_order_status}`") - else: - lines.append("*Open Order:* `{open_order}`") - - lines_detail = self._prepare_entry_details( - r['orders'], r['base_currency'], r['is_open']) - lines.extend(lines_detail if lines_detail else "") - - # Filter empty lines using list-comprehension - messages.append("\n".join([line for line in lines if line]).format(**r)) - - for msg in messages: - self._send_msg(msg) - - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _status_table(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /status table. - Returns the current TradeThread status in table format - :param bot: telegram bot - :param update: message update - :return: None - """ - try: - fiat_currency = self._config.get('fiat_display_currency', '') - statlist, head, fiat_profit_sum = self._rpc._rpc_status_table( - self._config['stake_currency'], fiat_currency) - - show_total = not isnan(fiat_profit_sum) and len(statlist) > 1 - max_trades_per_msg = 50 - """ - Calculate the number of messages of 50 trades per message - 0.99 is used to make sure that there are no extra (empty) messages - As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message - """ - messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1) - for i in range(0, messages_count): - trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg] - if show_total and i == messages_count - 1: - # append total line - trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"]) - - message = tabulate(trades, - headers=head, - tablefmt='simple') - if show_total and i == messages_count - 1: - # insert separators line between Total - lines = message.split("\n") - message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]]) - self._send_msg(f"
{message}", parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_status_table", - query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _daily(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /daily
{stats_tab}' - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path="update_daily", query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _weekly(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /weekly
{stats_tab}' - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path="update_weekly", query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _monthly(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /monthly
{stats_tab}' - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path="update_monthly", query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _profit(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /profit. - Returns a cumulative profit statistics. - :param bot: telegram bot - :param update: message update - :return: None - """ - stake_cur = self._config['stake_currency'] - fiat_disp_cur = self._config.get('fiat_display_currency', '') - - start_date = datetime.fromtimestamp(0) - timescale = None - try: - if context.args: - timescale = int(context.args[0]) - 1 - today_start = datetime.combine(date.today(), datetime.min.time()) - start_date = today_start - timedelta(days=timescale) - except (TypeError, ValueError, IndexError): - pass - - stats = self._rpc._rpc_trade_statistics( - stake_cur, - fiat_disp_cur, - start_date) - profit_closed_coin = stats['profit_closed_coin'] - profit_closed_ratio_mean = stats['profit_closed_ratio_mean'] - profit_closed_percent = stats['profit_closed_percent'] - profit_closed_fiat = stats['profit_closed_fiat'] - profit_all_coin = stats['profit_all_coin'] - profit_all_ratio_mean = stats['profit_all_ratio_mean'] - profit_all_percent = stats['profit_all_percent'] - profit_all_fiat = stats['profit_all_fiat'] - trade_count = stats['trade_count'] - first_trade_date = stats['first_trade_date'] - latest_trade_date = stats['latest_trade_date'] - avg_duration = stats['avg_duration'] - best_pair = stats['best_pair'] - best_pair_profit_ratio = stats['best_pair_profit_ratio'] - if stats['trade_count'] == 0: - markdown_msg = 'No trades yet.' - else: - # Message to display - if stats['closed_trade_count'] > 0: - markdown_msg = ("*ROI:* Closed trades\n" - f"∙ `{round_coin_value(profit_closed_coin, stake_cur)} " - f"({profit_closed_ratio_mean:.2%}) " - f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" - f"∙ `{round_coin_value(profit_closed_fiat, fiat_disp_cur)}`\n") - else: - markdown_msg = "`No closed trade` \n" - - markdown_msg += ( - f"*ROI:* All trades\n" - f"∙ `{round_coin_value(profit_all_coin, stake_cur)} " - f"({profit_all_ratio_mean:.2%}) " - f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" - f"∙ `{round_coin_value(profit_all_fiat, fiat_disp_cur)}`\n" - f"*Total Trade Count:* `{trade_count}`\n" - f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " - f"`{first_trade_date}`\n" - f"*Latest Trade opened:* `{latest_trade_date}\n`" - f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`" - ) - if stats['closed_trade_count'] > 0: - markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n" - f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`") - self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit", - query=update.callback_query) - - @authorized_only - def _stats(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /stats - Show stats of recent trades - """ - stats = self._rpc._rpc_stats() - - reason_map = { - 'roi': 'ROI', - 'stop_loss': 'Stoploss', - 'trailing_stop_loss': 'Trail. Stop', - 'stoploss_on_exchange': 'Stoploss', - 'sell_signal': 'Sell Signal', - 'force_sell': 'Forcesell', - 'emergency_sell': 'Emergency Sell', - } - exit_reasons_tabulate = [ - [ - reason_map.get(reason, reason), - sum(count.values()), - count['wins'], - count['losses'] - ] for reason, count in stats['exit_reasons'].items() - ] - exit_reasons_msg = 'No trades yet.' - for reason in chunks(exit_reasons_tabulate, 25): - exit_reasons_msg = tabulate( - reason, - headers=['Exit Reason', 'Exits', 'Wins', 'Losses'] - ) - if len(exit_reasons_tabulate) > 25: - self._send_msg(exit_reasons_msg, ParseMode.MARKDOWN) - exit_reasons_msg = '' - - durations = stats['durations'] - duration_msg = tabulate( - [ - ['Wins', str(timedelta(seconds=durations['wins'])) - if durations['wins'] is not None else 'N/A'], - ['Losses', str(timedelta(seconds=durations['losses'])) - if durations['losses'] is not None else 'N/A'] - ], - headers=['', 'Avg. Duration'] - ) - msg = (f"""```\n{exit_reasons_msg}```\n```\n{duration_msg}```""") - - self._send_msg(msg, ParseMode.MARKDOWN) - - @authorized_only - def _balance(self, update: Update, context: CallbackContext) -> None: - """ Handler for /balance """ - try: - result = self._rpc._rpc_balance(self._config['stake_currency'], - self._config.get('fiat_display_currency', '')) - - balance_dust_level = self._config['telegram'].get('balance_dust_level', 0.0) - if not balance_dust_level: - balance_dust_level = DUST_PER_COIN.get(self._config['stake_currency'], 1.0) - - output = '' - if self._config['dry_run']: - output += "*Warning:* Simulated balances in Dry Mode.\n" - starting_cap = round_coin_value( - result['starting_capital'], self._config['stake_currency']) - output += f"Starting capital: `{starting_cap}`" - starting_cap_fiat = round_coin_value( - result['starting_capital_fiat'], self._config['fiat_display_currency'] - ) if result['starting_capital_fiat'] > 0 else '' - output += (f" `, {starting_cap_fiat}`.\n" - ) if result['starting_capital_fiat'] > 0 else '.\n' - - total_dust_balance = 0 - total_dust_currencies = 0 - for curr in result['currencies']: - curr_output = '' - if curr['est_stake'] > balance_dust_level: - if curr['is_position']: - curr_output = ( - f"*{curr['currency']}:*\n" - f"\t`{curr['side']}: {curr['position']:.8f}`\n" - f"\t`Leverage: {curr['leverage']:.1f}`\n" - f"\t`Est. {curr['stake']}: " - f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") - else: - curr_output = ( - f"*{curr['currency']}:*\n" - f"\t`Available: {curr['free']:.8f}`\n" - f"\t`Balance: {curr['balance']:.8f}`\n" - f"\t`Pending: {curr['used']:.8f}`\n" - f"\t`Est. {curr['stake']}: " - f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") - elif curr['est_stake'] <= balance_dust_level: - total_dust_balance += curr['est_stake'] - total_dust_currencies += 1 - - # Handle overflowing message length - if len(output + curr_output) >= MAX_TELEGRAM_MESSAGE_LENGTH: - self._send_msg(output) - output = curr_output - else: - output += curr_output - - if total_dust_balance > 0: - output += ( - f"*{total_dust_currencies} Other " - f"{plural(total_dust_currencies, 'Currency', 'Currencies')} " - f"(< {balance_dust_level} {result['stake']}):*\n" - f"\t`Est. {result['stake']}: " - f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n") - tc = result['trade_count'] > 0 - stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else '' - fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else '' - - output += ("\n*Estimated Value*:\n" - f"\t`{result['stake']}: " - f"{round_coin_value(result['total'], result['stake'], False)}`" - f"{stake_improve}\n" - f"\t`{result['symbol']}: " - f"{round_coin_value(result['value'], result['symbol'], False)}`" - f"{fiat_val}\n") - self._send_msg(output, reload_able=True, callback_path="update_balance", - query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _start(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /start. - Starts TradeThread - :param bot: telegram bot - :param update: message update - :return: None - """ - msg = self._rpc._rpc_start() - self._send_msg('Status: `{status}`'.format(**msg)) - - @authorized_only - def _stop(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /stop. - Stops TradeThread - :param bot: telegram bot - :param update: message update - :return: None - """ - msg = self._rpc._rpc_stop() - self._send_msg('Status: `{status}`'.format(**msg)) - - @authorized_only - def _reload_config(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /reload_config. - Triggers a config file reload - :param bot: telegram bot - :param update: message update - :return: None - """ - msg = self._rpc._rpc_reload_config() - self._send_msg('Status: `{status}`'.format(**msg)) - - @authorized_only - def _stopbuy(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /stop_buy. - Sets max_open_trades to 0 and gracefully sells all open trades - :param bot: telegram bot - :param update: message update - :return: None - """ - msg = self._rpc._rpc_stopbuy() - self._send_msg('Status: `{status}`'.format(**msg)) - - @authorized_only - def _forceexit(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /forcesell
{trades_tab}" if trades['trades_count'] > 0 else '')) - self._send_msg(message, parse_mode=ParseMode.HTML) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _delete_trade(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /delete
{trade['pair']}\t"
- f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
- f"({trade['profit_ratio']:.2%}) "
- f"({trade['count']})
\n")
-
- if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
- self._send_msg(output, parse_mode=ParseMode.HTML)
- output = stat_line
- else:
- output += stat_line
-
- self._send_msg(output, parse_mode=ParseMode.HTML,
- reload_able=True, callback_path="update_performance",
- query=update.callback_query)
- except RPCException as e:
- self._send_msg(str(e))
-
- @authorized_only
- def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None:
- """
- Handler for /buys PAIR .
- Shows a performance statistic from finished trades
- :param bot: telegram bot
- :param update: message update
- :return: None
- """
- try:
- pair = None
- if context.args and isinstance(context.args[0], str):
- pair = context.args[0]
-
- trades = self._rpc._rpc_enter_tag_performance(pair)
- output = "Entry Tag Performance:\n"
- for i, trade in enumerate(trades):
- stat_line = (
- f"{i+1}.\t {trade['enter_tag']}\t"
- f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
- f"({trade['profit_ratio']:.2%}) "
- f"({trade['count']})
\n")
-
- if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
- self._send_msg(output, parse_mode=ParseMode.HTML)
- output = stat_line
- else:
- output += stat_line
-
- self._send_msg(output, parse_mode=ParseMode.HTML,
- reload_able=True, callback_path="update_enter_tag_performance",
- query=update.callback_query)
- except RPCException as e:
- self._send_msg(str(e))
-
- @authorized_only
- def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None:
- """
- Handler for /sells.
- Shows a performance statistic from finished trades
- :param bot: telegram bot
- :param update: message update
- :return: None
- """
- try:
- pair = None
- if context.args and isinstance(context.args[0], str):
- pair = context.args[0]
-
- trades = self._rpc._rpc_exit_reason_performance(pair)
- output = "Exit Reason Performance:\n"
- for i, trade in enumerate(trades):
- stat_line = (
- f"{i+1}.\t {trade['exit_reason']}\t"
- f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
- f"({trade['profit_ratio']:.2%}) "
- f"({trade['count']})
\n")
-
- if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
- self._send_msg(output, parse_mode=ParseMode.HTML)
- output = stat_line
- else:
- output += stat_line
-
- self._send_msg(output, parse_mode=ParseMode.HTML,
- reload_able=True, callback_path="update_exit_reason_performance",
- query=update.callback_query)
- except RPCException as e:
- self._send_msg(str(e))
-
- @authorized_only
- def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None:
- """
- Handler for /mix_tags.
- Shows a performance statistic from finished trades
- :param bot: telegram bot
- :param update: message update
- :return: None
- """
- try:
- pair = None
- if context.args and isinstance(context.args[0], str):
- pair = context.args[0]
-
- trades = self._rpc._rpc_mix_tag_performance(pair)
- output = "Mix Tag Performance:\n"
- for i, trade in enumerate(trades):
- stat_line = (
- f"{i+1}.\t {trade['mix_tag']}\t"
- f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
- f"({trade['profit']:.2%}) "
- f"({trade['count']})
\n")
-
- if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
- self._send_msg(output, parse_mode=ParseMode.HTML)
- output = stat_line
- else:
- output += stat_line
-
- self._send_msg(output, parse_mode=ParseMode.HTML,
- reload_able=True, callback_path="update_mix_tag_performance",
- query=update.callback_query)
- except RPCException as e:
- self._send_msg(str(e))
-
- @authorized_only
- def _count(self, update: Update, context: CallbackContext) -> None:
- """
- Handler for /count.
- Returns the number of trades running
- :param bot: telegram bot
- :param update: message update
- :return: None
- """
- try:
- counts = self._rpc._rpc_count()
- message = tabulate({k: [v] for k, v in counts.items()},
- headers=['current', 'max', 'total stake'],
- tablefmt='simple')
- message = "{}".format(message) - logger.debug(message) - self._send_msg(message, parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_count", - query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _locks(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /locks. - Returns the currently active locks - """ - rpc_locks = self._rpc._rpc_locks() - if not rpc_locks['locks']: - self._send_msg('No active locks.', parse_mode=ParseMode.HTML) - - for locks in chunks(rpc_locks['locks'], 25): - message = tabulate([[ - lock['id'], - lock['pair'], - lock['lock_end_time'], - lock['reason']] for lock in locks], - headers=['ID', 'Pair', 'Until', 'Reason'], - tablefmt='simple') - message = f"
{escape(message)}" - logger.debug(message) - self._send_msg(message, parse_mode=ParseMode.HTML) - - @authorized_only - def _delete_locks(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /delete_locks. - Returns the currently active locks - """ - arg = context.args[0] if context.args and len(context.args) > 0 else None - lockid = None - pair = None - if arg: - try: - lockid = int(arg) - except ValueError: - pair = arg - - self._rpc._rpc_delete_lock(lockid=lockid, pair=pair) - self._locks(update, context) - - @authorized_only - def _whitelist(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /whitelist - Shows the currently active whitelist - """ - try: - whitelist = self._rpc._rpc_whitelist() - - message = f"Using whitelist `{whitelist['method']}` with {whitelist['length']} pairs\n" - message += f"`{', '.join(whitelist['whitelist'])}`" - - logger.debug(message) - self._send_msg(message) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _blacklist(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /blacklist - Shows the currently active blacklist - """ - self.send_blacklist_msg(self._rpc._rpc_blacklist(context.args)) - - def send_blacklist_msg(self, blacklist: Dict): - errmsgs = [] - for pair, error in blacklist['errors'].items(): - errmsgs.append(f"Error adding `{pair}` to blacklist: `{error['error_msg']}`") - if errmsgs: - self._send_msg('\n'.join(errmsgs)) - - message = f"Blacklist contains {blacklist['length']} pairs\n" - message += f"`{', '.join(blacklist['blacklist'])}`" - - logger.debug(message) - self._send_msg(message) - - @authorized_only - def _blacklist_delete(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /bl_delete - Deletes pair(s) from current blacklist - """ - self.send_blacklist_msg(self._rpc._rpc_blacklist_delete(context.args or [])) - - @authorized_only - def _logs(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /logs - Shows the latest logs - """ - try: - try: - limit = int(context.args[0]) if context.args else 10 - except (TypeError, ValueError, IndexError): - limit = 10 - logs = RPC._rpc_get_logs(limit)['logs'] - msgs = '' - msg_template = "*{}* {}: {} \\- `{}`" - for logrec in logs: - msg = msg_template.format(escape_markdown(logrec[0], version=2), - escape_markdown(logrec[2], version=2), - escape_markdown(logrec[3], version=2), - escape_markdown(logrec[4], version=2)) - if len(msgs + msg) + 10 >= MAX_TELEGRAM_MESSAGE_LENGTH: - # Send message immediately if it would become too long - self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2) - msgs = msg + '\n' - else: - # Append message to messages to send - msgs += msg + '\n' - - if msgs: - self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2) - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _edge(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /edge - Shows information related to Edge - """ - try: - edge_pairs = self._rpc._rpc_edge() - if not edge_pairs: - message = 'Edge only validated following pairs:' - self._send_msg(message, parse_mode=ParseMode.HTML) - - for chunk in chunks(edge_pairs, 25): - edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple') - message = (f'Edge only validated following pairs:\n' - f'
{edge_pairs_tab}') - - self._send_msg(message, parse_mode=ParseMode.HTML) - - except RPCException as e: - self._send_msg(str(e)) - - @authorized_only - def _help(self, update: Update, context: CallbackContext) -> None: - """ - Handler for /help. - Show commands of the bot - :param bot: telegram bot - :param update: message update - :return: None - """ - forceenter_text = ("*/forcelong