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

freqtrade / freqtrade / 9394559170

26 Apr 2024 06:36AM UTC coverage: 94.656% (-0.02%) from 94.674%
9394559170

push

github

xmatthias
Loader should be passed as kwarg for clarity

20280 of 21425 relevant lines covered (94.66%)

0.95 hits per line

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

96.81
/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
from numpy import nan
1✔
13
from pandas import DataFrame
1✔
14

15
from freqtrade import constants
1✔
16
from freqtrade.configuration import TimeRange, validate_config_consistency
1✔
17
from freqtrade.constants import DATETIME_PRINT_FORMAT, Config, IntOrInf, LongShort
1✔
18
from freqtrade.data import history
1✔
19
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
1✔
20
from freqtrade.data.converter import trim_dataframe, trim_dataframes
1✔
21
from freqtrade.data.dataprovider import DataProvider
1✔
22
from freqtrade.data.metrics import combined_dataframes_with_rel_mean
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_seconds)
28
from freqtrade.exchange.exchange import Exchange
1✔
29
from freqtrade.mixins import LoggingMixin
1✔
30
from freqtrade.optimize.backtest_caching import get_strategy_run_id
1✔
31
from freqtrade.optimize.bt_progress import BTProgress
1✔
32
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, generate_rejected_signals,
1✔
33
                                                 generate_trade_signal_candles,
34
                                                 show_backtest_results,
35
                                                 store_backtest_analysis_results,
36
                                                 store_backtest_stats)
37
from freqtrade.persistence import (CustomDataWrapper, LocalTrade, Order, PairLocks, Trade,
1✔
38
                                   disable_database_use, enable_database_use)
39
from freqtrade.plugins.pairlistmanager import PairListManager
1✔
40
from freqtrade.plugins.protectionmanager import ProtectionManager
1✔
41
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
1✔
42
from freqtrade.strategy.interface import IStrategy
1✔
43
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
1✔
44
from freqtrade.types import BacktestResultType, get_BacktestResultType_default
1✔
45
from freqtrade.util.migrations import migrate_data
1✔
46
from freqtrade.wallets import Wallets
1✔
47

48

49
logger = logging.getLogger(__name__)
1✔
50

51
# Indexes for backtest tuples
52
DATE_IDX = 0
1✔
53
OPEN_IDX = 1
1✔
54
HIGH_IDX = 2
1✔
55
LOW_IDX = 3
1✔
56
CLOSE_IDX = 4
1✔
57
LONG_IDX = 5
1✔
58
ELONG_IDX = 6  # Exit long
1✔
59
SHORT_IDX = 7
1✔
60
ESHORT_IDX = 8  # Exit short
1✔
61
ENTER_TAG_IDX = 9
1✔
62
EXIT_TAG_IDX = 10
1✔
63

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

69

70
class Backtesting:
1✔
71
    """
72
    Backtesting class, this class contains all the logic to run a backtest
73

74
    To run a backtest:
75
    backtesting = Backtesting(config)
76
    backtesting.start()
77
    """
78

79
    def __init__(self, config: Config, exchange: Optional[Exchange] = None) -> None:
1✔
80

81
        LoggingMixin.show_output = False
1✔
82
        self.config = config
1✔
83
        self.results: BacktestResultType = get_BacktestResultType_default()
1✔
84
        self.trade_id_counter: int = 0
1✔
85
        self.order_id_counter: int = 0
1✔
86

87
        config['dry_run'] = True
1✔
88
        self.run_ids: Dict[str, str] = {}
1✔
89
        self.strategylist: List[IStrategy] = []
1✔
90
        self.all_results: Dict[str, Dict] = {}
1✔
91
        self.processed_dfs: Dict[str, Dict] = {}
1✔
92
        self.rejected_dict: Dict[str, List] = {}
1✔
93
        self.rejected_df: Dict[str, Dict] = {}
1✔
94

95
        self._exchange_name = self.config['exchange']['name']
1✔
96
        if not exchange:
1✔
97
            exchange = ExchangeResolver.load_exchange(self.config, load_leverage_tiers=True)
1✔
98
        self.exchange = exchange
1✔
99

100
        self.dataprovider = DataProvider(self.config, self.exchange)
1✔
101

102
        if self.config.get('strategy_list'):
1✔
103
            if self.config.get('freqai', {}).get('enabled', False):
1✔
104
                logger.warning("Using --strategy-list with FreqAI REQUIRES all strategies "
1✔
105
                               "to have identical feature_engineering_* functions.")
106
            for strat in list(self.config['strategy_list']):
1✔
107
                stratconf = deepcopy(self.config)
1✔
108
                stratconf['strategy'] = strat
1✔
109
                self.strategylist.append(StrategyResolver.load_strategy(stratconf))
1✔
110
                validate_config_consistency(stratconf)
1✔
111

112
        else:
113
            # No strategy list specified, only one strategy
114
            self.strategylist.append(StrategyResolver.load_strategy(self.config))
1✔
115
            validate_config_consistency(self.config)
1✔
116

117
        if "timeframe" not in self.config:
1✔
118
            raise OperationalException("Timeframe needs to be set in either "
1✔
119
                                       "configuration or as cli argument `--timeframe 5m`")
120
        self.timeframe = str(self.config.get('timeframe'))
1✔
121
        self.timeframe_secs = timeframe_to_seconds(self.timeframe)
1✔
122
        self.timeframe_min = self.timeframe_secs // 60
1✔
123
        self.timeframe_td = timedelta(seconds=self.timeframe_secs)
1✔
124
        self.disable_database_use()
1✔
125
        self.init_backtest_detail()
1✔
126
        self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
1✔
127
        self._validate_pairlists_for_backtesting()
1✔
128

129
        self.dataprovider.add_pairlisthandler(self.pairlists)
1✔
130
        self.pairlists.refresh_pairlist()
1✔
131

132
        if len(self.pairlists.whitelist) == 0:
1✔
133
            raise OperationalException("No pair in whitelist.")
1✔
134

135
        if config.get('fee', None) is not None:
1✔
136
            self.fee = config['fee']
1✔
137
        else:
138
            self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0])
1✔
139
        self.precision_mode = self.exchange.precisionMode
1✔
140

141
        if self.config.get('freqai_backtest_live_models', False):
1✔
142
            from freqtrade.freqai.utils import get_timerange_backtest_live_models
1✔
143
            self.config['timerange'] = get_timerange_backtest_live_models(self.config)
1✔
144

145
        self.timerange = TimeRange.parse_timerange(
1✔
146
            None if self.config.get('timerange') is None else str(self.config.get('timerange')))
147

148
        # Get maximum required startup period
149
        self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
1✔
150
        self.exchange.validate_required_startup_candles(self.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
        if self.config.get('freqai', {}).get('enabled', False):
1✔
156
            # For FreqAI, increase the required_startup to includes the training data
157
            # This value should NOT be written to startup_candle_count
158
            self.required_startup = self.dataprovider.get_required_startup(self.timeframe)
1✔
159

160
        self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT)
1✔
161
        # strategies which define "can_short=True" will fail to load in Spot mode.
162
        self._can_short = self.trading_mode != TradingMode.SPOT
1✔
163
        self._position_stacking: bool = self.config.get('position_stacking', False)
1✔
164
        self.enable_protections: bool = self.config.get('enable_protections', False)
1✔
165
        migrate_data(config, self.exchange)
1✔
166

167
        self.init_backtest()
1✔
168

169
    def _validate_pairlists_for_backtesting(self):
1✔
170
        if 'VolumePairList' in self.pairlists.name_list:
1✔
171
            raise OperationalException("VolumePairList not allowed for backtesting. "
1✔
172
                                       "Please use StaticPairList instead.")
173
        if 'PerformanceFilter' in self.pairlists.name_list:
1✔
174
            raise OperationalException("PerformanceFilter not allowed for backtesting.")
1✔
175

176
        if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list:
1✔
177
            raise OperationalException(
1✔
178
                "PrecisionFilter not allowed for backtesting multiple strategies."
179
            )
180

181
    @staticmethod
