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

freqtrade / freqtrade / 6181253459

08 Sep 2023 06:04AM UTC coverage: 94.614% (+0.06%) from 94.556%
6181253459

push

github-actions

web-flow
Merge pull request #9159 from stash86/fix-adjust

remove old codes when we only can do partial entries

2 of 2 new or added lines in 1 file covered. (100.0%)

19114 of 20202 relevant lines covered (94.61%)

0.95 hits per line

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

96.78
/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.enums import (BacktestState, CandleType, ExitCheckTuple, ExitType, RunMode,
1✔
23
                             TradingMode)
24
from freqtrade.exceptions import DependencyException, OperationalException
1✔
25
from freqtrade.exchange import (amount_to_contract_precision, price_to_precision,
1✔
26
                                timeframe_to_minutes, timeframe_to_seconds)
27
from freqtrade.exchange.exchange import Exchange
1✔
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, generate_rejected_signals,
1✔
32
                                                 generate_trade_signal_candles,
33
                                                 show_backtest_results,
34
                                                 store_backtest_analysis_results,
35
                                                 store_backtest_stats)
36
from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade
1✔
37
from freqtrade.plugins.pairlistmanager import PairListManager
1✔
38
from freqtrade.plugins.protectionmanager import ProtectionManager
1✔
39
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
1✔
40
from freqtrade.strategy.interface import IStrategy
1✔
41
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
1✔
42
from freqtrade.types import BacktestResultType, get_BacktestResultType_default
1✔
43
from freqtrade.util.binance_mig import migrate_binance_futures_data
1✔
44
from freqtrade.wallets import Wallets
1✔
45

46

47
logger = logging.getLogger(__name__)
1✔
48

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

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

67

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

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

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

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

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

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

98
        self.dataprovider = DataProvider(self.config, self.exchange)
1✔
99

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

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

115
        if "timeframe" not in self.config:
1✔
116
            raise OperationalException("Timeframe needs to be set in either "
1✔
117
                                       "configuration or as cli argument `--timeframe 5m`")
118
        self.timeframe = str(self.config.get('timeframe'))
1✔
119
        self.timeframe_min = timeframe_to_minutes(self.timeframe)
1✔
120
        self.init_backtest_detail()
1✔
121
        self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
1✔
122
        self._validate_pairlists_for_backtesting()
1✔
123

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

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

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

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

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

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

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

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

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

161
        self.init_backtest()
1✔
162

163
    def _validate_pairlists_for_backtesting(self):
1✔
164
        if 'VolumePairList' in self.pairlists.name_list:
1✔
165
            raise OperationalException("VolumePairList not allowed for backtesting. "
1✔
166
                                       "Please use StaticPairList instead.")
167
        if 'PerformanceFilter' in self.pairlists.name_list:
1✔
168
            raise OperationalException("PerformanceFilter not allowed for backtesting.")
1✔
169

170
        if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list:
1✔
171
            raise OperationalException(
1✔
172
                "PrecisionFilter not allowed for backtesting multiple strategies."
173
            )
174

175
    @staticmethod
1✔
176
    def cleanup():
1✔
177
        LoggingMixin.show_output = True
1✔
178
        PairLocks.use_db = True
1✔
179
        Trade.use_db = True
1✔
180

181
    def init_backtest_detail(self) -> None:
1✔
182
        # Load detail timeframe if specified
183
        self.timeframe_detail = str(self.config.get('timeframe_detail', ''))
1✔
184
        if self.timeframe_detail:
1✔
185
            self.timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail)
1✔
186
            if self.timeframe_min <= self.timeframe_detail_min:
1✔
187
                raise OperationalException(
1✔
188
                    "Detail timeframe must be smaller than strategy timeframe.")
189

190
        else:
191
            self.timeframe_detail_min = 0
1✔
192
        self.detail_data: Dict[str, DataFrame] = {}
1✔
193
        self.futures_data: Dict[str, DataFrame] = {}
1✔
194

195
    def init_backtest(self):
1✔
196

197
        self.prepare_backtest(False)
1✔
198

199
        self.wallets = Wallets(self.config, self.exchange, log=False)
1✔
200

201
        self.progress = BTProgress()
1✔
202
        self.abort = False
1✔
203

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

219
        self.strategy.ft_bot_start()
1✔
220

221
    def _load_protections(self, strategy: IStrategy):
1✔
222
        if self.config.get('enable_protections', False):
1✔
223
            conf = self.config
1✔
224
            if hasattr(strategy, 'protections'):
1✔
225
                conf = deepcopy(conf)
1✔
226
                conf['protections'] = strategy.protections
1✔
227
            self.protections = ProtectionManager(self.config, strategy.protections)
1✔
228

229
    def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
1✔
230
        """
231
        Loads backtest data and returns the data combined with the timerange
232
        as tuple.
233
        """
234
        self.progress.init_step(BacktestState.DATALOAD, 1)
1✔
235

236
        data = history.load_data(
1✔
237
            datadir=self.config['datadir'],
238
            pairs=self.pairlists.whitelist,
239
            timeframe=self.timeframe,
240
            timerange=self.timerange,
241
            startup_candles=self.config['startup_candle_count'],
242
            fail_without_data=True,
243
            data_format=self.config['dataformat_ohlcv'],
244
            candle_type=self.config.get('candle_type_def', CandleType.SPOT)
245
        )
246

247
        min_date, max_date = history.get_timerange(data)
1✔
248

249
        logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
1✔
250
                    f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
251
                    f'({(max_date - min_date).days} days).')
252

253
        # Adjust startts forward if not enough data is available
254
        self.timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
1✔
255
                                                 self.required_startup, min_date)
256

257
        self.progress.set_new_value(1)
1✔
258
        return data, self.timerange
1✔
259

260
    def load_bt_data_detail(self) -> None:
1✔
261
        """
262
        Loads backtest detail data (smaller timeframe) if necessary.
263
        """
264
        if self.timeframe_detail:
1✔
265
            self.detail_data = history.load_data(
1✔
266
                datadir=self.config['datadir'],
267
                pairs=self.pairlists.whitelist,
268
                timeframe=self.timeframe_detail,
269
                timerange=self.timerange,
270
                startup_candles=0,
271
                fail_without_data=True,
272
                data_format=self.config['dataformat_ohlcv'],
273
                candle_type=self.config.get('candle_type_def', CandleType.SPOT)
274
            )
