diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1aa00f07..842c87976 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,10 +16,10 @@ repos: additional_dependencies: - types-cachetools==5.3.0.7 - types-filelock==3.2.7 - - types-requests==2.31.0.20240125 + - types-requests==2.31.0.20240218 - types-tabulate==0.9.0.20240106 - types-python-dateutil==2.8.19.20240106 - - SQLAlchemy==2.0.26 + - SQLAlchemy==2.0.27 # stages: [push] - repo: https://github.com/pycqa/isort @@ -31,7 +31,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: 'v0.2.1' + rev: 'v0.2.2' hooks: - id: ruff diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 9781edf10..d1dd2cda7 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -68,7 +68,7 @@ When used in the leading position of the chain of Pairlist Handlers, the `pair_w The `refresh_period` setting allows to define the period (in seconds), at which the pairlist will be refreshed. Defaults to 1800s (30 minutes). The pairlist cache (`refresh_period`) on `VolumePairList` is only applicable to generating pairlists. -Filtering instances (not the first position in the list) will not apply any cache and will always use up-to-date data. +Filtering instances (not the first position in the list) will not apply any cache (beyond caching candles for the duration of the candle in advanced mode) and will always use up-to-date data. `VolumePairList` is per default based on the ticker data from exchange, as reported by the ccxt library: @@ -201,7 +201,7 @@ The RemotePairList is defined in the pairlists section of the configuration sett The optional `mode` option specifies if the pairlist should be used as a `blacklist` or as a `whitelist`. The default value is "whitelist". -The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append". +The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append". The default value is "filter". In "filter" mode, the retrieved pairlist is used as a filter. Only the pairs present in both the original pairlist and the retrieved pairlist are included in the final pairlist. Other pairs are filtered out. diff --git a/docs/sql_cheatsheet.md b/docs/sql_cheatsheet.md index 67c081d4c..a0c5c8da1 100644 --- a/docs/sql_cheatsheet.md +++ b/docs/sql_cheatsheet.md @@ -109,7 +109,7 @@ Freqtrade does not depend or install any additional database driver. Please refe The following systems have been tested and are known to work with freqtrade: * sqlite (default) -* PostgreSQL) +* PostgreSQL * MariaDB !!! Warning diff --git a/freqtrade/data/converter/trade_converter_kraken.py b/freqtrade/data/converter/trade_converter_kraken.py index 614d97b2a..b0fa11c25 100644 --- a/freqtrade/data/converter/trade_converter_kraken.py +++ b/freqtrade/data/converter/trade_converter_kraken.py @@ -48,11 +48,13 @@ def import_kraken_trades_from_csv(config: Config, convert_to: str): logger.info(f"Converting pairs: {', '.join(m[0] for m in markets)}.") for pair, name in markets: + logger.debug(f"Converting pair {pair}, files */{name}.csv") dfs = [] # Load and combine all csv files for this pair for f in tradesdir.rglob(f"{name}.csv"): df = pd.read_csv(f, names=KRAKEN_CSV_TRADE_COLUMNS) - dfs.append(df) + if not df.empty: + dfs.append(df) # Load existing trades data if not dfs: diff --git a/freqtrade/data/metrics.py b/freqtrade/data/metrics.py index 7b45342bb..738129939 100644 --- a/freqtrade/data/metrics.py +++ b/freqtrade/data/metrics.py @@ -143,8 +143,10 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date' starting_balance=starting_balance ) - idxmin = max_drawdown_df['drawdown_relative'].idxmax() if relative \ - else max_drawdown_df['drawdown'].idxmin() + idxmin = ( + max_drawdown_df['drawdown_relative'].idxmax() + if relative else max_drawdown_df['drawdown'].idxmin() + ) if idxmin == 0: raise ValueError("No losing trade, therefore no drawdown.") high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col] @@ -191,6 +193,9 @@ def calculate_cagr(days_passed: int, starting_balance: float, final_balance: flo :param final_balance: Final balance to calculate CAGR against :return: CAGR """ + if final_balance < 0: + # With leveraged trades, final_balance can become negative. + return 0 return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1 diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index e7c463140..63047066a 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -25,6 +25,7 @@ class Bybit(Exchange): officially supported by the Freqtrade development team. So some features may still not work as expected. """ + unified_account = False _ft_has: Dict = { "ohlcv_candle_limit": 1000, @@ -82,9 +83,20 @@ class Bybit(Exchange): Must be overridden in child methods if required. """ try: - if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']: - position_mode = self._api.set_position_mode(False) - self._log_exchange_response('set_position_mode', position_mode) + if not self._config['dry_run']: + if self.trading_mode == TradingMode.FUTURES: + position_mode = self._api.set_position_mode(False) + self._log_exchange_response('set_position_mode', position_mode) + is_unified = self._api.is_unified_enabled() + # Returns a tuple of bools, first for margin, second for Account + if is_unified and len(is_unified) > 1 and is_unified[1]: + self.unified_account = True + logger.info("Bybit: Unified account.") + raise OperationalException("Bybit: Unified account is not supported. " + "Please use a standard (sub)account.") + else: + self.unified_account = False + logger.info("Bybit: Standard account.") except ccxt.DDoSProtection as e: raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index 72ad774b6..8909ef5ff 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -2,7 +2,7 @@ import asyncio import logging import time from functools import wraps -from typing import Any, Callable, Optional, TypeVar, cast, overload +from typing import Any, Callable, Dict, List, Optional, TypeVar, cast, overload from freqtrade.constants import ExchangeConfig from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError @@ -60,16 +60,17 @@ SUPPORTED_EXCHANGES = [ 'okx', ] -EXCHANGE_HAS_REQUIRED = [ +# either the main, or replacement methods (array) is required +EXCHANGE_HAS_REQUIRED: Dict[str, List[str]] = { # Required / private - 'fetchOrder', - 'cancelOrder', - 'createOrder', - 'fetchBalance', + 'fetchOrder': ['fetchOpenOrder', 'fetchClosedOrder'], + 'cancelOrder': [], + 'createOrder': [], + 'fetchBalance': [], # Public endpoints - 'fetchOHLCV', -] + 'fetchOHLCV': [], +} EXCHANGE_HAS_OPTIONAL = [ # Private @@ -86,6 +87,7 @@ EXCHANGE_HAS_OPTIONAL = [ # 'fetchPositions', # Futures trading # 'fetchLeverageTiers', # Futures initialization # 'fetchMarketLeverageTiers', # Futures initialization + # 'fetchOpenOrder', 'fetchClosedOrder', # replacement for fetchOrder # 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance... ] diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 85a77fe5e..2872e603e 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -43,6 +43,7 @@ from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_ from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.util import dt_from_ts, dt_now from freqtrade.util.datetime_helpers import dt_humanize, dt_ts +from freqtrade.util.periodic_cache import PeriodicCache logger = logging.getLogger(__name__) @@ -131,6 +132,7 @@ class Exchange: # Holds candles self._klines: Dict[PairWithTimeframe, DataFrame] = {} + self._expiring_candle_cache: Dict[str, PeriodicCache] = {} # Holds all open sell orders for dry_run self._dry_run_open_orders: Dict[str, Any] = {} @@ -1242,7 +1244,7 @@ class Exchange: f'Insufficient funds to create {ordertype} {side} order on market {pair}. ' f'Tried to {side} amount {amount} at rate {limit_rate} with ' f'stop-price {stop_price_norm}. Message: {e}') from e - except (ccxt.InvalidOrder, ccxt.BadRequest) as e: + except (ccxt.InvalidOrder, ccxt.BadRequest, ccxt.OperationRejected) as e: # Errors: # `Order would trigger immediately.` raise InvalidOrderException( @@ -1258,11 +1260,43 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + def fetch_order_emulated(self, order_id: str, pair: str, params: Dict) -> Dict: + """ + Emulated fetch_order if the exchange doesn't support fetch_order, but requires separate + calls for open and closed orders. + """ + try: + order = self._api.fetch_open_order(order_id, pair, params=params) + self._log_exchange_response('fetch_open_order', order) + order = self._order_contracts_to_amount(order) + return order + except ccxt.OrderNotFound: + try: + order = self._api.fetch_closed_order(order_id, pair, params=params) + self._log_exchange_response('fetch_closed_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 + @retrier(retries=API_FETCH_ORDER_RETRY_COUNT) def fetch_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict: if self._config['dry_run']: return self.fetch_dry_run_order(order_id) try: + if not self.exchange_has('fetchOrder'): + return self.fetch_order_emulated(order_id, pair, params) order = self._api.fetch_order(order_id, pair, params=params) self._log_exchange_response('fetch_order', order) order = self._order_contracts_to_amount(order) @@ -2124,6 +2158,39 @@ class Exchange: return results_df + def refresh_ohlcv_with_cache( + self, + pairs: List[PairWithTimeframe], + since_ms: int + ) -> Dict[PairWithTimeframe, DataFrame]: + """ + Refresh ohlcv data for all pairs in needed_pairs if necessary. + Caches data with expiring per timeframe. + Should only be used for pairlists which need "on time" expirarion, and no longer cache. + """ + + timeframes = [p[1] for p in pairs] + for timeframe in timeframes: + if timeframe not in self._expiring_candle_cache: + timeframe_in_sec = timeframe_to_seconds(timeframe) + # Initialise cache + self._expiring_candle_cache[timeframe] = PeriodicCache(ttl=timeframe_in_sec, + maxsize=1000) + + # Get candles from cache + candles = { + c: self._expiring_candle_cache[c[1]].get(c, None) for c in pairs + if c in self._expiring_candle_cache[c[1]] + } + pairs_to_download = [p for p in pairs if p not in candles] + if pairs_to_download: + candles = self.refresh_latest_ohlcv( + pairs_to_download, since_ms=since_ms, cache=False + ) + for c, val in candles.items(): + self._expiring_candle_cache[c[1]][c] = val + return candles + 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) @@ -2685,7 +2752,7 @@ class Exchange: self._log_exchange_response('set_leverage', res) except ccxt.DDoSProtection as e: raise DDosProtection(e) from e - except (ccxt.BadRequest, ccxt.InsufficientFunds) as e: + except (ccxt.BadRequest, ccxt.OperationRejected, ccxt.InsufficientFunds) as e: if not accept_fail: raise TemporaryError( f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e @@ -2727,7 +2794,7 @@ class Exchange: self._log_exchange_response('set_margin_mode', res) except ccxt.DDoSProtection as e: raise DDosProtection(e) from e - except ccxt.BadRequest as e: + except (ccxt.BadRequest, ccxt.OperationRejected) as e: if not accept_fail: raise TemporaryError( f'Could not set margin mode due to {e.__class__.__name__}. Message: {e}') from e diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 98e05bf7a..f4dc3a721 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -40,21 +40,34 @@ def available_exchanges(ccxt_module: Optional[CcxtModuleType] = None) -> List[st def validate_exchange(exchange: str) -> Tuple[bool, str]: + """ + returns: can_use, reason + with Reason including both missing and missing_opt + """ ex_mod = getattr(ccxt, exchange.lower())() + result = True + reason = '' 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] + missing = [ + k for k, v in EXCHANGE_HAS_REQUIRED.items() + if ex_mod.has.get(k) is not True + and not (all(ex_mod.has.get(x) for x in v)) + ] if missing: - return False, f"missing: {', '.join(missing)}" + result = False + reason += 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)}" + result = False + reason = BAD_EXCHANGES.get(exchange.lower(), '') - return True, '' + if missing_opt: + reason += f"{'. ' if reason else ''}missing opt: {', '.join(missing_opt)}. " + + return result, reason def _build_exchange_list_entry( diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index ff04037da..2032e437d 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -702,7 +702,7 @@ class FreqtradeBot(LoggingMixin): delta = f"Delta: {bids_ask_delta}" logger.info( - f"{bids}, {asks}, {delta}, Direction: {side.value}" + 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]}." diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index d2a92a554..06a6e818d 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -24,7 +24,7 @@ from freqtrade.leverage import interest from freqtrade.misc import safe_value_fallback from freqtrade.persistence.base import ModelBase, SessionType from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData -from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts +from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none logger = logging.getLogger(__name__) @@ -225,8 +225,7 @@ class Order(ModelBase): 'amount': self.safe_amount, 'safe_price': self.safe_price, 'ft_order_side': self.ft_order_side, - 'order_filled_timestamp': int(self.order_filled_date.replace( - tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None, + 'order_filled_timestamp': dt_ts_none(self.order_filled_utc), 'ft_is_entry': self.ft_order_side == entry_side, 'ft_order_tag': self.ft_order_tag, } @@ -463,6 +462,17 @@ class LocalTrade: return self.open_date_utc return max([self.open_date_utc, dt_last_filled]) + @property + def date_entry_fill_utc(self) -> Optional[datetime]: + """ Date of the first filled order""" + orders = self.select_filled_orders(self.entry_side) + if ( + orders + and len(filled_date := [o.order_filled_utc for o in orders if o.order_filled_utc]) + ): + return min(filled_date) + return None + @property def open_date_utc(self): return self.open_date.replace(tzinfo=timezone.utc) @@ -627,15 +637,17 @@ class LocalTrade: '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_timestamp': dt_ts_none(self.open_date_utc), + 'open_fill_date': (self.date_entry_fill_utc.strftime(DATETIME_PRINT_FORMAT) + if self.date_entry_fill_utc else None), + 'open_fill_timestamp': dt_ts_none(self.date_entry_fill_utc), '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, + 'close_timestamp': dt_ts_none(self.close_date_utc), 'realized_profit': self.realized_profit or 0.0, # Close-profit corresponds to relative realized_profit ratio 'realized_profit_ratio': self.close_profit or None, @@ -661,8 +673,7 @@ class LocalTrade: 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, 'stoploss_last_update': (self.stoploss_last_update_utc.strftime(DATETIME_PRINT_FORMAT) if self.stoploss_last_update_utc else None), - 'stoploss_last_update_timestamp': int(self.stoploss_last_update_utc.timestamp() * 1000 - ) if self.stoploss_last_update_utc else None, + 'stoploss_last_update_timestamp': dt_ts_none(self.stoploss_last_update_utc), '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), diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 800bf3664..b6ce1b9a2 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -104,10 +104,7 @@ class VolatilityFilter(IPairList): since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days)) # Get all candles - candles = {} - if needed_pairs: - candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms, - cache=False) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) if self._enabled: for p in deepcopy(pairlist): @@ -125,8 +122,7 @@ class VolatilityFilter(IPairList): :return: True if the pair can stay, false if it should be removed """ # Check symbol in cache - cached_res = self._pair_cache.get(pair, None) - if cached_res is not None: + if (cached_res := self._pair_cache.get(pair, None)) is not None: return cached_res result = False diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index b5525e950..f4d08e800 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -229,12 +229,8 @@ class VolumePairList(IPairList): if p not in self._pair_cache ] - # Get all candles - candles = {} - if needed_pairs: - candles = self._exchange.refresh_latest_ohlcv( - needed_pairs, since_ms=since_ms, cache=False - ) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms) + for i, p in enumerate(filtered_tickers): contract_size = self._exchange.markets[p['symbol']].get('contractSize', 1.0) or 1.0 pair_candles = candles[ diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index f4625f572..f2cf4d486 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -101,11 +101,7 @@ class RangeStabilityFilter(IPairList): (p, '1d', self._def_candletype) for p in pairlist if p not in self._pair_cache] since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days - 1)) - # Get all candles - candles = {} - if needed_pairs: - candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms, - cache=False) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) if self._enabled: for p in deepcopy(pairlist): @@ -123,8 +119,7 @@ class RangeStabilityFilter(IPairList): :return: True if the pair can stay, false if it should be removed """ # Check symbol in cache - cached_res = self._pair_cache.get(pair, None) - if cached_res is not None: + if (cached_res := self._pair_cache.get(pair, None)) is not None: return cached_res result = True diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 9919d1a05..3ea9ed4d0 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -288,6 +288,8 @@ class TradeSchema(BaseModel): open_date: str open_timestamp: int + open_fill_date: Optional[str] + open_fill_timestamp: Optional[int] open_rate: float open_rate_requested: Optional[float] = None open_trade_value: float diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 0e33faf5f..d0e12cc4a 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -1365,7 +1365,7 @@ class Telegram(RPCHandler): @authorized_only async def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None: """ - Handler for /buys PAIR . + Handler for /entries PAIR . Shows a performance statistic from finished trades :param bot: telegram bot :param update: message update @@ -1376,28 +1376,28 @@ class Telegram(RPCHandler): pair = context.args[0] trades = self._rpc._rpc_enter_tag_performance(pair) - output = "Entry Tag Performance:\n" + output = "*Entry Tag Performance:*\n" for i, trade in enumerate(trades): stat_line = ( - f"{i + 1}.\t {trade['enter_tag']}\t" + f"{i + 1}.\t `{trade['enter_tag']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " f"({trade['profit_ratio']:.2%}) " - f"({trade['count']})\n") + f"({trade['count']})`\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - await self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.MARKDOWN) output = stat_line else: output += stat_line - await self._send_msg(output, parse_mode=ParseMode.HTML, + await self._send_msg(output, parse_mode=ParseMode.MARKDOWN, reload_able=True, callback_path="update_enter_tag_performance", query=update.callback_query) @authorized_only async def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None: """ - Handler for /sells. + Handler for /exits. Shows a performance statistic from finished trades :param bot: telegram bot :param update: message update @@ -1408,21 +1408,21 @@ class Telegram(RPCHandler): pair = context.args[0] trades = self._rpc._rpc_exit_reason_performance(pair) - output = "Exit Reason Performance:\n" + output = "*Exit Reason Performance:*\n" for i, trade in enumerate(trades): stat_line = ( - f"{i + 1}.\t {trade['exit_reason']}\t" + f"{i + 1}.\t `{trade['exit_reason']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " f"({trade['profit_ratio']:.2%}) " - f"({trade['count']})\n") + f"({trade['count']})`\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - await self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.MARKDOWN) output = stat_line else: output += stat_line - await self._send_msg(output, parse_mode=ParseMode.HTML, + await self._send_msg(output, parse_mode=ParseMode.MARKDOWN, reload_able=True, callback_path="update_exit_reason_performance", query=update.callback_query) @@ -1440,21 +1440,21 @@ class Telegram(RPCHandler): pair = context.args[0] trades = self._rpc._rpc_mix_tag_performance(pair) - output = "Mix Tag Performance:\n" + output = "*Mix Tag Performance:*\n" for i, trade in enumerate(trades): stat_line = ( - f"{i + 1}.\t {trade['mix_tag']}\t" + f"{i + 1}.\t `{trade['mix_tag']}\t" f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} " f"({trade['profit_ratio']:.2%}) " - f"({trade['count']})\n") + f"({trade['count']})`\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - await self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.MARKDOWN) output = stat_line else: output += stat_line - await self._send_msg(output, parse_mode=ParseMode.HTML, + await self._send_msg(output, parse_mode=ParseMode.MARKDOWN, reload_able=True, callback_path="update_mix_tag_performance", query=update.callback_query) @@ -1677,8 +1677,8 @@ class Telegram(RPCHandler): " *table :* `will display trades in a table`\n" " `pending buy orders are marked with an asterisk (*)`\n" " `pending sell orders are marked with a double asterisk (**)`\n" - "*/buys :* `Shows the enter_tag performance`\n" - "*/sells :* `Shows the exit reason performance`\n" + "*/entries :* `Shows the enter_tag performance`\n" + "*/exits :* `Shows the exit reason performance`\n" "*/mix_tags :* `Shows combined entry tag + exit reason performance`\n" "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n" "*/profit []:* `Lists cumulative profit from all finished trades, " diff --git a/freqtrade/util/__init__.py b/freqtrade/util/__init__.py index 513406fd2..f7e63d9d3 100644 --- a/freqtrade/util/__init__.py +++ b/freqtrade/util/__init__.py @@ -1,6 +1,6 @@ from freqtrade.util.datetime_helpers import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, - dt_ts_def, dt_utc, format_date, format_ms_time, - shorten_date) + dt_ts_def, dt_ts_none, dt_utc, format_date, + format_ms_time, shorten_date) from freqtrade.util.formatters import decimals_per_coin, fmt_coin, round_value from freqtrade.util.ft_precise import FtPrecise from freqtrade.util.periodic_cache import PeriodicCache @@ -14,6 +14,7 @@ __all__ = [ 'dt_now', 'dt_ts', 'dt_ts_def', + 'dt_ts_none', 'dt_utc', 'format_date', 'format_ms_time', diff --git a/freqtrade/util/datetime_helpers.py b/freqtrade/util/datetime_helpers.py index 102c83143..66b738e8d 100644 --- a/freqtrade/util/datetime_helpers.py +++ b/freqtrade/util/datetime_helpers.py @@ -31,13 +31,23 @@ def dt_ts(dt: Optional[datetime] = None) -> int: def dt_ts_def(dt: Optional[datetime], default: int = 0) -> int: """ Return dt in ms as a timestamp in UTC. - If dt is None, return the current datetime in UTC. + If dt is None, return the given default. """ if dt: return int(dt.timestamp() * 1000) return default +def dt_ts_none(dt: Optional[datetime]) -> Optional[int]: + """ + Return dt in ms as a timestamp in UTC. + If dt is None, return the given default. + """ + if dt: + return int(dt.timestamp() * 1000) + return None + + def dt_floor_day(dt: datetime) -> datetime: """Return the floor of the day for the given datetime.""" return dt.replace(hour=0, minute=0, second=0, microsecond=0) diff --git a/requirements-dev.txt b/requirements-dev.txt index f0095bffa..e0993988a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,10 +7,10 @@ -r docs/requirements-docs.txt coveralls==3.3.1 -ruff==0.2.1 +ruff==0.2.2 mypy==1.8.0 -pre-commit==3.6.1 -pytest==8.0.0 +pre-commit==3.6.2 +pytest==8.0.1 pytest-asyncio==0.23.5 pytest-cov==4.1.0 pytest-mock==3.12.0 @@ -26,6 +26,6 @@ nbconvert==7.16.0 # mypy types types-cachetools==5.3.0.7 types-filelock==3.2.7 -types-requests==2.31.0.20240125 +types-requests==2.31.0.20240218 types-tabulate==0.9.0.20240106 types-python-dateutil==2.8.19.20240106 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index 848b6d920..0532562da 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -3,10 +3,10 @@ -r requirements-plot.txt # Required for freqai -scikit-learn==1.4.0 +scikit-learn==1.4.1.post1 joblib==1.3.2 catboost==1.2.2; 'arm' not in platform_machine and python_version < '3.12' lightgbm==4.3.0 xgboost==2.0.3 -tensorboard==2.15.2 +tensorboard==2.16.2 datasieve==0.1.7 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index b961b3b04..5347adf9c 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -3,6 +3,6 @@ # Required for hyperopt scipy==1.12.0 -scikit-learn==1.4.0 +scikit-learn==1.4.1.post1 ft-scikit-optimize==0.9.2 filelock==3.13.1 diff --git a/requirements-plot.txt b/requirements-plot.txt index 8900bf1f9..af746ef98 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,4 +1,4 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==5.18.0 +plotly==5.19.0 diff --git a/requirements.txt b/requirements.txt index c30036e5a..1efe0b7da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,17 +2,17 @@ numpy==1.26.4 pandas==2.1.4 pandas-ta==0.3.14b -ccxt==4.2.42 -cryptography==42.0.2 +ccxt==4.2.47 +cryptography==42.0.3 aiohttp==3.9.3 -SQLAlchemy==2.0.26 +SQLAlchemy==2.0.27 python-telegram-bot==20.8 # can't be hard-pinned due to telegram-bot pinning httpx with ~ httpx>=0.24.1 arrow==1.3.0 cachetools==5.3.2 requests==2.31.0 -urllib3==2.2.0 +urllib3==2.2.1 jsonschema==4.21.1 TA-Lib==0.4.28 technical==1.4.3 @@ -30,7 +30,7 @@ py_find_1st==1.1.6 # Load ticker files 30% faster python-rapidjson==1.14 # Properly format api responses -orjson==3.9.13 +orjson==3.9.14 # Notify systemd sdnotify==0.3.2 diff --git a/setup.py b/setup.py index 3b92b9dd7..38f0f9a78 100644 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ setup( ], install_requires=[ # from requirements.txt - 'ccxt>=4.2.15', + 'ccxt>=4.2.47', 'SQLAlchemy>=2.0.6', 'python-telegram-bot>=20.1', 'arrow>=1.0.0', diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index c1b007e77..554ee261a 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -455,6 +455,13 @@ def test_calculate_max_drawdown2(): with pytest.raises(ValueError, match='No losing trade, therefore no drawdown.'): calculate_max_drawdown(df, date_col='open_date', value_col='profit') + df1 = DataFrame(zip(values[:5], dates[:5]), columns=['profit', 'open_date']) + df1.loc[:, 'profit'] = df1['profit'] * -1 + # No winning trade ... + drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown( + df1, date_col='open_date', value_col='profit') + assert drawdown == 0.043965 + @pytest.mark.parametrize('profits,relative,highd,lowd,result,result_rel', [ ([0.0, -500.0, 500.0, 10000.0, -1000.0], False, 3, 4, 1000.0, 0.090909), diff --git a/tests/exchange/test_bybit.py b/tests/exchange/test_bybit.py index f7383934b..556547d88 100644 --- a/tests/exchange/test_bybit.py +++ b/tests/exchange/test_bybit.py @@ -1,20 +1,40 @@ from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock +import pytest + from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.tradingmode import TradingMode -from tests.conftest import EXMS, get_mock_coro, get_patched_exchange +from freqtrade.exceptions import OperationalException +from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has from tests.exchange.test_exchange import ccxt_exceptionhandlers -def test_additional_exchange_init_bybit(default_conf, mocker): +def test_additional_exchange_init_bybit(default_conf, mocker, caplog): default_conf['dry_run'] = False default_conf['trading_mode'] = TradingMode.FUTURES default_conf['margin_mode'] = MarginMode.ISOLATED api_mock = MagicMock() api_mock.set_position_mode = MagicMock(return_value={"dualSidePosition": False}) - get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock) + api_mock.is_unified_enabled = MagicMock(return_value=[False, False]) + + exchange = get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock) assert api_mock.set_position_mode.call_count == 1 + assert api_mock.is_unified_enabled.call_count == 1 + assert exchange.unified_account is False + + assert log_has("Bybit: Standard account.", caplog) + + api_mock.set_position_mode.reset_mock() + api_mock.is_unified_enabled = MagicMock(return_value=[False, True]) + with pytest.raises(OperationalException, match=r"Bybit: Unified account is not supported.*"): + get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock) + assert log_has("Bybit: Unified account.", caplog) + # exchange = get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock) + # assert api_mock.set_position_mode.call_count == 1 + # assert api_mock.is_unified_enabled.call_count == 1 + # assert exchange.unified_account is True + ccxt_exceptionhandlers(mocker, default_conf, api_mock, 'bybit', "additional_exchange_init", "set_position_mode") @@ -111,6 +131,7 @@ def test_bybit_fetch_order_canceled_empty(default_conf_usdt, mocker): 'amount': 20.0, }) + mocker.patch(f"{EXMS}.exchange_has", return_value=True) exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, id='bybit') res = exchange.fetch_order('123', 'BTC/USDT') diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index fc199a7f5..5c4879a32 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -2303,6 +2303,66 @@ def test_refresh_latest_ohlcv_cache(mocker, default_conf, candle_type, time_mach assert res[pair2].at[0, 'open'] +def test_refresh_ohlcv_with_cache(mocker, default_conf, time_machine) -> None: + start = datetime(2021, 8, 1, 0, 0, 0, 0, tzinfo=timezone.utc) + ohlcv = generate_test_data_raw('1h', 100, start.strftime('%Y-%m-%d')) + time_machine.move_to(start, tick=False) + pairs = [ + ('ETH/BTC', '1d', CandleType.SPOT), + ('TKN/BTC', '1d', CandleType.SPOT), + ('LTC/BTC', '1d', CandleType.SPOT), + ('LTC/BTC', '5m', CandleType.SPOT), + ('LTC/BTC', '1h', CandleType.SPOT), + ] + + ohlcv_data = { + p: ohlcv for p in pairs + } + ohlcv_mock = mocker.patch(f"{EXMS}.refresh_latest_ohlcv", return_value=ohlcv_data) + mocker.patch(f"{EXMS}.ohlcv_candle_limit", return_value=100) + exchange = get_patched_exchange(mocker, default_conf) + + assert len(exchange._expiring_candle_cache) == 0 + + res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + assert ohlcv_mock.call_count == 1 + assert ohlcv_mock.call_args_list[0][0][0] == pairs + assert len(ohlcv_mock.call_args_list[0][0][0]) == 5 + + assert len(res) == 5 + # length of 3 - as we have 3 different timeframes + assert len(exchange._expiring_candle_cache) == 3 + + ohlcv_mock.reset_mock() + res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + assert ohlcv_mock.call_count == 0 + + # Expire 5m cache + time_machine.move_to(start + timedelta(minutes=6), tick=False) + + ohlcv_mock.reset_mock() + res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + assert ohlcv_mock.call_count == 1 + assert len(ohlcv_mock.call_args_list[0][0][0]) == 1 + + # Expire 5m and 1h cache + time_machine.move_to(start + timedelta(hours=2), tick=False) + + ohlcv_mock.reset_mock() + res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + assert ohlcv_mock.call_count == 1 + assert len(ohlcv_mock.call_args_list[0][0][0]) == 2 + + # Expire all caches + time_machine.move_to(start + timedelta(days=1, hours=2), tick=False) + + ohlcv_mock.reset_mock() + res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + assert ohlcv_mock.call_count == 1 + assert len(ohlcv_mock.call_args_list[0][0][0]) == 5 + assert ohlcv_mock.call_args_list[0][0][0] == pairs + + @pytest.mark.parametrize("exchange_name", EXCHANGES) async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name): ohlcv = [ @@ -3177,6 +3237,7 @@ def test_is_cancel_order_result_suitable(mocker, default_conf, exchange_name, or def test_cancel_order_with_result(default_conf, mocker, exchange_name, corder, call_corder, call_forder): default_conf['dry_run'] = False + mocker.patch(f"{EXMS}.exchange_has", return_value=True) api_mock = MagicMock() api_mock.cancel_order = MagicMock(return_value=corder) api_mock.fetch_order = MagicMock(return_value={}) @@ -3190,6 +3251,7 @@ def test_cancel_order_with_result(default_conf, mocker, exchange_name, corder, @pytest.mark.parametrize("exchange_name", EXCHANGES) def test_cancel_order_with_result_error(default_conf, mocker, exchange_name, caplog): default_conf['dry_run'] = False + mocker.patch(f"{EXMS}.exchange_has", return_value=True) api_mock = MagicMock() api_mock.cancel_order = MagicMock(side_effect=ccxt.InvalidOrder("Did not find order")) api_mock.fetch_order = MagicMock(side_effect=ccxt.InvalidOrder("Did not find order")) @@ -3287,6 +3349,7 @@ def test_fetch_order(default_conf, mocker, exchange_name, caplog): order.myid = 123 order.symbol = 'TKN/BTC' + mocker.patch(f"{EXMS}.exchange_has", return_value=True) exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) exchange._dry_run_open_orders['X'] = order assert exchange.fetch_order('X', 'TKN/BTC').myid == 123 @@ -3331,10 +3394,80 @@ def test_fetch_order(default_conf, mocker, exchange_name, caplog): order_id='_', pair='TKN/BTC') +@pytest.mark.usefixtures("init_persistence") +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_fetch_order_emulated(default_conf, mocker, exchange_name, caplog): + default_conf['dry_run'] = True + default_conf['exchange']['log_responses'] = True + order = MagicMock() + order.myid = 123 + order.symbol = 'TKN/BTC' + + exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) + mocker.patch(f'{EXMS}.exchange_has', return_value=False) + exchange._dry_run_open_orders['X'] = order + # Dry run - regular fetch_order behavior + assert exchange.fetch_order('X', 'TKN/BTC').myid == 123 + + with pytest.raises(InvalidOrderException, match=r'Tried to get an invalid dry-run-order.*'): + exchange.fetch_order('Y', 'TKN/BTC') + + default_conf['dry_run'] = False + mocker.patch(f'{EXMS}.exchange_has', return_value=False) + api_mock = MagicMock() + api_mock.fetch_open_order = MagicMock( + return_value={'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'}) + api_mock.fetch_closed_order = MagicMock( + return_value={'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'}) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + assert exchange.fetch_order( + 'X', 'TKN/BTC') == {'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'} + assert log_has( + ("API fetch_open_order: {\'id\': \'123\', \'amount\': 2, \'symbol\': \'TKN/BTC\'}" + ), + caplog + ) + assert api_mock.fetch_open_order.call_count == 1 + assert api_mock.fetch_closed_order.call_count == 0 + caplog.clear() + + # open_order doesn't find order + api_mock.fetch_open_order = MagicMock(side_effect=ccxt.OrderNotFound("Order not found")) + api_mock.fetch_closed_order = MagicMock( + return_value={'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'}) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + assert exchange.fetch_order( + 'X', 'TKN/BTC') == {'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'} + assert log_has( + ("API fetch_closed_order: {\'id\': \'123\', \'amount\': 2, \'symbol\': \'TKN/BTC\'}" + ), + caplog + ) + assert api_mock.fetch_open_order.call_count == 1 + assert api_mock.fetch_closed_order.call_count == 1 + caplog.clear() + + with pytest.raises(InvalidOrderException): + api_mock.fetch_open_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found")) + api_mock.fetch_closed_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + exchange.fetch_order(order_id='_', pair='TKN/BTC') + assert api_mock.fetch_open_order.call_count == 1 + + api_mock.fetch_open_order = MagicMock(side_effect=ccxt.OrderNotFound("Order not found")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, + 'fetch_order_emulated', 'fetch_open_order', + retries=1, + order_id='_', pair='TKN/BTC', params={}) + + @pytest.mark.usefixtures("init_persistence") @pytest.mark.parametrize("exchange_name", EXCHANGES) def test_fetch_stoploss_order(default_conf, mocker, exchange_name): default_conf['dry_run'] = True + mocker.patch(f"{EXMS}.exchange_has", return_value=True) order = MagicMock() order.myid = 123 exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index a613ae586..f8cd8f413 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -324,7 +324,8 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker): @pytest.fixture(params=EXCHANGES, scope="class") -def exchange(request, exchange_conf): +def exchange(request, exchange_conf, class_mocker): + class_mocker.patch('freqtrade.exchange.bybit.Bybit.additional_exchange_init') yield from get_exchange(request.param, exchange_conf) diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 95db7bc0f..0e0e70ee8 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -1400,6 +1400,8 @@ def test_to_json(fee): 'is_open': None, 'open_date': trade.open_date.strftime(DATETIME_PRINT_FORMAT), 'open_timestamp': int(trade.open_date.timestamp() * 1000), + 'open_fill_date': None, + 'open_fill_timestamp': None, 'close_date': None, 'close_timestamp': None, 'open_rate': 0.123, @@ -1486,6 +1488,8 @@ def test_to_json(fee): 'quote_currency': 'BTC', 'open_date': trade.open_date.strftime(DATETIME_PRINT_FORMAT), 'open_timestamp': int(trade.open_date.timestamp() * 1000), + 'open_fill_date': None, + 'open_fill_timestamp': None, 'close_date': trade.close_date.strftime(DATETIME_PRINT_FORMAT), 'close_timestamp': int(trade.close_date.timestamp() * 1000), 'open_rate': 0.123, diff --git a/tests/persistence/test_trade_fromjson.py b/tests/persistence/test_trade_fromjson.py index 302a81c54..988f7ed5b 100644 --- a/tests/persistence/test_trade_fromjson.py +++ b/tests/persistence/test_trade_fromjson.py @@ -223,7 +223,7 @@ def test_trade_serialize_load_back(fee): 'realized_profit_ratio', 'close_profit_pct', 'trade_duration_s', 'trade_duration', 'profit_ratio', 'profit_pct', 'profit_abs', 'stop_loss_abs', - 'initial_stop_loss_abs', + 'initial_stop_loss_abs', 'open_fill_date', 'open_fill_timestamp', 'orders', ] failed = [] diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 09dcd0af3..d125f8896 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -621,13 +621,20 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", "lookback_timeframe": "1d", "lookback_period": 6, "refresh_period": 86400}], "BTC", "binance", ['LTC/BTC', 'XRP/BTC', 'ETH/BTC', 'HOT/BTC', 'NEO/BTC']), + # VolumePairlist in range mode as filter. + # TKN/BTC is removed because it doesn't have enough candles + ([{"method": "VolumePairList", "number_assets": 5}, + {"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_timeframe": "1d", "lookback_period": 2, "refresh_period": 86400}], + "BTC", "binance", ['LTC/BTC', 'XRP/BTC', 'ETH/BTC', 'TKN/BTC', 'HOT/BTC']), # ftx data is already in Quote currency, therefore won't require conversion # ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", # "lookback_timeframe": "1d", "lookback_period": 1, "refresh_period": 86400}], # "BTC", "ftx", ['HOT/BTC', 'LTC/BTC', 'ETH/BTC', 'TKN/BTC', 'XRP/BTC']), ]) -def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers, ohlcv_history, - pairlists, base_currency, exchange, volumefilter_result) -> None: +def test_VolumePairList_range( + mocker, whitelist_conf, shitcoinmarkets, tickers, ohlcv_history, + pairlists, base_currency, exchange, volumefilter_result, time_machine) -> None: whitelist_conf['pairlists'] = pairlists whitelist_conf['stake_currency'] = base_currency whitelist_conf['exchange']['name'] = exchange @@ -686,23 +693,36 @@ def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers, get_tickers=tickers, markets=PropertyMock(return_value=shitcoinmarkets) ) - + start_dt = dt_now() + time_machine.move_to(start_dt) # remove ohlcv when looback_timeframe != 1d # to enforce fallback to ticker data if 'lookback_timeframe' in pairlists[0]: if pairlists[0]['lookback_timeframe'] != '1d': - ohlcv_data = [] + ohlcv_data = {} - mocker.patch.multiple( - EXMS, - refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data), - ) + ohclv_mock = mocker.patch(f"{EXMS}.refresh_latest_ohlcv", return_value=ohlcv_data) freqtrade.pairlists.refresh_pairlist() whitelist = freqtrade.pairlists.whitelist + assert ohclv_mock.call_count == 1 assert isinstance(whitelist, list) assert whitelist == volumefilter_result + # Test caching + ohclv_mock.reset_mock() + freqtrade.pairlists.refresh_pairlist() + # in "filter" mode, caching is disabled. + assert ohclv_mock.call_count == 0 + whitelist = freqtrade.pairlists.whitelist + assert whitelist == volumefilter_result + + time_machine.move_to(start_dt + timedelta(days=2)) + ohclv_mock.reset_mock() + freqtrade.pairlists.refresh_pairlist() + assert ohclv_mock.call_count == 1 + whitelist = freqtrade.pairlists.whitelist + assert whitelist == volumefilter_result def test_PrecisionFilter_error(mocker, whitelist_conf) -> None: diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 1f51b30df..85b105892 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -25,6 +25,8 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'quote_currency': 'BTC', 'open_date': ANY, 'open_timestamp': ANY, + 'open_fill_date': ANY, + 'open_fill_timestamp': ANY, 'is_open': ANY, 'fee_open': ANY, 'fee_open_cost': ANY, diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index bba18bcd3..1e008d98e 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -180,7 +180,9 @@ def test_api_auth(): def test_api_ws_auth(botclient): ftbot, client = botclient - def url(token): return f"/api/v1/message/ws?token={token}" + + def url(token): + return f"/api/v1/message/ws?token={token}" bad_token = "bad-ws_token" with pytest.raises(WebSocketDisconnect): @@ -1165,6 +1167,8 @@ def test_api_status(botclient, mocker, ticker, fee, markets, is_short, 'current_rate': current_rate, 'open_date': ANY, 'open_timestamp': ANY, + 'open_fill_date': ANY, + 'open_fill_timestamp': ANY, 'open_rate': 0.123, 'pair': 'ETH/BTC', 'base_currency': 'ETH', @@ -1368,6 +1372,8 @@ def test_api_force_entry(botclient, mocker, fee, endpoint): 'close_rate': 0.265441, 'open_date': ANY, 'open_timestamp': ANY, + 'open_fill_date': ANY, + 'open_fill_timestamp': ANY, 'open_rate': 0.245441, 'pair': 'ETH/BTC', 'base_currency': 'ETH', diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index cdc56b804..29a2b2723 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -1507,7 +1507,7 @@ async def test_telegram_entry_tag_performance_handle( await telegram._enter_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Entry Tag Performance' in msg_mock.call_args_list[0][0][0] - assert 'TEST1\t3.987 USDT (5.00%) (1)' in msg_mock.call_args_list[0][0][0] + assert '`TEST1\t3.987 USDT (5.00%) (1)`' in msg_mock.call_args_list[0][0][0] context.args = ['XRP/USDT'] await telegram._enter_tag_performance(update=update, context=context) @@ -1538,7 +1538,7 @@ async def test_telegram_exit_reason_performance_handle( await telegram._exit_reason_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Exit Reason Performance' in msg_mock.call_args_list[0][0][0] - assert 'roi\t2.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0] + assert '`roi\t2.842 USDT (10.00%) (1)`' in msg_mock.call_args_list[0][0][0] context.args = ['XRP/USDT'] await telegram._exit_reason_performance(update=update, context=context) @@ -1570,7 +1570,7 @@ async def test_telegram_mix_tag_performance_handle(default_conf_usdt, update, ti await telegram._mix_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0] - assert ('TEST3 roi\t2.842 USDT (10.00%) (1)' + assert ('`TEST3 roi\t2.842 USDT (10.00%) (1)`' in msg_mock.call_args_list[0][0][0]) context.args = ['XRP/USDT'] diff --git a/tests/utils/test_datetime_helpers.py b/tests/utils/test_datetime_helpers.py index b70065645..6fbe75200 100644 --- a/tests/utils/test_datetime_helpers.py +++ b/tests/utils/test_datetime_helpers.py @@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone import pytest import time_machine -from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_ts_def, dt_utc, - format_date, format_ms_time, shorten_date) +from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_ts_def, + dt_ts_none, dt_utc, format_date, format_ms_time, shorten_date) def test_dt_now(): @@ -29,6 +29,13 @@ def test_dt_ts_def(): assert dt_ts_def(datetime(2023, 5, 5, tzinfo=timezone.utc), 123) == 1683244800000 +def test_dt_ts_none(): + assert dt_ts_none(None) is None + assert dt_ts_none(None) is None + assert dt_ts_none(datetime(2023, 5, 5, tzinfo=timezone.utc)) == 1683244800000 + assert dt_ts_none(datetime(2023, 5, 5, tzinfo=timezone.utc)) == 1683244800000 + + def test_dt_utc(): assert dt_utc(2023, 5, 5) == datetime(2023, 5, 5, tzinfo=timezone.utc) assert dt_utc(2023, 5, 5, 0, 0, 0, 555500) == datetime(2023, 5, 5, 0, 0, 0, 555500, diff --git a/tests/utils/test_periodiccache.py b/tests/utils/test_periodiccache.py index df05de4ef..a8931d6a2 100644 --- a/tests/utils/test_periodiccache.py +++ b/tests/utils/test_periodiccache.py @@ -5,7 +5,7 @@ from freqtrade.util import PeriodicCache def test_ttl_cache(): - with time_machine.travel("2021-09-01 05:00:00 +00:00") as t: + with time_machine.travel("2021-09-01 05:00:00 +00:00", tick=False) as t: cache = PeriodicCache(5, ttl=60) cache1h = PeriodicCache(5, ttl=3600)