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

freqtrade / freqtrade / 14507242113

02 Dec 2024 07:11PM UTC coverage: 94.422% (+0.05%) from 94.377%
14507242113

push

github

web-flow
Merge pull request #11028 from xzmeng/fix-none

fix: check if days is None before conversion

21684 of 22965 relevant lines covered (94.42%)

0.94 hits per line

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

97.04
/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, timezone
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_analysis_results,
51
    store_backtest_stats,
52
)
53
from freqtrade.persistence import (
1✔
54
    CustomDataWrapper,
55
    LocalTrade,
56
    Order,
57
    PairLocks,
58
    Trade,
59
    disable_database_use,
60
    enable_database_use,
61
)
62
from freqtrade.plugins.pairlistmanager import PairListManager
1✔
63
from freqtrade.plugins.protectionmanager import ProtectionManager
1✔
64
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
1✔
65
from freqtrade.strategy.interface import IStrategy
1✔
66
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
1✔
67
from freqtrade.util import FtPrecise
1✔
68
from freqtrade.util.migrations import migrate_data
1✔
69
from freqtrade.wallets import Wallets
1✔
70

71

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

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

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

103

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

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

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

120
        config["dry_run"] = True
1✔
121
        self.run_ids: dict[str, str] = {}
1✔
122
        self.strategylist: list[IStrategy] = []
1✔
123
        self.all_results: dict[str, dict] = {}
1✔
124
        self.processed_dfs: dict[str, dict] = {}
1✔
125
        self.rejected_dict: dict[str, list] = {}
1✔
126
        self.rejected_df: dict[str, dict] = {}
1✔
127
        self.exited_dfs: dict[str, dict] = {}
1✔
128

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

218
        self.init_backtest()
1✔
219

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

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

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

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

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

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

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

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

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

275
        self.strategy.ft_bot_start()
1✔
276

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

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

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

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

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

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

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

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

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

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

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

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

381
    def disable_database_use(self):
1✔
382
        disable_database_use(self.timeframe)
1✔
383

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

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

411
    def _get_ohlcv_as_lists(self, processed: dict[str, DataFrame]) -> dict[str, tuple]:
1✔
412
        """
413
        Helper function to convert a processed dataframes into lists for performance reasons.
414

415
        Used by backtest() - so keep this optimized for performance.
416

417
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
418
        optimize memory usage!
419
        """
420

421
        data: dict = {}
1✔
422
        self.progress.init_step(BacktestState.CONVERT, len(processed))
1✔
423

424
        # Create dict with data
425
        for pair in processed.keys():
1✔
426
            pair_data = processed[pair]
1✔
427
            self.check_abort()
1✔
428
            self.progress.increment()
1✔
429

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

439
            # Trim startup period from analyzed dataframe
440
            df_analyzed = processed[pair] = pair_data = trim_dataframe(
1✔
441
                df_analyzed, self.timerange, startup_candles=self.required_startup
442
            )
443

444
            # Create a copy of the dataframe before shifting, that way the entry signal/tag
445
            # remains on the correct candle for callbacks.
446
            df_analyzed = df_analyzed.copy()
1✔
447

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

461
            df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
1✔
462

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

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

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

500
        if is_short:
1✔
501
            if stoploss_value < row[LOW_IDX]:
1✔
502
                return row[OPEN_IDX]
1✔
503
        else:
504
            if stoploss_value > row[HIGH_IDX]:
1✔
505
                return row[OPEN_IDX]
1✔
506

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

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

537
        # Set close_rate to stoploss
538
        return stoploss_value
1✔
539

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

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

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

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

599
        else:
600
            # This should not be reached...
601
            return row[OPEN_IDX]
×
602

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

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

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

670
        return trade
1✔
671

672
    def _get_order_filled(self, rate: float, row: tuple) -> bool:
1✔
673
        """Rate is within candle, therefore filled"""
674
        return row[LOW_IDX] <= rate <= row[HIGH_IDX]
1✔
675

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

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

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

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

735
                LocalTrade.close_bt_trade(trade)
1✔
736
            self.wallets.update()
1✔
737
            self.run_protections(pair, current_time, trade.trade_direction)
1✔
738

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

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

814
            trade.exit_reason = exit_reason
1✔
815

816
            return self._exit_trade(trade, row, close_rate, amount_, exit_reason)
1✔
817
        return None
×
818

819
    def _exit_trade(
1✔
820
        self,
821
        trade: LocalTrade,
822
        sell_row: tuple,
823
        close_rate: float,
824
        amount: float,
825
        exit_reason: str | None,
826
    ) -> LocalTrade | None:
827
        self.order_id_counter += 1
1✔
828
        exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
1✔
829
        order_type = self.strategy.order_types["exit"]
1✔
830
        # amount = amount or trade.amount
831
        amount = amount_to_contract_precision(
1✔
832
            amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size
833
        )
