diff --git a/docs/backtesting.md b/docs/backtesting.md index 7420c1dec..e7846b1f8 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -313,6 +313,7 @@ A backtesting result will look like that: | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | | Rejected Buy signals | 3089 | +| Entry/Exit Timeouts | 0 / 0 | | | | | Min balance | 0.00945123 BTC | | Max balance | 0.01846651 BTC | @@ -400,6 +401,7 @@ It contains some useful key metrics about performance of your strategy on backte | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | | Rejected Buy signals | 3089 | +| Entry/Exit Timeouts | 0 / 0 | | | | | Min balance | 0.00945123 BTC | | Max balance | 0.01846651 BTC | @@ -429,6 +431,7 @@ It contains some useful key metrics about performance of your strategy on backte - `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade). - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. - `Rejected Buy signals`: Buy signals that could not be acted upon due to max_open_trades being reached. +- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period. - `Drawdown (Account)`: Maximum Account Drawdown experienced. Calculated as $(Absolute Drawdown) / (DrawdownHigh + startingBalance)$. - `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point. diff --git a/docs/bot-basics.md b/docs/bot-basics.md index a9a2628f6..8c6303063 100644 --- a/docs/bot-basics.md +++ b/docs/bot-basics.md @@ -62,6 +62,7 @@ This loop will be repeated again and again until the bot is stopped. * Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested. * Call `custom_stoploss()` and `custom_sell()` to find custom exit points. * For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle). + * Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_buy_timeout()` / `check_sell_timeout()` strategy callbacks. * Generate backtest report output !!! Note diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index bff5bd998..555352d21 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -389,8 +389,8 @@ class AwesomeStrategy(IStrategy): If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98, which is 2% below the current (proposed) rate. !!! Warning "Backtesting" - While Custom prices are supported in backtesting (starting with 2021.12), prices will be moved to within the candle's high/low prices. - This behavior is currently being tested, and might be changed at a later point. + Custom prices are supported in backtesting (starting with 2021.12), and orders will fill if the price falls within the candle's low/high range. + Orders that don't fill immediately are subject to regular timeout handling, which happens once per (detail) candle. `custom_exit_price()` is only called for sells of type Sell_signal and Custom sell. All other sell-types will use regular backtesting prices. ## Custom order timeout rules @@ -400,7 +400,8 @@ Simple, time-based order-timeouts can be configured either via strategy or in th However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an order did time out or not. !!! Note - Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. + Backtesting fills orders if their price falls within the candle's low/high range. + The below callbacks will be called once per (detail) candle for orders that don't fill immediately (which use custom pricing). ### Custom order timeout example @@ -467,7 +468,8 @@ class AwesomeStrategy(IStrategy): 'sell': 60 * 25 } - def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + def check_buy_timeout(self, pair: str, trade: Trade, order: dict, + current_time: datetime, **kwargs) -> bool: ob = self.dp.orderbook(pair, 1) current_price = ob['bids'][0][0] # Cancel buy order if price is more than 2% above the order. @@ -476,7 +478,8 @@ class AwesomeStrategy(IStrategy): return False - def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + def check_sell_timeout(self, pair: str, trade: Trade, order: dict, + current_time: datetime, **kwargs) -> bool: ob = self.dp.orderbook(pair, 1) current_price = ob['asks'][0][0] # Cancel sell order if price is more than 2% below the order. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e7782b6d2..077be51f7 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -456,6 +456,7 @@ SCHEMA_BACKTEST_REQUIRED = [ 'dry_run_wallet', 'dataformat_ohlcv', 'dataformat_trades', + 'unfilledtimeout', ] SCHEMA_MINIMAL_REQUIRED = [ diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 572ceeabf..279bb6161 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -987,18 +987,20 @@ class FreqtradeBot(LoggingMixin): fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order) + order_obj = trade.select_order_by_order_id(trade.open_order_id) + if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and ( fully_cancelled - or self.strategy.ft_check_timed_out( - 'buy', trade, order, datetime.now(timezone.utc)) - )): + or (order_obj and self.strategy.ft_check_timed_out( + 'buy', trade, order_obj, datetime.now(timezone.utc)) + ))): self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and ( fully_cancelled - or self.strategy.ft_check_timed_out( - 'sell', trade, order, datetime.now(timezone.utc))) - ): + or (order_obj and self.strategy.ft_check_timed_out( + 'sell', trade, order_obj, datetime.now(timezone.utc)) + ))): 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) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 3dd8986d3..6c5933a51 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -63,6 +63,8 @@ class Backtesting: 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] = {} @@ -231,6 +233,8 @@ class Backtesting: 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) @@ -353,7 +357,10 @@ class Backtesting: # use Open rate if open_rate > calculated sell rate return sell_row[OPEN_IDX] - return close_rate + # 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... @@ -376,10 +383,15 @@ class Backtesting: if stake_amount is not None and stake_amount > 0.0: pos_trade = self._enter_trade(trade.pair, row, stake_amount, trade) 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]: @@ -405,18 +417,21 @@ class Backtesting: closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) # call the custom exit price,with default value as previous closerate current_profit = trade.calc_profit_ratio(closerate) + order_type = self.strategy.order_types['sell'] if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL): # Custom exit pricing only for sell-signals - closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, - default_retval=closerate)( - pair=trade.pair, trade=trade, - current_time=sell_row[DATE_IDX], - proposed_rate=closerate, current_profit=current_profit) - # Use the maximum between close_rate and low as we cannot sell outside of a candle. - closerate = min(max(closerate, sell_row[LOW_IDX]), sell_row[HIGH_IDX]) - + if order_type == 'limit': + closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, + default_retval=closerate)( + pair=trade.pair, trade=trade, + current_time=sell_candle_time, + proposed_rate=closerate, 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 + closerate = max(closerate, sell_row[LOW_IDX]) # Confirm trade exit: time_in_force = self.strategy.order_time_in_force['sell'] + 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=closerate, @@ -436,7 +451,28 @@ class Backtesting: ): trade.sell_reason = sell_row[EXIT_TAG_IDX] - trade.close(closerate, show_msg=False) + 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="sell", + side="sell", + 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 return None @@ -475,13 +511,16 @@ class Backtesting: current_time = row[DATE_IDX].to_pydatetime() entry_tag = row[BUY_TAG_IDX] if len(row) >= BUY_TAG_IDX + 1 else None # let's call the custom entry price, using the open price as default price - propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, - default_retval=row[OPEN_IDX])( - pair=pair, current_time=current_time, - proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate - - # Move rate to within the candle's low/high rate - propose_rate = min(max(propose_rate, row[LOW_IDX]), row[HIGH_IDX]) + order_type = self.strategy.order_types['buy'] + propose_rate = row[OPEN_IDX] + if order_type == 'limit': + propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, + default_retval=row[OPEN_IDX])( + 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. + propose_rate = min(propose_rate, row[HIGH_IDX]) min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0 max_stake_amount = self.wallets.get_available_stake_amount() @@ -489,9 +528,9 @@ class Backtesting: pos_adjust = trade is not None if not pos_adjust: try: - stake_amount = self.wallets.get_trade_stake_amount(pair, None) + stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False) except DependencyException: - return trade + return None stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, default_retval=stake_amount)( @@ -506,8 +545,7 @@ class Backtesting: # If not pos adjust, trade is None return trade - order_type = self.strategy.order_types['buy'] - time_in_force = self.strategy.order_time_in_force['sell'] + time_in_force = self.strategy.order_time_in_force['buy'] # Confirm trade entry: if not pos_adjust: if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( @@ -517,15 +555,21 @@ class Backtesting: return None 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, 8) 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, @@ -533,28 +577,36 @@ class Backtesting: exchange='backtesting', orders=[] ) + trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) order = Order( - ft_is_open=False, + 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="buy", side="buy", - order_type="market", - status="closed", + 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=amount, - cost=stake_amount + trade.fee_open + 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) - if pos_adjust: - trade.recalc_trade_from_orders() + trade.recalc_trade_from_orders() return trade @@ -567,6 +619,9 @@ class Backtesting: 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_buys == 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() @@ -587,6 +642,51 @@ class Backtesting: self.rejected_trades += 1 return False + 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(order.side, trade, order, current_time) + if timedout: + if order.side == 'buy': + self.timedout_entry_orders += 1 + if trade.nr_of_successful_buys == 0: + # Remove trade due to buy timeout expiration. + return True + else: + # Close additional buy order + del trade.orders[trade.orders.index(order)] + if order.side == 'sell': + self.timedout_exit_orders += 1 + # Close sell order and retry selling 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, @@ -616,7 +716,7 @@ class Backtesting: # Indexes per pair, so some pairs are allowed to have a missing start. indexes: Dict = defaultdict(int) - tmp = start_date + timedelta(minutes=self.timeframe_min) + current_time = start_date + timedelta(minutes=self.timeframe_min) open_trades: Dict[str, List[LocalTrade]] = defaultdict(list) open_trade_count = 0 @@ -625,35 +725,27 @@ class Backtesting: (end_date - start_date) / timedelta(minutes=self.timeframe_min))) # Loop timerange and get candle for each pair at that point in time - while tmp <= end_date: + 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] - 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 - continue - - # Waits until the time-counter reaches the start of the data for this pair. - if row[DATE_IDX] > tmp: + 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) + # 1. 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 if ( (position_stacking or len(open_trades[pair]) == 0) and self.trade_slot_available(max_open_trades, open_trade_count_start) - and tmp != end_date + and current_time != end_date and row[BUY_IDX] == 1 and row[SELL_IDX] != 1 and not PairLocks.is_pair_locked(pair, row[DATE_IDX]) @@ -661,32 +753,51 @@ class Backtesting: trade = self._enter_trade(pair, row) if trade: # TODO: hacky workaround to avoid opening > max_open_trades - # This emulates previous behaviour - not sure if this is correct + # 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) - LocalTrade.add_bt_trade(trade) for trade in list(open_trades[pair]): - # also check the buying candle for sell conditions. - trade_entry = self._get_sell_trade_entry(trade, row) - # Sell occurred - if trade_entry: + # 2. Process buy orders. + order = trade.select_order('buy', 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() + + # 3. Create sell orders (if any) + if not trade.open_order_id: + self._get_sell_trade_entry(trade, row) # Place sell order if necessary + + # 4. Process sell orders. + order = trade.select_order('sell', is_open=True) + if order and self._get_order_filled(order.price, row): + trade.open_order_id = None + 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_entry) - if enable_protections: - self.protections.stop_per_pair(pair, row[DATE_IDX]) - self.protections.global_stop(tmp) + trades.append(trade) + self.wallets.update() + self.run_protections(enable_protections, pair, current_time) + + # 5. Cancel expired buy/sell orders. + if self.check_order_cancel(trade, current_time): + # Close trade due to buy timeout expiration. + open_trade_count -= 1 + open_trades[pair].remove(trade) + self.wallets.update() # Move time one configured time_interval ahead. self.progress.increment() - tmp += timedelta(minutes=self.timeframe_min) + current_time += timedelta(minutes=self.timeframe_min) trades += self.handle_left_open(open_trades, data=data) self.wallets.update() @@ -697,6 +808,8 @@ class Backtesting: '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']), } diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 859238af3..5b1c2e135 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -436,6 +436,8 @@ def generate_strategy_stats(pairlist: List[str], 'dry_run_wallet': starting_balance, 'final_balance': content['final_balance'], 'rejected_signals': content['rejected_signals'], + 'timedout_entry_orders': content['timedout_entry_orders'], + 'timedout_exit_orders': content['timedout_exit_orders'], 'max_open_trades': max_open_trades, 'max_open_trades_setting': (config['max_open_trades'] if config['max_open_trades'] != float('inf') else -1), @@ -726,6 +728,9 @@ def text_table_add_metrics(strat_results: Dict) -> str: ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), ('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')), + ('Entry/Exit Timeouts', + f"{strat_results.get('timedout_entry_orders', 'N/A')} / " + f"{strat_results.get('timedout_exit_orders', 'N/A')}"), ('', ''), # Empty line to improve readability ('Min balance', round_coin_value(strat_results['csum_min'], diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index ff500b549..dfa98d97f 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -132,6 +132,10 @@ class Order(_DECL_BASE): order_filled_date = Column(DateTime, nullable=True) order_update_date = Column(DateTime, nullable=True) + @property + def order_date_utc(self): + return self.order_date.replace(tzinfo=timezone.utc) + def __repr__(self): return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, ' @@ -188,6 +192,12 @@ class Order(_DECL_BASE): 'status': self.status, } + 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]): """ @@ -635,14 +645,27 @@ class LocalTrade(): 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(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]: + 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: Side of the order (either 'buy' or 'sell') :param is_open: Only search for open orders? :return: latest Order object if it exists, else None """ - orders = [o for o in self.orders if o.side == order_side] + orders = self.orders + if order_side: + orders = [o for o in self.orders if o.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: diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 78dae6c5d..0bd7834e2 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -18,6 +18,7 @@ from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange.exchange import timeframe_to_next_date from freqtrade.persistence import PairLocks, Trade +from freqtrade.persistence.models import LocalTrade, Order from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -862,23 +863,22 @@ class IStrategy(ABC, HyperStrategyMixin): else: return current_profit > roi - def ft_check_timed_out(self, side: str, trade: Trade, order: Dict, + def ft_check_timed_out(self, side: str, trade: LocalTrade, order: Order, current_time: datetime) -> bool: """ FT Internal method. Check if timeout is active, and if the order is still open and timed out """ timeout = self.config.get('unfilledtimeout', {}).get(side) - ordertime = arrow.get(order['datetime']).datetime if timeout is not None: timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes') timeout_kwargs = {timeout_unit: -timeout} timeout_threshold = current_time + timedelta(**timeout_kwargs) - timedout = (order['status'] == 'open' and order['side'] == side - and ordertime < timeout_threshold) + timedout = (order.status == 'open' and order.side == side + and order.order_date_utc < timeout_threshold) if timedout: return True - time_method = self.check_sell_timeout if order['side'] == 'sell' else self.check_buy_timeout + time_method = self.check_sell_timeout if order.side == 'sell' else self.check_buy_timeout return strategy_safe_wrapper(time_method, default_retval=False)( diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index e57739595..93f3d3800 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -211,7 +211,7 @@ class Wallets: return stake_amount - def get_trade_stake_amount(self, pair: str, edge=None) -> float: + def get_trade_stake_amount(self, pair: str, edge=None, update: bool = True) -> float: """ Calculate stake amount for the trade :return: float: Stake amount @@ -219,7 +219,8 @@ class Wallets: """ stake_amount: float # Ensure wallets are uptodate. - self.update() + if update: + self.update() val_tied_up = Trade.total_open_trades_stakes() available_amount = self.get_available_stake_amount() diff --git a/tests/conftest.py b/tests/conftest.py index 92deb6568..630223d55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ from freqtrade.edge import PairInfo from freqtrade.enums import RunMode from freqtrade.exchange import Exchange from freqtrade.freqtradebot import FreqtradeBot -from freqtrade.persistence import LocalTrade, Trade, init_db +from freqtrade.persistence import LocalTrade, Order, Trade, init_db from freqtrade.resolvers import ExchangeResolver from freqtrade.worker import Worker from tests.conftest_trades import (mock_trade_1, mock_trade_2, mock_trade_3, mock_trade_4, @@ -1985,7 +1985,7 @@ def import_fails() -> None: @pytest.fixture(scope="function") def open_trade(): - return Trade( + trade = Trade( pair='ETH/BTC', open_rate=0.00001099, exchange='binance', @@ -1997,6 +1997,26 @@ def open_trade(): open_date=arrow.utcnow().shift(minutes=-601).datetime, is_open=True ) + trade.orders = [ + Order( + ft_order_side='buy', + ft_pair=trade.pair, + ft_is_open=False, + order_id='123456789', + status="closed", + symbol=trade.pair, + order_type="market", + side="buy", + price=trade.open_rate, + average=trade.open_rate, + filled=trade.amount, + remaining=0, + cost=trade.open_rate * trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ) + ] + return trade @pytest.fixture(scope="function") diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index 68088d2d5..ce6ea0f0c 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -36,6 +36,8 @@ class BTContainer(NamedTuple): trailing_stop_positive_offset: float = 0.0 use_sell_signal: bool = False use_custom_stoploss: bool = False + custom_entry_price: Optional[float] = None + custom_exit_price: Optional[float] = None def _get_frame_time_from_offset(offset): diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index f41b6101c..3164e11b9 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, C0330, unused-argument import logging +from unittest.mock import MagicMock import pytest @@ -534,6 +535,80 @@ tc33 = BTContainer(data=[ )] ) +# Test 34: Custom-entry-price below all candles should timeout - so no trade happens. +tc34 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # timeout + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.0, + custom_entry_price=4200, trades=[] +) + +# Test 35: Custom-entry-price above all candles should have rate adjusted to "entry candle high" +tc35 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Timeout + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=-0.01, + custom_entry_price=7200, trades=[ + BTrade(sell_reason=SellType.STOP_LOSS, open_tick=1, close_tick=1) + ] +) + +# Test 36: Custom-entry-price around candle low +# Causes immediate ROI exit. This is currently expected behavior (#6261) +# https://github.com/freqtrade/freqtrade/issues/6261 +# But may change at a later point. +tc36 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Enter and immediate ROI + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.1, + custom_entry_price=4952, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=1)] +) + + +# Test 37: Custom exit price below all candles +# Price adjusted to candle Low. +tc37 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout + [3, 5100, 5100, 4950, 4950, 6172, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=-0.01, + use_sell_signal=True, + custom_exit_price=4552, + trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=3)] +) + +# Test 38: Custom exit price above all candles +# causes sell signal timeout +tc38 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout + [3, 5100, 5100, 4950, 4950, 6172, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.0, + use_sell_signal=True, + custom_exit_price=6052, + trades=[BTrade(sell_reason=SellType.FORCE_SELL, open_tick=1, close_tick=4)] +) + + TESTS = [ tc0, tc1, @@ -569,6 +644,11 @@ TESTS = [ tc31, tc32, tc33, + tc34, + tc35, + tc36, + tc37, + tc38, ] @@ -597,6 +677,10 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: backtesting.required_startup = 0 backtesting.strategy.advise_buy = lambda a, m: frame backtesting.strategy.advise_sell = lambda a, m: frame + if data.custom_entry_price: + backtesting.strategy.custom_entry_price = MagicMock(return_value=data.custom_entry_price) + if data.custom_exit_price: + backtesting.strategy.custom_exit_price = MagicMock(return_value=data.custom_exit_price) backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss caplog.set_level(logging.DEBUG) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 3af431f87..d61dffac4 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -521,6 +521,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: # Fake 2 trades, so there's not enough amount for the next trade left. LocalTrade.trades_open.append(trade) LocalTrade.trades_open.append(trade) + backtesting.wallets.update() trade = backtesting._enter_trade(pair, row=row) assert trade is None LocalTrade.trades_open.pop() @@ -528,6 +529,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: assert trade is not None backtesting.strategy.custom_stake_amount = lambda **kwargs: 123.5 + backtesting.wallets.update() trade = backtesting._enter_trade(pair, row=row) assert trade assert trade.stake_amount == 123.5 @@ -635,7 +637,8 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None: assert res.sell_reason == SellType.ROI.value # Sell at minute 3 (not available above!) assert res.close_date_utc == datetime(2020, 1, 1, 5, 3, tzinfo=timezone.utc) - assert round(res.close_rate, 3) == round(209.0225, 3) + sell_order = res.select_order('sell', True) + assert sell_order is not None def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: @@ -1020,6 +1023,8 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', @@ -1128,6 +1133,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1135,6 +1142,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1237,6 +1246,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1244,6 +1255,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1305,6 +1318,8 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 1f7c2ee8c..2328585dd 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -364,6 +364,8 @@ def test_hyperopt_format_results(hyperopt): 'locks': [], 'final_balance': 0.02, 'rejected_signals': 2, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': 1619718665, 'backtest_end_time': 1619718665, } @@ -431,6 +433,8 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: 'config': hyperopt_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 68257f4d8..c8768e236 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -82,6 +82,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'locks': [], 'final_balance': 1000.02, 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp, 'run_id': '123', @@ -131,6 +133,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'locks': [], 'final_balance': 1000.02, 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp, 'run_id': '124', diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 523696759..a84616516 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -2042,6 +2042,7 @@ def test_check_handle_timedout_buy_usercustom(default_conf_usdt, ticker_usdt, li def test_check_handle_timedout_buy(default_conf_usdt, ticker_usdt, limit_buy_order_old, open_trade, fee, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old['id'] = open_trade.open_order_id limit_buy_cancel = deepcopy(limit_buy_order_old) limit_buy_cancel['status'] = 'canceled' cancel_order_mock = MagicMock(return_value=limit_buy_cancel) @@ -2126,6 +2127,8 @@ def test_check_handle_timedout_buy_exception(default_conf_usdt, ticker_usdt, def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, limit_sell_order_old, mocker, open_trade, caplog) -> None: default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440, "exit_timeout_count": 1} + limit_sell_order_old['id'] = open_trade.open_order_id + rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) @@ -2174,7 +2177,7 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l # 2nd canceled trade - Fail execute sell caplog.clear() - open_trade.open_order_id = 'order_id_2' + open_trade.open_order_id = limit_sell_order_old['id'] mocker.patch('freqtrade.persistence.Trade.get_exit_order_count', return_value=1) mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit', side_effect=DependencyException) @@ -2185,7 +2188,7 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l caplog.clear() # 2nd canceled trade ... - open_trade.open_order_id = 'order_id_2' + open_trade.open_order_id = limit_sell_order_old['id'] freqtrade.check_handle_timedout() assert log_has_re('Emergencyselling trade.*', caplog) assert et_mock.call_count == 1 @@ -2195,6 +2198,7 @@ def test_check_handle_timedout_sell(default_conf_usdt, ticker_usdt, limit_sell_o open_trade) -> None: rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() + limit_sell_order_old['id'] = open_trade.open_order_id patch_exchange(mocker) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -2253,6 +2257,7 @@ def test_check_handle_cancelled_sell(default_conf_usdt, ticker_usdt, limit_sell_ def test_check_handle_timedout_partial(default_conf_usdt, ticker_usdt, limit_buy_order_old_partial, open_trade, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial['id'] = open_trade.open_order_id limit_buy_canceled = deepcopy(limit_buy_order_old_partial) limit_buy_canceled['status'] = 'canceled' @@ -2283,6 +2288,7 @@ def test_check_handle_timedout_partial_fee(default_conf_usdt, ticker_usdt, open_ limit_buy_order_old_partial, trades_for_order, limit_buy_order_old_partial_canceled, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial['id'] = open_trade.open_order_id cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled) mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=0)) patch_exchange(mocker) @@ -2322,6 +2328,8 @@ def test_check_handle_timedout_partial_except(default_conf_usdt, ticker_usdt, op fee, limit_buy_order_old_partial, trades_for_order, limit_buy_order_old_partial_canceled, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial_canceled['id'] = open_trade.open_order_id + limit_buy_order_old_partial['id'] = open_trade.open_order_id cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled) patch_exchange(mocker) mocker.patch.multiple(