Backtesting: Offline runs with test data, use argument '--backtesting'

This commit is contained in:
xsmile 2017-09-30 16:18:14 +02:00
parent 5e0f143a38
commit c88c3f9b84
7 changed files with 521 additions and 27 deletions

View File

@ -6,7 +6,8 @@ import arrow
import talib.abstract as ta
from pandas import DataFrame
from freqtrade.exchange import get_ticker_history
from freqtrade.exchange import backtesting, get_ticker_history
from freqtrade.misc import State, get_state
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
@ -23,6 +24,10 @@ def parse_ticker_dataframe(ticker: list, minimum_date: arrow.Arrow) -> DataFrame
.drop('BV', 1) \
.rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'}) \
.sort_values('date')
if get_state() == State.BACKTESTING:
return df
return df[df['date'].map(arrow.get) > minimum_date]
@ -69,16 +74,20 @@ def analyze_ticker(pair: str) -> DataFrame:
add several TA indicators and buy signal to it
:return DataFrame with ticker data and indicator data
"""
if get_state() == State.BACKTESTING:
minimum_date = backtesting.get_minimum_date(pair)
else:
minimum_date = arrow.utcnow().shift(hours=-24)
data = get_ticker_history(pair, minimum_date)
dataframe = parse_ticker_dataframe(data['result'], minimum_date)
if dataframe.empty:
logger.warning('Empty dataframe for pair %s', pair)
return dataframe
dataframe = populate_indicators(dataframe)
dataframe = populate_buy_trend(dataframe)
return dataframe
@ -95,6 +104,7 @@ def get_buy_signal(pair: str) -> bool:
latest = dataframe.iloc[-1]
if get_state() != State.BACKTESTING:
# Check if dataframe is out of date
signal_date = arrow.get(latest['date'])
if signal_date < arrow.now() - timedelta(minutes=10):

View File

@ -4,8 +4,10 @@ from typing import List
import arrow
from freqtrade.exchange.backtesting import Backtesting
from freqtrade.exchange.bittrex import Bittrex
from freqtrade.exchange.interface import Exchange
from freqtrade.misc import State, get_state
logger = logging.getLogger(__name__)
@ -33,22 +35,24 @@ def init(config: dict) -> None:
_CONF.update(config)
if config['dry_run']:
if get_state() == State.BACKTESTING:
logger.info('Instance is running with backtesting enabled')
EXCHANGE = Backtesting(config['exchange'])
return
elif config['dry_run']:
logger.info('Instance is running with dry_run enabled')
exchange_config = config['exchange']
# Find matching class for the given exchange name
name = exchange_config['name']
name = _CONF['exchange']['name']
try:
exchange_class = Exchanges[name.upper()].value
except KeyError:
raise RuntimeError('Exchange {} is not supported'.format(name))
EXCHANGE = exchange_class(exchange_config)
EXCHANGE = exchange_class(_CONF['exchange'])
# Check if all pairs are available
validate_pairs(config['exchange']['pair_whitelist'])
validate_pairs(_CONF['exchange']['pair_whitelist'])
def validate_pairs(pairs: List[str]) -> None:

View File

@ -0,0 +1,220 @@
# pylint: disable=C0103
import glob
import json
import logging
import os
from datetime import datetime, timedelta
from os.path import basename, splitext
from typing import List, Optional
import arrow
from sqlalchemy import func, text
from freqtrade import exchange
from freqtrade.exchange.interface import Exchange
from freqtrade.persistence import Trade
logger = logging.getLogger(__name__)
TESTDATA_DIR = os.path.join('freqtrade', 'tests', 'testdata')
# TODO: Define a global value for analyze and backtesting
TICKER_HISTORY_INTERVAL_H: int = 24
_ROW_INDEX: int = 0
_ROW_INTERVAL: int = 0
_LEN_ROWS: int = 0
_TESTDATA: dict = {}
class Backtesting(Exchange):
@property
def sleep_time(self) -> float:
return 0
def __init__(self, config: dict, testdata_dir: Optional[str] = TESTDATA_DIR) -> None:
global _ROW_INDEX, _ROW_INTERVAL, _LEN_ROWS, _TESTDATA
# Disable debug logs for a quicker run
# logging.disable(logging.DEBUG)
# Get pairs from test data directory and inject into config replacing existing ones
(files, pairs) = _get_testdata_pairs(testdata_dir)
config['pair_whitelist'] = pairs
# Load the test data for each pair
(_TESTDATA, _LEN_ROWS) = _get_testdata(files, pairs)
# Set first row according to shift time
# to have some ticker history available for the first analysis
_ROW_INDEX = _initial_row_index(_TESTDATA[pairs[0]]['result'], TICKER_HISTORY_INTERVAL_H)
_ROW_INTERVAL = _ROW_INDEX
if _ROW_INDEX >= _LEN_ROWS or _ROW_INDEX == 0:
raise RuntimeError('Test data is not usable with the current row interval')
def buy(self, pair: str, rate: float, amount: float) -> str:
return 'backtesting'
def sell(self, pair: str, rate: float, amount: float) -> str:
return 'backtesting'
def get_balance(self, currency: str) -> float:
return 999.9
def get_ticker(self, pair: str) -> dict:
row = _TESTDATA[pair]['result'][_ROW_INDEX]
return {'bid': row['C'], 'ask': row['C'], 'last': row['C']}
def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None):
minimum = _ROW_INDEX - _ROW_INTERVAL
maximum = _ROW_INDEX
return \
{'success': True, 'message': '', 'result': _TESTDATA[pair]['result'][minimum:maximum]}
def cancel_order(self, order_id: str) -> None:
return
def get_open_orders(self, pair: str) -> List[dict]:
return []
def get_pair_detail_url(self, pair: str) -> str:
return ''
def get_markets(self) -> List[str]:
return []
def _get_testdata_pairs(directory: str) -> (List[str], List[str]):
"""
Returns a file and a pair list for a given test data directory.
:param directory: Test data directory containing JSON files
:return: File list with paths relative to the project directory, pair list e.g. ['BTC_ETC']
"""
files = sorted(glob.glob(os.path.join(directory, '*.json')))
pairs = sorted([splitext(basename(p))[0].replace('-', '_').upper() for p in files])
return files, pairs
def _get_testdata(files: List[str], pairs) -> (dict, int):
"""
Returns test data content from a given file list.
:param files: JSON file list with paths relative to the project directory
:param pairs: Pair list, e.g. ['BTC_ETC']
:return: Test data dict with currency pairs as keys, maximal common testdata length for pairs
"""
testdata_lengths = [] # Find a common maximum for all testdata pairs
testdata = {}
for (file, pair) in zip(files, pairs):
with open(file) as f:
testdata[pair] = json.load(f)
testdata_lengths.append(len(testdata[pair]['result']))
len_rows = min(testdata_lengths)
return testdata, len_rows
def _initial_row_index(ticker_history: dict, ticker_history_interval: float) -> int:
"""
Calculates the initial row index to have a buffer for the first signal analysis.
:param ticker_history: Ticker history of a currency pair
:param ticker_history_interval: Time interval used during the ticker analysis
:return: Calculated initial row index
"""
result = 0
t0 = arrow.get(ticker_history[0]['T'])
t1 = t0.shift(hours=ticker_history_interval)
for i, row in enumerate(ticker_history):
row_time = arrow.get(row['T'])
if row_time >= t1:
result = i
break
return result
def time_step() -> bool:
"""
Advances in time or rather increases the row counter by 1 (one).
:return: Success status - False if the end of a data set is reached
"""
global _ROW_INDEX
time = _ROW_INDEX
_ROW_INDEX += 1
logger.debug('Row: %s/%s', time, _LEN_ROWS)
if time >= _LEN_ROWS: # Backtesting complete
return False
return True
def get_minimum_date(pair: str) -> datetime:
"""
Subtracts the ticker history interval (e.g. 24 hours) from the current time.
:param pair: Pair as str, format: BTC_ETH
:return: datetime
"""
ticker_history = _TESTDATA[pair]['result']
minimum_row = _ROW_INDEX - _ROW_INTERVAL
minimum_date = ticker_history[minimum_row]['T']
return minimum_date
def current_time(pair: str) -> datetime:
"""
Gets the time stored in the current row of a data set for a given currency pair.
:param pair: Pair as str, format: BTC_ETC
:return: datetime
"""
return arrow.get(_TESTDATA[pair]['result'][_ROW_INDEX]['T']).datetime.replace(tzinfo=None)
# TODO: Common statistic methods for telegram and backtesting
def print_results() -> None:
"""
Prints cumulative profit statistics.
:return: None
"""
trades = Trade.query.order_by(Trade.id).all()
profit_amounts = []
profits = []
durations = []
for trade in trades:
if trade.close_date:
durations.append((trade.close_date - trade.open_date).total_seconds())
if trade.close_profit:
profit = trade.close_profit
else:
# Get current rate
current_rate = exchange.get_ticker(trade.pair)['bid']
profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
profit_amounts.append((profit / 100) * trade.stake_amount)
profits.append(profit)
best_pair = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
.filter(Trade.is_open.is_(False)) \
.group_by(Trade.pair) \
.order_by(text('profit_sum DESC')) \
.first()
if not best_pair:
logger.info('No closed trade')
return
bp_pair, bp_rate = best_pair
msg = """
ROI: {profit_btc:.2f} ({profit:.2f}%)
Trade Count: {trade_count}
First Trade opened: {first_trade_date}
Latest Trade opened: {latest_trade_date}
Avg. Duration: {avg_duration}
Best Performing: {best_pair}: {best_rate:.2f}%
""".format(
profit_btc=round(sum(profit_amounts), 8),
profit=round(sum(profits), 2),
trade_count=len(trades),
first_trade_date=arrow.get(trades[0].open_date).humanize(),
latest_trade_date=arrow.get(trades[-1].open_date).humanize(),
avg_duration=str(timedelta(seconds=sum(durations) / float(len(durations)))).split('.')[0],
best_pair=bp_pair,
best_rate=round(bp_rate, 2),
)
logger.info(msg)

View File

@ -2,6 +2,7 @@
import copy
import json
import logging
import sys
import time
import traceback
from datetime import datetime
@ -11,7 +12,8 @@ from jsonschema import validate
from freqtrade import __version__, exchange, persistence
from freqtrade.analyze import get_buy_signal
from freqtrade.misc import CONF_SCHEMA, State, get_state, update_state
from freqtrade.exchange import backtesting
from freqtrade.misc import CONF_SCHEMA, State, get_args, get_state, parse_args, update_state
from freqtrade.persistence import Trade
from freqtrade.rpc import telegram
@ -106,6 +108,9 @@ def should_sell(trade: Trade, current_rate: float, current_time: datetime) -> bo
Based an earlier trade and current price and configuration, decides whether bot should sell
:return True if bot should sell at current rate
"""
if get_state() == State.BACKTESTING:
current_time = backtesting.current_time(trade.pair)
current_profit = (current_rate - trade.open_rate) / trade.open_rate
if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']):
@ -186,6 +191,10 @@ def create_trade(stake_amount: float) -> Optional[Trade]:
order_id = exchange.buy(pair, open_rate, amount)
# Create trade entity and return
if get_state() == State.BACKTESTING:
current_time = backtesting.current_time(pair)
else:
current_time = datetime.utcnow()
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
exchange.EXCHANGE.name.upper(),
pair.replace('_', '/'),
@ -197,41 +206,48 @@ def create_trade(stake_amount: float) -> Optional[Trade]:
return Trade(pair=pair,
stake_amount=stake_amount,
open_rate=open_rate,
open_date=datetime.utcnow(),
open_date=current_time,
amount=amount,
exchange=exchange.EXCHANGE.name.upper(),
open_order_id=order_id,
is_open=True)
def init(config: dict, db_url: Optional[str] = None) -> None:
def init(config: dict, db_url: Optional[str] = None, argv: Optional[list] = None) -> None:
"""
Initializes all modules and updates the config
:param config: config as dict
:param db_url: database connector string for sqlalchemy (Optional)
:param argv: (optional) command-line arguments
:return: None
"""
# Parse command-line arguments
parse_args(argv)
# Set initial application state
if get_args().backtesting:
initial_state = State.BACKTESTING
_CONF['telegram']['enabled'] = False
else:
initial_state = config.get('initial_state', State.STOPPED.name.lower())
initial_state = State[initial_state.upper()]
update_state(initial_state)
# Initialize all modules
telegram.init(config)
persistence.init(config, db_url)
exchange.init(config)
# Set initial application state
initial_state = config.get('initial_state')
if initial_state:
update_state(State[initial_state.upper()])
else:
update_state(State.STOPPED)
def app(config: dict) -> None:
def app(config: dict, argv: Optional[list] = None) -> None:
"""
Main loop which handles the application state
:param config: config as dict
:param argv: (optional) command-line arguments
:return: None
"""
logger.info('Starting freqtrade %s', __version__)
init(config)
init(config, argv=argv)
try:
old_state = get_state()
logger.info('Initial State: %s', old_state)
@ -249,6 +265,12 @@ def app(config: dict) -> None:
_process()
# We need to sleep here because otherwise we would run into bittrex rate limit
time.sleep(exchange.EXCHANGE.sleep_time)
elif new_state == State.BACKTESTING:
_process()
# Advance in time without sleeping
if not backtesting.time_step():
backtesting.print_results()
break
old_state = new_state
except RuntimeError:
telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc()))
@ -266,7 +288,7 @@ def main():
with open('config.json') as file:
_CONF = json.load(file)
validate(_CONF, CONF_SCHEMA)
app(_CONF)
app(_CONF, sys.argv)
if __name__ == '__main__':