834
        order = Order(
1✔
835
            id=self.order_id_counter,
836
            ft_trade_id=trade.id,
837
            order_date=exit_candle_time,
838
            order_update_date=exit_candle_time,
839
            ft_is_open=True,
840
            ft_pair=trade.pair,
841
            order_id=str(self.order_id_counter),
842
            symbol=trade.pair,
843
            ft_order_side=trade.exit_side,
844
            side=trade.exit_side,
845
            order_type=order_type,
846
            status="open",
847
            ft_price=close_rate,
848
            price=close_rate,
849
            average=close_rate,
850
            amount=amount,
851
            filled=0,
852
            remaining=amount,
853
            cost=amount * close_rate,
854
            ft_order_tag=exit_reason,
855
        )
856
        order._trade_bt = trade
1✔
857
        trade.orders.append(order)
1✔
858
        return trade
1✔
859

860
    def _check_trade_exit(
1✔
861
        self, trade: LocalTrade, row: tuple, current_time: datetime
862
    ) -> LocalTrade | None:
863
        self._run_funding_fees(trade, current_time)
1✔
864

865
        # Check if we need to adjust our current positions
866
        if self.strategy.position_adjustment_enable:
1✔
867
            trade = self._get_adjust_trade_entry_for_candle(trade, row, current_time)
1✔
868

869
        if trade.is_open:
1✔
870
            enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
1✔
871
            exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
1✔
872
            exits = self.strategy.should_exit(
1✔
873
                trade,  # type: ignore
874
                row[OPEN_IDX],
875
                row[DATE_IDX].to_pydatetime(),
876
                enter=enter,
877
                exit_=exit_sig,
878
                low=row[LOW_IDX],
879
                high=row[HIGH_IDX],
880
            )
881
            for exit_ in exits:
1✔
882
                t = self._get_exit_for_signal(trade, row, exit_, current_time)
1✔
883
                if t:
1✔
884
                    return t
1✔
885
        return None
1✔
886

887
    def _run_funding_fees(self, trade: LocalTrade, current_time: datetime, force: bool = False):
1✔
888
        """
889
        Calculate funding fees if necessary and add them to the trade.
890
        """
891
        if self.trading_mode == TradingMode.FUTURES:
1✔
892
            if force or (current_time.timestamp() % self.funding_fee_timeframe_secs) == 0:
1✔
893
                # Funding fee interval.
894
                trade.set_funding_fees(
1✔
895
                    self.exchange.calculate_funding_fees(
896
                        self.futures_data[trade.pair],
897
                        amount=trade.amount,
898
                        is_short=trade.is_short,
899
                        open_date=trade.date_last_filled_utc,
900
                        close_date=current_time,
901
                    )
902
                )
903

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

939
        pos_adjust = trade is not None
1✔
940
        leverage = trade.leverage if trade else 1.0
1✔
941
        if not pos_adjust:
1✔
942
            try:
1✔
943
                stake_amount = self.wallets.get_trade_stake_amount(
1✔
944
                    pair, self.strategy.max_open_trades, update=False
945
                )
946
            except DependencyException:
1✔
947
                return 0, 0, 0, 0
1✔
948

949
            max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
1✔
950
            leverage = (
1✔
951
                strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
952
                    pair=pair,
953
                    current_time=current_time,
954
                    current_rate=row[OPEN_IDX],
955
                    proposed_leverage=1.0,
956
                    max_leverage=max_leverage,
957
                    side=direction,
958
                    entry_tag=entry_tag,
959
                )
960
                if self.trading_mode != TradingMode.SPOT
961
                else 1.0
962
            )
963
            # Cap leverage between 1.0 and max_leverage.
964
            leverage = min(max(leverage, 1.0), max_leverage)
1✔
965

966
        min_stake_amount = (
1✔
967
            self.exchange.get_min_pair_stake_amount(
968
                pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage
969
            )
970
            or 0
971
        )
972
        max_stake_amount = self.exchange.get_max_pair_stake_amount(
1✔
973
            pair, propose_rate, leverage=leverage
974
        )
975
        stake_available = self.wallets.get_available_stake_amount()
1✔
976

977
        if not pos_adjust:
1✔
978
            stake_amount = strategy_safe_wrapper(
1✔
979
                self.strategy.custom_stake_amount, default_retval=stake_amount
980
            )(
981
                pair=pair,
982
                current_time=current_time,
983
                current_rate=propose_rate,
984
                proposed_stake=stake_amount,
985
                min_stake=min_stake_amount,
986
                max_stake=min(stake_available, max_stake_amount),
987
                leverage=leverage,
988
                entry_tag=entry_tag,
989
                side=direction,
990
            )
991

992
        stake_amount_val = self.wallets.validate_stake_amount(
1✔
993
            pair=pair,
994
            stake_amount=stake_amount,
995
            min_stake_amount=min_stake_amount,
996
            max_stake_amount=max_stake_amount,
997
            trade_amount=trade.stake_amount if trade else None,
998
        )
999

1000
        return propose_rate, stake_amount_val, leverage, min_stake_amount
1✔
1001

1002
    def _enter_trade(
1✔
1003
        self,
1004
        pair: str,
1005
        row: tuple,
1006
        direction: LongShort,
1007
        stake_amount: float | None = None,
1008
        trade: LocalTrade | None = None,
1009
        requested_rate: float | None = None,
1010
        requested_stake: float | None = None,
1011
        entry_tag1: str | None = None,
1012
    ) -> LocalTrade | None:
1013
        """
1014
        :param trade: Trade to adjust - initial entry if None
1015
        :param requested_rate: Adjusted entry rate
1016
        :param requested_stake: Stake amount for adjusted orders (`adjust_entry_price`).
1017
        """
1018

1019
        current_time = row[DATE_IDX].to_pydatetime()
1✔
1020
        entry_tag = entry_tag1 or (row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None)
1✔
1021
        # let's call the custom entry price, using the open price as default price
1022
        order_type = self.strategy.order_types["entry"]
1✔
1023
        pos_adjust = trade is not None and requested_rate is None
1✔
1024

1025
        stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
1✔
1026
        precision_price = self.exchange.get_precision_price(pair)
1✔
1027

1028
        propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
1✔
1029
            pair,
1030
            row,
1031
            row[OPEN_IDX],
1032
            stake_amount_,
1033
            direction,
1034
            current_time,
1035
            entry_tag,
1036
            trade,
1037
            order_type,
1038
            precision_price,
1039
        )
1040

1041
        # replace proposed rate if another rate was requested
1042
        propose_rate = requested_rate if requested_rate else propose_rate
1✔
1043
        stake_amount = requested_stake if requested_stake else stake_amount
1✔
1044

1045
        if not stake_amount:
1✔
1046
            # In case of pos adjust, still return the original trade
1047
            # If not pos adjust, trade is None
1048
            return trade
1✔
1049
        time_in_force = self.strategy.order_time_in_force["entry"]
1✔
1050

1051
        if stake_amount and (not min_stake_amount or stake_amount >= min_stake_amount):
1✔
1052
            self.order_id_counter += 1
1✔
1053
            base_currency = self.exchange.get_pair_base_currency(pair)
1✔
1054
            amount_p = (stake_amount / propose_rate) * leverage
1✔
1055

1056
            contract_size = self.exchange.get_contract_size(pair)
1✔
1057
            precision_amount = self.exchange.get_precision_amount(pair)
1✔
1058
            amount = amount_to_contract_precision(
1✔
1059
                amount_p, precision_amount, self.precision_mode, contract_size
1060
            )
1061
            if not amount:
1✔
1062
                # No amount left after truncating to precision.
1063
                return trade
×
1064
            # Backcalculate actual stake amount.
1065
            stake_amount = amount * propose_rate / leverage
1✔
1066

1067
            if not pos_adjust:
1✔
1068
                # Confirm trade entry:
1069
                if not strategy_safe_wrapper(
1✔
1070
                    self.strategy.confirm_trade_entry, default_retval=True
1071
                )(
1072
                    pair=pair,
1073
                    order_type=order_type,
1074
                    amount=amount,
1075
                    rate=propose_rate,
1076
                    time_in_force=time_in_force,
1077
                    current_time=current_time,
1078
                    entry_tag=entry_tag,
1079
                    side=direction,
1080
                ):
1081
                    return trade
1✔
1082

1083
            is_short = direction == "short"
1✔
1084
            # Necessary for Margin trading. Disabled until support is enabled.
1085
            # interest_rate = self.exchange.get_interest_rate()
1086

1087
            if trade is None:
1✔
1088
                # Enter trade
1089
                self.trade_id_counter += 1
1✔
1090
                trade = LocalTrade(
1✔
1091
                    id=self.trade_id_counter,
1092
                    pair=pair,
1093
                    base_currency=base_currency,
1094
                    stake_currency=self.config["stake_currency"],
1095
                    open_rate=propose_rate,
1096
                    open_rate_requested=propose_rate,
1097
                    open_date=current_time,
1098
                    stake_amount=stake_amount,
1099
                    amount=0,
1100
                    amount_requested=amount,
1101
                    fee_open=self.fee,
1102
                    fee_close=self.fee,
1103
                    is_open=True,
1104
                    enter_tag=entry_tag,
1105
                    timeframe=self.timeframe_min,
1106
                    exchange=self._exchange_name,
1107
                    is_short=is_short,
1108
                    trading_mode=self.trading_mode,
1109
                    leverage=leverage,
1110
                    # interest_rate=interest_rate,
1111
                    amount_precision=precision_amount,
1112
                    price_precision=precision_price,
1113
                    precision_mode=self.precision_mode,
1114
                    precision_mode_price=self.precision_mode_price,
1115
                    contract_size=contract_size,
1116
                    orders=[],
1117
                )
1118
                LocalTrade.add_bt_trade(trade)
1✔
1119

1120
            trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
1✔
1121