275
        else:
276
            self.detail_data = {}
1✔
277
        if self.trading_mode == TradingMode.FUTURES:
1✔
278
            # Load additional futures data.
279
            funding_rates_dict = history.load_data(
1✔
280
                datadir=self.config['datadir'],
281
                pairs=self.pairlists.whitelist,
282
                timeframe=self.exchange.get_option('mark_ohlcv_timeframe'),
283
                timerange=self.timerange,
284
                startup_candles=0,
285
                fail_without_data=True,
286
                data_format=self.config['dataformat_ohlcv'],
287
                candle_type=CandleType.FUNDING_RATE
288
            )
289

290
            # For simplicity, assign to CandleType.Mark (might contian index candles!)
291
            mark_rates_dict = history.load_data(
1✔
292
                datadir=self.config['datadir'],
293
                pairs=self.pairlists.whitelist,
294
                timeframe=self.exchange.get_option('mark_ohlcv_timeframe'),
295
                timerange=self.timerange,
296
                startup_candles=0,
297
                fail_without_data=True,
298
                data_format=self.config['dataformat_ohlcv'],
299
                candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price"))
300
            )
301
            # Combine data to avoid combining the data per trade.
302
            unavailable_pairs = []
1✔
303
            for pair in self.pairlists.whitelist:
1✔
304
                if pair not in self.exchange._leverage_tiers:
1✔
305
                    unavailable_pairs.append(pair)
1✔
306
                    continue
1✔
307

308
                self.futures_data[pair] = self.exchange.combine_funding_and_mark(
1✔
309
                    funding_rates=funding_rates_dict[pair],
310
                    mark_rates=mark_rates_dict[pair],
311
                    futures_funding_rate=self.config.get('futures_funding_rate', None),
312
                )
313

314
            if unavailable_pairs:
1✔
315
                raise OperationalException(
1✔
316
                    f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. "
317
                    "It is therefore impossible to backtest with this pair at the moment.")
318
        else:
319
            self.futures_data = {}
1✔
320

321
    def prepare_backtest(self, enable_protections):
1✔
322
        """
323
        Backtesting setup method - called once for every call to "backtest()".
324
        """
325
        PairLocks.use_db = False
1✔
326
        PairLocks.timeframe = self.config['timeframe']
1✔
327
        Trade.use_db = False
1✔
328
        PairLocks.reset_locks()
1✔
329
        Trade.reset_trades()
1✔
330
        self.rejected_trades = 0
1✔
331
        self.timedout_entry_orders = 0
1✔
332
        self.timedout_exit_orders = 0
1✔
333
        self.canceled_trade_entries = 0
1✔
334
        self.canceled_entry_orders = 0
1✔
335
        self.replaced_entry_orders = 0
1✔
336
        self.dataprovider.clear_cache()
1✔
337
        if enable_protections:
1✔
338
            self._load_protections(self.strategy)
1✔
339

340
    def check_abort(self):
1✔
341
        """
342
        Check if abort was requested, raise DependencyException if that's the case
343
        Only applies to Interactive backtest mode (webserver mode)
344
        """
345
        if self.abort:
1✔
346
            self.abort = False
1✔
347
            raise DependencyException("Stop requested")
1✔
348

349
    def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
1✔
350
        """
351
        Helper function to convert a processed dataframes into lists for performance reasons.
352

353
        Used by backtest() - so keep this optimized for performance.
354

355
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
356
        optimize memory usage!
357
        """
358

359
        data: Dict = {}
1✔
360
        self.progress.init_step(BacktestState.CONVERT, len(processed))
1✔
361

362
        # Create dict with data
363
        for pair in processed.keys():
1✔
364
            pair_data = processed[pair]
1✔
365
            self.check_abort()
1✔
366
            self.progress.increment()
1✔
367

368
            if not pair_data.empty:
1✔
369
                # Cleanup from prior runs
370
                pair_data.drop(HEADERS[5:] + ['buy', 'sell'], axis=1, errors='ignore')
1✔
371
            df_analyzed = self.strategy.ft_advise_signals(pair_data, {'pair': pair})
1✔
372
            # Update dataprovider cache
373
            self.dataprovider._set_cached_df(
1✔
374
                pair, self.timeframe, df_analyzed, self.config['candle_type_def'])
375

376
            # Trim startup period from analyzed dataframe
377
            df_analyzed = processed[pair] = pair_data = trim_dataframe(
1✔
378
                df_analyzed, self.timerange, startup_candles=self.required_startup)
379

380
            # Create a copy of the dataframe before shifting, that way the entry signal/tag
381
            # remains on the correct candle for callbacks.
382
            df_analyzed = df_analyzed.copy()
1✔
383

384
            # To avoid using data from future, we use entry/exit signals shifted
385
            # from the previous candle
386
            for col in HEADERS[5:]:
1✔
387
                tag_col = col in ('enter_tag', 'exit_tag')
1✔
388
                if col in df_analyzed.columns:
1✔
389
                    df_analyzed[col] = df_analyzed.loc[:, col].replace(
1✔
390
                        [nan], [0 if not tag_col else None]).shift(1)
391
                elif not df_analyzed.empty:
1✔
392
                    df_analyzed[col] = 0 if not tag_col else None
1✔
393

394
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
395

396
            # Convert from Pandas to list for performance reasons
397
            # (Looping Pandas is slow.)
398
            data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else []
1✔
399
        return data
1✔
400

401
    def _get_close_rate(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
402
                        trade_dur: int) -> float:
403
        """
404
        Get close rate for backtesting result
405
        """
406
        # Special handling if high or low hit STOP_LOSS or ROI
407
        if exit.exit_type in (
1✔
408
                ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS, ExitType.LIQUIDATION):
409
            return self._get_close_rate_for_stoploss(row, trade, exit, trade_dur)
1✔
410
        elif exit.exit_type == (ExitType.ROI):
1✔
411
            return self._get_close_rate_for_roi(row, trade, exit, trade_dur)
1✔
412
        else:
413
            return row[OPEN_IDX]
1✔
414

415
    def _get_close_rate_for_stoploss(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
1✔
416
                                     trade_dur: int) -> float:
417
        # our stoploss was already lower than candle high,
