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
commit b81f24d9c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 237 additions and 151 deletions

View File

@ -60,7 +60,8 @@ from freqtrade.strategy import IStrategy, timeframe_to_prev_date
class AwesomeStrategy(IStrategy): class AwesomeStrategy(IStrategy):
def custom_sell(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, def custom_sell(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float,
current_profit: float, dataframe: DataFrame, **kwargs): current_profit: float, **kwargs):
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
trade_open_date = timeframe_to_prev_date(self.timeframe, trade.open_date_utc) trade_open_date = timeframe_to_prev_date(self.timeframe, trade.open_date_utc)
trade_row = dataframe.loc[dataframe['date'] == trade_open_date].squeeze() trade_row = dataframe.loc[dataframe['date'] == trade_open_date].squeeze()
@ -105,8 +106,7 @@ class AwesomeStrategy(IStrategy):
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
""" """
Custom stoploss logic, returning the new distance relative to current_rate (as ratio). 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. e.g. returning -0.05 would create a stoploss 5% below current_rate.
@ -156,8 +156,7 @@ class AwesomeStrategy(IStrategy):
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
# Make sure you have the longest interval first - these conditions are evaluated from top to bottom. # Make sure you have the longest interval first - these conditions are evaluated from top to bottom.
if current_time - timedelta(minutes=120) > trade.open_date_utc: if current_time - timedelta(minutes=120) > trade.open_date_utc:
@ -183,8 +182,7 @@ class AwesomeStrategy(IStrategy):
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
if pair in ('ETH/BTC', 'XRP/BTC'): if pair in ('ETH/BTC', 'XRP/BTC'):
return -0.10 return -0.10
@ -210,8 +208,7 @@ class AwesomeStrategy(IStrategy):
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
if current_profit < 0.04: if current_profit < 0.04:
return -1 # return a value bigger than the inital stoploss to keep using the inital stoploss return -1 # return a value bigger than the inital stoploss to keep using the inital stoploss
@ -250,8 +247,7 @@ class AwesomeStrategy(IStrategy):
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
# evaluate highest to lowest, so that highest possible stop is used # evaluate highest to lowest, so that highest possible stop is used
if current_profit > 0.40: if current_profit > 0.40:
@ -269,12 +265,6 @@ class AwesomeStrategy(IStrategy):
Imagine you want to use `custom_stoploss()` to use a trailing indicator like e.g. "ATR" Imagine you want to use `custom_stoploss()` to use a trailing indicator like e.g. "ATR"
!!! Warning
Only use `dataframe` values up until and including `current_time` value. Reading past
`current_time` you will look into the future, which will produce incorrect backtesting results
and throw an exception in dry/live runs.
see [Common mistakes when developing strategies](strategy-customization.md#common-mistakes-when-developing-strategies) for more info.
!!! Note !!! Note
`dataframe['date']` contains the candle's open date. During dry/live runs `current_time` and `dataframe['date']` contains the candle's open date. During dry/live runs `current_time` and
`trade.open_date_utc` will not match the candle date precisely and using them directly will throw `trade.open_date_utc` will not match the candle date precisely and using them directly will throw
@ -293,20 +283,27 @@ class AwesomeStrategy(IStrategy):
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
# Default return value # Default return value
result = 1 result = 1
if trade: if trade:
# Using current_time directly would only work in backtesting. Live/dry runs need time to # Using current_time directly would only work in backtesting. Live/dry runs need time to
# be rounded to previous candle to be used as dataframe index. Rounding must also be # be rounded to previous candle to be used as dataframe index. Rounding must also be
# applied to `trade.open_date(_utc)` if it is used for `dataframe` indexing. # applied to `trade.open_date(_utc)` if it is used for `dataframe` indexing.
current_time = timeframe_to_prev_date(self.timeframe, current_time) dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
current_row = dataframe.loc[dataframe['date'] == current_time].squeeze() current_candle = dataframe.loc[-1].squeeze()
if 'atr' in current_row: if 'atr' in current_candle:
# new stoploss relative to current_rate # new stoploss relative to current_rate
new_stoploss = (current_rate - current_row['atr']) / current_rate new_stoploss = (current_rate - current_candle['atr']) / current_rate
# Round trade date to it's candle time.
trade_date = timeframe_to_prev_date(trade.open_date_utc)
trade_candle = dataframe.loc[dataframe['date'] == trade_date]
# Just opened trades do not have their candle complete yet therefore trade_candle may be None
if trade_candle is not None:
trade_candle = trade_candle.squeeze()
trade_stoploss = (current_rate - trade_candle['atr']) / current_rate
new_stoploss = max(new_stoploss, trade_stoploss)
# turn into relative negative offset required by `custom_stoploss` return implementation # turn into relative negative offset required by `custom_stoploss` return implementation
result = new_stoploss - 1 result = new_stoploss - 1

View File

@ -422,10 +422,6 @@ if self.dp:
Returns an empty dataframe if the requested pair was not cached. Returns an empty dataframe if the requested pair was not cached.
This should not happen when using whitelisted pairs. This should not happen when using whitelisted pairs.
!!! Warning "Warning about backtesting"
This method will return an empty dataframe during backtesting.
### *orderbook(pair, maximum)* ### *orderbook(pair, maximum)*
``` python ``` python
@ -631,8 +627,7 @@ Stoploss values returned from `custom_stoploss` must specify a percentage relati
use_custom_stoploss = True use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime,
current_rate: float, current_profit: float, dataframe: DataFrame, current_rate: float, current_profit: float, **kwargs) -> float:
**kwargs) -> float:
# once the profit has risen above 10%, keep the stoploss at 7% above the open price # once the profit has risen above 10%, keep the stoploss at 7% above the open price
if current_profit > 0.10: if current_profit > 0.10:

View File

@ -19,14 +19,25 @@ from freqtrade.state import RunMode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
NO_EXCHANGE_EXCEPTION = 'Exchange is not available to DataProvider.'
MAX_DATAFRAME_CANDLES = 1000
class DataProvider: 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._config = config
self._exchange = exchange self._exchange = exchange
self._pairlists = pairlists self._pairlists = pairlists
self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} 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: def _set_cached_df(self, pair: str, timeframe: str, dataframe: DataFrame) -> None:
""" """
@ -45,40 +56,6 @@ class DataProvider:
""" """
self._pairlists = pairlists 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: def historic_ohlcv(self, pair: str, timeframe: str = None) -> DataFrame:
""" """
Get stored historical candle (OHLCV) data Get stored historical candle (OHLCV) data
@ -111,47 +88,27 @@ class DataProvider:
def get_analyzed_dataframe(self, pair: str, timeframe: str) -> Tuple[DataFrame, datetime]: 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 pair: pair to get the data for
:param timeframe: timeframe to get data for :param timeframe: timeframe to get data for
:return: Tuple of (Analyzed Dataframe, lastrefreshed) for the requested pair / timeframe :return: Tuple of (Analyzed Dataframe, lastrefreshed) for the requested pair / timeframe
combination. combination.
Returns empty dataframe and Epoch 0 (1970-01-01) if no dataframe was cached. Returns empty dataframe and Epoch 0 (1970-01-01) if no dataframe was cached.
""" """
if (pair, timeframe) in self.__cached_pairs: pair_key = (pair, timeframe)
return self.__cached_pairs[(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: else:
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) 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 @property
def runmode(self) -> RunMode: def runmode(self) -> RunMode:
""" """
@ -173,3 +130,86 @@ class DataProvider:
return self._pairlists.whitelist.copy() return self._pairlists.whitelist.copy()
else: else:
raise OperationalException("Dataprovider was not initialized with a pairlist provider.") 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 import arrow
from cachetools import TTLCache from cachetools import TTLCache
from pandas import DataFrame
from freqtrade import __version__, constants from freqtrade import __version__, constants
from freqtrade.configuration import validate_config_consistency 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)( 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, 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}") logger.info(f"User requested abortion of buying {pair}")
return False return False
amount = self.exchange.amount_to_precision(pair, amount) amount = self.exchange.amount_to_precision(pair, amount)
@ -784,10 +783,10 @@ class FreqtradeBot(LoggingMixin):
config_ask_strategy = self.config.get('ask_strategy', {}) 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 if (config_ask_strategy.get('use_sell_signal', True) or
config_ask_strategy.get('ignore_roi_if_buy_signal', False)): 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) (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 # resulting in outdated RPC messages
self._sell_rate_cache[trade.pair] = sell_rate 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 return True
else: else:
logger.debug('checking sell') logger.debug('checking sell')
sell_rate = self.get_sell_rate(trade.pair, True) 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 return True
logger.debug('Found no sell signal for %s.', trade) 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 " logger.warning(f"Could not create trailing stoploss order "
f"for pair {trade.pair}.") 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: buy: bool, sell: bool) -> bool:
""" """
Check and execute sell Check and execute sell
""" """
should_sell = self.strategy.should_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 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)( 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, pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit,
time_in_force=time_in_force, time_in_force=time_in_force, sell_reason=sell_reason.sell_reason,
sell_reason=sell_reason.sell_reason): current_time=datetime.now(timezone.utc)):
logger.info(f"User requested abortion of selling {trade.pair}") logger.info(f"User requested abortion of selling {trade.pair}")
return False return False

