Merge pull request #1290 from freqtrade/fix/backtest_toomanyopen

fix backtesting not respecting max_open_trades
This commit is contained in:
Matthias 2018-11-30 19:17:09 +01:00 committed by GitHub
commit 3ac2106a16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 159 additions and 49 deletions

View File

@ -66,6 +66,7 @@ class Backtesting(object):
if self.config.get('strategy_list', None): if self.config.get('strategy_list', None):
# Force one interval # Force one interval
self.ticker_interval = str(self.config.get('ticker_interval')) self.ticker_interval = str(self.config.get('ticker_interval'))
self.ticker_interval_mins = constants.TICKER_INTERVAL_MINUTES[self.ticker_interval]
for strat in list(self.config['strategy_list']): for strat in list(self.config['strategy_list']):
stratconf = deepcopy(self.config) stratconf = deepcopy(self.config)
stratconf['strategy'] = strat stratconf['strategy'] = strat
@ -86,6 +87,8 @@ class Backtesting(object):
""" """
self.strategy = strategy self.strategy = strategy
self.ticker_interval = self.config.get('ticker_interval') self.ticker_interval = self.config.get('ticker_interval')
self.ticker_interval_mins = constants.TICKER_INTERVAL_MINUTES[self.ticker_interval]
self.tickerdata_to_dataframe = strategy.tickerdata_to_dataframe
self.advise_buy = strategy.advise_buy self.advise_buy = strategy.advise_buy
self.advise_sell = strategy.advise_sell self.advise_sell = strategy.advise_sell
@ -280,8 +283,13 @@ class Backtesting(object):
processed = args['processed'] processed = args['processed']
max_open_trades = args.get('max_open_trades', 0) max_open_trades = args.get('max_open_trades', 0)
position_stacking = args.get('position_stacking', False) position_stacking = args.get('position_stacking', False)
start_date = args['start_date']
end_date = args['end_date']
trades = [] trades = []
trade_count_lock: Dict = {} trade_count_lock: Dict = {}
ticker: Dict = {}
pairs = []
# Create ticker dict
for pair, pair_data in processed.items(): for pair, pair_data in processed.items():
pair_data['buy'], pair_data['sell'] = 0, 0 # cleanup from previous run pair_data['buy'], pair_data['sell'] = 0, 0 # cleanup from previous run
@ -296,15 +304,28 @@ class Backtesting(object):
# Convert from Pandas to list for performance reasons # Convert from Pandas to list for performance reasons
# (Looping Pandas is slow.) # (Looping Pandas is slow.)
ticker = [x for x in ticker_data.itertuples()] ticker[pair] = [x for x in ticker_data.itertuples()]
pairs.append(pair)
lock_pair_until: Dict = {}
tmp = start_date + timedelta(minutes=self.ticker_interval_mins)
index = 0
# Loop timerange and test per pair
while tmp < end_date:
# print(f"time: {tmp}")
for i, pair in enumerate(ticker):
try:
row = ticker[pair][index]
except IndexError:
# missing Data for one pair ...
# Warnings for this are shown by `validate_backtest_data`
continue
lock_pair_until = None
for index, row in enumerate(ticker):
if row.buy == 0 or row.sell == 1: if row.buy == 0 or row.sell == 1:
continue # skip rows where no buy signal or that would immediately sell off continue # skip rows where no buy signal or that would immediately sell off
if not position_stacking: if not position_stacking:
if lock_pair_until is not None and row.date <= lock_pair_until: if pair in lock_pair_until and row.date <= lock_pair_until[pair]:
continue continue
if max_open_trades > 0: if max_open_trades > 0:
# Check if max_open_trades has already been reached for the given date # Check if max_open_trades has already been reached for the given date
@ -313,17 +334,19 @@ class Backtesting(object):
trade_count_lock[row.date] = trade_count_lock.get(row.date, 0) + 1 trade_count_lock[row.date] = trade_count_lock.get(row.date, 0) + 1
trade_entry = self._get_sell_trade_entry(pair, row, ticker[index + 1:], trade_entry = self._get_sell_trade_entry(pair, row, ticker[pair][index + 1:],
trade_count_lock, args) trade_count_lock, args)
if trade_entry: if trade_entry:
lock_pair_until = trade_entry.close_time lock_pair_until[pair] = trade_entry.close_time
trades.append(trade_entry) trades.append(trade_entry)
else: else:
# Set lock_pair_until to end of testing period if trade could not be closed # Set lock_pair_until to end of testing period if trade could not be closed
# This happens only if the buy-signal was with the last candle # This happens only if the buy-signal was with the last candle
lock_pair_until = ticker_data.iloc[-1].date lock_pair_until[pair] = end_date
tmp += timedelta(minutes=self.ticker_interval_mins)
index += 1
return DataFrame.from_records(trades, columns=BacktestResult._fields) return DataFrame.from_records(trades, columns=BacktestResult._fields)
def start(self) -> None: def start(self) -> None:
@ -390,6 +413,8 @@ class Backtesting(object):
'processed': preprocessed, 'processed': preprocessed,
'max_open_trades': max_open_trades, 'max_open_trades': max_open_trades,
'position_stacking': self.config.get('position_stacking', False), 'position_stacking': self.config.get('position_stacking', False),
'start_date': min_date,
'end_date': max_date,
} }
) )