418
        # possibly due to a cancelled trade exit.
419
        # exit at open price.
420
        is_short = trade.is_short or False
1✔
421
        leverage = trade.leverage or 1.0
1✔
422
        side_1 = -1 if is_short else 1
1✔
423
        if exit.exit_type == ExitType.LIQUIDATION and trade.liquidation_price:
1✔
424
            stoploss_value = trade.liquidation_price
×
425
        else:
426
            stoploss_value = trade.stop_loss
1✔
427

428
        if is_short:
1✔
429
            if stoploss_value < row[LOW_IDX]:
1✔
430
                return row[OPEN_IDX]
1✔
431
        else:
432
            if stoploss_value > row[HIGH_IDX]:
1✔
433
                return row[OPEN_IDX]
1✔
434

435
        # Special case: trailing triggers within same candle as trade opened. Assume most
436
        # pessimistic price movement, which is moving just enough to arm stoploss and
437
        # immediately going down to stop price.
438
        if exit.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0:
1✔
439
            if (
1✔
440
                not self.strategy.use_custom_stoploss and self.strategy.trailing_stop
441
                and self.strategy.trailing_only_offset_is_reached
442
                and self.strategy.trailing_stop_positive_offset is not None
443
                and self.strategy.trailing_stop_positive
444
            ):
445
                # Worst case: price reaches stop_positive_offset and dives down.
446
                stop_rate = (row[OPEN_IDX] *
1✔
447
                             (1 + side_1 * abs(self.strategy.trailing_stop_positive_offset) -
448
                              side_1 * abs(self.strategy.trailing_stop_positive / leverage)))
449
            else:
450
                # Worst case: price ticks tiny bit above open and dives down.
451
                stop_rate = row[OPEN_IDX] * (1 - side_1 * abs(
1✔
452
                    (trade.stop_loss_pct or 0.0) / leverage))
453

454
            # Limit lower-end to candle low to avoid exits below the low.
455
            # This still remains "worst case" - but "worst realistic case".
456
            if is_short:
1✔
457
                return min(row[HIGH_IDX], stop_rate)
1✔
458
            else:
459
                return max(row[LOW_IDX], stop_rate)
1✔
460

461
        # Set close_rate to stoploss
462
        return stoploss_value
1✔
463

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

477
            # - (Expected abs profit - open_rate - open_fee) / (fee_close -1)
478
            roi_rate = trade.open_rate * roi / leverage
1✔
479
            open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open)
1✔
480
            close_rate = -(roi_rate + open_fee_rate) / ((trade.fee_close or 0.0) - side_1 * 1)
1✔
481
            if is_short:
1✔
482
                is_new_roi = row[OPEN_IDX] < close_rate
1✔
483
            else:
484
                is_new_roi = row[OPEN_IDX] > close_rate
1✔
485
            if (trade_dur > 0 and trade_dur == roi_entry
1✔
486
                    and roi_entry % self.timeframe_min == 0
487
                    and is_new_roi):
488
                # new ROI entry came into effect.
489
                # use Open rate if open_rate > calculated exit rate
490
                return row[OPEN_IDX]
1✔
491

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

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

520
        else:
521
            # This should not be reached...
522
            return row[OPEN_IDX]
×
523

524
    def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple
1✔
525
                                           ) -> LocalTrade:
526
        current_rate = row[OPEN_IDX]
1✔
527
        current_date = row[DATE_IDX].to_pydatetime()
1✔
528
        current_profit = trade.calc_profit_ratio(current_rate)
1✔
529
        min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1)
1✔
530
        max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate)
1✔
531
        stake_available = self.wallets.get_available_stake_amount()
1✔
532
        stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
1✔
533
                                             default_retval=None, supress_error=True)(
534
            trade=trade,  # type: ignore[arg-type]
535
            current_time=current_date, current_rate=current_rate,
536
            current_profit=current_profit, min_stake=min_stake,
537
            max_stake=min(max_stake, stake_available),
538
            current_entry_rate=current_rate, current_exit_rate=current_rate,
539
            current_entry_profit=current_profit, current_exit_profit=current_profit)
540

541
        # Check if we should increase our position
542
        if stake_amount is not None and stake_amount > 0.0:
1✔
543
            check_adjust_entry = True
1✔
544
            if self.strategy.max_entry_position_adjustment > -1:
1✔
545
                entry_count = trade.nr_of_successful_entries
×
546
                check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment)
×
547
            if check_adjust_entry:
1✔
548
                pos_trade = self._enter_trade(
1✔
549
                    trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade)
550
                if pos_trade is not None:
1✔
551
                    self.wallets.update()
1✔
552
                    return pos_trade
1✔
553

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

576
        return trade
1✔
577

578
    def _get_order_filled(self, rate: float, row: Tuple) -> bool:
1✔
579
        """ Rate is within candle, therefore filled"""
580
        return row[LOW_IDX] <= rate <= row[HIGH_IDX]
1✔
581

582
    def _call_adjust_stop(self, current_date: datetime, trade: LocalTrade, current_rate: float):
1✔
583
        profit = trade.calc_profit_ratio(current_rate)
1✔
584
        self.strategy.ft_stoploss_adjust(current_rate, trade,  # type: ignore
1✔
585
                                         current_date, profit, 0, after_fill=True)
586

587
    def _try_close_open_order(
1✔
588
            self, order: Optional[Order], trade: LocalTrade, current_date: datetime,
589
            row: Tuple) -> bool:
590
        """
591
        Check if an order is open and if it should've filled.
592
        :return:  True if the order filled.
593
        """
594
        if order and self._get_order_filled(order.ft_price, row):
1✔
595
            order.close_bt_order(current_date, trade)
1✔
596
            trade.open_order_id = None
1✔
597
            if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount):
1✔
598
                self._call_adjust_stop(current_date, trade, order.ft_price)
1✔
599
                # pass
600
            return True
1✔
601
        return False
1✔
602

603
    def _get_exit_for_signal(
1✔
604
            self, trade: LocalTrade, row: Tuple, exit_: ExitCheckTuple,
605
            amount: Optional[float] = None) -> Optional[LocalTrade]:
606

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

