• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

freqtrade / freqtrade / 4131167254

pending completion
4131167254

push

github-actions

GitHub
Merge pull request #7983 from stash86/bt-metrics

16866 of 17748 relevant lines covered (95.03%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.19
/freqtrade/optimize/backtesting.py
1
# pragma pylint: disable=missing-docstring, W0212, too-many-arguments
2

3
"""
1✔
4
This module contains the backtesting logic
5
"""
6
import logging
1✔
7
from collections import defaultdict
1✔
8
from copy import deepcopy
1✔
9
from datetime import datetime, timedelta, timezone
1✔
10
from typing import Any, Dict, List, Optional, Tuple
1✔
11

12
import pandas as pd
1✔
13
from numpy import nan
1✔
14
from pandas import DataFrame
1✔
15

16
from freqtrade import constants
1✔
17
from freqtrade.configuration import TimeRange, validate_config_consistency
1✔
18
from freqtrade.constants import DATETIME_PRINT_FORMAT, Config, LongShort
1✔
19
from freqtrade.data import history
1✔
20
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
1✔
21
from freqtrade.data.converter import trim_dataframe, trim_dataframes
1✔
22
from freqtrade.data.dataprovider import DataProvider
1✔
23
from freqtrade.enums import (BacktestState, CandleType, ExitCheckTuple, ExitType, RunMode,
1✔
24
                             TradingMode)
25
from freqtrade.exceptions import DependencyException, OperationalException
1✔
26
from freqtrade.exchange import (amount_to_contract_precision, price_to_precision,
1✔
27
                                timeframe_to_minutes, timeframe_to_seconds)
28
from freqtrade.mixins import LoggingMixin
1✔
29
from freqtrade.optimize.backtest_caching import get_strategy_run_id
1✔
30
from freqtrade.optimize.bt_progress import BTProgress
1✔
31
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
1✔
32
                                                 store_backtest_signal_candles,
33
                                                 store_backtest_stats)
34
from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade
1✔
35
from freqtrade.plugins.pairlistmanager import PairListManager
1✔
36
from freqtrade.plugins.protectionmanager import ProtectionManager
1✔
37
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
1✔
38
from freqtrade.strategy.interface import IStrategy
1✔
39
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
1✔
40
from freqtrade.wallets import Wallets
1✔
41

42

43
logger = logging.getLogger(__name__)
1✔
44

45
# Indexes for backtest tuples
46
DATE_IDX = 0
1✔
47
OPEN_IDX = 1
1✔
48
HIGH_IDX = 2
1✔
49
LOW_IDX = 3
1✔
50
CLOSE_IDX = 4
1✔
51
LONG_IDX = 5
1✔
52
ELONG_IDX = 6  # Exit long
1✔
53
SHORT_IDX = 7
1✔
54
ESHORT_IDX = 8  # Exit short
1✔
55
ENTER_TAG_IDX = 9
1✔
56
EXIT_TAG_IDX = 10
1✔
57

58
# Every change to this headers list must evaluate further usages of the resulting tuple
59
# and eventually change the constants for indexes at the top
60
HEADERS = ['date', 'open', 'high', 'low', 'close', 'enter_long', 'exit_long',
1✔
61
           'enter_short', 'exit_short', 'enter_tag', 'exit_tag']
62

63

64
class Backtesting:
1✔
65
    """
66
    Backtesting class, this class contains all the logic to run a backtest
67

68
    To run a backtest:
69
    backtesting = Backtesting(config)
70
    backtesting.start()
71
    """
72

73
    def __init__(self, config: Config) -> None:
1✔
74

75
        LoggingMixin.show_output = False
1✔
76
        self.config = config
1✔
77
        self.results: Dict[str, Any] = {}
1✔
78
        self.trade_id_counter: int = 0
1✔
79
        self.order_id_counter: int = 0
1✔
80

81
        config['dry_run'] = True
1✔
82
        self.run_ids: Dict[str, str] = {}
1✔
83
        self.strategylist: List[IStrategy] = []
1✔
84
        self.all_results: Dict[str, Dict] = {}
1✔
85
        self.processed_dfs: Dict[str, Dict] = {}
1✔
86

87
        self._exchange_name = self.config['exchange']['name']
1✔
88
        self.exchange = ExchangeResolver.load_exchange(
1✔
89
            self._exchange_name, self.config, load_leverage_tiers=True)
90
        self.dataprovider = DataProvider(self.config, self.exchange)
1✔
91

92
        if self.config.get('strategy_list'):
1✔
93
            if self.config.get('freqai', {}).get('enabled', False):
1✔
94
                logger.warning("Using --strategy-list with FreqAI REQUIRES all strategies "
1✔
95
                               "to have identical populate_any_indicators.")
96
            for strat in list(self.config['strategy_list']):
1✔
97
                stratconf = deepcopy(self.config)
1✔
98
                stratconf['strategy'] = strat
1✔
99
                self.strategylist.append(StrategyResolver.load_strategy(stratconf))
1✔
100
                validate_config_consistency(stratconf)
1✔
101

102
        else:
103
            # No strategy list specified, only one strategy
104
            self.strategylist.append(StrategyResolver.load_strategy(self.config))
1✔
105
            validate_config_consistency(self.config)
1✔
106

107
        if "timeframe" not in self.config:
1✔
108
            raise OperationalException("Timeframe needs to be set in either "
1✔
109
                                       "configuration or as cli argument `--timeframe 5m`")
110
        self.timeframe = str(self.config.get('timeframe'))
1✔
111
        self.timeframe_min = timeframe_to_minutes(self.timeframe)
1✔
112
        self.init_backtest_detail()
1✔
113
        self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
1✔
114
        if 'VolumePairList' in self.pairlists.name_list:
1✔
115
            raise OperationalException("VolumePairList not allowed for backtesting. "
1✔
116
                                       "Please use StaticPairList instead.")
117
        if 'PerformanceFilter' in self.pairlists.name_list:
1✔
118
            raise OperationalException("PerformanceFilter not allowed for backtesting.")
1✔
119

120
        if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list:
1✔
121
            raise OperationalException(
1✔
122
                "PrecisionFilter not allowed for backtesting multiple strategies."
123
            )
124

125
        self.dataprovider.add_pairlisthandler(self.pairlists)
1✔
126
        self.pairlists.refresh_pairlist()
1✔
127

128
        if len(self.pairlists.whitelist) == 0:
1✔
129
            raise OperationalException("No pair in whitelist.")
1✔
130

131
        if config.get('fee', None) is not None:
1✔
132
            self.fee = config['fee']
1✔
133
        else:
134
            self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0])
1✔
135
        self.precision_mode = self.exchange.precisionMode
1✔
136

137
        if self.config.get('freqai_backtest_live_models', False):
1✔
138
            from freqtrade.freqai.utils import get_timerange_backtest_live_models
1✔
139
            self.config['timerange'] = get_timerange_backtest_live_models(self.config)
1✔
140

141
        self.timerange = TimeRange.parse_timerange(
1✔
142
            None if self.config.get('timerange') is None else str(self.config.get('timerange')))
143

144
        # Get maximum required startup period
145
        self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
1✔
146
        self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe)
1✔
147

148
        if self.config.get('freqai', {}).get('enabled', False):
1✔
149
            # For FreqAI, increase the required_startup to includes the training data
150
            self.required_startup = self.dataprovider.get_required_startup(self.timeframe)
1✔
151

152
        # Add maximum startup candle count to configuration for informative pairs support
153
        self.config['startup_candle_count'] = self.required_startup
1✔
154

155
        self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT)
1✔
156
        # strategies which define "can_short=True" will fail to load in Spot mode.
157
        self._can_short = self.trading_mode != TradingMode.SPOT
