Merge pull request #4851 from rokups/rk/backtest-dataprovider

Data provider support in backtesting
This commit is contained in:
Matthias
2021-05-10 19:11:05 +02:00
committed by GitHub
11 changed files with 237 additions and 151 deletions

View File

@@ -19,14 +19,25 @@ from freqtrade.state import RunMode
logger = logging.getLogger(__name__)
NO_EXCHANGE_EXCEPTION = 'Exchange is not available to DataProvider.'
MAX_DATAFRAME_CANDLES = 1000
class DataProvider:
def __init__(self, config: dict, exchange: Exchange, pairlists=None) -> None:
def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None:
self._config = config
self._exchange = exchange
self._pairlists = pairlists
self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {}
self.__slice_index: Optional[int] = None
def _set_dataframe_max_index(self, limit_index: int):
"""
Limit analyzed dataframe to max specified index.
:param limit_index: dataframe index.
"""
self.__slice_index = limit_index
def _set_cached_df(self, pair: str, timeframe: str, dataframe: DataFrame) -> None:
"""
@@ -45,40 +56,6 @@ class DataProvider:
"""
self._pairlists = pairlists
def refresh(self,
pairlist: ListPairsWithTimeframes,
helping_pairs: ListPairsWithTimeframes = None) -> None:
"""
Refresh data, called with each cycle
"""
if helping_pairs:
self._exchange.refresh_latest_ohlcv(pairlist + helping_pairs)
else:
self._exchange.refresh_latest_ohlcv(pairlist)
@property
def available_pairs(self) -> ListPairsWithTimeframes:
"""
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
Should be whitelist + open trades.
"""
return list(self._exchange._klines.keys())
def ohlcv(self, pair: str, timeframe: str = None, copy: bool = True) -> DataFrame:
"""
Get candle (OHLCV) data for the given pair as DataFrame
Please use the `available_pairs` method to verify which pairs are currently cached.
:param pair: pair to get the data for
:param timeframe: Timeframe to get data for
:param copy: copy dataframe before returning if True.
Use False only for read-only operations (where the dataframe is not modified)
"""
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
copy=copy)
else:
return DataFrame()
def historic_ohlcv(self, pair: str, timeframe: str = None) -> DataFrame:
"""
Get stored historical candle (OHLCV) data
@@ -111,47 +88,27 @@ class DataProvider:
def get_analyzed_dataframe(self, pair: str, timeframe: str) -> Tuple[DataFrame, datetime]:
"""
Retrieve the analyzed dataframe. Returns the full dataframe in trade mode (live / dry),
and the last 1000 candles (up to the time evaluated at this moment) in all other modes.
:param pair: pair to get the data for
:param timeframe: timeframe to get data for
:return: Tuple of (Analyzed Dataframe, lastrefreshed) for the requested pair / timeframe
combination.
Returns empty dataframe and Epoch 0 (1970-01-01) if no dataframe was cached.
"""
if (pair, timeframe) in self.__cached_pairs:
return self.__cached_pairs[(pair, timeframe)]
pair_key = (pair, timeframe)
if pair_key in self.__cached_pairs:
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
df, date = self.__cached_pairs[pair_key]
else:
df, date = self.__cached_pairs[pair_key]
if self.__slice_index is not None:
max_index = self.__slice_index
df = df.iloc[max(0, max_index - MAX_DATAFRAME_CANDLES):max_index]
return df, date
else:
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc))
def market(self, pair: str) -> Optional[Dict[str, Any]]:
"""
Return market data for the pair
:param pair: Pair to get the data for
:return: Market data dict from ccxt or None if market info is not available for the pair
"""
return self._exchange.markets.get(pair)
def ticker(self, pair: str):
"""
Return last ticker data from exchange
:param pair: Pair to get the data for
:return: Ticker dict from exchange or empty dict if ticker is not available for the pair
"""
try:
return self._exchange.fetch_ticker(pair)
except ExchangeError:
return {}
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
"""
Fetch latest l2 orderbook data
Warning: Does a network request - so use with common sense.
:param pair: pair to get the data for
:param maximum: Maximum number of orderbook entries to query
:return: dict including bids/asks with a total of `maximum` entries.
"""
return self._exchange.fetch_l2_order_book(pair, maximum)
@property
def runmode(self) -> RunMode:
"""
@@ -173,3 +130,86 @@ class DataProvider:
return self._pairlists.whitelist.copy()
else:
raise OperationalException("Dataprovider was not initialized with a pairlist provider.")
def clear_cache(self):
"""
Clear pair dataframe cache.
"""
self.__cached_pairs = {}
# Exchange functions
def refresh(self,
pairlist: ListPairsWithTimeframes,
helping_pairs: ListPairsWithTimeframes = None) -> None:
"""
Refresh data, called with each cycle
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
if helping_pairs:
self._exchange.refresh_latest_ohlcv(pairlist + helping_pairs)
else:
self._exchange.refresh_latest_ohlcv(pairlist)
@property
def available_pairs(self) -> ListPairsWithTimeframes:
"""
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
Should be whitelist + open trades.
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
return list(self._exchange._klines.keys())
def ohlcv(self, pair: str, timeframe: str = None, copy: bool = True) -> DataFrame:
"""
Get candle (OHLCV) data for the given pair as DataFrame
Please use the `available_pairs` method to verify which pairs are currently cached.
:param pair: pair to get the data for
:param timeframe: Timeframe to get data for
:param copy: copy dataframe before returning if True.
Use False only for read-only operations (where the dataframe is not modified)
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
copy=copy)
else:
return DataFrame()
def market(self, pair: str) -> Optional[Dict[str, Any]]:
"""
Return market data for the pair
:param pair: Pair to get the data for
:return: Market data dict from ccxt or None if market info is not available for the pair
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
return self._exchange.markets.get(pair)
def ticker(self, pair: str):
"""
Return last ticker data from exchange
:param pair: Pair to get the data for
:return: Ticker dict from exchange or empty dict if ticker is not available for the pair
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
try:
return self._exchange.fetch_ticker(pair)
except ExchangeError:
return {}
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
"""
Fetch latest l2 orderbook data
Warning: Does a network request - so use with common sense.
:param pair: pair to get the data for
:param maximum: Maximum number of orderbook entries to query
:return: dict including bids/asks with a total of `maximum` entries.
"""
if self._exchange is None:
raise OperationalException(NO_EXCHANGE_EXCEPTION)
return self._exchange.fetch_l2_order_book(pair, maximum)

View File

@@ -11,7 +11,6 @@ from typing import Any, Dict, List, Optional
import arrow
from cachetools import TTLCache
from pandas import DataFrame
from freqtrade import __version__, constants
from freqtrade.configuration import validate_config_consistency
@@ -553,7 +552,7 @@ class FreqtradeBot(LoggingMixin):
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=amount, rate=buy_limit_requested,
time_in_force=time_in_force):
time_in_force=time_in_force, current_time=datetime.now(timezone.utc)):
logger.info(f"User requested abortion of buying {pair}")
return False
amount = self.exchange.amount_to_precision(pair, amount)
@@ -784,10 +783,10 @@ class FreqtradeBot(LoggingMixin):
config_ask_strategy = self.config.get('ask_strategy', {})
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
self.strategy.timeframe)
if (config_ask_strategy.get('use_sell_signal', True) or
config_ask_strategy.get('ignore_roi_if_buy_signal', False)):
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
self.strategy.timeframe)
(buy, sell) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df)
@@ -814,13 +813,13 @@ class FreqtradeBot(LoggingMixin):
# resulting in outdated RPC messages
self._sell_rate_cache[trade.pair] = sell_rate
if self._check_and_execute_sell(analyzed_df, trade, sell_rate, buy, sell):
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
return True
else:
logger.debug('checking sell')
sell_rate = self.get_sell_rate(trade.pair, True)
if self._check_and_execute_sell(analyzed_df, trade, sell_rate, buy, sell):
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
return True
logger.debug('Found no sell signal for %s.', trade)
@@ -951,13 +950,13 @@ class FreqtradeBot(LoggingMixin):
logger.warning(f"Could not create trailing stoploss order "
f"for pair {trade.pair}.")
def _check_and_execute_sell(self, dataframe: DataFrame, trade: Trade, sell_rate: float,
def _check_and_execute_sell(self, trade: Trade, sell_rate: float,
buy: bool, sell: bool) -> bool:
"""
Check and execute sell
"""
should_sell = self.strategy.should_sell(
dataframe, trade, sell_rate, datetime.now(timezone.utc), buy, sell,
trade, sell_rate, datetime.now(timezone.utc), buy, sell,
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
)
@@ -1191,8 +1190,8 @@ class FreqtradeBot(LoggingMixin):
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit,
time_in_force=time_in_force,
sell_reason=sell_reason.sell_reason):
time_in_force=time_in_force, sell_reason=sell_reason.sell_reason,
current_time=datetime.now(timezone.utc)):
logger.info(f"User requested abortion of selling {trade.pair}")
return False

View File

@@ -63,9 +63,7 @@ class Backtesting:
self.all_results: Dict[str, Dict] = {}
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
dataprovider = DataProvider(self.config, self.exchange)
IStrategy.dp = dataprovider
self.dataprovider = DataProvider(self.config, None)
if self.config.get('strategy_list', None):
for strat in list(self.config['strategy_list']):
@@ -96,7 +94,7 @@ class Backtesting:
"PrecisionFilter not allowed for backtesting multiple strategies."
)
dataprovider.add_pairlisthandler(self.pairlists)
self.dataprovider.add_pairlisthandler(self.pairlists)
self.pairlists.refresh_pairlist()
if len(self.pairlists.whitelist) == 0:
@@ -112,8 +110,6 @@ class Backtesting:
PairLocks.timeframe = self.config['timeframe']
PairLocks.use_db = False
PairLocks.reset_locks()
if self.config.get('enable_protections', False):
self.protections = ProtectionManager(self.config)
self.wallets = Wallets(self.config, self.exchange, log=False)
@@ -132,10 +128,17 @@ class Backtesting:
Load strategy into backtesting
"""
self.strategy: IStrategy = strategy
strategy.dp = self.dataprovider
# 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
if self.config.get('enable_protections', False):
conf = self.config
if hasattr(strategy, 'protections'):
conf = deepcopy(conf)
conf['protections'] = strategy.protections
self.protections = ProtectionManager(conf)
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
"""
@@ -176,6 +179,7 @@ class Backtesting:
Trade.use_db = False
PairLocks.reset_locks()
Trade.reset_trades()
self.dataprovider.clear_cache()
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
"""
@@ -247,10 +251,9 @@ class Backtesting:
else:
return sell_row[OPEN_IDX]
def _get_sell_trade_entry(self, dataframe: DataFrame, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]:
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
sell = self.strategy.should_sell(dataframe, trade, sell_row[OPEN_IDX], # type: ignore
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore
sell_row[DATE_IDX].to_pydatetime(), sell_row[BUY_IDX],
sell_row[SELL_IDX],
low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX])
@@ -267,7 +270,8 @@ class Backtesting:
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
rate=closerate,
time_in_force=time_in_force,
sell_reason=sell.sell_reason):
sell_reason=sell.sell_reason,
current_time=sell_row[DATE_IDX].to_pydatetime()):
return None
trade.close(closerate, show_msg=False)
@@ -287,7 +291,7 @@ class Backtesting:
# 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=row[OPEN_IDX],
time_in_force=time_in_force):
time_in_force=time_in_force, current_time=row[DATE_IDX].to_pydatetime()):
return None
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
@@ -349,6 +353,10 @@ class Backtesting:
trades: List[LocalTrade] = []
self.prepare_backtest(enable_protections)
# Update dataprovider cache
for pair, dataframe in processed.items():
self.dataprovider._set_cached_df(pair, self.timeframe, dataframe)
# 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)
@@ -365,8 +373,9 @@ class Backtesting:
open_trade_count_start = open_trade_count
for i, pair in enumerate(data):
row_index = indexes[pair]
try:
row = data[pair][indexes[pair]]
row = data[pair][row_index]
except IndexError:
# missing Data for one pair at the end.
# Warnings for this are shown during data loading
@@ -375,7 +384,10 @@ class Backtesting:
# Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > tmp:
continue
indexes[pair] += 1
row_index += 1
self.dataprovider._set_dataframe_max_index(row_index)
indexes[pair] = row_index
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
@@ -398,7 +410,7 @@ class Backtesting:
for trade in open_trades[pair]:
# also check the buying candle for sell conditions.
trade_entry = self._get_sell_trade_entry(processed[pair], trade, row)
trade_entry = self._get_sell_trade_entry(trade, row)
# Sell occured
if trade_entry:
# logger.debug(f"{pair} - Backtesting sell {trade}")