652
            if (exit_.exit_type not in (ExitType.LIQUIDATION, ExitType.PARTIAL_EXIT)
1✔
653
                    and not strategy_safe_wrapper(
654
                    self.strategy.confirm_trade_exit, default_retval=True)(
655
                        pair=trade.pair,
656
                        trade=trade,  # type: ignore[arg-type]
657
                        order_type=order_type,
658
                        amount=amount_,
659
                        rate=close_rate,
660
                        time_in_force=time_in_force,
661
                        sell_reason=exit_reason,  # deprecated
662
                        exit_reason=exit_reason,
663
                        current_time=exit_candle_time)):
664
                return None
×
665

666
            trade.exit_reason = exit_reason
1✔
667

668
            return self._exit_trade(trade, row, close_rate, amount_)
1✔
669
        return None
×
670

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

704
    def _check_trade_exit(self, trade: LocalTrade, row: Tuple) -> Optional[LocalTrade]:
1✔
705
        exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
706

707
        if self.trading_mode == TradingMode.FUTURES:
1✔
708
            trade.funding_fees = self.exchange.calculate_funding_fees(
1✔
709
                self.futures_data[trade.pair],
710
                amount=trade.amount,
711
                is_short=trade.is_short,
712
                open_date=trade.date_last_filled_utc,
713
                close_date=exit_candle_time,
714
            )
715

716
        # Check if we need to adjust our current positions
717
        if self.strategy.position_adjustment_enable:
1✔
718
            trade = self._get_adjust_trade_entry_for_candle(trade, row)
1✔
719

720
        enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
721
        exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
722
        exits = self.strategy.should_exit(
1✔
723
            trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(),  # type: ignore
724
            enter=enter, exit_=exit_sig,
725
            low=row[LOW_IDX], high=row[HIGH_IDX]
726
        )
727
        for exit_ in exits:
1✔
728
            t = self._get_exit_for_signal(trade, row, exit_)
1✔
729
            if t:
1✔
730
                return t
1✔
731
        return None
1✔
732

733
    def get_valid_price_and_stake(
1✔
734
        self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
735
        direction: LongShort, current_time: datetime, entry_tag: Optional[str],
736
        trade: Optional[LocalTrade], order_type: str, price_precision: Optional[float]
737
    ) -> Tuple[float, float, float, float]:
738

739
        if order_type == 'limit':
1✔
740
            new_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
1✔
741
                                             default_retval=propose_rate)(
742
                pair=pair, current_time=current_time,
743
                proposed_rate=propose_rate, entry_tag=entry_tag,
744
                side=direction,
745
            )  # default value is the open rate
746
            # We can't place orders higher than current high (otherwise it'd be a stop limit entry)
747
            # which freqtrade does not support in live.
748
            if new_rate != propose_rate:
1✔
749
                propose_rate = price_to_precision(new_rate, price_precision,
1✔
750
                                                  self.precision_mode)
751
            if direction == "short":
1✔
752
                propose_rate = max(propose_rate, row[LOW_IDX])
1✔
753
            else:
754
                propose_rate = min(propose_rate, row[HIGH_IDX])
1✔
755

756
        pos_adjust = trade is not None
1✔
757
        leverage = trade.leverage if trade else 1.0
1✔
758
        if not pos_adjust:
1✔
759
            try:
1✔
760
                stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False)
1✔
761
            except DependencyException:
1✔
762
                return 0, 0, 0, 0
1✔
763

764
            max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
1✔
765
            leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
1✔
766
                pair=pair,
767
                current_time=current_time,
768
                current_rate=row[OPEN_IDX],
769
                proposed_leverage=1.0,
770
                max_leverage=max_leverage,
771
                side=direction, entry_tag=entry_tag,
772
            ) if self.trading_mode != TradingMode.SPOT else 1.0
773
            # Cap leverage between 1.0 and max_leverage.
774
            leverage = min(max(leverage, 1.0), max_leverage)
1✔
775

776
        min_stake_amount = self.exchange.get_min_pair_stake_amount(
1✔
777
            pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage) or 0
778
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
779
            pair, propose_rate, leverage=leverage)
780
        stake_available = self.wallets.get_available_stake_amount()
1✔
781

782
        if not pos_adjust:
1✔
783
            stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
1✔
784
                                                 default_retval=stake_amount)(
785
                pair=pair, current_time=current_time, current_rate=propose_rate,
786
                proposed_stake=stake_amount, min_stake=min_stake_amount,
787
                max_stake=min(stake_available, max_stake_amount),
788
                leverage=leverage, entry_tag=entry_tag, side=direction)
789

790
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
791
            pair=pair,
792
            stake_amount=stake_amount,
793
            min_stake_amount=min_stake_amount,
794
            max_stake_amount=max_stake_amount,
795
            trade_amount=trade.stake_amount if trade else None
796
        )
797

798
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
799

800
    def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
1✔
801
                     stake_amount: Optional[float] = None,
802
                     trade: Optional[LocalTrade] = None,
803
                     requested_rate: Optional[float] = None,
804
                     requested_stake: Optional[float] = None) -> Optional[LocalTrade]:
805
        """
806
        :param trade: Trade to adjust - initial entry if None
807
        :param requested_rate: Adjusted entry rate
808
        :param requested_stake: Stake amount for adjusted orders (`adjust_entry_price`).
809
        """
810

811
        current_time = row[DATE_IDX].to_pydatetime()
1✔
812
        entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None
1✔
813
        # let's call the custom entry price, using the open price as default price
814
        order_type = self.strategy.order_types['entry']
1✔
815
        pos_adjust = trade is not None and requested_rate is None
1✔
816

817
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
818
        precision_price = self.exchange.get_precision_price(pair)
1✔
819

820
        propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
1✔
821
            pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
822
            order_type, precision_price,
823
        )
824

825
        # replace proposed rate if another rate was requested
826
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
827
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
828

829
        if not stake_amount:
1✔
830
            # In case of pos adjust, still return the original trade
831
            # If not pos adjust, trade is None
832
            return trade
1✔
833
        time_in_force = self.strategy.order_time_in_force['entry']
1✔
834

835
        if stake_amount and (not min_stake_amount or stake_amount >= min_stake_amount):
1✔
836
            self.order_id_counter += 1
1✔
837
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
838
            amount_p = (stake_amount / propose_rate) * leverage
1✔
839

