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

freqtrade / freqtrade / 14163013824

30 Mar 2025 05:34PM UTC coverage: 94.422%. Remained the same
14163013824

push

github

xmatthias
fix: allow backtesting for specific exchanges

3 of 3 new or added lines in 2 files covered. (100.0%)

22 existing lines in 2 files now uncovered.

22190 of 23501 relevant lines covered (94.42%)

0.94 hits per line

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

96.85
/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
from typing import Any
1✔
12

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

70

71
logger = logging.getLogger(__name__)
1✔
72

73
# Indexes for backtest tuples
74
DATE_IDX = 0
1✔
75
OPEN_IDX = 1
1✔
76
HIGH_IDX = 2
1✔
77
LOW_IDX = 3
1✔
78
CLOSE_IDX = 4
1✔
79
LONG_IDX = 5
1✔
80
ELONG_IDX = 6  # Exit long
1✔
81
SHORT_IDX = 7
1✔
82
ESHORT_IDX = 8  # Exit short
1✔
83
ENTER_TAG_IDX = 9
1✔
84
EXIT_TAG_IDX = 10
1✔
85

86
# Every change to this headers list must evaluate further usages of the resulting tuple
87
# and eventually change the constants for indexes at the top
88
HEADERS = [
1✔
89
    "date",
90
    "open",
91
    "high",
92
    "low",
93
    "close",
94
    "enter_long",
95
    "exit_long",
96
    "enter_short",
97
    "exit_short",
98
    "enter_tag",
99
    "exit_tag",
100
]
101

102

103
class Backtesting:
1✔
104
    """
105
    Backtesting class, this class contains all the logic to run a backtest
106

107
    To run a backtest:
108
    backtesting = Backtesting(config)
109
    backtesting.start()
110
    """
111

112
    def __init__(self, config: Config, exchange: Exchange | None = None) -> None:
1✔
113
        LoggingMixin.show_output = False
1✔
114
        self.config = config
1✔
115
        self.results: BacktestResultType = get_BacktestResultType_default()
1✔
116
        self.trade_id_counter: int = 0
1✔
117
        self.order_id_counter: int = 0
1✔
118

119
        config["dry_run"] = True
1✔
120
        self.run_ids: dict[str, str] = {}
1✔
121
        self.strategylist: list[IStrategy] = []
1✔
122
        self.all_results: dict[str, dict] = {}
1✔
123
        self.analysis_results: dict[str, dict[str, DataFrame]] = {
1✔
124
            "signals": {},
125
            "rejected": {},
126
            "exited": {},
127
        }
128
        self.rejected_dict: dict[str, list] = {}
1✔
129

130
        self._exchange_name = self.config["exchange"]["name"]
1✔
131
        if not exchange:
1✔
132
            exchange = ExchangeResolver.load_exchange(self.config, load_leverage_tiers=True)
1✔
133
        self.exchange = exchange
1✔
134

135
        self.dataprovider = DataProvider(self.config, self.exchange)
1✔
136

137
        if self.config.get("strategy_list"):
1✔
138
            if self.config.get("freqai", {}).get("enabled", False):
1✔
139
                logger.warning(
1✔
140
                    "Using --strategy-list with FreqAI REQUIRES all strategies "
141
                    "to have identical feature_engineering_* functions."
142
                )
143
            for strat in list(self.config["strategy_list"]):
1✔
144
                stratconf = deepcopy(self.config)
1✔
145
                stratconf["strategy"] = strat
1✔
146
                self.strategylist.append(StrategyResolver.load_strategy(stratconf))
1✔
147
                validate_config_consistency(stratconf)
1✔
148

149
        else:
150
            # No strategy list specified, only one strategy
151
            self.strategylist.append(StrategyResolver.load_strategy(self.config))
1✔
152
            validate_config_consistency(self.config)
1✔
153

154
        if "timeframe" not in self.config:
1✔
155
            raise OperationalException(
1✔
156
                "Timeframe needs to be set in either "
157
                "configuration or as cli argument `--timeframe 5m`"
158
            )
159
        self.timeframe = str(self.config.get("timeframe"))
1✔
160
        self.timeframe_secs = timeframe_to_seconds(self.timeframe)
1✔
161
        self.timeframe_min = self.timeframe_secs // 60
1✔
162
        self.timeframe_td = timedelta(seconds=self.timeframe_secs)
1✔
163
        self.disable_database_use()
1✔
164
        self.init_backtest_detail()
1✔
165
        self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
1✔
166
        self._validate_pairlists_for_backtesting()
1✔
167

168
        self.dataprovider.add_pairlisthandler(self.pairlists)
1✔
169
        self.pairlists.refresh_pairlist()
1✔
170

171
        if len(self.pairlists.whitelist) == 0:
1✔
172
            raise OperationalException("No pair in whitelist.")
1✔
173

174
        if config.get("fee", None) is not None:
1✔
175
            self.fee = config["fee"]
1✔
176
            logger.info(f"Using fee {self.fee:.4%} from config.")
1✔
177
        else:
178
            fees = [
1✔
179
                self.exchange.get_fee(
180
                    symbol=self.pairlists.whitelist[0],
181
                    taker_or_maker=mt,  # type: ignore
182
                )
183
                for mt in ("taker", "maker")
184
            ]
185
            self.fee = max(fee for fee in fees if fee is not None)
1✔
186
            logger.info(f"Using fee {self.fee:.4%} - worst case fee from exchange (lowest tier).")
1✔
187
        self.precision_mode = self.exchange.precisionMode
1✔
188
        self.precision_mode_price = self.exchange.precision_mode_price
1✔
189

190
        if self.config.get("freqai_backtest_live_models", False):
1✔
191
            from freqtrade.freqai.utils import get_timerange_backtest_live_models
1✔
192

193
            self.config["timerange"] = get_timerange_backtest_live_models(self.config)
1✔
194

195
        self.timerange = TimeRange.parse_timerange(
1✔
196
            None if self.config.get("timerange") is None else str(self.config.get("timerange"))
197
        )
198

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

203
        # Add maximum startup candle count to configuration for informative pairs support
204
        self.config["startup_candle_count"] = self.required_startup
1✔
205

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

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

219
        self.init_backtest()
1✔
220

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

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

232
    @staticmethod
1✔
233
    def cleanup():
1✔
234
        LoggingMixin.show_output = True
1✔
235
        enable_database_use()
1✔
236

237
    def init_backtest_detail(self) -> None:
1✔
238
        # Load detail timeframe if specified
239
        self.timeframe_detail = str(self.config.get("timeframe_detail", ""))
1✔
240
        if self.timeframe_detail:
1✔
241
            timeframe_detail_secs = timeframe_to_seconds(self.timeframe_detail)
1✔
242
            self.timeframe_detail_td = timedelta(seconds=timeframe_detail_secs)
1✔
243
            if self.timeframe_secs <= timeframe_detail_secs:
1✔
244
                raise OperationalException(
1✔
245
                    "Detail timeframe must be smaller than strategy timeframe."
246
                )
247

248
        else:
249
            self.timeframe_detail_td = timedelta(seconds=0)
1✔
250
        self.detail_data: dict[str, DataFrame] = {}
1✔
251
        self.futures_data: dict[str, DataFrame] = {}
1✔
252

253
    def init_backtest(self):
1✔
254
        self.prepare_backtest(False)
1✔
255

256
        self.wallets = Wallets(self.config, self.exchange, is_backtest=True)
1✔
257

258
        self.progress = BTProgress()
1✔
259
        self.abort = False
1✔
260

261
    def _set_strategy(self, strategy: IStrategy):
1✔
262
        """
263
        Load strategy into backtesting
264
        """
265
        self.strategy: IStrategy = strategy
1✔
266
        strategy.dp = self.dataprovider
1✔
267
        # Attach Wallets to Strategy baseclass
268
        strategy.wallets = self.wallets
1✔
269
        # Set stoploss_on_exchange to false for backtesting,
270
        # since a "perfect" stoploss-exit is assumed anyway
271
        # And the regular "stoploss" function would not apply to that case
272
        self.strategy.order_types["stoploss_on_exchange"] = False
1✔
273
        # Update can_short flag
274
        self._can_short = self.trading_mode != TradingMode.SPOT and strategy.can_short
1✔
275

276
        self.strategy.ft_bot_start()
1✔
277

278
    def _load_protections(self, strategy: IStrategy):
1✔
279
        if self.config.get("enable_protections", False):
1✔
280
            self.protections = ProtectionManager(self.config, strategy.protections)
1✔
281

282
    def load_bt_data(self) -> tuple[dict[str, DataFrame], TimeRange]:
1✔
283
        """
284
        Loads backtest data and returns the data combined with the timerange
285
        as tuple.
286
        """
287
        self.progress.init_step(BacktestState.DATALOAD, 1)
1✔
288

289
        data = history.load_data(
1✔
290
            datadir=self.config["datadir"],
291
            pairs=self.pairlists.whitelist,
292
            timeframe=self.timeframe,
293
            timerange=self.timerange,
294
            startup_candles=self.required_startup,
295
            fail_without_data=True,
296
            data_format=self.config["dataformat_ohlcv"],
297
            candle_type=self.config.get("candle_type_def", CandleType.SPOT),
298
        )