View File

@ -20,7 +20,7 @@ from skopt.space import Dimension
from freqtrade.arguments import Arguments from freqtrade.arguments import Arguments
from freqtrade.configuration import Configuration from freqtrade.configuration import Configuration
from freqtrade.optimize import load_data from freqtrade.optimize import load_data, get_timeframe
from freqtrade.optimize.backtesting import Backtesting from freqtrade.optimize.backtesting import Backtesting
from freqtrade.resolvers import HyperOptResolver from freqtrade.resolvers import HyperOptResolver
@ -167,11 +167,14 @@ class Hyperopt(Backtesting):
self.strategy.stoploss = params['stoploss'] self.strategy.stoploss = params['stoploss']
processed = load(TICKERDATA_PICKLE) processed = load(TICKERDATA_PICKLE)
min_date, max_date = get_timeframe(processed)
results = self.backtest( results = self.backtest(
{ {
'stake_amount': self.config['stake_amount'], 'stake_amount': self.config['stake_amount'],
'processed': processed, 'processed': processed,
'position_stacking': self.config.get('position_stacking', True), 'position_stacking': self.config.get('position_stacking', True),
'start_date': min_date,
'end_date': max_date,
} }
) )
result_explanation = self.format_results(results) result_explanation = self.format_results(results)

View File

@ -4,9 +4,10 @@ import arrow
from pandas import DataFrame from pandas import DataFrame
from freqtrade.strategy.interface import SellType from freqtrade.strategy.interface import SellType
from freqtrade.constants import TICKER_INTERVAL_MINUTES
ticker_start_time = arrow.get(2018, 10, 3) ticker_start_time = arrow.get(2018, 10, 3)
ticker_interval_in_minute = 60 tests_ticker_interval = "1h"
class BTrade(NamedTuple): class BTrade(NamedTuple):
@ -30,8 +31,8 @@ class BTContainer(NamedTuple):
def _get_frame_time_from_offset(offset): def _get_frame_time_from_offset(offset):
return ticker_start_time.shift( return ticker_start_time.shift(minutes=(offset * TICKER_INTERVAL_MINUTES[tests_ticker_interval])
minutes=(offset * ticker_interval_in_minute)).datetime.replace(tzinfo=None) ).datetime.replace(tzinfo=None)
def _build_backtest_dataframe(ticker_with_signals): def _build_backtest_dataframe(ticker_with_signals):

View File

