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

freqtrade / freqtrade / 17562381253

08 Sep 2025 05:40PM UTC coverage: 94.23%. Remained the same
17562381253

push

github

xmatthias
refactor: Improve clarity of method name

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

14 existing lines in 1 file now uncovered.

22456 of 23831 relevant lines covered (94.23%)

0.94 hits per line

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

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

3
"""
4
This module contains the backtesting logic
5
"""
6

7
import logging
1✔
8
from collections import defaultdict
1✔
9
from copy import deepcopy
1✔
10
from datetime import datetime, timedelta
1✔
11

12
from numpy import isnan, nan
1✔
13
from pandas import DataFrame, Series
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 (
1✔
20
    find_existing_backtest_stats,
21
    get_tick_size_over_time,
22
    trade_list_to_dataframe,
23
)
24
from freqtrade.data.converter import trim_dataframe, trim_dataframes
1✔
25
from freqtrade.data.dataprovider import DataProvider
1✔
26
from freqtrade.data.metrics import combined_dataframes_with_rel_mean
1✔
27
from freqtrade.enums import (
1✔
28
    BacktestState,
29
    CandleType,
30
    ExitCheckTuple,
31
    ExitType,
32
    MarginMode,
33
    RunMode,
34
    TradingMode,
35
)
36
from freqtrade.exceptions import DependencyException, OperationalException
1✔
37
from freqtrade.exchange import (
1✔
38
    amount_to_contract_precision,
39
    price_to_precision,
40
    timeframe_to_seconds,
41
)
42
from freqtrade.exchange.exchange import TICK_SIZE, Exchange
1✔
43
from freqtrade.ft_types import (
1✔
44
    BacktestContentType,
45
    BacktestContentTypeIcomplete,
46
    BacktestResultType,
47
    get_BacktestResultType_default,
48
)
49
from freqtrade.leverage.liquidation_price import update_liquidation_prices
1✔
50
from freqtrade.mixins import LoggingMixin
1✔
51
from freqtrade.optimize.backtest_caching import get_strategy_run_id
1✔
52
from freqtrade.optimize.bt_progress import BTProgress
1✔
53
from freqtrade.optimize.optimize_reports import (
1✔
54
    generate_backtest_stats,
55
    generate_rejected_signals,
56
    generate_trade_signal_candles,
57
    show_backtest_results,
58
    store_backtest_results,
59
)
60
from freqtrade.persistence import (
1✔
61
    CustomDataWrapper,
62
    LocalTrade,
63
    Order,
64
    PairLocks,
65
    Trade,
66
    disable_database_use,
67
    enable_database_use,
68
)
69
from freqtrade.plugins.pairlistmanager import PairListManager
1✔
70
from freqtrade.plugins.protectionmanager import ProtectionManager
1✔
71
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
1✔
72
from freqtrade.strategy.interface import IStrategy
1✔
73
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
1✔
74
from freqtrade.util import FtPrecise, dt_now
1✔
75
from freqtrade.util.migrations import migrate_data
1✔
76
from freqtrade.wallets import Wallets
1✔
77

78

79
logger = logging.getLogger(__name__)
1✔
80

81
# Indexes for backtest tuples
82
DATE_IDX = 0
1✔
83
OPEN_IDX = 1
1✔
84
HIGH_IDX = 2
1✔
85
LOW_IDX = 3
1✔
86
CLOSE_IDX = 4
1✔
87
LONG_IDX = 5
1✔
88
ELONG_IDX = 6  # Exit long
1✔
89
SHORT_IDX = 7
1✔
90
ESHORT_IDX = 8  # Exit short
1✔
91
ENTER_TAG_IDX = 9
1✔
92
EXIT_TAG_IDX = 10
1✔
93

94
# Every change to this headers list must evaluate further usages of the resulting tuple
95
# and eventually change the constants for indexes at the top
96
HEADERS = [
1✔
97
    "date",
98
    "open",
99
    "high",
100
    "low",
101
    "close",
102
    "enter_long",
103
    "exit_long",
104
    "enter_short",
105
    "exit_short",
106
    "enter_tag",
107
    "exit_tag",
108
]
109

110

111
class Backtesting:
1✔
112
    """
113
    Backtesting class, this class contains all the logic to run a backtest
114

115
    To run a backtest:
116
    backtesting = Backtesting(config)
117
    backtesting.start()
118
    """
119

120
    def __init__(self, config: Config, exchange: Exchange | None = None) -> None:
1✔
121
        LoggingMixin.show_output = False
1✔
122
        self.config = config
1✔
123
        self.results: BacktestResultType = get_BacktestResultType_default()
1✔
124
        self.trade_id_counter: int = 0
1✔
125
        self.order_id_counter: int = 0
1✔
126

127
        self.config["dry_run"] = True
1✔
128
        self.price_pair_prec: dict[str, Series] = {}
1✔
129
        self.run_ids: dict[str, str] = {}
1✔
130
        self.strategylist: list[IStrategy] = []
1✔
131
        self.all_bt_content: dict[str, BacktestContentType] = {}
1✔
132
        self.analysis_results: dict[str, dict[str, DataFrame]] = {
1✔
133
            "signals": {},
134
            "rejected": {},
135
            "exited": {},
136
        }
137
        self.rejected_dict: dict[str, list] = {}
1✔
138

139
        self._exchange_name = self.config["exchange"]["name"]
1✔
140
        self.__initial_backtest = exchange is None
1✔
141
        if not exchange:
1✔
142
            exchange = ExchangeResolver.load_exchange(self.config, load_leverage_tiers=True)
1✔
143
        self.exchange = exchange
1✔
144

145
        self.dataprovider = DataProvider(self.config, self.exchange)
1✔
146

147
        if self.config.get("strategy_list"):
1✔
148
            if self.config.get("freqai", {}).get("enabled", False):
1✔
149
                logger.warning(
1✔
150
                    "Using --strategy-list with FreqAI REQUIRES all strategies "
151
                    "to have identical feature_engineering_* functions."
152
                )
153
            for strat in list(self.config["strategy_list"]):
1✔
154
                stratconf = deepcopy(self.config)
1✔
155
                stratconf["strategy"] = strat
1✔
156
                self.strategylist.append(StrategyResolver.load_strategy(stratconf))
1✔
157
                validate_config_consistency(stratconf)
1✔
158

159
        else:
160
            # No strategy list specified, only one strategy
161
            self.strategylist.append(StrategyResolver.load_strategy(self.config))
1✔
162
            validate_config_consistency(self.config)
1✔
163

164
        if "timeframe" not in self.config:
1✔
165
            raise OperationalException(
1✔
166
                "Timeframe needs to be set in either "
167
                "configuration or as cli argument `--timeframe 5m`"
168
            )
169
        self.timeframe = str(self.config.get("timeframe"))
1✔
170
        self.timeframe_secs = timeframe_to_seconds(self.timeframe)
1✔
171
        self.timeframe_min = self.timeframe_secs // 60
1✔
172
        self.timeframe_td = timedelta(seconds=self.timeframe_secs)
1✔
173
        self.disable_database_use()
1✔
174
        self.init_backtest_detail()
1✔
175
        self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
1✔
176
        self._validate_pairlists_for_backtesting()
1✔
177

178
        self.dataprovider.add_pairlisthandler(self.pairlists)
1✔
179
        self.pairlists.refresh_pairlist()
1✔
180

181
        if len(self.pairlists.whitelist) == 0:
1✔
182
            raise OperationalException("No pair in whitelist.")
1✔
183
        self.set_fee()
1✔
184
        self.precision_mode = self.exchange.precisionMode
1✔
185
        self.precision_mode_price = self.exchange.precision_mode_price
1✔
186

187
        if self.config.get("freqai_backtest_live_models", False):
1✔
188
            from freqtrade.freqai.utils import get_timerange_backtest_live_models
1✔
189

190
            self.config["timerange"] = get_timerange_backtest_live_models(self.config)
1✔
191

192
        self.timerange = TimeRange.parse_timerange(
1✔
193
            None if self.config.get("timerange") is None else str(self.config.get("timerange"))
194
        )
195

196
        # Get maximum required startup period
197
        self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
1✔
198
        self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe)
1✔
199

200
        # Add maximum startup candle count to configuration for informative pairs support
201
        self.config["startup_candle_count"] = self.required_startup
1✔
202

203
        if self.config.get("freqai", {}).get("enabled", False):
1✔
204
            # For FreqAI, increase the required_startup to includes the training data
205
            # This value should NOT be written to startup_candle_count
206
            self.required_startup = self.dataprovider.get_required_startup(self.timeframe)
1✔
207

208
        self.trading_mode: TradingMode = self.config.get("trading_mode", TradingMode.SPOT)
1✔
209
        self.margin_mode: MarginMode = self.config.get("margin_mode", MarginMode.ISOLATED)
1✔
210
        # strategies which define "can_short=True" will fail to load in Spot mode.
211
        self._can_short = self.trading_mode != TradingMode.SPOT
1✔
212
        self._position_stacking: bool = self.config.get("position_stacking", False)
1✔
213
        self.enable_protections: bool = self.config.get("enable_protections", False)
1✔
214
        migrate_data(config, self.exchange)
1✔
215

216
        self.init_backtest()
1✔
217

218
    def _validate_pairlists_for_backtesting(self):
1✔
219
        if "VolumePairList" in self.pairlists.name_list:
1✔
220
            raise OperationalException(
1✔
221
                "VolumePairList not allowed for backtesting. Please use StaticPairList instead."
222
            )
223

224
        if len(self.strategylist) > 1 and "PrecisionFilter" in self.pairlists.name_list:
1✔
225
            raise OperationalException(
1✔
226
                "PrecisionFilter not allowed for backtesting multiple strategies."
227
            )
228

229
    def log_once(self, msg: str) -> None:
1✔
230
        """
231
        Partial reimplementation of log_once from the Login mixin.
232
        only used by recursive, as __initial_backtest is false in all other cases.
233

234
        """
