freqtrade_origin/freqtrade/optimize/backtesting.py

443 lines
19 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
2018-07-18 18:08:55 +00:00
from datetime import datetime, timedelta
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
2018-03-17 21:44:47 +00:00
2018-03-29 18:16:25 +00:00
from pandas import DataFrame
2017-09-28 21:26:28 +00:00
2020-09-28 17:39:41 +00:00
from freqtrade.configuration import TimeRange, remove_credentials, validate_config_consistency
2020-06-26 05:46:59 +00:00
from freqtrade.constants import DATETIME_PRINT_FORMAT
2018-12-13 05:34:10 +00:00
from freqtrade.data import history
from freqtrade.data.converter import trim_dataframe
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import OperationalException
2019-10-20 11:56:01 +00:00
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
2020-09-28 17:39:41 +00:00
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
2020-06-26 05:46:59 +00:00
store_backtest_stats)
from freqtrade.pairlist.pairlistmanager import PairListManager
2017-09-28 21:26:28 +00:00
from freqtrade.persistence import Trade
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
2020-02-02 04:00:40 +00:00
from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType
2020-09-28 17:39:41 +00:00
2018-03-25 19:37:14 +00:00
logger = logging.getLogger(__name__)
# Indexes for backtest tuples
DATE_IDX = 0
BUY_IDX = 1
OPEN_IDX = 2
CLOSE_IDX = 3
SELL_IDX = 4
LOW_IDX = 5
HIGH_IDX = 6
2018-03-25 19:37:14 +00:00
2018-06-10 11:15:25 +00:00
class BacktestResult(NamedTuple):
"""
NamedTuple Defining BacktestResults inputs.
"""
pair: str
profit_percent: float
profit_abs: float
open_date: datetime
open_rate: float
2020-06-26 18:21:08 +00:00
open_fee: float
close_date: datetime
close_rate: float
2020-06-26 18:21:08 +00:00
close_fee: float
2020-06-26 18:51:36 +00:00
amount: float
2018-06-10 11:15:25 +00:00
trade_duration: float
2018-06-10 11:37:53 +00:00
open_at_end: bool
2018-07-11 18:03:40 +00:00
sell_reason: SellType
2018-06-10 11:15:25 +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:
self.config = config
# Reset keys for backtesting
remove_credentials(self.config)
2018-07-28 05:41:38 +00:00
self.strategylist: List[IStrategy] = []
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
2020-08-08 15:04:32 +00:00
dataprovider = DataProvider(self.config, self.exchange)
IStrategy.dp = dataprovider
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:
raise OperationalException("Timeframe (ticker interval) 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.pairlists = PairListManager(self.exchange, self.config)
if 'VolumePairList' in self.pairlists.name_list:
raise OperationalException("VolumePairList 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."
)
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])
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])
2019-06-09 23:08:54 +00:00
# Load one (first) strategy
2018-07-28 05:41:38 +00:00
self._set_strategy(self.strategylist[0])
2018-07-28 05:00:58 +00:00
def _set_strategy(self, strategy):
2018-07-28 04:54:33 +00:00
"""
Load strategy into backtesting
"""
self.strategy: IStrategy = strategy
# Set stoploss_on_exchange to false for backtesting,
# since a "perfect" stoploss-sell is assumed anyway
# And the regular "stoploss" function would not apply to that case
self.strategy.order_types['stoploss_on_exchange'] = False
2018-07-28 04:54:33 +00:00
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
timerange = TimeRange.parse_timerange(None if self.config.get(
'timerange') is None else str(self.config.get('timerange')))
data = history.load_data(
2019-12-23 18:32:31 +00:00
datadir=self.config['datadir'],
pairs=self.pairlists.whitelist,
timeframe=self.timeframe,
timerange=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'),
)
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)} '
f'({(max_date - min_date).days} days)..')
# Adjust startts forward if not enough data is available
2019-11-02 19:34:39 +00:00
timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
self.required_startup, min_date)
return data, timerange
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.
"""
# 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', 'buy', 'open', 'close', 'sell', 'low', 'high']
data: Dict = {}
# Create dict with data
for pair, pair_data in processed.items():
2019-11-03 09:38:21 +00:00
pair_data.loc[:, 'buy'] = 0 # cleanup from previous run
pair_data.loc[:, 'sell'] = 0 # cleanup from previous run
df_analyzed = self.strategy.advise_sell(
2019-09-18 19:57:17 +00:00
self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair})[headers].copy()
# To avoid using data from future, we use buy/sell signals shifted
# from the previous candle
2020-01-25 10:42:31 +00:00
df_analyzed.loc[:, 'buy'] = df_analyzed.loc[:, 'buy'].shift(1)
df_analyzed.loc[:, 'sell'] = df_analyzed.loc[:, 'sell'].shift(1)
df_analyzed.drop(df_analyzed.head(1).index, inplace=True)
# Convert from Pandas to list for performance reasons
# (Looping Pandas is slow.)
data[pair] = [x for x in df_analyzed.itertuples(index=False, name=None)]
return data
def _get_close_rate(self, sell_row: Tuple, trade: Trade, sell: SellCheckTuple,
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 sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
# Set close_rate to stoploss
return trade.stop_loss
elif sell.sell_type == (SellType.ROI):
2019-12-07 14:18:12 +00:00
roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur)
if roi is not None and roi_entry is not None:
2019-12-14 22:10:09 +00:00
if roi == -1 and roi_entry % self.timeframe_min == 0:
# When forceselling 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 sell_row[OPEN_IDX]
# - (Expected abs profit + open_rate + open_fee) / (fee_close -1)
close_rate = - (trade.open_rate * roi + trade.open_rate *
(1 + trade.fee_open)) / (trade.fee_close - 1)
if (trade_dur > 0 and trade_dur == roi_entry
2019-12-14 22:10:09 +00:00
and roi_entry % self.timeframe_min == 0
and sell_row[OPEN_IDX] > close_rate):
# new ROI entry came into effect.
# use Open rate if open_rate > calculated sell rate
return sell_row[OPEN_IDX]
# Use the maximum between close_rate and low as we
# cannot sell outside of a candle.
2019-12-07 14:18:12 +00:00
# Applies when a new ROI setting comes in place and the whole candle is above that.
return max(close_rate, sell_row[LOW_IDX])
2019-12-07 14:18:12 +00:00
else:
# This should not be reached...
return sell_row[OPEN_IDX]
else:
return sell_row[OPEN_IDX]
def _get_sell_trade_entry(self, trade: Trade, sell_row: Tuple) -> Optional[BacktestResult]:
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], sell_row[DATE_IDX],
sell_row[BUY_IDX], sell_row[SELL_IDX],
low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX])
if sell.sell_flag:
trade_dur = int((sell_row[DATE_IDX] - trade.open_date).total_seconds() // 60)
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
return BacktestResult(pair=trade.pair,
profit_percent=trade.calc_profit_ratio(rate=closerate),
profit_abs=trade.calc_profit(rate=closerate),
open_date=trade.open_date,
open_rate=trade.open_rate,
open_fee=self.fee,
close_date=sell_row[DATE_IDX],
close_rate=closerate,
close_fee=self.fee,
amount=trade.amount,
trade_duration=trade_dur,
open_at_end=False,
sell_reason=sell.sell_type
)
return None
def handle_left_open(self, open_trades: Dict[str, List[Trade]],
data: Dict[str, List[Tuple]]) -> List[BacktestResult]:
"""
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]:
sell_row = data[pair][-1]
trade_entry = BacktestResult(pair=trade.pair,
profit_percent=trade.calc_profit_ratio(
rate=sell_row[OPEN_IDX]),
profit_abs=trade.calc_profit(sell_row[OPEN_IDX]),
open_date=trade.open_date,
open_rate=trade.open_rate,
open_fee=self.fee,
close_date=sell_row[DATE_IDX],
close_rate=sell_row[OPEN_IDX],
close_fee=self.fee,
amount=trade.amount,
trade_duration=int((
sell_row[DATE_IDX] - trade.open_date
).total_seconds() // 60),
open_at_end=True,
sell_reason=SellType.FORCE_SELL
)
trades.append(trade_entry)
return trades
2020-01-25 11:48:13 +00:00
def backtest(self, processed: Dict, stake_amount: float,
start_date: datetime, end_date: datetime,
2019-12-13 23:12:16 +00:00
max_open_trades: int = 0, position_stacking: bool = False) -> DataFrame:
"""
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}
:param stake_amount: amount to use for each trade
: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?
:return: DataFrame with trades (results of backtesting)
"""
2019-12-13 23:12:16 +00:00
logger.debug(f"Run backtest, stake_amount: {stake_amount}, "
f"start_date: {start_date}, end_date: {end_date}, "
f"max_open_trades: {max_open_trades}, position_stacking: {position_stacking}"
2020-01-25 11:48:13 +00:00
)
trades = []
# 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.
2019-03-20 17:38:10 +00:00
indexes: Dict = {}
2019-12-11 06:12:37 +00:00
tmp = start_date + timedelta(minutes=self.timeframe_min)
2019-03-20 17:38:10 +00:00
open_trades: Dict[str, List] = defaultdict(list)
open_trade_count = 0
2019-04-04 18:23:10 +00:00
# Loop timerange and get candle for each pair at that point in time
while tmp <= end_date:
open_trade_count_start = open_trade_count
2019-03-20 17:38:10 +00:00
for i, pair in enumerate(data):
2019-03-20 17:38:10 +00:00
if pair not in indexes:
indexes[pair] = 0
try:
row = data[pair][indexes[pair]]
except IndexError:
# missing Data for one pair at the end.
# Warnings for this are shown during data loading
continue
# Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > tmp:
2019-03-20 17:38:10 +00:00
continue
indexes[pair] += 1
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
# don't open on the last row
if ((position_stacking or len(open_trades[pair]) == 0)
and max_open_trades > 0 and open_trade_count_start < max_open_trades
and tmp != end_date
and row[BUY_IDX] == 1 and row[SELL_IDX] != 1):
# Enter trade
trade = Trade(
pair=pair,
open_rate=row[OPEN_IDX],
open_date=row[DATE_IDX],
stake_amount=stake_amount,
amount=round(stake_amount / row[OPEN_IDX], 8),
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
)
# TODO: hacky workaround to avoid opening > max_open_trades
# This emulates previous behaviour - not sure if this is correct
# Prevents buying if the trade-slot was freed in this candle
open_trade_count_start += 1
open_trade_count += 1
# logger.debug(f"{pair} - Backtesting emulates creation of new trade: {trade}.")
open_trades[pair].append(trade)
for trade in open_trades[pair]:
# since indexes has been incremented before, we need to go one step back to
# also check the buying candle for sell conditions.
trade_entry = self._get_sell_trade_entry(trade, row)
# Sell occured
if trade_entry:
# logger.debug(f"{pair} - Backtesting sell {trade}")
open_trade_count -= 1
open_trades[pair].remove(trade)
trades.append(trade_entry)
# Move time one configured time_interval ahead.
tmp += timedelta(minutes=self.timeframe_min)
trades += self.handle_left_open(open_trades, data=data)
2018-06-10 11:15:25 +00:00
return DataFrame.from_records(trades, columns=BacktestResult._fields)
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
2018-03-25 19:37:14 +00:00
logger.info('Using stake_currency: %s ...', self.config['stake_currency'])
logger.info('Using stake_amount: %s ...', self.config['stake_amount'])
2019-12-13 23:12:16 +00:00
position_stacking = self.config.get('position_stacking', False)
data, timerange = self.load_bt_data()
all_results = {}
2018-07-28 05:41:38 +00:00
for strat in self.strategylist:
2018-07-28 04:54:33 +00:00
logger.info("Running backtesting for Strategy %s", strat.get_strategy_name())
2018-07-28 05:00:58 +00:00
self._set_strategy(strat)
2018-07-27 21:01:52 +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.ohlcvdata_to_dataframe(data)
2019-10-20 11:56:01 +00:00
# Trim startup period from analyzed dataframe
for pair, df in preprocessed.items():
preprocessed[pair] = trim_dataframe(df, timerange)
2019-12-17 22:06:03 +00:00
min_date, max_date = history.get_timerange(preprocessed)
2019-10-20 11:56:01 +00:00
2020-06-09 06:07:34 +00:00
logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
f'({(max_date - min_date).days} days)..')
2018-07-27 21:01:52 +00:00
# Execute backtest and print results
results = self.backtest(
processed=preprocessed,
stake_amount=self.config['stake_amount'],
start_date=min_date.datetime,
end_date=max_date.datetime,
max_open_trades=max_open_trades,
position_stacking=position_stacking,
2018-07-27 21:01:52 +00:00
)
all_results[self.strategy.get_strategy_name()] = {
'results': results,
'config': self.strategy.config,
}
2020-09-26 12:55:12 +00:00
stats = generate_backtest_stats(data, all_results, min_date=min_date, max_date=max_date)
if self.config.get('export', False):
store_backtest_stats(self.config['exportfilename'], stats)
2020-06-26 17:20:51 +00:00
# Show backtest results
2020-06-01 07:24:27 +00:00
show_backtest_results(self.config, stats)