@ -6,10 +6,11 @@ from pandas import DataFrame
import pytest import pytest
from freqtrade.optimize import get_timeframe
from freqtrade.optimize.backtesting import Backtesting from freqtrade.optimize.backtesting import Backtesting
from freqtrade.strategy.interface import SellType from freqtrade.strategy.interface import SellType
from freqtrade.tests.optimize import (BTrade, BTContainer, _build_backtest_dataframe, from freqtrade.tests.optimize import (BTrade, BTContainer, _build_backtest_dataframe,
_get_frame_time_from_offset) _get_frame_time_from_offset, tests_ticker_interval)
from freqtrade.tests.conftest import patch_exchange from freqtrade.tests.conftest import patch_exchange
@ -147,6 +148,7 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None:
""" """
default_conf["stoploss"] = data.stop_loss default_conf["stoploss"] = data.stop_loss
default_conf["minimal_roi"] = {"0": data.roi} default_conf["minimal_roi"] = {"0": data.roi}
default_conf['ticker_interval'] = tests_ticker_interval
mocker.patch('freqtrade.exchange.Exchange.get_fee', MagicMock(return_value=0.0)) mocker.patch('freqtrade.exchange.Exchange.get_fee', MagicMock(return_value=0.0))
patch_exchange(mocker) patch_exchange(mocker)
frame = _build_backtest_dataframe(data.data) frame = _build_backtest_dataframe(data.data)
@ -158,29 +160,21 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None:
pair = 'UNITTEST/BTC' pair = 'UNITTEST/BTC'
# Dummy data as we mock the analyze functions # Dummy data as we mock the analyze functions
data_processed = {pair: DataFrame()} data_processed = {pair: DataFrame()}
min_date, max_date = get_timeframe({pair: frame})
results = backtesting.backtest( results = backtesting.backtest(
{ {
'stake_amount': default_conf['stake_amount'], 'stake_amount': default_conf['stake_amount'],
'processed': data_processed, 'processed': data_processed,
'max_open_trades': 10, 'max_open_trades': 10,
'start_date': min_date,
'end_date': max_date,
} }
) )
print(results.T) print(results.T)
assert len(results) == len(data.trades) assert len(results) == len(data.trades)
assert round(results["profit_percent"].sum(), 3) == round(data.profit_perc, 3) assert round(results["profit_percent"].sum(), 3) == round(data.profit_perc, 3)
# if data.sell_r == SellType.STOP_LOSS:
# assert log_has("Stop loss hit.", caplog.record_tuples)
# else:
# assert not log_has("Stop loss hit.", caplog.record_tuples)
# log_test = (f'Force_selling still open trade UNITTEST/BTC with '
# f'{results.iloc[-1].profit_percent} perc - {results.iloc[-1].profit_abs}')
# if data.sell_r == SellType.FORCE_SELL:
# assert log_has(log_test,
# caplog.record_tuples)
# else:
# assert not log_has(log_test,
# caplog.record_tuples)
for c, trade in enumerate(data.trades): for c, trade in enumerate(data.trades):
res = results.iloc[c] res = results.iloc[c]
assert res.sell_reason == trade.sell_reason assert res.sell_reason == trade.sell_reason

View File