235
        if self.__initial_backtest:
1✔
236
            logger.info(msg)
1✔
237

238
    def set_fee(self):
1✔
239
        if self.config.get("fee", None) is not None:
1✔
240
            self.fee = self.config["fee"]
1✔
241
            self.log_once(f"Using fee {self.fee:.4%} from config.")
1✔
242
        else:
243
            fees = [
1✔
244
                self.exchange.get_fee(
245
                    symbol=self.pairlists.whitelist[0],
246
                    taker_or_maker=mt,
247
                )
248
                for mt in ("taker", "maker")
249
            ]
250
            self.fee = max(fee for fee in fees if fee is not None)
1✔
251
            self.log_once(f"Using fee {self.fee:.4%} - worst case fee from exchange (lowest tier).")
1✔
252

253
    @staticmethod
1✔
254
    def cleanup():
1✔
255
        LoggingMixin.show_output = True
1✔
256
        enable_database_use()
1✔
257

258
    def init_backtest_detail(self) -> None:
1✔
259
        # Load detail timeframe if specified
260
        self.timeframe_detail = str(self.config.get("timeframe_detail", ""))
1✔
261
        if self.timeframe_detail:
1✔
262
            timeframe_detail_secs = timeframe_to_seconds(self.timeframe_detail)
1✔
263
            self.timeframe_detail_td = timedelta(seconds=timeframe_detail_secs)
1✔
264
            if self.timeframe_secs <= timeframe_detail_secs:
1✔
265
                raise OperationalException(
1✔
266
                    "Detail timeframe must be smaller than strategy timeframe."
267
                )
268

269
        else:
270
            self.timeframe_detail_td = timedelta(seconds=0)
1✔
271
        self.detail_data: dict[str, DataFrame] = {}
1✔
272
        self.futures_data: dict[str, DataFrame] = {}
1✔
273

274
    def init_backtest(self):
1✔
275
        self.prepare_backtest(False)
1✔
276

277
        self.wallets = Wallets(self.config, self.exchange, is_backtest=True)
1✔
278

279
        self.progress = BTProgress()
1✔
280
        self.abort = False
1✔
281

282
    def _set_strategy(self, strategy: IStrategy):
1✔
283
        """
284
        Load strategy into backtesting
285
        """
286
        self.strategy: IStrategy = strategy
1✔
287
        strategy.dp = self.dataprovider
1✔
288
        # Attach Wallets to Strategy baseclass
289
        strategy.wallets = self.wallets
1✔
290
        # Set stoploss_on_exchange to false for backtesting,
291
        # since a "perfect" stoploss-exit is assumed anyway
292
        # And the regular "stoploss" function would not apply to that case
293
        self.strategy.order_types["stoploss_on_exchange"] = False
1✔
294
        # Update can_short flag
295
        self._can_short = self.trading_mode != TradingMode.SPOT and strategy.can_short
1✔
296

297
        self.strategy.ft_bot_start()
1✔
298

299
    def _load_protections(self, strategy: IStrategy):
1✔
300
        if self.config.get("enable_protections", False):
1✔
301
            self.protections = ProtectionManager(self.config, strategy.protections)
1✔
302

303
    def load_bt_data(self) -> tuple[dict[str, DataFrame], TimeRange]:
1✔
304
        """
305
        Loads backtest data and returns the data combined with the timerange
306
        as tuple.
307
        """
308
        self.progress.init_step(BacktestState.DATALOAD, 1)
1✔
309

310
        data = history.load_data(
1✔
311
            datadir=self.config["datadir"],
312
            pairs=self.pairlists.whitelist,
313
            timeframe=self.timeframe,
314
            timerange=self.timerange,
315
            startup_candles=self.required_startup,
316
            fail_without_data=True,
317
            data_format=self.config["dataformat_ohlcv"],
318
            candle_type=self.config.get("candle_type_def", CandleType.SPOT),
319
        )
320

321
        min_date, max_date = history.get_timerange(data)
1✔
322

323
        logger.info(
1✔
324
            f"Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} "
325
            f"up to {max_date.strftime(DATETIME_PRINT_FORMAT)} "
326
            f"({(max_date - min_date).days} days)."
327
        )
328

329
        # Adjust startts forward if not enough data is available
330
        self.timerange.adjust_start_if_necessary(
1✔
331
            timeframe_to_seconds(self.timeframe), self.required_startup, min_date
332
        )
333

334
        self.progress.set_new_value(1)
1✔
335
        self._load_bt_data_detail()
1✔
336
        self.price_pair_prec = {}
1✔
337
        for pair in self.pairlists.whitelist:
1✔
338
            if pair in data:
1✔
339
                # Load price precision logic
340
                self.price_pair_prec[pair] = get_tick_size_over_time(data[pair])
1✔
341
        return data, self.timerange
1✔
342

343
    def _load_bt_data_detail(self) -> None:
1✔
344
        """
345
        Loads backtest detail data (smaller timeframe) if necessary.
346
        """
347
        if self.timeframe_detail:
1✔
348
            self.detail_data = history.load_data(
1✔
349
                datadir=self.config["datadir"],
350
                pairs=self.pairlists.whitelist,
351
                timeframe=self.timeframe_detail,
352
                timerange=self.timerange,
353
                startup_candles=0,
354
                fail_without_data=True,
355
                data_format=self.config["dataformat_ohlcv"],
356
                candle_type=self.config.get("candle_type_def", CandleType.SPOT),
357
            )
358
        else:
359
            self.detail_data = {}
1✔
360
        if self.trading_mode == TradingMode.FUTURES:
1✔
361
            funding_fee_timeframe: str = self.exchange.get_option("funding_fee_timeframe")
1✔
362
            self.funding_fee_timeframe_secs: int = timeframe_to_seconds(funding_fee_timeframe)
1✔
363
            mark_timeframe: str = self.exchange.get_option("mark_ohlcv_timeframe")
1✔
364

365
            # Load additional futures data.
366
            funding_rates_dict = history.load_data(
1✔
367
                datadir=self.config["datadir"],
368
                pairs=self.pairlists.whitelist,
369
                timeframe=funding_fee_timeframe,
370
                timerange=self.timerange,
371
                startup_candles=0,
372
                fail_without_data=True,
373
                data_format=self.config["dataformat_ohlcv"],
374
                candle_type=CandleType.FUNDING_RATE,
375
            )
376

377
            # For simplicity, assign to CandleType.Mark (might contain index candles!)
378
            mark_rates_dict = history.load_data(
1✔
379
                datadir=self.config["datadir"],
380
                pairs=self.pairlists.whitelist,
381
                timeframe=mark_timeframe,
382
                timerange=self.timerange,
383
                startup_candles=0,
384
                fail_without_data=True,
385
                data_format=self.config["dataformat_ohlcv"],
386
                candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price")),
387
            )
388
            # Combine data to avoid combining the data per trade.
389
            unavailable_pairs = []
1✔
390
            uses_leverage_tiers = self.exchange.get_option("uses_leverage_tiers", True)
1✔
391
            for pair in self.pairlists.whitelist:
1✔
392
                if uses_leverage_tiers and pair not in self.exchange._leverage_tiers:
1✔
393
                    unavailable_pairs.append(pair)
1✔
394
                    continue
1✔
395

396
                self.futures_data[pair] = self.exchange.combine_funding_and_mark(
1✔
397
                    funding_rates=funding_rates_dict[pair],
398
                    mark_rates=mark_rates_dict[pair],
399
                    futures_funding_rate=self.config.get("futures_funding_rate", None),
400
                )
401

402
            if unavailable_pairs:
1✔
403
                raise OperationalException(
1✔
404
                    f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. "
405
                    "It is therefore impossible to backtest with this pair at the moment."
406
                )
407
        else:
408
            self.futures_data = {}
1✔
409

410
    def get_pair_precision(self, pair: str, current_time: datetime) -> tuple[float | None, int]:
1✔
411
        """
412
        Get pair precision at that moment in time
413
        :param pair: Pair to get precision for
414
        :param current_time: Time to get precision for
415
        :return: tuple of price precision, precision_mode_price for the pair at that given time.
416
        """
417
        precision_series = self.price_pair_prec.get(pair)
1✔
418
        if precision_series is not None:
1✔
419
            precision = precision_series.asof(current_time)
1✔
420

421
            if not isnan(precision):
1✔
422
                # Force tick size if we define the precision
423
                return precision, TICK_SIZE
1✔
424
        return self.exchange.get_precision_price(pair), self.precision_mode_price
1✔
425

426
    def disable_database_use(self):
1✔
427
        disable_database_use(self.timeframe)
1✔
428

429
    def prepare_backtest(self, enable_protections):
1✔
430
        """
431
        Backtesting setup method - called once for every call to "backtest()".
432
        """
433
        self.disable_database_use()
1✔
434
        PairLocks.reset_locks()
1✔
435
        Trade.reset_trades()
1✔
436
        CustomDataWrapper.reset_custom_data()
1✔
437
        self.rejected_trades = 0
1✔
438
        self.timedout_entry_orders = 0
1✔
439
        self.timedout_exit_orders = 0
1✔
440
        self.canceled_trade_entries = 0
1✔
441
        self.canceled_entry_orders = 0
1✔
442
        self.replaced_entry_orders = 0
1✔
443
        self.canceled_exit_orders = 0
1✔
444
        self.replaced_exit_orders = 0
1✔
445
        self.dataprovider.clear_cache()
1✔
446
        if enable_protections:
1✔
447
            self._load_protections(self.strategy)
1✔
448

449
    def check_abort(self):
1✔
450
        """
451
        Check if abort was requested, raise DependencyException if that's the case
452
        Only applies to Interactive backtest mode (webserver mode)
453
        """
454
        if self.abort:
1✔
455
            self.abort = False
1✔
456
            raise DependencyException("Stop requested")
1✔
457

458
    def _get_ohlcv_as_lists(self, processed: dict[str, DataFrame]) -> dict[str, tuple]:
1✔
459
        """
460
        Helper function to convert a processed dataframes into lists for performance reasons.
461

462
        Used by backtest() - so keep this optimized for performance.
463

464
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
465
        optimize memory usage!
466
        """
467

468
        data: dict = {}
1✔
469
        self.progress.init_step(BacktestState.CONVERT, len(processed))
1✔
470

471
        # Create dict with data
472
        for pair in processed.keys():
1✔
473
            pair_data = processed[pair]
1✔
474
            self.check_abort()
1✔
475
            self.progress.increment()
1✔
476

477
            if not pair_data.empty:
1✔
478
                # Cleanup from prior runs
479
                pair_data.drop(HEADERS[5:] + ["buy", "sell"], axis=1, errors="ignore")
1✔
480
            df_analyzed = self.strategy.ft_advise_signals(pair_data, {"pair": pair})
1✔
481
            # Update dataprovider cache
482
            self.dataprovider._set_cached_df(
1✔
483
                pair, self.timeframe, df_analyzed, self.config["candle_type_def"]
484
            )
485

486
            # Trim startup period from analyzed dataframe
487
            df_analyzed = processed[pair] = pair_data = trim_dataframe(
1✔
488
                df_analyzed, self.timerange, startup_candles=self.required_startup
489
            )
490

491
            # Create a copy of the dataframe before shifting, that way the entry signal/tag
492
            # remains on the correct candle for callbacks.
493
            df_analyzed = df_analyzed.copy()
1✔
494

495
            # To avoid using data from future, we use entry/exit signals shifted
496
            # from the previous candle
497
            for col in HEADERS[5:]:
1✔
498
                tag_col = col in ("enter_tag", "exit_tag")
1✔
499
                if col in df_analyzed.columns:
1✔
500
                    df_analyzed[col] = (
1✔
501
                        df_analyzed.loc[:, col]
502
                        .replace([nan], [0 if not tag_col else None])
503
                        .shift(1)
504
                    )
505
                elif not df_analyzed.empty:
1✔
506
                    df_analyzed[col] = 0 if not tag_col else None
1✔
507

508
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
509

510
            # Convert from Pandas to list for performance reasons
511
            # (Looping Pandas is slow.)
512
            data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else []
1✔
513
        return data
1✔
514

515
    def _get_close_rate(
1✔
516
        self,
517
        row: tuple,
518
        trade: LocalTrade,
519
        current_time: datetime,
520
        exit_: ExitCheckTuple,
521
        trade_dur: int,
522
    ) -> float:
523
        """
524
        Get close rate for backtesting result
525
        """
526
        # Special handling if high or low hit STOP_LOSS or ROI
527
        if exit_.exit_type in (
1✔
528
            ExitType.STOP_LOSS,
529
            ExitType.TRAILING_STOP_LOSS,
530
            ExitType.LIQUIDATION,
531
        ):
532
            return self._get_close_rate_for_stoploss(row, trade, exit_, trade_dur)
1✔
533
        elif exit_.exit_type == (ExitType.ROI):
1✔
534
            return self._get_close_rate_for_roi(row, trade, current_time, exit_, trade_dur)
1✔
535
        else:
536
            return row[OPEN_IDX]
1✔
537

538
    def _get_close_rate_for_stoploss(
1✔
539
        self, row: tuple, trade: LocalTrade, exit_: ExitCheckTuple, trade_dur: int
540
    ) -> float:
541
        # our stoploss was already lower than candle high,
542
        # possibly due to a cancelled trade exit.
543
        # exit at open price.
544
        is_short = trade.is_short or False
1✔
545
        leverage = trade.leverage or 1.0
1✔
546
        side_1 = -1 if is_short else 1
1✔
547
        if exit_.exit_type == ExitType.LIQUIDATION and trade.liquidation_price:
1✔
548
            stoploss_value = trade.liquidation_price
1✔
549
        else:
550
            stoploss_value = trade.stop_loss
1✔
551

552
        if is_short:
1✔
553
            if stoploss_value < row[LOW_IDX]:
1✔
554
                return row[OPEN_IDX]
1✔
555
        else:
556
            if stoploss_value > row[HIGH_IDX]:
1✔
557
                return row[OPEN_IDX]
1✔
558

559
        # Special case: trailing triggers within same candle as trade opened. Assume most
560
        # pessimistic price movement, which is moving just enough to arm stoploss and
561
        # immediately going down to stop price.
562
        if exit_.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0:
1✔
563
            if (
1✔
564
                not self.strategy.use_custom_stoploss
565
                and self.strategy.trailing_stop
566
                and self.strategy.trailing_only_offset_is_reached
567
                and self.strategy.trailing_stop_positive_offset is not None
568
                and self.strategy.trailing_stop_positive
569
            ):
570
                # Worst case: price reaches stop_positive_offset and dives down.
571
                stop_rate = row[OPEN_IDX] * (
1✔
572
                    1
573
                    + side_1 * abs(self.strategy.trailing_stop_positive_offset)
574
                    - side_1 * abs(self.strategy.trailing_stop_positive / leverage)
575
                )
576
            else:
577
                # Worst case: price ticks tiny bit above open and dives down.
578
                stop_rate = row[OPEN_IDX] * (
1✔
579
                    1 - side_1 * abs((trade.stop_loss_pct or 0.0) / leverage)
580
                )
581

582
            # Limit lower-end to candle low to avoid exits below the low.
583
            # This still remains "worst case" - but "worst realistic case".
584
            if is_short:
1✔
585
                return min(row[HIGH_IDX], stop_rate)
1✔
586
            else:
587
                return max(row[LOW_IDX], stop_rate)
1✔
588

589
        # Set close_rate to stoploss
590
        return stoploss_value
1✔
591

592
    def _get_close_rate_for_roi(
1✔
593
        self,
594
        row: tuple,
595
        trade: LocalTrade,
596
        current_time: datetime,
597
        exit_: ExitCheckTuple,
598
        trade_dur: int,
599
    ) -> float:
600
        is_short = trade.is_short or False
1✔
601
        leverage = trade.leverage or 1.0
1✔
602
        side_1 = -1 if is_short else 1
1✔
603
        roi_entry, roi = self.strategy.min_roi_reached_entry(
1✔
604
            trade,  # type: ignore[arg-type]
605
            trade_dur,
606
            current_time,
607
        )
608
        if roi is not None and roi_entry is not None:
1✔
609
            if roi == -1 and roi_entry % self.timeframe_min == 0:
1✔
610
                # When force_exiting with ROI=-1, the roi time will always be equal to trade_dur.
611
                # If that entry is a multiple of the timeframe (so on candle open)
612
                # - we'll use open instead of close
613
                return row[OPEN_IDX]
1✔
614

615
            # - (Expected abs profit - open_rate - open_fee) / (fee_close -1)
616
            roi_rate = trade.open_rate * roi / leverage
1✔
617
            open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open)
1✔
618
            close_rate = -(roi_rate + open_fee_rate) / ((trade.fee_close or 0.0) - side_1 * 1)
1✔
619
            if is_short:
1✔
620
                is_new_roi = row[OPEN_IDX] < close_rate
1✔
621
            else:
622
                is_new_roi = row[OPEN_IDX] > close_rate
1✔
623
            if (
1✔
624
                trade_dur > 0
625
                and trade_dur == roi_entry
626
                and roi_entry % self.timeframe_min == 0
627
                and is_new_roi
628
            ):
629
                # new ROI entry came into effect.
630
                # use Open rate if open_rate > calculated exit rate
631
                return row[OPEN_IDX]
1✔
632

633
            if trade_dur == 0 and (
1✔
634
                (
635
                    is_short
636
                    # Red candle (for longs)
637
                    and row[OPEN_IDX] < row[CLOSE_IDX]  # Red candle
638
                    and trade.open_rate > row[OPEN_IDX]  # trade-open above open_rate
639
                    and close_rate < row[CLOSE_IDX]  # closes below close
640
                )
641
                or (
642
                    not is_short
643
                    # green candle (for shorts)
644
                    and row[OPEN_IDX] > row[CLOSE_IDX]  # green candle
645
                    and trade.open_rate < row[OPEN_IDX]  # trade-open below open_rate
646
                    and close_rate > row[CLOSE_IDX]  # closes above close
647
                )
648
            ):
649
                # ROI on opening candles with custom pricing can only
650
                # trigger if the entry was at Open or lower wick.
651
                # details: https: // github.com/freqtrade/freqtrade/issues/6261
652
                # If open_rate is < open, only allow exits below the close on red candles.
653
                raise ValueError("Opening candle ROI on red candles.")
1✔
654

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

660
        else:
661
            # This should not be reached...
662
            return row[OPEN_IDX]
×
663

664
    def _check_adjust_trade_for_candle(
1✔
665
        self, trade: LocalTrade, row: tuple, current_time: datetime
666
    ) -> LocalTrade:
667
        current_rate: float = row[OPEN_IDX]
1✔
668
        current_profit = trade.calc_profit_ratio(current_rate)
1✔
669
        min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1)
1✔
670
        max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate)
1✔
671
        stake_available = self.wallets.get_available_stake_amount()
1✔
672
        stake_amount, order_tag = self.strategy._adjust_trade_position_internal(
1✔
673
            trade=trade,  # type: ignore[arg-type]
674
            current_time=current_time,
675
            current_rate=current_rate,
676
            current_profit=current_profit,
677
            min_stake=min_stake,
678
            max_stake=min(max_stake, stake_available),
679
            current_entry_rate=current_rate,
680
            current_exit_rate=current_rate,
681
            current_entry_profit=current_profit,
682
            current_exit_profit=current_profit,
683
        )
684

685
        # Check if we should increase our position
686
        if stake_amount is not None and stake_amount > 0.0:
1✔
687
            check_adjust_entry = True
1✔
688
            if self.strategy.max_entry_position_adjustment > -1:
1✔
689
                entry_count = trade.nr_of_successful_entries
×
690
                check_adjust_entry = entry_count <= self.strategy.max_entry_position_adjustment
