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

freqtrade / freqtrade / 14098273902

06 Mar 2025 06:17AM UTC coverage: 94.436% (+0.02%) from 94.417%
14098273902

push

github

xmatthias
test: adjust test for raise-exception behavior

22064 of 23364 relevant lines covered (94.44%)

0.94 hits per line

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

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

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

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

13
from numpy import nan
1✔
14
from pandas import DataFrame
1✔
15

16
from freqtrade import constants
1✔
17
from freqtrade.configuration import TimeRange, validate_config_consistency
1✔
18
from freqtrade.constants import DATETIME_PRINT_FORMAT, Config, IntOrInf, LongShort
1✔
19
from freqtrade.data import history
1✔
20
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
1✔
21
from freqtrade.data.converter import trim_dataframe, trim_dataframes
1✔
22
from freqtrade.data.dataprovider import DataProvider
1✔
23
from freqtrade.data.metrics import combined_dataframes_with_rel_mean
1✔
24
from freqtrade.enums import (
1✔
25
    BacktestState,
26
    CandleType,
27
    ExitCheckTuple,
28
    ExitType,
29
    MarginMode,
30
    RunMode,
31
    TradingMode,
32
)
33
from freqtrade.exceptions import DependencyException, OperationalException
1✔
34
from freqtrade.exchange import (
1✔
35
    amount_to_contract_precision,
36
    price_to_precision,
37
    timeframe_to_seconds,
38
)
39
from freqtrade.exchange.exchange import Exchange
1✔
40
from freqtrade.ft_types import BacktestResultType, get_BacktestResultType_default
1✔
41
from freqtrade.leverage.liquidation_price import update_liquidation_prices
1✔
42
from freqtrade.mixins import LoggingMixin
1✔
43
from freqtrade.optimize.backtest_caching import get_strategy_run_id
1✔
44
from freqtrade.optimize.bt_progress import BTProgress
1✔
45
from freqtrade.optimize.optimize_reports import (
1✔
46
    generate_backtest_stats,
47
    generate_rejected_signals,
48
    generate_trade_signal_candles,
49
    show_backtest_results,
50
    store_backtest_results,
51
)
52
from freqtrade.persistence import (
1✔
53
    CustomDataWrapper,
54
    LocalTrade,
55
    Order,
56
    PairLocks,
57
    Trade,
58
    disable_database_use,
59
    enable_database_use,
60
)
61
from freqtrade.plugins.pairlistmanager import PairListManager
1✔
62
from freqtrade.plugins.protectionmanager import ProtectionManager
1✔
63
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
1✔
64
from freqtrade.strategy.interface import IStrategy
1✔
65
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
1✔
66
from freqtrade.util import FtPrecise, dt_now
1✔
67
from freqtrade.util.migrations import migrate_data
1✔
68
from freqtrade.wallets import Wallets
1✔
69

70

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

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

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

102

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

219
        self.init_backtest()
1✔
220

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

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

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

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

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

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

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

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

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

276
        self.strategy.ft_bot_start()
1✔
277

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

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

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

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

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

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

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

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

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

350
            # For simplicity, assign to CandleType.Mark (might contain index candles!)
351
            mark_rates_dict = history.load_data(
1✔
352
                datadir=self.config["datadir"],
353
                pairs=self.pairlists.whitelist,
354
                timeframe=mark_timeframe,
355
                timerange=self.timerange,
356
                startup_candles=0,
357
                fail_without_data=True,
358
                data_format=self.config["dataformat_ohlcv"],
359
                candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price")),
360
            )
361
            # Combine data to avoid combining the data per trade.
362
            unavailable_pairs = []
1✔
363
            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.canceled_exit_orders = 0
1✔
400
        self.replaced_exit_orders = 0
1✔
401
        self.dataprovider.clear_cache()
1✔
402
        if enable_protections:
1✔
403
            self._load_protections(self.strategy)
1✔
404

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

673
        return trade
1✔
674

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

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

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

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

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

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

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

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

817
            trade.exit_reason = exit_reason
1✔
818

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1161
        return trade
1✔
1162

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

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

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

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

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

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

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

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

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

1261
        return False
1✔
1262

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1577
                self.dataprovider._set_dataframe_max_date(current_time_det)
1✔
1578

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

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

1606
                is_last_row = current_time_det == end_date
1✔
1607

1608
                yield current_time_det, pair, row, is_last_row, trade_dir
1✔
1609
            self.progress.increment()
1✔
1610

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

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

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

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

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

1653
        self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
1✔
1654
        self.wallets.update()
1✔
1655

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

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

1679
        # need to reprocess data every time to populate signals
1680
        preprocessed = self.strategy.advise_all_indicators(data)
1✔
1681

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

1686
        if not preprocessed_tmp:
1✔
1687
            raise OperationalException("No data left after adjusting for startup candles.")
×
1688

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

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

1721
            self.analysis_results["signals"][strategy_name] = signals
1✔
1722
            self.analysis_results["rejected"][strategy_name] = rejected
1✔
1723
            self.analysis_results["exited"][strategy_name] = exited
1✔
1724

1725
        return min_date, max_date
1✔
1726

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

1740
    def load_prior_backtest(self):
1✔
1741
        self.run_ids = {
1✔
1742
            strategy.get_strategy_name(): get_strategy_run_id(strategy)
1743
            for strategy in self.strategylist
1744
        }
1745

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

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

1760
        data, timerange = self.load_bt_data()
1✔
1761
        self.load_bt_data_detail()
1✔
1762
        logger.info("Dataload complete. Calculating indicators")
1✔
1763

1764
        self.load_prior_backtest()
1✔
1765

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

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

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

1808
        if len(self.strategylist) > 0:
1✔
1809
            # Show backtest results
1810
            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