View File

@ -63,9 +63,7 @@ class Backtesting:
self.all_results: Dict[str, Dict] = {} self.all_results: Dict[str, Dict] = {}
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
self.dataprovider = DataProvider(self.config, None)
dataprovider = DataProvider(self.config, self.exchange)
IStrategy.dp = dataprovider
if self.config.get('strategy_list', None): if self.config.get('strategy_list', None):
for strat in list(self.config['strategy_list']): for strat in list(self.config['strategy_list']):
@ -96,7 +94,7 @@ class Backtesting:
"PrecisionFilter not allowed for backtesting multiple strategies." "PrecisionFilter not allowed for backtesting multiple strategies."
) )
dataprovider.add_pairlisthandler(self.pairlists) self.dataprovider.add_pairlisthandler(self.pairlists)
self.pairlists.refresh_pairlist() self.pairlists.refresh_pairlist()
if len(self.pairlists.whitelist) == 0: if len(self.pairlists.whitelist) == 0:
@ -112,8 +110,6 @@ class Backtesting:
PairLocks.timeframe = self.config['timeframe'] PairLocks.timeframe = self.config['timeframe']
PairLocks.use_db = False PairLocks.use_db = False
PairLocks.reset_locks() PairLocks.reset_locks()
if self.config.get('enable_protections', False):
self.protections = ProtectionManager(self.config)
self.wallets = Wallets(self.config, self.exchange, log=False) self.wallets = Wallets(self.config, self.exchange, log=False)
@ -132,10 +128,17 @@ class Backtesting:
Load strategy into backtesting Load strategy into backtesting
""" """
self.strategy: IStrategy = strategy self.strategy: IStrategy = strategy
strategy.dp = self.dataprovider
# Set stoploss_on_exchange to false for backtesting, # Set stoploss_on_exchange to false for backtesting,
# since a "perfect" stoploss-sell is assumed anyway # since a "perfect" stoploss-sell is assumed anyway
# And the regular "stoploss" function would not apply to that case # And the regular "stoploss" function would not apply to that case
self.strategy.order_types['stoploss_on_exchange'] = False 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]: def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
""" """
@ -176,6 +179,7 @@ class Backtesting:
Trade.use_db = False Trade.use_db = False
PairLocks.reset_locks() PairLocks.reset_locks()
Trade.reset_trades() Trade.reset_trades()
self.dataprovider.clear_cache()
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]: def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
""" """
@ -247,10 +251,9 @@ class Backtesting:
else: else:
return sell_row[OPEN_IDX] return sell_row[OPEN_IDX]
def _get_sell_trade_entry(self, dataframe: DataFrame, trade: LocalTrade, def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[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[DATE_IDX].to_pydatetime(), sell_row[BUY_IDX],
sell_row[SELL_IDX], sell_row[SELL_IDX],
low=sell_row[LOW_IDX], high=sell_row[HIGH_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, pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
rate=closerate, rate=closerate,
time_in_force=time_in_force, 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 return None
trade.close(closerate, show_msg=False) trade.close(closerate, show_msg=False)
@ -287,7 +291,7 @@ class Backtesting:
# Confirm trade entry: # Confirm trade entry:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( 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], 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 return None
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount): if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
@ -349,6 +353,10 @@ class Backtesting:
trades: List[LocalTrade] = [] trades: List[LocalTrade] = []
self.prepare_backtest(enable_protections) 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 # Use dict of lists with data for performance
# (looping lists is a lot faster than pandas DataFrames) # (looping lists is a lot faster than pandas DataFrames)
data: Dict = self._get_ohlcv_as_lists(processed) data: Dict = self._get_ohlcv_as_lists(processed)
@ -365,8 +373,9 @@ class Backtesting:
open_trade_count_start = open_trade_count open_trade_count_start = open_trade_count
for i, pair in enumerate(data): for i, pair in enumerate(data):
row_index = indexes[pair]
try: try:
row = data[pair][indexes[pair]] row = data[pair][row_index]
except IndexError: except IndexError:
# missing Data for one pair at the end. # missing Data for one pair at the end.
# Warnings for this are shown during data loading # 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. # Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > tmp: if row[DATE_IDX] > tmp:
continue 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. # without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected # max_open_trades must be respected
@ -398,7 +410,7 @@ class Backtesting:
for trade in open_trades[pair]: for trade in open_trades[pair]:
# also check the buying candle for sell conditions. # 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 # Sell occured
if trade_entry: if trade_entry:
# logger.debug(f"{pair} - Backtesting sell {trade}") # 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.hyperopt_tools import HyperoptTools
from freqtrade.optimize.optimize_reports import generate_strategy_stats from freqtrade.optimize.optimize_reports import generate_strategy_stats
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver
from freqtrade.strategy import IStrategy
# Suppress scikit-learn FutureWarnings from skopt # Suppress scikit-learn FutureWarnings from skopt
@ -372,8 +371,6 @@ class Hyperopt:
self.backtesting.exchange._api_async = None # type: ignore self.backtesting.exchange._api_async = None # type: ignore
# self.backtesting.exchange = None # type: ignore # self.backtesting.exchange = None # type: ignore
self.backtesting.pairlists = None # type: ignore self.backtesting.pairlists = None # type: ignore
self.backtesting.strategy.dp = None # type: ignore
IStrategy.dp = None # type: ignore
cpus = cpu_count() cpus = cpu_count()
logger.info(f"Found {cpus} CPU cores. Let's make them scream!") logger.info(f"Found {cpus} CPU cores. Let's make them scream!")

View File

@ -229,7 +229,7 @@ class IStrategy(ABC, HyperStrategyMixin):
pass pass
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, 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. Called right before placing a buy order.
Timing for this function is critical, so avoid doing heavy computations or 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 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 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 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. :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. :return bool: When True is returned, then the buy-order is placed on the exchange.
False aborts the process False aborts the process
@ -251,7 +252,8 @@ class IStrategy(ABC, HyperStrategyMixin):
return True return True
def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, 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. Called right before placing a regular sell order.
Timing for this function is critical, so avoid doing heavy computations or 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. :param sell_reason: Sell reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'sell_signal', 'force_sell', 'emergency_sell'] '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. :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. :return bool: When True is returned, then the sell-order is placed on the exchange.
False aborts the process False aborts the process
@ -277,7 +280,7 @@ class IStrategy(ABC, HyperStrategyMixin):
return True return True
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, 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). 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. 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_time: datetime object, containing the current datetime
:param current_rate: Rate, calculated based on pricing settings in ask_strategy. :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 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. :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 float: New stoploss value, relative to the currentrate
""" """
return self.stoploss return self.stoploss
def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, dataframe: DataFrame, current_profit: float, **kwargs) -> Optional[Union[str, bool]]:
**kwargs) -> Optional[Union[str, bool]]:
""" """
Custom sell signal logic indicating that specified position should be sold. Returning a 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 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: else:
return False return False
def should_sell(self, dataframe: DataFrame, trade: Trade, rate: float, date: datetime, def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
buy: bool, sell: bool, low: float = None, high: float = None, sell: bool, low: float = None, high: float = None,
force_stoploss: float = 0) -> SellCheckTuple: force_stoploss: float = 0) -> SellCheckTuple:
""" """
This function evaluates if one of the conditions required to trigger a sell 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) trade.adjust_min_max_rates(high or current_rate)
stoplossflag = self.stop_loss_reached(dataframe=dataframe, current_rate=current_rate, stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade,
trade=trade, current_time=date, current_time=date, current_profit=current_profit,
current_profit=current_profit,
force_stoploss=force_stoploss, high=high) force_stoploss=force_stoploss, high=high)
# Set current rate to high for backtesting sell # Set current rate to high for backtesting sell
@ -583,7 +583,7 @@ class IStrategy(ABC, HyperStrategyMixin):
else: else:
custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)( custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)(
pair=trade.pair, trade=trade, current_time=date, current_rate=current_rate, 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: if custom_reason:
sell_signal = SellType.CUSTOM_SELL sell_signal = SellType.CUSTOM_SELL
if isinstance(custom_reason, str): if isinstance(custom_reason, str):
@ -620,7 +620,7 @@ class IStrategy(ABC, HyperStrategyMixin):
# logger.debug(f"{trade.pair} - No sell signal.") # logger.debug(f"{trade.pair} - No sell signal.")
return SellCheckTuple(sell_type=SellType.NONE) 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, current_time: datetime, current_profit: float,
force_stoploss: float, high: float = None) -> SellCheckTuple: force_stoploss: float, high: float = None) -> SellCheckTuple:
""" """
@ -638,8 +638,7 @@ class IStrategy(ABC, HyperStrategyMixin):
)(pair=trade.pair, trade=trade, )(pair=trade.pair, trade=trade,
current_time=current_time, current_time=current_time,
current_rate=current_rate, current_rate=current_rate,
current_profit=current_profit, current_profit=current_profit)
dataframe=dataframe)
# Sanity check - error cases will return None # Sanity check - error cases will return None
if stop_loss_value: if stop_loss_value:
# logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}") # 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 return self.stoploss
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, 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. Called right before placing a buy order.
Timing for this function is critical, so avoid doing heavy computations or 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 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 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 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. :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. :return bool: When True is returned, then the buy-order is placed on the exchange.
False aborts the process False aborts the process
@ -61,7 +62,8 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
return True return True
def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount: float, 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. Called right before placing a regular sell order.
Timing for this function is critical, so avoid doing heavy computations or 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. :param sell_reason: Sell reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'sell_signal', 'force_sell', 'emergency_sell'] '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. :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. :return bool: When True is returned, then the sell-order is placed on the exchange.
False aborts the process False aborts the process