1✔
158
        self._position_stacking: bool = self.config.get('position_stacking', False)
1✔
159
        self.enable_protections: bool = self.config.get('enable_protections', False)
1✔
160

161
        self.init_backtest()
1✔
162

163
    @staticmethod
1✔
164
    def cleanup():
1✔
165
        LoggingMixin.show_output = True
1✔
166
        PairLocks.use_db = True
1✔
167
        Trade.use_db = True
1✔
168

169
    def init_backtest_detail(self) -> None:
1✔
170
        # Load detail timeframe if specified
171
        self.timeframe_detail = str(self.config.get('timeframe_detail', ''))
1✔
172
        if self.timeframe_detail:
1✔
173
            self.timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail)
1✔
174
            if self.timeframe_min <= self.timeframe_detail_min:
1✔
175
                raise OperationalException(
1✔
176
                    "Detail timeframe must be smaller than strategy timeframe.")
177

178
        else:
179
            self.timeframe_detail_min = 0
1✔
180
        self.detail_data: Dict[str, DataFrame] = {}
1✔
181
        self.futures_data: Dict[str, DataFrame] = {}
1✔
182

183
    def init_backtest(self):
1✔
184

185
        self.prepare_backtest(False)
1✔
186

187
        self.wallets = Wallets(self.config, self.exchange, log=False)
1✔
188

189
        self.progress = BTProgress()
1✔
190
        self.abort = False
1✔
191

192
    def _set_strategy(self, strategy: IStrategy):
1✔
193
        """
194
        Load strategy into backtesting
195
        """
196
        self.strategy: IStrategy = strategy
1✔
197
        strategy.dp = self.dataprovider
1✔
198
        # Attach Wallets to Strategy baseclass
199
        strategy.wallets = self.wallets
1✔
200
        # Set stoploss_on_exchange to false for backtesting,
201
        # since a "perfect" stoploss-exit is assumed anyway
202
        # And the regular "stoploss" function would not apply to that case
203
        self.strategy.order_types['stoploss_on_exchange'] = False
1✔
204

205
        self.strategy.ft_bot_start()
1✔
206
        strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)()
1✔
207

208
    def _load_protections(self, strategy: IStrategy):
1✔
209
        if self.config.get('enable_protections', False):
1✔
210
            conf = self.config
1✔
211
            if hasattr(strategy, 'protections'):
1✔
212
                conf = deepcopy(conf)
1✔
213
                conf['protections'] = strategy.protections
1✔
214
            self.protections = ProtectionManager(self.config, strategy.protections)
1✔
215

216
    def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
1✔
217
        """
218
        Loads backtest data and returns the data combined with the timerange
219
        as tuple.
220
        """
221
        self.progress.init_step(BacktestState.DATALOAD, 1)
1✔
222

223
        data = history.load_data(
1✔
224
            datadir=self.config['datadir'],
225
            pairs=self.pairlists.whitelist,
226
            timeframe=self.timeframe,
227
            timerange=self.timerange,
228
            startup_candles=self.config['startup_candle_count'],
229
            fail_without_data=True,
230
            data_format=self.config.get('dataformat_ohlcv', 'json'),
231
            candle_type=self.config.get('candle_type_def', CandleType.SPOT)
232
        )
233

234
        min_date, max_date = history.get_timerange(data)
1✔
235

236
        logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
1✔
237
                    f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
238
                    f'({(max_date - min_date).days} days).')
239

240
        # Adjust startts forward if not enough data is available
241
        self.timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
1✔
242
                                                 self.required_startup, min_date)
243

244
        self.progress.set_new_value(1)
1✔
245
        return data, self.timerange
1✔
246

247
    def load_bt_data_detail(self) -> None:
1✔
248
        """
249
        Loads backtest detail data (smaller timeframe) if necessary.
250
        """
251
        if self.timeframe_detail:
1✔
252
            self.detail_data = history.load_data(
1✔
253
                datadir=self.config['datadir'],
254
                pairs=self.pairlists.whitelist,
255
                timeframe=self.timeframe_detail,
256
                timerange=self.timerange,
257
                startup_candles=0,
258
                fail_without_data=True,
259
                data_format=self.config.get('dataformat_ohlcv', 'json'),
260
                candle_type=self.config.get('candle_type_def', CandleType.SPOT)
261
            )
262
        else:
263
            self.detail_data = {}
1✔
264
        if self.trading_mode == TradingMode.FUTURES:
1✔
265
            # Load additional futures data.
266
            funding_rates_dict = history.load_data(
1✔
267
                datadir=self.config['datadir'],
268
                pairs=self.pairlists.whitelist,
269
                timeframe=self.exchange.get_option('mark_ohlcv_timeframe'),
270
                timerange=self.timerange,
271
                startup_candles=0,
272
                fail_without_data=True,
273
                data_format=self.config.get('dataformat_ohlcv', 'json'),
274
                candle_type=CandleType.FUNDING_RATE
275
            )
276

277
            # For simplicity, assign to CandleType.Mark (might contian index candles!)
278
            mark_rates_dict = history.load_data(
1✔
279
                datadir=self.config['datadir'],
280
                pairs=self.pairlists.whitelist,
281
                timeframe=self.exchange.get_option('mark_ohlcv_timeframe'),
282
                timerange=self.timerange,
283
                startup_candles=0,
284
                fail_without_data=True,
285
                data_format=self.config.get('dataformat_ohlcv', 'json'),
286
                candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price"))
287
            )
288
            # Combine data to avoid combining the data per trade.
289
            unavailable_pairs = []
1✔
290
            for pair in self.pairlists.whitelist:
1✔
291
                if pair not in self.exchange._leverage_tiers:
1✔
292
                    unavailable_pairs.append(pair)
1✔
293
                    continue
1✔
294

295
                self.futures_data[pair] = self.exchange.combine_funding_and_mark(
1✔
296
                    funding_rates=funding_rates_dict[pair],
297
                    mark_rates=mark_rates_dict[pair],
298
                    futures_funding_rate=self.config.get('futures_funding_rate', None),
299
                )
300

301
            if unavailable_pairs:
1✔
302
                raise OperationalException(
1✔
303
                    f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. "
304
                    "It is therefore impossible to backtest with this pair at the moment.")
305
        else:
306
            self.futures_data = {}
1✔
307

308
    def prepare_backtest(self, enable_protections):
1✔
309
        """
310
        Backtesting setup method - called once for every call to "backtest()".
311
        """
312
        PairLocks.use_db = False
1✔
313
        PairLocks.timeframe = self.config['timeframe']
1✔
314
        Trade.use_db = False
1✔
315
        PairLocks.reset_locks()
1✔
316
        Trade.reset_trades()
1✔
317
        self.rejected_trades = 0
1✔
318
        self.timedout_entry_orders = 0
1✔
319
        self.timedout_exit_orders = 0
1✔
320
        self.canceled_trade_entries = 0
1✔
321
        self.canceled_entry_orders = 0
1✔
322
        self.replaced_entry_orders = 0
1✔
323
        self.dataprovider.clear_cache()
1✔
324
        if enable_protections:
1✔
325
            self._load_protections(self.strategy)
1✔
326

327
    def check_abort(self):
1✔
328
        """
329
        Check if abort was requested, raise DependencyException if that's the case
330
        Only applies to Interactive backtest mode (webserver mode)
331
        """
332
        if self.abort:
1✔
333
            self.abort = False
1✔
334
            raise DependencyException("Stop requested")
1✔
335

336
    def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
1✔
337
        """
338
        Helper function to convert a processed dataframes into lists for performance reasons.
339

340
        Used by backtest() - so keep this optimized for performance.
341

342
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
343
        optimize memory usage!
344
        """