1122
            order = Order(
1✔
1123
                id=self.order_id_counter,
1124
                ft_trade_id=trade.id,
1125
                ft_is_open=True,
1126
                ft_pair=trade.pair,
1127
                order_id=str(self.order_id_counter),
1128
                symbol=trade.pair,
1129
                ft_order_side=trade.entry_side,
1130
                side=trade.entry_side,
1131
                order_type=order_type,
1132
                status="open",
1133
                order_date=current_time,
1134
                order_filled_date=current_time,
1135
                order_update_date=current_time,
1136
                ft_price=propose_rate,
1137
                price=propose_rate,
1138
                average=propose_rate,
1139
                amount=amount,
1140
                filled=0,
1141
                remaining=amount,
1142
                cost=amount * propose_rate * (1 + self.fee),
1143
                ft_order_tag=entry_tag,
1144
            )
1145
            order._trade_bt = trade
1✔
1146
            trade.orders.append(order)
1✔
1147
            self._try_close_open_order(order, trade, current_time, row)
1✔
1148
            trade.recalc_trade_from_orders()
1✔
1149

1150
        return trade
1✔
1151

1152
    def handle_left_open(
1✔
1153
        self, open_trades: dict[str, list[LocalTrade]], data: dict[str, list[tuple]]
1154
    ) -> None:
1155
        """
1156
        Handling of left open trades at the end of backtesting
1157
        """
1158
        for pair in open_trades.keys():
1✔
1159
            for trade in list(open_trades[pair]):
1✔
1160
                if trade.has_open_orders and trade.nr_of_successful_entries == 0:
1✔
1161
                    # Ignore trade if entry-order did not fill yet
1162
                    continue
×
1163
                exit_row = data[pair][-1]
1✔
1164
                self._exit_trade(
1✔
1165
                    trade, exit_row, exit_row[OPEN_IDX], trade.amount, ExitType.FORCE_EXIT.value
1166
                )
1167
                trade.exit_reason = ExitType.FORCE_EXIT.value
1✔
1168
                self._process_exit_order(
1✔
1169
                    trade.orders[-1], trade, exit_row[DATE_IDX].to_pydatetime(), exit_row, pair
1170
                )
1171

1172
    def trade_slot_available(self, open_trade_count: int) -> bool:
1✔
1173
        # Always allow trades when max_open_trades is enabled.
1174
        max_open_trades: IntOrInf = self.strategy.max_open_trades
1✔
1175
        if max_open_trades <= 0 or open_trade_count < max_open_trades:
1✔
1176
            return True
1✔
1177
        # Rejected trade
1178
        self.rejected_trades += 1
1✔
1179
        return False
1✔
1180

1181
    def check_for_trade_entry(self, row) -> LongShort | None:
1✔
1182
        enter_long = row[LONG_IDX] == 1
1✔
1183
        exit_long = row[ELONG_IDX] == 1
1✔
1184
        enter_short = self._can_short and row[SHORT_IDX] == 1
1✔
1185
        exit_short = self._can_short and row[ESHORT_IDX] == 1
1✔
1186

1187
        if enter_long == 1 and not any([exit_long, enter_short]):
1✔
1188
            # Long
1189
            return "long"
1✔
1190
        if enter_short == 1 and not any([exit_short, enter_long]):
1✔
1191
            # Short
1192
            return "short"
1✔
1193
        return None
1✔
1194

1195
    def run_protections(self, pair: str, current_time: datetime, side: LongShort):
1✔
1196
        if self.enable_protections:
1✔
1197
            self.protections.stop_per_pair(pair, current_time, side)
1✔
1198
            self.protections.global_stop(current_time, side)
1✔
1199

1200
    def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tuple) -> bool:
1✔
1201
        """
1202
        Check if any open order needs to be cancelled or replaced.
1203
        Returns True if the trade should be deleted.
1204
        """
1205
        for order in [o for o in trade.orders if o.ft_is_open]:
1✔
1206
            oc = self.check_order_cancel(trade, order, current_time)
1✔
1207
            if oc:
1✔
1208
                # delete trade due to order timeout
1209
                return True
1✔
1210
            elif oc is None and self.check_order_replace(trade, order, current_time, row):
1✔
1211
                # delete trade due to user request
1212
                self.canceled_trade_entries += 1
1✔
1213
                return True
1✔
1214
        # default maintain trade
1215
        return False
1✔
1216

1217
    def check_order_cancel(
1✔
1218
        self, trade: LocalTrade, order: Order, current_time: datetime
1219
    ) -> bool | None:
1220
        """
1221
        Check if current analyzed order has to be canceled.
1222
        Returns True if the trade should be Deleted (initial order was canceled),
1223
                False if it's Canceled
1224
                None if the order is still active.
1225
        """
1226
        timedout = self.strategy.ft_check_timed_out(
1✔
1227
            trade,  # type: ignore[arg-type]
1228
            order,
1229
            current_time,
1230
        )
1231
        if timedout:
1✔
1232
            if order.side == trade.entry_side:
1✔
1233
                self.timedout_entry_orders += 1
1✔
1234
                if trade.nr_of_successful_entries == 0:
1✔
1235
                    # Remove trade due to entry timeout expiration.
1236
                    return True
1✔
1237
                else:
1238
                    # Close additional entry order
1239
                    del trade.orders[trade.orders.index(order)]
×
1240
                    return False
×
1241
            if order.side == trade.exit_side:
1✔
1242
                self.timedout_exit_orders += 1
