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

freqtrade / freqtrade / 4131164979

pending completion
4131164979

push

github-actions

Matthias
filled-date shouldn't update again

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

17024 of 17946 relevant lines covered (94.86%)

0.95 hits per line

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

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

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

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

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

43

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

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

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

64

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

163
        self.init_backtest()
1✔
164

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

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

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

185
    def init_backtest(self):
1✔
186

187
        self.prepare_backtest(False)
1✔
188

189
        self.wallets = Wallets(self.config, self.exchange, log=False)
1✔
190

191
        self.progress = BTProgress()
1✔
192
        self.abort = False
1✔
193

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

207
        self.strategy.ft_bot_start()
1✔
208
        strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)()
1✔
209

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

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

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

236
        min_date, max_date = history.get_timerange(data)
1✔
237

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

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

246
        self.progress.set_new_value(1)
1✔
247
        return data, self.timerange
1✔
248

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

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

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

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

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

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

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

342
        Used by backtest() - so keep this optimized for performance.
343

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

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

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

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

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

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

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

386
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
387

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

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

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

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

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

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

456
        # Set close_rate to stoploss
457
        return stoploss_value
1✔
458

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

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

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

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

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

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

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

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

572
        return trade
1✔
573

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

578
    def _get_exit_for_signal(
1✔
579
            self, trade: LocalTrade, row: Tuple, exit_: ExitCheckTuple,
580
            amount: Optional[float] = None) -> Optional[LocalTrade]:
581

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

627
            if (exit_.exit_type not in (ExitType.LIQUIDATION, ExitType.PARTIAL_EXIT)
1✔
628
                    and not strategy_safe_wrapper(
629
                    self.strategy.confirm_trade_exit, default_retval=True)(
630
                        pair=trade.pair,
631
                        trade=trade,  # type: ignore[arg-type]
632
                        order_type=order_type,
633
                        amount=amount_,
634
                        rate=close_rate,
635
                        time_in_force=time_in_force,
636
                        sell_reason=exit_reason,  # deprecated
637
                        exit_reason=exit_reason,
638
                        current_time=exit_candle_time)):
639
                return None
×
640

641
            trade.exit_reason = exit_reason
1✔
642

643
            return self._exit_trade(trade, row, close_rate, amount_)
1✔
644
        return None
×
645

646
    def _exit_trade(self, trade: LocalTrade, sell_row: Tuple,
1✔
647
                    close_rate: float, amount: Optional[float] = None) -> Optional[LocalTrade]:
648
        self.order_id_counter += 1
1✔
649
        exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
1✔
650
        order_type = self.strategy.order_types['exit']
1✔
651
        # amount = amount or trade.amount
652
        amount = amount_to_contract_precision(amount or trade.amount, trade.amount_precision,
1✔
653
                                              self.precision_mode, trade.contract_size)
654
        order = Order(
1✔
655
            id=self.order_id_counter,
656
            ft_trade_id=trade.id,
657
            order_date=exit_candle_time,
658
            order_update_date=exit_candle_time,
659
            ft_is_open=True,
660
            ft_pair=trade.pair,
661
            order_id=str(self.order_id_counter),
662
            symbol=trade.pair,
663
            ft_order_side=trade.exit_side,
664
            side=trade.exit_side,
665
            order_type=order_type,
666
            status="open",
667
            price=close_rate,
668
            average=close_rate,
669
            amount=amount,
670
            filled=0,
671
            remaining=amount,
672
            cost=amount * close_rate,
673
        )
674
        trade.orders.append(order)
1✔
675
        return trade
1✔
676

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

680
        if self.trading_mode == TradingMode.FUTURES:
1✔
681
            trade.funding_fees = self.exchange.calculate_funding_fees(
1✔
682
                self.futures_data[trade.pair],
683
                amount=trade.amount,
684
                is_short=trade.is_short,
685
                open_date=trade.date_last_filled_utc,
686
                close_date=exit_candle_time,
687
            )
688

689
        # Check if we need to adjust our current positions
690
        if self.strategy.position_adjustment_enable:
1✔
691
            trade = self._get_adjust_trade_entry_for_candle(trade, row)
1✔
692

693
        enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
694
        exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
695
        exits = self.strategy.should_exit(
1✔
696
            trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(),  # type: ignore
697
            enter=enter, exit_=exit_sig,
698
            low=row[LOW_IDX], high=row[HIGH_IDX]
699
        )
700
        for exit_ in exits:
1✔
701
            t = self._get_exit_for_signal(trade, row, exit_)
1✔
702
            if t:
1✔
703
                return t
1✔
704
        return None
1✔
705

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

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

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

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

749
        min_stake_amount = self.exchange.get_min_pair_stake_amount(
1✔
750
            pair, propose_rate, -0.05, leverage=leverage) or 0
751
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
752
            pair, propose_rate, leverage=leverage)
753
        stake_available = self.wallets.get_available_stake_amount()
1✔
754

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

763
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
764
            pair=pair,
765
            stake_amount=stake_amount,
766
            min_stake_amount=min_stake_amount,
767
            max_stake_amount=max_stake_amount,
768
            trade_amount=trade.stake_amount if trade else None