840
            contract_size = self.exchange.get_contract_size(pair)
1✔
841
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
842
            amount = amount_to_contract_precision(amount_p, precision_amount, self.precision_mode,
1✔
843
                                                  contract_size)
844
            # Backcalculate actual stake amount.
845
            stake_amount = amount * propose_rate / leverage
1✔
846

847
            if not pos_adjust:
1✔
848
                # Confirm trade entry:
849
                if not strategy_safe_wrapper(
1✔
850
                        self.strategy.confirm_trade_entry, default_retval=True)(
851
                            pair=pair, order_type=order_type, amount=amount, rate=propose_rate,
852
                            time_in_force=time_in_force, current_time=current_time,
853
                            entry_tag=entry_tag, side=direction):
854
                    return trade
1✔
855

856
            is_short = (direction == 'short')
1✔
857
            # Necessary for Margin trading. Disabled until support is enabled.
858
            # interest_rate = self.exchange.get_interest_rate()
859

860
            if trade is None:
1✔
861
                # Enter trade
862
                self.trade_id_counter += 1
1✔
863
                trade = LocalTrade(
1✔
864
                    id=self.trade_id_counter,
865
                    open_order_id=self.order_id_counter,
866
                    pair=pair,
867
                    base_currency=base_currency,
868
                    stake_currency=self.config['stake_currency'],
869
                    open_rate=propose_rate,
870
                    open_rate_requested=propose_rate,
871
                    open_date=current_time,
872
                    stake_amount=stake_amount,
873
                    amount=amount,
874
                    amount_requested=amount,
875
                    fee_open=self.fee,
876
                    fee_close=self.fee,
877
                    is_open=True,
878
                    enter_tag=entry_tag,
879
                    exchange=self._exchange_name,
880
                    is_short=is_short,
881
                    trading_mode=self.trading_mode,
882
                    leverage=leverage,
883
                    # interest_rate=interest_rate,
884
                    amount_precision=precision_amount,
885
                    price_precision=precision_price,
886
                    precision_mode=self.precision_mode,
887
                    contract_size=contract_size,
888
                    orders=[],
889
                )
890

891
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
892

893
            trade.set_liquidation_price(self.exchange.get_liquidation_price(
1✔
894
                pair=pair,
895
                open_rate=propose_rate,
896
                amount=amount,
897
                stake_amount=trade.stake_amount,
898
                leverage=trade.leverage,
899
                wallet_balance=trade.stake_amount,
900
                is_short=is_short,
901
            ))
902

903
            order = Order(
1✔
904
                id=self.order_id_counter,
905
                ft_trade_id=trade.id,
906
                ft_is_open=True,
907
                ft_pair=trade.pair,
908
                order_id=str(self.order_id_counter),
909
                symbol=trade.pair,
910
                ft_order_side=trade.entry_side,
911
                side=trade.entry_side,
912
                order_type=order_type,
913
                status="open",
914
                order_date=current_time,
915
                order_filled_date=current_time,
916
                order_update_date=current_time,
917
                ft_price=propose_rate,
918
                price=propose_rate,
919
                average=propose_rate,
920
                amount=amount,
921
                filled=0,
922
                remaining=amount,
923
                cost=amount * propose_rate + trade.fee_open,
924
            )
925
            order._trade_bt = trade
1✔
926
            trade.orders.append(order)
1✔
927
            if not self._try_close_open_order(order, trade, current_time, row):
1✔
928
                trade.open_order_id = str(self.order_id_counter)
1✔
929
            trade.recalc_trade_from_orders()
1✔
930

931
        return trade
1✔
932

933
    def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
1✔
934
                         data: Dict[str, List[Tuple]]) -> None:
935
        """
936
        Handling of left open trades at the end of backtesting
937
        """
938
        for pair in open_trades.keys():
1✔
939
            for trade in list(open_trades[pair]):
1✔
940
                if trade.open_order_id and trade.nr_of_successful_entries == 0:
1✔
941
                    # Ignore trade if entry-order did not fill yet
942
                    continue
×
943
                exit_row = data[pair][-1]
1✔
944
                self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount)
1✔
945
                trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade)
1✔
946

947
                trade.close_date = exit_row[DATE_IDX].to_pydatetime()
1✔
948
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
949
                trade.close(exit_row[OPEN_IDX], show_msg=False)
1✔
950
                LocalTrade.close_bt_trade(trade)
1✔
951

952
    def trade_slot_available(self, open_trade_count: int) -> bool:
1✔
953
        # Always allow trades when max_open_trades is enabled.
954
        max_open_trades: IntOrInf = self.config['max_open_trades']
1✔
955
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
956
            return True
1✔
957
        # Rejected trade
958
        self.rejected_trades += 1
1✔
959
        return False
1✔
960

961
    def check_for_trade_entry(self, row) -> Optional[LongShort]:
1✔
962
        enter_long = row[LONG_IDX] == 1
1✔
963
        exit_long = row[ELONG_IDX] == 1
1✔
964
        enter_short = self._can_short and row[SHORT_IDX] == 1
1✔
965
        exit_short = self._can_short and row[ESHORT_IDX] == 1
1✔
966

967
        if enter_long == 1 and not any([exit_long, enter_short]):
1✔
968
            # Long
969
            return 'long'
1✔
970
        if enter_short == 1 and not any([exit_short, enter_long]):
1✔
971
            # Short
972
            return 'short'
1✔
973
        return None
1✔
974

975
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
976
        if self.enable_protections:
1✔
977
            self.protections.stop_per_pair(pair, current_time, side)
1✔
978
            self.protections.global_stop(current_time, side)
1✔
979

980
    def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: Tuple) -> bool:
1✔
981
        """
982
        Check if any open order needs to be cancelled or replaced.
983
        Returns True if the trade should be deleted.
984
        """
985
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
986
            oc = self.check_order_cancel(trade, order, current_time)
1✔
987
            if oc:
1✔
988
                # delete trade due to order timeout
989
                return True
1✔
990
            elif oc is None and self.check_order_replace(trade, order, current_time, row):
1✔
991
                # delete trade due to user request
992
                self.canceled_trade_entries += 1
1✔
993
                return True
1✔
994
        # default maintain trade
995
        return False
1✔
996

997
    def check_order_cancel(
1✔
998
            self, trade: LocalTrade, order: Order, current_time: datetime) -> Optional[bool]:
999
        """
1000
        Check if current analyzed order has to be canceled.
1001
        Returns True if the trade should be Deleted (initial order was canceled),
1002
                False if it's Canceled
1003
                None if the order is still active.
1004
        """
1005
        timedout = self.strategy.ft_check_timed_out(
1✔
1006
            trade,  # type: ignore[arg-type]
1007
            order, current_time)
1008
        if timedout:
1✔
1009
            if order.side == trade.entry_side:
1✔
1010
                self.timedout_entry_orders += 1
1✔
1011
                if trade.nr_of_successful_entries == 0:
1✔
1012
                    # Remove trade due to entry timeout expiration.
1013
                    return True
1✔
1014
                else:
1015
                    # Close additional entry order
1016
                    del trade.orders[trade.orders.index(order)]
×
1017
                    trade.open_order_id = None
×
1018
                    return False
×
1019
            if order.side == trade.exit_side:
1✔
1020
                self.timedout_exit_orders += 1
1✔
1021
                # Close exit order and retry exiting on next signal.
1022
                del trade.orders[trade.orders.index(order)]
1✔
1023
                trade.open_order_id = None
1✔
1024
                return False
1✔
1025
        return None
1✔
1026

1027
    def check_order_replace(self, trade: LocalTrade, order: Order, current_time,
1✔
1028
                            row: Tuple) -> bool:
1029
        """
1030
        Check if current analyzed entry order has to be replaced and do so.
1031
        If user requested cancellation and there are no filled orders in the trade will
1032
        instruct caller to delete the trade.
1033
        Returns True if the trade should be deleted.
1034
        """
1035
        # only check on new candles for open entry orders
1036
        if order.side == trade.entry_side and current_time > order.order_date_utc:
1✔
1037
            requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price,
1✔
1038
                                                   default_retval=order.ft_price)(
1039
                trade=trade,  # type: ignore[arg-type]
1040
                order=order, pair=trade.pair, current_time=current_time,
1041
                proposed_rate=row[OPEN_IDX], current_order_rate=order.ft_price,
1042
                entry_tag=trade.enter_tag, side=trade.trade_direction
1043
            )  # default value is current order price
1044

1045
            # cancel existing order whenever a new rate is requested (or None)
1046
            if requested_rate == order.ft_price:
1✔
1047
                # assumption: there can't be multiple open entry orders at any given time
1048
                return False
1✔
1049
            else:
1050
                del trade.orders[trade.orders.index(order)]
1✔
1051
                trade.open_order_id = None
1✔
1052
                self.canceled_entry_orders += 1
1✔
1053

1054
            # place new order if result was not None
1055
            if requested_rate:
1✔
1056
                self._enter_trade(pair=trade.pair, row=row, trade=trade,
1✔
1057
                                  requested_rate=requested_rate,
1058
                                  requested_stake=(
1059
                                    order.safe_remaining * order.ft_price / trade.leverage),
1060
                                  direction='short' if trade.is_short else 'long')
1061
                # Delete trade if no successful entries happened (if placing the new order failed)
1062
                if trade.open_order_id is None and trade.nr_of_successful_entries == 0:
1✔
1063
                    return True
×
1064
                self.replaced_entry_orders += 1
1✔
1065
            else:
1066
                # assumption: there can't be multiple open entry orders at any given time
1067
                return (trade.nr_of_successful_entries == 0)
1✔
1068
        return False
1✔
1069

1070
    def validate_row(
1✔
1071
            self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
1072
        try:
1✔
1073
            # Row is treated as "current incomplete candle".
1074
            # entry / exit signals are shifted by 1 to compensate for this.
1075
            row = data[pair][row_index]
1✔
1076
        except IndexError:
1✔
1077
            # missing Data for one pair at the end.
1078
            # Warnings for this are shown during data loading
1079
            return None
1✔
1080

1081
        # Waits until the time-counter reaches the start of the data for this pair.
1082
        if row[DATE_IDX] > current_time:
1✔
1083
            return None
1✔
1084
        return row
1✔
1085

1086
    def _collate_rejected(self, pair, row):
1✔
1087
        """
1088
        Temporarily store rejected signal information for downstream use in backtesting_analysis
1089
        """
1090
        # It could be fun to enable hyperopt mode to write
1091
        # a loss function to reduce rejected signals
1092
        if (self.config.get('export', 'none') == 'signals' and
1✔
1093
                self.dataprovider.runmode == RunMode.BACKTEST):
1094
            if pair not in self.rejected_dict:
×
1095
                self.rejected_dict[pair] = []
×
1096
            self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]])
×
1097

1098
    def backtest_loop(
1✔
1099
            self, row: Tuple, pair: str, current_time: datetime, end_date: datetime,
1100
            open_trade_count_start: int, trade_dir: Optional[LongShort],
1101
            is_first: bool = True) -> int:
1102
        """
1103
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1104

1105
        Backtesting processing for one candle/pair.
1106
        """
1107
        for t in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1108
            # 1. Manage currently open orders of active trades
1109
            if self.manage_open_orders(t, current_time, row):
1✔
1110
                # Close trade
1111
                open_trade_count_start -= 1
1✔
1112
                LocalTrade.remove_bt_trade(t)
1✔
1113
                self.wallets.update()
1✔
1114

1115
        # 2. Process entries.
1116
        # without positionstacking, we can only have one open trade per pair.
1117
        # max_open_trades must be respected
1118
        # don't open on the last row
1119
        # We only open trades on the main candle, not on detail candles