1✔
1243
                # Close exit order and retry exiting on next signal.
1244
                del trade.orders[trade.orders.index(order)]
1✔
1245
                return False
1✔
1246
        return None
1✔
1247

1248
    def check_order_replace(
1✔
1249
        self, trade: LocalTrade, order: Order, current_time, row: tuple
1250
    ) -> bool:
1251
        """
1252
        Check if current analyzed entry order has to be replaced and do so.
1253
        If user requested cancellation and there are no filled orders in the trade will
1254
        instruct caller to delete the trade.
1255
        Returns True if the trade should be deleted.
1256
        """
1257
        # only check on new candles for open entry orders
1258
        if order.side == trade.entry_side and current_time > order.order_date_utc:
1✔
1259
            requested_rate = strategy_safe_wrapper(
1✔
1260
                self.strategy.adjust_entry_price, default_retval=order.ft_price
1261
            )(
1262
                trade=trade,  # type: ignore[arg-type]
1263
                order=order,
1264
                pair=trade.pair,
1265
                current_time=current_time,
1266
                proposed_rate=row[OPEN_IDX],
1267
                current_order_rate=order.ft_price,
1268
                entry_tag=trade.enter_tag,
1269
                side=trade.trade_direction,
1270
            )  # default value is current order price
1271

1272
            # cancel existing order whenever a new rate is requested (or None)
1273
            if requested_rate == order.ft_price:
1✔
1274
                # assumption: there can't be multiple open entry orders at any given time
1275
                return False
1✔
1276
            else:
1277
                del trade.orders[trade.orders.index(order)]
1✔
1278
                self.canceled_entry_orders += 1
1✔
1279

1280
            # place new order if result was not None
1281
            if requested_rate:
1✔
1282
                self._enter_trade(
1✔
1283
                    pair=trade.pair,
1284
                    row=row,
1285
                    trade=trade,
1286
                    requested_rate=requested_rate,
1287
                    requested_stake=(order.safe_remaining * order.ft_price / trade.leverage),
1288
                    direction="short" if trade.is_short else "long",
1289
                )
1290
                # Delete trade if no successful entries happened (if placing the new order failed)
1291
                if not trade.has_open_orders and trade.nr_of_successful_entries == 0:
1✔
1292
                    return True
×
1293
                self.replaced_entry_orders += 1
1✔
1294
            else:
1295
                # assumption: there can't be multiple open entry orders at any given time
1296
                return trade.nr_of_successful_entries == 0
1✔
1297
        return False
1✔
1298

1299
    def validate_row(
1✔
1300
        self, data: dict, pair: str, row_index: int, current_time: datetime
1301
    ) -> tuple | None:
1302
        try:
1✔
1303
            # Row is treated as "current incomplete candle".
1304
            # entry / exit signals are shifted by 1 to compensate for this.
1305
            row = data[pair][row_index]
1✔
1306
        except IndexError:
1✔
1307
            # missing Data for one pair at the end.
1308
            # Warnings for this are shown during data loading
1309
            return None
1✔
1310

1311
        # Waits until the time-counter reaches the start of the data for this pair.
1312
        if row[DATE_IDX] > current_time:
1✔
1313
            return None
1✔
1314
        return row
1✔
1315

1316
    def _collate_rejected(self, pair, row):
1✔
1317
        """
1318
        Temporarily store rejected signal information for downstream use in backtesting_analysis
1319
        """
1320
        # It could be fun to enable hyperopt mode to write
1321
        # a loss function to reduce rejected signals
1322
        if (
1✔
1323
            self.config.get("export", "none") == "signals"
1324
            and self.dataprovider.runmode == RunMode.BACKTEST
1325
        ):
1326
            if pair not in self.rejected_dict:
×
1327
                self.rejected_dict[pair] = []
×
1328
            self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]])
×
1329

1330
    def backtest_loop(
1✔
1331
        self,
1332
        row: tuple,
1333
        pair: str,
1334
        current_time: datetime,
1335
        trade_dir: LongShort | None,
1336
        can_enter: bool,
1337
    ) -> None:
1338
        """
1339
        Conditionally call backtest_loop_inner a 2nd time if shorting is enabled,
1340
         a position closed and a new signal in the other direction is available.
1341
        """
1342
        if not self._can_short or trade_dir is None:
1✔
1343
            # No need to reverse position if shorting is disabled or there's no new signal
1344
            self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter)
1✔
1345
        else:
1346
            for _ in (0, 1):
1✔
1347
                a = self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter)
1✔
1348
                if not a or a == trade_dir:
1✔
1349
                    # the trade didn't close or position change is in the same direction
1350
                    break
1✔
1351

1352
    def backtest_loop_inner(
1✔
1353
        self,
1354
        row: tuple,
1355
        pair: str,
1356
        current_time: datetime,
1357
        trade_dir: LongShort | None,
1358
        can_enter: bool,
1359
    ) -> LongShort | None:
1360
        """
1361
        NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
1362

1363
        Backtesting processing for one candle/pair.
1364
        """
1365
        exiting_dir: LongShort | None = None