View File

@ -246,3 +246,46 @@ def test_get_analyzed_dataframe(mocker, default_conf, ohlcv_history):
assert dataframe.empty assert dataframe.empty
assert isinstance(time, datetime) assert isinstance(time, datetime)
assert time == datetime(1970, 1, 1, tzinfo=timezone.utc) assert time == datetime(1970, 1, 1, tzinfo=timezone.utc)
# Test backtest mode
default_conf["runmode"] = RunMode.BACKTEST
dp._set_dataframe_max_index(1)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == 1
dp._set_dataframe_max_index(2)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == 2
dp._set_dataframe_max_index(3)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == 3
dp._set_dataframe_max_index(500)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == len(ohlcv_history)
def test_no_exchange_mode(default_conf):
dp = DataProvider(default_conf, None)
message = "Exchange is not available to DataProvider."
with pytest.raises(OperationalException, match=message):
dp.refresh([()])
with pytest.raises(OperationalException, match=message):
dp.ohlcv('XRP/USDT', '5m')
with pytest.raises(OperationalException, match=message):
dp.market('XRP/USDT')
with pytest.raises(OperationalException, match=message):
dp.ticker('XRP/USDT')
with pytest.raises(OperationalException, match=message):
dp.orderbook('XRP/USDT', 20)
with pytest.raises(OperationalException, match=message):
dp.available_pairs()

View File

@ -36,10 +36,11 @@ def test_default_strategy(result, fee):
) )
assert strategy.confirm_trade_entry(pair='ETH/BTC', order_type='limit', amount=0.1, assert strategy.confirm_trade_entry(pair='ETH/BTC', order_type='limit', amount=0.1,
rate=20000, time_in_force='gtc') is True rate=20000, time_in_force='gtc',
current_time=datetime.utcnow()) is True
assert strategy.confirm_trade_exit(pair='ETH/BTC', trade=trade, order_type='limit', amount=0.1, assert strategy.confirm_trade_exit(pair='ETH/BTC', trade=trade, order_type='limit', amount=0.1,
rate=20000, time_in_force='gtc', sell_reason='roi') is True rate=20000, time_in_force='gtc', sell_reason='roi',
current_time=datetime.utcnow()) is True
assert strategy.custom_stoploss(pair='ETH/BTC', trade=trade, current_time=datetime.now(), assert strategy.custom_stoploss(pair='ETH/BTC', trade=trade, current_time=datetime.now(),
current_rate=20_000, current_profit=0.05, dataframe=None current_rate=20_000, current_profit=0.05) == strategy.stoploss
) == strategy.stoploss

View File

@ -360,7 +360,7 @@ def test_stop_loss_reached(default_conf, fee, profit, adjusted, expected, traili
now = arrow.utcnow().datetime now = arrow.utcnow().datetime
sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit), trade=trade, sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit), trade=trade,
current_time=now, current_profit=profit, current_time=now, current_profit=profit,
force_stoploss=0, high=None, dataframe=None) force_stoploss=0, high=None)
assert isinstance(sl_flag, SellCheckTuple) assert isinstance(sl_flag, SellCheckTuple)
assert sl_flag.sell_type == expected assert sl_flag.sell_type == expected
if expected == SellType.NONE: if expected == SellType.NONE:
@ -371,7 +371,7 @@ def test_stop_loss_reached(default_conf, fee, profit, adjusted, expected, traili
sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit2), trade=trade, sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit2), trade=trade,
current_time=now, current_profit=profit2, current_time=now, current_profit=profit2,
force_stoploss=0, high=None, dataframe=None) force_stoploss=0, high=None)
assert sl_flag.sell_type == expected2 assert sl_flag.sell_type == expected2
if expected2 == SellType.NONE: if expected2 == SellType.NONE:
assert sl_flag.sell_flag is False assert sl_flag.sell_flag is False
@ -399,27 +399,27 @@ def test_custom_sell(default_conf, fee, caplog) -> None:
) )
now = arrow.utcnow().datetime now = arrow.utcnow().datetime
res = strategy.should_sell(None, trade, 1, now, False, False, None, None, 0) res = strategy.should_sell(trade, 1, now, False, False, None, None, 0)
assert res.sell_flag is False assert res.sell_flag is False
assert res.sell_type == SellType.NONE assert res.sell_type == SellType.NONE
strategy.custom_sell = MagicMock(return_value=True) strategy.custom_sell = MagicMock(return_value=True)
res = strategy.should_sell(None, trade, 1, now, False, False, None, None, 0) res = strategy.should_sell(trade, 1, now, False, False, None, None, 0)
assert res.sell_flag is True assert res.sell_flag is True
assert res.sell_type == SellType.CUSTOM_SELL assert res.sell_type == SellType.CUSTOM_SELL
assert res.sell_reason == 'custom_sell' assert res.sell_reason == 'custom_sell'
strategy.custom_sell = MagicMock(return_value='hello world') strategy.custom_sell = MagicMock(return_value='hello world')
res = strategy.should_sell(None, trade, 1, now, False, False, None, None, 0) res = strategy.should_sell(trade, 1, now, False, False, None, None, 0)
assert res.sell_type == SellType.CUSTOM_SELL assert res.sell_type == SellType.CUSTOM_SELL
assert res.sell_flag is True assert res.sell_flag is True
assert res.sell_reason == 'hello world' assert res.sell_reason == 'hello world'
caplog.clear() caplog.clear()
strategy.custom_sell = MagicMock(return_value='h' * 100) strategy.custom_sell = MagicMock(return_value='h' * 100)
res = strategy.should_sell(None, trade, 1, now, False, False, None, None, 0) res = strategy.should_sell(trade, 1, now, False, False, None, None, 0)
assert res.sell_type == SellType.CUSTOM_SELL assert res.sell_type == SellType.CUSTOM_SELL
assert res.sell_flag is True assert res.sell_flag is True
assert res.sell_reason == 'h' * 64 assert res.sell_reason == 'h' * 64