1✔
182
    def cleanup():
1✔
183
        LoggingMixin.show_output = True
1✔
184
        enable_database_use()
1✔
185

186
    def init_backtest_detail(self) -> None:
1✔
187
        # Load detail timeframe if specified
188
        self.timeframe_detail = str(self.config.get('timeframe_detail', ''))
1✔
189
        if self.timeframe_detail:
1✔
190
            timeframe_detail_secs = timeframe_to_seconds(self.timeframe_detail)
1✔
191
            self.timeframe_detail_td = timedelta(seconds=timeframe_detail_secs)
1✔
192
            if self.timeframe_secs <= timeframe_detail_secs:
1✔
193
                raise OperationalException(
1✔
194
                    "Detail timeframe must be smaller than strategy timeframe.")
195

196
        else:
197
            self.timeframe_detail_td = timedelta(seconds=0)
1✔
198
        self.detail_data: Dict[str, DataFrame] = {}
1✔
199
        self.futures_data: Dict[str, DataFrame] = {}
1✔
200

201
    def init_backtest(self):
1✔
202

203
        self.prepare_backtest(False)
1✔
204

205
        self.wallets = Wallets(self.config, self.exchange, is_backtest=True)
1✔
206

207
        self.progress = BTProgress()
1✔
208
        self.abort = False
1✔
209

210
    def _set_strategy(self, strategy: IStrategy):
1✔
211
        """
212
        Load strategy into backtesting
213
        """
214
        self.strategy: IStrategy = strategy
1✔
215
        strategy.dp = self.dataprovider
1✔
216
        # Attach Wallets to Strategy baseclass
217
        strategy.wallets = self.wallets
1✔
218
        # Set stoploss_on_exchange to false for backtesting,
219
        # since a "perfect" stoploss-exit is assumed anyway
220
        # And the regular "stoploss" function would not apply to that case
221
        self.strategy.order_types['stoploss_on_exchange'] = False
1✔
222
        # Update can_short flag
223
        self._can_short = self.trading_mode != TradingMode.SPOT and strategy.can_short
1✔
224

225
        self.strategy.ft_bot_start()
1✔
226

227
    def _load_protections(self, strategy: IStrategy):
1✔
228
        if self.config.get('enable_protections', False):
1✔
229
            conf = self.config
1✔
230
            if hasattr(strategy, 'protections'):
1✔
231
                conf = deepcopy(conf)
1✔
232
                conf['protections'] = strategy.protections
1✔
233
            self.protections = ProtectionManager(self.config, strategy.protections)
1✔
234

235
    def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
1✔
236
        """
237
        Loads backtest data and returns the data combined with the timerange
238
        as tuple.
239
        """
240
        self.progress.init_step(BacktestState.DATALOAD, 1)
1✔
241

242
        data = history.load_data(
1✔
243
            datadir=self.config['datadir'],
244
            pairs=self.pairlists.whitelist,
245
            timeframe=self.timeframe,
246
            timerange=self.timerange,
247
            startup_candles=self.required_startup,
248
            fail_without_data=True,
249
            data_format=self.config['dataformat_ohlcv'],
250
            candle_type=self.config.get('candle_type_def', CandleType.SPOT)
251
        )
252

253
        min_date, max_date = history.get_timerange(data)
1✔
254

255
        logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
1✔
256
                    f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
257
                    f'({(max_date - min_date).days} days).')
258

259
        # Adjust startts forward if not enough data is available
260
        self.timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
1✔
261
                                                 self.required_startup, min_date)
262

263
        self.progress.set_new_value(1)
1✔
264
        return data, self.timerange
1✔
265

266
    def load_bt_data_detail(self) -> None:
1✔
267
        """
268
        Loads backtest detail data (smaller timeframe) if necessary.
269
        """
270
        if self.timeframe_detail:
1✔
271
            self.detail_data = history.load_data(
1✔
272
                datadir=self.config['datadir'],
273
                pairs=self.pairlists.whitelist,
274
                timeframe=self.timeframe_detail,
275
                timerange=self.timerange,
276
                startup_candles=0,
277
                fail_without_data=True,
278
                data_format=self.config['dataformat_ohlcv'],
279
                candle_type=self.config.get('candle_type_def', CandleType.SPOT)
280
            )
281
        else:
282
            self.detail_data = {}
1✔
283
        if self.trading_mode == TradingMode.FUTURES:
1✔
284
            self.funding_fee_timeframe: str = self.exchange.get_option('funding_fee_timeframe')
1✔
285
            self.funding_fee_timeframe_secs: int = timeframe_to_seconds(self.funding_fee_timeframe)
1✔
286
            mark_timeframe: str = self.exchange.get_option('mark_ohlcv_timeframe')
1✔
287

288
            # Load additional futures data.
289
            funding_rates_dict = history.load_data(
1✔
290
                datadir=self.config['datadir'],
291
                pairs=self.pairlists.whitelist,
292
                timeframe=self.funding_fee_timeframe,
293
                timerange=self.timerange,
294
                startup_candles=0,
295
                fail_without_data=True,
296
                data_format=self.config['dataformat_ohlcv'],
297
                candle_type=CandleType.FUNDING_RATE
298
            )
299

300
            # For simplicity, assign to CandleType.Mark (might contain index candles!)
301
            mark_rates_dict = history.load_data(
1✔
302
                datadir=self.config['datadir'],
303
                pairs=self.pairlists.whitelist,
304
                timeframe=mark_timeframe,
305
                timerange=self.timerange,
306
                startup_candles=0,
307
                fail_without_data=True,
308
                data_format=self.config['dataformat_ohlcv'],
309
                candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price"))
310
            )
311
            # Combine data to avoid combining the data per trade.
312
            unavailable_pairs = []
1✔
313
            for pair in self.pairlists.whitelist:
1✔
314
                if pair not in self.exchange._leverage_tiers:
1✔
315
                    unavailable_pairs.append(pair)
1✔
316
                    continue
1✔
317

318
                self.futures_data[pair] = self.exchange.combine_funding_and_mark(
1✔
319
                    funding_rates=funding_rates_dict[pair],
320
                    mark_rates=mark_rates_dict[pair],
321
                    futures_funding_rate=self.config.get('futures_funding_rate', None),
322
                )
323

324
            if unavailable_pairs:
1✔
325
                raise OperationalException(
1✔
326
                    f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. "
327
                    "It is therefore impossible to backtest with this pair at the moment.")
328
        else:
329
            self.futures_data = {}
1✔
330

331
    def disable_database_use(self):
1✔
332
        disable_database_use(self.timeframe)
1✔
333

334
    def prepare_backtest(self, enable_protections):
1✔
335
        """
336
        Backtesting setup method - called once for every call to "backtest()".
337
        """
338
        self.disable_database_use()
1✔
339
        PairLocks.reset_locks()
1✔
340
        Trade.reset_trades()
1✔
341
        CustomDataWrapper.reset_custom_data()
1✔
342
        self.rejected_trades = 0
1✔
343
        self.timedout_entry_orders = 0
1✔
344
        self.timedout_exit_orders = 0
1✔
345
        self.canceled_trade_entries = 0
1✔
346
        self.canceled_entry_orders = 0
1✔
347
        self.replaced_entry_orders = 0
1✔
348
        self.dataprovider.clear_cache()
1✔
349
        if enable_protections:
1✔
350
            self._load_protections(self.strategy)
1✔
351

352
    def check_abort(self):
1✔
353
        """
354
        Check if abort was requested, raise DependencyException if that's the case
355
        Only applies to Interactive backtest mode (webserver mode)
356
        """
357
        if self.abort:
1✔
358
            self.abort = False
1✔
359
            raise DependencyException("Stop requested")
1✔
360

361
    def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
1✔
362
        """
363
        Helper function to convert a processed dataframes into lists for performance reasons.
364

365
        Used by backtest() - so keep this optimized for performance.
366

367
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
368
        optimize memory usage!
369
        """
370

371
        data: Dict = {}
1✔
372
        self.progress.init_step(BacktestState.CONVERT, len(processed))