1✔
1366
        if not self._position_stacking and len(LocalTrade.bt_trades_open_pp[pair]) > 0:
1✔
1367
            # position_stacking not supported for now.
1368
            exiting_dir = "short" if LocalTrade.bt_trades_open_pp[pair][0].is_short else "long"
1✔
1369

1370
        for t in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1371
            # 1. Manage currently open orders of active trades
1372
            if self.manage_open_orders(t, current_time, row):
1✔
1373
                # Remove trade (initial open order never filled)
1374
                LocalTrade.remove_bt_trade(t)
1✔
1375
                self.wallets.update()
1✔
1376

1377
        # 2. Process entries.
1378
        # without positionstacking, we can only have one open trade per pair.
1379
        # max_open_trades must be respected
1380
        # don't open on the last row
1381
        # We only open trades on the main candle, not on detail candles
1382
        if (
1✔
1383
            can_enter
1384
            and trade_dir is not None
1385
            and (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
1386
            and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
1387
        ):
1388
            if self.trade_slot_available(LocalTrade.bt_open_open_trade_count_candle):
1✔
1389
                trade = self._enter_trade(pair, row, trade_dir)
1✔
1390
                if trade:
1✔
1391
                    self.wallets.update()
1✔
1392
            else:
1393
                self._collate_rejected(pair, row)
1✔
1394

1395
        for trade in list(LocalTrade.bt_trades_open_pp[pair]):
1✔
1396
            # 3. Process entry orders.
1397
            order = trade.select_order(trade.entry_side, is_open=True)
1✔
1398
            if self._try_close_open_order(order, trade, current_time, row):
1✔
1399
                self.wallets.update()
1✔
1400

1401
            # 4. Create exit orders (if any)
1402
            if not trade.has_open_orders:
1✔
1403
                self._check_trade_exit(trade, row, current_time)  # Place exit order if necessary
1✔
1404

1405
            # 5. Process exit orders.
1406
            order = trade.select_order(trade.exit_side, is_open=True)
1✔
1407
            if order:
1✔
1408
                self._process_exit_order(order, trade, current_time, row, pair)
1✔
1409

1410
        if exiting_dir and len(LocalTrade.bt_trades_open_pp[pair]) == 0:
1✔
1411
            return exiting_dir
1✔
1412
        return None
1✔
1413

1414
    def time_pair_generator(
1✔
1415
        self, start_date: datetime, end_date: datetime, increment: timedelta, pairs: list[str]
1416
    ):
1417
        """
1418
        Backtest time and pair generator
1419
        """
1420
        current_time = start_date + increment
1✔
1421
        self.progress.init_step(
1✔
1422
            BacktestState.BACKTEST, int((end_date - start_date) / self.timeframe_td)
1423
        )
1424
        while current_time <= end_date:
1✔
1425
            is_first = True
1✔
1426
            # Pairs that have open trades should be processed first
1427
            new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs))
1✔
1428

1429
            for pair in new_pairlist:
1✔
1430
                yield current_time, pair, is_first
1✔
1431
                is_first = False
1✔
1432

1433
            self.progress.increment()
1✔
1434
            current_time += increment
1✔
1435

1436
    def backtest(self, processed: dict, start_date: datetime, end_date: datetime) -> dict[str, Any]:
1✔
1437
        """
1438
        Implement backtesting functionality
1439

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

1444
        :param processed: a processed dictionary with format {pair, data}, which gets cleared to
1445
        optimize memory usage!
1446
        :param start_date: backtesting timerange start datetime
1447
        :param end_date: backtesting timerange end datetime
1448
        :return: DataFrame with trades (results of backtesting)
1449
        """
1450
        self.prepare_backtest(self.enable_protections)
1✔
1451
        # Ensure wallets are up-to-date (important for --strategy-list)
1452
        self.wallets.update()
1✔
1453
        # Use dict of lists with data for performance
1454
        # (looping lists is a lot faster than pandas DataFrames)
1455
        data: dict = self._get_ohlcv_as_lists(processed)
1✔
1456

1457
        # Indexes per pair, so some pairs are allowed to have a missing start.
1458
        indexes: dict = defaultdict(int)
1✔
1459

1460
        # Loop timerange and get candle for each pair at that point in time
1461
        for current_time, pair, is_first_call in self.time_pair_generator(
1✔
1462
            start_date, end_date, self.timeframe_td, list(data.keys())
1463
        ):
1464
            if is_first_call:
1✔
1465
                self.check_abort()
1✔
1466
                # Reset open trade count for this candle
1467
                # Critical to avoid exceeding max_open_trades in backtesting
1468
                # when timeframe-detail is used and trades close within the opening candle.
1469
                LocalTrade.bt_open_open_trade_count_candle = LocalTrade.bt_open_open_trade_count
1✔
1470
                strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)(
1✔
1471
                    current_time=current_time
1472
                )
1473
            row_index = indexes[pair]
1✔
1474
            row = self.validate_row(data, pair, row_index, current_time)
1✔
1475
            if not row:
1✔
1476
                continue
1✔
1477

1478
            row_index += 1
1✔
1479
            indexes[pair] = row_index