1120
        if (
1✔
1121
            (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
1122
            and is_first
1123
            and current_time != end_date
1124
            and trade_dir is not None
1125
            and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
1126
        ):
1127
            if (self.trade_slot_available(open_trade_count_start)):
1✔
1128
                trade = self._enter_trade(pair, row, trade_dir)
1✔
1129
                if trade:
1✔
1130
                    # TODO: hacky workaround to avoid opening > max_open_trades
1131
                    # This emulates previous behavior - not sure if this is correct
1132
                    # Prevents entering if the trade-slot was freed in this candle
1133
                    open_trade_count_start += 1
1✔
1134
                    # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
1135
                    LocalTrade.add_bt_trade(trade)
1✔
1136
                    self.wallets.update()
1✔
1137
            else:
1138
                self._collate_rejected(pair, row)
1✔
1139

1140
        for trade in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1141
            # 3. Process entry orders.
1142
            order = trade.select_order(trade.entry_side, is_open=True)
1✔
1143
            if self._try_close_open_order(order, trade, current_time, row):
1✔
1144
                self.wallets.update()
1✔
1145

1146
            # 4. Create exit orders (if any)
1147
            if not trade.open_order_id:
1✔
1148
                self._check_trade_exit(trade, row)  # Place exit order if necessary
1✔
1149

1150
            # 5. Process exit orders.
1151
            order = trade.select_order(trade.exit_side, is_open=True)
1✔
1152
            if order and self._try_close_open_order(order, trade, current_time, row):
1✔
1153
                sub_trade = order.safe_amount_after_fee != trade.amount
1✔
1154
                if sub_trade:
1✔
1155
                    trade.recalc_trade_from_orders()
×
1156
                else:
1157
                    trade.close_date = current_time
1✔
1158
                    trade.close(order.ft_price, show_msg=False)
1✔
1159

1160
                    # logger.debug(f"{pair} - Backtesting exit {trade}")
1161
                    LocalTrade.close_bt_trade(trade)
1✔
1162
                self.wallets.update()
1✔
1163
                self.run_protections(pair, current_time, trade.trade_direction)
1✔
1164
        return open_trade_count_start
1✔
1165

1166
    def backtest(self, processed: Dict,
1✔
1167
                 start_date: datetime, end_date: datetime) -> Dict[str, Any]:
1168
        """
1169
        Implement backtesting functionality
1170

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

1175
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
1176
        optimize memory usage!
1177
        :param start_date: backtesting timerange start datetime
1178
        :param end_date: backtesting timerange end datetime
1179
        :return: DataFrame with trades (results of backtesting)
1180
        """
1181
        self.prepare_backtest(self.enable_protections)
1✔
1182
        # Ensure wallets are uptodate (important for --strategy-list)
1183
        self.wallets.update()
1✔
1184
        # Use dict of lists with data for performance
1185
        # (looping lists is a lot faster than pandas DataFrames)
1186
        data: Dict = self._get_ohlcv_as_lists(processed)
1✔
1187

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

1192
        self.progress.init_step(BacktestState.BACKTEST, int(
1✔
1193
            (end_date - start_date) / timedelta(minutes=self.timeframe_min)))
1194
        # Loop timerange and get candle for each pair at that point in time
1195
        while current_time <= end_date:
1✔
1196
            open_trade_count_start = LocalTrade.bt_open_open_trade_count
1✔
1197
            self.check_abort()
1✔
1198
            strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)(
1✔
1199
                current_time=current_time)
1200
            for i, pair in enumerate(data):
1✔
1201
                row_index = indexes[pair]
1✔
1202
                row = self.validate_row(data, pair, row_index, current_time)
1✔
1203
                if not row:
1✔
1204
                    continue
1✔
1205

1206
                row_index += 1
1✔
1207
                indexes[pair] = row_index
1✔
1208
                self.dataprovider._set_dataframe_max_index(self.required_startup + row_index)
1✔
1209
                self.dataprovider._set_dataframe_max_date(current_time)
1✔
1210
                current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1211
                trade_dir: Optional[LongShort] = self.check_for_trade_entry(row)
1✔
1212

1213
                if (
1✔
1214
                    (trade_dir is not None or len(LocalTrade.bt_trades_open_pp[pair]) > 0)
1215
                    and self.timeframe_detail and pair in self.detail_data
1216
                ):
1217
                    # Spread out into detail timeframe.
1218
                    # Should only happen when we are either in a trade for this pair
1219
                    # or when we got the signal for a new trade.
1220
                    exit_candle_end = current_detail_time + timedelta(minutes=self.timeframe_min)
1✔
1221

1222
                    detail_data = self.detail_data[pair]
1✔
1223
                    detail_data = detail_data.loc[
1✔
1224
                        (detail_data['date'] >= current_detail_time) &
1225
                        (detail_data['date'] < exit_candle_end)
1226
                    ].copy()
1227
                    if len(detail_data) == 0:
1✔
1228
                        # Fall back to "regular" data if no detail data was found for this candle
1229
                        open_trade_count_start = self.backtest_loop(
×
1230
                            row, pair, current_time, end_date,
1231
                            open_trade_count_start, trade_dir)
1232
                        continue
×
1233
                    detail_data.loc[:, 'enter_long'] = row[LONG_IDX]
1✔
1234
                    detail_data.loc[:, 'exit_long'] = row[ELONG_IDX]
1✔
1235
                    detail_data.loc[:, 'enter_short'] = row[SHORT_IDX]
1✔
1236
                    detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX]
1✔
1237
                    detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX]
1✔
1238
                    detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX]
1✔
1239
                    is_first = True
1✔
1240
                    current_time_det = current_time
1✔
1241
                    for det_row in detail_data[HEADERS].values.tolist():
1✔
1242
                        self.dataprovider._set_dataframe_max_date(current_time_det)
1✔
1243
                        open_trade_count_start = self.backtest_loop(
1✔
1244
                            det_row, pair, current_time_det, end_date,
1245
                            open_trade_count_start, trade_dir, is_first)
1246
                        current_time_det += timedelta(minutes=self.timeframe_detail_min)
1✔
1247
                        is_first = False
1✔
1248
                else:
1249
                    self.dataprovider._set_dataframe_max_date(current_time)
1✔
1250
                    open_trade_count_start = self.backtest_loop(
1✔
1251
                        row, pair, current_time, end_date,
1252
                        open_trade_count_start, trade_dir)
1253

1254
            # Move time one configured time_interval ahead.
1255
            self.progress.increment()
1✔
1256
            current_time += timedelta(minutes=self.timeframe_min)
1✔
1257

1258
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1259
        self.wallets.update()
1✔
1260

1261
        results = trade_list_to_dataframe(LocalTrade.trades)