@ -13,6 +13,7 @@ from arrow import Arrow
from freqtrade import DependencyException, constants, optimize from freqtrade import DependencyException, constants, optimize
from freqtrade.arguments import Arguments, TimeRange from freqtrade.arguments import Arguments, TimeRange
from freqtrade.optimize import get_timeframe
from freqtrade.optimize.backtesting import (Backtesting, setup_configuration, from freqtrade.optimize.backtesting import (Backtesting, setup_configuration,
start) start)
from freqtrade.tests.conftest import log_has, patch_exchange from freqtrade.tests.conftest import log_has, patch_exchange
@ -86,17 +87,21 @@ def load_data_test(what):
def simple_backtest(config, contour, num_results, mocker) -> None: def simple_backtest(config, contour, num_results, mocker) -> None:
patch_exchange(mocker) patch_exchange(mocker)
config['ticker_interval'] = '1m'
backtesting = Backtesting(config) backtesting = Backtesting(config)
data = load_data_test(contour) data = load_data_test(contour)
processed = backtesting.strategy.tickerdata_to_dataframe(data) processed = backtesting.strategy.tickerdata_to_dataframe(data)
min_date, max_date = get_timeframe(processed)
assert isinstance(processed, dict) assert isinstance(processed, dict)
results = backtesting.backtest( results = backtesting.backtest(
{ {
'stake_amount': config['stake_amount'], 'stake_amount': config['stake_amount'],
'processed': processed, 'processed': processed,
'max_open_trades': 1, 'max_open_trades': 1,
'position_stacking': False 'position_stacking': False,
'start_date': min_date,
'end_date': max_date,
} }
) )
# results :: <class 'pandas.core.frame.DataFrame'> # results :: <class 'pandas.core.frame.DataFrame'>
@ -123,12 +128,16 @@ def _make_backtest_conf(mocker, conf=None, pair='UNITTEST/BTC', record=None):
data = trim_dictlist(data, -201) data = trim_dictlist(data, -201)
patch_exchange(mocker) patch_exchange(mocker)
backtesting = Backtesting(conf) backtesting = Backtesting(conf)
processed = backtesting.strategy.tickerdata_to_dataframe(data)
min_date, max_date = get_timeframe(processed)
return { return {
'stake_amount': conf['stake_amount'], 'stake_amount': conf['stake_amount'],
'processed': backtesting.strategy.tickerdata_to_dataframe(data), 'processed': processed,
'max_open_trades': 10, 'max_open_trades': 10,
'position_stacking': False, 'position_stacking': False,
'record': record 'record': record,
'start_date': min_date,
'end_date': max_date,
} }
@ -449,7 +458,7 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None:
) )
default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC']
default_conf['ticker_interval'] = "1m" default_conf['ticker_interval'] = '1m'
default_conf['live'] = False default_conf['live'] = False
default_conf['datadir'] = None default_conf['datadir'] = None
default_conf['export'] = None default_conf['export'] = None
@ -505,12 +514,15 @@ def test_backtest(default_conf, fee, mocker) -> None:
data = optimize.load_data(None, ticker_interval='5m', pairs=['UNITTEST/BTC']) data = optimize.load_data(None, ticker_interval='5m', pairs=['UNITTEST/BTC'])
data = trim_dictlist(data, -200) data = trim_dictlist(data, -200)
data_processed = backtesting.strategy.tickerdata_to_dataframe(data) data_processed = backtesting.strategy.tickerdata_to_dataframe(data)
min_date, max_date = get_timeframe(data_processed)
results = backtesting.backtest( results = backtesting.backtest(
{ {
'stake_amount': default_conf['stake_amount'], 'stake_amount': default_conf['stake_amount'],
'processed': data_processed, 'processed': data_processed,
'max_open_trades': 10, 'max_open_trades': 10,
'position_stacking': False 'position_stacking': False,
'start_date': min_date,
'end_date': max_date,
} }
) )
assert not results.empty assert not results.empty
@ -554,12 +566,16 @@ def test_backtest_1min_ticker_interval(default_conf, fee, mocker) -> None:
# Run a backtesting for an exiting 5min ticker_interval # Run a backtesting for an exiting 5min ticker_interval
data = optimize.load_data(None, ticker_interval='1m', pairs=['UNITTEST/BTC']) data = optimize.load_data(None, ticker_interval='1m', pairs=['UNITTEST/BTC'])
data = trim_dictlist(data, -200) data = trim_dictlist(data, -200)
processed = backtesting.strategy.tickerdata_to_dataframe(data)
min_date, max_date = get_timeframe(processed)
results = backtesting.backtest( results = backtesting.backtest(
{ {
'stake_amount': default_conf['stake_amount'], 'stake_amount': default_conf['stake_amount'],
'processed': backtesting.strategy.tickerdata_to_dataframe(data), 'processed': processed,
'max_open_trades': 1, 'max_open_trades': 1,
'position_stacking': False 'position_stacking': False,
'start_date': min_date,
'end_date': max_date,
} }
) )
assert not results.empty assert not results.empty
@ -583,25 +599,13 @@ def test_processed(default_conf, mocker) -> None:
def test_backtest_pricecontours(default_conf, fee, mocker) -> None: def test_backtest_pricecontours(default_conf, fee, mocker) -> None:
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
tests = [['raise', 18], ['lower', 0], ['sine', 19]] tests = [['raise', 18], ['lower', 0], ['sine', 19]]
# We need to enable sell-signal - otherwise it sells on ROI!!
default_conf['experimental'] = {"use_sell_signal": True}
for [contour, numres] in tests: for [contour, numres] in tests:
simple_backtest(default_conf, contour, numres, mocker) simple_backtest(default_conf, contour, numres, mocker)
# Test backtest using offline data (testdata directory)
def test_backtest_ticks(default_conf, fee, mocker):
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
patch_exchange(mocker)
ticks = [1, 5]
fun = Backtesting(default_conf).advise_buy
for _ in ticks:
backtest_conf = _make_backtest_conf(mocker, conf=default_conf)
backtesting = Backtesting(default_conf)
backtesting.advise_buy = fun # Override
backtesting.advise_sell = fun # Override
results = backtesting.backtest(backtest_conf)
assert not results.empty
def test_backtest_clash_buy_sell(mocker, default_conf): def test_backtest_clash_buy_sell(mocker, default_conf):
# Override the default buy trend function in our default_strategy # Override the default buy trend function in our default_strategy
def fun(dataframe=None, pair=None): def fun(dataframe=None, pair=None):
@ -636,14 +640,92 @@ def test_backtest_alternate_buy_sell(default_conf, fee, mocker):
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
mocker.patch('freqtrade.optimize.backtesting.file_dump_json', MagicMock()) mocker.patch('freqtrade.optimize.backtesting.file_dump_json', MagicMock())
backtest_conf = _make_backtest_conf(mocker, conf=default_conf, pair='UNITTEST/BTC') backtest_conf = _make_backtest_conf(mocker, conf=default_conf, pair='UNITTEST/BTC')
# We need to enable sell-signal - otherwise it sells on ROI!!
default_conf['experimental'] = {"use_sell_signal": True}
default_conf['ticker_interval'] = '1m'
backtesting = Backtesting(default_conf) backtesting = Backtesting(default_conf)
backtesting.advise_buy = _trend_alternate # Override backtesting.advise_buy = _trend_alternate # Override
backtesting.advise_sell = _trend_alternate # Override backtesting.advise_sell = _trend_alternate # Override
results = backtesting.backtest(backtest_conf) results = backtesting.backtest(backtest_conf)
backtesting._store_backtest_result("test_.json", results) backtesting._store_backtest_result("test_.json", results)
assert len(results) == 4 # 200 candles in backtest data
# won't buy on first (shifted by 1)
# 100 buys signals
assert len(results) == 99
# One trade was force-closed at the end # One trade was force-closed at the end
assert len(results.loc[results.open_at_end]) == 1 assert len(results.loc[results.open_at_end]) == 0
def test_backtest_multi_pair(default_conf, fee, mocker):
def evaluate_result_multi(results, freq, max_open_trades):
# Find overlapping trades by expanding each trade once per period
# and then counting overlaps
dates = [pd.Series(pd.date_range(row[1].open_time, row[1].close_time, freq=freq))
for row in results[['open_time', 'close_time']].iterrows()]
deltas = [len(x) for x in dates]
dates = pd.Series(pd.concat(dates).values, name='date')
df2 = pd.DataFrame(np.repeat(results.values, deltas, axis=0), columns=results.columns)
df2 = df2.astype(dtype={"open_time": "datetime64", "close_time": "datetime64"})
df2 = pd.concat([dates, df2], axis=1)
df2 = df2.set_index('date')
df_final = df2.resample(freq)[['pair']].count()
return df_final[df_final['pair'] > max_open_trades]
def _trend_alternate_hold(dataframe=None, metadata=None):
"""
Buy every 8th candle - sell every other 8th -2 (hold on to pairs a bit)
"""
multi = 8
dataframe['buy'] = np.where(dataframe.index % multi == 0, 1, 0)
dataframe['sell'] = np.where((dataframe.index + multi - 2) % multi == 0, 1, 0)
if metadata['pair'] in('ETH/BTC', 'LTC/BTC'):
dataframe['buy'] = dataframe['buy'].shift(-4)
dataframe['sell'] = dataframe['sell'].shift(-4)
return dataframe
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
patch_exchange(mocker)
pairs = ['ADA/BTC', 'DASH/BTC', 'ETH/BTC', 'LTC/BTC', 'NXT/BTC']
data = optimize.load_data(None, ticker_interval='5m', pairs=pairs)
data = trim_dictlist(data, -500)
# We need to enable sell-signal - otherwise it sells on ROI!!
default_conf['experimental'] = {"use_sell_signal": True}
default_conf['ticker_interval'] = '5m'
backtesting = Backtesting(default_conf)
backtesting.advise_buy = _trend_alternate_hold # Override
backtesting.advise_sell = _trend_alternate_hold # Override
data_processed = backtesting.strategy.tickerdata_to_dataframe(data)
min_date, max_date = get_timeframe(data_processed)
backtest_conf = {
'stake_amount': default_conf['stake_amount'],
'processed': data_processed,
'max_open_trades': 3,
'position_stacking': False,
'start_date': min_date,
'end_date': max_date,
}
results = backtesting.backtest(backtest_conf)
# Make sure we have parallel trades
assert len(evaluate_result_multi(results, '5min', 2)) > 0
# make sure we don't have trades with more than configured max_open_trades
assert len(evaluate_result_multi(results, '5min', 3)) == 0
backtest_conf = {
'stake_amount': default_conf['stake_amount'],
'processed': data_processed,
'max_open_trades': 1,
'position_stacking': False,
'start_date': min_date,
'end_date': max_date,
}
results = backtesting.backtest(backtest_conf)
assert len(evaluate_result_multi(results, '5min', 1)) == 0
def test_backtest_record(default_conf, fee, mocker): def test_backtest_record(default_conf, fee, mocker):