1✔
373

374
        # Create dict with data
375
        for pair in processed.keys():
1✔
376
            pair_data = processed[pair]
1✔
377
            self.check_abort()
1✔
378
            self.progress.increment()
1✔
379

380
            if not pair_data.empty:
1✔
381
                # Cleanup from prior runs
382
                pair_data.drop(HEADERS[5:] + ['buy', 'sell'], axis=1, errors='ignore')
1✔
383
            df_analyzed = self.strategy.ft_advise_signals(pair_data, {'pair': pair})
1✔
384
            # Update dataprovider cache
385
            self.dataprovider._set_cached_df(
1✔
386
                pair, self.timeframe, df_analyzed, self.config['candle_type_def'])
387

388
            # Trim startup period from analyzed dataframe
389
            df_analyzed = processed[pair] = pair_data = trim_dataframe(
1✔
390
                df_analyzed, self.timerange, startup_candles=self.required_startup)
391

392
            # Create a copy of the dataframe before shifting, that way the entry signal/tag
393
            # remains on the correct candle for callbacks.
394
            df_analyzed = df_analyzed.copy()
1✔
395

396
            # To avoid using data from future, we use entry/exit signals shifted
397
            # from the previous candle
398
            for col in HEADERS[5:]:
1✔
399
                tag_col = col in ('enter_tag', 'exit_tag')
1✔
400
                if col in df_analyzed.columns:
1✔
401
                    df_analyzed[col] = df_analyzed.loc[:, col].replace(
1✔
402
                        [nan], [0 if not tag_col else None]).shift(1)
403
                elif not df_analyzed.empty:
1✔
404
                    df_analyzed[col] = 0 if not tag_col else None
1✔
405

406
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
407

408
            # Convert from Pandas to list for performance reasons
409
            # (Looping Pandas is slow.)
410
            data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else []
1✔
411
        return data
1✔
412

413
    def _get_close_rate(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
414
                        trade_dur: int) -> float:
415
        """
416
        Get close rate for backtesting result
417
        """
418
        # Special handling if high or low hit STOP_LOSS or ROI
419
        if exit.exit_type in (
1✔
420
                ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS, ExitType.LIQUIDATION):
421
            return self._get_close_rate_for_stoploss(row, trade, exit, trade_dur)
1✔
422
        elif exit.exit_type == (ExitType.ROI):
1✔
423
            return self._get_close_rate_for_roi(row, trade, exit, trade_dur)
1✔
424
        else:
425
            return row[OPEN_IDX]
1✔
426

427
    def _get_close_rate_for_stoploss(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
428
                                     trade_dur: int) -> float:
429
        # our stoploss was already lower than candle high,
430
        # possibly due to a cancelled trade exit.
431
        # exit at open price.
432
        is_short = trade.is_short or False
1✔
433
        leverage = trade.leverage or 1.0
1✔
434
        side_1 = -1 if is_short else 1
1✔
435
        if exit.exit_type == ExitType.LIQUIDATION and trade.liquidation_price:
1✔
436
            stoploss_value = trade.liquidation_price
×
437
        else:
438
            stoploss_value = trade.stop_loss
1✔
439

440
        if is_short:
1✔
441
            if stoploss_value < row[LOW_IDX]:
1✔
442
                return row[OPEN_IDX]
1✔
443
        else:
444
            if stoploss_value > row[HIGH_IDX]:
1✔
445
                return row[OPEN_IDX]
1✔
446

447
        # Special case: trailing triggers within same candle as trade opened. Assume most
448
        # pessimistic price movement, which is moving just enough to arm stoploss and
449
        # immediately going down to stop price.
450
        if exit.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0:
1✔
451
            if (
1✔
452
                not self.strategy.use_custom_stoploss and self.strategy.trailing_stop
453
                and self.strategy.trailing_only_offset_is_reached
454
                and self.strategy.trailing_stop_positive_offset is not None
455
                and self.strategy.trailing_stop_positive
456
            ):
457
                # Worst case: price reaches stop_positive_offset and dives down.
458
                stop_rate = (row[OPEN_IDX] *
1✔
459
                             (1 + side_1 * abs(self.strategy.trailing_stop_positive_offset) -
460
                              side_1 * abs(self.strategy.trailing_stop_positive / leverage)))
461
            else:
462
                # Worst case: price ticks tiny bit above open and dives down.
463
                stop_rate = row[OPEN_IDX] * (1 - side_1 * abs(
1✔
464
                    (trade.stop_loss_pct or 0.0) / leverage))
465

466
            # Limit lower-end to candle low to avoid exits below the low.
467
            # This still remains "worst case" - but "worst realistic case".
468
            if is_short:
1✔
469
                return min(row[HIGH_IDX], stop_rate)
1✔
470
            else:
471
                return max(row[LOW_IDX], stop_rate)
1✔
472

473
        # Set close_rate to stoploss
474
        return stoploss_value
1✔
475

476
    def _get_close_rate_for_roi(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
477
                                trade_dur: int) -> float:
478
        is_short = trade.is_short or False
1✔
479
        leverage = trade.leverage or 1.0
1✔
480
        side_1 = -1 if is_short else 1
1✔
481
        roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur)
1✔
482
        if roi is not None and roi_entry is not None:
1✔
483
            if roi == -1 and roi_entry % self.timeframe_min == 0:
1✔
484
                # When force_exiting with ROI=-1, the roi time will always be equal to trade_dur.
485
                # If that entry is a multiple of the timeframe (so on candle open)
486
                # - we'll use open instead of close
487
                return row[OPEN_IDX]
1✔
488

489
            # - (Expected abs profit - open_rate - open_fee) / (fee_close -1)
490
            roi_rate = trade.open_rate * roi / leverage
1✔
491
            open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open)
1✔
492
            close_rate = -(roi_rate + open_fee_rate) / ((trade.fee_close or 0.0) - side_1 * 1)
1✔
493
            if is_short:
1✔
494
                is_new_roi = row[OPEN_IDX] < close_rate
1✔
495
            else:
496
                is_new_roi = row[OPEN_IDX] > close_rate
1✔
497
            if (trade_dur > 0 and trade_dur == roi_entry
1✔
498
                    and roi_entry % self.timeframe_min == 0
499
                    and is_new_roi):
500
                # new ROI entry came into effect.
501
                # use Open rate if open_rate > calculated exit rate
502
                return row[OPEN_IDX]
1✔
503

504
            if (trade_dur == 0 and (
1✔
505
                (
506
                    is_short
507
                    # Red candle (for longs)
508
                    and row[OPEN_IDX] < row[CLOSE_IDX]  # Red candle
509
                    and trade.open_rate > row[OPEN_IDX]  # trade-open above open_rate
510
                    and close_rate < row[CLOSE_IDX]  # closes below close
511
                )
512
                or
513
                (
514
                    not is_short
515
                    # green candle (for shorts)
516
                    and row[OPEN_IDX] > row[CLOSE_IDX]  # green candle
517
                    and trade.open_rate < row[OPEN_IDX]  # trade-open below open_rate
518
                    and close_rate > row[CLOSE_IDX]  # closes above close
519
                )
520
            )):
521
                # ROI on opening candles with custom pricing can only
522
                # trigger if the entry was at Open or lower wick.
523
                # details: https: // github.com/freqtrade/freqtrade/issues/6261
524
                # If open_rate is < open, only allow exits below the close on red candles.
525
                raise ValueError("Opening candle ROI on red candles.")
1✔
526

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

532
        else:
533
            # This should not be reached...
534
            return row[OPEN_IDX]
×
535

536
    def _get_adjust_trade_entry_for_candle(
1✔
537
            self, trade: LocalTrade, row: Tuple, current_time: datetime
538
    ) -> LocalTrade:
539
        current_rate: float = row[OPEN_IDX]
1✔
540
        current_profit = trade.calc_profit_ratio(current_rate)
1✔
541
        min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1)
1✔
542
        max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate)
1✔
543
        stake_available = self.wallets.get_available_stake_amount()
1✔
544
        stake_amount, order_tag = self.strategy._adjust_trade_position_internal(
1✔
545
            trade=trade,  # type: ignore[arg-type]
546
            current_time=current_time, current_rate=current_rate,
547
            current_profit=current_profit, min_stake=min_stake,
548
            max_stake=min(max_stake, stake_available),
549
            current_entry_rate=current_rate, current_exit_rate=current_rate,
550
            current_entry_profit=current_profit, current_exit_profit=current_profit
551
        )
552

553
        # Check if we should increase our position
554
        if stake_amount is not None and stake_amount > 0.0:
1✔
555
            check_adjust_entry = True
1✔
556
            if self.strategy.max_entry_position_adjustment > -1:
1✔
557
                entry_count = trade.nr_of_successful_entries
×
558
                check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment)
×
559
            if check_adjust_entry:
1✔
560
                pos_trade = self._enter_trade(
1✔
561
                    trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade,
562
                    entry_tag1=order_tag)
563
                if pos_trade is not None:
1✔
564
                    self.wallets.update()
1✔
565
                    return pos_trade
1✔
566

567
        if stake_amount is not None and stake_amount < 0.0:
1✔
568
            amount = amount_to_contract_precision(
1✔
569
                abs(stake_amount * trade.amount / trade.stake_amount),
570
                trade.amount_precision,
571
                self.precision_mode, trade.contract_size)
572
            if amount == 0.0:
1✔
573
                return trade
×
574
            remaining = (trade.amount - amount) * current_rate
1✔
575
            if min_stake and remaining != 0 and remaining < min_stake:
1✔
576
                # Remaining stake is too low to be sold.
577
                return trade
1✔
578
            exit_ = ExitCheckTuple(ExitType.PARTIAL_EXIT, order_tag)
1✔
579
            pos_trade = self._get_exit_for_signal(trade, row, exit_, current_time, amount)
1✔
580
            if pos_trade is not None:
1✔
581
                order = pos_trade.orders[-1]
1✔
582
                if self._try_close_open_order(order, trade, current_time, row):
1✔
583
                    trade.recalc_trade_from_orders()
1✔
584
                self.wallets.update()
1✔
585
                return pos_trade
1✔
586

587
        return trade
1✔
588

589
    def _get_order_filled(self, rate: float, row: Tuple) -> bool:
1✔
590
        """ Rate is within candle, therefore filled"""
591
        return row[LOW_IDX] <= rate <= row[HIGH_IDX]
1✔
592

593
    def _call_adjust_stop(self, current_date: datetime, trade: LocalTrade, current_rate: float):
1✔
594
        profit = trade.calc_profit_ratio(current_rate)
1✔
595
        self.strategy.ft_stoploss_adjust(current_rate, trade,  # type: ignore
1✔
596
                                         current_date, profit, 0, after_fill=True)
597

598
    def _try_close_open_order(
1✔
599
            self, order: Optional[Order], trade: LocalTrade, current_date: datetime,
600
            row: Tuple) -> bool:
601
        """
602
        Check if an order is open and if it should've filled.
603
        :return:  True if the order filled.
604
        """
605
        if order and self._get_order_filled(order.ft_price, row):
1✔
606
            order.close_bt_order(current_date, trade)
1✔
607
            self._run_funding_fees(trade, current_date, force=True)
1✔
608
            strategy_safe_wrapper(
1✔
609
                self.strategy.order_filled,
610
                default_retval=None)(
611
                pair=trade.pair, trade=trade,  # type: ignore[arg-type]
612
                order=order, current_time=current_date)
613

614
            if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount):
1✔
615
                # trade is still open
616
                trade.set_liquidation_price(self.exchange.get_liquidation_price(
1✔
617
                    pair=trade.pair,
618
                    open_rate=trade.open_rate,
619
                    is_short=trade.is_short,
620
                    amount=trade.amount,
621
                    stake_amount=trade.stake_amount,
622
                    leverage=trade.leverage,
623
                    wallet_balance=trade.stake_amount,
624
                ))
625
                self._call_adjust_stop(current_date, trade, order.ft_price)
1✔
626
                # pass
627
            return True
1✔
628
        return False
1✔
629

630
    def _get_exit_for_signal(
1✔
631
            self, trade: LocalTrade, row: Tuple, exit_: ExitCheckTuple,
632
            current_time: datetime,
633
            amount: Optional[float] = None) -> Optional[LocalTrade]:
634

635
        if exit_.exit_flag:
1✔
636
            trade.close_date = current_time
1✔
637
            exit_reason = exit_.exit_reason
1✔
638
            amount_ = amount if amount is not None else trade.amount
1✔
639
            trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
1✔
640
            try:
1✔
641
                close_rate = self._get_close_rate(row, trade, exit_, trade_dur)
1✔
642
            except ValueError:
1✔
643
                return None
1✔
644
            # call the custom exit price,with default value as previous close_rate
645
            current_profit = trade.calc_profit_ratio(close_rate)
1✔
646
            order_type = self.strategy.order_types['exit']
1✔
647
            if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT,
1✔
648
                                   ExitType.PARTIAL_EXIT):
649
                # Checks and adds an exit tag, after checking that the length of the
650
                # row has the length for an exit tag column
651
                if (
1✔
652
                    len(row) > EXIT_TAG_IDX
653
                    and row[EXIT_TAG_IDX] is not None
654
                    and len(row[EXIT_TAG_IDX]) > 0
655
                    and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
656
                ):
657
                    exit_reason = row[EXIT_TAG_IDX]
×
658
                # Custom exit pricing only for exit-signals
659
                if order_type == 'limit':
1✔
660
                    rate = strategy_safe_wrapper(self.strategy.custom_exit_price,
1✔
661
                                                 default_retval=close_rate)(
662
                        pair=trade.pair,
663
                        trade=trade,  # type: ignore[arg-type]
664
                        current_time=current_time,
665
                        proposed_rate=close_rate, current_profit=current_profit,
666
                        exit_tag=exit_reason)
667
                    if rate is not None and rate != close_rate:
1✔
668
                        close_rate = price_to_precision(rate, trade.price_precision,
1✔
669
                                                        self.precision_mode)
670
                    # We can't place orders lower than current low.
671
                    # freqtrade does not support this in live, and the order would fill immediately
672
                    if trade.is_short:
1✔
673
                        close_rate = min(close_rate, row[HIGH_IDX])
1✔
674
                    else:
675
                        close_rate = max(close_rate, row[LOW_IDX])
1✔
676
            # Confirm trade exit:
677
            time_in_force = self.strategy.order_time_in_force['exit']
1✔
678

679
            if (exit_.exit_type not in (ExitType.LIQUIDATION, ExitType.PARTIAL_EXIT)
1✔
680
                    and not strategy_safe_wrapper(
681
                    self.strategy.confirm_trade_exit, default_retval=True)(
682
                        pair=trade.pair,
683
                        trade=trade,  # type: ignore[arg-type]
684
                        order_type=order_type,
685
                        amount=amount_,
686
                        rate=close_rate,
687
                        time_in_force=time_in_force,
688
                        sell_reason=exit_reason,  # deprecated
689
                        exit_reason=exit_reason,
690
                        current_time=current_time)):
691
                return None
×
692

693
            trade.exit_reason = exit_reason
1✔
694

695
            return self._exit_trade(trade, row, close_rate, amount_, exit_reason)
1✔
696
        return None
×
697

698
    def _exit_trade(self, trade: LocalTrade, sell_row: Tuple, close_rate: float,
1✔
699
                    amount: float, exit_reason: Optional[str]) -> Optional[LocalTrade]:
700
        self.order_id_counter += 1
1✔
701
        exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
1✔
702
        order_type = self.strategy.order_types['exit']
1✔
703
        # amount = amount or trade.amount
704
        amount = amount_to_contract_precision(amount or trade.amount, trade.amount_precision,
1✔
705
                                              self.precision_mode, trade.contract_size)
706
        order = Order(
1✔
707
            id=self.order_id_counter,
708
            ft_trade_id=trade.id,
709
            order_date=exit_candle_time,
710
            order_update_date=exit_candle_time,
711
            ft_is_open=True,
712
            ft_pair=trade.pair,
713
            order_id=str(self.order_id_counter),
714
            symbol=trade.pair,
715
            ft_order_side=trade.exit_side,
716
            side=trade.exit_side,
717
            order_type=order_type,
718
            status="open",
719
            ft_price=close_rate,
720
            price=close_rate,
721
            average=close_rate,
722
            amount=amount,
723
            filled=0,
724
            remaining=amount,
725
            cost=amount * close_rate,
726
            ft_order_tag=exit_reason,
727
        )
728
        order._trade_bt = trade
1✔
729
        trade.orders.append(order)
1✔
730
        return trade
1✔
731

732
    def _check_trade_exit(
1✔
733
            self, trade: LocalTrade, row: Tuple, current_time: datetime
734
    ) -> Optional[LocalTrade]:
735

736
        self._run_funding_fees(trade, current_time)
1✔
737

738
        # Check if we need to adjust our current positions
739
        if self.strategy.position_adjustment_enable:
1✔
740
            trade = self._get_adjust_trade_entry_for_candle(trade, row, current_time)
1✔
741

742
        enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
743
        exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
744
        exits = self.strategy.should_exit(
1✔
745
            trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(),  # type: ignore
746
            enter=enter, exit_=exit_sig,
747
            low=row[LOW_IDX], high=row[HIGH_IDX]
748
        )
749
        for exit_ in exits:
1✔
750
            t = self._get_exit_for_signal(trade, row, exit_, current_time)
1✔
751
            if t:
1✔
752
                return t
1✔
753
        return None
1✔
754

755
    def _run_funding_fees(self, trade: LocalTrade, current_time: datetime, force: bool = False):
1✔
756
        """
757
        Calculate funding fees if necessary and add them to the trade.
758
        """
759
        if self.trading_mode == TradingMode.FUTURES:
1✔
760

761
            if (
1✔
762
                force
763
                or (current_time.timestamp() % self.funding_fee_timeframe_secs) == 0
764
            ):
765
                # Funding fee interval.
766
                trade.set_funding_fees(
1✔
767
                    self.exchange.calculate_funding_fees(
768
                        self.futures_data[trade.pair],
769
                        amount=trade.amount,
770
                        is_short=trade.is_short,
771
                        open_date=trade.date_last_filled_utc,
772
                        close_date=current_time
773
                    )
774
                )
775

776
    def get_valid_price_and_stake(
1✔
777
        self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
778
        direction: LongShort, current_time: datetime, entry_tag: Optional[str],
779
        trade: Optional[LocalTrade], order_type: str, price_precision: Optional[float]
780
    ) -> Tuple[float, float, float, float]:
781

782
        if order_type == 'limit':
1✔
783
            new_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
1✔
784
                                             default_retval=propose_rate)(
785
                pair=pair,
786
                trade=trade,  # type: ignore[arg-type]
787
                current_time=current_time,
788
                proposed_rate=propose_rate, entry_tag=entry_tag,
789
                side=direction,
790
            )  # default value is the open rate
791
            # We can't place orders higher than current high (otherwise it'd be a stop limit entry)
792
            # which freqtrade does not support in live.
793
            if new_rate is not None and new_rate != propose_rate:
1✔
794
                propose_rate = price_to_precision(new_rate, price_precision,
1✔
795
                                                  self.precision_mode)
796
            if direction == "short":
1✔
797
                propose_rate = max(propose_rate, row[LOW_IDX])
1✔
798
            else:
799
                propose_rate = min(propose_rate, row[HIGH_IDX])
1✔
800

801
        pos_adjust = trade is not None
1✔
802
        leverage = trade.leverage if trade else 1.0
1✔
803
        if not pos_adjust:
1✔
804
            try:
1✔
805
                stake_amount = self.wallets.get_trade_stake_amount(
1✔
806
                    pair, self.strategy.max_open_trades, update=False)
807
            except DependencyException:
1✔
808
                return 0, 0, 0, 0
1✔
809

810
            max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
1✔
811
            leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
1✔
812
                pair=pair,
813
                current_time=current_time,
814
                current_rate=row[OPEN_IDX],
815
                proposed_leverage=1.0,
816
                max_leverage=max_leverage,
817
                side=direction, entry_tag=entry_tag,
818
            ) if self.trading_mode != TradingMode.SPOT else 1.0
819
            # Cap leverage between 1.0 and max_leverage.
820
            leverage = min(max(leverage, 1.0), max_leverage)
1✔
821

822
        min_stake_amount = self.exchange.get_min_pair_stake_amount(
1✔
823
            pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage) or 0
824
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
825
            pair, propose_rate, leverage=leverage)
826
        stake_available = self.wallets.get_available_stake_amount()
1✔
827

828
        if not pos_adjust:
1✔
829
            stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
1✔
830
                                                 default_retval=stake_amount)(
831
                pair=pair, current_time=current_time, current_rate=propose_rate,
832
                proposed_stake=stake_amount, min_stake=min_stake_amount,
833
                max_stake=min(stake_available, max_stake_amount),
834
                leverage=leverage, entry_tag=entry_tag, side=direction)
835

836
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
837
            pair=pair,
838
            stake_amount=stake_amount,
839
            min_stake_amount=min_stake_amount,
840
            max_stake_amount=max_stake_amount,
841
            trade_amount=trade.stake_amount if trade else None
842
        )
843

844
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
845

846
    def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
1✔
847
                     stake_amount: Optional[float] = None,
848
                     trade: Optional[LocalTrade] = None,
849
                     requested_rate: Optional[float] = None,
850
                     requested_stake: Optional[float] = None,
851
                     entry_tag1: Optional[str] = None
852
                     ) -> Optional[LocalTrade]:
853
        """
854
        :param trade: Trade to adjust - initial entry if None
855
        :param requested_rate: Adjusted entry rate
856
        :param requested_stake: Stake amount for adjusted orders (`adjust_entry_price`).
857
        """
858

859
        current_time = row[DATE_IDX].to_pydatetime()
1✔
860
        entry_tag = entry_tag1 or (row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None)
1✔
861
        # let's call the custom entry price, using the open price as default price
862
        order_type = self.strategy.order_types['entry']
1✔
863
        pos_adjust = trade is not None and requested_rate is None
1✔
864

865
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
866
        precision_price = self.exchange.get_precision_price(pair)
1✔
867

868
        propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
1✔
869
            pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
870
            order_type, precision_price,
871
        )
872

873
        # replace proposed rate if another rate was requested
874
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
875
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
876

877
        if not stake_amount:
1✔
878
            # In case of pos adjust, still return the original trade
879
            # If not pos adjust, trade is None
880
            return trade
1✔
881
        time_in_force = self.strategy.order_time_in_force['entry']
1✔
882

883
        if stake_amount and (not min_stake_amount or stake_amount >= min_stake_amount):
1✔
884
            self.order_id_counter += 1
1✔
885
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
886
            amount_p = (stake_amount / propose_rate) * leverage
1✔
887

888
            contract_size = self.exchange.get_contract_size(pair)
1✔
889
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
890
            amount = amount_to_contract_precision(amount_p, precision_amount, self.precision_mode,
1✔
891
                                                  contract_size)
892
            if not amount:
1✔
893
                # No amount left after truncating to precision.
894
                return trade
×
895
            # Backcalculate actual stake amount.
896
            stake_amount = amount * propose_rate / leverage
1✔
897

898
            if not pos_adjust:
1✔
899
                # Confirm trade entry:
900
                if not strategy_safe_wrapper(
1✔
901
                        self.strategy.confirm_trade_entry, default_retval=True)(
902
                            pair=pair, order_type=order_type, amount=amount, rate=propose_rate,
903
                            time_in_force=time_in_force, current_time=current_time,
904
                            entry_tag=entry_tag, side=direction):
905
                    return trade
1✔
906

907
            is_short = (direction == 'short')
1✔
908
            # Necessary for Margin trading. Disabled until support is enabled.
909
            # interest_rate = self.exchange.get_interest_rate()
910

911
            if trade is None:
1✔
912
                # Enter trade
913
                self.trade_id_counter += 1
1✔
914
                trade = LocalTrade(
1✔
915
                    id=self.trade_id_counter,
916
                    pair=pair,
917
                    base_currency=base_currency,
918
                    stake_currency=self.config['stake_currency'],
919
                    open_rate=propose_rate,
920
                    open_rate_requested=propose_rate,
921
                    open_date=current_time,
922
                    stake_amount=stake_amount,
923
                    amount=amount,
924
                    amount_requested=amount,
925
                    fee_open=self.fee,
926
                    fee_close=self.fee,
927
                    is_open=True,
928
                    enter_tag=entry_tag,
929
                    exchange=self._exchange_name,
930
                    is_short=is_short,
931
                    trading_mode=self.trading_mode,
932
                    leverage=leverage,
933
                    # interest_rate=interest_rate,
934
                    amount_precision=precision_amount,
935
                    price_precision=precision_price,
936
                    precision_mode=self.precision_mode,
937
                    contract_size=contract_size,
938
                    orders=[],
939
                )
940

941
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
942

943
            order = Order(
1✔
944
                id=self.order_id_counter,
945
                ft_trade_id=trade.id,
946
                ft_is_open=True,
947
                ft_pair=trade.pair,
948
                order_id=str(self.order_id_counter),
949
                symbol=trade.pair,
950
                ft_order_side=trade.entry_side,
951
                side=trade.entry_side,
952
                order_type=order_type,
953
                status="open",
954
                order_date=current_time,
955
                order_filled_date=current_time,
956
                order_update_date=current_time,
957
                ft_price=propose_rate,
958
                price=propose_rate,
959
                average=propose_rate,
960
                amount=amount,
961
                filled=0,
962
                remaining=amount,
963
                cost=amount * propose_rate + trade.fee_open,
964
                ft_order_tag=entry_tag,
965
            )
966
            order._trade_bt = trade
1✔
967
            trade.orders.append(order)
1✔
968
            self._try_close_open_order(order, trade, current_time, row)
1✔
969
            trade.recalc_trade_from_orders()
1✔
970

971
        return trade
1✔
972

973
    def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
1✔
974
                         data: Dict[str, List[Tuple]]) -> None:
975
        """
976
        Handling of left open trades at the end of backtesting
977
        """
978
        for pair in open_trades.keys():
1✔
979
            for trade in list(open_trades[pair]):
1✔
980
                if trade.has_open_orders and trade.nr_of_successful_entries == 0:
1✔
981
                    # Ignore trade if entry-order did not fill yet
982
                    continue
×
983
                exit_row = data[pair][-1]
1✔
984
                self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount,
1✔
985
                                 ExitType.FORCE_EXIT.value)
986
                trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade)
1✔
987

988
                trade.close_date = exit_row[DATE_IDX].to_pydatetime()
1✔
989
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
990
                trade.close(exit_row[OPEN_IDX], show_msg=False)
1✔
991
                LocalTrade.close_bt_trade(trade)
1✔
992

993
    def trade_slot_available(self, open_trade_count: int) -> bool:
1✔
994
        # Always allow trades when max_open_trades is enabled.
995
        max_open_trades: IntOrInf = self.strategy.max_open_trades
1✔
996
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
997
            return True
1✔
998
        # Rejected trade
999
        self.rejected_trades += 1
1✔
1000
        return False
1✔
1001

1002
    def check_for_trade_entry(self, row) -> Optional[LongShort]:
1✔
1003
        enter_long = row[LONG_IDX] == 1
1✔
1004
        exit_long = row[ELONG_IDX] == 1
1✔
1005
        enter_short = self._can_short and row[SHORT_IDX] == 1
1✔
1006
        exit_short = self._can_short and row[ESHORT_IDX] == 1
1✔
1007

1008
        if enter_long == 1 and not any([exit_long, enter_short]):
1✔
1009
            # Long
1010
            return 'long'
1✔
1011
        if enter_short == 1 and not any([exit_short, enter_long]):
1✔
1012
            # Short
1013
            return 'short'
1✔
1014
        return None
1✔
1015

1016
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
1017
        if self.enable_protections:
1✔
1018
            self.protections.stop_per_pair(pair, current_time, side)
1✔
1019
            self.protections.global_stop(current_time, side)
1✔
1020

1021
    def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: Tuple) -> bool:
1✔
1022
        """
1023
        Check if any open order needs to be cancelled or replaced.
1024
        Returns True if the trade should be deleted.
1025
        """
1026
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
1027
            oc = self.check_order_cancel(trade, order, current_time)
1✔
1028
            if oc:
1✔
1029
                # delete trade due to order timeout
1030
                return True
1✔
1031
            elif oc is None and self.check_order_replace(trade, order, current_time, row):
1✔
1032
                # delete trade due to user request
1033
                self.canceled_trade_entries += 1
1✔
1034
                return True
1✔
1035
        # default maintain trade
1036
        return False
1✔
1037

1038
    def check_order_cancel(
1✔
1039
            self, trade: LocalTrade, order: Order, current_time: datetime) -> Optional[bool]:
1040
        """
1041
        Check if current analyzed order has to be canceled.
1042
        Returns True if the trade should be Deleted (initial order was canceled),
1043
                False if it's Canceled
1044
                None if the order is still active.
1045
        """
1046
        timedout = self.strategy.ft_check_timed_out(
1✔
1047
            trade,  # type: ignore[arg-type]
1048
            order, current_time)
1049
        if timedout:
1✔
1050
            if order.side == trade.entry_side:
1✔
1051
                self.timedout_entry_orders += 1
1✔
1052
                if trade.nr_of_successful_entries == 0:
1✔
1053
                    # Remove trade due to entry timeout expiration.
1054
                    return True
1✔
1055
                else:
1056
                    # Close additional entry order
1057
                    del trade.orders[trade.orders.index(order)]
×
1058
                    return False
×
1059
            if order.side == trade.exit_side:
1✔
1060
                self.timedout_exit_orders += 1
1✔
1061
                # Close exit order and retry exiting on next signal.
1062
                del trade.orders[trade.orders.index(order)]
1✔
1063
                return False
1✔
1064
        return None
1✔
1065

1066
    def check_order_replace(self, trade: LocalTrade, order: Order, current_time,
1✔
1067
                            row: Tuple) -> bool:
1068
        """
1069
        Check if current analyzed entry order has to be replaced and do so.
1070
        If user requested cancellation and there are no filled orders in the trade will
1071
        instruct caller to delete the trade.
1072
        Returns True if the trade should be deleted.
1073
        """
1074
        # only check on new candles for open entry orders
1075
        if order.side == trade.entry_side and current_time > order.order_date_utc:
1✔
1076
            requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price,
1✔
1077
                                                   default_retval=order.ft_price)(
1078
                trade=trade,  # type: ignore[arg-type]
1079
                order=order, pair=trade.pair, current_time=current_time,
1080
                proposed_rate=row[OPEN_IDX], current_order_rate=order.ft_price,
1081
                entry_tag=trade.enter_tag, side=trade.trade_direction
1082
            )  # default value is current order price
1083

1084
            # cancel existing order whenever a new rate is requested (or None)
1085
            if requested_rate == order.ft_price:
1✔
1086
                # assumption: there can't be multiple open entry orders at any given time
1087
                return False
1✔
1088
            else:
1089
                del trade.orders[trade.orders.index(order)]
1✔
1090
                self.canceled_entry_orders += 1
1✔
1091

1092
            # place new order if result was not None
1093
            if requested_rate:
1✔
1094
                self._enter_trade(pair=trade.pair, row=row, trade=trade,
1✔
1095
                                  requested_rate=requested_rate,
1096
                                  requested_stake=(
1097
                                    order.safe_remaining * order.ft_price / trade.leverage),
1098
                                  direction='short' if trade.is_short else 'long')
1099
                # Delete trade if no successful entries happened (if placing the new order failed)
1100
                if not trade.has_open_orders and trade.nr_of_successful_entries == 0:
1✔
1101
                    return True
×
1102
                self.replaced_entry_orders += 1
1✔
1103
            else:
1104
                # assumption: there can't be multiple open entry orders at any given time
1105
                return (trade.nr_of_successful_entries == 0)
1✔
1106
        return False
1✔
1107

1108
    def validate_row(
1✔
1109
            self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
1110
        try:
1✔
1111
            # Row is treated as "current incomplete candle".
1112
            # entry / exit signals are shifted by 1 to compensate for this.
1113
            row = data[pair][row_index]
1✔
1114
        except IndexError:
1✔
1115
            # missing Data for one pair at the end.
1116
            # Warnings for this are shown during data loading
1117
            return None
1✔
1118

1119
        # Waits until the time-counter reaches the start of the data for this pair.
1120
        if row[DATE_IDX] > current_time:
1✔
1121
            return None
1✔
1122
        return row
1✔
1123

1124
    def _collate_rejected(self, pair, row):
1✔
1125
        """
1126
        Temporarily store rejected signal information for downstream use in backtesting_analysis
1127
        """
1128
        # It could be fun to enable hyperopt mode to write
1129
        # a loss function to reduce rejected signals
1130
        if (self.config.get('export', 'none') == 'signals' and
1✔
1131
                self.dataprovider.runmode == RunMode.BACKTEST):
1132
            if pair not in self.rejected_dict:
×
1133
                self.rejected_dict[pair] = []
×
1134
            self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]])
×
1135

1136
    def backtest_loop(
1✔
1137
            self, row: Tuple, pair: str, current_time: datetime, end_date: datetime,
1138
            open_trade_count_start: int, trade_dir: Optional[LongShort],
1139
            is_first: bool = True) -> int:
1140
        """
1141
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1142

1143
        Backtesting processing for one candle/pair.
1144
        """
1145
        for t in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1146
            # 1. Manage currently open orders of active trades
1147
            if self.manage_open_orders(t, current_time, row):
1✔
1148
                # Close trade
1149
                open_trade_count_start -= 1
1✔
1150
                LocalTrade.remove_bt_trade(t)
1✔
1151
                self.wallets.update()
1✔
1152

1153
        # 2. Process entries.
1154
        # without positionstacking, we can only have one open trade per pair.
1155
        # max_open_trades must be respected
1156
        # don't open on the last row
1157
        # We only open trades on the main candle, not on detail candles
1158
        if (
1✔
1159
            (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
1160
            and is_first
1161
            and current_time != end_date
1162
            and trade_dir is not None
1163
            and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
1164
        ):
1165
            if (self.trade_slot_available(open_trade_count_start)):
1✔
1166
                trade = self._enter_trade(pair, row, trade_dir)
1✔
1167
                if trade:
1✔
1168
                    # TODO: hacky workaround to avoid opening > max_open_trades
1169
                    # This emulates previous behavior - not sure if this is correct
1170
                    # Prevents entering if the trade-slot was freed in this candle
1171
                    open_trade_count_start += 1
1✔
1172
                    # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
1173
                    LocalTrade.add_bt_trade(trade)
1✔
1174
                    self.wallets.update()
1✔
1175
            else:
1176
                self._collate_rejected(pair, row)
1✔
1177

1178
        for trade in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1179
            # 3. Process entry orders.
1180
            order = trade.select_order(trade.entry_side, is_open=True)
1✔
1181
            if self._try_close_open_order(order, trade, current_time, row):
1✔
1182
                self.wallets.update()
1✔
1183

1184
            # 4. Create exit orders (if any)
1185
            if not trade.has_open_orders:
1✔
1186
                self._check_trade_exit(trade, row, current_time)  # Place exit order if necessary
1✔
1187

1188
            # 5. Process exit orders.
1189
            order = trade.select_order(trade.exit_side, is_open=True)
1✔
1190
            if order and self._try_close_open_order(order, trade, current_time, row):
1✔
1191
                sub_trade = order.safe_amount_after_fee != trade.amount
1✔
1192
                if sub_trade:
1✔
1193
                    trade.recalc_trade_from_orders()
×
1194
                else:
1195
                    trade.close_date = current_time
1✔
1196
                    trade.close(order.ft_price, show_msg=False)
1✔
1197

1198
                    # logger.debug(f"{pair} - Backtesting exit {trade}")
1199
                    LocalTrade.close_bt_trade(trade)
1✔
1200
                self.wallets.update()
1✔
1201
                self.run_protections(pair, current_time, trade.trade_direction)
1✔
1202
        return open_trade_count_start
1✔
1203

1204
    def backtest(self, processed: Dict,
1✔
1205
                 start_date: datetime, end_date: datetime) -> Dict[str, Any]:
1206
        """
1207
        Implement backtesting functionality
1208

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

1213
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
1214
        optimize memory usage!
1215
        :param start_date: backtesting timerange start datetime
1216
        :param end_date: backtesting timerange end datetime
1217
        :return: DataFrame with trades (results of backtesting)
1218
        """
1219
        self.prepare_backtest(self.enable_protections)
1✔
1220
        # Ensure wallets are up-to-date (important for --strategy-list)
1221
        self.wallets.update()
1✔
1222
        # Use dict of lists with data for performance
1223
        # (looping lists is a lot faster than pandas DataFrames)
1224
        data: Dict = self._get_ohlcv_as_lists(processed)
1✔
1225

1226
        # Indexes per pair, so some pairs are allowed to have a missing start.
1227
        indexes: Dict = defaultdict(int)
1✔
1228
        current_time = start_date + self.timeframe_td
1✔
1229

1230
        self.progress.init_step(BacktestState.BACKTEST, int(
1✔
1231
            (end_date - start_date) / self.timeframe_td))
1232
        # Loop timerange and get candle for each pair at that point in time
1233
        while current_time <= end_date:
1✔
1234
            open_trade_count_start = LocalTrade.bt_open_open_trade_count
1✔
1235
            self.check_abort()
1✔
1236
            strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)(
1✔
1237
                current_time=current_time)
1238
            for i, pair in enumerate(data):
1✔
1239
                row_index = indexes[pair]
1✔
1240
                row = self.validate_row(data, pair, row_index, current_time)
1✔
1241
                if not row:
1✔
1242
                    continue
1✔
1243

1244
                row_index += 1
1✔
1245
                indexes[pair] = row_index
1✔
1246
                self.dataprovider._set_dataframe_max_index(self.required_startup + row_index)
1✔
1247
                self.dataprovider._set_dataframe_max_date(current_time)
1✔
1248
                current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1249
                trade_dir: Optional[LongShort] = self.check_for_trade_entry(row)
1✔
1250

1251
                if (
1✔
1252
                    (trade_dir is not None or len(LocalTrade.bt_trades_open_pp[pair]) > 0)
1253
                    and self.timeframe_detail and pair in self.detail_data
1254
                ):
1255
                    # Spread out into detail timeframe.
1256
                    # Should only happen when we are either in a trade for this pair
1257
                    # or when we got the signal for a new trade.
1258
                    exit_candle_end = current_detail_time + self.timeframe_td
1✔
1259

1260
                    detail_data = self.detail_data[pair]
1✔
1261
                    detail_data = detail_data.loc[
1✔
1262
                        (detail_data['date'] >= current_detail_time) &
1263
                        (detail_data['date'] < exit_candle_end)
1264
                    ].copy()
1265
                    if len(detail_data) == 0:
1✔
1266
                        # Fall back to "regular" data if no detail data was found for this candle
1267
                        open_trade_count_start = self.backtest_loop(
×
1268
                            row, pair, current_time, end_date,
1269
                            open_trade_count_start, trade_dir)
1270
                        continue
×
1271
                    detail_data.loc[:, 'enter_long'] = row[LONG_IDX]
1✔
1272
                    detail_data.loc[:, 'exit_long'] = row[ELONG_IDX]
1✔
1273
                    detail_data.loc[:, 'enter_short'] = row[SHORT_IDX]
1✔
1274
                    detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX]
1✔
1275
                    detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX]
1✔
1276
                    detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX]
1✔
1277
                    is_first = True
1✔
1278
                    current_time_det = current_time
1✔
1279
                    for det_row in detail_data[HEADERS].values.tolist():
1✔
1280
                        self.dataprovider._set_dataframe_max_date(current_time_det)
1✔
1281
                        open_trade_count_start = self.backtest_loop(
1✔
1282
                            det_row, pair, current_time_det, end_date,
1283
                            open_trade_count_start, trade_dir, is_first)
1284
                        current_time_det += self.timeframe_detail_td
1✔
1285
                        is_first = False
1✔
1286
                else:
1287
                    self.dataprovider._set_dataframe_max_date(current_time)
1✔
1288
                    open_trade_count_start = self.backtest_loop(
1✔
1289
                        row, pair, current_time, end_date,
1290
                        open_trade_count_start, trade_dir)
1291

1292
            # Move time one configured time_interval ahead.
1293
            self.progress.increment()
1✔
1294
            current_time += self.timeframe_td
1✔
1295

1296
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1297
        self.wallets.update()
1✔
1298

1299
        results = trade_list_to_dataframe(LocalTrade.trades)
1✔
1300
        return {
1✔
1301
            'results': results,
1302
            'config': self.strategy.config,
1303
            'locks': PairLocks.get_all_locks(),
1304
            'rejected_signals': self.rejected_trades,
1305
            'timedout_entry_orders': self.timedout_entry_orders,
1306
            'timedout_exit_orders': self.timedout_exit_orders,
1307
            'canceled_trade_entries': self.canceled_trade_entries,
1308
            'canceled_entry_orders': self.canceled_entry_orders,
1309
            'replaced_entry_orders': self.replaced_entry_orders,
1310
            'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
1311
        }
1312

1313
    def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame],
1✔
1314
                              timerange: TimeRange):
1315
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1316
        strategy_name = strat.get_strategy_name()
1✔
1317
        logger.info(f"Running backtesting for Strategy {strategy_name}")
1✔
1318
        backtest_start_time = datetime.now(timezone.utc)
1✔
1319
        self._set_strategy(strat)
1✔
1320

1321
        # Use max_open_trades in backtesting, except --disable-max-market-positions is set
1322
        if not self.config.get('use_max_market_positions', True):
1✔
1323
            logger.info(
1✔
1324
                'Ignoring max_open_trades (--disable-max-market-positions was used) ...')
1325
            self.strategy.max_open_trades = float('inf')
1✔
1326
            self.config.update({'max_open_trades': self.strategy.max_open_trades})
1✔
1327

1328
        # need to reprocess data every time to populate signals
1329
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1330

1331
        # Trim startup period from analyzed dataframe
1332
        # This only used to determine if trimming would result in an empty dataframe
1333
        preprocessed_tmp = trim_dataframes(preprocessed, timerange, self.required_startup)
1✔
1334

1335
        if not preprocessed_tmp:
1✔
1336
            raise OperationalException(
×
1337
                "No data left after adjusting for startup candles.")
1338

1339
        # Use preprocessed_tmp for date generation (the trimmed dataframe).
1340
        # Backtesting will re-trim the dataframes after entry/exit signal generation.
1341
        min_date, max_date = history.get_timerange(preprocessed_tmp)
1✔
1342
        logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
1✔
1343
                    f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
1344
                    f'({(max_date - min_date).days} days).')
1345
        # Execute backtest and store results
1346
        results = self.backtest(
1✔
1347
            processed=preprocessed,
1348
            start_date=min_date,
1349
            end_date=max_date,
1350
        )
1351
        backtest_end_time = datetime.now(timezone.utc)
1✔
1352
        results.update({
1✔
1353
            'run_id': self.run_ids.get(strategy_name, ''),
1354
            'backtest_start_time': int(backtest_start_time.timestamp()),
1355
            'backtest_end_time': int(backtest_end_time.timestamp()),
1356
        })
1357
        self.all_results[strategy_name] = results
1✔
1358

1359
        if (self.config.get('export', 'none') == 'signals' and
1✔
1360
                self.dataprovider.runmode == RunMode.BACKTEST):
1361
            self.processed_dfs[strategy_name] = generate_trade_signal_candles(
1✔
1362
                preprocessed_tmp, results)
1363
            self.rejected_df[strategy_name] = generate_rejected_signals(
1✔
1364
                preprocessed_tmp, self.rejected_dict)
1365

1366
        return min_date, max_date
1✔
1367

1368
    def _get_min_cached_backtest_date(self):
1✔
1369
        min_backtest_date = None
1✔
1370
        backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
1✔
1371
        if self.timerange.stopts == 0 or self.timerange.stopdt > datetime.now(tz=timezone.utc):
1✔
1372
            logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
1✔
1373
        elif backtest_cache_age == 'day':
1✔
1374
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
1✔
1375
        elif backtest_cache_age == 'week':
1✔
1376
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
1✔
1377
        elif backtest_cache_age == 'month':
1✔
1378
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
1✔
1379
        return min_backtest_date
1✔
1380

1381
    def load_prior_backtest(self):
1✔
1382
        self.run_ids = {
1✔
1383
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1384
            for strategy in self.strategylist
1385
        }
1386

1387
        # Load previous result that will be updated incrementally.
1388
        # This can be circumvented in certain instances in combination with downloading more data
1389
        min_backtest_date = self._get_min_cached_backtest_date()
1✔
1390
        if min_backtest_date is not None:
1✔
1391
            self.results = find_existing_backtest_stats(
1✔
1392
                self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
1393

1394
    def start(self) -> None:
1✔
1395
        """
1396
        Run backtesting end-to-end
1397
        """
1398
        data: Dict[str, DataFrame] = {}
1✔
1399

1400
        data, timerange = self.load_bt_data()
1✔
1401
        self.load_bt_data_detail()
1✔
1402
        logger.info("Dataload complete. Calculating indicators")
1✔
1403

1404
        self.load_prior_backtest()
1✔
1405

1406
        for strat in self.strategylist:
1✔
1407
            if self.results and strat.get_strategy_name() in self.results['strategy']:
1✔
1408
                # When previous result hash matches - reuse that result and skip backtesting.
1409
                logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
1✔
1410
                continue
1✔
1411
            min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
1✔
1412

1413
        # Update old results with new ones.
1414
        if len(self.all_results) > 0:
1✔
1415
            results = generate_backtest_stats(
1✔
1416
                data, self.all_results, min_date=min_date, max_date=max_date)
1417
            if self.results:
1✔
1418
                self.results['metadata'].update(results['metadata'])
1✔
1419
                self.results['strategy'].update(results['strategy'])
1✔
1420
                self.results['strategy_comparison'].extend(results['strategy_comparison'])
1✔
1421
            else:
1422
                self.results = results
×
1423
            dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1✔
1424
            if self.config.get('export', 'none') in ('trades', 'signals'):
1✔
1425
                combined_res = combined_dataframes_with_rel_mean(data, min_date, max_date)
1✔
1426
                store_backtest_stats(self.config['exportfilename'], self.results, dt_appendix,
1✔
1427
                                     market_change_data=combined_res)
1428

1429
            if (self.config.get('export', 'none') == 'signals' and
1✔
1430
                    self.dataprovider.runmode == RunMode.BACKTEST):
1431
                store_backtest_analysis_results(
1✔
1432
                    self.config['exportfilename'], self.processed_dfs, self.rejected_df,
1433
                    dt_appendix)
1434

1435
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1436
        if 'strategy_list' in self.config and len(self.results) > 0:
1✔
1437
            self.results['strategy_comparison'] = sorted(
1✔
1438
                self.results['strategy_comparison'],
1439
                key=lambda c: self.config['strategy_list'].index(c['key']))
1440
            self.results['strategy'] = dict(
1✔
1441
                sorted(self.results['strategy'].items(),
1442
                       key=lambda kv: self.config['strategy_list'].index(kv[0])))
1443

1444
        if len(self.strategylist) > 0:
1✔
1445
            # Show backtest results
1446
            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