1✔
1262
        return {
1✔
1263
            'results': results,
1264
            'config': self.strategy.config,
1265
            'locks': PairLocks.get_all_locks(),
1266
            'rejected_signals': self.rejected_trades,
1267
            'timedout_entry_orders': self.timedout_entry_orders,
1268
            'timedout_exit_orders': self.timedout_exit_orders,
1269
            'canceled_trade_entries': self.canceled_trade_entries,
1270
            'canceled_entry_orders': self.canceled_entry_orders,
1271
            'replaced_entry_orders': self.replaced_entry_orders,
1272
            'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
1273
        }
1274

1275
    def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame],
1✔
1276
                              timerange: TimeRange):
1277
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1278
        strategy_name = strat.get_strategy_name()
1✔
1279
        logger.info(f"Running backtesting for Strategy {strategy_name}")
1✔
1280
        backtest_start_time = datetime.now(timezone.utc)
1✔
1281
        self._set_strategy(strat)
1✔
1282

1283
        # Use max_open_trades in backtesting, except --disable-max-market-positions is set
1284
        if not self.config.get('use_max_market_positions', True):
1✔
1285
            logger.info(
1✔
1286
                'Ignoring max_open_trades (--disable-max-market-positions was used) ...')
1287
            self.strategy.max_open_trades = float('inf')
1✔
1288
            self.config.update({'max_open_trades': self.strategy.max_open_trades})
1✔
1289

1290
        # need to reprocess data every time to populate signals
1291
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1292

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

1297
        if not preprocessed_tmp:
1✔
1298
            raise OperationalException(
×
1299
                "No data left after adjusting for startup candles.")
1300

1301
        # Use preprocessed_tmp for date generation (the trimmed dataframe).
1302
        # Backtesting will re-trim the dataframes after entry/exit signal generation.
1303
        min_date, max_date = history.get_timerange(preprocessed_tmp)
1✔
1304
        logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
1✔
1305
                    f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
1306
                    f'({(max_date - min_date).days} days).')
1307
        # Execute backtest and store results
1308
        results = self.backtest(
1✔
1309
            processed=preprocessed,
1310
            start_date=min_date,
1311
            end_date=max_date,
1312
        )
1313
        backtest_end_time = datetime.now(timezone.utc)
1✔
1314
        results.update({
1✔
1315
            'run_id': self.run_ids.get(strategy_name, ''),
1316
            'backtest_start_time': int(backtest_start_time.timestamp()),
1317
            'backtest_end_time': int(backtest_end_time.timestamp()),
1318
        })
1319
        self.all_results[strategy_name] = results
1✔
1320

1321
        if (self.config.get('export', 'none') == 'signals' and
1✔
1322
                self.dataprovider.runmode == RunMode.BACKTEST):
1323
            self.processed_dfs[strategy_name] = generate_trade_signal_candles(
1✔
1324
                preprocessed_tmp, results)
1325
            self.rejected_df[strategy_name] = generate_rejected_signals(
1✔
1326
                preprocessed_tmp, self.rejected_dict)
1327

1328
        return min_date, max_date
1✔
1329

1330
    def _get_min_cached_backtest_date(self):
1✔
1331
        min_backtest_date = None
1✔
1332
        backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
1✔
1333
        if self.timerange.stopts == 0 or self.timerange.stopdt > datetime.now(tz=timezone.utc):
1✔
1334
            logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
1✔
1335
        elif backtest_cache_age == 'day':
1✔
1336
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
1✔
1337
        elif backtest_cache_age == 'week':
1✔
1338
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
1✔
1339
        elif backtest_cache_age == 'month':
1✔
1340
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
1✔
1341
        return min_backtest_date
1✔
1342

1343
    def load_prior_backtest(self):
1✔
1344
        self.run_ids = {
1✔
1345
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1346
            for strategy in self.strategylist
1347
        }
1348

1349
        # Load previous result that will be updated incrementally.
1350
        # This can be circumvented in certain instances in combination with downloading more data
1351
        min_backtest_date = self._get_min_cached_backtest_date()
1✔
1352
        if min_backtest_date is not None:
1✔
1353
            self.results = find_existing_backtest_stats(
1✔
1354
                self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
1355

1356
    def start(self) -> None:
1✔
1357
        """
1358
        Run backtesting end-to-end
1359
        :return: None
1360
        """
1361
        data: Dict[str, Any] = {}
1✔
1362

1363
        data, timerange = self.load_bt_data()
1✔
1364
        self.load_bt_data_detail()
1✔
1365
        logger.info("Dataload complete. Calculating indicators")
1✔
1366

1367
        self.load_prior_backtest()
1✔
1368

1369
        for strat in self.strategylist:
1✔
1370
            if self.results and strat.get_strategy_name() in self.results['strategy']:
1✔
1371
                # When previous result hash matches - reuse that result and skip backtesting.
1372
                logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
1✔
1373
                continue
1✔
1374
            min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
1✔
1375

1376
        # Update old results with new ones.
1377
        if len(self.all_results) > 0:
1✔
1378
            results = generate_backtest_stats(
1✔
1379
                data, self.all_results, min_date=min_date, max_date=max_date)
1380
            if self.results:
1✔
1381
                self.results['metadata'].update(results['metadata'])
1✔
1382
                self.results['strategy'].update(results['strategy'])
1✔
1383
                self.results['strategy_comparison'].extend(results['strategy_comparison'])
1✔
1384
            else:
1385
                self.results = results
×
1386
            dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1✔
1387
            if self.config.get('export', 'none') in ('trades', 'signals'):
1✔
1388
                store_backtest_stats(self.config['exportfilename'], self.results, dt_appendix)
1✔
1389

1390
            if (self.config.get('export', 'none') == 'signals' and
1✔
1391
                    self.dataprovider.runmode == RunMode.BACKTEST):
1392
                store_backtest_analysis_results(
1✔
1393
                    self.config['exportfilename'], self.processed_dfs, self.rejected_df,
1394
                    dt_appendix)
1395

1396
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1397
        if 'strategy_list' in self.config and len(self.results) > 0:
1✔
1398
            self.results['strategy_comparison'] = sorted(
1✔
1399
                self.results['strategy_comparison'],
1400
                key=lambda c: self.config['strategy_list'].index(c['key']))
1401
            self.results['strategy'] = dict(
1✔
1402
                sorted(self.results['strategy'].items(),
1403
                       key=lambda kv: self.config['strategy_list'].index(kv[0])))
1404

1405
        if len(self.strategylist) > 0:
1✔
1406
            # Show backtest results
1407
            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