Backtesting: Offline runs with test data, use argument '--backtesting'
This commit is contained in:
parent
5e0f143a38
commit
c88c3f9b84
@ -6,7 +6,8 @@ import arrow
|
|||||||
import talib.abstract as ta
|
import talib.abstract as ta
|
||||||
from pandas import DataFrame
|
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,
|
logging.basicConfig(level=logging.DEBUG,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
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) \
|
.drop('BV', 1) \
|
||||||
.rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'}) \
|
.rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'}) \
|
||||||
.sort_values('date')
|
.sort_values('date')
|
||||||
|
|
||||||
|
if get_state() == State.BACKTESTING:
|
||||||
|
return df
|
||||||
|
|
||||||
return df[df['date'].map(arrow.get) > minimum_date]
|
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
|
add several TA indicators and buy signal to it
|
||||||
:return DataFrame with ticker data and indicator data
|
:return DataFrame with ticker data and indicator data
|
||||||
"""
|
"""
|
||||||
minimum_date = arrow.utcnow().shift(hours=-24)
|
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)
|
data = get_ticker_history(pair, minimum_date)
|
||||||
dataframe = parse_ticker_dataframe(data['result'], minimum_date)
|
dataframe = parse_ticker_dataframe(data['result'], minimum_date)
|
||||||
|
|
||||||
if dataframe.empty:
|
if dataframe.empty:
|
||||||
logger.warning('Empty dataframe for pair %s', pair)
|
logger.warning('Empty dataframe for pair %s', pair)
|
||||||
return dataframe
|
return dataframe
|
||||||
|
|
||||||
dataframe = populate_indicators(dataframe)
|
dataframe = populate_indicators(dataframe)
|
||||||
dataframe = populate_buy_trend(dataframe)
|
dataframe = populate_buy_trend(dataframe)
|
||||||
|
|
||||||
return dataframe
|
return dataframe
|
||||||
|
|
||||||
|
|
||||||
@ -95,10 +104,11 @@ def get_buy_signal(pair: str) -> bool:
|
|||||||
|
|
||||||
latest = dataframe.iloc[-1]
|
latest = dataframe.iloc[-1]
|
||||||
|
|
||||||
# Check if dataframe is out of date
|
if get_state() != State.BACKTESTING:
|
||||||
signal_date = arrow.get(latest['date'])
|
# Check if dataframe is out of date
|
||||||
if signal_date < arrow.now() - timedelta(minutes=10):
|
signal_date = arrow.get(latest['date'])
|
||||||
return False
|
if signal_date < arrow.now() - timedelta(minutes=10):
|
||||||
|
return False
|
||||||
|
|
||||||
signal = latest['buy'] == 1
|
signal = latest['buy'] == 1
|
||||||
logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal)
|
logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal)
|
||||||
|
@ -4,8 +4,10 @@ from typing import List
|
|||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
|
|
||||||
|
from freqtrade.exchange.backtesting import Backtesting
|
||||||
from freqtrade.exchange.bittrex import Bittrex
|
from freqtrade.exchange.bittrex import Bittrex
|
||||||
from freqtrade.exchange.interface import Exchange
|
from freqtrade.exchange.interface import Exchange
|
||||||
|
from freqtrade.misc import State, get_state
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -33,22 +35,24 @@ def init(config: dict) -> None:
|
|||||||
|
|
||||||
_CONF.update(config)
|
_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')
|
logger.info('Instance is running with dry_run enabled')
|
||||||
|
|
||||||
exchange_config = config['exchange']
|
|
||||||
|
|
||||||
# Find matching class for the given exchange name
|
# Find matching class for the given exchange name
|
||||||
name = exchange_config['name']
|
name = _CONF['exchange']['name']
|
||||||
try:
|
try:
|
||||||
exchange_class = Exchanges[name.upper()].value
|
exchange_class = Exchanges[name.upper()].value
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise RuntimeError('Exchange {} is not supported'.format(name))
|
raise RuntimeError('Exchange {} is not supported'.format(name))
|
||||||
|
|
||||||
EXCHANGE = exchange_class(exchange_config)
|
EXCHANGE = exchange_class(_CONF['exchange'])
|
||||||
|
|
||||||
# Check if all pairs are available
|
# 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:
|
def validate_pairs(pairs: List[str]) -> None:
|
||||||
|
220
freqtrade/exchange/backtesting.py
Normal file
220
freqtrade/exchange/backtesting.py
Normal 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)
|
@ -2,6 +2,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -11,7 +12,8 @@ from jsonschema import validate
|
|||||||
|
|
||||||
from freqtrade import __version__, exchange, persistence
|
from freqtrade import __version__, exchange, persistence
|
||||||
from freqtrade.analyze import get_buy_signal
|
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.persistence import Trade
|
||||||
from freqtrade.rpc import telegram
|
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
|
Based an earlier trade and current price and configuration, decides whether bot should sell
|
||||||
:return True if bot should sell at current rate
|
: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
|
current_profit = (current_rate - trade.open_rate) / trade.open_rate
|
||||||
|
|
||||||
if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']):
|
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)
|
order_id = exchange.buy(pair, open_rate, amount)
|
||||||
|
|
||||||
# Create trade entity and return
|
# 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(
|
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
|
||||||
exchange.EXCHANGE.name.upper(),
|
exchange.EXCHANGE.name.upper(),
|
||||||
pair.replace('_', '/'),
|
pair.replace('_', '/'),
|
||||||
@ -197,41 +206,48 @@ def create_trade(stake_amount: float) -> Optional[Trade]:
|
|||||||
return Trade(pair=pair,
|
return Trade(pair=pair,
|
||||||
stake_amount=stake_amount,
|
stake_amount=stake_amount,
|
||||||
open_rate=open_rate,
|
open_rate=open_rate,
|
||||||
open_date=datetime.utcnow(),
|
open_date=current_time,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
exchange=exchange.EXCHANGE.name.upper(),
|
exchange=exchange.EXCHANGE.name.upper(),
|
||||||
open_order_id=order_id,
|
open_order_id=order_id,
|
||||||
is_open=True)
|
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
|
Initializes all modules and updates the config
|
||||||
:param config: config as dict
|
:param config: config as dict
|
||||||
:param db_url: database connector string for sqlalchemy (Optional)
|
:param db_url: database connector string for sqlalchemy (Optional)
|
||||||
|
:param argv: (optional) command-line arguments
|
||||||
:return: None
|
: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
|
# Initialize all modules
|
||||||
telegram.init(config)
|
telegram.init(config)
|
||||||
persistence.init(config, db_url)
|
persistence.init(config, db_url)
|
||||||
exchange.init(config)
|
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, argv: Optional[list] = None) -> None:
|
||||||
def app(config: dict) -> None:
|
|
||||||
"""
|
"""
|
||||||
Main loop which handles the application state
|
Main loop which handles the application state
|
||||||
:param config: config as dict
|
:param config: config as dict
|
||||||
|
:param argv: (optional) command-line arguments
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
logger.info('Starting freqtrade %s', __version__)
|
logger.info('Starting freqtrade %s', __version__)
|
||||||
init(config)
|
init(config, argv=argv)
|
||||||
try:
|
try:
|
||||||
old_state = get_state()
|
old_state = get_state()
|
||||||
logger.info('Initial State: %s', old_state)
|
logger.info('Initial State: %s', old_state)
|
||||||
@ -249,6 +265,12 @@ def app(config: dict) -> None:
|
|||||||
_process()
|
_process()
|
||||||
# We need to sleep here because otherwise we would run into bittrex rate limit
|
# We need to sleep here because otherwise we would run into bittrex rate limit
|
||||||
time.sleep(exchange.EXCHANGE.sleep_time)
|
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
|
old_state = new_state
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc()))
|
telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc()))
|
||||||
@ -266,7 +288,7 @@ def main():
|
|||||||
with open('config.json') as file:
|
with open('config.json') as file:
|
||||||
_CONF = json.load(file)
|
_CONF = json.load(file)
|
||||||
validate(_CONF, CONF_SCHEMA)
|
validate(_CONF, CONF_SCHEMA)
|
||||||
app(_CONF)
|
app(_CONF, sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import argparse
|
||||||
import enum
|
import enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from wrapt import synchronized
|
from wrapt import synchronized
|
||||||
|
|
||||||
@ -6,8 +8,12 @@ from wrapt import synchronized
|
|||||||
class State(enum.Enum):
|
class State(enum.Enum):
|
||||||
RUNNING = 0
|
RUNNING = 0
|
||||||
STOPPED = 1
|
STOPPED = 1
|
||||||
|
BACKTESTING = 2
|
||||||
|
|
||||||
|
|
||||||
|
# Command-line arguments
|
||||||
|
_ARGS: argparse.Namespace = None
|
||||||
|
|
||||||
# Current application state
|
# Current application state
|
||||||
_STATE = State.STOPPED
|
_STATE = State.STOPPED
|
||||||
|
|
||||||
@ -32,6 +38,29 @@ def get_state() -> State:
|
|||||||
return _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
|
# Required json-schema for user specified config
|
||||||
CONF_SCHEMA = {
|
CONF_SCHEMA = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
|
@ -8,6 +8,7 @@ from sqlalchemy.orm.session import sessionmaker
|
|||||||
from sqlalchemy.types import Enum
|
from sqlalchemy.types import Enum
|
||||||
|
|
||||||
from freqtrade import exchange
|
from freqtrade import exchange
|
||||||
|
from freqtrade.misc import State, get_state
|
||||||
|
|
||||||
_CONF = {}
|
_CONF = {}
|
||||||
|
|
||||||
@ -25,7 +26,9 @@ def init(config: dict, db_url: Optional[str] = None) -> None:
|
|||||||
"""
|
"""
|
||||||
_CONF.update(config)
|
_CONF.update(config)
|
||||||
if not db_url:
|
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'
|
db_url = 'sqlite:///tradesv2.dry_run.sqlite'
|
||||||
else:
|
else:
|
||||||
db_url = 'sqlite:///tradesv2.sqlite'
|
db_url = 'sqlite:///tradesv2.sqlite'
|
||||||
|
206
freqtrade/tests/test_backtesting_internal.py
Normal file
206
freqtrade/tests/test_backtesting_internal.py
Normal 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]
|
Loading…
Reference in New Issue
Block a user