View File

@ -1,11 +1,12 @@
# pragma pylint: disable=missing-docstring,W0212,C0103 # pragma pylint: disable=missing-docstring,W0212,C0103
from datetime import datetime
import os import os
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pandas as pd import pandas as pd
import pytest import pytest
from freqtrade.optimize.__init__ import load_tickerdata_file from freqtrade.optimize import load_tickerdata_file
from freqtrade.optimize.hyperopt import Hyperopt, start from freqtrade.optimize.hyperopt import Hyperopt, start
from freqtrade.resolvers import StrategyResolver from freqtrade.resolvers import StrategyResolver
from freqtrade.tests.conftest import log_has, patch_exchange from freqtrade.tests.conftest import log_has, patch_exchange
@ -293,6 +294,10 @@ def test_generate_optimizer(mocker, default_conf) -> None:
'freqtrade.optimize.hyperopt.Hyperopt.backtest', 'freqtrade.optimize.hyperopt.Hyperopt.backtest',
MagicMock(return_value=backtest_result) MagicMock(return_value=backtest_result)
) )
mocker.patch(
'freqtrade.optimize.hyperopt.get_timeframe',
MagicMock(return_value=(datetime(2017, 12, 10), datetime(2017, 12, 13)))
)
patch_exchange(mocker) patch_exchange(mocker)
mocker.patch('freqtrade.optimize.hyperopt.load', MagicMock()) mocker.patch('freqtrade.optimize.hyperopt.load', MagicMock())