345

346
        data: Dict = {}
1✔
347
        self.progress.init_step(BacktestState.CONVERT, len(processed))
1✔
348

349
        # Create dict with data
350
        for pair in processed.keys():
1✔
351
            pair_data = processed[pair]
1✔
352
            self.check_abort()
1✔
353
            self.progress.increment()
1✔
354

355
            if not pair_data.empty:
1✔
356
                # Cleanup from prior runs
357
                pair_data.drop(HEADERS[5:] + ['buy', 'sell'], axis=1, errors='ignore')
1✔
358

359
            df_analyzed = self.strategy.advise_exit(
1✔
360
                self.strategy.advise_entry(pair_data, {'pair': pair}),
361
                {'pair': pair}
362
            ).copy()
363
            # Trim startup period from analyzed dataframe
364
            df_analyzed = processed[pair] = pair_data = trim_dataframe(
1✔
365
                df_analyzed, self.timerange, startup_candles=self.required_startup)
366
            # Update dataprovider cache
367
            self.dataprovider._set_cached_df(
1✔
368
                pair, self.timeframe, df_analyzed, self.config['candle_type_def'])
369

370
            # Create a copy of the dataframe before shifting, that way the entry signal/tag
371
            # remains on the correct candle for callbacks.
372
            df_analyzed = df_analyzed.copy()
1✔
373

374
            # To avoid using data from future, we use entry/exit signals shifted
375
            # from the previous candle
376
            for col in HEADERS[5:]:
1✔
377
                tag_col = col in ('enter_tag', 'exit_tag')
1✔
378
                if col in df_analyzed.columns:
1✔
379
                    df_analyzed[col] = df_analyzed.loc[:, col].replace(
1✔
380
                        [nan], [0 if not tag_col else None]).shift(1)
381
                elif not df_analyzed.empty:
1✔
382
                    df_analyzed[col] = 0 if not tag_col else None
1✔
383

384
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
385

386
            # Convert from Pandas to list for performance reasons
387
            # (Looping Pandas is slow.)
388
            data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else []
1✔
389
        return data
1✔
390

391
    def _get_close_rate(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
392
                        trade_dur: int) -> float:
393
        """
394
        Get close rate for backtesting result
395
        """
396
        # Special handling if high or low hit STOP_LOSS or ROI
397
        if exit.exit_type in (
1✔
398
                ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS, ExitType.LIQUIDATION):
399
            return self._get_close_rate_for_stoploss(row, trade, exit, trade_dur)
1✔
400
        elif exit.exit_type == (ExitType.ROI):
1✔
401
            return self._get_close_rate_for_roi(row, trade, exit, trade_dur)
1✔
402
        else:
403
            return row[OPEN_IDX]
1✔
404

405
    def _get_close_rate_for_stoploss(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
406
                                     trade_dur: int) -> float:
407
        # our stoploss was already lower than candle high,
408
        # possibly due to a cancelled trade exit.
409
        # exit at open price.
410
        is_short = trade.is_short or False
1✔
411
        leverage = trade.leverage or 1.0
1✔
412
        side_1 = -1 if is_short else 1
1✔
413
        if exit.exit_type == ExitType.LIQUIDATION and trade.liquidation_price:
1✔
414
            stoploss_value = trade.liquidation_price
×
415
        else:
416
            stoploss_value = trade.stop_loss
1✔
417

418
        if is_short:
1✔
419
            if stoploss_value < row[LOW_IDX]:
1✔
420
                return row[OPEN_IDX]
1✔
421
        else:
422
            if stoploss_value > row[HIGH_IDX]:
1✔
423
                return row[OPEN_IDX]
1✔
424

425
        # Special case: trailing triggers within same candle as trade opened. Assume most
426
        # pessimistic price movement, which is moving just enough to arm stoploss and
427
        # immediately going down to stop price.
428
        if exit.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0:
1✔
429
            if (
1✔
430
                not self.strategy.use_custom_stoploss and self.strategy.trailing_stop
431
                and self.strategy.trailing_only_offset_is_reached
432
                and self.strategy.trailing_stop_positive_offset is not None
433
                and self.strategy.trailing_stop_positive
434
            ):
435
                # Worst case: price reaches stop_positive_offset and dives down.
436
                stop_rate = (row[OPEN_IDX] *
1✔
437
                             (1 + side_1 * abs(self.strategy.trailing_stop_positive_offset) -
438
                              side_1 * abs(self.strategy.trailing_stop_positive / leverage)))
439
            else:
440
                # Worst case: price ticks tiny bit above open and dives down.
441
                stop_rate = row[OPEN_IDX] * (1 - side_1 * abs(trade.stop_loss_pct / leverage))
1✔
442
                if is_short:
1✔
443
                    assert stop_rate > row[LOW_IDX]
1✔
444
                else:
445
                    assert stop_rate < row[HIGH_IDX]
1✔
446

447
            # Limit lower-end to candle low to avoid exits below the low.
448
            # This still remains "worst case" - but "worst realistic case".
449
            if is_short:
1✔
450
                return min(row[HIGH_IDX], stop_rate)
1✔
451
            else:
452
                return max(row[LOW_IDX], stop_rate)
1✔
453

454
        # Set close_rate to stoploss
455
        return stoploss_value
1✔
456

457
    def _get_close_rate_for_roi(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
458
                                trade_dur: int) -> float:
459
        is_short = trade.is_short or False
1✔
460
        leverage = trade.leverage or 1.0
1✔
461
        side_1 = -1 if is_short else 1
1✔
462
        roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur)
1✔
463
        if roi is not None and roi_entry is not None:
1✔
464
            if roi == -1 and roi_entry % self.timeframe_min == 0:
1✔
465
                # When force_exiting with ROI=-1, the roi time will always be equal to trade_dur.
466
                # If that entry is a multiple of the timeframe (so on candle open)
467
                # - we'll use open instead of close
468
                return row[OPEN_IDX]
1✔
469

470
            # - (Expected abs profit - open_rate - open_fee) / (fee_close -1)
471
            roi_rate = trade.open_rate * roi / leverage
1✔
472
            open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open)
1✔
473
            close_rate = -(roi_rate + open_fee_rate) / (trade.fee_close - side_1 * 1)
1✔
474
            if is_short:
1✔
475
                is_new_roi = row[OPEN_IDX] < close_rate
1✔
476
            else:
477
                is_new_roi = row[OPEN_IDX] > close_rate
1✔
478
            if (trade_dur > 0 and trade_dur == roi_entry
1✔
479
                    and roi_entry % self.timeframe_min == 0
480
                    and is_new_roi):
481
                # new ROI entry came into effect.
482
                # use Open rate if open_rate > calculated exit rate
483
                return row[OPEN_IDX]
1✔
484

485
            if (trade_dur == 0 and (
1✔
486
                (
487
                    is_short
488
                    # Red candle (for longs)
489
                    and row[OPEN_IDX] < row[CLOSE_IDX]  # Red candle
490
                    and trade.open_rate > row[OPEN_IDX]  # trade-open above open_rate
491
                    and close_rate < row[CLOSE_IDX]  # closes below close
492
                )
493
                or
494
                (
495
                    not is_short
496
                    # green candle (for shorts)
497
                    and row[OPEN_IDX] > row[CLOSE_IDX]  # green candle
498
                    and trade.open_rate < row[OPEN_IDX]  # trade-open below open_rate
499
                    and close_rate > row[CLOSE_IDX]  # closes above close
500
                )
501
            )):
502
                # ROI on opening candles with custom pricing can only
503
                # trigger if the entry was at Open or lower wick.
504
                # details: https: // github.com/freqtrade/freqtrade/issues/6261
