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

freqtrade / freqtrade / 12801367446

03 Jan 2025 02:29PM UTC coverage: 94.313% (-0.08%) from 94.396%
12801367446

push

github

xmatthias
test: add test for improved safe_wrapper behavior

21791 of 23105 relevant lines covered (94.31%)

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_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
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
            for pair in self.pairlists.whitelist:
1✔
364
                if pair not in self.exchange._leverage_tiers:
1✔
365
                    unavailable_pairs.append(pair)
1✔
366
                    continue
1✔
367

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

671
        return trade
1✔
672

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

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

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

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

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

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

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

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

815
            trade.exit_reason = exit_reason
1✔
816

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1151
        return trade
1✔
1152

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1590
        if (
1✔
1591
            self.config.get("export", "none") == "signals"
1592
            and self.dataprovider.runmode == RunMode.BACKTEST
1593
        ):
1594
            signals = generate_trade_signal_candles(preprocessed_tmp, results, "open_date")
1✔
1595
            rejected = generate_rejected_signals(preprocessed_tmp, self.rejected_dict)
1✔
1596
            exited = generate_trade_signal_candles(preprocessed_tmp, results, "close_date")
1✔
1597

1598
            self.analysis_results["signals"][strategy_name] = signals
1✔
1599
            self.analysis_results["rejected"][strategy_name] = rejected
1✔
1600
            self.analysis_results["exited"][strategy_name] = exited
1✔
1601

1602
        return min_date, max_date
1✔
1603

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

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

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

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

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

1641
        self.load_prior_backtest()
1✔
1642

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

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

1672
        # Results may be mixed up now. Sort them so they follow --strategy-list order.
1673
        if "strategy_list" in self.config and len(self.results) > 0:
1✔
1674
            self.results["strategy_comparison"] = sorted(
1✔
1675
                self.results["strategy_comparison"],
1676
                key=lambda c: self.config["strategy_list"].index(c["key"]),
1677
            )
1678
            self.results["strategy"] = dict(
1✔
1679
                sorted(
1680
                    self.results["strategy"].items(),
1681
                    key=lambda kv: self.config["strategy_list"].index(kv[0]),
1682
                )
1683
            )
1684

1685
        if len(self.strategylist) > 0:
1✔
1686
            # Show backtest results
1687
            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