1✔
1480
            is_last_row = current_time == end_date
1✔
1481
            self.dataprovider._set_dataframe_max_index(self.required_startup + row_index)
1✔
1482
            self.dataprovider._set_dataframe_max_date(current_time)
1✔
1483
            current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
1✔
1484
            trade_dir: LongShort | None = self.check_for_trade_entry(row)
1✔
1485

1486
            if (
1✔
1487
                (trade_dir is not None or len(LocalTrade.bt_trades_open_pp[pair]) > 0)
1488
                and self.timeframe_detail
1489
                and pair in self.detail_data
1490
            ):
1491
                # Spread out into detail timeframe.
1492
                # Should only happen when we are either in a trade for this pair
1493
                # or when we got the signal for a new trade.
1494
                exit_candle_end = current_detail_time + self.timeframe_td
1✔
1495

1496
                detail_data = self.detail_data[pair]
1✔
1497
                detail_data = detail_data.loc[
1✔
1498
                    (detail_data["date"] >= current_detail_time)
1499
                    & (detail_data["date"] < exit_candle_end)
1500
                ].copy()
1501
                if len(detail_data) == 0:
1✔
1502
                    # Fall back to "regular" data if no detail data was found for this candle
1503
                    self.dataprovider._set_dataframe_max_date(current_time)
×
1504
                    self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
×
1505
                    continue
×
1506
                detail_data.loc[:, "enter_long"] = row[LONG_IDX]
1✔
1507
                detail_data.loc[:, "exit_long"] = row[ELONG_IDX]
1✔
1508
                detail_data.loc[:, "enter_short"] = row[SHORT_IDX]
1✔
1509
                detail_data.loc[:, "exit_short"] = row[ESHORT_IDX]
1✔
1510
                detail_data.loc[:, "enter_tag"] = row[ENTER_TAG_IDX]
1✔
1511
                detail_data.loc[:, "exit_tag"] = row[EXIT_TAG_IDX]
1✔
1512
                is_first = True
1✔
1513
                current_time_det = current_time
1✔
1514
                for det_row in detail_data[HEADERS].values.tolist():
1✔
1515
                    self.dataprovider._set_dataframe_max_date(current_time_det)
1✔
1516
                    self.backtest_loop(
1✔
1517
                        det_row,
1518
                        pair,
1519
                        current_time_det,
1520
                        trade_dir,
1521
                        is_first and not is_last_row,
1522
                    )
1523
                    current_time_det += self.timeframe_detail_td
1✔
1524
                    is_first = False
1✔
1525
            else:
1526
                self.dataprovider._set_dataframe_max_date(current_time)
1✔
1527
                self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
1✔
1528

1529
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1530
        self.wallets.update()
1✔
1531

1532
        results = trade_list_to_dataframe(LocalTrade.bt_trades)
1✔
1533
        return {
1✔
1534
            "results": results,
1535
            "config": self.strategy.config,
1536
            "locks": PairLocks.get_all_locks(),
1537
            "rejected_signals": self.rejected_trades,
1538
            "timedout_entry_orders": self.timedout_entry_orders,
1539
            "timedout_exit_orders": self.timedout_exit_orders,
1540
            "canceled_trade_entries": self.canceled_trade_entries,
1541
            "canceled_entry_orders": self.canceled_entry_orders,
1542
            "replaced_entry_orders": self.replaced_entry_orders,
1543
            "final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]),
1544
        }
1545

1546
    def backtest_one_strategy(
1✔
1547
        self, strat: IStrategy, data: dict[str, DataFrame], timerange: TimeRange
1548
    ):
1549
        self.progress.init_step(BacktestState.ANALYZE, 0)
1✔
1550
        strategy_name = strat.get_strategy_name()
1✔
1551
        logger.info(f"Running backtesting for Strategy {strategy_name}")
1✔
1552
        backtest_start_time = datetime.now(timezone.utc)
1✔
1553
        self._set_strategy(strat)
1✔
1554

1555
        # need to reprocess data every time to populate signals
1556
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1557

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

1562
        if not preprocessed_tmp:
1✔
1563
            raise OperationalException("No data left after adjusting for startup candles.")
×
1564

1565
        # Use preprocessed_tmp for date generation (the trimmed dataframe).
1566
        # Backtesting will re-trim the dataframes after entry/exit signal generation.
1567
        min_date, max_date = history.get_timerange(preprocessed_tmp)
1✔
1568
        logger.info(
1✔
1569
            f"Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} "
1570
            f"up to {max_date.strftime(DATETIME_PRINT_FORMAT)} "
1571
            f"({(max_date - min_date).days} days)."
1572
        )
1573
        # Execute backtest and store results
1574
        results = self.backtest(
1✔
1575
            processed=preprocessed,
1576
            start_date=min_date,
1577
            end_date=max_date,
1578
        )
1579
        backtest_end_time = datetime.now(timezone.utc)
1✔
1580
        results.update(
1✔
1581
            {
1582
                "run_id": self.run_ids.get(strategy_name, ""),
1583
                "backtest_start_time": int(backtest_start_time.timestamp()),
1584
                "backtest_end_time": int(backtest_end_time.timestamp()),
1585
            }
1586
        )