View File

@@ -31,7 +31,6 @@ from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F4
from freqtrade.optimize.hyperopt_tools import HyperoptTools
from freqtrade.optimize.optimize_reports import generate_strategy_stats
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver
from freqtrade.strategy import IStrategy
# Suppress scikit-learn FutureWarnings from skopt
@@ -372,8 +371,6 @@ class Hyperopt:
self.backtesting.exchange._api_async = None # type: ignore
# self.backtesting.exchange = None # type: ignore
self.backtesting.pairlists = None # type: ignore
self.backtesting.strategy.dp = None # type: ignore
IStrategy.dp = None # type: ignore
cpus = cpu_count()
logger.info(f"Found {cpus} CPU cores. Let's make them scream!")

View File

@@ -229,7 +229,7 @@ class IStrategy(ABC, HyperStrategyMixin):
pass
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, **kwargs) -> bool:
time_in_force: str, current_time: datetime, **kwargs) -> bool:
"""
Called right before placing a buy order.
Timing for this function is critical, so avoid doing heavy computations or
@@ -244,6 +244,7 @@ class IStrategy(ABC, HyperStrategyMixin):
:param amount: Amount in target (quote) currency that's going to be traded.
:param rate: Rate that's going to be used when using limit orders
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the buy-order is placed on the exchange.
False aborts the process
@@ -251,7 +252,8 @@ class IStrategy(ABC, HyperStrategyMixin):
return True
def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float,
rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool:
rate: float, time_in_force: str, sell_reason: str,
current_time: datetime, **kwargs) -> bool:
"""
Called right before placing a regular sell order.
Timing for this function is critical, so avoid doing heavy computations or
@@ -270,6 +272,7 @@ class IStrategy(ABC, HyperStrategyMixin):
:param sell_reason: Sell reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'sell_signal', 'force_sell', 'emergency_sell']
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the sell-order is placed on the exchange.
False aborts the process
@@ -277,7 +280,7 @@ class IStrategy(ABC, HyperStrategyMixin):
return True
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, dataframe: DataFrame, **kwargs) -> float:
current_profit: float, **kwargs) -> float:
"""
Custom stoploss logic, returning the new distance relative to current_rate (as ratio).
e.g. returning -0.05 would create a stoploss 5% below current_rate.
@@ -293,15 +296,13 @@ class IStrategy(ABC, HyperStrategyMixin):
:param current_time: datetime object, containing the current datetime
:param current_rate: Rate, calculated based on pricing settings in ask_strategy.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param dataframe: Analyzed dataframe for this pair. Can contain future data in backtesting.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New stoploss value, relative to the currentrate
"""
return self.stoploss
def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, dataframe: DataFrame,
**kwargs) -> Optional[Union[str, bool]]:
current_profit: float, **kwargs) -> Optional[Union[str, bool]]:
"""
Custom sell signal logic indicating that specified position should be sold. Returning a
string or True from this method is equal to setting sell signal on a candle at specified
@@ -539,8 +540,8 @@ class IStrategy(ABC, HyperStrategyMixin):
else:
return False
def should_sell(self, dataframe: DataFrame, trade: Trade, rate: float, date: datetime,
buy: bool, sell: bool, low: float = None, high: float = None,
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
sell: bool, low: float = None, high: float = None,
force_stoploss: float = 0) -> SellCheckTuple:
"""
This function evaluates if one of the conditions required to trigger a sell
@@ -556,9 +557,8 @@ class IStrategy(ABC, HyperStrategyMixin):
trade.adjust_min_max_rates(high or current_rate)
stoplossflag = self.stop_loss_reached(dataframe=dataframe, current_rate=current_rate,
trade=trade, current_time=date,
current_profit=current_profit,
stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade,
current_time=date, current_profit=current_profit,
force_stoploss=force_stoploss, high=high)
# Set current rate to high for backtesting sell
@@ -583,7 +583,7 @@ class IStrategy(ABC, HyperStrategyMixin):
else:
custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)(
pair=trade.pair, trade=trade, current_time=date, current_rate=current_rate,
current_profit=current_profit, dataframe=dataframe)
current_profit=current_profit)
if custom_reason:
sell_signal = SellType.CUSTOM_SELL
if isinstance(custom_reason, str):
@@ -620,7 +620,7 @@ class IStrategy(ABC, HyperStrategyMixin):
# logger.debug(f"{trade.pair} - No sell signal.")
return SellCheckTuple(sell_type=SellType.NONE)
def stop_loss_reached(self, dataframe: DataFrame, current_rate: float, trade: Trade,
def stop_loss_reached(self, current_rate: float, trade: Trade,
current_time: datetime, current_profit: float,
force_stoploss: float, high: float = None) -> SellCheckTuple:
"""
@@ -638,8 +638,7 @@ class IStrategy(ABC, HyperStrategyMixin):
)(pair=trade.pair, trade=trade,
current_time=current_time,
current_rate=current_rate,
current_profit=current_profit,
dataframe=dataframe)
current_profit=current_profit)
# Sanity check - error cases will return None
if stop_loss_value:
# logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}")

View File

@@ -39,7 +39,7 @@ def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime',
return self.stoploss
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, **kwargs) -> bool:
time_in_force: str, current_time: 'datetime', **kwargs) -> bool:
"""
Called right before placing a buy order.
Timing for this function is critical, so avoid doing heavy computations or
@@ -54,6 +54,7 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
:param amount: Amount in target (quote) currency that's going to be traded.
:param rate: Rate that's going to be used when using limit orders
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the buy-order is placed on the exchange.
False aborts the process
@@ -61,7 +62,8 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
return True
def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount: float,
rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool:
rate: float, time_in_force: str, sell_reason: str,
current_time: 'datetime', **kwargs) -> bool:
"""
Called right before placing a regular sell order.
Timing for this function is critical, so avoid doing heavy computations or
@@ -80,6 +82,7 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount:
:param sell_reason: Sell reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'sell_signal', 'force_sell', 'emergency_sell']
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the sell-order is placed on the exchange.
False aborts the process