View File

@ -1,4 +1,6 @@
import argparse
import enum
from typing import Optional
from wrapt import synchronized
@ -6,8 +8,12 @@ from wrapt import synchronized
class State(enum.Enum):
RUNNING = 0
STOPPED = 1
BACKTESTING = 2
# Command-line arguments
_ARGS: argparse.Namespace = None
# Current application state
_STATE = State.STOPPED
@ -32,6 +38,29 @@ def get_state() -> State:
return _STATE
def parse_args(argv: Optional[list] = None) -> None:
"""
Parses and stores command-line arguments.
:param argv: (optional) command-line arguments
:return: None
"""
global _ARGS
parser = argparse.ArgumentParser(
description='Simple High Frequency Trading Bot for crypto currencies')
parser.add_argument('-b', '--backtesting', action='store_true',
help='test bot performance on offline data and print results')
_ARGS = parser.parse_args(argv[1:] if argv else [])
def get_args():
"""
Gets the current command-line arguments.
:return: arguments
"""
return _ARGS
# Required json-schema for user specified config
CONF_SCHEMA = {
'type': 'object',

View File

@ -8,6 +8,7 @@ from sqlalchemy.orm.session import sessionmaker
from sqlalchemy.types import Enum
from freqtrade import exchange
from freqtrade.misc import State, get_state
_CONF = {}
@ -25,7 +26,9 @@ def init(config: dict, db_url: Optional[str] = None) -> None:
"""
_CONF.update(config)
if not db_url:
if _CONF.get('dry_run', False):
if get_state() == State.BACKTESTING:
db_url = 'sqlite://'
elif _CONF.get('dry_run', False):
db_url = 'sqlite:///tradesv2.dry_run.sqlite'
else:
db_url = 'sqlite:///tradesv2.sqlite'

View File

@ -0,0 +1,206 @@
# pragma pylint: disable=missing-docstring,protected-access
import os
from datetime import datetime
from unittest.mock import MagicMock
import arrow
import pytest
from jsonschema import validate
from freqtrade.exchange import backtesting
from freqtrade.exchange.backtesting import Backtesting
from freqtrade.main import create_trade, init
from freqtrade.misc import CONF_SCHEMA
from freqtrade.persistence import Trade
@pytest.fixture
def conf():
configuration = {
'max_open_trades': 3,
'stake_currency': 'BTC',
'stake_amount': 0.05,
'dry_run': True,
'minimal_roi': {
'60': 0.0,
'40': 0.01,
'20': 0.02,
'0': 0.03
},
'stoploss': -0.40,
'bid_strategy': {
'ask_last_balance': 0.0
},
'exchange': {
'name': 'bittrex',
'key': 'key',
'secret': 'secret',
'pair_whitelist': [
'BTC_RLC'
]
},
'telegram': {
'enabled': False,
'token': 'token',
'chat_id': 'chat_id'
}
}
validate(configuration, CONF_SCHEMA)
return configuration
FILES = [
os.path.join('freqtrade', 'tests', 'testdata', 'btc-edg.json'),
os.path.join('freqtrade', 'tests', 'testdata', 'btc-etc.json')
]
PAIRS = ['BTC_EDG', 'BTC_ETC']
TESTDATA = {
PAIRS[0]: {
'success': True,
'message': '',
'result': [
{'O': 0.00014469, 'H': 0.00014469, 'L': 0.00014469, 'C': 0.00014469, 'V': 10.66173857, 'T': '2017-09-05T18:55:00', 'BV': 0.00154264},
{'O': 0.00014469, 'H': 0.00014477, 'L': 0.00014469, 'C': 0.00014477, 'V': 410.54795113, 'T': '2017-09-05T19:00:00', 'BV': 0.05942728},
{'O': 0.00014477, 'H': 0.00014477, 'L': 0.00014477, 'C': 0.00014477, 'V': 69.10850034, 'T': '2017-09-05T19:05:00', 'BV': 0.01000482},
{'O': 0.00014470, 'H': 0.00014474, 'L': 0.00014400, 'C': 0.00014473, 'V': 7612.36224582, 'T': '2017-09-05T19:10:00', 'BV': 1.09730748},
]
},
PAIRS[1]: {
'success': True,
'message': '',
'result': [
{'O': 0.00391500, 'H': 0.00392700, 'L': 0.00391500, 'C': 0.00392000, 'V': 29.90264260, 'T': '2017-09-05T18:55:00', 'BV': 0.11712504},
{'O': 0.00392680, 'H': 0.00392749, 'L': 0.00391500, 'C': 0.00391500, 'V': 329.35043009, 'T': '2017-09-05T19:00:00', 'BV': 1.29065913},
{'O': 0.00391500, 'H': 0.00392733, 'L': 0.00391500, 'C': 0.00392300, 'V': 186.96019741, 'T': '2017-09-05T19:05:00', 'BV': 0.73332203},
{'O': 0.00391500, 'H': 0.00391500, 'L': 0.00390007, 'C': 0.00390007, 'V': 298.06457786, 'T': '2017-09-05T19:10:00', 'BV': 1.16560055},
{'O': 0.00391490, 'H': 0.00391490, 'L': 0.00389126, 'C': 0.00389126, 'V': 1007.91208513, 'T': '2017-09-05T19:15:00', 'BV': 3.92491826}
]
}
}
@pytest.fixture()
def len_rows():
ticker_history = TESTDATA[PAIRS[0]]
return len(ticker_history['result'])
def _json_load(file):
pair = os.path.splitext(os.path.basename(file.name))[0].replace('-', '_').upper()
return TESTDATA[pair]
def test_init(conf, mocker):
mocker.patch('json.load', side_effect=_json_load)
mocker.patch('glob.glob', return_value=FILES)
mocker.patch.dict('freqtrade.main._CONF', conf)
mocker.patch('freqtrade.main.get_args', backtesting=True)
mocker.patch('freqtrade.exchange', init=MagicMock())
mocker.patch('freqtrade.main.backtesting.TICKER_HISTORY_INTERVAL_H', 1/6)
init(conf)
assert backtesting._TESTDATA == TESTDATA
assert backtesting._LEN_ROWS == 4
# 10 minutes (1/6 hours) should correspond to 2 rows
assert backtesting._ROW_INDEX == 2
assert backtesting._ROW_INTERVAL == 2
# Available test data should not cover the interval
mocker.patch('freqtrade.main.backtesting.TICKER_HISTORY_INTERVAL_H', 24)
with pytest.raises(RuntimeError):
assert init(conf)
def test_time_step(len_rows, mocker):
mocker.patch('freqtrade.exchange.backtesting._LEN_ROWS', len_rows)
for index in range(4):
assert backtesting._ROW_INDEX == index
assert backtesting.time_step() is True
assert backtesting.time_step() is False
def test_returns_minimum_date(mocker):
mocker.patch('freqtrade.exchange.backtesting._TESTDATA', TESTDATA)
mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 3)
mocker.patch('freqtrade.exchange.backtesting._ROW_INTERVAL', 2)
assert backtesting.get_minimum_date(PAIRS[0]) == '2017-09-05T19:00:00'
def test_returns_ticker(conf, mocker):
mocker.patch('freqtrade.main.get_args', backtesting=True)
mocker.patch('freqtrade.exchange.backtesting._get_testdata', return_value=(TESTDATA, 4))
mocker.patch('freqtrade.exchange.backtesting.TICKER_HISTORY_INTERVAL_H', 1/6)
first_pair_close = 0.00014477
backtesting = Backtesting(conf)
mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 2)
assert backtesting.get_ticker(PAIRS[0]) == \
{'bid': first_pair_close, 'ask': first_pair_close, 'last': first_pair_close}
def test_returns_ticker_history(conf, mocker):
mocker.patch('freqtrade.exchange.backtesting._get_testdata', return_value=(TESTDATA, 4))
mocker.patch('freqtrade.main.backtesting.TICKER_HISTORY_INTERVAL_H', 1/6)
backtesting = Backtesting(conf)
mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 3)
mocker.patch('freqtrade.exchange.backtesting._ROW_INTERVAL', 2)
assert backtesting.get_ticker_history(PAIRS[0]) == \
{'success': True,
'message': '',
'result': [
{'O': 0.00014469, 'H': 0.00014477, 'L': 0.00014469, 'C': 0.00014477, 'V': 410.54795113, 'T': '2017-09-05T19:00:00', 'BV': 0.05942728},
{'O': 0.00014477, 'H': 0.00014477, 'L': 0.00014477, 'C': 0.00014477, 'V': 69.10850034, 'T': '2017-09-05T19:05:00', 'BV': 0.01000482}
]}
def test_returns_current_time(mocker):
mocker.patch('freqtrade.exchange.backtesting._TESTDATA', TESTDATA)
mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 1)
assert backtesting.current_time(PAIRS[0]) == \
arrow.get('2017-09-05T19:00:00').datetime.replace(tzinfo=None)
def test_results(conf, mocker):
current_time = datetime.utcnow()
logger_mock = MagicMock()
mocker.patch.dict('freqtrade.main._CONF', conf)
mocker.patch('freqtrade.main.get_args', backtesting=True)
mocker.patch('freqtrade.main.backtesting', init=MagicMock())
mocker.patch('freqtrade.main.telegram', init=MagicMock())
mocker.patch('freqtrade.main.backtesting.current_time', return_value=current_time)
mocker.patch('freqtrade.main.get_buy_signal', return_value=True)
mocker.patch('freqtrade.exchange.backtesting.logger', info=logger_mock)
mocker.patch.multiple('freqtrade.main.exchange',
get_ticker=MagicMock(return_value={
'bid': 0.07256061,
'ask': 0.072661,
'last': 0.07256061
}),
buy=MagicMock(return_value='mocked_order_id'))
init(conf)
# Create some test data
trade = create_trade(15.0)
assert trade
trade.close_rate = 0.07256061
trade.close_profit = 100.00
trade.close_date = current_time
trade.open_order_id = None
trade.is_open = False
Trade.session.add(trade)
Trade.session.flush()
backtesting.print_results()
assert logger_mock.call_count == 1
assert '(100.00%)' in logger_mock.call_args_list[-1][0][0]
# Trade should not be closed yet
Trade.session.delete(trade)
trade.close_rate = None
trade.close_profit = None
trade.close_date = None
trade.is_open = True
Trade.session.add(trade)
Trade.session.flush()
backtesting.print_results()
assert logger_mock.call_count == 2
assert 'No closed trade' in logger_mock.call_args_list[-1][0][0]