1587
        self.all_results[strategy_name] = results
1✔
1588

1589
        if (
1✔
1590
            self.config.get("export", "none") == "signals"
1591
            and self.dataprovider.runmode == RunMode.BACKTEST
1592
        ):
1593
            self.processed_dfs[strategy_name] = generate_trade_signal_candles(
1✔
1594
                preprocessed_tmp, results, "open_date"
1595
            )
1596
            self.rejected_df[strategy_name] = generate_rejected_signals(
1✔
1597
                preprocessed_tmp, self.rejected_dict
1598
            )
1599
            self.exited_dfs[strategy_name] = generate_trade_signal_candles(
1✔
1600
                preprocessed_tmp, results, "close_date"
1601
            )
1602

1603
        return min_date, max_date
1✔
1604

1605
    def _get_min_cached_backtest_date(self):
1✔
1606
        min_backtest_date = None
1✔
1607
        backtest_cache_age = self.config.get("backtest_cache", constants.BACKTEST_CACHE_DEFAULT)
1✔
1608
        if self.timerange.stopts == 0 or self.timerange.stopdt > datetime.now(tz=timezone.utc):
1✔
1609
            logger.warning("Backtest result caching disabled due to use of open-ended timerange.")
1✔
1610
        elif backtest_cache_age == "day":
1✔
1611
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
1✔
1612
        elif backtest_cache_age == "week":
1✔
1613
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
1✔
1614
        elif backtest_cache_age == "month":
1✔
1615
            min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
1✔
1616
        return min_backtest_date
1✔
1617

1618
    def load_prior_backtest(self):
1✔
1619
        self.run_ids = {
1✔
1620
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1621
            for strategy in self.strategylist
1622
        }
1623

1624
        # Load previous result that will be updated incrementally.
1625
        # This can be circumvented in certain instances in combination with downloading more data
1626
        min_backtest_date = self._get_min_cached_backtest_date()
1✔
1627
        if min_backtest_date is not None:
1✔
1628
            self.results = find_existing_backtest_stats(
1✔
1629
                self.config["user_data_dir"] / "backtest_results", self.run_ids, min_backtest_date
1630
            )
1631

1632
    def start(self) -> None:
1✔
1633
        """
1634
        Run backtesting end-to-end
1635
        """
1636
        data: dict[str, DataFrame] = {}
1✔
1637

1638
        data, timerange = self.load_bt_data()
1✔
1639
        self.load_bt_data_detail()
1✔
1640
        logger.info("Dataload complete. Calculating indicators")
1✔
1641

1642
        self.load_prior_backtest()
1✔
1643

1644
        for strat in self.strategylist:
1✔
1645
            if self.results and strat.get_strategy_name() in self.results["strategy"]:
1✔
1646
                # When previous result hash matches - reuse that result and skip backtesting.
1647
                logger.info(f"Reusing result of previous backtest for {strat.get_strategy_name()}")
1✔
1648
                continue
1✔
1649
            min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
1✔
1650

1651
        # Update old results with new ones.
1652
        if len(self.all_results) > 0:
1✔
1653
            results = generate_backtest_stats(
1✔
1654
                data, self.all_results, min_date=min_date, max_date=max_date
1655
            )
1656
            if self.results:
1✔
1657
                self.results["metadata"].update(results["metadata"])
1✔
1658
                self.results["strategy"].update(results["strategy"])
1✔
1659
                self.results["strategy_comparison"].extend(results["strategy_comparison"])
1✔
1660
            else:
1661
                self.results = results
×
1662
            dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1✔
1663
            if self.config.get("export", "none") in ("trades", "signals"):
1✔
1664
                combined_res = combined_dataframes_with_rel_mean(data, min_date, max_date)
1✔
1665
                store_backtest_stats(
1✔
1666
                    self.config["exportfilename"],
1667
                    self.results,
1668
                    dt_appendix,
1669
                    market_change_data=combined_res,
1670
                )
1671

1672
            if (
1✔
1673
                self.config.get("export", "none") == "signals"
1674
                and self.dataprovider.runmode == RunMode.BACKTEST
1675
            ):
1676
                store_backtest_analysis_results(
1✔
1677
                    self.config["exportfilename"],
1678
                    self.processed_dfs,
1679
                    self.rejected_df,
1680
                    self.exited_dfs,
1681
                    dt_appendix,
1682
                )
1683

1684
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1685
        if "strategy_list" in self.config and len(self.results) > 0:
1✔
1686
            self.results["strategy_comparison"] = sorted(
1✔
1687
                self.results["strategy_comparison"],
1688
                key=lambda c: self.config["strategy_list"].index(c["key"]),
1689
            )
1690
            self.results["strategy"] = dict(
1✔
1691
                sorted(
1692
                    self.results["strategy"].items(),
1693
                    key=lambda kv: self.config["strategy_list"].index(kv[0]),
1694
                )
1695
            )
1696

1697
        if len(self.strategylist) > 0:
1✔
1698
            # Show backtest results
1699
            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