299

300
        min_date, max_date = history.get_timerange(data)
1✔
301

302
        logger.info(
1✔
303
            f"Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} "
304
            f"up to {max_date.strftime(DATETIME_PRINT_FORMAT)} "
305
            f"({(max_date - min_date).days} days)."
306
        )
307

308
        # Adjust startts forward if not enough data is available
309
        self.timerange.adjust_start_if_necessary(
1✔
310
            timeframe_to_seconds(self.timeframe), self.required_startup, min_date
311
        )
312

313
        self.progress.set_new_value(1)
1✔
314
        return data, self.timerange
1✔
315

316
    def load_bt_data_detail(self) -> None:
1✔
317
        """
318
        Loads backtest detail data (smaller timeframe) if necessary.
319
        """
320
        if self.timeframe_detail:
1✔
321
            self.detail_data = history.load_data(
1✔
322
                datadir=self.config["datadir"],
323
                pairs=self.pairlists.whitelist,
324
                timeframe=self.timeframe_detail,
325
                timerange=self.timerange,
326
                startup_candles=0,
327
                fail_without_data=True,
328
                data_format=self.config["dataformat_ohlcv"],
329
                candle_type=self.config.get("candle_type_def", CandleType.SPOT),
330
            )
331
        else:
332
            self.detail_data = {}
1✔
333
        if self.trading_mode == TradingMode.FUTURES:
1✔
334
            funding_fee_timeframe: str = self.exchange.get_option("funding_fee_timeframe")
1✔
335
            self.funding_fee_timeframe_secs: int = timeframe_to_seconds(funding_fee_timeframe)
1✔
336
            mark_timeframe: str = self.exchange.get_option("mark_ohlcv_timeframe")
1✔
337

338
            # Load additional futures data.
339
            funding_rates_dict = history.load_data(
1✔
340
                datadir=self.config["datadir"],
341
                pairs=self.pairlists.whitelist,
342
                timeframe=funding_fee_timeframe,
343
                timerange=self.timerange,
344
                startup_candles=0,
345
                fail_without_data=True,
346
                data_format=self.config["dataformat_ohlcv"],
347
                candle_type=CandleType.FUNDING_RATE,
348
            )
349

350
            # For simplicity, assign to CandleType.Mark (might contain index candles!)
351
            mark_rates_dict = history.load_data(
1✔
352
                datadir=self.config["datadir"],
353
                pairs=self.pairlists.whitelist,
354
                timeframe=mark_timeframe,
355
                timerange=self.timerange,
356
                startup_candles=0,
357
                fail_without_data=True,
358
                data_format=self.config["dataformat_ohlcv"],
359
                candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price")),
360
            )
361
            # Combine data to avoid combining the data per trade.
362
            unavailable_pairs = []
1✔
363
            uses_leverage_tiers = self.exchange.get_option("uses_leverage_tiers", True)
1✔
364
            for pair in self.pairlists.whitelist:
1✔
365
                if uses_leverage_tiers and pair not in self.exchange._leverage_tiers:
1✔
366
                    unavailable_pairs.append(pair)
1✔
367
                    continue
1✔
368

369
                self.futures_data[pair] = self.exchange.combine_funding_and_mark(
1✔
370
                    funding_rates=funding_rates_dict[pair],
371
                    mark_rates=mark_rates_dict[pair],
372
                    futures_funding_rate=self.config.get("futures_funding_rate", None),
373
                )
374

375
            if unavailable_pairs:
1✔
376
                raise OperationalException(
1✔
377
                    f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. "
378
                    "It is therefore impossible to backtest with this pair at the moment."
379
                )
380
        else:
381
            self.futures_data = {}
1✔
382

383
    def disable_database_use(self):
1✔
384
        disable_database_use(self.timeframe)
1✔
385

386
    def prepare_backtest(self, enable_protections):
1✔
387
        """
388
        Backtesting setup method - called once for every call to "backtest()".
389
        """
390
        self.disable_database_use()
1✔
391
        PairLocks.reset_locks()
1✔
392
        Trade.reset_trades()
1✔
393
        CustomDataWrapper.reset_custom_data()
1✔
394
        self.rejected_trades = 0
1✔
395
        self.timedout_entry_orders = 0
1✔
396
        self.timedout_exit_orders = 0
1✔
397
        self.canceled_trade_entries = 0
1✔
398
        self.canceled_entry_orders = 0
1✔
399
        self.replaced_entry_orders = 0
1✔
400
        self.canceled_exit_orders = 0
1✔
401
        self.replaced_exit_orders = 0
1✔
402
        self.dataprovider.clear_cache()
1✔
403
        if enable_protections:
1✔
404
            self._load_protections(self.strategy)
1✔
405

406
    def check_abort(self):
1✔
407
        """
408
        Check if abort was requested, raise DependencyException if that's the case
409
        Only applies to Interactive backtest mode (webserver mode)
410
        """
411
        if self.abort:
1✔
412
            self.abort = False
1✔
413
            raise DependencyException("Stop requested")
1✔
414

415
    def _get_ohlcv_as_lists(self, processed: dict[str, DataFrame]) -> dict[str, tuple]:
1✔
416
        """
417
        Helper function to convert a processed dataframes into lists for performance reasons.
418

419
        Used by backtest() - so keep this optimized for performance.
420

421
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
422
        optimize memory usage!
423
        """
424

425
        data: dict = {}
1✔
426
        self.progress.init_step(BacktestState.CONVERT, len(processed))
1✔
427

428
        # Create dict with data
429
        for pair in processed.keys():
1✔
430
            pair_data = processed[pair]
1✔
431
            self.check_abort()
1✔
432
            self.progress.increment()
1✔
433

434
            if not pair_data.empty:
1✔
435
                # Cleanup from prior runs
436
                pair_data.drop(HEADERS[5:] + ["buy", "sell"], axis=1, errors="ignore")
1✔
437
            df_analyzed = self.strategy.ft_advise_signals(pair_data, {"pair": pair})
1✔
438
            # Update dataprovider cache
439
            self.dataprovider._set_cached_df(
1✔
440
                pair, self.timeframe, df_analyzed, self.config["candle_type_def"]
441
            )
442

443
            # Trim startup period from analyzed dataframe
444
            df_analyzed = processed[pair] = pair_data = trim_dataframe(
1✔
445
                df_analyzed, self.timerange, startup_candles=self.required_startup
446
            )
447

448
            # Create a copy of the dataframe before shifting, that way the entry signal/tag
449
            # remains on the correct candle for callbacks.
450
            df_analyzed = df_analyzed.copy()
1✔
451

452
            # To avoid using data from future, we use entry/exit signals shifted
453
            # from the previous candle
454
            for col in HEADERS[5:]:
1✔
455
                tag_col = col in ("enter_tag", "exit_tag")
1✔
456
                if col in df_analyzed.columns:
1✔
457
                    df_analyzed[col] = (
1✔
458
                        df_analyzed.loc[:, col]
459
                        .replace([nan], [0 if not tag_col else None])
460
                        .shift(1)
461
                    )
462
                elif not df_analyzed.empty:
1✔
463
                    df_analyzed[col] = 0 if not tag_col else None
1✔
464

465
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
466

467
            # Convert from Pandas to list for performance reasons
468
            # (Looping Pandas is slow.)
469
            data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else []
1✔
470
        return data
1✔
471

472
    def _get_close_rate(
1✔
473
        self, row: tuple, trade: LocalTrade, exit_: ExitCheckTuple, trade_dur: int
474
    ) -> float:
475
        """
476
        Get close rate for backtesting result
477
        """
478
        # Special handling if high or low hit STOP_LOSS or ROI
479
        if exit_.exit_type in (
1✔
480
            ExitType.STOP_LOSS,
481
            ExitType.TRAILING_STOP_LOSS,
482
            ExitType.LIQUIDATION,
483
        ):
484
            return self._get_close_rate_for_stoploss(row, trade, exit_, trade_dur)
1✔
485
        elif exit_.exit_type == (ExitType.ROI):
1✔
486
            return self._get_close_rate_for_roi(row, trade, exit_, trade_dur)
1✔
487
        else:
488
            return row[OPEN_IDX]
1✔
489

490
    def _get_close_rate_for_stoploss(
1✔
491
        self, row: tuple, trade: LocalTrade, exit_: ExitCheckTuple, trade_dur: int
492
    ) -> float:
493
        # our stoploss was already lower than candle high,
494
        # possibly due to a cancelled trade exit.
495
        # exit at open price.
496
        is_short = trade.is_short or False
1✔
497
        leverage = trade.leverage or 1.0
1✔
498
        side_1 = -1 if is_short else 1
1✔
499
        if exit_.exit_type == ExitType.LIQUIDATION and trade.liquidation_price:
1✔
500
            stoploss_value = trade.liquidation_price
1✔
501
        else:
502
            stoploss_value = trade.stop_loss
1✔
503

504
        if is_short:
1✔
505
            if stoploss_value < row[LOW_IDX]:
1✔
506
                return row[OPEN_IDX]
1✔
507
        else:
508
            if stoploss_value > row[HIGH_IDX]:
1✔
509
                return row[OPEN_IDX]