×
691
            if check_adjust_entry:
1✔
692
                pos_trade = self._enter_trade(
1✔
693
                    trade.pair,
694
                    row,
695
                    "short" if trade.is_short else "long",
696
                    stake_amount,
697
                    trade,
698
                    entry_tag1=order_tag,
699
                )
700
                if pos_trade is not None:
1✔
701
                    self.wallets.update()
1✔
702
                    return pos_trade
1✔
703

704
        if stake_amount is not None and stake_amount < 0.0:
1✔
705
            amount = amount_to_contract_precision(
1✔
706
                abs(
707
                    float(
708
                        FtPrecise(stake_amount)
709
                        * FtPrecise(trade.amount)
710
                        / FtPrecise(trade.stake_amount)
711
                    )
712
                ),
713
                trade.amount_precision,
714
                self.precision_mode,
715
                trade.contract_size,
716
            )
717
            if amount == 0.0:
1✔
718
                return trade
×
719
            remaining = (trade.amount - amount) * current_rate
1✔
720
            if min_stake and remaining != 0 and remaining < min_stake:
1✔
721
                # Remaining stake is too low to be sold.
722
                return trade
1✔
723
            exit_ = ExitCheckTuple(ExitType.PARTIAL_EXIT, order_tag)
1✔
724
            pos_trade = self._get_exit_for_signal(trade, row, exit_, current_time, amount)
1✔
725
            if pos_trade is not None:
1✔
726
                order = pos_trade.orders[-1]
1✔
727
                # If the order was filled and for the full trade amount, we need to close the trade.
728
                self._process_exit_order(order, pos_trade, current_time, row, trade.pair)
1✔
729
                return pos_trade
1✔
730

731
        return trade
1✔
732

733
    def _get_order_filled(self, rate: float, row: tuple) -> bool:
1✔
734
        """Rate is within candle, therefore filled"""
735
        return row[LOW_IDX] <= rate <= row[HIGH_IDX]
1✔
736

737
    def _call_adjust_stop(self, current_date: datetime, trade: LocalTrade, current_rate: float):
1✔
738
        profit = trade.calc_profit_ratio(current_rate)
1✔
739
        self.strategy.ft_stoploss_adjust(
1✔
740
            current_rate,
741
            trade,  # type: ignore
742
            current_date,
743
            profit,
744
            0,
745
            after_fill=True,
746
        )
747

748
    def _try_close_open_order(
1✔
749
        self, order: Order | None, trade: LocalTrade, current_date: datetime, row: tuple
750
    ) -> bool:
751
        """
752
        Check if an order is open and if it should've filled.
753
        :return:  True if the order filled.
754
        """
755
        if order and self._get_order_filled(order.ft_price, row):
1✔
756
            order.close_bt_order(current_date, trade)
1✔
757
            self._run_funding_fees(trade, current_date, force=True)
1✔
758
            strategy_safe_wrapper(self.strategy.order_filled, supress_error=True)(
1✔
759
                pair=trade.pair,
760
                trade=trade,  # type: ignore[arg-type]
761
                order=order,
762
                current_time=current_date,
763
            )
764

765
            if self.margin_mode == MarginMode.CROSS or not (
1✔
766
                order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount
767
            ):
768
                # trade is still open or we are in cross margin mode and
769
                # must update all liquidation prices
770
                update_liquidation_prices(
1✔
771
                    trade,
772
                    exchange=self.exchange,
773
                    wallets=self.wallets,
774
                    stake_currency=self.config["stake_currency"],
775
                    dry_run=True,
776
                )
777
            if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount):
1✔
778
                self._call_adjust_stop(current_date, trade, order.ft_price)
1✔
779
            return True
1✔
780
        return False
1✔
781

782
    def _process_exit_order(
1✔
783
        self, order: Order, trade: LocalTrade, current_time: datetime, row: tuple, pair: str
784
    ):
785
        """
786
        Takes an exit order and processes it, potentially closing the trade.
787
        """
788
        if self._try_close_open_order(order, trade, current_time, row):
1✔
789
            sub_trade = order.safe_amount_after_fee != trade.amount
1✔
790
            if sub_trade:
1✔
791
                trade.recalc_trade_from_orders()
1✔
792
            else:
793
                trade.close_date = current_time
1✔
794
                trade.close(order.ft_price, show_msg=False)
1✔
795

796
                LocalTrade.close_bt_trade(trade)
1✔
797
            self.wallets.update()
1✔
798
            self.run_protections(pair, current_time, trade.trade_direction)
1✔
799

800
    def _get_exit_for_signal(
1✔
801
        self,
802
        trade: LocalTrade,
803
        row: tuple,
804
        exit_: ExitCheckTuple,
805
        current_time: datetime,
806
        amount: float | None = None,
807
    ) -> LocalTrade | None:
808
        if exit_.exit_flag:
1✔
809
            trade.close_date = current_time
1✔
810
            exit_reason = exit_.exit_reason
1✔
811
            amount_ = amount if amount is not None else trade.amount
1✔
812
            trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
1✔
813
            try:
1✔
814
                close_rate = self._get_close_rate(row, trade, current_time, exit_, trade_dur)
1✔
815
            except ValueError:
1✔
816
                return None
1✔
817
            # call the custom exit price,with default value as previous close_rate
818
            current_profit = trade.calc_profit_ratio(close_rate)
1✔
819
            order_type = self.strategy.order_types["exit"]
1✔
820
            if exit_.exit_type in (
1✔
821
                ExitType.EXIT_SIGNAL,
822
                ExitType.CUSTOM_EXIT,
823
                ExitType.PARTIAL_EXIT,
824
            ):
825
                # Checks and adds an exit tag, after checking that the length of the
826
                # row has the length for an exit tag column
827
                if (
1✔
828
                    len(row) > EXIT_TAG_IDX
829
                    and row[EXIT_TAG_IDX] is not None
830
                    and len(row[EXIT_TAG_IDX]) > 0
831
                    and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
832
                ):
833
                    exit_reason = row[EXIT_TAG_IDX]
1✔
834
                # Custom exit pricing only for exit-signals
835
                if order_type == "limit":
1✔
836
                    rate = strategy_safe_wrapper(
1✔
837
                        self.strategy.custom_exit_price, default_retval=close_rate
838
                    )(
839
                        pair=trade.pair,
840
                        trade=trade,  # type: ignore[arg-type]
841
                        current_time=current_time,
842
                        proposed_rate=close_rate,
843
                        current_profit=current_profit,
844
                        exit_tag=exit_reason,
845
                    )
846
                    if rate is not None and rate != close_rate:
1✔
847
                        close_rate = price_to_precision(
1✔
848
                            rate, trade.price_precision, trade.precision_mode_price
849
                        )
850
                    # We can't place orders lower than current low.
851
                    # freqtrade does not support this in live, and the order would fill immediately
852
                    if trade.is_short:
1✔
853
                        close_rate = min(close_rate, row[HIGH_IDX])
1✔
854
                    else:
855
                        close_rate = max(close_rate, row[LOW_IDX])
1✔
856
            # Confirm trade exit:
857
            time_in_force = self.strategy.order_time_in_force["exit"]
1✔
858

859
            if exit_.exit_type not in (
1✔
860
                ExitType.LIQUIDATION,
861
                ExitType.PARTIAL_EXIT,
862
            ) and not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
863
                pair=trade.pair,
864
                trade=trade,  # type: ignore[arg-type]
865
                order_type=order_type,
866
                amount=amount_,
867
                rate=close_rate,
868
                time_in_force=time_in_force,
869
                sell_reason=exit_reason,  # deprecated
870
                exit_reason=exit_reason,
871
                current_time=current_time,
872
            ):
873
                return None
×
874

875
            trade.exit_reason = exit_reason
1✔
876

877
            return self._exit_trade(trade, row, close_rate, amount_, exit_reason)
1✔
878
        return None
×
879

880
    def _exit_trade(
1✔
881
        self,
882
        trade: LocalTrade,
883
        sell_row: tuple,
884
        close_rate: float,
885
        amount: float,
886
        exit_reason: str | None,
887
    ) -> LocalTrade | None:
888
        self.order_id_counter += 1
1✔
889
        exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
1✔
890
        order_type = self.strategy.order_types["exit"]
1✔
891
        # amount = amount or trade.amount
892
        amount = amount_to_contract_precision(
1✔
893
            amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size
894
        )
895

896
        if self.handle_similar_order(trade, close_rate, amount, trade.exit_side, exit_candle_time):
1✔
897
            return None
×
898

899
        order = Order(
1✔
900
            id=self.order_id_counter,
901
            ft_trade_id=trade.id,
902
            order_date=exit_candle_time,
903
            order_update_date=exit_candle_time,
904
            ft_is_open=True,
905
            ft_pair=trade.pair,
906
            order_id=str(self.order_id_counter),
907
            symbol=trade.pair,
908
            ft_order_side=trade.exit_side,
909
            side=trade.exit_side,
910
            order_type=order_type,
911
            status="open",
912
            ft_price=close_rate,
913
            price=close_rate,
914
            average=close_rate,
915
            amount=amount,
916
            filled=0,
917
            remaining=amount,
918
            cost=amount * close_rate * (1 + self.fee),
919
            ft_order_tag=exit_reason,
920
        )
921
        order._trade_bt = trade
1✔
922
        trade.orders.append(order)
1✔
923
        return trade
1✔
924

925
    def _check_trade_exit(
1✔
926
        self, trade: LocalTrade, row: tuple, current_time: datetime
927
    ) -> LocalTrade | None:
928
        self._run_funding_fees(trade, current_time)
1✔
929

930
        # Check if we need to adjust our current positions
931
        if self.strategy.position_adjustment_enable:
1✔
932
            trade = self._check_adjust_trade_for_candle(trade, row, current_time)
1✔
933

934
        if trade.is_open:
1✔
935
            enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
936
            exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