505
                # If open_rate is < open, only allow exits below the close on red candles.
506
                raise ValueError("Opening candle ROI on red candles.")
1✔
507

508
            # Use the maximum between close_rate and low as we
509
            # cannot exit outside of a candle.
510
            # Applies when a new ROI setting comes in place and the whole candle is above that.
511
            return min(max(close_rate, row[LOW_IDX]), row[HIGH_IDX])
1✔
512

513
        else:
514
            # This should not be reached...
515
            return row[OPEN_IDX]
×
516

517
    def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple
1✔
518
                                           ) -> LocalTrade:
519
        current_rate = row[OPEN_IDX]
1✔
520
        current_date = row[DATE_IDX].to_pydatetime()
1✔
521
        current_profit = trade.calc_profit_ratio(current_rate)
1✔
522
        min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1)
1✔
523
        max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate)
1✔
524
        stake_available = self.wallets.get_available_stake_amount()
1✔
525
        stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
1✔
526
                                             default_retval=None)(
527
            trade=trade,  # type: ignore[arg-type]
528
            current_time=current_date, current_rate=current_rate,
529
            current_profit=current_profit, min_stake=min_stake,
530
            max_stake=min(max_stake, stake_available),
531
            current_entry_rate=current_rate, current_exit_rate=current_rate,
532
            current_entry_profit=current_profit, current_exit_profit=current_profit)
533

534
        # Check if we should increase our position
535
        if stake_amount is not None and stake_amount > 0.0:
1✔
536
            check_adjust_entry = True
1✔
537
            if self.strategy.max_entry_position_adjustment > -1:
1✔
538
                entry_count = trade.nr_of_successful_entries
×
539
                check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment)
×
540
            if check_adjust_entry:
1✔
541
                pos_trade = self._enter_trade(
1✔
542
                    trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade)
543
                if pos_trade is not None:
1✔
544
                    self.wallets.update()
1✔
545
                    return pos_trade
1✔
546

547
        if stake_amount is not None and stake_amount < 0.0:
1✔
548
            amount = amount_to_contract_precision(
1✔
549
                abs(stake_amount * trade.leverage) / current_rate, trade.amount_precision,
550
                self.precision_mode, trade.contract_size)
551
            if amount == 0.0:
1✔
552
                return trade
×
553
            if amount > trade.amount:
1✔
554
                # This is currently ineffective as remaining would become < min tradable
555
                amount = trade.amount
1✔
556
            remaining = (trade.amount - amount) * current_rate
1✔
557
            if remaining < min_stake:
1✔
558
                # Remaining stake is too low to be sold.
559
                return trade
1✔
560
            exit_ = ExitCheckTuple(ExitType.PARTIAL_EXIT)
1✔
561
            pos_trade = self._get_exit_for_signal(trade, row, exit_, amount)
1✔
562
            if pos_trade is not None:
1✔
563
                order = pos_trade.orders[-1]
1✔
564
                if self._get_order_filled(order.price, row):
1✔
565
                    order.close_bt_order(current_date, trade)
1✔
566
                    trade.recalc_trade_from_orders()
1✔
567
                self.wallets.update()
1✔
568
                return pos_trade
1✔
569

570
        return trade
1✔
571

572
    def _get_order_filled(self, rate: float, row: Tuple) -> bool:
1✔
573
        """ Rate is within candle, therefore filled"""
574
        return row[LOW_IDX] <= rate <= row[HIGH_IDX]
1✔
575

576
    def _get_exit_trade_entry_for_candle(self, trade: LocalTrade,
1✔
577
                                         row: Tuple) -> Optional[LocalTrade]:
578

579
        # Check if we need to adjust our current positions
580
        if self.strategy.position_adjustment_enable:
1✔
581
            trade = self._get_adjust_trade_entry_for_candle(trade, row)
1✔
582

583
        enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
584
        exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
585
        exits = self.strategy.should_exit(
1✔
586
            trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(),  # type: ignore
587
            enter=enter, exit_=exit_sig,
588
            low=row[LOW_IDX], high=row[HIGH_IDX]
589
        )
590
        for exit_ in exits:
1✔
591
            t = self._get_exit_for_signal(trade, row, exit_)
1✔
592
            if t:
1✔
593
                return t
1✔
594
        return None
1✔
595

596
    def _get_exit_for_signal(
1✔
597
            self, trade: LocalTrade, row: Tuple, exit_: ExitCheckTuple,
598
            amount: Optional[float] = None) -> Optional[LocalTrade]:
599

600
        exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
601
        if exit_.exit_flag:
1✔
602
            trade.close_date = exit_candle_time
1✔
603
            exit_reason = exit_.exit_reason
1✔
604
            amount_ = amount if amount is not None else trade.amount
1✔
605
            trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
1✔
606
            try:
1✔
607
                close_rate = self._get_close_rate(row, trade, exit_, trade_dur)
1✔
608
            except ValueError:
1✔
609
                return None
1✔
610
            # call the custom exit price,with default value as previous close_rate
611
            current_profit = trade.calc_profit_ratio(close_rate)
1✔
612
            order_type = self.strategy.order_types['exit']
1✔
613
            if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT,
1✔
614
                                   ExitType.PARTIAL_EXIT):
615
                # Checks and adds an exit tag, after checking that the length of the
616
                # row has the length for an exit tag column
617
                if (
1✔
618
                    len(row) > EXIT_TAG_IDX
619
                    and row[EXIT_TAG_IDX] is not None
620
                    and len(row[EXIT_TAG_IDX]) > 0
621
                    and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
622
                ):
623
                    exit_reason = row[EXIT_TAG_IDX]
×
624
                # Custom exit pricing only for exit-signals
625
                if order_type == 'limit':
1✔
626
                    rate = strategy_safe_wrapper(self.strategy.custom_exit_price,
1✔
627
                                                 default_retval=close_rate)(
628
                        pair=trade.pair,
629
                        trade=trade,  # type: ignore[arg-type]
630
                        current_time=exit_candle_time,
631
                        proposed_rate=close_rate, current_profit=current_profit,
632
                        exit_tag=exit_reason)
633
                    if rate != close_rate:
1✔
634
                        close_rate = price_to_precision(rate, trade.price_precision,
1✔
635
                                                        self.precision_mode)
636
                    # We can't place orders lower than current low.
637
                    # freqtrade does not support this in live, and the order would fill immediately
638
                    if trade.is_short:
1✔
639
                        close_rate = min(close_rate, row[HIGH_IDX])
1✔
640
                    else:
641
                        close_rate = max(close_rate, row[LOW_IDX])
1✔
642
            # Confirm trade exit:
643
            time_in_force = self.strategy.order_time_in_force['exit']
1✔
644

645
            if (exit_.exit_type not in (ExitType.LIQUIDATION, ExitType.PARTIAL_EXIT)
1✔
646
                    and not strategy_safe_wrapper(
647
                    self.strategy.confirm_trade_exit, default_retval=True)(
648
                        pair=trade.pair,
649
                        trade=trade,  # type: ignore[arg-type]
650
                        order_type=order_type,
651
                        amount=amount_,
652
                        rate=close_rate,
653
                        time_in_force=time_in_force,
654
                        sell_reason=exit_reason,  # deprecated
655
                        exit_reason=exit_reason,
656
                        current_time=exit_candle_time)):
657
                return None
×
658

659
            trade.exit_reason = exit_reason
1✔
660

661
            return self._exit_trade(trade, row, close_rate, amount_)
1✔
662
        return None
×
663

664
    def _exit_trade(self, trade: LocalTrade, sell_row: Tuple,
1✔
665
                    close_rate: float, amount: float = None) -> Optional[LocalTrade]:
666
        self.order_id_counter += 1
1✔
667
        exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
1✔
668
        order_type = self.strategy.order_types['exit']
1✔
669
        # amount = amount or trade.amount
670
        amount = amount_to_contract_precision(amount or trade.amount, trade.amount_precision,
1✔
671
                                              self.precision_mode, trade.contract_size)
672
        order = Order(
1✔
673
            id=self.order_id_counter,
674
            ft_trade_id=trade.id,
675
            order_date=exit_candle_time,
676
            order_update_date=exit_candle_time,
677
            ft_is_open=True,
678
            ft_pair=trade.pair,
679
            order_id=str(self.order_id_counter),
680
            symbol=trade.pair,
681
            ft_order_side=trade.exit_side,
682
            side=trade.exit_side,
683
            order_type=order_type,
684
            status="open",
685
            price=close_rate,
686
            average=close_rate,
687
            amount=amount,
688
            filled=0,
689
            remaining=amount,
690
            cost=amount * close_rate,
691
        )
692
        trade.orders.append(order)
1✔
693
        return trade
1✔
694

695
    def _get_exit_trade_entry(
1✔
696
            self, trade: LocalTrade, row: Tuple, is_first: bool) -> Optional[LocalTrade]:
697
        exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
698

699
        if is_first and self.trading_mode == TradingMode.FUTURES:
1✔
700
            trade.funding_fees = self.exchange.calculate_funding_fees(
×
701
                self.futures_data[trade.pair],
702
                amount=trade.amount,
703
                is_short=trade.is_short,
704
                open_date=trade.date_last_filled_utc,
705
                close_date=exit_candle_time,
706
            )
707

708
        return self._get_exit_trade_entry_for_candle(trade, row)
1✔
709

710
    def get_valid_price_and_stake(
1✔
711
        self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
712
        direction: LongShort, current_time: datetime, entry_tag: Optional[str],
713
        trade: Optional[LocalTrade], order_type: str, price_precision: Optional[float]
714
    ) -> Tuple[float, float, float, float]:
715

716
        if order_type == 'limit':
1✔
717
            new_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
1✔
718
                                             default_retval=propose_rate)(
719
                pair=pair, current_time=current_time,
720
                proposed_rate=propose_rate, entry_tag=entry_tag,
721
                side=direction,
722
            )  # default value is the open rate
723
            # We can't place orders higher than current high (otherwise it'd be a stop limit entry)
724
            # which freqtrade does not support in live.
725
            if new_rate != propose_rate:
1✔
726
                propose_rate = price_to_precision(new_rate, price_precision,
1✔
727
                                                  self.precision_mode)
728
            if direction == "short":
1✔
729
                propose_rate = max(propose_rate, row[LOW_IDX])
1✔
730
            else:
731
                propose_rate = min(propose_rate, row[HIGH_IDX])
1✔
732

733
        pos_adjust = trade is not None
1✔
734
        leverage = trade.leverage if trade else 1.0
1✔
735
        if not pos_adjust:
1✔
736
            try:
1✔
737
                stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False)
1✔
738
            except DependencyException:
1✔
739
                return 0, 0, 0, 0
1✔
740

741
            max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
1✔
742
            leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
1✔
743
                pair=pair,
744
                current_time=current_time,
745
                current_rate=row[OPEN_IDX],
746
                proposed_leverage=1.0,
747
                max_leverage=max_leverage,
748
                side=direction, entry_tag=entry_tag,
749
            ) if self._can_short else 1.0
750
            # Cap leverage between 1.0 and max_leverage.
751
            leverage = min(max(leverage, 1.0), max_leverage)
1✔
752

753
        min_stake_amount = self.exchange.get_min_pair_stake_amount(
1✔
754
            pair, propose_rate, -0.05, leverage=leverage) or 0
755
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
756
            pair, propose_rate, leverage=leverage)
757
        stake_available = self.wallets.get_available_stake_amount()
1✔
758

759
        if not pos_adjust:
1✔
760
            stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
1✔
761
                                                 default_retval=stake_amount)(
762
                pair=pair, current_time=current_time, current_rate=propose_rate,
763
                proposed_stake=stake_amount, min_stake=min_stake_amount,
764
                max_stake=min(stake_available, max_stake_amount),
765
                leverage=leverage, entry_tag=entry_tag, side=direction)
766

767
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
768
            pair=pair,
769
            stake_amount=stake_amount,
770
            min_stake_amount=min_stake_amount,
771
            max_stake_amount=max_stake_amount,
772
            trade_amount=trade.stake_amount if trade else None
773
        )
774

775
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
776

777
    def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
1✔
778
                     stake_amount: Optional[float] = None,
779
                     trade: Optional[LocalTrade] = None,
780
                     requested_rate: Optional[float] = None,
781
                     requested_stake: Optional[float] = None) -> Optional[LocalTrade]:
782

783
        current_time = row[DATE_IDX].to_pydatetime()
1✔
784
        entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None
1✔
785
        # let's call the custom entry price, using the open price as default price
786
        order_type = self.strategy.order_types['entry']
1✔
787
        pos_adjust = trade is not None and requested_rate is None
1✔
788

789
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
790
        precision_price = self.exchange.get_precision_price(pair)
1✔
791

792
        propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
1✔
793
            pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
794
            order_type, precision_price,
795
        )
796

797
        # replace proposed rate if another rate was requested
798
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
799
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
800

801
        if not stake_amount:
1✔
802
            # In case of pos adjust, still return the original trade
803
            # If not pos adjust, trade is None
804
            return trade
1✔
805
        time_in_force = self.strategy.order_time_in_force['entry']
1✔
806

807
        if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
1✔
808
            self.order_id_counter += 1
1✔
809
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
810
            amount_p = (stake_amount / propose_rate) * leverage
1✔
811

812
            contract_size = self.exchange.get_contract_size(pair)
1✔
813
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
814
            amount = amount_to_contract_precision(amount_p, precision_amount, self.precision_mode,
1✔
815
                                                  contract_size)
816
            # Backcalculate actual stake amount.
817
            stake_amount = amount * propose_rate / leverage
1✔
818

819
            if not pos_adjust:
1✔
820
                # Confirm trade entry:
821
                if not strategy_safe_wrapper(
1✔
822
                        self.strategy.confirm_trade_entry, default_retval=True)(
823
                            pair=pair, order_type=order_type, amount=amount, rate=propose_rate,
824
                            time_in_force=time_in_force, current_time=current_time,
825
                            entry_tag=entry_tag, side=direction):
826
                    return trade
1✔
827

828
            is_short = (direction == 'short')
1✔
829
            # Necessary for Margin trading. Disabled until support is enabled.
830
            # interest_rate = self.exchange.get_interest_rate()
831

832
            if trade is None:
1✔
833
                # Enter trade
834
                self.trade_id_counter += 1
1✔
835
                trade = LocalTrade(
1✔
836
                    id=self.trade_id_counter,
837
                    open_order_id=self.order_id_counter,
838
                    pair=pair,
839
                    base_currency=base_currency,
840
                    stake_currency=self.config['stake_currency'],
841
                    open_rate=propose_rate,
842
                    open_rate_requested=propose_rate,
843
                    open_date=current_time,
844
                    stake_amount=stake_amount,
845
                    amount=amount,
846
                    amount_requested=amount,
847
                    fee_open=self.fee,
848
                    fee_close=self.fee,
849
                    is_open=True,
850
                    enter_tag=entry_tag,
851
                    exchange=self._exchange_name,
852
                    is_short=is_short,
853
                    trading_mode=self.trading_mode,
854
                    leverage=leverage,
855
                    # interest_rate=interest_rate,
856
                    amount_precision=precision_amount,
857
                    price_precision=precision_price,
858
                    precision_mode=self.precision_mode,
859
                    contract_size=contract_size,
860
                    orders=[],
861
                )