1✔
510

511
        # Special case: trailing triggers within same candle as trade opened. Assume most
512
        # pessimistic price movement, which is moving just enough to arm stoploss and
513
        # immediately going down to stop price.
514
        if exit_.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0:
1✔
515
            if (
1✔
516
                not self.strategy.use_custom_stoploss
517
                and self.strategy.trailing_stop
518
                and self.strategy.trailing_only_offset_is_reached
519
                and self.strategy.trailing_stop_positive_offset is not None
520
                and self.strategy.trailing_stop_positive
521
            ):
522
                # Worst case: price reaches stop_positive_offset and dives down.
523
                stop_rate = row[OPEN_IDX] * (
1✔
524
                    1
525
                    + side_1 * abs(self.strategy.trailing_stop_positive_offset)
526
                    - side_1 * abs(self.strategy.trailing_stop_positive / leverage)
527
                )
528
            else:
529
                # Worst case: price ticks tiny bit above open and dives down.
530
                stop_rate = row[OPEN_IDX] * (
1✔
531
                    1 - side_1 * abs((trade.stop_loss_pct or 0.0) / leverage)
532
                )
533

534
            # Limit lower-end to candle low to avoid exits below the low.
535
            # This still remains "worst case" - but "worst realistic case".
536
            if is_short:
1✔
537
                return min(row[HIGH_IDX], stop_rate)
1✔
538
            else:
539
                return max(row[LOW_IDX], stop_rate)
1✔
540

541
        # Set close_rate to stoploss
542
        return stoploss_value
1✔
543

544
    def _get_close_rate_for_roi(
1✔
545
        self, row: tuple, trade: LocalTrade, exit_: ExitCheckTuple, trade_dur: int
546
    ) -> float:
547
        is_short = trade.is_short or False
1✔
548
        leverage = trade.leverage or 1.0
1✔
549
        side_1 = -1 if is_short else 1
1✔
550
        roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur)
1✔
551
        if roi is not None and roi_entry is not None:
1✔
552
            if roi == -1 and roi_entry % self.timeframe_min == 0:
1✔
553
                # When force_exiting with ROI=-1, the roi time will always be equal to trade_dur.
554
                # If that entry is a multiple of the timeframe (so on candle open)
555
                # - we'll use open instead of close
556
                return row[OPEN_IDX]
1✔
557

558
            # - (Expected abs profit - open_rate - open_fee) / (fee_close -1)
559
            roi_rate = trade.open_rate * roi / leverage
1✔
560
            open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open)
1✔
561
            close_rate = -(roi_rate + open_fee_rate) / ((trade.fee_close or 0.0) - side_1 * 1)
1✔
562
            if is_short:
1✔
563
                is_new_roi = row[OPEN_IDX] < close_rate
1✔
564
            else:
565
                is_new_roi = row[OPEN_IDX] > close_rate
1✔
566
            if (
1✔
567
                trade_dur > 0
568
                and trade_dur == roi_entry
569
                and roi_entry % self.timeframe_min == 0
570
                and is_new_roi
571
            ):
572
                # new ROI entry came into effect.
573
                # use Open rate if open_rate > calculated exit rate
574
                return row[OPEN_IDX]
1✔
575

576
            if trade_dur == 0 and (
1✔
577
                (
578
                    is_short
579
                    # Red candle (for longs)
580
                    and row[OPEN_IDX] < row[CLOSE_IDX]  # Red candle
581
                    and trade.open_rate > row[OPEN_IDX]  # trade-open above open_rate
582
                    and close_rate < row[CLOSE_IDX]  # closes below close
583
                )
584
                or (
585
                    not is_short
586
                    # green candle (for shorts)
587
                    and row[OPEN_IDX] > row[CLOSE_IDX]  # green candle
588
                    and trade.open_rate < row[OPEN_IDX]  # trade-open below open_rate
589
                    and close_rate > row[CLOSE_IDX]  # closes above close
590
                )
591
            ):
592
                # ROI on opening candles with custom pricing can only
593
                # trigger if the entry was at Open or lower wick.
594
                # details: https: // github.com/freqtrade/freqtrade/issues/6261
595
                # If open_rate is < open, only allow exits below the close on red candles.
596
                raise ValueError("Opening candle ROI on red candles.")
1✔
597

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

603
        else:
604
            # This should not be reached...
UNCOV
605
            return row[OPEN_IDX]
×
606

607
    def _check_adjust_trade_for_candle(
1✔
608
        self, trade: LocalTrade, row: tuple, current_time: datetime
609
    ) -> LocalTrade:
610
        current_rate: float = row[OPEN_IDX]
1✔
611
        current_profit = trade.calc_profit_ratio(current_rate)
1✔
612
        min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1)
1✔
613
        max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate)
1✔
614
        stake_available = self.wallets.get_available_stake_amount()
1✔
615
        stake_amount, order_tag = self.strategy._adjust_trade_position_internal(
1✔
616
            trade=trade,  # type: ignore[arg-type]
617
            current_time=current_time,
618
            current_rate=current_rate,
619
            current_profit=current_profit,
620
            min_stake=min_stake,
621
            max_stake=min(max_stake, stake_available),
622
            current_entry_rate=current_rate,
623
            current_exit_rate=current_rate,
624
            current_entry_profit=current_profit,
625
            current_exit_profit=current_profit,
626
        )
627

628
        # Check if we should increase our position
629
        if stake_amount is not None and stake_amount > 0.0:
1✔
630
            check_adjust_entry = True
1✔
631
            if self.strategy.max_entry_position_adjustment > -1:
1✔
UNCOV
632
                entry_count = trade.nr_of_successful_entries
×
633
                check_adjust_entry = entry_count <= self.strategy.max_entry_position_adjustment
×
634
            if check_adjust_entry:
1✔
635
                pos_trade = self._enter_trade(
1✔
636
                    trade.pair,
637
                    row,
638
                    "short" if trade.is_short else "long",
639
                    stake_amount,
640
                    trade,
641
                    entry_tag1=order_tag,
642
                )
643
                if pos_trade is not None:
1✔
644
                    self.wallets.update()
1✔
645
                    return pos_trade
1✔
646

647
        if stake_amount is not None and stake_amount < 0.0:
1✔
648
            amount = amount_to_contract_precision(
1✔
649
                abs(
650
                    float(
651
                        FtPrecise(stake_amount)
652
                        * FtPrecise(trade.amount)
653
                        / FtPrecise(trade.stake_amount)
654
                    )
655
                ),
656
                trade.amount_precision,
657
                self.precision_mode,
658
                trade.contract_size,
659
            )
660
            if amount == 0.0:
1✔
UNCOV
661
                return trade
×
662
            remaining = (trade.amount - amount) * current_rate
1✔
663
            if min_stake and remaining != 0 and remaining < min_stake:
1✔
664
                # Remaining stake is too low to be sold.
665
                return trade
1✔
666
            exit_ = ExitCheckTuple(ExitType.PARTIAL_EXIT, order_tag)
1✔
667
            pos_trade = self._get_exit_for_signal(trade, row, exit_, current_time, amount)
1✔
668
            if pos_trade is not None:
1✔
669
                order = pos_trade.orders[-1]
1✔
670
                # If the order was filled and for the full trade amount, we need to close the trade.
671
                self._process_exit_order(order, pos_trade, current_time, row, trade.pair)
1✔
672
                return pos_trade
1✔
673

674
        return trade
1✔
675

676
    def _get_order_filled(self, rate: float, row: tuple) -> bool:
1✔
677
        """Rate is within candle, therefore filled"""
678
        return row[LOW_IDX] <= rate <= row[HIGH_IDX]
1✔
679

680
    def _call_adjust_stop(self, current_date: datetime, trade: LocalTrade, current_rate: float):
1✔
681
        profit = trade.calc_profit_ratio(current_rate)
1✔
682
        self.strategy.ft_stoploss_adjust(
1✔
683
            current_rate,
684
            trade,  # type: ignore
685
            current_date,
686
            profit,
687
            0,
688
            after_fill=True,
689
        )
690

691
    def _try_close_open_order(
1✔
692
        self, order: Order | None, trade: LocalTrade, current_date: datetime, row: tuple
693
    ) -> bool:
694
        """
695
        Check if an order is open and if it should've filled.
696
        :return:  True if the order filled.
697
        """
698
        if order and self._get_order_filled(order.ft_price, row):
1✔
699
            order.close_bt_order(current_date, trade)
1✔
700
            self._run_funding_fees(trade, current_date, force=True)
1✔
701
            strategy_safe_wrapper(self.strategy.order_filled, default_retval=None)(
1✔
702
                pair=trade.pair,
703
                trade=trade,  # type: ignore[arg-type]
704
                order=order,
705
                current_time=current_date,
706
            )
707

708
            if self.margin_mode == MarginMode.CROSS or not (
1✔
709
                order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount
710
            ):
711
                # trade is still open or we are in cross margin mode and
712
                # must update all liquidation prices
713
                update_liquidation_prices(
1✔
714
                    trade,
715
                    exchange=self.exchange,
716
                    wallets=self.wallets,
717
                    stake_currency=self.config["stake_currency"],
718
                    dry_run=True,
719
                )