937
            exits = self.strategy.should_exit(
1✔
938
                trade,  # type: ignore
939
                row[OPEN_IDX],
940
                row[DATE_IDX].to_pydatetime(),
941
                enter=enter,
942
                exit_=exit_sig,
943
                low=row[LOW_IDX],
944
                high=row[HIGH_IDX],
945
            )
946
            for exit_ in exits:
1✔
947
                t = self._get_exit_for_signal(trade, row, exit_, current_time)
1✔
948
                if t:
1✔
949
                    return t
1✔
950
        return None
1✔
951

952
    def _run_funding_fees(self, trade: LocalTrade, current_time: datetime, force: bool = False):
1✔
953
        """
954
        Calculate funding fees if necessary and add them to the trade.
955
        """
956
        if self.trading_mode == TradingMode.FUTURES:
1✔
957
            if force or (current_time.timestamp() % self.funding_fee_timeframe_secs) == 0:
1✔
958
                # Funding fee interval.
959
                trade.set_funding_fees(
1✔
960
                    self.exchange.calculate_funding_fees(
961
                        self.futures_data[trade.pair],
962
                        amount=trade.amount,
963
                        is_short=trade.is_short,
964
                        open_date=trade.date_last_filled_utc,
965
                        close_date=current_time,
966
                    )
967
                )
968

969
    def get_valid_entry_price_and_stake(
1✔
970
        self,
971
        pair: str,
972
        row: tuple,
973
        propose_rate: float,
974
        stake_amount: float,
975
        direction: LongShort,
976
        current_time: datetime,
977
        entry_tag: str | None,
978
        trade: LocalTrade | None,
979
        order_type: str,
980
        price_precision: float | None,
981
        precision_mode_price: int,
982
    ) -> tuple[float, float, float, float]:
983
        if order_type == "limit":
1✔
984
            new_rate = strategy_safe_wrapper(
1✔
985
                self.strategy.custom_entry_price, default_retval=propose_rate
986
            )(
987
                pair=pair,
988
                trade=trade,  # type: ignore[arg-type]
989
                current_time=current_time,
990
                proposed_rate=propose_rate,
991
                entry_tag=entry_tag,
992
                side=direction,
993
            )  # default value is the open rate
994
            # We can't place orders higher than current high (otherwise it'd be a stop limit entry)
995
            # which freqtrade does not support in live.
996
            if new_rate is not None and new_rate != propose_rate:
1✔
997
                propose_rate = price_to_precision(new_rate, price_precision, precision_mode_price)
1✔
998
            if direction == "short":
1✔
999
                propose_rate = max(propose_rate, row[LOW_IDX])
1✔
1000
            else:
1001
                propose_rate = min(propose_rate, row[HIGH_IDX])
1✔
1002

1003
        pos_adjust = trade is not None
1✔
1004
        leverage = trade.leverage if trade else 1.0
1✔
1005
        if not pos_adjust:
1✔
1006
            try:
1✔
1007
                stake_amount = self.wallets.get_trade_stake_amount(
1✔
1008
                    pair, self.strategy.max_open_trades, update=False
1009
                )
1010
            except DependencyException:
1✔
1011
                return 0, 0, 0, 0
1✔
1012

1013
            max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
1✔
1014
            leverage = (
1✔
1015
                strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
1016
                    pair=pair,
1017
                    current_time=current_time,
1018
                    current_rate=row[OPEN_IDX],
1019
                    proposed_leverage=1.0,
1020
                    max_leverage=max_leverage,
1021
                    side=direction,
1022
                    entry_tag=entry_tag,
1023
                )
1024
                if self.trading_mode != TradingMode.SPOT
1025
                else 1.0
1026
            )
1027
            # Cap leverage between 1.0 and max_leverage.
1028
            leverage = min(max(leverage, 1.0), max_leverage)
1✔
1029

1030
        min_stake_amount = (
1✔
1031
            self.exchange.get_min_pair_stake_amount(
1032
                pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage
1033
            )
1034
            or 0
1035
        )
1036
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
1037
            pair, propose_rate, leverage=leverage
1038
        )
1039
        stake_available = self.wallets.get_available_stake_amount()
1✔
1040

1041
        if not pos_adjust:
1✔
1042
            stake_amount = strategy_safe_wrapper(
1✔
1043
                self.strategy.custom_stake_amount, default_retval=stake_amount
1044
            )(
1045
                pair=pair,
1046
                current_time=current_time,
1047
                current_rate=propose_rate,
1048
                proposed_stake=stake_amount,
1049
                min_stake=min_stake_amount,
1050
                max_stake=min(stake_available, max_stake_amount),
1051
                leverage=leverage,
1052
                entry_tag=entry_tag,
1053
                side=direction,
1054
            )
1055

1056
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
1057
            pair=pair,
1058
            stake_amount=stake_amount,
1059
            min_stake_amount=min_stake_amount,
1060
            max_stake_amount=max_stake_amount,
1061
            trade_amount=trade.stake_amount if trade else None,
1062
        )
1063

1064
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
1065

1066
    def _enter_trade(
1✔
1067
        self,
1068
        pair: str,
1069
        row: tuple,
1070
        direction: LongShort,
1071
        stake_amount: float | None = None,
1072
        trade: LocalTrade | None = None,
1073
        requested_rate: float | None = None,
1074
        requested_stake: float | None = None,
1075
        entry_tag1: str | None = None,
1076
    ) -> LocalTrade | None:
1077
        """
1078
        :param trade: Trade to adjust - initial entry if None
1079
        :param requested_rate: Adjusted entry rate
1080
        :param requested_stake: Stake amount for adjusted orders (`adjust_entry_price`).
1081
        """
1082

1083
        current_time = row[DATE_IDX].to_pydatetime()
1✔
1084
        entry_tag = entry_tag1 or (row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None)
1✔
1085
        # let's call the custom entry price, using the open price as default price
1086
        order_type = self.strategy.order_types["entry"]
1✔
1087
        pos_adjust = trade is not None and requested_rate is None
1✔
1088

1089
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
1090
        precision_price, precision_mode_price = self.get_pair_precision(pair, current_time)
1✔
1091

1092
        propose_rate, stake_amount, leverage, min_stake_amount = (
1✔
1093
            self.get_valid_entry_price_and_stake(
1094
                pair,
1095
                row,
1096
                row[OPEN_IDX],
1097
                stake_amount_,
1098
                direction,
1099
                current_time,
1100
                entry_tag,
1101
                trade,
1102
                order_type,
1103
                precision_price,
1104
                precision_mode_price,
1105
            )
1106
        )
1107

1108
        # replace proposed rate if another rate was requested
1109
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
1110
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
1111

1112
        if not stake_amount:
1✔
1113
            # In case of pos adjust, still return the original trade
1114
            # If not pos adjust, trade is None
1115
            return trade
1✔
1116
        time_in_force = self.strategy.order_time_in_force["entry"]
1✔
1117

1118
        if stake_amount and (not min_stake_amount or stake_amount >= min_stake_amount):
1✔
1119
            self.order_id_counter += 1
1✔
1120
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
1121
            amount_p = (stake_amount / propose_rate) * leverage
1✔
1122

1123
            contract_size = self.exchange.get_contract_size(pair)
1✔
1124
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
1125
            amount = amount_to_contract_precision(
1✔
1126
                amount_p, precision_amount, self.precision_mode, contract_size
1127
            )
1128
            if not amount:
1✔
1129
                # No amount left after truncating to precision.
UNCOV
1130
                return trade
×
1131
            # Backcalculate actual stake amount.
1132
            stake_amount = amount * propose_rate / leverage
1✔
1133

1134
            if not pos_adjust:
1✔
1135
                # Confirm trade entry:
1136
                if not strategy_safe_wrapper(
1✔
1137
                    self.strategy.confirm_trade_entry, default_retval=True
1138
                )(
1139
                    pair=pair,
1140
                    order_type=order_type,
1141
                    amount=amount,
1142
                    rate=propose_rate,
1143
                    time_in_force=time_in_force,
1144
                    current_time=current_time,
1145
                    entry_tag=entry_tag,
1146
                    side=direction,
1147
                ):
1148
                    return trade
1✔
1149

1150
            is_short = direction == "short"
1✔
1151
            # Necessary for Margin trading. Disabled until support is enabled.
1152
            # interest_rate = self.exchange.get_interest_rate()
1153

1154
            if trade is None:
1✔
1155
                # Enter trade
1156
                self.trade_id_counter += 1
1✔
1157
                trade = LocalTrade(
1✔
1158
                    id=self.trade_id_counter,
1159
                    pair=pair,
1160
                    base_currency=base_currency,
1161
                    stake_currency=self.config["stake_currency"],
1162
                    open_rate=propose_rate,
1163
                    open_rate_requested=propose_rate,
1164
                    open_date=current_time,
1165
                    stake_amount=stake_amount,
1166
                    amount=0,
1167
                    amount_requested=amount,
1168
                    fee_open=self.fee,
1169
                    fee_close=self.fee,
1170
                    is_open=True,
1171
                    enter_tag=entry_tag,
1172
                    timeframe=self.timeframe_min,
1173
                    exchange=self._exchange_name,
1174
                    is_short=is_short,
1175
                    trading_mode=self.trading_mode,
1176
                    leverage=leverage,
1177
                    # interest_rate=interest_rate,
1178
                    amount_precision=precision_amount,
1179
                    price_precision=precision_price,
1180
                    precision_mode=self.precision_mode,
1181
                    precision_mode_price=precision_mode_price,
1182
                    contract_size=contract_size,
1183
                    orders=[],
1184
                )
1185
                LocalTrade.add_bt_trade(trade)
1✔
1186
            elif self.handle_similar_order(
1✔
1187
                trade, propose_rate, amount, trade.entry_side, current_time
1188
            ):
UNCOV
1189
                return None
×
1190

1191
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
1192

