stable/freqtrade/optimize/backtesting.py

1193 lines
53 KiB
Python
Raw Normal View History

# pragma pylint: disable=missing-docstring, W0212, too-many-arguments
2017-11-14 21:15:24 +00:00
"""
This module contains the backtesting logic
"""
2018-03-25 19:37:14 +00:00
import logging
from collections import defaultdict
2018-07-27 21:01:52 +00:00
from copy import deepcopy
2021-01-13 06:47:03 +00:00
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
2018-03-17 21:44:47 +00:00
2022-04-25 09:31:19 +00:00
import pandas as pd
from numpy import nan
Fix exception when few pairs with no data do not result in aborting backtest. Exception is triggered by backtesting 20210301-20210501 range with BAKE/USDT pair (binance). Pair data starts on 2021-04-30 12:00:00 and after adjusting for startup candles pair dataframe is empty. Solution: Since there are other pairs with enough data - skip pairs with no data and issue a warning. Exception: ``` Traceback (most recent call last): File "/home/rk/src/freqtrade/freqtrade/main.py", line 37, in main return_code = args['func'](args) File "/home/rk/src/freqtrade/freqtrade/commands/optimize_commands.py", line 53, in start_backtesting backtesting.start() File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 502, in start min_date, max_date = self.backtest_one_strategy(strat, data, timerange) File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 474, in backtest_one_strategy results = self.backtest( File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 365, in backtest data: Dict = self._get_ohlcv_as_lists(processed) File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 199, in _get_ohlcv_as_lists pair_data.loc[:, 'buy'] = 0 # cleanup from previous run File "/home/rk/src/freqtrade/venv/lib/python3.9/site-packages/pandas/core/indexing.py", line 692, in __setitem__ iloc._setitem_with_indexer(indexer, value, self.name) File "/home/rk/src/freqtrade/venv/lib/python3.9/site-packages/pandas/core/indexing.py", line 1587, in _setitem_with_indexer raise ValueError( ValueError: cannot set a frame with no defined index and a scalar ```
2021-05-13 06:47:28 +00:00
from pandas import DataFrame
2017-09-28 21:26:28 +00:00
from freqtrade import constants
from freqtrade.configuration import TimeRange, validate_config_consistency
from freqtrade.constants import DATETIME_PRINT_FORMAT, LongShort
2018-12-13 05:34:10 +00:00
from freqtrade.data import history
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
from freqtrade.data.converter import trim_dataframe, trim_dataframes
from freqtrade.data.dataprovider import DataProvider
2022-04-16 16:03:24 +00:00
from freqtrade.enums import (BacktestState, CandleType, ExitCheckTuple, ExitType, RunMode,
TradingMode)
2021-02-10 19:37:55 +00:00
from freqtrade.exceptions import DependencyException, OperationalException
2019-10-20 11:56:01 +00:00
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
2020-12-07 14:45:02 +00:00
from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.backtest_caching import get_strategy_run_id
2021-03-21 14:56:36 +00:00
from freqtrade.optimize.bt_progress import BTProgress
2020-09-28 17:39:41 +00:00
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
2022-04-16 16:03:24 +00:00
store_backtest_signal_candles,
store_backtest_stats)
2021-12-18 09:15:59 +00:00
from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade
2020-12-23 16:00:02 +00:00
from freqtrade.plugins.pairlistmanager import PairListManager
from freqtrade.plugins.protectionmanager import ProtectionManager
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
2022-03-25 05:50:18 +00:00
from freqtrade.strategy.interface import IStrategy
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
2021-01-28 06:06:58 +00:00
from freqtrade.wallets import Wallets
2021-03-25 08:34:33 +00:00
2018-03-25 19:37:14 +00:00
logger = logging.getLogger(__name__)
# Indexes for backtest tuples
DATE_IDX = 0
OPEN_IDX = 1
HIGH_IDX = 2
LOW_IDX = 3
CLOSE_IDX = 4
2021-08-24 04:54:55 +00:00
LONG_IDX = 5
ELONG_IDX = 6 # Exit long
SHORT_IDX = 7
2021-08-24 04:54:55 +00:00
ESHORT_IDX = 8 # Exit short
2021-09-25 17:31:06 +00:00
ENTER_TAG_IDX = 9
2021-11-06 14:24:52 +00:00
EXIT_TAG_IDX = 10
# Every change to this headers list must evaluate further usages of the resulting tuple
# and eventually change the constants for indexes at the top
HEADERS = ['date', 'open', 'high', 'low', 'close', 'enter_long', 'exit_long',
'enter_short', 'exit_short', 'enter_tag', 'exit_tag']
2018-03-25 19:37:14 +00:00
2019-09-12 01:39:52 +00:00
class Backtesting:
"""
Backtesting class, this class contains all the logic to run a backtest
To run a backtest:
backtesting = Backtesting(config)
backtesting.start()
"""
2018-07-28 05:00:58 +00:00
def __init__(self, config: Dict[str, Any]) -> None:
LoggingMixin.show_output = False
self.config = config
self.results: Dict[str, Any] = {}
2022-01-22 13:11:33 +00:00
self.trade_id_counter: int = 0
self.order_id_counter: int = 0
config['dry_run'] = True
self.run_ids: Dict[str, str] = {}
2018-07-28 05:41:38 +00:00
self.strategylist: List[IStrategy] = []
2020-09-18 05:44:11 +00:00
self.all_results: Dict[str, Dict] = {}
self.processed_dfs: Dict[str, Dict] = {}
self._exchange_name = self.config['exchange']['name']
self.exchange = ExchangeResolver.load_exchange(self._exchange_name, self.config)
self.dataprovider = DataProvider(self.config, self.exchange)
2018-07-28 05:41:38 +00:00
if self.config.get('strategy_list', None):
2018-07-28 05:55:59 +00:00
for strat in list(self.config['strategy_list']):
2018-07-28 05:41:38 +00:00
stratconf = deepcopy(self.config)
stratconf['strategy'] = strat
self.strategylist.append(StrategyResolver.load_strategy(stratconf))
validate_config_consistency(stratconf)
2018-07-28 05:41:38 +00:00
else:
2019-06-09 23:08:54 +00:00
# No strategy list specified, only one strategy
self.strategylist.append(StrategyResolver.load_strategy(self.config))
validate_config_consistency(self.config)
2019-06-09 23:08:54 +00:00
if "timeframe" not in self.config:
2022-03-20 08:00:53 +00:00
raise OperationalException("Timeframe needs to be set in either "
"configuration or as cli argument `--timeframe 5m`")
self.timeframe = str(self.config.get('timeframe'))
2019-12-11 06:12:37 +00:00
self.timeframe_min = timeframe_to_minutes(self.timeframe)
self.init_backtest_detail()
self.pairlists = PairListManager(self.exchange, self.config)
if 'VolumePairList' in self.pairlists.name_list:
raise OperationalException("VolumePairList not allowed for backtesting. "
2021-11-26 05:27:06 +00:00
"Please use StaticPairlist instead.")
if 'PerformanceFilter' in self.pairlists.name_list:
raise OperationalException("PerformanceFilter not allowed for backtesting.")
if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list:
raise OperationalException(
"PrecisionFilter not allowed for backtesting multiple strategies."
)
self.dataprovider.add_pairlisthandler(self.pairlists)
self.pairlists.refresh_pairlist()
if len(self.pairlists.whitelist) == 0:
raise OperationalException("No pair in whitelist.")
if config.get('fee', None) is not None:
self.fee = config['fee']
else:
self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0])
self.timerange = TimeRange.parse_timerange(
None if self.config.get('timerange') is None else str(self.config.get('timerange')))
2019-10-20 11:56:01 +00:00
# Get maximum required startup period
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
# Add maximum startup candle count to configuration for informative pairs support
self.config['startup_candle_count'] = self.required_startup
self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe)
2022-02-21 18:19:12 +00:00
self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT)
# strategies which define "can_short=True" will fail to load in Spot mode.
self._can_short = self.trading_mode != TradingMode.SPOT
self.init_backtest()
2021-03-11 18:16:18 +00:00
def __del__(self):
2021-08-09 09:18:18 +00:00
self.cleanup()
@staticmethod
def cleanup():
LoggingMixin.show_output = True
PairLocks.use_db = True
Trade.use_db = True
def init_backtest_detail(self):
# Load detail timeframe if specified
self.timeframe_detail = str(self.config.get('timeframe_detail', ''))
if self.timeframe_detail:
self.timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail)
if self.timeframe_min <= self.timeframe_detail_min:
raise OperationalException(
"Detail timeframe must be smaller than strategy timeframe.")
else:
self.timeframe_detail_min = 0
self.detail_data: Dict[str, DataFrame] = {}
self.futures_data: Dict[str, DataFrame] = {}
def init_backtest(self):
self.prepare_backtest(False)
self.wallets = Wallets(self.config, self.exchange, log=False)
self.progress = BTProgress()
self.abort = False
2021-02-26 18:48:06 +00:00
def _set_strategy(self, strategy: IStrategy):
2018-07-28 04:54:33 +00:00
"""
Load strategy into backtesting
"""
self.strategy: IStrategy = strategy
2021-05-03 06:47:58 +00:00
strategy.dp = self.dataprovider
# Attach Wallets to Strategy baseclass
strategy.wallets = self.wallets
# Set stoploss_on_exchange to false for backtesting,
# since a "perfect" stoploss-exit is assumed anyway
# And the regular "stoploss" function would not apply to that case
self.strategy.order_types['stoploss_on_exchange'] = False
2022-04-03 16:28:09 +00:00
self.strategy.bot_start()
def _load_protections(self, strategy: IStrategy):
if self.config.get('enable_protections', False):
conf = self.config
if hasattr(strategy, 'protections'):
conf = deepcopy(conf)
conf['protections'] = strategy.protections
self.protections = ProtectionManager(self.config, strategy.protections)
2018-07-28 04:54:33 +00:00
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
"""
Loads backtest data and returns the data combined with the timerange
as tuple.
"""
2021-03-21 14:56:36 +00:00
self.progress.init_step(BacktestState.DATALOAD, 1)
2021-03-11 18:16:18 +00:00
data = history.load_data(
2019-12-23 18:32:31 +00:00
datadir=self.config['datadir'],
pairs=self.pairlists.whitelist,
timeframe=self.timeframe,
timerange=self.timerange,
startup_candles=self.required_startup,
fail_without_data=True,
2019-12-28 13:57:39 +00:00
data_format=self.config.get('dataformat_ohlcv', 'json'),
candle_type=self.config.get('candle_type_def', CandleType.SPOT)
)
2019-12-17 22:06:03 +00:00
min_date, max_date = history.get_timerange(data)
2020-06-09 06:07:34 +00:00
logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
2021-05-06 18:49:48 +00:00
f'({(max_date - min_date).days} days).')
2020-06-09 06:07:34 +00:00
# Adjust startts forward if not enough data is available
self.timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
self.required_startup, min_date)
2021-03-21 14:56:36 +00:00
self.progress.set_new_value(1)
return data, self.timerange
def load_bt_data_detail(self) -> None:
"""
Loads backtest detail data (smaller timeframe) if necessary.
"""
if self.timeframe_detail:
self.detail_data = history.load_data(
datadir=self.config['datadir'],
pairs=self.pairlists.whitelist,
timeframe=self.timeframe_detail,
timerange=self.timerange,
startup_candles=0,
fail_without_data=True,
data_format=self.config.get('dataformat_ohlcv', 'json'),
candle_type=self.config.get('candle_type_def', CandleType.SPOT)
)
else:
self.detail_data = {}
if self.trading_mode == TradingMode.FUTURES:
# Load additional futures data.
funding_rates_dict = history.load_data(
datadir=self.config['datadir'],
pairs=self.pairlists.whitelist,
timeframe=self.exchange._ft_has['mark_ohlcv_timeframe'],
timerange=self.timerange,
startup_candles=0,
fail_without_data=True,
data_format=self.config.get('dataformat_ohlcv', 'json'),
candle_type=CandleType.FUNDING_RATE
)
# For simplicity, assign to CandleType.Mark (might contian index candles!)
mark_rates_dict = history.load_data(
datadir=self.config['datadir'],
pairs=self.pairlists.whitelist,
timeframe=self.exchange._ft_has['mark_ohlcv_timeframe'],
timerange=self.timerange,
startup_candles=0,
fail_without_data=True,
data_format=self.config.get('dataformat_ohlcv', 'json'),
candle_type=CandleType.from_string(self.exchange._ft_has["mark_ohlcv_price"])
)
# Combine data to avoid combining the data per trade.
unavailable_pairs = []
for pair in self.pairlists.whitelist:
if pair not in self.exchange._leverage_tiers:
unavailable_pairs.append(pair)
continue
self.futures_data[pair] = funding_rates_dict[pair].merge(
mark_rates_dict[pair], on='date', how="inner", suffixes=["_fund", "_mark"])
if unavailable_pairs:
raise OperationalException(
f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. "
"It is therefore impossible to backtest with this pair at the moment.")
else:
self.futures_data = {}
2020-11-27 16:38:15 +00:00
def prepare_backtest(self, enable_protections):
"""
Backtesting setup method - called once for every call to "backtest()".
"""
PairLocks.use_db = False
PairLocks.timeframe = self.config['timeframe']
2020-11-27 16:38:15 +00:00
Trade.use_db = False
PairLocks.reset_locks()
Trade.reset_trades()
2021-05-23 07:36:02 +00:00
self.rejected_trades = 0
2022-02-07 17:49:30 +00:00
self.timedout_entry_orders = 0
self.timedout_exit_orders = 0
self.dataprovider.clear_cache()
if enable_protections:
self._load_protections(self.strategy)
2020-11-27 16:38:15 +00:00
def check_abort(self):
"""
Check if abort was requested, raise DependencyException if that's the case
Only applies to Interactive backtest mode (webserver mode)
"""
if self.abort:
self.abort = False
raise DependencyException("Stop requested")
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
"""
Helper function to convert a processed dataframes into lists for performance reasons.
Used by backtest() - so keep this optimized for performance.
:param processed: a processed dictionary with format {pair, data}, which gets cleared to
optimize memory usage!
"""
data: Dict = {}
2021-03-21 14:56:36 +00:00
self.progress.init_step(BacktestState.CONVERT, len(processed))
# Create dict with data
for pair in processed.keys():
pair_data = processed[pair]
self.check_abort()
2021-03-21 14:56:36 +00:00
self.progress.increment()
2021-05-20 04:49:25 +00:00
if not pair_data.empty:
# Cleanup from prior runs
pair_data.drop(HEADERS[5:] + ['buy', 'sell'], axis=1, errors='ignore')
2021-09-22 18:42:31 +00:00
df_analyzed = self.strategy.advise_exit(
self.strategy.advise_entry(pair_data, {'pair': pair}),
2021-08-18 12:03:44 +00:00
{'pair': pair}
).copy()
# Trim startup period from analyzed dataframe
df_analyzed = processed[pair] = pair_data = trim_dataframe(
df_analyzed, self.timerange, startup_candles=self.required_startup)
# Update dataprovider cache
2022-02-11 16:02:04 +00:00
self.dataprovider._set_cached_df(
pair, self.timeframe, df_analyzed, self.config['candle_type_def'])
2022-04-24 12:28:15 +00:00
# Create a copy of the dataframe before shifting, that way the entry signal/tag
# remains on the correct candle for callbacks.
df_analyzed = df_analyzed.copy()
2022-04-24 12:28:15 +00:00
# To avoid using data from future, we use entry/exit signals shifted
# from the previous candle
for col in HEADERS[5:]:
tag_col = col in ('enter_tag', 'exit_tag')
if col in df_analyzed.columns:
df_analyzed.loc[:, col] = df_analyzed.loc[:, col].replace(
[nan], [0 if not tag_col else None]).shift(1)
elif not df_analyzed.empty:
df_analyzed.loc[:, col] = 0 if not tag_col else None
df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
# Convert from Pandas to list for performance reasons
# (Looping Pandas is slow.)
data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else []
return data
def _get_close_rate(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
2020-02-02 04:00:40 +00:00
trade_dur: int) -> float:
"""
Get close rate for backtesting result
"""
# Special handling if high or low hit STOP_LOSS or ROI
if exit.exit_type in (ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS):
return self._get_close_rate_for_stoploss(row, trade, exit, trade_dur)
elif exit.exit_type == (ExitType.ROI):
return self._get_close_rate_for_roi(row, trade, exit, trade_dur)
2022-03-14 10:38:44 +00:00
else:
return row[OPEN_IDX]
2022-03-14 10:38:44 +00:00
def _get_close_rate_for_stoploss(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
trade_dur: int) -> float:
# our stoploss was already lower than candle high,
# possibly due to a cancelled trade exit.
# exit at open price.
is_short = trade.is_short or False
2022-03-14 10:38:44 +00:00
leverage = trade.leverage or 1.0
side_1 = -1 if is_short else 1
if is_short:
if trade.stop_loss < row[LOW_IDX]:
return row[OPEN_IDX]
else:
if trade.stop_loss > row[HIGH_IDX]:
return row[OPEN_IDX]
# Special case: trailing triggers within same candle as trade opened. Assume most
# pessimistic price movement, which is moving just enough to arm stoploss and
# immediately going down to stop price.
if exit.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0:
if (
not self.strategy.use_custom_stoploss and self.strategy.trailing_stop
and self.strategy.trailing_only_offset_is_reached
and self.strategy.trailing_stop_positive_offset is not None
and self.strategy.trailing_stop_positive
):
# Worst case: price reaches stop_positive_offset and dives down.
stop_rate = (row[OPEN_IDX] *
2022-03-15 04:23:59 +00:00
(1 + side_1 * abs(self.strategy.trailing_stop_positive_offset) -
side_1 * abs(self.strategy.trailing_stop_positive / leverage)))
else:
# Worst case: price ticks tiny bit above open and dives down.
stop_rate = row[OPEN_IDX] * (1 - side_1 * abs(trade.stop_loss_pct / leverage))
if is_short:
assert stop_rate > row[LOW_IDX]
else:
assert stop_rate < row[HIGH_IDX]
2019-12-07 14:18:12 +00:00
# Limit lower-end to candle low to avoid exits below the low.
# This still remains "worst case" - but "worst realistic case".
if is_short:
return min(row[HIGH_IDX], stop_rate)
2022-03-14 10:38:44 +00:00
else:
return max(row[LOW_IDX], stop_rate)
2022-03-14 10:38:44 +00:00
# Set close_rate to stoploss
return trade.stop_loss
def _get_close_rate_for_roi(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple,
trade_dur: int) -> float:
is_short = trade.is_short or False
2022-03-14 10:38:44 +00:00
leverage = trade.leverage or 1.0
side_1 = -1 if is_short else 1
roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur)
if roi is not None and roi_entry is not None:
if roi == -1 and roi_entry % self.timeframe_min == 0:
2022-04-10 13:56:29 +00:00
# When force_exiting with ROI=-1, the roi time will always be equal to trade_dur.
# If that entry is a multiple of the timeframe (so on candle open)
# - we'll use open instead of close
return row[OPEN_IDX]
2022-03-14 10:38:44 +00:00
# - (Expected abs profit - open_rate - open_fee) / (fee_close -1)
roi_rate = trade.open_rate * roi / leverage
open_fee_rate = side_1 * trade.open_rate * (1 + side_1 * trade.fee_open)
2022-03-16 18:50:25 +00:00
close_rate = -(roi_rate + open_fee_rate) / (trade.fee_close - side_1 * 1)
if is_short:
is_new_roi = row[OPEN_IDX] < close_rate
else:
is_new_roi = row[OPEN_IDX] > close_rate
if (trade_dur > 0 and trade_dur == roi_entry
and roi_entry % self.timeframe_min == 0
and is_new_roi):
# new ROI entry came into effect.
# use Open rate if open_rate > calculated exit rate
return row[OPEN_IDX]
if (trade_dur == 0 and (
(
is_short
# Red candle (for longs)
and row[OPEN_IDX] < row[CLOSE_IDX] # Red candle
and trade.open_rate > row[OPEN_IDX] # trade-open above open_rate
and close_rate < row[CLOSE_IDX] # closes below close
)
or
(
not is_short
# green candle (for shorts)
and row[OPEN_IDX] > row[CLOSE_IDX] # green candle
and trade.open_rate < row[OPEN_IDX] # trade-open below open_rate
and close_rate > row[CLOSE_IDX] # closes above close
)
)):
# ROI on opening candles with custom pricing can only
# trigger if the entry was at Open or lower wick.
# details: https: // github.com/freqtrade/freqtrade/issues/6261
# If open_rate is < open, only allow exits below the close on red candles.
raise ValueError("Opening candle ROI on red candles.")
# Use the maximum between close_rate and low as we
# cannot exit outside of a candle.
# Applies when a new ROI setting comes in place and the whole candle is above that.
return min(max(close_rate, row[LOW_IDX]), row[HIGH_IDX])
2019-12-07 14:18:12 +00:00
else:
# This should not be reached...
return row[OPEN_IDX]
2021-12-18 09:00:25 +00:00
def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple
2021-12-18 09:15:59 +00:00
) -> LocalTrade:
current_profit = trade.calc_profit_ratio(row[OPEN_IDX])
min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, row[OPEN_IDX], -0.1)
max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, row[OPEN_IDX])
stake_available = self.wallets.get_available_stake_amount()
2021-12-18 09:00:25 +00:00
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
default_retval=None)(
trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
current_profit=current_profit, min_stake=min_stake,
max_stake=min(max_stake, stake_available))
# Check if we should increase our position
if stake_amount is not None and stake_amount > 0.0:
2022-01-22 16:25:21 +00:00
pos_trade = self._enter_trade(
trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade)
if pos_trade is not None:
self.wallets.update()
return pos_trade
return trade
2022-01-16 18:39:42 +00:00
def _get_order_filled(self, rate: float, row: Tuple) -> bool:
""" Rate is within candle, therefore filled"""
2022-01-30 14:27:18 +00:00
return row[LOW_IDX] <= rate <= row[HIGH_IDX]
def _get_exit_trade_entry_for_candle(self, trade: LocalTrade,
row: Tuple) -> Optional[LocalTrade]:
# Check if we need to adjust our current positions
if self.strategy.position_adjustment_enable:
2022-02-13 14:10:09 +00:00
check_adjust_entry = True
if self.strategy.max_entry_position_adjustment > -1:
2022-02-13 14:10:09 +00:00
entry_count = trade.nr_of_successful_entries
check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment)
if check_adjust_entry:
trade = self._get_adjust_trade_entry_for_candle(trade, row)
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
exit_ = self.strategy.should_exit(
trade, row[OPEN_IDX], exit_candle_time, # type: ignore
enter=enter, exit_=exit_sig,
low=row[LOW_IDX], high=row[HIGH_IDX]
2021-10-02 09:15:12 +00:00
)
if exit_.exit_flag:
trade.close_date = exit_candle_time
2021-03-25 08:25:25 +00:00
2022-03-09 16:34:59 +00:00
trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
2022-02-14 19:02:38 +00:00
try:
closerate = self._get_close_rate(row, trade, exit_, trade_dur)
2022-02-14 19:02:38 +00:00
except ValueError:
return None
# call the custom exit price,with default value as previous closerate
current_profit = trade.calc_profit_ratio(closerate)
order_type = self.strategy.order_types['exit']
if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT):
# Custom exit pricing only for exit-signals
if order_type == 'limit':
closerate = strategy_safe_wrapper(self.strategy.custom_exit_price,
default_retval=closerate)(
pair=trade.pair, trade=trade,
current_time=exit_candle_time,
proposed_rate=closerate, current_profit=current_profit,
exit_tag=exit_.exit_reason)
# We can't place orders lower than current low.
# freqtrade does not support this in live, and the order would fill immediately
if trade.is_short:
closerate = min(closerate, row[HIGH_IDX])
else:
closerate = max(closerate, row[LOW_IDX])
2021-03-25 08:25:25 +00:00
# Confirm trade exit:
time_in_force = self.strategy.order_time_in_force['exit']
2021-03-25 08:25:25 +00:00
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
rate=closerate,
time_in_force=time_in_force,
sell_reason=exit_.exit_reason, # deprecated
exit_reason=exit_.exit_reason,
current_time=exit_candle_time):
2021-03-25 08:25:25 +00:00
return None
trade.exit_reason = exit_.exit_reason
# Checks and adds an exit tag, after checking that the length of the
# row has the length for an exit tag column
if(
len(row) > EXIT_TAG_IDX
and row[EXIT_TAG_IDX] is not None
and len(row[EXIT_TAG_IDX]) > 0
and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
):
trade.exit_reason = row[EXIT_TAG_IDX]
2022-01-22 13:11:33 +00:00
self.order_id_counter += 1
2022-01-16 18:39:42 +00:00
order = Order(
2022-01-22 13:11:33 +00:00
id=self.order_id_counter,
ft_trade_id=trade.id,
order_date=exit_candle_time,
order_update_date=exit_candle_time,
2022-01-22 13:11:33 +00:00
ft_is_open=True,
2022-01-16 18:39:42 +00:00
ft_pair=trade.pair,
2022-01-22 13:11:33 +00:00
order_id=str(self.order_id_counter),
2022-01-16 18:39:42 +00:00
symbol=trade.pair,
ft_order_side=trade.exit_side,
side=trade.exit_side,
order_type=order_type,
2022-01-22 13:11:33 +00:00
status="open",
2022-01-16 18:39:42 +00:00
price=closerate,
average=closerate,
amount=trade.amount,
2022-01-22 13:11:33 +00:00
filled=0,
remaining=trade.amount,
cost=trade.amount * closerate,
2022-01-16 18:39:42 +00:00
)
trade.orders.append(order)
return trade
return None
def _get_exit_trade_entry(self, trade: LocalTrade, row: Tuple) -> Optional[LocalTrade]:
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
if self.trading_mode == TradingMode.FUTURES:
trade.funding_fees = self.exchange.calculate_funding_fees(
self.futures_data[trade.pair],
amount=trade.amount,
is_short=trade.is_short,
open_date=trade.open_date_utc,
close_date=exit_candle_time,
)
if self.timeframe_detail and trade.pair in self.detail_data:
exit_candle_end = exit_candle_time + timedelta(minutes=self.timeframe_min)
detail_data = self.detail_data[trade.pair]
detail_data = detail_data.loc[
(detail_data['date'] >= exit_candle_time) &
(detail_data['date'] < exit_candle_end)
2021-10-02 09:15:12 +00:00
].copy()
if len(detail_data) == 0:
# Fall back to "regular" data if no detail data was found for this candle
return self._get_exit_trade_entry_for_candle(trade, row)
detail_data.loc[:, 'enter_long'] = row[LONG_IDX]
detail_data.loc[:, 'exit_long'] = row[ELONG_IDX]
detail_data.loc[:, 'enter_short'] = row[SHORT_IDX]
detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX]
detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX]
detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX]
for det_row in detail_data[HEADERS].values.tolist():
res = self._get_exit_trade_entry_for_candle(trade, det_row)
if res:
return res
return None
else:
return self._get_exit_trade_entry_for_candle(trade, row)
def get_valid_price_and_stake(
self, pair: str, row: Tuple, propose_rate: float, stake_amount: Optional[float],
direction: LongShort, current_time: datetime, entry_tag: Optional[str],
trade: Optional[LocalTrade], order_type: str
) -> Tuple[float, float, float, float]:
2022-01-22 13:11:33 +00:00
if order_type == 'limit':
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=propose_rate)(
2022-01-22 13:11:33 +00:00
pair=pair, current_time=current_time,
2022-04-04 14:48:27 +00:00
proposed_rate=propose_rate, entry_tag=entry_tag,
side=direction,
) # default value is the open rate
2022-04-24 12:28:15 +00:00
# We can't place orders higher than current high (otherwise it'd be a stop limit entry)
# which freqtrade does not support in live.
2022-03-09 12:00:06 +00:00
if direction == "short":
propose_rate = max(propose_rate, row[LOW_IDX])
else:
propose_rate = min(propose_rate, row[HIGH_IDX])
pos_adjust = trade is not None
leverage = trade.leverage if trade else 1.0
if not pos_adjust:
try:
stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False)
except DependencyException:
return 0, 0, 0, 0
max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
pair=pair,
current_time=current_time,
current_rate=row[OPEN_IDX],
proposed_leverage=1.0,
max_leverage=max_leverage,
side=direction,
) if self._can_short else 1.0
# Cap leverage between 1.0 and max_leverage.
leverage = min(max(leverage, 1.0), max_leverage)
min_stake_amount = self.exchange.get_min_pair_stake_amount(
pair, propose_rate, -0.05, leverage=leverage) or 0
max_stake_amount = self.exchange.get_max_pair_stake_amount(
pair, propose_rate, leverage=leverage)
stake_available = self.wallets.get_available_stake_amount()
2022-01-13 18:24:21 +00:00
if not pos_adjust:
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
2022-01-22 16:25:21 +00:00
pair=pair, current_time=current_time, current_rate=propose_rate,
proposed_stake=stake_amount, min_stake=min_stake_amount,
max_stake=min(stake_available, max_stake_amount),
2022-01-29 13:19:30 +00:00
entry_tag=entry_tag, side=direction)
stake_amount_val = self.wallets.validate_stake_amount(
pair=pair,
stake_amount=stake_amount,
min_stake_amount=min_stake_amount,
max_stake_amount=max_stake_amount,
)
return propose_rate, stake_amount_val, leverage, min_stake_amount
def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
stake_amount: Optional[float] = None,
trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]:
current_time = row[DATE_IDX].to_pydatetime()
entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None
# let's call the custom entry price, using the open price as default price
order_type = self.strategy.order_types['entry']
pos_adjust = trade is not None
propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
pair, row, row[OPEN_IDX], stake_amount, direction, current_time, entry_tag, trade,
order_type
)
if not stake_amount:
# In case of pos adjust, still return the original trade
# If not pos adjust, trade is None
return trade
time_in_force = self.strategy.order_time_in_force['entry']
2022-02-21 18:27:38 +00:00
if not pos_adjust:
2022-02-28 19:10:23 +00:00
# Confirm trade entry:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
2022-01-22 16:25:21 +00:00
time_in_force=time_in_force, current_time=current_time,
2022-01-29 13:19:30 +00:00
entry_tag=entry_tag, side=direction):
2022-02-28 19:10:23 +00:00
return trade
2021-02-20 19:21:30 +00:00
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
2022-01-22 13:11:33 +00:00
self.order_id_counter += 1
2022-04-09 14:42:18 +00:00
base_currency = self.exchange.get_pair_base_currency(pair)
amount = round((stake_amount / propose_rate) * leverage, 8)
is_short = (direction == 'short')
2022-02-28 18:45:15 +00:00
# Necessary for Margin trading. Disabled until support is enabled.
# interest_rate = self.exchange.get_interest_rate()
if trade is None:
# Enter trade
2022-01-22 13:11:33 +00:00
self.trade_id_counter += 1
trade = LocalTrade(
2022-01-22 13:11:33 +00:00
id=self.trade_id_counter,
open_order_id=self.order_id_counter,
pair=pair,
2022-04-09 14:42:18 +00:00
base_currency=base_currency,
stake_currency=self.config['stake_currency'],
open_rate=propose_rate,
open_rate_requested=propose_rate,
2022-01-22 16:25:21 +00:00
open_date=current_time,
stake_amount=stake_amount,
amount=amount,
2022-01-22 13:11:33 +00:00
amount_requested=amount,
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
2022-01-29 13:19:30 +00:00
enter_tag=entry_tag,
2022-01-22 16:25:21 +00:00
exchange=self._exchange_name,
is_short=is_short,
trading_mode=self.trading_mode,
2022-01-22 16:25:21 +00:00
leverage=leverage,
2022-02-28 18:45:15 +00:00
# interest_rate=interest_rate,
orders=[],
)
2022-01-30 14:27:18 +00:00
2022-01-22 14:03:12 +00:00
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
2022-03-14 03:29:26 +00:00
trade.set_isolated_liq(self.exchange.get_liquidation_price(
2021-02-10 19:37:55 +00:00
pair=pair,
2022-03-14 03:29:26 +00:00
open_rate=propose_rate,
amount=amount,
leverage=leverage,
is_short=is_short,
))
order = Order(
2022-01-22 13:11:33 +00:00
id=self.order_id_counter,
ft_trade_id=trade.id,
ft_is_open=True,
ft_pair=trade.pair,
2022-01-22 13:11:33 +00:00
order_id=str(self.order_id_counter),
symbol=trade.pair,
2022-04-06 01:02:13 +00:00
ft_order_side=trade.entry_side,
side=trade.entry_side,
2022-01-22 13:11:33 +00:00
order_type=order_type,
status="open",
order_date=current_time,
order_filled_date=current_time,
order_update_date=current_time,
price=propose_rate,
average=propose_rate,
amount=amount,
2022-01-22 13:11:33 +00:00
filled=0,
remaining=amount,
cost=stake_amount + trade.fee_open,
2021-02-10 19:37:55 +00:00
)
if pos_adjust and self._get_order_filled(order.price, row):
order.close_bt_order(current_time)
else:
2022-01-30 16:47:37 +00:00
trade.open_order_id = str(self.order_id_counter)
trade.orders.append(order)
2022-01-22 13:11:33 +00:00
trade.recalc_trade_from_orders()
return trade
2021-02-10 19:37:55 +00:00
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
data: Dict[str, List[Tuple]]) -> List[LocalTrade]:
"""
Handling of left open trades at the end of backtesting
"""
trades = []
for pair in open_trades.keys():
if len(open_trades[pair]) > 0:
for trade in open_trades[pair]:
2022-02-13 14:10:09 +00:00
if trade.open_order_id and trade.nr_of_successful_entries == 0:
2022-04-24 12:28:15 +00:00
# Ignore trade if entry-order did not fill yet
2022-01-22 13:11:33 +00:00
continue
exit_row = data[pair][-1]
trade.close_date = exit_row[DATE_IDX].to_pydatetime()
2022-04-04 14:59:27 +00:00
trade.exit_reason = ExitType.FORCE_EXIT.value
trade.close(exit_row[OPEN_IDX], show_msg=False)
LocalTrade.close_bt_trade(trade)
2021-01-28 06:06:58 +00:00
# Deepcopy object to have wallets update correctly
trade1 = deepcopy(trade)
trade1.is_open = True
trades.append(trade1)
return trades
2021-05-23 07:36:02 +00:00
def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool:
2021-05-23 07:46:51 +00:00
# Always allow trades when max_open_trades is enabled.
if max_open_trades <= 0 or open_trade_count < max_open_trades:
2021-05-23 07:36:02 +00:00
return True
# Rejected trade
self.rejected_trades += 1
return False
def check_for_trade_entry(self, row) -> Optional[LongShort]:
2021-08-24 04:54:55 +00:00
enter_long = row[LONG_IDX] == 1
exit_long = row[ELONG_IDX] == 1
enter_short = self._can_short and row[SHORT_IDX] == 1
exit_short = self._can_short and row[ESHORT_IDX] == 1
if enter_long == 1 and not any([exit_long, enter_short]):
# Long
return 'long'
if enter_short == 1 and not any([exit_short, enter_long]):
# Short
return 'short'
return None
2022-04-24 08:58:21 +00:00
def run_protections(
self, enable_protections, pair: str, current_time: datetime, side: LongShort):
2022-01-30 16:17:03 +00:00
if enable_protections:
self.protections.stop_per_pair(pair, current_time, side)
self.protections.global_stop(current_time, side)
2022-01-30 16:17:03 +00:00
2022-01-30 16:39:23 +00:00
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool:
"""
Check if an order has been canceled.
Returns True if the trade should be Deleted (initial order was canceled).
"""
for order in [o for o in trade.orders if o.ft_is_open]:
timedout = self.strategy.ft_check_timed_out(trade, order, current_time)
2022-01-30 16:39:23 +00:00
if timedout:
2022-04-06 01:02:13 +00:00
if order.side == trade.entry_side:
2022-02-07 17:49:30 +00:00
self.timedout_entry_orders += 1
2022-02-13 14:10:09 +00:00
if trade.nr_of_successful_entries == 0:
# Remove trade due to entry timeout expiration.
2022-01-30 16:39:23 +00:00
return True
else:
2022-04-24 12:28:15 +00:00
# Close additional entry order
2022-01-30 16:39:23 +00:00
del trade.orders[trade.orders.index(order)]
if order.side == trade.exit_side:
2022-02-07 17:49:30 +00:00
self.timedout_exit_orders += 1
# Close exit order and retry exiting on next signal.
2022-01-30 16:39:23 +00:00
del trade.orders[trade.orders.index(order)]
return False
def validate_row(
self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
try:
# Row is treated as "current incomplete candle".
2022-04-24 12:28:15 +00:00
# entry / exit signals are shifted by 1 to compensate for this.
row = data[pair][row_index]
except IndexError:
# missing Data for one pair at the end.
# Warnings for this are shown during data loading
return None
# Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > current_time:
return None
return row
2021-02-10 19:37:55 +00:00
def backtest(self, processed: Dict,
start_date: datetime, end_date: datetime,
max_open_trades: int = 0, position_stacking: bool = False,
enable_protections: bool = False) -> Dict[str, Any]:
"""
2019-12-13 23:12:16 +00:00
Implement backtesting functionality
NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
Of course try to not have ugly code. By some accessor are sometime slower than functions.
2019-12-13 23:12:16 +00:00
Avoid extensive logging in this method and functions it calls.
:param processed: a processed dictionary with format {pair, data}, which gets cleared to
optimize memory usage!
2019-12-13 23:12:16 +00:00
:param start_date: backtesting timerange start datetime
:param end_date: backtesting timerange end datetime
:param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited
:param position_stacking: do we allow position stacking?
:param enable_protections: Should protections be enabled?
2019-12-13 23:12:16 +00:00
:return: DataFrame with trades (results of backtesting)
"""
trades: List[LocalTrade] = []
2020-11-27 16:38:15 +00:00
self.prepare_backtest(enable_protections)
# Ensure wallets are uptodate (important for --strategy-list)
self.wallets.update()
# Use dict of lists with data for performance
# (looping lists is a lot faster than pandas DataFrames)
data: Dict = self._get_ohlcv_as_lists(processed)
2019-04-04 18:23:10 +00:00
# Indexes per pair, so some pairs are allowed to have a missing start.
2021-04-24 17:15:09 +00:00
indexes: Dict = defaultdict(int)
2022-01-22 13:11:33 +00:00
current_time = start_date + timedelta(minutes=self.timeframe_min)
2019-03-20 17:38:10 +00:00
open_trades: Dict[str, List[LocalTrade]] = defaultdict(list)
open_trade_count = 0
2021-03-21 14:56:36 +00:00
self.progress.init_step(BacktestState.BACKTEST, int(
(end_date - start_date) / timedelta(minutes=self.timeframe_min)))
2021-03-11 18:16:18 +00:00
2019-04-04 18:23:10 +00:00
# Loop timerange and get candle for each pair at that point in time
2022-01-22 13:11:33 +00:00
while current_time <= end_date:
open_trade_count_start = open_trade_count
self.check_abort()
for i, pair in enumerate(data):
row_index = indexes[pair]
row = self.validate_row(data, pair, row_index, current_time)
if not row:
2019-03-20 17:38:10 +00:00
continue
row_index += 1
indexes[pair] = row_index
self.dataprovider._set_dataframe_max_index(row_index)
2019-03-20 17:38:10 +00:00
2022-03-28 13:29:50 +00:00
for t in list(open_trades[pair]):
2022-04-24 12:28:15 +00:00
# 1. Cancel expired entry/exit orders.
2022-03-28 13:29:50 +00:00
if self.check_order_cancel(t, current_time):
2022-04-24 12:28:15 +00:00
# Close trade due to entry timeout expiration.
open_trade_count -= 1
2022-03-28 13:29:50 +00:00
open_trades[pair].remove(t)
self.wallets.update()
2022-04-24 12:28:15 +00:00
# 2. Process entries.
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
# don't open on the last row
trade_dir = self.check_for_trade_entry(row)
2021-05-23 07:36:02 +00:00
if (
(position_stacking or len(open_trades[pair]) == 0)
and self.trade_slot_available(max_open_trades, open_trade_count_start)
2022-01-22 13:11:33 +00:00
and current_time != end_date
and trade_dir is not None
and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
2021-05-23 07:36:02 +00:00
):
trade = self._enter_trade(pair, row, trade_dir)
2021-02-10 19:37:55 +00:00
if trade:
# TODO: hacky workaround to avoid opening > max_open_trades
2022-01-30 14:27:18 +00:00
# This emulates previous behavior - not sure if this is correct
2022-04-24 12:28:15 +00:00
# Prevents entering if the trade-slot was freed in this candle
2021-02-10 19:37:55 +00:00
open_trade_count_start += 1
open_trade_count += 1
2021-02-16 06:56:35 +00:00
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
2021-02-10 19:37:55 +00:00
open_trades[pair].append(trade)
2021-08-11 06:35:16 +00:00
for trade in list(open_trades[pair]):
# 3. Process entry orders.
2022-04-06 01:02:13 +00:00
order = trade.select_order(trade.entry_side, is_open=True)
2022-01-22 13:11:33 +00:00
if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time)
2022-01-22 13:11:33 +00:00
trade.open_order_id = None
LocalTrade.add_bt_trade(trade)
self.wallets.update()
2022-01-22 13:11:33 +00:00
# 4. Create exit orders (if any)
2022-01-22 13:11:33 +00:00
if not trade.open_order_id:
self._get_exit_trade_entry(trade, row) # Place exit order if necessary
2022-01-22 13:11:33 +00:00
# 5. Process exit orders.
order = trade.select_order(trade.exit_side, is_open=True)
2022-01-22 13:11:33 +00:00
if order and self._get_order_filled(order.price, row):
trade.open_order_id = None
trade.close_date = current_time
2022-01-22 13:11:33 +00:00
trade.close(order.price, show_msg=False)
# logger.debug(f"{pair} - Backtesting exit {trade}")
open_trade_count -= 1
open_trades[pair].remove(trade)
2021-03-13 09:16:32 +00:00
LocalTrade.close_bt_trade(trade)
2022-01-22 13:11:33 +00:00
trades.append(trade)
self.wallets.update()
self.run_protections(
enable_protections, pair, current_time, trade.trade_direction)
# Move time one configured time_interval ahead.
2021-03-21 14:56:36 +00:00
self.progress.increment()
2022-01-22 13:11:33 +00:00
current_time += timedelta(minutes=self.timeframe_min)
trades += self.handle_left_open(open_trades, data=data)
self.wallets.update()
results = trade_list_to_dataframe(trades)
return {
'results': results,
'config': self.strategy.config,
'locks': PairLocks.get_all_locks(),
2021-05-23 07:46:51 +00:00
'rejected_signals': self.rejected_trades,
2022-02-07 17:49:30 +00:00
'timedout_entry_orders': self.timedout_entry_orders,
'timedout_exit_orders': self.timedout_exit_orders,
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
}
def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame],
timerange: TimeRange):
2021-03-21 14:56:36 +00:00
self.progress.init_step(BacktestState.ANALYZE, 0)
2022-04-30 12:51:57 +00:00
logger.info(f"Running backtesting for Strategy {strat.get_strategy_name()}")
2021-01-13 06:47:03 +00:00
backtest_start_time = datetime.now(timezone.utc)
2020-09-18 05:44:11 +00:00
self._set_strategy(strat)
strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)()
2020-09-18 05:44:11 +00:00
# Use max_open_trades in backtesting, except --disable-max-market-positions is set
if self.config.get('use_max_market_positions', True):
# Must come from strategy config, as the strategy may modify this setting.
max_open_trades = self.strategy.config['max_open_trades']
else:
logger.info(
'Ignoring max_open_trades (--disable-max-market-positions was used) ...')
max_open_trades = 0
# need to reprocess data every time to populate signals
preprocessed = self.strategy.advise_all_indicators(data)
2020-09-18 05:44:11 +00:00
# Trim startup period from analyzed dataframe
preprocessed_tmp = trim_dataframes(preprocessed, timerange, self.required_startup)
Fix exception when few pairs with no data do not result in aborting backtest. Exception is triggered by backtesting 20210301-20210501 range with BAKE/USDT pair (binance). Pair data starts on 2021-04-30 12:00:00 and after adjusting for startup candles pair dataframe is empty. Solution: Since there are other pairs with enough data - skip pairs with no data and issue a warning. Exception: ``` Traceback (most recent call last): File "/home/rk/src/freqtrade/freqtrade/main.py", line 37, in main return_code = args['func'](args) File "/home/rk/src/freqtrade/freqtrade/commands/optimize_commands.py", line 53, in start_backtesting backtesting.start() File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 502, in start min_date, max_date = self.backtest_one_strategy(strat, data, timerange) File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 474, in backtest_one_strategy results = self.backtest( File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 365, in backtest data: Dict = self._get_ohlcv_as_lists(processed) File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 199, in _get_ohlcv_as_lists pair_data.loc[:, 'buy'] = 0 # cleanup from previous run File "/home/rk/src/freqtrade/venv/lib/python3.9/site-packages/pandas/core/indexing.py", line 692, in __setitem__ iloc._setitem_with_indexer(indexer, value, self.name) File "/home/rk/src/freqtrade/venv/lib/python3.9/site-packages/pandas/core/indexing.py", line 1587, in _setitem_with_indexer raise ValueError( ValueError: cannot set a frame with no defined index and a scalar ```
2021-05-13 06:47:28 +00:00
if not preprocessed_tmp:
2021-05-06 18:49:48 +00:00
raise OperationalException(
Fix exception when few pairs with no data do not result in aborting backtest. Exception is triggered by backtesting 20210301-20210501 range with BAKE/USDT pair (binance). Pair data starts on 2021-04-30 12:00:00 and after adjusting for startup candles pair dataframe is empty. Solution: Since there are other pairs with enough data - skip pairs with no data and issue a warning. Exception: ``` Traceback (most recent call last): File "/home/rk/src/freqtrade/freqtrade/main.py", line 37, in main return_code = args['func'](args) File "/home/rk/src/freqtrade/freqtrade/commands/optimize_commands.py", line 53, in start_backtesting backtesting.start() File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 502, in start min_date, max_date = self.backtest_one_strategy(strat, data, timerange) File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 474, in backtest_one_strategy results = self.backtest( File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 365, in backtest data: Dict = self._get_ohlcv_as_lists(processed) File "/home/rk/src/freqtrade/freqtrade/optimize/backtesting.py", line 199, in _get_ohlcv_as_lists pair_data.loc[:, 'buy'] = 0 # cleanup from previous run File "/home/rk/src/freqtrade/venv/lib/python3.9/site-packages/pandas/core/indexing.py", line 692, in __setitem__ iloc._setitem_with_indexer(indexer, value, self.name) File "/home/rk/src/freqtrade/venv/lib/python3.9/site-packages/pandas/core/indexing.py", line 1587, in _setitem_with_indexer raise ValueError( ValueError: cannot set a frame with no defined index and a scalar ```
2021-05-13 06:47:28 +00:00
"No data left after adjusting for startup candles.")
# Use preprocessed_tmp for date generation (the trimmed dataframe).
2022-04-24 12:28:15 +00:00
# Backtesting will re-trim the dataframes after entry/exit signal generation.
min_date, max_date = history.get_timerange(preprocessed_tmp)
2020-09-18 05:44:11 +00:00
logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
2021-05-06 18:49:48 +00:00
f'({(max_date - min_date).days} days).')
2020-09-18 05:44:11 +00:00
# Execute backtest and store results
results = self.backtest(
processed=preprocessed,
start_date=min_date,
end_date=max_date,
2020-09-18 05:44:11 +00:00
max_open_trades=max_open_trades,
position_stacking=self.config.get('position_stacking', False),
enable_protections=self.config.get('enable_protections', False),
)
2021-01-13 06:47:03 +00:00
backtest_end_time = datetime.now(timezone.utc)
results.update({
'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
2021-01-13 06:47:03 +00:00
'backtest_start_time': int(backtest_start_time.timestamp()),
'backtest_end_time': int(backtest_end_time.timestamp()),
})
self.all_results[self.strategy.get_strategy_name()] = results
if (self.config.get('export', 'none') == 'signals' and
self.dataprovider.runmode == RunMode.BACKTEST):
self._generate_trade_signal_candles(preprocessed_tmp, results)
return min_date, max_date
def _generate_trade_signal_candles(self, preprocessed_df, bt_results):
signal_candles_only = {}
for pair in preprocessed_df.keys():
signal_candles_only_df = DataFrame()
pairdf = preprocessed_df[pair]
resdf = bt_results['results']
pairresults = resdf.loc[(resdf["pair"] == pair)]
if pairdf.shape[0] > 0:
for t, v in pairresults.open_date.items():
allinds = pairdf.loc[(pairdf['date'] < v)]
signal_inds = allinds.iloc[[-1]]
2022-04-25 09:31:19 +00:00
signal_candles_only_df = pd.concat([signal_candles_only_df, signal_inds])
signal_candles_only[pair] = signal_candles_only_df
self.processed_dfs[self.strategy.get_strategy_name()] = signal_candles_only
2020-09-18 05:44:11 +00:00
def _get_min_cached_backtest_date(self):
min_backtest_date = None
backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
if self.timerange.stopts == 0 or datetime.fromtimestamp(
self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc):
logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
elif backtest_cache_age == 'day':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
elif backtest_cache_age == 'week':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
elif backtest_cache_age == 'month':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
return min_backtest_date
def load_prior_backtest(self):
self.run_ids = {
strategy.get_strategy_name(): get_strategy_run_id(strategy)
for strategy in self.strategylist
}
# Load previous result that will be updated incrementally.
2022-01-16 17:01:05 +00:00
# This can be circumvented in certain instances in combination with downloading more data
min_backtest_date = self._get_min_cached_backtest_date()
if min_backtest_date is not None:
self.results = find_existing_backtest_stats(
self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
def start(self) -> None:
"""
2019-12-13 23:12:16 +00:00
Run backtesting end-to-end
:return: None
"""
data: Dict[str, Any] = {}
2019-12-13 23:12:16 +00:00
data, timerange = self.load_bt_data()
self.load_bt_data_detail()
logger.info("Dataload complete. Calculating indicators")
self.load_prior_backtest()
2018-07-28 05:41:38 +00:00
for strat in self.strategylist:
if self.results and strat.get_strategy_name() in self.results['strategy']:
# When previous result hash matches - reuse that result and skip backtesting.
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
continue
2020-09-18 05:44:11 +00:00
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
# Update old results with new ones.
if len(self.all_results) > 0:
results = generate_backtest_stats(
data, self.all_results, min_date=min_date, max_date=max_date)
if self.results:
self.results['metadata'].update(results['metadata'])
self.results['strategy'].update(results['strategy'])
self.results['strategy_comparison'].extend(results['strategy_comparison'])
else:
self.results = results
2022-04-23 07:23:53 +00:00
if self.config.get('export', 'none') in ('trades', 'signals'):
store_backtest_stats(self.config['exportfilename'], self.results)
2020-09-26 12:55:12 +00:00
if (self.config.get('export', 'none') == 'signals' and
self.dataprovider.runmode == RunMode.BACKTEST):
2022-04-16 13:49:53 +00:00
store_backtest_signal_candles(self.config['exportfilename'], self.processed_dfs)
# Results may be mixed up now. Sort them so they follow --strategy-list order.
if 'strategy_list' in self.config and len(self.results) > 0:
self.results['strategy_comparison'] = sorted(
self.results['strategy_comparison'],
key=lambda c: self.config['strategy_list'].index(c['key']))
self.results['strategy'] = dict(
sorted(self.results['strategy'].items(),
key=lambda kv: self.config['strategy_list'].index(kv[0])))
if len(self.strategylist) > 0:
# Show backtest results
show_backtest_results(self.config, self.results)