720
            if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount):
1✔
721
                self._call_adjust_stop(current_date, trade, order.ft_price)
1✔
722
            return True
1✔
723
        return False
1✔
724

725
    def _process_exit_order(
1✔
726
        self, order: Order, trade: LocalTrade, current_time: datetime, row: tuple, pair: str
727
    ):
728
        """
729
        Takes an exit order and processes it, potentially closing the trade.
730
        """
731
        if self._try_close_open_order(order, trade, current_time, row):
1✔
732
            sub_trade = order.safe_amount_after_fee != trade.amount
1✔
733
            if sub_trade:
1✔
734
                trade.recalc_trade_from_orders()
1✔
735
            else:
736
                trade.close_date = current_time
1✔
737
                trade.close(order.ft_price, show_msg=False)
1✔
738

739
                LocalTrade.close_bt_trade(trade)
1✔
740
            self.wallets.update()
1✔
741
            self.run_protections(pair, current_time, trade.trade_direction)
1✔
742

743
    def _get_exit_for_signal(
1✔
744
        self,
745
        trade: LocalTrade,
746
        row: tuple,
747
        exit_: ExitCheckTuple,
748
        current_time: datetime,
749
        amount: float | None = None,
750
    ) -> LocalTrade | None:
751
        if exit_.exit_flag:
1✔
752
            trade.close_date = current_time
1✔
753
            exit_reason = exit_.exit_reason
1✔
754
            amount_ = amount if amount is not None else trade.amount
1✔
755
            trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
1✔
756
            try:
1✔
757
                close_rate = self._get_close_rate(row, trade, exit_, trade_dur)
1✔
758
            except ValueError:
1✔
759
                return None
1✔
760
            # call the custom exit price,with default value as previous close_rate
761
            current_profit = trade.calc_profit_ratio(close_rate)
1✔
762
            order_type = self.strategy.order_types["exit"]
1✔
763
            if exit_.exit_type in (
1✔
764
                ExitType.EXIT_SIGNAL,
765
                ExitType.CUSTOM_EXIT,
766
                ExitType.PARTIAL_EXIT,
767
            ):
768
                # Checks and adds an exit tag, after checking that the length of the
769
                # row has the length for an exit tag column
770
                if (
1✔
771
                    len(row) > EXIT_TAG_IDX
772
                    and row[EXIT_TAG_IDX] is not None
773
                    and len(row[EXIT_TAG_IDX]) > 0
774
                    and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
775
                ):
UNCOV
776
                    exit_reason = row[EXIT_TAG_IDX]
×
777
                # Custom exit pricing only for exit-signals
778
                if order_type == "limit":
1✔
779
                    rate = strategy_safe_wrapper(
1✔
780
                        self.strategy.custom_exit_price, default_retval=close_rate
781
                    )(
782
                        pair=trade.pair,
783
                        trade=trade,  # type: ignore[arg-type]
784
                        current_time=current_time,
785
                        proposed_rate=close_rate,
786
                        current_profit=current_profit,
787
                        exit_tag=exit_reason,
788
                    )
789
                    if rate is not None and rate != close_rate:
1✔
790
                        close_rate = price_to_precision(
1✔
791
                            rate, trade.price_precision, self.precision_mode_price
792
                        )
793
                    # We can't place orders lower than current low.
794
                    # freqtrade does not support this in live, and the order would fill immediately
795
                    if trade.is_short:
1✔
796
                        close_rate = min(close_rate, row[HIGH_IDX])
1✔
797
                    else:
798
                        close_rate = max(close_rate, row[LOW_IDX])
1✔
799
            # Confirm trade exit:
800
            time_in_force = self.strategy.order_time_in_force["exit"]
1✔
801

802
            if exit_.exit_type not in (
1✔
803
                ExitType.LIQUIDATION,
804
                ExitType.PARTIAL_EXIT,
805
            ) and not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
806
                pair=trade.pair,
807
                trade=trade,  # type: ignore[arg-type]
808
                order_type=order_type,
809
                amount=amount_,
810
                rate=close_rate,
811
                time_in_force=time_in_force,
812
                sell_reason=exit_reason,  # deprecated
813
                exit_reason=exit_reason,
814
                current_time=current_time,
815
            ):
UNCOV
816
                return None
×
817

818
            trade.exit_reason = exit_reason
1✔
819

820
            return self._exit_trade(trade, row, close_rate, amount_, exit_reason)
1✔
UNCOV
821
        return None
×
822

823
    def _exit_trade(
1✔
824
        self,
825
        trade: LocalTrade,
826
        sell_row: tuple,
827
        close_rate: float,
828
        amount: float,
829
        exit_reason: str | None,
830
    ) -> LocalTrade | None:
831
        self.order_id_counter += 1
1✔
832
        exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
1✔
833
        order_type = self.strategy.order_types["exit"]
1✔
834
        # amount = amount or trade.amount
835
        amount = amount_to_contract_precision(
1✔
836
            amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size
837
        )
838

839
        if self.handle_similar_order(trade, close_rate, amount, trade.exit_side, exit_candle_time):
1✔
UNCOV
840
            return None
×
841

842
        order = Order(
1✔
843
            id=self.order_id_counter,
844
            ft_trade_id=trade.id,
845
            order_date=exit_candle_time,
846
            order_update_date=exit_candle_time,
847
            ft_is_open=True,
848
            ft_pair=trade.pair,
849
            order_id=str(self.order_id_counter),
850
            symbol=trade.pair,
851
            ft_order_side=trade.exit_side,
852
            side=trade.exit_side,
853
            order_type=order_type,
854
            status="open",
855
            ft_price=close_rate,
856
            price=close_rate,
857
            average=close_rate,
858
            amount=amount,
859
            filled=0,
860
            remaining=amount,
861
            cost=amount * close_rate * (1 + self.fee),
862
            ft_order_tag=exit_reason,
863
        )
864
        order._trade_bt = trade
1✔
865
        trade.orders.append(order)
1✔
866
        return trade
1✔
867

868
    def _check_trade_exit(
1✔
869
        self, trade: LocalTrade, row: tuple, current_time: datetime
870
    ) -> LocalTrade | None:
871
        self._run_funding_fees(trade, current_time)
1✔
872

873
        # Check if we need to adjust our current positions
874
        if self.strategy.position_adjustment_enable:
1✔
875
            trade = self._check_adjust_trade_for_candle(trade, row, current_time)
1✔
876

877
        if trade.is_open:
1✔
878
            enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
879
            exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
880
            exits = self.strategy.should_exit(
1✔
881
                trade,  # type: ignore
882
                row[OPEN_IDX],
883
                row[DATE_IDX].to_pydatetime(),
884
                enter=enter,
885
                exit_=exit_sig,
886
                low=row[LOW_IDX],
887
                high=row[HIGH_IDX],
888
            )
889
            for exit_ in exits:
1✔
890
                t = self._get_exit_for_signal(trade, row, exit_, current_time)
1✔
891
                if t:
1✔
892
                    return t
1✔
893
        return None
1✔
894

895
    def _run_funding_fees(self, trade: LocalTrade, current_time: datetime, force: bool = False):
1✔
896
        """
897
        Calculate funding fees if necessary and add them to the trade.
898
        """
899
        if self.trading_mode == TradingMode.FUTURES:
1✔
900
            if force or (current_time.timestamp() % self.funding_fee_timeframe_secs) == 0:
1✔
901
                # Funding fee interval.
902
                trade.set_funding_fees(
1✔
903
                    self.exchange.calculate_funding_fees(
904
                        self.futures_data[trade.pair],
905
                        amount=trade.amount,
906
                        is_short=trade.is_short,
907
                        open_date=trade.date_last_filled_utc,
908
                        close_date=current_time,
909
                    )
910
                )
911

912
    def get_valid_price_and_stake(
1✔
913
        self,
914
        pair: str,
915
        row: tuple,
916
        propose_rate: float,
917
        stake_amount: float,
918
        direction: LongShort,
919
        current_time: datetime,
920
        entry_tag: str | None,
921
        trade: LocalTrade | None,
922
        order_type: str,
923
        price_precision: float | None,
924
    ) -> tuple[float, float, float, float]:
925
        if order_type == "limit":
1✔
926
            new_rate = strategy_safe_wrapper(
1✔
927
                self.strategy.custom_entry_price, default_retval=propose_rate
928
            )(
929
                pair=pair,
930
                trade=trade,  # type: ignore[arg-type]
931
                current_time=current_time,
932
                proposed_rate=propose_rate,
933
                entry_tag=entry_tag,
934
                side=direction,
935
            )  # default value is the open rate
936
            # We can't place orders higher than current high (otherwise it'd be a stop limit entry)
937
            # which freqtrade does not support in live.
938
            if new_rate is not None and new_rate != propose_rate:
1✔
939
                propose_rate = price_to_precision(
1✔
940
                    new_rate, price_precision, self.precision_mode_price
941
                )
942
            if direction == "short":
1✔
943
                propose_rate = max(propose_rate, row[LOW_IDX])
1✔
944
            else:
945
                propose_rate = min(propose_rate, row[HIGH_IDX])
1✔
946

947
        pos_adjust = trade is not None