1193
            order = Order(
1✔
1194
                id=self.order_id_counter,
1195
                ft_trade_id=trade.id,
1196
                ft_is_open=True,
1197
                ft_pair=trade.pair,
1198
                order_id=str(self.order_id_counter),
1199
                symbol=trade.pair,
1200
                ft_order_side=trade.entry_side,
1201
                side=trade.entry_side,
1202
                order_type=order_type,
1203
                status="open",
1204
                order_date=current_time,
1205
                order_filled_date=current_time,
1206
                order_update_date=current_time,
1207
                ft_price=propose_rate,
1208
                price=propose_rate,
1209
                average=propose_rate,
1210
                amount=amount,
1211
                filled=0,
1212
                remaining=amount,
1213
                cost=amount * propose_rate * (1 + self.fee),
1214
                ft_order_tag=entry_tag,
1215
            )
1216
            order._trade_bt = trade
1✔
1217
            trade.orders.append(order)
1✔
1218
            self._try_close_open_order(order, trade, current_time, row)
1✔
1219
            trade.recalc_trade_from_orders()
1✔
1220

1221
        return trade
1✔
1222

1223
    def handle_left_open(
1✔
1224
        self, open_trades: dict[str, list[LocalTrade]], data: dict[str, list[tuple]]
1225
    ) -> None:
1226
        """
1227
        Handling of left open trades at the end of backtesting
1228
        """
1229
        for pair in open_trades.keys():
1✔
1230
            for trade in list(open_trades[pair]):
1✔
1231
                if (
1✔
1232
                    trade.has_open_orders and trade.nr_of_successful_entries == 0
1233
                ) or not trade.has_open_position:
1234
                    # Ignore trade if entry-order did not fill yet
1235
                    LocalTrade.remove_bt_trade(trade)
1✔
1236
                    continue
1✔
1237

1238
                exit_row = data[pair][-1]
1✔
1239
                self._exit_trade(
1✔
1240
                    trade, exit_row, exit_row[OPEN_IDX], trade.amount, ExitType.FORCE_EXIT.value
1241
                )
1242
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
1243
                self._process_exit_order(
1✔
1244
                    trade.orders[-1], trade, exit_row[DATE_IDX].to_pydatetime(), exit_row, pair
1245
                )
1246

1247
    def trade_slot_available(self, open_trade_count: int) -> bool:
1✔
1248
        # Always allow trades when max_open_trades is enabled.
1249
        max_open_trades: IntOrInf = self.strategy.max_open_trades
1✔
1250
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
1251
            return True
1✔
1252
        # Rejected trade
1253
        self.rejected_trades += 1
1✔
1254
        return False
1✔
1255

1256
    def check_for_trade_entry(self, row) -> LongShort | None:
1✔
1257
        enter_long = row[LONG_IDX] == 1
1✔
1258
        exit_long = row[ELONG_IDX] == 1
1✔
1259
        enter_short = self._can_short and row[SHORT_IDX] == 1
1✔
1260
        exit_short = self._can_short and row[ESHORT_IDX] == 1
1✔
1261

1262
        if enter_long == 1 and not any([exit_long, enter_short]):
1✔
1263
            # Long
1264
            return "long"
1✔
1265
        if enter_short == 1 and not any([exit_short, enter_long]):
1✔
1266
            # Short
1267
            return "short"
1✔
1268
        return None
1✔
1269

1270
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
1271
        if self.enable_protections:
1✔
1272
            self.protections.stop_per_pair(pair, current_time, side)
1✔
1273
            self.protections.global_stop(current_time, side)
1✔
1274

1275
    def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tuple) -> bool:
1✔
1276
        """
1277
        Check if any open order needs to be cancelled or replaced.
1278
        Returns True if the trade should be deleted.
1279
        """
1280
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
1281
            oc = self.check_order_cancel(trade, order, current_time)
1✔
1282
            if oc:
1✔
1283
                # delete trade due to order timeout
1284
                return True
1✔
1285
            elif oc is None and self.check_order_replace(trade, order, current_time, row):
1✔
1286
                # delete trade due to user request
UNCOV
1287
                self.canceled_trade_entries += 1
×
UNCOV
1288
                return True
×
1289
        # default maintain trade
1290
        return False
1✔
1291

1292
    def cancel_open_orders(self, trade: LocalTrade, current_time: datetime):
1✔
1293
        """
1294
        Cancel all open orders for the given trade.
1295
        """
1296
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
1297
            if order.side == trade.entry_side:
1✔
1298
                self.canceled_entry_orders += 1
1✔
1299
            elif order.side == trade.exit_side:
1✔
1300
                self.canceled_exit_orders += 1
1✔
1301
            # canceled orders are removed from the trade
1302
            del trade.orders[trade.orders.index(order)]
1✔
1303

1304
    def handle_similar_order(
1✔
1305
        self, trade: LocalTrade, price: float, amount: float, side: str, current_time: datetime
1306
    ) -> bool:
1307
        """
1308
        Handle similar order for the given trade.
1309
        """
1310
        if trade.has_open_orders:
1✔
1311
            oo = trade.select_order(side, True)
1✔
1312
            if oo:
1✔
1313
                if (price == oo.price) and (side == oo.side) and (amount == oo.amount):
1✔
1314
                    # logger.info(
1315
                    #     f"A similar open order was found for {trade.pair}. "
1316
                    #     f"Keeping existing {trade.exit_side} order. {price=},  {amount=}"
1317
                    # )
UNCOV
1318
                    return True
×
1319
            self.cancel_open_orders(trade, current_time)
1✔
1320

1321
        return False
1✔
1322

1323
    def check_order_cancel(
1✔
1324
        self, trade: LocalTrade, order: Order, current_time: datetime
1325
    ) -> bool | None:
1326
        """
1327
        Check if current analyzed order has to be canceled.
1328
        Returns True if the trade should be Deleted (initial order was canceled),
1329
                False if it's Canceled
1330
                None if the order is still active.
1331
        """
1332
        timedout = self.strategy.ft_check_timed_out(
1✔
1333
            trade,  # type: ignore[arg-type]
1334
            order,
1335
            current_time,
1336
        )
1337
        if timedout:
1✔
1338
            if order.side == trade.entry_side:
1✔
1339
                self.timedout_entry_orders += 1
1✔
1340
                if trade.nr_of_successful_entries == 0:
1✔
1341
                    # Remove trade due to entry timeout expiration.
1342
                    return True
1✔
1343
                else:
1344
                    # Close additional entry order
UNCOV
1345
                    del trade.orders[trade.orders.index(order)]
×
UNCOV
1346
                    return False
×
1347
            if order.side == trade.exit_side:
1✔
1348
                self.timedout_exit_orders += 1
1✔
1349
                # Close exit order and retry exiting on next signal.
1350
                del trade.orders[trade.orders.index(order)]
1✔
1351
                return False
1✔
1352
        return None
1✔
1353

1354
    def check_order_replace(
1✔
1355
        self, trade: LocalTrade, order: Order, current_time, row: tuple
1356
    ) -> bool:
1357
        """
1358
        Check if current analyzed entry order has to be replaced and do so.
1359
        If user requested cancellation and there are no filled orders in the trade will
1360
        instruct caller to delete the trade.
1361
        Returns True if the trade should be deleted.
1362
        """
1363
        # only check on new candles for open entry orders
1364
        if current_time > order.order_date_utc:
1✔
1365
            is_entry = order.side == trade.entry_side
1✔
1366
            requested_rate = strategy_safe_wrapper(
1✔
1367
                self.strategy.adjust_order_price, default_retval=order.ft_price
1368
            )(
1369
                trade=trade,  # type: ignore[arg-type]
1370
                order=order,
1371
                pair=trade.pair,
1372
                current_time=current_time,
1373
                proposed_rate=row[OPEN_IDX],
1374
                current_order_rate=order.ft_price,
1375
                entry_tag=trade.enter_tag,
1376
                side=trade.trade_direction,
1377
                is_entry=is_entry,
1378
            )  # default value is current order price
1379

1380
            # cancel existing order whenever a new rate is requested (or None)
1381
            if requested_rate == order.ft_price:
1✔
1382
                # assumption: there can't be multiple open entry orders at any given time
1383
                return False
1✔
1384
            else:
1385
                del trade.orders[trade.orders.index(order)]
1✔
1386
                if is_entry:
1✔
1387
                    self.canceled_entry_orders += 1
1✔
1388
                else:
1389
                    self.canceled_exit_orders += 1
1✔
1390

1391
            # place new order if result was not None
1392
            if requested_rate:
1✔
1393
                if is_entry:
1✔
1394
                    self._enter_trade(
1✔
1395
                        pair=trade.pair,
1396
                        row=row,
1397
                        trade=trade,
1398
                        requested_rate=requested_rate,
1399
                        requested_stake=(order.safe_remaining * order.ft_price / trade.leverage),
1400
                        direction="short" if trade.is_short else "long",
1401
                    )
1402
                    self.replaced_entry_orders += 1
1✔
1403
                else:
1404
                    self._exit_trade(
1✔
1405
                        trade=trade,
1406
                        sell_row=row,
1407
                        close_rate=requested_rate,
1408
                        amount=order.safe_remaining,
1409
                        exit_reason=order.ft_order_tag,
1410
                    )
1411
                    self.replaced_exit_orders += 1
1✔
1412
                # Delete trade if no successful entries happened (if placing the new order failed)
1413
                if not trade.has_open_orders and is_entry and trade.nr_of_successful_entries == 0:
1✔
UNCOV
1414
                    return True
×
1415
            else:
1416
                # assumption: there can't be multiple open entry orders at any given time
UNCOV
1417
                return trade.nr_of_successful_entries == 0
×
1418
        return False
1✔
1419

1420
    def validate_row(
1✔
1421
        self, data: dict, pair: str, row_index: int, current_time: datetime
1422
    ) -> tuple | None:
1423
        try:
1✔
1424
            # Row is treated as "current incomplete candle".
1425
            # entry / exit signals are shifted by 1 to compensate for this.
1426
            row = data[pair][row_index]
1✔
1427
        except IndexError:
1✔
1428
            # missing Data for one pair at the end.
1429
            # Warnings for this are shown during data loading
1430
            return None
1✔
1431

1432
        # Waits until the time-counter reaches the start of the data for this pair.
1433
        if row[DATE_IDX] > current_time:
1✔
1434
            return None
1✔
1435
        return row
1✔
1436

1437
    def _collate_rejected(self, pair, row):
1✔
1438
        """
1439
        Temporarily store rejected signal information for downstream use in backtesting_analysis
1440
        """
1441
        # It could be fun to enable hyperopt mode to write
1442
        # a loss function to reduce rejected signals
1443
        if (
1✔
1444
            self.config.get("export", "none") == "signals"
1445
            and self.dataprovider.runmode == RunMode.BACKTEST
1446
        ):
UNCOV
1447
            if pair not in self.rejected_dict:
×
UNCOV
1448
                self.rejected_dict[pair] = []
×
1449
            self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]])
×
1450

1451
    def backtest_loop(
1✔
1452
        self,
1453
        row: tuple,
1454
        pair: str,
1455
        current_time: datetime,
1456
        trade_dir: LongShort | None,
1457
        can_enter: bool,
1458
    ) -> LongShort | None:
1459
        """
1460
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1461

1462
        Backtesting processing for one candle/pair.
1463
        """
1464
        exiting_dir: LongShort | None = None
1✔
1465
        if not self._position_stacking and len(LocalTrade.bt_trades_open_pp[pair]) > 0:
1✔
1466
            # position_stacking not supported for now.
1467
            exiting_dir = "short" if LocalTrade.bt_trades_open_pp[pair][0].is_short else "long"
1✔
1468

1469
        for t in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1470
            # 1. Manage currently open orders of active trades
1471
            if self.manage_open_orders(t, current_time, row):
1✔
1472
                # Remove trade (initial open order never filled)
1473
                LocalTrade.remove_bt_trade(t)
1✔
1474
                self.wallets.update()
1✔
1475

1476
        # 2. Process entries.
1477
        # without positionstacking, we can only have one open trade per pair.
1478
        # max_open_trades must be respected
1479
        # don't open on the last row
1480
        # We only open trades on the main candle, not on detail candles
1481
        if (
1✔
1482
            can_enter
1483
            and trade_dir is not None
1484
            and (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
1485
            and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
1486
        ):
1487
            if self.trade_slot_available(LocalTrade.bt_open_open_trade_count):
1✔
1488
                trade = self._enter_trade(pair, row, trade_dir)
1✔
1489
                if trade:
1✔
1490
                    self.wallets.update()
1✔
1491
            else:
1492
                self._collate_rejected(pair, row)
1✔
1493

1494
        for trade in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1495
            # 3. Process entry orders.
1496
            order = trade.select_order(trade.entry_side, is_open=True)
1✔
1497
            if self._try_close_open_order(order, trade, current_time, row):
1✔
1498
                self.wallets.update()
1✔
1499

1500
            # 4. Create exit orders (if any)
1501
            if trade.has_open_position:
1✔
1502
                self._check_trade_exit(trade, row, current_time)  # Place exit order if necessary
1✔
1503

1504
            # 5. Process exit orders.
1505
            order = trade.select_order(trade.exit_side, is_open=True)
1✔
1506
            if order:
1✔
1507
                self._process_exit_order(order, trade, current_time, row, pair)
1✔
1508

1509
        if exiting_dir and len(LocalTrade.bt_trades_open_pp[pair]) == 0:
1✔
1510
            return exiting_dir
1✔
1511
        return None
1✔
1512

1513
    def get_detail_data(self, pair: str, row: tuple) -> list[tuple] | None:
1✔
1514
        """
1515
        Spread into detail data
1516
        """
1517
        current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1518
        exit_candle_end = current_detail_time + self.timeframe_td
1✔
1519
        detail_data = self.detail_data[pair]
1✔
1520
        detail_data = detail_data.loc[
1✔
1521
            (detail_data["date"] >= current_detail_time) & (detail_data["date"] < exit_candle_end)
1522
        ].copy()
1523

1524
        if len(detail_data) == 0:
1✔
UNCOV
1525
            return None
×
1526
        detail_data.loc[:, "enter_long"] = row[LONG_IDX]
1✔
1527
        detail_data.loc[:, "exit_long"] = row[ELONG_IDX]
1✔
1528
        detail_data.loc[:, "enter_short"] = row[SHORT_IDX]
1✔
1529
        detail_data.loc[:, "exit_short"] = row[ESHORT_IDX]
1✔
1530
        detail_data.loc[:, "enter_tag"] = row[ENTER_TAG_IDX]
1✔
1531
        detail_data.loc[:, "exit_tag"] = row[EXIT_TAG_IDX]
1✔
1532
        return detail_data[HEADERS].values.tolist()
1✔
1533

1534
    def _time_generator(self, start_date: datetime, end_date: datetime):
1✔
1535
        current_time = start_date + self.timeframe_td
1✔
1536
        while current_time <= end_date:
1✔
1537
            yield current_time
1✔
1538
            current_time += self.timeframe_td
1✔
1539

1540
    def _time_generator_det(self, start_date: datetime, end_date: datetime):
1✔
1541
        """
1542
        Loop for each detail candle.
1543
        Yields only the start date if no detail timeframe is set.
1544
        """
1545
        if not self.timeframe_detail_td:
1✔
1546
            yield start_date, True, False, 0
1✔
1547
            return
1✔
1548

1549
        current_time = start_date
1✔
1550
        i = 0
1✔
1551
        while current_time <= end_date:
1✔
1552
            yield current_time, i == 0, True, i
1✔
1553
            i += 1
1✔
1554
            current_time += self.timeframe_detail_td
1✔
1555

1556
    def _time_pair_generator_det(self, current_time: datetime, pairs: list[str]):
1✔
1557
        for current_time_det, is_first, has_detail, idx in self._time_generator_det(
1✔
1558
            current_time, current_time + self.timeframe_td
1559
        ):
1560
            # Pairs that have open trades should be processed first
1561
            new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs))
1✔
1562
            for pair in new_pairlist:
1✔
1563
                yield current_time_det, is_first, has_detail, idx, pair
1✔
1564

1565
    def time_pair_generator(
1✔
1566
        self,
1567
        start_date: datetime,
1568
        end_date: datetime,
1569
        pairs: list[str],
1570
        data: dict[str, list[tuple]],
1571
    ):
1572
        """
1573
        Backtest time and pair generator
1574
        :returns: generator of (current_time, pair, row, is_last_row, trade_dir)
1575
            where is_last_row is a boolean indicating if this is the data end date.
1576
        """
1577
        current_time = start_date + self.timeframe_td
1✔
1578
        self.progress.init_step(
1✔
1579
            BacktestState.BACKTEST, int((end_date - start_date) / self.timeframe_td)
1580
        )
1581
        # Indexes per pair, so some pairs are allowed to have a missing start.
1582
        indexes: dict = defaultdict(int)
1✔
1583

1584
        for current_time in self._time_generator(start_date, end_date):
1✔
1585
            # Loop for each main candle.
1586
            self.check_abort()
1✔
1587
            # Reset open trade count for this candle
1588
            # Critical to avoid exceeding max_open_trades in backtesting
1589
            # when timeframe-detail is used and trades close within the opening candle.
1590
            strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)(
1✔
1591
                current_time=current_time
1592
            )
1593
            pair_detail_cache: dict[str, list[tuple]] = {}
1✔
1594
            pair_tradedir_cache: dict[str, LongShort | None] = {}
1✔
1595
            pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open]
1✔
1596

1597
            for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det(
1✔
1598
                current_time, pairs
1599
            ):
1600
                # Loop for each detail candle (if necessary) and pair
1601
                # Yields only the main date if no detail timeframe is set.
1602

1603
                # Pairs that have open trades should be processed first
1604
                trade_dir: LongShort | None = None
1✔
1605
                if is_first:
1✔
1606
                    # Main candle
1607
                    row_index = indexes[pair]
1✔
1608
                    row = self.validate_row(data, pair, row_index, current_time)
1✔
1609
                    if not row:
1✔
1610
                        continue
1✔
1611

1612
                    row_index += 1
1✔
1613
                    indexes[pair] = row_index
1✔
1614
                    is_last_row = current_time == end_date
1✔
1615
                    self.dataprovider._set_dataframe_max_index(
1✔
1616
                        pair, self.required_startup + row_index
1617
                    )
1618
                    trade_dir = self.check_for_trade_entry(row)
1✔
1619
                    pair_tradedir_cache[pair] = trade_dir
1✔
1620

1621
                else:
1622
                    # Detail candle - from cache.
1623
                    detail_data = pair_detail_cache.get(pair)
1✔
1624
                    if detail_data is None or len(detail_data) <= idx:
1✔
1625
                        # logger.info(f"skipping {pair}, {current_time_det}, {trade_dir}")
1626
                        continue
1✔
1627
                    row = detail_data[idx]
1✔
1628
                    trade_dir = pair_tradedir_cache.get(pair)
1✔
1629

1630
                    if self.strategy.ignore_expired_candle(
1✔
1631
                        current_time - self.timeframe_td,  # last closed candle is 1 timeframe away.
1632
                        current_time_det,
1633
                        self.timeframe_secs,
1634
                        trade_dir is not None,
1635
                    ):
1636
                        # Ignore late entries eventually
1637
                        trade_dir = None
1✔
1638

1639
                self.dataprovider._set_dataframe_max_date(current_time_det)
1✔
1640

1641
                pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0
1✔
1642
                if pair in pairs_with_open_trades and not pair_has_open_trades:
1✔
1643
                    # Pair has had open trades which closed in the current main candle.
1644
                    # Skip this pair for this timeframe
1645
                    continue
1✔
1646
                if pair_has_open_trades and pair not in pairs_with_open_trades:
1✔
1647
                    # auto-lock for pairs that have open trades
1648
                    # Necessary for detail - to capture trades that open and close within
1649
                    # the same main candle
1650
                    pairs_with_open_trades.append(pair)
1✔
1651

1652
                if (
1✔
1653
                    is_first
1654
                    and (trade_dir is not None or pair_has_open_trades)
1655
                    and has_detail
1656
                    and pair not in pair_detail_cache
1657
                    and pair in self.detail_data
1658
                    and row
1659
                ):
1660
                    # Spread candle into detail timeframe and cache that -
1661
                    # only once per main candle
1662
                    # and only if we can expect activity.
1663
                    pair_detail = self.get_detail_data(pair, row)
1✔
1664
                    if pair_detail is not None:
1✔
1665
                        pair_detail_cache[pair] = pair_detail
1✔
1666
                        row = pair_detail_cache[pair][idx]
1✔
1667

1668
                is_last_row = current_time_det == end_date
1✔
1669

1670
                yield current_time_det, pair, row, is_last_row, trade_dir
1✔
1671
            self.progress.increment()
1✔
1672

1673
    def backtest(
1✔
1674
        self, processed: dict, start_date: datetime, end_date: datetime
1675
    ) -> BacktestContentTypeIcomplete:
1676
        """
1677
        Implement backtesting functionality
1678

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

1683
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
1684
        optimize memory usage!
1685
        :param start_date: backtesting timerange start datetime
1686
        :param end_date: backtesting timerange end datetime
1687
        :return: DataFrame with trades (results of backtesting)
1688
        """
1689
        self.prepare_backtest(self.enable_protections)
1✔
1690
        # Ensure wallets are up-to-date (important for --strategy-list)
1691
        self.wallets.update()
1✔
1692
        # Use dict of lists with data for performance
1693
        # (looping lists is a lot faster than pandas DataFrames)
1694
        data: dict = self._get_ohlcv_as_lists(processed)
1✔
1695

1696
        # Loop timerange and get candle for each pair at that point in time
1697
        for (
1✔
1698
            current_time,
1699
            pair,
1700
            row,
1701
            is_last_row,
1702
            trade_dir,
1703
        ) in self.time_pair_generator(start_date, end_date, list(data.keys()), data):
1704
            if not self._can_short or trade_dir is None:
1✔
1705
                # No need to reverse position if shorting is disabled or there's no new signal
1706
                self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
1✔
1707
            else:
1708
                # Conditionally call backtest_loop a 2nd time if shorting is enabled,
1709
                # a position closed and a new signal in the other direction is available.
1710

1711
                for _ in (0, 1):
1✔
1712
                    a = self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
1✔
1713
                    if not a or a == trade_dir:
1✔
1714
                        # the trade didn't close or position change is in the same direction
1715
                        break
1✔
1716

1717
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1718
        self.wallets.update()
1✔
1719

1720
        results = trade_list_to_dataframe(LocalTrade.bt_trades)
1✔
1721
        return {
1✔
1722
            "results": results,
1723
            "config": self.strategy.config,
1724
            "locks": PairLocks.get_all_locks(),
1725
            "rejected_signals": self.rejected_trades,
1726
            "timedout_entry_orders": self.timedout_entry_orders,
1727
            "timedout_exit_orders": self.timedout_exit_orders,
1728
            "canceled_trade_entries": self.canceled_trade_entries,
1729
            "canceled_entry_orders": self.canceled_entry_orders,
1730
            "replaced_entry_orders": self.replaced_entry_orders,
1731
            "final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
1732
        }
1733

1734
    def backtest_one_strategy(
1✔
1735
        self, strat: IStrategy, data: dict[str, DataFrame], timerange: TimeRange
1736
    ):
1737
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1738
        strategy_name = strat.get_strategy_name()
1✔
1739
        logger.info(f"Running backtesting for Strategy {strategy_name}")
1✔
1740
        backtest_start_time = dt_now()
1✔
1741
        self._set_strategy(strat)
1✔
1742

1743
        # need to reprocess data every time to populate signals
1744
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1745

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

1750
        if not preprocessed_tmp:
1✔
UNCOV
1751
            raise OperationalException("No data left after adjusting for startup candles.")
×
1752

1753
        # Use preprocessed_tmp for date generation (the trimmed dataframe).
1754
        # Backtesting will re-trim the dataframes after entry/exit signal generation.
1755
        min_date, max_date = history.get_timerange(preprocessed_tmp)
1✔
1756
        logger.info(
1✔
1757
            f"Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} "
1758
            f"up to {max_date.strftime(DATETIME_PRINT_FORMAT)} "
1759
            f"({(max_date - min_date).days} days)."
1760
        )
1761
        # Execute backtest and store results
1762
        results = self.backtest(
1✔
1763
            processed=preprocessed,
1764
            start_date=min_date,
1765
            end_date=max_date,
1766
        )
1767
        backtest_end_time = dt_now()
1✔
1768
        results.update(
1✔
1769
            {
1770
                "run_id": self.run_ids.get(strategy_name, ""),
1771
                "backtest_start_time": int(backtest_start_time.timestamp()),
1772
                "backtest_end_time": int(backtest_end_time.timestamp()),
1773
            }
1774
        )
1775
        self.all_bt_content[strategy_name] = results
1✔
1776

1777
        if (
1✔
1778
            self.config.get("export", "none") == "signals"
1779
            and self.dataprovider.runmode == RunMode.BACKTEST
1780
        ):
1781
            signals = generate_trade_signal_candles(preprocessed_tmp, results, "open_date")
1✔
1782
            rejected = generate_rejected_signals(preprocessed_tmp, self.rejected_dict)
1✔
1783
            exited = generate_trade_signal_candles(preprocessed_tmp, results, "close_date")
1✔
1784

1785
            self.analysis_results["signals"][strategy_name] = signals
1✔
1786
            self.analysis_results["rejected"][strategy_name] = rejected
1✔
1787
            self.analysis_results["exited"][strategy_name] = exited
1✔
1788

1789
        return min_date, max_date
1✔
1790

1791
    def _get_min_cached_backtest_date(self):
1✔
1792
        min_backtest_date = None
1✔
1793
        backtest_cache_age = self.config.get("backtest_cache", constants.BACKTEST_CACHE_DEFAULT)
1✔
1794
        if self.timerange.stopts == 0 or self.timerange.stopdt > dt_now():
1✔
1795
            logger.warning("Backtest result caching disabled due to use of open-ended timerange.")
1✔
1796
        elif backtest_cache_age == "day":
1✔
1797
            min_backtest_date = dt_now() - timedelta(days=1)
1✔
1798
        elif backtest_cache_age == "week":
1✔
1799
            min_backtest_date = dt_now() - timedelta(weeks=1)
1✔
1800
        elif backtest_cache_age == "month":
1✔
1801
            min_backtest_date = dt_now() - timedelta(weeks=4)
1✔
1802
        return min_backtest_date
1✔
1803

1804
    def load_prior_backtest(self):
1✔
1805
        self.run_ids = {
1✔
1806
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1807
            for strategy in self.strategylist
1808
        }
1809

1810
        # Load previous result that will be updated incrementally.
1811
        # This can be circumvented in certain instances in combination with downloading more data
1812
        min_backtest_date = self._get_min_cached_backtest_date()
1✔
1813
        if min_backtest_date is not None:
1✔
1814
            self.results = find_existing_backtest_stats(
1✔
1815
                self.config["user_data_dir"] / "backtest_results", self.run_ids, min_backtest_date
1816
            )
1817

1818
    def start(self) -> None:
1✔
1819
        """
1820
        Run backtesting end-to-end
1821
        """
1822
        data: dict[str, DataFrame] = {}
1✔
1823

1824
        data, timerange = self.load_bt_data()
1✔
1825
        logger.info("Dataload complete. Calculating indicators")
1✔
1826

1827
        self.load_prior_backtest()
1✔
1828

1829
        for strat in self.strategylist:
1✔
1830
            if self.results and strat.get_strategy_name() in self.results["strategy"]:
1✔
1831
                # When previous result hash matches - reuse that result and skip backtesting.
1832
                logger.info(f"Reusing result of previous backtest for {strat.get_strategy_name()}")
1✔
1833
                continue
1✔
1834
            min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
1✔
1835

1836
        # Update old results with new ones.
1837
        if len(self.all_bt_content) > 0:
1✔
1838
            results = generate_backtest_stats(
1✔
1839
                data,
1840
                self.all_bt_content,
1841
                min_date=min_date,
1842
                max_date=max_date,
1843
                notes=self.config.get("backtest_notes"),
1844
            )
1845
            if self.results:
1✔
1846
                self.results["metadata"].update(results["metadata"])
1✔
1847
                self.results["strategy"].update(results["strategy"])
1✔
1848
                self.results["strategy_comparison"].extend(results["strategy_comparison"])
1✔
1849
            else:
UNCOV
1850
                self.results = results
×
1851
            dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1✔
1852
            if self.config.get("export", "none") in ("trades", "signals"):
1✔
1853
                combined_res = combined_dataframes_with_rel_mean(data, min_date, max_date)
1✔
1854
                store_backtest_results(
1✔
1855
                    self.config,
1856
                    self.results,
1857
                    dt_appendix,
1858
                    market_change_data=combined_res,
1859
                    analysis_results=self.analysis_results,
1860
                    strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
1861
                )
1862

1863
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1864
        if "strategy_list" in self.config and len(self.results) > 0:
1✔
1865
            self.results["strategy_comparison"] = sorted(
1✔
1866
                self.results["strategy_comparison"],
1867
                key=lambda c: self.config["strategy_list"].index(c["key"]),
1868
            )
1869
            self.results["strategy"] = dict(
1✔
1870
                sorted(
1871
                    self.results["strategy"].items(),
1872
                    key=lambda kv: self.config["strategy_list"].index(kv[0]),
1873
                )
1874
            )
1875

1876
        if len(self.strategylist) > 0:
1✔
1877
            # Show backtest results
1878
            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

© 2026 Coveralls, Inc