862

863
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
864

865
            trade.set_liquidation_price(self.exchange.get_liquidation_price(
1✔
866
                pair=pair,
867
                open_rate=propose_rate,
868
                amount=amount,
869
                stake_amount=trade.stake_amount,
870
                wallet_balance=trade.stake_amount,
871
                is_short=is_short,
872
            ))
873

874
            order = Order(
1✔
875
                id=self.order_id_counter,
876
                ft_trade_id=trade.id,
877
                ft_is_open=True,
878
                ft_pair=trade.pair,
879
                order_id=str(self.order_id_counter),
880
                symbol=trade.pair,
881
                ft_order_side=trade.entry_side,
882
                side=trade.entry_side,
883
                order_type=order_type,
884
                status="open",
885
                order_date=current_time,
886
                order_filled_date=current_time,
887
                order_update_date=current_time,
888
                price=propose_rate,
889
                average=propose_rate,
890
                amount=amount,
891
                filled=0,
892
                remaining=amount,
893
                cost=stake_amount + trade.fee_open,
894
            )
895
            trade.orders.append(order)
1✔
896
            if pos_adjust and self._get_order_filled(order.price, row):
1✔
897
                order.close_bt_order(current_time, trade)
1✔
898
            else:
899
                trade.open_order_id = str(self.order_id_counter)
1✔
900
            trade.recalc_trade_from_orders()
1✔
901

902
        return trade
1✔
903

904
    def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
1✔
905
                         data: Dict[str, List[Tuple]]) -> None:
906
        """
907
        Handling of left open trades at the end of backtesting
908
        """
909
        for pair in open_trades.keys():
1✔
910
            for trade in list(open_trades[pair]):
1✔
911
                if trade.open_order_id and trade.nr_of_successful_entries == 0:
1✔
912
                    # Ignore trade if entry-order did not fill yet
913
                    continue
×
914
                exit_row = data[pair][-1]
1✔
915
                self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount)
1✔
916
                trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade)
1✔
917

918
                trade.close_date = exit_row[DATE_IDX].to_pydatetime()
1✔
919
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
920
                trade.close(exit_row[OPEN_IDX], show_msg=False)
1✔
921
                LocalTrade.close_bt_trade(trade)
1✔
922

923
    def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool:
1✔
924
        # Always allow trades when max_open_trades is enabled.
925
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
926
            return True
1✔
927
        # Rejected trade
928
        self.rejected_trades += 1
1✔
929
        return False
1✔
930

931
    def check_for_trade_entry(self, row) -> Optional[LongShort]:
1✔
932
        enter_long = row[LONG_IDX] == 1
1✔
933
        exit_long = row[ELONG_IDX] == 1
1✔
934
        enter_short = self._can_short and row[SHORT_IDX] == 1
1✔
935
        exit_short = self._can_short and row[ESHORT_IDX] == 1
1✔
936

937
        if enter_long == 1 and not any([exit_long, enter_short]):
1✔
938
            # Long
939
            return 'long'
1✔
940
        if enter_short == 1 and not any([exit_short, enter_long]):
1✔
941
            # Short
942
            return 'short'
1✔
943
        return None
1✔
944

945
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
946
        if self.enable_protections:
1✔
947
            self.protections.stop_per_pair(pair, current_time, side)
1✔
948
            self.protections.global_stop(current_time, side)
1✔
949

950
    def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: Tuple) -> bool:
1✔
951
        """
952
        Check if any open order needs to be cancelled or replaced.
953
        Returns True if the trade should be deleted.
954
        """
955
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
956
            oc = self.check_order_cancel(trade, order, current_time)
1✔
957
            if oc:
1✔
958
                # delete trade due to order timeout
959
                return True
1✔
960
            elif oc is None and self.check_order_replace(trade, order, current_time, row):
1✔
961
                # delete trade due to user request
962
                self.canceled_trade_entries += 1
1✔
963
                return True
1✔
964
        # default maintain trade
965
        return False
1✔
966

967
    def check_order_cancel(
1✔
968
            self, trade: LocalTrade, order: Order, current_time: datetime) -> Optional[bool]:
969
        """
970
        Check if current analyzed order has to be canceled.
971
        Returns True if the trade should be Deleted (initial order was canceled),
972
                False if it's Canceled
973
                None if the order is still active.
974
        """
975
        timedout = self.strategy.ft_check_timed_out(
1✔
976
            trade,  # type: ignore[arg-type]
977
            order, current_time)
978
        if timedout:
1✔
979
            if order.side == trade.entry_side:
1✔
980
                self.timedout_entry_orders += 1
1✔
981
                if trade.nr_of_successful_entries == 0:
1✔
982
                    # Remove trade due to entry timeout expiration.
983
                    return True
1✔
984
                else:
985
                    # Close additional entry order
986
                    del trade.orders[trade.orders.index(order)]
×
987
                    trade.open_order_id = None
×
988
                    return False
×
989
            if order.side == trade.exit_side:
1✔
990
                self.timedout_exit_orders += 1
1✔
991
                # Close exit order and retry exiting on next signal.
992
                del trade.orders[trade.orders.index(order)]
1✔
993
                trade.open_order_id = None
1✔
994
                return False
1✔
995
        return None
1✔
996

997
    def check_order_replace(self, trade: LocalTrade, order: Order, current_time,
1✔
998
                            row: Tuple) -> bool:
999
        """
1000
        Check if current analyzed entry order has to be replaced and do so.
1001
        If user requested cancellation and there are no filled orders in the trade will
1002
        instruct caller to delete the trade.
1003
        Returns True if the trade should be deleted.
1004
        """
1005
        # only check on new candles for open entry orders
1006
        if order.side == trade.entry_side and current_time > order.order_date_utc:
1✔
1007
            requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price,
1✔
1008
                                                   default_retval=order.price)(
1009
                trade=trade,  # type: ignore[arg-type]
1010
                order=order, pair=trade.pair, current_time=current_time,
1011
                proposed_rate=row[OPEN_IDX], current_order_rate=order.price,
1012
                entry_tag=trade.enter_tag, side=trade.trade_direction
1013
            )  # default value is current order price
1014

1015
            # cancel existing order whenever a new rate is requested (or None)
1016
            if requested_rate == order.price:
1✔
1017
                # assumption: there can't be multiple open entry orders at any given time
1018
                return False
1✔
1019
            else:
1020
                del trade.orders[trade.orders.index(order)]
1✔
1021
                trade.open_order_id = None
1✔
1022
                self.canceled_entry_orders += 1
1✔
1023

1024
            # place new order if result was not None
1025
            if requested_rate:
1✔
1026
                self._enter_trade(pair=trade.pair, row=row, trade=trade,
1✔
1027
                                  requested_rate=requested_rate,
1028
                                  requested_stake=(order.remaining * order.price / trade.leverage),
1029
                                  direction='short' if trade.is_short else 'long')
1030
                self.replaced_entry_orders += 1
1✔
1031
            else:
1032
                # assumption: there can't be multiple open entry orders at any given time
1033
                return (trade.nr_of_successful_entries == 0)
1✔
1034
        return False
1✔
1035