1✔
948
        leverage = trade.leverage if trade else 1.0
1✔
949
        if not pos_adjust:
1✔
950
            try:
1✔
951
                stake_amount = self.wallets.get_trade_stake_amount(
1✔
952
                    pair, self.strategy.max_open_trades, update=False
953
                )
954
            except DependencyException:
1✔
955
                return 0, 0, 0, 0
1✔
956

957
            max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
1✔
958
            leverage = (
1✔
959
                strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
960
                    pair=pair,
961
                    current_time=current_time,
962
                    current_rate=row[OPEN_IDX],
963
                    proposed_leverage=1.0,
964
                    max_leverage=max_leverage,
965
                    side=direction,
966
                    entry_tag=entry_tag,
967
                )
968
                if self.trading_mode != TradingMode.SPOT
969
                else 1.0
970
            )
971
            # Cap leverage between 1.0 and max_leverage.
972
            leverage = min(max(leverage, 1.0), max_leverage)
1✔
973

974
        min_stake_amount = (
1✔
975
            self.exchange.get_min_pair_stake_amount(
976
                pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage
977
            )
978
            or 0
979
        )
980
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
981
            pair, propose_rate, leverage=leverage
982
        )
983
        stake_available = self.wallets.get_available_stake_amount()
1✔
984

985
        if not pos_adjust:
1✔
986
            stake_amount = strategy_safe_wrapper(
1✔
987
                self.strategy.custom_stake_amount, default_retval=stake_amount
988
            )(
989
                pair=pair,
990
                current_time=current_time,
991
                current_rate=propose_rate,
992
                proposed_stake=stake_amount,
993
                min_stake=min_stake_amount,
994
                max_stake=min(stake_available, max_stake_amount),
995
                leverage=leverage,
996
                entry_tag=entry_tag,
997
                side=direction,
998
            )
999

1000
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
1001
            pair=pair,
1002
            stake_amount=stake_amount,
1003
            min_stake_amount=min_stake_amount,
1004
            max_stake_amount=max_stake_amount,
1005
            trade_amount=trade.stake_amount if trade else None,
1006
        )
1007

1008
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
1009

1010
    def _enter_trade(
1✔
1011
        self,
1012
        pair: str,
1013
        row: tuple,
1014
        direction: LongShort,
1015
        stake_amount: float | None = None,
1016
        trade: LocalTrade | None = None,
1017
        requested_rate: float | None = None,
1018
        requested_stake: float | None = None,
1019
        entry_tag1: str | None = None,
1020
    ) -> LocalTrade | None:
1021
        """
1022
        :param trade: Trade to adjust - initial entry if None
1023
        :param requested_rate: Adjusted entry rate
1024
        :param requested_stake: Stake amount for adjusted orders (`adjust_entry_price`).
1025
        """
1026

1027
        current_time = row[DATE_IDX].to_pydatetime()
1✔
1028
        entry_tag = entry_tag1 or (row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None)
1✔
1029
        # let's call the custom entry price, using the open price as default price
1030
        order_type = self.strategy.order_types["entry"]
1✔
1031
        pos_adjust = trade is not None and requested_rate is None
1✔
1032

1033
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
1034
        precision_price = self.exchange.get_precision_price(pair)
1✔
1035

1036
        propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
1✔
1037
            pair,
1038
            row,
1039
            row[OPEN_IDX],
1040
            stake_amount_,
1041
            direction,
1042
            current_time,
1043
            entry_tag,
1044
            trade,
1045
            order_type,
1046
            precision_price,
1047
        )
1048

1049
        # replace proposed rate if another rate was requested
1050
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
1051
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
1052

1053
        if not stake_amount:
1✔
1054
            # In case of pos adjust, still return the original trade
1055
            # If not pos adjust, trade is None
1056
            return trade
1✔
1057
        time_in_force = self.strategy.order_time_in_force["entry"]
1✔
1058

1059
        if stake_amount and (not min_stake_amount or stake_amount >= min_stake_amount):
1✔
1060
            self.order_id_counter += 1
1✔
1061
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
1062
            amount_p = (stake_amount / propose_rate) * leverage
1✔
1063

1064
            contract_size = self.exchange.get_contract_size(pair)
1✔
1065
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
1066
            amount = amount_to_contract_precision(
1✔
1067
                amount_p, precision_amount, self.precision_mode, contract_size
1068
            )
1069
            if not amount:
1✔
1070
                # No amount left after truncating to precision.
UNCOV
1071
                return trade
×
1072
            # Backcalculate actual stake amount.
1073
            stake_amount = amount * propose_rate / leverage
1✔
1074

1075
            if not pos_adjust:
1✔
1076
                # Confirm trade entry:
1077
                if not strategy_safe_wrapper(
1✔
1078
                    self.strategy.confirm_trade_entry, default_retval=True
1079
                )(
1080
                    pair=pair,
1081
                    order_type=order_type,
1082
                    amount=amount,
1083
                    rate=propose_rate,
1084
                    time_in_force=time_in_force,
1085
                    current_time=current_time,
1086
                    entry_tag=entry_tag,
1087
                    side=direction,
1088
                ):
1089
                    return trade
1✔
1090

1091
            is_short = direction == "short"
1✔
1092
            # Necessary for Margin trading. Disabled until support is enabled.
1093
            # interest_rate = self.exchange.get_interest_rate()
1094

1095
            if trade is None:
1✔
1096
                # Enter trade
1097
                self.trade_id_counter += 1
1✔
1098
                trade = LocalTrade(
1✔
1099
                    id=self.trade_id_counter,
1100
                    pair=pair,
1101
                    base_currency=base_currency,
1102
                    stake_currency=self.config["stake_currency"],
1103
                    open_rate=propose_rate,
1104
                    open_rate_requested=propose_rate,
1105
                    open_date=current_time,
1106
                    stake_amount=stake_amount,
1107
                    amount=0,
1108
                    amount_requested=amount,
1109
                    fee_open=self.fee,
1110
                    fee_close=self.fee,
1111
                    is_open=True,
1112
                    enter_tag=entry_tag,
1113
                    timeframe=self.timeframe_min,
1114
                    exchange=self._exchange_name,
1115
                    is_short=is_short,
1116
                    trading_mode=self.trading_mode,
1117
                    leverage=leverage,
1118
                    # interest_rate=interest_rate,
1119
                    amount_precision=precision_amount,
1120
                    price_precision=precision_price,
1121
                    precision_mode=self.precision_mode,
1122
                    precision_mode_price=self.precision_mode_price,
1123
                    contract_size=contract_size,
1124
                    orders=[],
1125
                )
1126
                LocalTrade.add_bt_trade(trade)
1✔
1127
            elif self.handle_similar_order(
1✔
1128
                trade, propose_rate, amount, trade.entry_side, current_time
1129
            ):
UNCOV
1130
                return None
×
1131

1132
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
1133

1134
            order = Order(
1✔
1135
                id=self.order_id_counter,
1136
                ft_trade_id=trade.id,
1137
                ft_is_open=True,
1138
                ft_pair=trade.pair,
1139
                order_id=str(self.order_id_counter),
1140
                symbol=trade.pair,
1141
                ft_order_side=trade.entry_side,
1142
                side=trade.entry_side,
1143
                order_type=order_type,
1144
                status="open",
1145
                order_date=current_time,
1146
                order_filled_date=current_time,
1147
                order_update_date=current_time,
1148
                ft_price=propose_rate,
1149
                price=propose_rate,
1150
                average=propose_rate,
1151
                amount=amount,
1152
                filled=0,
1153
                remaining=amount,
1154
                cost=amount * propose_rate * (1 + self.fee),
1155
                ft_order_tag=entry_tag,
1156
            )
1157
            order._trade_bt = trade
1✔
1158
            trade.orders.append(order)
1✔
1159
            self._try_close_open_order(order, trade, current_time, row)
1✔
1160
            trade.recalc_trade_from_orders()
1✔
1161

1162
        return trade
1✔
1163

1164
    def handle_left_open(
1✔
1165
        self, open_trades: dict[str, list[LocalTrade]], data: dict[str, list[tuple]]
1166
    ) -> None:
1167
        """
1168
        Handling of left open trades at the end of backtesting
1169
        """
1170
        for pair in open_trades.keys():
1✔
1171
            for trade in list(open_trades[pair]):
1✔
1172
                if (
1✔
1173
                    trade.has_open_orders and trade.nr_of_successful_entries == 0
1174
                ) or not trade.has_open_position:
1175
                    # Ignore trade if entry-order did not fill yet
1176
                    LocalTrade.remove_bt_trade(trade)
1✔
1177
                    continue
1✔
1178

1179
                exit_row = data[pair][-1]
1✔
1180
                self._exit_trade(
1✔
1181
                    trade, exit_row, exit_row[OPEN_IDX], trade.amount, ExitType.FORCE_EXIT.value
1182
                )
1183
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
1184
                self._process_exit_order(
1✔
1185
                    trade.orders[-1], trade, exit_row[DATE_IDX].to_pydatetime(), exit_row, pair
1186
                )
1187

1188
    def trade_slot_available(self, open_trade_count: int) -> bool:
1✔
1189
        # Always allow trades when max_open_trades is enabled.
1190
        max_open_trades: IntOrInf = self.strategy.max_open_trades
1✔
1191
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
1192
            return True
1✔
1193
        # Rejected trade
1194
        self.rejected_trades += 1
1✔
1195
        return False
1✔
1196

1197
    def check_for_trade_entry(self, row) -> LongShort | None:
1✔
1198
        enter_long = row[LONG_IDX] == 1
1✔
1199
        exit_long = row[ELONG_IDX] == 1
1✔
1200
        enter_short = self._can_short and row[SHORT_IDX] == 1
1✔
1201
        exit_short = self._can_short and row[ESHORT_IDX] == 1
1✔
1202

1203
        if enter_long == 1 and not any([exit_long, enter_short]):
1✔
1204
            # Long
1205
            return "long"
1✔
1206
        if enter_short == 1 and not any([exit_short, enter_long]):
1✔
1207
            # Short
1208
            return "short"
1✔
1209
        return None
1✔
1210

1211
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
1212
        if self.enable_protections:
1✔
1213
            self.protections.stop_per_pair(pair, current_time, side)
1✔
1214
            self.protections.global_stop(current_time, side)
1✔
1215

1216
    def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tuple) -> bool:
1✔
1217
        """
1218
        Check if any open order needs to be cancelled or replaced.
1219
        Returns True if the trade should be deleted.
1220
        """
1221
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
1222
            oc = self.check_order_cancel(trade, order, current_time)
1✔
1223
            if oc:
1✔
1224
                # delete trade due to order timeout
1225
                return True
1✔
1226
            elif oc is None and self.check_order_replace(trade, order, current_time, row):
1✔
1227
                # delete trade due to user request
UNCOV
1228
                self.canceled_trade_entries += 1
×
1229
                return True
×
1230
        # default maintain trade
1231
        return False
1✔
1232

1233
    def cancel_open_orders(self, trade: LocalTrade, current_time: datetime):
1✔
1234
        """
1235
        Cancel all open orders for the given trade.
1236
        """
1237
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
1238
            if order.side == trade.entry_side:
1✔
1239
                self.canceled_entry_orders += 1
1✔
1240
            elif order.side == trade.exit_side:
1✔
1241
                self.canceled_exit_orders += 1
1✔
1242
            # canceled orders are removed from the trade
1243
            del trade.orders[trade.orders.index(order)]
1✔
1244

1245
    def handle_similar_order(
1✔
1246
        self, trade: LocalTrade, price: float, amount: float, side: str, current_time: datetime
1247
    ) -> bool:
1248
        """
1249
        Handle similar order for the given trade.
1250
        """
1251
        if trade.has_open_orders:
1✔
1252
            oo = trade.select_order(side, True)
1✔
1253
            if oo:
1✔
1254
                if (price == oo.price) and (side == oo.side) and (amount == oo.amount):
1✔
1255
                    # logger.info(
1256
                    #     f"A similar open order was found for {trade.pair}. "
1257
                    #     f"Keeping existing {trade.exit_side} order. {price=},  {amount=}"
1258
                    # )
UNCOV
1259
                    return True
×
1260
            self.cancel_open_orders(trade, current_time)
1✔
1261

1262
        return False
1✔
1263

1264
    def check_order_cancel(
1✔
1265
        self, trade: LocalTrade, order: Order, current_time: datetime
1266
    ) -> bool | None:
1267
        """
1268
        Check if current analyzed order has to be canceled.
1269
        Returns True if the trade should be Deleted (initial order was canceled),
1270
                False if it's Canceled
1271
                None if the order is still active.
1272
        """
1273
        timedout = self.strategy.ft_check_timed_out(
1✔
1274
            trade,  # type: ignore[arg-type]
1275
            order,
1276
            current_time,
1277
        )
1278
        if timedout:
1✔
1279
            if order.side == trade.entry_side:
1✔
1280
                self.timedout_entry_orders += 1
1✔
1281
                if trade.nr_of_successful_entries == 0:
1✔
1282
                    # Remove trade due to entry timeout expiration.
1283
                    return True
1✔
1284
                else:
1285
                    # Close additional entry order
UNCOV
1286
                    del trade.orders[trade.orders.index(order)]
×
1287
                    return False
×
1288
            if order.side == trade.exit_side:
1✔
1289
                self.timedout_exit_orders += 1
1✔
1290
                # Close exit order and retry exiting on next signal.
1291
                del trade.orders[trade.orders.index(order)]
1✔
1292
                return False
1✔
1293
        return None
1✔
1294

1295
    def check_order_replace(
1✔
1296
        self, trade: LocalTrade, order: Order, current_time, row: tuple
1297
    ) -> bool:
1298
        """
1299
        Check if current analyzed entry order has to be replaced and do so.
1300
        If user requested cancellation and there are no filled orders in the trade will
1301
        instruct caller to delete the trade.
1302
        Returns True if the trade should be deleted.
1303
        """
1304
        # only check on new candles for open entry orders
1305
        if current_time > order.order_date_utc:
1✔
1306
            is_entry = order.side == trade.entry_side
1✔
1307
            requested_rate = strategy_safe_wrapper(
1✔
1308
                self.strategy.adjust_order_price, default_retval=order.ft_price
1309
            )(
1310
                trade=trade,  # type: ignore[arg-type]
1311
                order=order,
1312
                pair=trade.pair,
1313
                current_time=current_time,
1314
                proposed_rate=row[OPEN_IDX],
1315
                current_order_rate=order.ft_price,
1316
                entry_tag=trade.enter_tag,
1317
                side=trade.trade_direction,
1318
                is_entry=is_entry,
1319
            )  # default value is current order price
1320

1321
            # cancel existing order whenever a new rate is requested (or None)
1322
            if requested_rate == order.ft_price:
1✔
1323
                # assumption: there can't be multiple open entry orders at any given time
1324
                return False
1✔
1325
            else:
1326
                del trade.orders[trade.orders.index(order)]
1✔
1327
                if is_entry:
1✔
1328
                    self.canceled_entry_orders += 1
1✔
1329
                else:
1330
                    self.canceled_exit_orders += 1
1✔
1331

1332
            # place new order if result was not None
1333
            if requested_rate:
1✔
1334
                if is_entry:
1✔
1335
                    self._enter_trade(
1✔
1336
                        pair=trade.pair,
1337
                        row=row,
1338
                        trade=trade,
1339
                        requested_rate=requested_rate,
1340
                        requested_stake=(order.safe_remaining * order.ft_price / trade.leverage),
1341
                        direction="short" if trade.is_short else "long",
1342
                    )
1343
                    self.replaced_entry_orders += 1
1✔
1344
                else:
1345
                    self._exit_trade(
1✔
1346
                        trade=trade,
1347
                        sell_row=row,
1348
                        close_rate=requested_rate,
1349
                        amount=order.safe_remaining,
1350
                        exit_reason=order.ft_order_tag,
1351
                    )
1352
                    self.replaced_exit_orders += 1
1✔
1353
                # Delete trade if no successful entries happened (if placing the new order failed)
1354
                if not trade.has_open_orders and is_entry and trade.nr_of_successful_entries == 0:
1✔
UNCOV
1355
                    return True
×
1356
            else:
1357
                # assumption: there can't be multiple open entry orders at any given time
UNCOV
1358
                return trade.nr_of_successful_entries == 0
×
1359
        return False
1✔
1360

1361
    def validate_row(
1✔
1362
        self, data: dict, pair: str, row_index: int, current_time: datetime
1363
    ) -> tuple | None:
1364
        try:
1✔
1365
            # Row is treated as "current incomplete candle".
1366
            # entry / exit signals are shifted by 1 to compensate for this.
1367
            row = data[pair][row_index]
1✔
1368
        except IndexError:
1✔
1369
            # missing Data for one pair at the end.
1370
            # Warnings for this are shown during data loading
1371
            return None
1✔
1372

1373
        # Waits until the time-counter reaches the start of the data for this pair.
1374
        if row[DATE_IDX] > current_time:
1✔
1375
            return None
1✔
1376
        return row
1✔
1377

1378
    def _collate_rejected(self, pair, row):
1✔
1379
        """
1380
        Temporarily store rejected signal information for downstream use in backtesting_analysis
1381
        """
1382
        # It could be fun to enable hyperopt mode to write
1383
        # a loss function to reduce rejected signals
1384
        if (
1✔
1385
            self.config.get("export", "none") == "signals"
1386
            and self.dataprovider.runmode == RunMode.BACKTEST
1387
        ):
UNCOV
1388
            if pair not in self.rejected_dict:
×
1389
                self.rejected_dict[pair] = []
×
1390
            self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]])
×
1391

1392
    def backtest_loop(
1✔
1393
        self,
1394
        row: tuple,
1395
        pair: str,
1396
        current_time: datetime,
1397
        trade_dir: LongShort | None,
1398
        can_enter: bool,
1399
    ) -> LongShort | None:
1400
        """
1401
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1402

1403
        Backtesting processing for one candle/pair.
1404
        """
1405
        exiting_dir: LongShort | None = None
1✔
1406
        if not self._position_stacking and len(LocalTrade.bt_trades_open_pp[pair]) > 0:
1✔
1407
            # position_stacking not supported for now.
1408
            exiting_dir = "short" if LocalTrade.bt_trades_open_pp[pair][0].is_short else "long"
1✔
1409

1410
        for t in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1411
            # 1. Manage currently open orders of active trades
1412
            if self.manage_open_orders(t, current_time, row):
1✔
1413
                # Remove trade (initial open order never filled)
1414
                LocalTrade.remove_bt_trade(t)
1✔
1415
                self.wallets.update()
1✔
1416

1417
        # 2. Process entries.
1418
        # without positionstacking, we can only have one open trade per pair.
1419
        # max_open_trades must be respected
1420
        # don't open on the last row
1421
        # We only open trades on the main candle, not on detail candles
1422
        if (
1✔
1423
            can_enter
1424
            and trade_dir is not None
1425
            and (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
1426
            and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
1427
        ):
1428
            if self.trade_slot_available(LocalTrade.bt_open_open_trade_count):
1✔
1429
                trade = self._enter_trade(pair, row, trade_dir)
1✔
1430
                if trade:
1✔
1431
                    self.wallets.update()
1✔
1432
            else:
1433
                self._collate_rejected(pair, row)
1✔
1434

1435
        for trade in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1436
            # 3. Process entry orders.
1437
            order = trade.select_order(trade.entry_side, is_open=True)
1✔
1438
            if self._try_close_open_order(order, trade, current_time, row):
1✔
1439
                self.wallets.update()
1✔
1440

1441
            # 4. Create exit orders (if any)
1442
            if trade.has_open_position:
1✔
1443
                self._check_trade_exit(trade, row, current_time)  # Place exit order if necessary
1✔
1444

1445
            # 5. Process exit orders.
1446
            order = trade.select_order(trade.exit_side, is_open=True)
1✔
1447
            if order:
1✔
1448
                self._process_exit_order(order, trade, current_time, row, pair)
1✔
1449

1450
        if exiting_dir and len(LocalTrade.bt_trades_open_pp[pair]) == 0:
1✔
1451
            return exiting_dir
1✔
1452
        return None
1✔
1453

1454
    def get_detail_data(self, pair: str, row: tuple) -> list[tuple] | None:
1✔
1455
        """
1456
        Spread into detail data
1457
        """
1458
        current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1459
        exit_candle_end = current_detail_time + self.timeframe_td
1✔
1460
        detail_data = self.detail_data[pair]
1✔
1461
        detail_data = detail_data.loc[
1✔
1462
            (detail_data["date"] >= current_detail_time) & (detail_data["date"] < exit_candle_end)
1463
        ].copy()
1464

1465
        if len(detail_data) == 0:
1✔
UNCOV
1466
            return None
×
1467
        detail_data.loc[:, "enter_long"] = row[LONG_IDX]
1✔
1468
        detail_data.loc[:, "exit_long"] = row[ELONG_IDX]
1✔
1469
        detail_data.loc[:, "enter_short"] = row[SHORT_IDX]
1✔
1470
        detail_data.loc[:, "exit_short"] = row[ESHORT_IDX]
1✔
1471
        detail_data.loc[:, "enter_tag"] = row[ENTER_TAG_IDX]
1✔
1472
        detail_data.loc[:, "exit_tag"] = row[EXIT_TAG_IDX]
1✔
1473
        return detail_data[HEADERS].values.tolist()
1✔
1474

1475
    def _time_generator(self, start_date: datetime, end_date: datetime):
1✔
1476
        current_time = start_date + self.timeframe_td
1✔
1477
        while current_time <= end_date:
1✔
1478
            yield current_time
1✔
1479
            current_time += self.timeframe_td
1✔
1480

1481
    def _time_generator_det(self, start_date: datetime, end_date: datetime):
1✔
1482
        """
1483
        Loop for each detail candle.
1484
        Yields only the start date if no detail timeframe is set.
1485
        """
1486
        if not self.timeframe_detail_td:
1✔
1487
            yield start_date, True, False, 0
1✔
1488
            return
1✔
1489

1490
        current_time = start_date
1✔
1491
        i = 0
1✔
1492
        while current_time <= end_date:
1✔
1493
            yield current_time, i == 0, True, i
1✔
1494
            i += 1
1✔
1495
            current_time += self.timeframe_detail_td
1✔
1496

1497
    def _time_pair_generator_det(self, current_time: datetime, pairs: list[str]):
1✔
1498
        for current_time_det, is_first, has_detail, idx in self._time_generator_det(
1✔
1499
            current_time, current_time + self.timeframe_td
1500
        ):
1501
            # Pairs that have open trades should be processed first
1502
            new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs))
1✔
1503
            for pair in new_pairlist:
1✔
1504
                yield current_time_det, is_first, has_detail, idx, pair
1✔
1505

1506
    def time_pair_generator(
1✔
1507
        self,
1508
        start_date: datetime,
1509
        end_date: datetime,
1510
        pairs: list[str],
1511
        data: dict[str, list[tuple]],
1512
    ):
1513
        """
1514
        Backtest time and pair generator
1515
        :returns: generator of (current_time, pair, row, is_last_row, trade_dir)
1516
            where is_last_row is a boolean indicating if this is the data end date.
1517
        """
1518
        current_time = start_date + self.timeframe_td
1✔
1519
        self.progress.init_step(
1✔
1520
            BacktestState.BACKTEST, int((end_date - start_date) / self.timeframe_td)
1521
        )
1522
        # Indexes per pair, so some pairs are allowed to have a missing start.
1523
        indexes: dict = defaultdict(int)
1✔
1524

1525
        for current_time in self._time_generator(start_date, end_date):
1✔
1526
            # Loop for each main candle.
1527
            self.check_abort()
1✔
1528
            # Reset open trade count for this candle
1529
            # Critical to avoid exceeding max_open_trades in backtesting
1530
            # when timeframe-detail is used and trades close within the opening candle.
1531
            strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)(
1✔
1532
                current_time=current_time
1533
            )
1534
            pair_detail_cache: dict[str, list[tuple]] = {}
1✔
1535
            pair_tradedir_cache: dict[str, LongShort | None] = {}
1✔
1536
            pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open]
1✔
1537

1538
            for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det(
1✔
1539
                current_time, pairs
1540
            ):
1541
                # Loop for each detail candle (if necessary) and pair
1542
                # Yields only the main date if no detail timeframe is set.
1543

1544
                # Pairs that have open trades should be processed first
1545
                trade_dir: LongShort | None = None
1✔
1546
                if is_first:
1✔
1547
                    # Main candle
1548
                    row_index = indexes[pair]
1✔
1549
                    row = self.validate_row(data, pair, row_index, current_time)
1✔
1550
                    if not row:
1✔
1551
                        continue
1✔
1552

1553
                    row_index += 1
1✔
1554
                    indexes[pair] = row_index
1✔
1555
                    is_last_row = current_time == end_date
1✔
1556
                    self.dataprovider._set_dataframe_max_index(
1✔
1557
                        pair, self.required_startup + row_index
1558
                    )
1559
                    trade_dir = self.check_for_trade_entry(row)
1✔
1560
                    pair_tradedir_cache[pair] = trade_dir
1✔
1561

1562
                else:
1563
                    # Detail candle - from cache.
1564
                    detail_data = pair_detail_cache.get(pair)
1✔
1565
                    if detail_data is None or len(detail_data) <= idx:
1✔
1566
                        # logger.info(f"skipping {pair}, {current_time_det}, {trade_dir}")
1567
                        continue
1✔
1568
                    row = detail_data[idx]
1✔
1569
                    trade_dir = pair_tradedir_cache.get(pair)
1✔
1570

1571
                    if self.strategy.ignore_expired_candle(
1✔
1572
                        current_time - self.timeframe_td,  # last closed candle is 1 timeframe away.
1573
                        current_time_det,
1574
                        self.timeframe_secs,
1575
                        trade_dir is not None,
1576
                    ):
1577
                        # Ignore late entries eventually
1578
                        trade_dir = None
1✔
1579

1580
                self.dataprovider._set_dataframe_max_date(current_time_det)
1✔
1581

1582
                pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0
1✔
1583
                if pair in pairs_with_open_trades and not pair_has_open_trades:
1✔
1584
                    # Pair has had open trades which closed in the current main candle.
1585
                    # Skip this pair for this timeframe
1586
                    continue
1✔
1587
                if pair_has_open_trades and pair not in pairs_with_open_trades:
1✔
1588
                    # auto-lock for pairs that have open trades
1589
                    # Necessary for detail - to capture trades that open and close within
1590
                    # the same main candle
1591
                    pairs_with_open_trades.append(pair)
1✔
1592

1593
                if (
1✔
1594
                    is_first
1595
                    and (trade_dir is not None or pair_has_open_trades)
1596
                    and has_detail
1597
                    and pair not in pair_detail_cache
1598
                    and pair in self.detail_data
1599
                    and row
1600
                ):
1601
                    # Spread candle into detail timeframe and cache that -
1602
                    # only once per main candle
1603
                    # and only if we can expect activity.
1604
                    pair_detail = self.get_detail_data(pair, row)
1✔
1605
                    if pair_detail is not None:
1✔
1606
                        pair_detail_cache[pair] = pair_detail
1✔
1607
                    row = pair_detail_cache[pair][idx]
1✔
1608

1609
                is_last_row = current_time_det == end_date
1✔
1610

1611
                yield current_time_det, pair, row, is_last_row, trade_dir
1✔
1612
            self.progress.increment()
1✔
1613

1614
    def backtest(self, processed: dict, start_date: datetime, end_date: datetime) -> dict[str, Any]:
1✔
1615
        """
1616
        Implement backtesting functionality
1617

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

1622
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
1623
        optimize memory usage!
1624
        :param start_date: backtesting timerange start datetime
1625
        :param end_date: backtesting timerange end datetime
1626
        :return: DataFrame with trades (results of backtesting)
1627
        """
1628
        self.prepare_backtest(self.enable_protections)
1✔
1629
        # Ensure wallets are up-to-date (important for --strategy-list)
1630
        self.wallets.update()
1✔
1631
        # Use dict of lists with data for performance
1632
        # (looping lists is a lot faster than pandas DataFrames)
1633
        data: dict = self._get_ohlcv_as_lists(processed)
1✔
1634

1635
        # Loop timerange and get candle for each pair at that point in time
1636
        for (
1✔
1637
            current_time,
1638
            pair,
1639
            row,
1640
            is_last_row,
1641
            trade_dir,
1642
        ) in self.time_pair_generator(start_date, end_date, list(data.keys()), data):
1643
            if not self._can_short or trade_dir is None:
1✔
1644
                # No need to reverse position if shorting is disabled or there's no new signal
1645
                self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
1✔
1646
            else:
1647
                # Conditionally call backtest_loop a 2nd time if shorting is enabled,
1648
                # a position closed and a new signal in the other direction is available.
1649

1650
                for _ in (0, 1):
1✔
1651
                    a = self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
1✔
1652
                    if not a or a == trade_dir:
1✔
1653
                        # the trade didn't close or position change is in the same direction
1654
                        break
1✔
1655

1656
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1657
        self.wallets.update()
1✔
1658

1659
        results = trade_list_to_dataframe(LocalTrade.bt_trades)
1✔
1660
        return {
1✔
1661
            "results": results,
1662
            "config": self.strategy.config,
1663
            "locks": PairLocks.get_all_locks(),
1664
            "rejected_signals": self.rejected_trades,
1665
            "timedout_entry_orders": self.timedout_entry_orders,
1666
            "timedout_exit_orders": self.timedout_exit_orders,
1667
            "canceled_trade_entries": self.canceled_trade_entries,
1668
            "canceled_entry_orders": self.canceled_entry_orders,
1669
            "replaced_entry_orders": self.replaced_entry_orders,
1670
            "final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
1671
        }
1672

1673
    def backtest_one_strategy(
1✔
1674
        self, strat: IStrategy, data: dict[str, DataFrame], timerange: TimeRange
1675
    ):
1676
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1677
        strategy_name = strat.get_strategy_name()
1✔
1678
        logger.info(f"Running backtesting for Strategy {strategy_name}")
1✔
1679
        backtest_start_time = dt_now()
1✔
1680
        self._set_strategy(strat)
1✔
1681

1682
        # need to reprocess data every time to populate signals
1683
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1684

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

1689
        if not preprocessed_tmp:
1✔
UNCOV
1690
            raise OperationalException("No data left after adjusting for startup candles.")
×
1691

1692
        # Use preprocessed_tmp for date generation (the trimmed dataframe).
1693
        # Backtesting will re-trim the dataframes after entry/exit signal generation.
1694
        min_date, max_date = history.get_timerange(preprocessed_tmp)
1✔
1695
        logger.info(
1✔
1696
            f"Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} "
1697
            f"up to {max_date.strftime(DATETIME_PRINT_FORMAT)} "
1698
            f"({(max_date - min_date).days} days)."
1699
        )
1700
        # Execute backtest and store results
1701
        results = self.backtest(
1✔
1702
            processed=preprocessed,
1703
            start_date=min_date,
1704
            end_date=max_date,
1705
        )
1706
        backtest_end_time = dt_now()
1✔
1707
        results.update(
1✔
1708
            {
1709
                "run_id": self.run_ids.get(strategy_name, ""),
1710
                "backtest_start_time": int(backtest_start_time.timestamp()),
1711
                "backtest_end_time": int(backtest_end_time.timestamp()),
1712
            }
1713
        )
1714
        self.all_results[strategy_name] = results
1✔
1715

1716
        if (
1✔
1717
            self.config.get("export", "none") == "signals"
1718
            and self.dataprovider.runmode == RunMode.BACKTEST
1719
        ):
1720
            signals = generate_trade_signal_candles(preprocessed_tmp, results, "open_date")
1✔
1721
            rejected = generate_rejected_signals(preprocessed_tmp, self.rejected_dict)
1✔
1722
            exited = generate_trade_signal_candles(preprocessed_tmp, results, "close_date")
1✔
1723

1724
            self.analysis_results["signals"][strategy_name] = signals
1✔
1725
            self.analysis_results["rejected"][strategy_name] = rejected
1✔
1726
            self.analysis_results["exited"][strategy_name] = exited
1✔
1727

1728
        return min_date, max_date
1✔
1729

1730
    def _get_min_cached_backtest_date(self):
1✔
1731
        min_backtest_date = None
1✔
1732
        backtest_cache_age = self.config.get("backtest_cache", constants.BACKTEST_CACHE_DEFAULT)
1✔
1733
        if self.timerange.stopts == 0 or self.timerange.stopdt > dt_now():
1✔
1734
            logger.warning("Backtest result caching disabled due to use of open-ended timerange.")
1✔
1735
        elif backtest_cache_age == "day":
1✔
1736
            min_backtest_date = dt_now() - timedelta(days=1)
1✔
1737
        elif backtest_cache_age == "week":
1✔
1738
            min_backtest_date = dt_now() - timedelta(weeks=1)
1✔
1739
        elif backtest_cache_age == "month":
1✔
1740
            min_backtest_date = dt_now() - timedelta(weeks=4)
1✔
1741
        return min_backtest_date
1✔
1742

1743
    def load_prior_backtest(self):
1✔
1744
        self.run_ids = {
1✔
1745
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1746
            for strategy in self.strategylist
1747
        }
1748

1749
        # Load previous result that will be updated incrementally.
1750
        # This can be circumvented in certain instances in combination with downloading more data
1751
        min_backtest_date = self._get_min_cached_backtest_date()
1✔
1752
        if min_backtest_date is not None:
1✔
1753
            self.results = find_existing_backtest_stats(
1✔
1754
                self.config["user_data_dir"] / "backtest_results", self.run_ids, min_backtest_date
1755
            )
1756

1757
    def start(self) -> None:
1✔
1758
        """
1759
        Run backtesting end-to-end
1760
        """
1761
        data: dict[str, DataFrame] = {}
1✔
1762

1763
        data, timerange = self.load_bt_data()
1✔
1764
        self.load_bt_data_detail()
1✔
1765
        logger.info("Dataload complete. Calculating indicators")
1✔
1766

1767
        self.load_prior_backtest()
1✔
1768

1769
        for strat in self.strategylist:
1✔
1770
            if self.results and strat.get_strategy_name() in self.results["strategy"]:
1✔
1771
                # When previous result hash matches - reuse that result and skip backtesting.
1772
                logger.info(f"Reusing result of previous backtest for {strat.get_strategy_name()}")
1✔
1773
                continue
1✔
1774
            min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
1✔
1775

1776
        # Update old results with new ones.
1777
        if len(self.all_results) > 0:
1✔
1778
            results = generate_backtest_stats(
1✔
1779
                data, self.all_results, min_date=min_date, max_date=max_date
1780
            )
1781
            if self.results:
1✔
1782
                self.results["metadata"].update(results["metadata"])
1✔
1783
                self.results["strategy"].update(results["strategy"])
1✔
1784
                self.results["strategy_comparison"].extend(results["strategy_comparison"])
1✔
1785
            else:
UNCOV
1786
                self.results = results
×
1787
            dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1✔
1788
            if self.config.get("export", "none") in ("trades", "signals"):
1✔
1789
                combined_res = combined_dataframes_with_rel_mean(data, min_date, max_date)
1✔
1790
                store_backtest_results(
1✔
1791
                    self.config,
1792
                    self.results,
1793
                    dt_appendix,
1794
                    market_change_data=combined_res,
1795
                    analysis_results=self.analysis_results,
1796
                    strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
1797
                )
1798

1799
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1800
        if "strategy_list" in self.config and len(self.results) > 0:
1✔
1801
            self.results["strategy_comparison"] = sorted(
1✔
1802
                self.results["strategy_comparison"],
1803
                key=lambda c: self.config["strategy_list"].index(c["key"]),
1804
            )
1805
            self.results["strategy"] = dict(
1✔
1806
                sorted(
1807
                    self.results["strategy"].items(),
1808
                    key=lambda kv: self.config["strategy_list"].index(kv[0]),
1809
                )
1810
            )
1811

1812
        if len(self.strategylist) > 0:
1✔
1813
            # Show backtest results
1814
            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