769
        )
770

771
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
772

773
    def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
1✔
774
                     stake_amount: Optional[float] = None,
775
                     trade: Optional[LocalTrade] = None,
776
                     requested_rate: Optional[float] = None,
777
                     requested_stake: Optional[float] = None) -> Optional[LocalTrade]:
778

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

785
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
786
        precision_price = self.exchange.get_precision_price(pair)
1✔
787

788
        propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
1✔
789
            pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
790
            order_type, precision_price,
791
        )
792

793
        # replace proposed rate if another rate was requested
794
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
795
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
796

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

803
        if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
1✔
804
            self.order_id_counter += 1
1✔
805
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
806
            amount_p = (stake_amount / propose_rate) * leverage
1✔
807

808
            contract_size = self.exchange.get_contract_size(pair)
1✔
809
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
810
            amount = amount_to_contract_precision(amount_p, precision_amount, self.precision_mode,
1✔
811
                                                  contract_size)
812
            # Backcalculate actual stake amount.
813
            stake_amount = amount * propose_rate / leverage
1✔
814

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

824
            is_short = (direction == 'short')
1✔
825
            # Necessary for Margin trading. Disabled until support is enabled.
826
            # interest_rate = self.exchange.get_interest_rate()
827

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

859
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
860

861
            trade.set_liquidation_price(self.exchange.get_liquidation_price(
1✔
862
                pair=pair,
863
                open_rate=propose_rate,
864
                amount=amount,
865
                stake_amount=trade.stake_amount,
866
                wallet_balance=trade.stake_amount,
867
                is_short=is_short,
868
            ))
869

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

898
        return trade
1✔
899

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

914
                trade.close_date = exit_row[DATE_IDX].to_pydatetime()
1✔
915
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
916
                trade.close(exit_row[OPEN_IDX], show_msg=False)
1✔
917
                LocalTrade.close_bt_trade(trade)
1✔
918

919
    def trade_slot_available(self, open_trade_count: int) -> bool:
1✔
920
        # Always allow trades when max_open_trades is enabled.
921
        max_open_trades: IntOrInf = self.config['max_open_trades']
1✔
922
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
923
            return True
1✔
924
        # Rejected trade
925
        self.rejected_trades += 1
1✔
926
        return False
1✔
927

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

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

942
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
943
        if self.enable_protections:
1✔
944
            self.protections.stop_per_pair(pair, current_time, side)
1✔
945
            self.protections.global_stop(current_time, side)
1✔
946

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

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

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

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

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

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

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

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

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

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

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

1097
                # 4. Create exit orders (if any)
1098
            if not trade.open_order_id:
1✔
1099
                self._check_trade_exit(trade, row)  # Place exit order if necessary
1✔
1100

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

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

1120
    def backtest(self, processed: Dict,
1✔
1121
                 start_date: datetime, end_date: datetime) -> Dict[str, Any]:
1122
        """
1123
        Implement backtesting functionality
1124

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

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

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

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

1158
                row_index += 1
1✔
1159
                indexes[pair] = row_index
1✔
1160
                self.dataprovider._set_dataframe_max_index(row_index)
1✔
1161
                current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1162
                trade_dir: Optional[LongShort] = self.check_for_trade_entry(row)
1✔
1163

1164
                if (
1✔
1165
                    (trade_dir is not None or len(LocalTrade.bt_trades_open_pp[pair]) > 0)
1166
                    and self.timeframe_detail and pair in self.detail_data
1167
                ):
1168
                    # Spread out into detail timeframe.
1169
                    # Should only happen when we are either in a trade for this pair
1170
                    # or when we got the signal for a new trade.
1171
                    exit_candle_end = current_detail_time + timedelta(minutes=self.timeframe_min)
1✔
1172

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

1203
            # Move time one configured time_interval ahead.
1204
            self.progress.increment()
1✔
1205
            current_time += timedelta(minutes=self.timeframe_min)
1✔
1206

1207
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1208
        self.wallets.update()
1✔
1209

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

1224
    def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame],
1✔
1225
                              timerange: TimeRange):
1226
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1227

1228
        logger.info(f"Running backtesting for Strategy {strat.get_strategy_name()}")
1✔
1229
        backtest_start_time = datetime.now(timezone.utc)
1✔
1230
        self._set_strategy(strat)
1✔
1231

1232
        # Use max_open_trades in backtesting, except --disable-max-market-positions is set
1233
        if not self.config.get('use_max_market_positions', True):
1✔
1234
            logger.info(
1✔
1235
                'Ignoring max_open_trades (--disable-max-market-positions was used) ...')
1236
            self.strategy.max_open_trades = float('inf')
1✔
1237
            self.config.update({'max_open_trades': self.strategy.max_open_trades})
1✔
1238

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

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

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

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

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

1273
        return min_date, max_date
1✔
1274

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

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

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

1290
                signal_candles_only[pair] = signal_candles_only_df
1✔
1291

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

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

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

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

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

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

1331
        self.load_prior_backtest()
1✔
1332

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

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

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

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

1368
        if len(self.strategylist) > 0:
1✔
1369
            # Show backtest results
1370
            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