1036
    def validate_row(
1✔
1037
            self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
1038
        try:
1✔
1039
            # Row is treated as "current incomplete candle".
1040
            # entry / exit signals are shifted by 1 to compensate for this.
1041
            row = data[pair][row_index]
1✔
1042
        except IndexError:
1✔
1043
            # missing Data for one pair at the end.
1044
            # Warnings for this are shown during data loading
1045
            return None
1✔
1046

1047
        # Waits until the time-counter reaches the start of the data for this pair.
1048
        if row[DATE_IDX] > current_time:
1✔
1049
            return None
1✔
1050
        return row
1✔
1051

1052
    def backtest_loop(
1✔
1053
            self, row: Tuple, pair: str, current_time: datetime, end_date: datetime,
1054
            max_open_trades: int, open_trade_count_start: int, is_first: bool = True) -> int:
1055
        """
1056
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1057

1058
        Backtesting processing for one candle/pair.
1059
        """
1060
        for t in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1061
            # 1. Manage currently open orders of active trades
1062
            if self.manage_open_orders(t, current_time, row):
1✔
1063
                # Close trade
1064
                open_trade_count_start -= 1
1✔
1065
                LocalTrade.remove_bt_trade(t)
1✔
1066
                self.wallets.update()
1✔
1067

1068
        # 2. Process entries.
1069
        # without positionstacking, we can only have one open trade per pair.
1070
        # max_open_trades must be respected
1071
        # don't open on the last row
1072
        # We only open trades on the main candle, not on detail candles
1073
        trade_dir = self.check_for_trade_entry(row)
1✔
1074
        if (
1✔
1075
            (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
1076
            and is_first
1077
            and self.trade_slot_available(max_open_trades, open_trade_count_start)
1078
            and current_time != end_date
1079
            and trade_dir is not None
1080
            and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
1081
        ):
1082
            trade = self._enter_trade(pair, row, trade_dir)
1✔
1083
            if trade:
1✔
1084
                # TODO: hacky workaround to avoid opening > max_open_trades
1085
                # This emulates previous behavior - not sure if this is correct
1086
                # Prevents entering if the trade-slot was freed in this candle
1087
                open_trade_count_start += 1
1✔
1088
                # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
1089
                LocalTrade.add_bt_trade(trade)
1✔
1090
                self.wallets.update()
1✔
1091

1092
        for trade in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1093
            # 3. Process entry orders.
1094
            order = trade.select_order(trade.entry_side, is_open=True)
1✔
1095
            if order and self._get_order_filled(order.price, row):
1✔
1096
                order.close_bt_order(current_time, trade)
1✔
1097
                trade.open_order_id = None
1✔
1098
                self.wallets.update()
1✔
1099

1100
                # 4. Create exit orders (if any)
1101
            if not trade.open_order_id:
1✔
1102
                self._get_exit_trade_entry(trade, row, is_first)  # Place exit order if necessary
1✔
1103

1104
                # 5. Process exit orders.
1105
            order = trade.select_order(trade.exit_side, is_open=True)
1✔
1106
            if order and self._get_order_filled(order.price, row):
1✔
1107
                order.close_bt_order(current_time, trade)
1✔
1108
                trade.open_order_id = None
1✔
1109
                sub_trade = order.safe_amount_after_fee != trade.amount
1✔
1110
                if sub_trade:
1✔
1111
                    order.close_bt_order(current_time, trade)
×
1112
                    trade.recalc_trade_from_orders()
×
1113
                else:
1114
                    trade.close_date = current_time
1✔
1115
                    trade.close(order.price, show_msg=False)
1✔
1116

1117
                    # logger.debug(f"{pair} - Backtesting exit {trade}")
1118
                    LocalTrade.close_bt_trade(trade)
1✔
1119
                self.wallets.update()
1✔
1120
                self.run_protections(pair, current_time, trade.trade_direction)
1✔
1121
        return open_trade_count_start
1✔
1122

1123
    def backtest(self, processed: Dict,
1✔
1124
                 start_date: datetime, end_date: datetime,
1125
                 max_open_trades: int = 0) -> Dict[str, Any]:
1126
        """
1127
        Implement backtesting functionality
1128

1129
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1130
        Of course try to not have ugly code. By some accessor are sometime slower than functions.
1131
        Avoid extensive logging in this method and functions it calls.
1132

1133
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
1134
        optimize memory usage!
1135
        :param start_date: backtesting timerange start datetime
1136
        :param end_date: backtesting timerange end datetime
1137
        :param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited
1138
        :return: DataFrame with trades (results of backtesting)
1139
        """
1140
        self.prepare_backtest(self.enable_protections)
1✔
1141
        # Ensure wallets are uptodate (important for --strategy-list)
1142
        self.wallets.update()
1✔
1143
        # Use dict of lists with data for performance
1144
        # (looping lists is a lot faster than pandas DataFrames)
1145
        data: Dict = self._get_ohlcv_as_lists(processed)
1✔
1146

1147
        # Indexes per pair, so some pairs are allowed to have a missing start.
1148
        indexes: Dict = defaultdict(int)
1✔
1149
        current_time = start_date + timedelta(minutes=self.timeframe_min)
1✔
1150

1151
        self.progress.init_step(BacktestState.BACKTEST, int(
1✔
1152
            (end_date - start_date) / timedelta(minutes=self.timeframe_min)))
1153
        # Loop timerange and get candle for each pair at that point in time
1154
        while current_time <= end_date:
1✔
1155
            open_trade_count_start = LocalTrade.bt_open_open_trade_count
1✔
1156
            self.check_abort()
1✔
1157
            for i, pair in enumerate(data):
1✔
1158
                row_index = indexes[pair]
1✔
1159
                row = self.validate_row(data, pair, row_index, current_time)
1✔
1160
                if not row:
1✔
1161
                    continue
1✔
1162

1163
                row_index += 1
1✔
1164
                indexes[pair] = row_index
1✔
1165
                self.dataprovider._set_dataframe_max_index(row_index)
1✔
1166
                current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1167
                if self.timeframe_detail and pair in self.detail_data:
1✔
1168
                    exit_candle_end = current_detail_time + timedelta(minutes=self.timeframe_min)
1✔
1169

1170
                    detail_data = self.detail_data[pair]
1✔
1171
                    detail_data = detail_data.loc[
1✔
1172
                        (detail_data['date'] >= current_detail_time) &
1173
                        (detail_data['date'] < exit_candle_end)
1174
                    ].copy()
1175
                    if len(detail_data) == 0:
1✔
1176
                        # Fall back to "regular" data if no detail data was found for this candle
1177
                        open_trade_count_start = self.backtest_loop(
×
1178
                            row, pair, current_time, end_date, max_open_trades,
1179
                            open_trade_count_start)
1180
                        continue
×
1181
                    detail_data.loc[:, 'enter_long'] = row[LONG_IDX]
1✔
1182
                    detail_data.loc[:, 'exit_long'] = row[ELONG_IDX]
1✔
1183
                    detail_data.loc[:, 'enter_short'] = row[SHORT_IDX]
1✔
1184
                    detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX]
1✔
1185
                    detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX]
1✔
1186
                    detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX]
1✔
1187
                    is_first = True
1✔
1188
                    current_time_det = current_time
1✔
1189
                    for det_row in detail_data[HEADERS].values.tolist():
1✔
1190
                        open_trade_count_start = self.backtest_loop(
1✔
1191
                            det_row, pair, current_time_det, end_date, max_open_trades,
1192
                            open_trade_count_start, is_first)
1193
                        current_time_det += timedelta(minutes=self.timeframe_detail_min)
1✔
1194
                        is_first = False
1✔
1195
                else:
1196
                    open_trade_count_start = self.backtest_loop(
1✔
1197
                        row, pair, current_time, end_date, max_open_trades, open_trade_count_start)
1198

1199
            # Move time one configured time_interval ahead.
1200
            self.progress.increment()
1✔
1201
            current_time += timedelta(minutes=self.timeframe_min)
1✔
1202

1203
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1204
        self.wallets.update()
1✔
1205

1206
        results = trade_list_to_dataframe(LocalTrade.trades)
1✔
1207
        return {
1✔
1208
            'results': results,
1209
            'config': self.strategy.config,
1210
            'locks': PairLocks.get_all_locks(),
1211
            'rejected_signals': self.rejected_trades,
1212
            'timedout_entry_orders': self.timedout_entry_orders,
1213
            'timedout_exit_orders': self.timedout_exit_orders,
1214
            'canceled_trade_entries': self.canceled_trade_entries,
1215
            'canceled_entry_orders': self.canceled_entry_orders,
1216
            'replaced_entry_orders': self.replaced_entry_orders,
1217
            'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
1218
        }
1219

1220
    def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame],
1✔
1221
                              timerange: TimeRange):
1222
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1223

1224
        logger.info(f"Running backtesting for Strategy {strat.get_strategy_name()}")
1✔
1225
        backtest_start_time = datetime.now(timezone.utc)
1✔
1226
        self._set_strategy(strat)
1✔
1227

1228
        # Use max_open_trades in backtesting, except --disable-max-market-positions is set
1229
        if self.config.get('use_max_market_positions', True):
1✔
1230
            # Must come from strategy config, as the strategy may modify this setting.
1231
            max_open_trades = self.strategy.config['max_open_trades']
1✔
1232
        else:
1233
            logger.info(
1✔
1234
                'Ignoring max_open_trades (--disable-max-market-positions was used) ...')
1235
            max_open_trades = 0
1✔
1236

1237
        # need to reprocess data every time to populate signals
1238
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1239

1240
        # Trim startup period from analyzed dataframe
1241
        preprocessed_tmp = trim_dataframes(preprocessed, timerange, self.required_startup)
1✔
1242

1243
        if not preprocessed_tmp:
1✔
1244
            raise OperationalException(
×
1245
                "No data left after adjusting for startup candles.")
1246

1247
        # Use preprocessed_tmp for date generation (the trimmed dataframe).
1248
        # Backtesting will re-trim the dataframes after entry/exit signal generation.
1249
        min_date, max_date = history.get_timerange(preprocessed_tmp)
1✔
1250
        logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
1✔
1251
                    f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
1252
                    f'({(max_date - min_date).days} days).')
1253
        # Execute backtest and store results
1254
        results = self.backtest(
1✔
1255
            processed=preprocessed,
1256
            start_date=min_date,
1257
            end_date=max_date,
1258
            max_open_trades=max_open_trades,
1259
        )
1260
        backtest_end_time = datetime.now(timezone.utc)
1✔
1261
        results.update({
1✔
1262
            'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
1263
            'backtest_start_time': int(backtest_start_time.timestamp()),
1264
            'backtest_end_time': int(backtest_end_time.timestamp()),
1265
        })
1266
        self.all_results[self.strategy.get_strategy_name()] = results
1✔
1267

1268
        if (self.config.get('export', 'none') == 'signals' and
1✔
1269
                self.dataprovider.runmode == RunMode.BACKTEST):
1270
            self._generate_trade_signal_candles(preprocessed_tmp, results)
1✔
1271

1272
        return min_date, max_date
1✔
1273

1274
    def _generate_trade_signal_candles(self, preprocessed_df, bt_results):
1✔
1275
        signal_candles_only = {}
1✔
1276
        for pair in preprocessed_df.keys():
1✔
1277
            signal_candles_only_df = DataFrame()
1✔
1278

1279
            pairdf = preprocessed_df[pair]
1✔
1280
            resdf = bt_results['results']
1✔
1281
            pairresults = resdf.loc[(resdf["pair"] == pair)]
1✔
1282

1283
            if pairdf.shape[0] > 0:
1✔
1284
                for t, v in pairresults.open_date.items():
1✔
1285
                    allinds = pairdf.loc[(pairdf['date'] < v)]
1✔
1286
                    signal_inds = allinds.iloc[[-1]]
1✔
1287
                    signal_candles_only_df = pd.concat([signal_candles_only_df, signal_inds])
1✔
1288

1289
                signal_candles_only[pair] = signal_candles_only_df
1✔
1290

1291
        self.processed_dfs[self.strategy.get_strategy_name()] = signal_candles_only
1✔
1292

1293
    def _get_min_cached_backtest_date(self):
1✔
1294
        min_backtest_date = None
1✔
1295
        backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
1✔
1296
        if self.timerange.stopts == 0 or self.timerange.stopdt > datetime.now(tz=timezone.utc):
1✔
1297
            logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
1✔
1298
        elif backtest_cache_age == 'day':
1✔
1299
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
1✔
1300
        elif backtest_cache_age == 'week':
1✔
1301
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
1✔
1302
        elif backtest_cache_age == 'month':
1✔
1303
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
1✔
1304
        return min_backtest_date
1✔
1305

1306
    def load_prior_backtest(self):
1✔
1307
        self.run_ids = {
1✔
1308
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1309
            for strategy in self.strategylist
1310
        }
1311

1312
        # Load previous result that will be updated incrementally.
1313
        # This can be circumvented in certain instances in combination with downloading more data
1314
        min_backtest_date = self._get_min_cached_backtest_date()
1✔
1315
        if min_backtest_date is not None:
1✔
1316
            self.results = find_existing_backtest_stats(
1✔
1317
                self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
1318

1319
    def start(self) -> None:
1✔
1320
        """
1321
        Run backtesting end-to-end
1322
        :return: None
1323
        """
1324
        data: Dict[str, Any] = {}
1✔
1325

1326
        data, timerange = self.load_bt_data()
1✔
1327
        self.load_bt_data_detail()
1✔
1328
        logger.info("Dataload complete. Calculating indicators")
1✔
1329

1330
        self.load_prior_backtest()
1✔
1331

1332
        for strat in self.strategylist:
1✔
1333
            if self.results and strat.get_strategy_name() in self.results['strategy']:
1✔
1334
                # When previous result hash matches - reuse that result and skip backtesting.
1335
                logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
1✔
1336
                continue
1✔
1337
            min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
1✔
1338

1339
        # Update old results with new ones.
1340
        if len(self.all_results) > 0:
1✔
1341
            results = generate_backtest_stats(
1✔
1342
                data, self.all_results, min_date=min_date, max_date=max_date)
1343
            if self.results:
1✔
1344
                self.results['metadata'].update(results['metadata'])
1✔
1345
                self.results['strategy'].update(results['strategy'])
1✔
1346
                self.results['strategy_comparison'].extend(results['strategy_comparison'])
1✔
1347
            else:
1348
                self.results = results
1✔
1349
            dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1✔
1350
            if self.config.get('export', 'none') in ('trades', 'signals'):
1✔
1351
                store_backtest_stats(self.config['exportfilename'], self.results, dt_appendix)
1✔
1352

1353
            if (self.config.get('export', 'none') == 'signals' and
1✔
1354
                    self.dataprovider.runmode == RunMode.BACKTEST):
1355
                store_backtest_signal_candles(
1✔
1356
                    self.config['exportfilename'], self.processed_dfs, dt_appendix)
1357

1358
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1359
        if 'strategy_list' in self.config and len(self.results) > 0:
1✔
1360
            self.results['strategy_comparison'] = sorted(
1✔
1361
                self.results['strategy_comparison'],
1362
                key=lambda c: self.config['strategy_list'].index(c['key']))
1363
            self.results['strategy'] = dict(
1✔
1364
                sorted(self.results['strategy'].items(),
1365
                       key=lambda kv: self.config['strategy_list'].index(kv[0])))
1366

1367
        if len(self.strategylist) > 0:
1✔
1368
            # Show backtest results
1369
            show_backtest_results(self.config, self.results)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc