diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..3d5e5889f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = freqtrade/tests/* diff --git a/.travis.yml b/.travis.yml index a31c421f9..461324f45 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,10 +4,6 @@ os: language: python python: - 3.6 -- nightly -matrix: - allow_failures: - - python: nightly addons: apt: packages: @@ -19,9 +15,12 @@ install: - tar zxvf ta-lib-0.4.0-src.tar.gz - cd ta-lib && ./configure && sudo make && sudo make install && cd .. - export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH +- pip install coveralls - pip install -r requirements.txt script: -- python -m unittest +- pytest --cov=freqtrade --cov-config=.coveragerc freqtrade/tests/ +after_success: +- coveralls notifications: slack: secure: bKLXmOrx8e2aPZl7W8DA5BdPAXWGpI5UzST33oc1G/thegXcDVmHBTJrBs4sZak6bgAclQQrdZIsRd2eFYzHLalJEaw6pk7hoAw8SvLnZO0ZurWboz7qg2+aZZXfK4eKl/VUe4sM9M4e/qxjkK+yWG7Marg69c4v1ypF7ezUi1fPYILYw8u0paaiX0N5UX8XNlXy+PBlga2MxDjUY70MuajSZhPsY2pDUvYnMY1D/7XN3cFW0g+3O8zXjF0IF4q1Z/1ASQe+eYjKwPQacE+O8KDD+ZJYoTOFBAPllrtpO1jnOPFjNGf3JIbVMZw4bFjIL0mSQaiSUaUErbU3sFZ5Or79rF93XZ81V7uEZ55vD8KMfR2CB1cQJcZcj0v50BxLo0InkFqa0Y8Nra3sbpV4fV5Oe8pDmomPJrNFJnX6ULQhQ1gTCe0M5beKgVms5SITEpt4/Y0CmLUr6iHDT0CUiyMIRWAXdIgbGh1jfaWOMksybeRevlgDsIsNBjXmYI1Sw2ZZR2Eo2u4R6zyfyjOMLwYJ3vgq9IrACv2w5nmf0+oguMWHf6iWi2hiOqhlAN1W74+3HsYQcqnuM3LGOmuCnPprV1oGBqkPXjIFGpy21gNx4vHfO1noLUyJnMnlu2L7SSuN1CdLsnjJ1hVjpJjPfqB4nn8g12x87TqM1bOm+3Q= diff --git a/Dockerfile b/Dockerfile index f03d75e4d..bd303769d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,20 @@ FROM python:3.6.2 -RUN pip install numpy RUN apt-get update -RUN apt-get -y install build-essential +RUN apt-get -y install build-essential + +# Install TA-lib RUN wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz RUN tar zxvf ta-lib-0.4.0-src.tar.gz RUN cd ta-lib && ./configure && make && make install ENV LD_LIBRARY_PATH /usr/local/lib -RUN mkdir -p /freqtrade +# Prepare environment +RUN mkdir /freqtrade +COPY . /freqtrade/ WORKDIR /freqtrade -ADD ./requirements.txt /freqtrade/requirements.txt -RUN pip install -r requirements.txt -ADD . /freqtrade -CMD python main.py +# Install dependencies and execute +RUN pip install -r requirements.txt +RUN pip install -e . +CMD ["freqtrade"] diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..ef776087e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include LICENSE +include README.md +include config.json.example +include freqtrade/exchange/*.py +include freqtrade/rpc/*.py +include freqtrade/tests/*.py +include freqtrade/tests/testdata/*.json diff --git a/README.md b/README.md index baec46d95..d9eb017ff 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # freqtrade [![Build Status](https://travis-ci.org/gcarq/freqtrade.svg?branch=develop)](https://travis-ci.org/gcarq/freqtrade) +[![Coverage Status](https://coveralls.io/repos/github/gcarq/freqtrade/badge.svg?branch=develop)](https://coveralls.io/github/gcarq/freqtrade?branch=develop) + Simple High frequency trading bot for crypto currencies. Currently supports trading on Bittrex exchange. @@ -68,7 +70,8 @@ $ cp config.json.example config.json $ python -m venv .env $ source .env/bin/activate $ pip install -r requirements.txt -$ ./main.py +$ pip install -e . +$ ./freqtrade/main.py ``` There is also an [article](https://www.sales4k.com/blockchain/high-frequency-trading-bot-tutorial/) about how to setup the bot (thanks [@gurghet](https://github.com/gurghet)). @@ -76,7 +79,12 @@ There is also an [article](https://www.sales4k.com/blockchain/high-frequency-tra #### Execute tests ``` -$ python -m unittest +$ pytest +``` +This will by default skip the slow running backtest set. To run backtest set: + +``` +$ BACKTEST=true pytest ``` #### Docker diff --git a/bin/freqtrade b/bin/freqtrade new file mode 100755 index 000000000..3ecd3a520 --- /dev/null +++ b/bin/freqtrade @@ -0,0 +1,4 @@ +#!/usr/bin/env python + +from freqtrade.main import main +main() \ No newline at end of file diff --git a/config.json.example b/config.json.example index a0b93c455..d2ae679db 100644 --- a/config.json.example +++ b/config.json.example @@ -4,16 +4,17 @@ "stake_amount": 0.05, "dry_run": false, "minimal_roi": { - "2880": 0.005, - "720": 0.01, - "0": 0.02 + "60": 0.0, + "40": 0.01, + "20": 0.02, + "0": 0.03 }, - "stoploss": -0.10, + "stoploss": -0.40, "bid_strategy": { "ask_last_balance": 0.0 }, - "bittrex": { - "enabled": true, + "exchange": { + "name": "bittrex", "key": "key", "secret": "secret", "pair_whitelist": [ diff --git a/exchange.py b/exchange.py deleted file mode 100644 index 2b0947311..000000000 --- a/exchange.py +++ /dev/null @@ -1,170 +0,0 @@ -import enum -import logging -from typing import List - -from bittrex.bittrex import Bittrex - -logger = logging.getLogger(__name__) - -# Current selected exchange -EXCHANGE = None -_API = None -_CONF = {} - - -class Exchange(enum.Enum): - BITTREX = 1 - - -def init(config: dict) -> None: - """ - Initializes this module with the given config, - it does basic validation whether the specified - exchange and pairs are valid. - :param config: config to use - :return: None - """ - global _API, EXCHANGE - - _CONF.update(config) - - if config['dry_run']: - logger.info('Instance is running with dry_run enabled') - - use_bittrex = config.get('bittrex', {}).get('enabled', False) - if use_bittrex: - EXCHANGE = Exchange.BITTREX - _API = Bittrex(api_key=config['bittrex']['key'], api_secret=config['bittrex']['secret']) - else: - raise RuntimeError('No exchange specified. Aborting!') - - # Check if all pairs are available - markets = get_markets() - exchange_name = EXCHANGE.name.lower() - for pair in config[exchange_name]['pair_whitelist']: - if pair not in markets: - raise RuntimeError('Pair {} is not available at {}'.format(pair, exchange_name)) - - -def buy(pair: str, rate: float, amount: float) -> str: - """ - Places a limit buy order. - :param pair: Pair as str, format: BTC_ETH - :param rate: Rate limit for order - :param amount: The amount to purchase - :return: order_id of the placed buy order - """ - if _CONF['dry_run']: - return 'dry_run' - elif EXCHANGE == Exchange.BITTREX: - data = _API.buy_limit(pair.replace('_', '-'), amount, rate) - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return data['result']['uuid'] - - -def sell(pair: str, rate: float, amount: float) -> str: - """ - Places a limit sell order. - :param pair: Pair as str, format: BTC_ETH - :param rate: Rate limit for order - :param amount: The amount to sell - :return: None - """ - if _CONF['dry_run']: - return 'dry_run' - elif EXCHANGE == Exchange.BITTREX: - data = _API.sell_limit(pair.replace('_', '-'), amount, rate) - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return data['result']['uuid'] - - -def get_balance(currency: str) -> float: - """ - Get account balance. - :param currency: currency as str, format: BTC - :return: float - """ - if _CONF['dry_run']: - return 999.9 - elif EXCHANGE == Exchange.BITTREX: - data = _API.get_balance(currency) - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return float(data['result']['Balance'] or 0.0) - - -def get_ticker(pair: str) -> dict: - """ - Get Ticker for given pair. - :param pair: Pair as str, format: BTC_ETC - :return: dict - """ - if EXCHANGE == Exchange.BITTREX: - data = _API.get_ticker(pair.replace('_', '-')) - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return { - 'bid': float(data['result']['Bid']), - 'ask': float(data['result']['Ask']), - 'last': float(data['result']['Last']), - } - - -def cancel_order(order_id: str) -> None: - """ - Cancel order for given order_id - :param order_id: id as str - :return: None - """ - if _CONF['dry_run']: - pass - elif EXCHANGE == Exchange.BITTREX: - data = _API.cancel(order_id) - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - - -def get_open_orders(pair: str) -> List[dict]: - """ - Get all open orders for given pair. - :param pair: Pair as str, format: BTC_ETC - :return: list of dicts - """ - if _CONF['dry_run']: - return [] - elif EXCHANGE == Exchange.BITTREX: - data = _API.get_open_orders(pair.replace('_', '-')) - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return [{ - 'id': entry['OrderUuid'], - 'type': entry['OrderType'], - 'opened': entry['Opened'], - 'rate': entry['PricePerUnit'], - 'amount': entry['Quantity'], - 'remaining': entry['QuantityRemaining'], - } for entry in data['result']] - - -def get_pair_detail_url(pair: str) -> str: - """ - Returns the market detail url for the given pair - :param pair: pair as str, format: BTC_ANT - :return: url as str - """ - if EXCHANGE == Exchange.BITTREX: - return 'https://bittrex.com/Market/Index?MarketName={}'.format(pair.replace('_', '-')) - - -def get_markets() -> List[str]: - """ - Returns all available markets - :return: list of all available pairs - """ - if EXCHANGE == Exchange. BITTREX: - data = _API.get_markets() - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return [m['MarketName'].replace('-', '_') for m in data['result']] diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py new file mode 100644 index 000000000..db6fd3728 --- /dev/null +++ b/freqtrade/__init__.py @@ -0,0 +1,3 @@ +__version__ = '0.11.0' + +from . import main diff --git a/analyze.py b/freqtrade/analyze.py similarity index 64% rename from analyze.py rename to freqtrade/analyze.py index 338549c87..159e1d137 100644 --- a/analyze.py +++ b/freqtrade/analyze.py @@ -1,36 +1,18 @@ +import logging import time from datetime import timedelta -import logging -import arrow -import requests -from pandas import DataFrame -import talib.abstract as ta +import arrow +import talib.abstract as ta +from pandas import DataFrame + +from freqtrade.exchange import get_ticker_history logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -def get_ticker(pair: str, minimum_date: arrow.Arrow) -> dict: - """ - Request ticker data from Bittrex for a given currency pair - """ - url = 'https://bittrex.com/Api/v2.0/pub/market/GetTicks' - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36', - } - params = { - 'marketName': pair.replace('_', '-'), - 'tickInterval': 'fiveMin', - '_': minimum_date.timestamp * 1000 - } - data = requests.get(url, params=params, headers=headers).json() - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - return data - - def parse_ticker_dataframe(ticker: list, minimum_date: arrow.Arrow) -> DataFrame: """ Analyses the trend for the given pair @@ -48,10 +30,16 @@ def populate_indicators(dataframe: DataFrame) -> DataFrame: """ Adds several different TA indicators to the given DataFrame """ - dataframe['ema'] = ta.EMA(dataframe, timeperiod=33) dataframe['sar'] = ta.SAR(dataframe, 0.02, 0.22) dataframe['adx'] = ta.ADX(dataframe) - + stoch = ta.STOCHF(dataframe) + dataframe['fastd'] = stoch['fastd'] + dataframe['fastk'] = stoch['fastk'] + dataframe['blower'] = ta.BBANDS(dataframe, nbdevup=2, nbdevdn=2)['lowerband'] + dataframe['cci'] = ta.CCI(dataframe, timeperiod=5) + dataframe['sma'] = ta.SMA(dataframe, timeperiod=100) + dataframe['tema'] = ta.TEMA(dataframe, timeperiod=4) + dataframe['mfi'] = ta.MFI(dataframe) return dataframe @@ -61,26 +49,14 @@ def populate_buy_trend(dataframe: DataFrame) -> DataFrame: :param dataframe: DataFrame :return: DataFrame with buy column """ - prev_sar = dataframe['sar'].shift(1) - prev_close = dataframe['close'].shift(1) - prev_sar2 = dataframe['sar'].shift(2) - prev_close2 = dataframe['close'].shift(2) - - # wait for stable turn from bearish to bullish market - dataframe.loc[ - (dataframe['close'] > dataframe['sar']) & - (prev_close > prev_sar) & - (prev_close2 < prev_sar2), - 'swap' - ] = 1 - - # consider prices above ema to be in upswing - dataframe.loc[dataframe['ema'] <= dataframe['close'], 'upswing'] = 1 dataframe.loc[ - (dataframe['upswing'] == 1) & - (dataframe['swap'] == 1) & - (dataframe['adx'] > 25), # adx over 25 tells there's enough momentum + (dataframe['close'] < dataframe['sma']) & + (dataframe['cci'] < -100) & + (dataframe['tema'] <= dataframe['blower']) & + (dataframe['mfi'] < 30) & + (dataframe['fastd'] < 20) & + (dataframe['adx'] > 20), 'buy'] = 1 dataframe.loc[dataframe['buy'] == 1, 'buy_price'] = dataframe['close'] @@ -93,13 +69,19 @@ def analyze_ticker(pair: str) -> DataFrame: add several TA indicators and buy signal to it :return DataFrame with ticker data and indicator data """ - minimum_date = arrow.utcnow().shift(hours=-6) - data = get_ticker(pair, minimum_date) + 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 + def get_buy_signal(pair: str) -> bool: """ Calculates a buy signal based several technical analysis indicators @@ -107,6 +89,10 @@ def get_buy_signal(pair: str) -> bool: :return: True if pair is good for buying, False otherwise """ dataframe = analyze_ticker(pair) + + if dataframe.empty: + return False + latest = dataframe.iloc[-1] # Check if dataframe is out of date @@ -138,12 +124,13 @@ def plot_dataframe(dataframe: DataFrame, pair: str) -> None: ax1.plot(dataframe.index.values, dataframe['sar'], 'g_', label='pSAR') ax1.plot(dataframe.index.values, dataframe['close'], label='close') # ax1.plot(dataframe.index.values, dataframe['sell'], 'ro', label='sell') - ax1.plot(dataframe.index.values, dataframe['ema'], '--', label='EMA(20)') - ax1.plot(dataframe.index.values, dataframe['buy'], 'bo', label='buy') + ax1.plot(dataframe.index.values, dataframe['sma'], '--', label='SMA') + ax1.plot(dataframe.index.values, dataframe['buy_price'], 'bo', label='buy') ax1.legend() - ax2.plot(dataframe.index.values, dataframe['adx'], label='ADX') - ax2.plot(dataframe.index.values, [25] * len(dataframe.index.values)) + # ax2.plot(dataframe.index.values, dataframe['adx'], label='ADX') + ax2.plot(dataframe.index.values, dataframe['mfi'], label='MFI') + # ax2.plot(dataframe.index.values, [25] * len(dataframe.index.values)) ax2.legend() # Fine-tune figure; make subplots close to each other and hide x ticks for @@ -156,8 +143,8 @@ def plot_dataframe(dataframe: DataFrame, pair: str) -> None: if __name__ == '__main__': # Install PYQT5==5.9 manually if you want to test this helper function while True: - test_pair = 'BTC_ANT' - #for pair in ['BTC_ANT', 'BTC_ETH', 'BTC_GNT', 'BTC_ETC']: - # get_buy_signal(pair) + test_pair = 'BTC_ETH' + # for pair in ['BTC_ANT', 'BTC_ETH', 'BTC_GNT', 'BTC_ETC']: + # get_buy_signal(pair) plot_dataframe(analyze_ticker(test_pair), test_pair) time.sleep(60) diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py new file mode 100644 index 000000000..77a2d4b84 --- /dev/null +++ b/freqtrade/exchange/__init__.py @@ -0,0 +1,115 @@ +import enum +import logging +from typing import List + +import arrow + +from freqtrade.exchange.bittrex import Bittrex +from freqtrade.exchange.interface import Exchange + +logger = logging.getLogger(__name__) + +# Current selected exchange +EXCHANGE: Exchange = None +_CONF: dict = {} + + +class Exchanges(enum.Enum): + """ + Maps supported exchange names to correspondent classes. + """ + BITTREX = Bittrex + + +def init(config: dict) -> None: + """ + Initializes this module with the given config, + it does basic validation whether the specified + exchange and pairs are valid. + :param config: config to use + :return: None + """ + global _CONF, EXCHANGE + + _CONF.update(config) + + if 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'] + try: + exchange_class = Exchanges[name.upper()].value + except KeyError: + raise RuntimeError('Exchange {} is not supported'.format(name)) + + EXCHANGE = exchange_class(exchange_config) + + # Check if all pairs are available + validate_pairs(config['exchange']['pair_whitelist']) + + +def validate_pairs(pairs: List[str]) -> None: + """ + Checks if all given pairs are tradable on the current exchange. + Raises RuntimeError if one pair is not available. + :param pairs: list of pairs + :return: None + """ + markets = EXCHANGE.get_markets() + for pair in pairs: + if pair not in markets: + raise RuntimeError('Pair {} is not available at {}'.format(pair, EXCHANGE.name.lower())) + + +def buy(pair: str, rate: float, amount: float) -> str: + if _CONF['dry_run']: + return 'dry_run' + + return EXCHANGE.buy(pair, rate, amount) + + +def sell(pair: str, rate: float, amount: float) -> str: + if _CONF['dry_run']: + return 'dry_run' + + return EXCHANGE.sell(pair, rate, amount) + + +def get_balance(currency: str) -> float: + if _CONF['dry_run']: + return 999.9 + + return EXCHANGE.get_balance(currency) + + +def get_ticker(pair: str) -> dict: + return EXCHANGE.get_ticker(pair) + + +def get_ticker_history(pair: str, minimum_date: arrow.Arrow): + return EXCHANGE.get_ticker_history(pair, minimum_date) + + +def cancel_order(order_id: str) -> None: + if _CONF['dry_run']: + return + + return EXCHANGE.cancel_order(order_id) + + +def get_open_orders(pair: str) -> List[dict]: + if _CONF['dry_run']: + return [] + + return EXCHANGE.get_open_orders(pair) + + +def get_pair_detail_url(pair: str) -> str: + return EXCHANGE.get_pair_detail_url(pair) + + +def get_markets() -> List[str]: + return EXCHANGE.get_markets() diff --git a/freqtrade/exchange/bittrex.py b/freqtrade/exchange/bittrex.py new file mode 100644 index 000000000..d2fd08ddc --- /dev/null +++ b/freqtrade/exchange/bittrex.py @@ -0,0 +1,120 @@ +import logging +from typing import List, Optional + +import arrow +import requests +from bittrex.bittrex import Bittrex as _Bittrex + +from freqtrade.exchange.interface import Exchange + +logger = logging.getLogger(__name__) + +_API: _Bittrex = None +_EXCHANGE_CONF: dict = {} + + +class Bittrex(Exchange): + """ + Bittrex API wrapper. + """ + # Base URL and API endpoints + BASE_URL: str = 'https://www.bittrex.com' + TICKER_METHOD: str = BASE_URL + '/Api/v2.0/pub/market/GetTicks' + PAIR_DETAIL_METHOD: str = BASE_URL + '/Market/Index' + # Ticker inveral + TICKER_INTERVAL: str = 'fiveMin' + # Sleep time to avoid rate limits, used in the main loop + SLEEP_TIME: float = 25 + + @property + def name(self) -> str: + return self.__class__.__name__ + + @property + def sleep_time(self) -> float: + return self.SLEEP_TIME + + def __init__(self, config: dict) -> None: + global _API, _EXCHANGE_CONF + + _EXCHANGE_CONF.update(config) + _API = _Bittrex(api_key=_EXCHANGE_CONF['key'], api_secret=_EXCHANGE_CONF['secret']) + + # Check if all pairs are available + markets = self.get_markets() + exchange_name = self.name + for pair in _EXCHANGE_CONF['pair_whitelist']: + if pair not in markets: + raise RuntimeError('Pair {} is not available at {}'.format(pair, exchange_name)) + + def buy(self, pair: str, rate: float, amount: float) -> str: + data = _API.buy_limit(pair.replace('_', '-'), amount, rate) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return data['result']['uuid'] + + def sell(self, pair: str, rate: float, amount: float) -> str: + data = _API.sell_limit(pair.replace('_', '-'), amount, rate) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return data['result']['uuid'] + + def get_balance(self, currency: str) -> float: + data = _API.get_balance(currency) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return float(data['result']['Balance'] or 0.0) + + def get_ticker(self, pair: str) -> dict: + data = _API.get_ticker(pair.replace('_', '-')) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return { + 'bid': float(data['result']['Bid']), + 'ask': float(data['result']['Ask']), + 'last': float(data['result']['Last']), + } + + def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None): + url = self.TICKER_METHOD + headers = { + # TODO: Set as global setting + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36' + } + params = { + 'marketName': pair.replace('_', '-'), + 'tickInterval': self.TICKER_INTERVAL, + # TODO: Timestamp has no effect on API response + '_': minimum_date.timestamp * 1000 + } + data = requests.get(url, params=params, headers=headers).json() + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return data + + def cancel_order(self, order_id: str) -> None: + data = _API.cancel(order_id) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + + def get_open_orders(self, pair: str) -> List[dict]: + data = _API.get_open_orders(pair.replace('_', '-')) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return [{ + 'id': entry['OrderUuid'], + 'type': entry['OrderType'], + 'opened': entry['Opened'], + 'rate': entry['PricePerUnit'], + 'amount': entry['Quantity'], + 'remaining': entry['QuantityRemaining'], + } for entry in data['result']] + + def get_pair_detail_url(self, pair: str) -> str: + return self.PAIR_DETAIL_METHOD + '?MarketName={}'.format(pair.replace('_', '-')) + + def get_markets(self) -> List[str]: + data = _API.get_markets() + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return [m['MarketName'].replace('-', '_') for m in data['result']] diff --git a/freqtrade/exchange/interface.py b/freqtrade/exchange/interface.py new file mode 100644 index 000000000..114ac9a6f --- /dev/null +++ b/freqtrade/exchange/interface.py @@ -0,0 +1,127 @@ +from abc import ABC, abstractmethod +from typing import List, Optional + +import arrow + + +class Exchange(ABC): + @property + def name(self) -> str: + """ + Name of the exchange. + :return: str representation of the class name + """ + return self.__class__.__name__ + + @property + @abstractmethod + def sleep_time(self) -> float: + """ + Sleep time in seconds for the main loop to avoid API rate limits. + :return: float + """ + + @abstractmethod + def buy(self, pair: str, rate: float, amount: float) -> str: + """ + Places a limit buy order. + :param pair: Pair as str, format: BTC_ETH + :param rate: Rate limit for order + :param amount: The amount to purchase + :return: order_id of the placed buy order + """ + + @abstractmethod + def sell(self, pair: str, rate: float, amount: float) -> str: + """ + Places a limit sell order. + :param pair: Pair as str, format: BTC_ETH + :param rate: Rate limit for order + :param amount: The amount to sell + :return: order_id of the placed sell order + """ + + @abstractmethod + def get_balance(self, currency: str) -> float: + """ + Gets account balance. + :param currency: Currency as str, format: BTC + :return: float + """ + + @abstractmethod + def get_ticker(self, pair: str) -> dict: + """ + Gets ticker for given pair. + :param pair: Pair as str, format: BTC_ETC + :return: dict, format: { + 'bid': float, + 'ask': float, + 'last': float + } + """ + + @abstractmethod + def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None) -> dict: + """ + Gets ticker history for given pair. + :param pair: Pair as str, format: BTC_ETC + :param minimum_date: Minimum date (optional) + :return: dict, format: { + 'success': bool, + 'message': str, + 'result': [ + { + 'O': float, (Open) + 'H': float, (High) + 'L': float, (Low) + 'C': float, (Close) + 'V': float, (Volume) + 'T': datetime, (Time) + 'BV': float, (Base Volume) + }, + ... + ] + } + """ + + @abstractmethod + def cancel_order(self, order_id: str) -> None: + """ + Cancels order for given order_id. + :param order_id: ID as str + :return: None + """ + + @abstractmethod + def get_open_orders(self, pair: str) -> List[dict]: + """ + Gets all open orders for given pair. + :param pair: Pair as str, format: BTC_ETC + :return: List of dicts, format: [ + { + 'id': str, + 'type': str, + 'opened': datetime, + 'rate': float, + 'amount': float, + 'remaining': int, + }, + ... + ] + """ + + @abstractmethod + def get_pair_detail_url(self, pair: str) -> str: + """ + Returns the market detail url for the given pair. + :param pair: Pair as str, format: BTC_ETC + :return: URL as str + """ + + @abstractmethod + def get_markets(self) -> List[str]: + """ + Returns all available markets. + :return: List of all available pairs + """ diff --git a/main.py b/freqtrade/main.py similarity index 89% rename from main.py rename to freqtrade/main.py index 8f2256bb4..ce933fe44 100755 --- a/main.py +++ b/freqtrade/main.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import copy import json import logging import time @@ -8,22 +9,16 @@ from typing import Dict, Optional from jsonschema import validate -import exchange -import persistence -from persistence import Trade -from analyze import get_buy_signal -from misc import CONF_SCHEMA, get_state, State, update_state -from rpc import telegram +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.persistence import Trade +from freqtrade.rpc import telegram logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -__author__ = "gcarq" -__copyright__ = "gcarq 2017" -__license__ = "GPLv3" -__version__ = "0.10.0" - _CONF = {} @@ -39,7 +34,7 @@ def _process() -> None: if len(trades) < _CONF['max_open_trades']: try: # Create entity and execute trade - trade = create_trade(float(_CONF['stake_amount']), exchange.EXCHANGE) + trade = create_trade(float(_CONF['stake_amount'])) if trade: Trade.session.add(trade) else: @@ -94,12 +89,9 @@ def execute_sell(trade: Trade, current_rate: float) -> None: # Get available balance currency = trade.pair.split('_')[1] balance = exchange.get_balance(currency) - whitelist = _CONF[trade.exchange.name.lower()]['pair_whitelist'] - profit = trade.exec_sell_order(current_rate, balance) - whitelist.append(trade.pair) message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format( - trade.exchange.name, + trade.exchange, trade.pair.replace('_', '/'), exchange.get_pair_detail_url(trade.pair), trade.close_rate, @@ -150,6 +142,7 @@ def handle_trade(trade: Trade) -> None: except ValueError: logger.exception('Unable to handle open order') + def get_target_bid(ticker: Dict[str, float]) -> float: """ Calculates bid target between current ask price and last price """ if ticker['ask'] < ticker['last']: @@ -158,15 +151,14 @@ def get_target_bid(ticker: Dict[str, float]) -> float: return ticker['ask'] + balance * (ticker['last'] - ticker['ask']) -def create_trade(stake_amount: float, _exchange: exchange.Exchange) -> Optional[Trade]: +def create_trade(stake_amount: float) -> Optional[Trade]: """ Checks the implemented trading indicator(s) for a randomly picked pair, if one pair triggers the buy_signal a new trade record gets created :param stake_amount: amount of btc to spend - :param _exchange: exchange to use """ logger.info('Creating new trade with stake_amount: %f ...', stake_amount) - whitelist = _CONF[_exchange.name.lower()]['pair_whitelist'] + whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist']) # Check if stake_amount is fulfilled if exchange.get_balance(_CONF['stake_currency']) < stake_amount: raise ValueError( @@ -174,11 +166,7 @@ def create_trade(stake_amount: float, _exchange: exchange.Exchange) -> Optional[ ) # Remove currently opened and latest pairs from whitelist - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - latest_trade = Trade.query.filter(Trade.is_open.is_(False)).order_by(Trade.id.desc()).first() - if latest_trade: - trades.append(latest_trade) - for trade in trades: + for trade in Trade.query.filter(Trade.is_open.is_(True)).all(): if trade.pair in whitelist: whitelist.remove(trade.pair) logger.debug('Ignoring %s in pair whitelist', trade.pair) @@ -199,7 +187,7 @@ def create_trade(stake_amount: float, _exchange: exchange.Exchange) -> Optional[ # Create trade entity and return message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format( - _exchange.name, + exchange.EXCHANGE.name.upper(), pair.replace('_', '/'), exchange.get_pair_detail_url(pair), open_rate @@ -211,7 +199,7 @@ def create_trade(stake_amount: float, _exchange: exchange.Exchange) -> Optional[ open_rate=open_rate, open_date=datetime.utcnow(), amount=amount, - exchange=_exchange, + exchange=exchange.EXCHANGE.name.upper(), open_order_id=order_id, is_open=True) @@ -238,7 +226,7 @@ def init(config: dict, db_url: Optional[str] = None) -> None: def app(config: dict) -> None: """ - Main function which handles the application state + Main loop which handles the application state :param config: config as dict :return: None """ @@ -260,7 +248,7 @@ def app(config: dict) -> None: elif new_state == State.RUNNING: _process() # We need to sleep here because otherwise we would run into bittrex rate limit - time.sleep(25) + time.sleep(exchange.EXCHANGE.sleep_time) old_state = new_state except RuntimeError: telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc())) @@ -269,8 +257,17 @@ def app(config: dict) -> None: telegram.send_msg('*Status:* `Trader has stopped`') -if __name__ == '__main__': +def main(): + """ + Loads and validates the config and starts the main loop + :return: None + """ + global _CONF with open('config.json') as file: _CONF = json.load(file) validate(_CONF, CONF_SCHEMA) app(_CONF) + + +if __name__ == '__main__': + main() diff --git a/misc.py b/freqtrade/misc.py similarity index 92% rename from misc.py rename to freqtrade/misc.py index be8cf71b9..585aee3de 100644 --- a/misc.py +++ b/freqtrade/misc.py @@ -60,7 +60,7 @@ CONF_SCHEMA = { }, 'required': ['ask_last_balance'] }, - 'bittrex': {'$ref': '#/definitions/exchange'}, + 'exchange': {'$ref': '#/definitions/exchange'}, 'telegram': { 'type': 'object', 'properties': { @@ -76,7 +76,7 @@ CONF_SCHEMA = { 'exchange': { 'type': 'object', 'properties': { - 'enabled': {'type': 'boolean'}, + 'name': {'type': 'string'}, 'key': {'type': 'string'}, 'secret': {'type': 'string'}, 'pair_whitelist': { @@ -85,11 +85,11 @@ CONF_SCHEMA = { 'uniqueItems': True } }, - 'required': ['enabled', 'key', 'secret', 'pair_whitelist'] + 'required': ['name', 'key', 'secret', 'pair_whitelist'] } }, 'anyOf': [ - {'required': ['bittrex']} + {'required': ['exchange']} ], 'required': [ 'max_open_trades', diff --git a/persistence.py b/freqtrade/persistence.py similarity index 97% rename from persistence.py rename to freqtrade/persistence.py index dd917a750..7f8bfbc69 100644 --- a/persistence.py +++ b/freqtrade/persistence.py @@ -5,11 +5,9 @@ from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.orm.session import sessionmaker - from sqlalchemy.types import Enum -import exchange - +from freqtrade import exchange _CONF = {} @@ -43,7 +41,7 @@ class Trade(Base): __tablename__ = 'trades' id = Column(Integer, primary_key=True) - exchange = Column(Enum(exchange.Exchange), nullable=False) + exchange = Column(String, nullable=False) pair = Column(String, nullable=False) is_open = Column(Boolean, nullable=False, default=True) open_rate = Column(Float, nullable=False) diff --git a/rpc/__init__.py b/freqtrade/rpc/__init__.py similarity index 100% rename from rpc/__init__.py rename to freqtrade/rpc/__init__.py diff --git a/rpc/telegram.py b/freqtrade/rpc/telegram.py similarity index 98% rename from rpc/telegram.py rename to freqtrade/rpc/telegram.py index cf764a65c..5312125ed 100644 --- a/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -4,14 +4,13 @@ from typing import Callable, Any import arrow from sqlalchemy import and_, func, text +from telegram import ParseMode, Bot, Update from telegram.error import NetworkError from telegram.ext import CommandHandler, Updater -from telegram import ParseMode, Bot, Update -from misc import get_state, State, update_state -from persistence import Trade - -import exchange +from freqtrade import exchange +from freqtrade.misc import get_state, State, update_state +from freqtrade.persistence import Trade # Remove noisy log messages logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO) @@ -31,9 +30,12 @@ def init(config: dict) -> None: :return: None """ global _updater - _updater = Updater(token=config['telegram']['token'], workers=0) _CONF.update(config) + if not _CONF['telegram']['enabled']: + return + + _updater = Updater(token=config['telegram']['token'], workers=0) # Register command handler and start telegram message polling handles = [ @@ -255,7 +257,7 @@ def _forcesell(bot: Bot, update: Update) -> None: # Execute sell profit = trade.exec_sell_order(current_rate, balance) message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format( - trade.exchange.name, + trade.exchange, trade.pair.replace('_', '/'), exchange.get_pair_detail_url(trade.pair), trade.close_rate, diff --git a/test/__init__.py b/freqtrade/tests/__init__.py similarity index 100% rename from test/__init__.py rename to freqtrade/tests/__init__.py diff --git a/freqtrade/tests/test_analyze.py b/freqtrade/tests/test_analyze.py new file mode 100644 index 000000000..e716eb3ad --- /dev/null +++ b/freqtrade/tests/test_analyze.py @@ -0,0 +1,47 @@ +# pragma pylint: disable=missing-docstring +import pytest +import arrow +from pandas import DataFrame + +from freqtrade.analyze import parse_ticker_dataframe, populate_buy_trend, populate_indicators, \ + get_buy_signal + +RESULT_BITTREX = { + 'success': True, + 'message': '', + 'result': [ + {'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 22.17210568, 'T': '2017-08-30T10:40:00', 'BV': 0.01448082}, + {'O': 0.00066194, 'H': 0.00066195, 'L': 0.00066194, 'C': 0.00066195, 'V': 33.4727437, 'T': '2017-08-30T10:34:00', 'BV': 0.02215696}, + {'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 53.85127609, 'T': '2017-08-30T10:37:00', 'BV': 0.0351708}, + {'O': 0.00066194, 'H': 0.00066194, 'L': 0.00065311, 'C': 0.00065311, 'V': 46.29210665, 'T': '2017-08-30T10:42:00', 'BV': 0.03063118}, + ] +} + +@pytest.fixture +def result(): + return parse_ticker_dataframe(RESULT_BITTREX['result'], arrow.get('2017-08-30T10:00:00')) + +def test_dataframe_has_correct_columns(result): + assert result.columns.tolist() == \ + ['close', 'high', 'low', 'open', 'date', 'volume'] + +def test_orders_by_date(result): + assert result['date'].tolist() == \ + ['2017-08-30T10:34:00', + '2017-08-30T10:37:00', + '2017-08-30T10:40:00', + '2017-08-30T10:42:00'] + +def test_populates_buy_trend(result): + dataframe = populate_buy_trend(populate_indicators(result)) + assert 'buy' in dataframe.columns + assert 'buy_price' in dataframe.columns + +def test_returns_latest_buy_signal(mocker): + buydf = DataFrame([{'buy': 1, 'date': arrow.utcnow()}]) + mocker.patch('freqtrade.analyze.analyze_ticker', return_value=buydf) + assert get_buy_signal('BTC-ETH') + + buydf = DataFrame([{'buy': 0, 'date': arrow.utcnow()}]) + mocker.patch('freqtrade.analyze.analyze_ticker', return_value=buydf) + assert not get_buy_signal('BTC-ETH') diff --git a/freqtrade/tests/test_backtesting.py b/freqtrade/tests/test_backtesting.py new file mode 100644 index 000000000..fb3b7e511 --- /dev/null +++ b/freqtrade/tests/test_backtesting.py @@ -0,0 +1,77 @@ +# pragma pylint: disable=missing-docstring +import json +import logging +import os + +import pytest +import arrow +from pandas import DataFrame + +from freqtrade.analyze import analyze_ticker +from freqtrade.main import should_sell +from freqtrade.persistence import Trade + +logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot + +def print_results(results): + print('Made {} buys. Average profit {:.2f}%. Total profit was {:.3f}. Average duration {:.1f} mins.'.format( + len(results.index), + results.profit.mean() * 100.0, + results.profit.sum(), + results.duration.mean()*5 + )) + +@pytest.fixture +def pairs(): + return ['btc-neo', 'btc-eth', 'btc-omg', 'btc-edg', 'btc-pay', + 'btc-pivx', 'btc-qtum', 'btc-mtl', 'btc-etc', 'btc-ltc'] + +@pytest.fixture +def conf(): + return { + "minimal_roi": { + "60": 0.0, + "40": 0.01, + "20": 0.02, + "0": 0.03 + }, + "stoploss": -0.40 + } + + +@pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set") +def test_backtest(conf, pairs, mocker): + trades = [] + mocker.patch.dict('freqtrade.main._CONF', conf) + for pair in pairs: + with open('freqtrade/tests/testdata/'+pair+'.json') as data_file: + data = json.load(data_file) + + mocker.patch('freqtrade.analyze.get_ticker_history', return_value=data) + mocker.patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00')) + ticker = analyze_ticker(pair) + # for each buy point + for index, row in ticker[ticker.buy == 1].iterrows(): + trade = Trade( + open_rate=row['close'], + open_date=arrow.get(row['date']).datetime, + amount=1, + ) + # calculate win/lose forwards from buy point + for index2, row2 in ticker[index:].iterrows(): + if should_sell(trade, row2['close'], arrow.get(row2['date']).datetime): + current_profit = (row2['close'] - trade.open_rate) / trade.open_rate + + trades.append((pair, current_profit, index2 - index)) + break + + labels = ['currency', 'profit', 'duration'] + results = DataFrame.from_records(trades, columns=labels) + + print('====================== BACKTESTING REPORT ================================') + + for pair in pairs: + print('For currency {}:'.format(pair)) + print_results(results[results.currency == pair]) + print('TOTAL OVER ALL TRADES:') + print_results(results) diff --git a/freqtrade/tests/test_main.py b/freqtrade/tests/test_main.py new file mode 100644 index 000000000..050d21ad4 --- /dev/null +++ b/freqtrade/tests/test_main.py @@ -0,0 +1,126 @@ +# pragma pylint: disable=missing-docstring +import copy +from unittest.mock import MagicMock, call + +import pytest +from jsonschema import validate + +from freqtrade.exchange import Exchanges +from freqtrade.main import create_trade, handle_trade, close_trade_if_fulfilled, init, \ + get_target_bid +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": { + "2880": 0.005, + "720": 0.01, + "0": 0.02 + }, + "bid_strategy": { + "ask_last_balance": 0.0 + }, + "exchange": { + "name": "bittrex", + "enabled": True, + "key": "key", + "secret": "secret", + "pair_whitelist": [ + "BTC_ETH", + "BTC_TKN", + "BTC_TRST", + "BTC_SWT", + ] + }, + "telegram": { + "enabled": True, + "token": "token", + "chat_id": "chat_id" + } + } + validate(configuration, CONF_SCHEMA) + return configuration + +def test_create_trade(conf, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + buy_signal = mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) + mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock()) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.07256061, + 'ask': 0.072661, + 'last': 0.07256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + # Save state of current whitelist + whitelist = copy.deepcopy(conf['exchange']['pair_whitelist']) + + init(conf, 'sqlite://') + for pair in ['BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT']: + trade = create_trade(15.0) + Trade.session.add(trade) + Trade.session.flush() + assert trade is not None + assert trade.open_rate == 0.072661 + assert trade.pair == pair + assert trade.exchange == Exchanges.BITTREX.name + assert trade.amount == 206.43811673387373 + assert trade.stake_amount == 15.0 + assert trade.is_open + assert trade.open_date is not None + assert whitelist == conf['exchange']['pair_whitelist'] + + buy_signal.assert_has_calls( + [call('BTC_ETH'), call('BTC_TKN'), call('BTC_TRST'), call('BTC_SWT')] + ) + +def test_handle_trade(conf, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock()) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.17256061, + 'ask': 0.172661, + 'last': 0.17256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + trade = Trade.query.filter(Trade.is_open.is_(True)).first() + assert trade + handle_trade(trade) + assert trade.close_rate == 0.17256061 + assert trade.close_profit == 137.4872490056564 + assert trade.close_date is not None + assert trade.open_order_id == 'dry_run' + +def test_close_trade(conf, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + trade = Trade.query.filter(Trade.is_open.is_(True)).first() + assert trade + + # Simulate that there is no open order + trade.open_order_id = None + + closed = close_trade_if_fulfilled(trade) + assert closed + assert not trade.is_open + +def test_balance_fully_ask_side(mocker): + mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 0.0}}) + assert get_target_bid({'ask': 20, 'last': 10}) == 20 + +def test_balance_fully_last_side(mocker): + mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}) + assert get_target_bid({'ask': 20, 'last': 10}) == 10 + +def test_balance_when_last_bigger_than_ask(mocker): + mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}) + assert get_target_bid({'ask': 5, 'last': 10}) == 5 diff --git a/freqtrade/tests/test_persistence.py b/freqtrade/tests/test_persistence.py new file mode 100644 index 000000000..8cf280130 --- /dev/null +++ b/freqtrade/tests/test_persistence.py @@ -0,0 +1,20 @@ +# pragma pylint: disable=missing-docstring +from freqtrade.exchange import Exchanges +from freqtrade.persistence import Trade + +def test_exec_sell_order(mocker): + api_mock = mocker.patch('freqtrade.main.exchange.sell', side_effect='mocked_order_id') + trade = Trade( + pair='BTC_ETH', + stake_amount=1.00, + open_rate=0.50, + amount=10.00, + exchange=Exchanges.BITTREX, + open_order_id='mocked' + ) + profit = trade.exec_sell_order(1.00, 10.00) + api_mock.assert_called_once_with('BTC_ETH', 1.0, 10.0) + assert profit == 100.0 + assert trade.close_rate == 1.0 + assert trade.close_profit == profit + assert trade.close_date is not None diff --git a/freqtrade/tests/test_telegram.py b/freqtrade/tests/test_telegram.py new file mode 100644 index 000000000..fb9a618a0 --- /dev/null +++ b/freqtrade/tests/test_telegram.py @@ -0,0 +1,199 @@ +# pragma pylint: disable=missing-docstring +from datetime import datetime +from unittest.mock import MagicMock + +import pytest +from jsonschema import validate +from telegram import Bot, Update, Message, Chat + +from freqtrade.main import init, create_trade +from freqtrade.misc import update_state, State, get_state, CONF_SCHEMA +from freqtrade.persistence import Trade +from freqtrade.rpc.telegram import _status, _profit, _forcesell, _performance, _start, _stop + + +@pytest.fixture +def conf(): + configuration = { + "max_open_trades": 3, + "stake_currency": "BTC", + "stake_amount": 0.05, + "dry_run": True, + "minimal_roi": { + "2880": 0.005, + "720": 0.01, + "0": 0.02 + }, + "bid_strategy": { + "ask_last_balance": 0.0 + }, + "exchange": { + "name": "bittrex", + "enabled": True, + "key": "key", + "secret": "secret", + "pair_whitelist": [ + "BTC_ETH" + ] + }, + "telegram": { + "enabled": True, + "token": "token", + "chat_id": "0" + }, + "initial_state": "running" + } + validate(configuration, CONF_SCHEMA) + return configuration + +@pytest.fixture +def update(): + _update = Update(0) + _update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0)) + return _update + + +class MagicBot(MagicMock, Bot): + pass + + +def test_status_handle(conf, update, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.07256061, + 'ask': 0.072661, + 'last': 0.07256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + init(conf, 'sqlite://') + + # Create some test data + trade = create_trade(15.0) + assert trade + Trade.session.add(trade) + Trade.session.flush() + + _status(bot=MagicBot(), update=update) + assert msg_mock.call_count == 2 + assert '[BTC_ETH]' in msg_mock.call_args_list[-1][0][0] + +def test_profit_handle(conf, update, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.07256061, + 'ask': 0.072661, + 'last': 0.07256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + init(conf, 'sqlite://') + + # Create some test data + trade = create_trade(15.0) + assert trade + trade.close_rate = 0.07256061 + trade.close_profit = 100.00 + trade.close_date = datetime.utcnow() + trade.open_order_id = None + trade.is_open = False + Trade.session.add(trade) + Trade.session.flush() + + _profit(bot=MagicBot(), update=update) + assert msg_mock.call_count == 2 + assert '(100.00%)' in msg_mock.call_args_list[-1][0][0] + +def test_forcesell_handle(conf, update, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.07256061, + 'ask': 0.072661, + 'last': 0.07256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + init(conf, 'sqlite://') + + # Create some test data + trade = create_trade(15.0) + assert trade + Trade.session.add(trade) + Trade.session.flush() + + update.message.text = '/forcesell 1' + _forcesell(bot=MagicBot(), update=update) + + assert msg_mock.call_count == 2 + assert 'Selling [BTC/ETH]' in msg_mock.call_args_list[-1][0][0] + assert '0.072561' in msg_mock.call_args_list[-1][0][0] + +def test_performance_handle(conf, update, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=MagicMock(return_value={ + 'bid': 0.07256061, + 'ask': 0.072661, + 'last': 0.07256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + init(conf, 'sqlite://') + + # Create some test data + trade = create_trade(15.0) + assert trade + trade.close_rate = 0.07256061 + trade.close_profit = 100.00 + trade.close_date = datetime.utcnow() + trade.open_order_id = None + trade.is_open = False + Trade.session.add(trade) + Trade.session.flush() + + _performance(bot=MagicBot(), update=update) + assert msg_mock.call_count == 2 + assert 'Performance' in msg_mock.call_args_list[-1][0][0] + assert 'BTC_ETH 100.00%' in msg_mock.call_args_list[-1][0][0] + +def test_start_handle(conf, update, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock()) + init(conf, 'sqlite://') + + update_state(State.STOPPED) + assert get_state() == State.STOPPED + _start(bot=MagicBot(), update=update) + assert get_state() == State.RUNNING + assert msg_mock.call_count == 0 + +def test_stop_handle(conf, update, mocker): + mocker.patch.dict('freqtrade.main._CONF', conf) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock()) + init(conf, 'sqlite://') + + update_state(State.RUNNING) + assert get_state() == State.RUNNING + _stop(bot=MagicBot(), update=update) + assert get_state() == State.STOPPED + assert msg_mock.call_count == 1 + assert 'Stopping trader' in msg_mock.call_args_list[0][0][0] diff --git a/test/testdata/btc-edg.json b/freqtrade/tests/testdata/btc-edg.json similarity index 100% rename from test/testdata/btc-edg.json rename to freqtrade/tests/testdata/btc-edg.json diff --git a/test/testdata/btc-etc.json b/freqtrade/tests/testdata/btc-etc.json similarity index 100% rename from test/testdata/btc-etc.json rename to freqtrade/tests/testdata/btc-etc.json diff --git a/test/testdata/btc-eth.json b/freqtrade/tests/testdata/btc-eth.json similarity index 100% rename from test/testdata/btc-eth.json rename to freqtrade/tests/testdata/btc-eth.json diff --git a/test/testdata/btc-ltc.json b/freqtrade/tests/testdata/btc-ltc.json similarity index 100% rename from test/testdata/btc-ltc.json rename to freqtrade/tests/testdata/btc-ltc.json diff --git a/test/testdata/btc-mtl.json b/freqtrade/tests/testdata/btc-mtl.json similarity index 100% rename from test/testdata/btc-mtl.json rename to freqtrade/tests/testdata/btc-mtl.json diff --git a/test/testdata/btc-neo.json b/freqtrade/tests/testdata/btc-neo.json similarity index 100% rename from test/testdata/btc-neo.json rename to freqtrade/tests/testdata/btc-neo.json diff --git a/test/testdata/btc-omg.json b/freqtrade/tests/testdata/btc-omg.json similarity index 100% rename from test/testdata/btc-omg.json rename to freqtrade/tests/testdata/btc-omg.json diff --git a/test/testdata/btc-pay.json b/freqtrade/tests/testdata/btc-pay.json similarity index 100% rename from test/testdata/btc-pay.json rename to freqtrade/tests/testdata/btc-pay.json diff --git a/test/testdata/btc-pivx.json b/freqtrade/tests/testdata/btc-pivx.json similarity index 100% rename from test/testdata/btc-pivx.json rename to freqtrade/tests/testdata/btc-pivx.json diff --git a/test/testdata/btc-qtum.json b/freqtrade/tests/testdata/btc-qtum.json similarity index 100% rename from test/testdata/btc-qtum.json rename to freqtrade/tests/testdata/btc-qtum.json diff --git a/requirements.txt b/requirements.txt index df6e07226..b7bf943ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,20 @@ --e git+https://github.com/ericsomdahl/python-bittrex.git#egg=python-bittrex +-e git+https://github.com/ericsomdahl/python-bittrex.git@d7033d0#egg=python-bittrex SQLAlchemy==1.1.13 -python-telegram-bot==7.0.1 +python-telegram-bot==8.0 arrow==0.10.0 requests==2.18.4 urllib3==1.22 wrapt==1.10.11 pandas==0.20.3 -matplotlib==2.0.2 scikit-learn==0.19.0 scipy==0.19.1 jsonschema==2.6.0 +numpy==1.13.3 TA-Lib==0.4.10 +pytest==3.2.2 +pytest-mock==1.6.3 +pytest-cov==2.5.1 + +# Required for plotting data +#matplotlib==2.0.2 #PYQT5==5.9 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..ea59030de --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[aliases] +test=pytest + +[tool:pytest] +addopts = --cov=freqtrade --cov-config=.coveragerc freqtrade/tests/ diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..e89fd2eee --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup + +from freqtrade import __version__ + + +setup(name='freqtrade', + version=__version__, + description='Simple High Frequency Trading Bot for crypto currencies', + url='https://github.com/gcarq/freqtrade', + author='gcarq and contributors', + author_email='michael.egger@tsn.at', + license='GPLv3', + packages=['freqtrade'], + scripts=['bin/freqtrade'], + setup_requires=['pytest-runner'], + tests_require=['pytest', 'pytest-mock', 'pytest-cov'], + install_requires=[ + 'python-bittrex==0.1.3', + 'SQLAlchemy==1.1.13', + 'python-telegram-bot==8.0', + 'arrow==0.10.0', + 'requests==2.18.4', + 'urllib3==1.22', + 'wrapt==1.10.11', + 'pandas==0.20.3', + 'scikit-learn==0.19.0', + 'scipy==0.19.1', + 'jsonschema==2.6.0', + 'TA-Lib==0.4.10', + ], + dependency_links=[ + "git+https://github.com/ericsomdahl/python-bittrex.git@d7033d0#egg=python-bittrex-0.1.3" + ], + include_package_data=True, + zip_safe=False, + classifiers=[ + 'Programming Language :: Python :: 3.6', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Topic :: Office/Business :: Financial :: Investment', + 'Intended Audience :: Science/Research', + ]) diff --git a/test/test_analyze.py b/test/test_analyze.py deleted file mode 100644 index 9fdc16d7a..000000000 --- a/test/test_analyze.py +++ /dev/null @@ -1,49 +0,0 @@ -# pragma pylint: disable=missing-docstring -import unittest -from unittest.mock import patch -from pandas import DataFrame -import arrow -from analyze import parse_ticker_dataframe, populate_buy_trend, populate_indicators, analyze_ticker, get_buy_signal - -RESULT_BITTREX = { - 'success': True, - 'message': '', - 'result': [ - {'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 22.17210568, 'T': '2017-08-30T10:40:00', 'BV': 0.01448082}, - {'O': 0.00066194, 'H': 0.00066195, 'L': 0.00066194, 'C': 0.00066195, 'V': 33.4727437, 'T': '2017-08-30T10:34:00', 'BV': 0.02215696}, - {'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 53.85127609, 'T': '2017-08-30T10:37:00', 'BV': 0.0351708}, - {'O': 0.00066194, 'H': 0.00066194, 'L': 0.00065311, 'C': 0.00065311, 'V': 46.29210665, 'T': '2017-08-30T10:42:00', 'BV': 0.03063118}, - ] -} - -class TestAnalyze(unittest.TestCase): - def setUp(self): - self.result = parse_ticker_dataframe(RESULT_BITTREX['result'], arrow.get('2017-08-30T10:00:00')) - - def test_1_dataframe_has_correct_columns(self): - self.assertEqual(self.result.columns.tolist(), - ['close', 'high', 'low', 'open', 'date', 'volume']) - - def test_2_orders_by_date(self): - self.assertEqual(self.result['date'].tolist(), - ['2017-08-30T10:34:00', - '2017-08-30T10:37:00', - '2017-08-30T10:40:00', - '2017-08-30T10:42:00']) - - def test_3_populates_buy_trend(self): - dataframe = populate_buy_trend(populate_indicators(self.result)) - self.assertTrue('buy' in dataframe.columns) - self.assertTrue('buy_price' in dataframe.columns) - - def test_4_returns_latest_buy_signal(self): - buydf = DataFrame([{'buy': 1, 'date': arrow.utcnow()}]) - with patch('analyze.analyze_ticker', return_value=buydf): - self.assertEqual(get_buy_signal('BTC-ETH'), True) - buydf = DataFrame([{'buy': 0, 'date': arrow.utcnow()}]) - with patch('analyze.analyze_ticker', return_value=buydf): - self.assertEqual(get_buy_signal('BTC-ETH'), False) - - -if __name__ == '__main__': - unittest.main() diff --git a/test/test_backtesting.py b/test/test_backtesting.py deleted file mode 100644 index fdf18cc32..000000000 --- a/test/test_backtesting.py +++ /dev/null @@ -1,71 +0,0 @@ -# pragma pylint: disable=missing-docstring -import unittest -from unittest.mock import patch -import os -import json -import logging -import arrow -from pandas import DataFrame -from analyze import analyze_ticker -from persistence import Trade -from main import should_sell - -def print_results(results): - print('Made {} buys. Average profit {:.1f}%. Total profit was {:.3f}. Average duration {:.1f} mins.'.format( - len(results.index), - results.profit.mean() * 100.0, - results.profit.sum(), - results.duration.mean()*5 - )) - -class TestMain(unittest.TestCase): - pairs = ['btc-neo', 'btc-eth', 'btc-omg', 'btc-edg', 'btc-pay', 'btc-pivx', 'btc-qtum', 'btc-mtl', 'btc-etc', 'btc-ltc'] - conf = { - "minimal_roi": { - "2880": 0.005, - "720": 0.01, - "0": 0.02 - }, - "stoploss": -0.10 - } - - @classmethod - def setUpClass(cls): - logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot - - @unittest.skipIf(not os.environ.get('BACKTEST', False), "slow, should be run manually") - def test_backtest(self): - trades = [] - with patch.dict('main._CONF', self.conf): - for pair in self.pairs: - with open('test/testdata/'+pair+'.json') as data_file: - data = json.load(data_file) - - with patch('analyze.get_ticker', return_value=data): - with patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00')): - ticker = analyze_ticker(pair) - # for each buy point - for index, row in ticker[ticker.buy == 1].iterrows(): - trade = Trade( - open_rate=row['close'], - open_date=arrow.get(row['date']).datetime, - amount=1, - ) - # calculate win/lose forwards from buy point - for index2, row2 in ticker[index:].iterrows(): - if should_sell(trade, row2['close'], arrow.get(row2['date']).datetime): - current_profit = (row2['close'] - trade.open_rate) / trade.open_rate - - trades.append((pair, current_profit, index2 - index)) - break - - labels = ['currency', 'profit', 'duration'] - results = DataFrame.from_records(trades, columns=labels) - - print('====================== BACKTESTING REPORT ================================') - - for pair in self.pairs: - print('For currency {}:'.format(pair)) - print_results(results[results.currency == pair]) - print('TOTAL OVER ALL TRADES:') - print_results(results) diff --git a/test/test_main.py b/test/test_main.py deleted file mode 100644 index 9ff4f97f2..000000000 --- a/test/test_main.py +++ /dev/null @@ -1,114 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock - -from jsonschema import validate - -import exchange -from main import create_trade, handle_trade, close_trade_if_fulfilled, init, get_target_bid -from misc import CONF_SCHEMA -from persistence import Trade - - -class TestMain(unittest.TestCase): - conf = { - "max_open_trades": 3, - "stake_currency": "BTC", - "stake_amount": 0.05, - "dry_run": True, - "minimal_roi": { - "2880": 0.005, - "720": 0.01, - "0": 0.02 - }, - "bid_strategy": { - "ask_last_balance": 0.0 - }, - "bittrex": { - "enabled": True, - "key": "key", - "secret": "secret", - "pair_whitelist": [ - "BTC_ETH" - ] - }, - "telegram": { - "enabled": True, - "token": "token", - "chat_id": "chat_id" - } - } - - def test_1_create_trade(self): - with patch.dict('main._CONF', self.conf): - with patch('main.get_buy_signal', side_effect=lambda _: True) as buy_signal: - with patch.multiple('main.telegram', init=MagicMock(), send_msg=MagicMock()): - with patch.multiple('main.exchange', - get_ticker=MagicMock(return_value={ - 'bid': 0.07256061, - 'ask': 0.072661, - 'last': 0.07256061 - }), - buy=MagicMock(return_value='mocked_order_id')): - init(self.conf, 'sqlite://') - trade = create_trade(15.0, exchange.Exchange.BITTREX) - Trade.session.add(trade) - Trade.session.flush() - self.assertIsNotNone(trade) - self.assertEqual(trade.open_rate, 0.072661) - self.assertEqual(trade.pair, 'BTC_ETH') - self.assertEqual(trade.exchange, exchange.Exchange.BITTREX) - self.assertEqual(trade.amount, 206.43811673387373) - self.assertEqual(trade.stake_amount, 15.0) - self.assertEqual(trade.is_open, True) - self.assertIsNotNone(trade.open_date) - buy_signal.assert_called_once_with('BTC_ETH') - - def test_2_handle_trade(self): - with patch.dict('main._CONF', self.conf): - with patch.multiple('main.telegram', init=MagicMock(), send_msg=MagicMock()): - with patch.multiple('main.exchange', - get_ticker=MagicMock(return_value={ - 'bid': 0.17256061, - 'ask': 0.172661, - 'last': 0.17256061 - }), - buy=MagicMock(return_value='mocked_order_id')): - trade = Trade.query.filter(Trade.is_open.is_(True)).first() - self.assertTrue(trade) - handle_trade(trade) - self.assertEqual(trade.close_rate, 0.17256061) - self.assertEqual(trade.close_profit, 137.4872490056564) - self.assertIsNotNone(trade.close_date) - self.assertEqual(trade.open_order_id, 'dry_run') - - def test_3_close_trade(self): - with patch.dict('main._CONF', self.conf): - trade = Trade.query.filter(Trade.is_open.is_(True)).first() - self.assertTrue(trade) - - # Simulate that there is no open order - trade.open_order_id = None - - closed = close_trade_if_fulfilled(trade) - self.assertTrue(closed) - self.assertEqual(trade.is_open, False) - - def test_balance_fully_ask_side(self): - with patch.dict('main._CONF', {'bid_strategy': {'ask_last_balance': 0.0}}): - self.assertEqual(get_target_bid({'ask': 20, 'last': 10}), 20) - - def test_balance_fully_last_side(self): - with patch.dict('main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}): - self.assertEqual(get_target_bid({'ask': 20, 'last': 10}), 10) - - def test_balance_when_last_bigger_than_ask(self): - with patch.dict('main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}): - self.assertEqual(get_target_bid({'ask': 5, 'last': 10}), 5) - - @classmethod - def setUpClass(cls): - validate(cls.conf, CONF_SCHEMA) - - -if __name__ == '__main__': - unittest.main() diff --git a/test/test_persistence.py b/test/test_persistence.py deleted file mode 100644 index 8875b0e78..000000000 --- a/test/test_persistence.py +++ /dev/null @@ -1,28 +0,0 @@ -import unittest -from unittest.mock import patch - -from exchange import Exchange -from persistence import Trade - - -class TestTrade(unittest.TestCase): - def test_1_exec_sell_order(self): - with patch('main.exchange.sell', side_effect='mocked_order_id') as api_mock: - trade = Trade( - pair='BTC_ETH', - stake_amount=1.00, - open_rate=0.50, - amount=10.00, - exchange=Exchange.BITTREX, - open_order_id='mocked' - ) - profit = trade.exec_sell_order(1.00, 10.00) - api_mock.assert_called_once_with('BTC_ETH', 1.0, 10.0) - self.assertEqual(profit, 100.0) - self.assertEqual(trade.close_rate, 1.0) - self.assertEqual(trade.close_profit, profit) - self.assertIsNotNone(trade.close_date) - - -if __name__ == '__main__': - unittest.main() diff --git a/test/test_telegram.py b/test/test_telegram.py deleted file mode 100644 index e7af1a876..000000000 --- a/test/test_telegram.py +++ /dev/null @@ -1,195 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock -from datetime import datetime - -from jsonschema import validate -from telegram import Bot, Update, Message, Chat - -import exchange -from main import init, create_trade -from misc import CONF_SCHEMA, update_state, State, get_state -from persistence import Trade -from rpc.telegram import _status, _profit, _forcesell, _performance, _start, _stop - - -class MagicBot(MagicMock, Bot): - pass - - -class TestTelegram(unittest.TestCase): - - conf = { - "max_open_trades": 3, - "stake_currency": "BTC", - "stake_amount": 0.05, - "dry_run": True, - "minimal_roi": { - "2880": 0.005, - "720": 0.01, - "0": 0.02 - }, - "bid_strategy": { - "ask_last_balance": 0.0 - }, - "bittrex": { - "enabled": True, - "key": "key", - "secret": "secret", - "pair_whitelist": [ - "BTC_ETH" - ] - }, - "telegram": { - "enabled": True, - "token": "token", - "chat_id": "0" - }, - "initial_state": "running" - } - - def test_1_status_handle(self): - with patch.dict('main._CONF', self.conf): - with patch('main.get_buy_signal', side_effect=lambda _: True): - msg_mock = MagicMock() - with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock): - with patch.multiple('main.exchange', - get_ticker=MagicMock(return_value={ - 'bid': 0.07256061, - 'ask': 0.072661, - 'last': 0.07256061 - }), - buy=MagicMock(return_value='mocked_order_id')): - init(self.conf, 'sqlite://') - - # Create some test data - trade = create_trade(15.0, exchange.Exchange.BITTREX) - self.assertTrue(trade) - Trade.session.add(trade) - Trade.session.flush() - - _status(bot=MagicBot(), update=self.update) - self.assertEqual(msg_mock.call_count, 2) - self.assertIn('[BTC_ETH]', msg_mock.call_args_list[-1][0][0]) - - def test_2_profit_handle(self): - with patch.dict('main._CONF', self.conf): - with patch('main.get_buy_signal', side_effect=lambda _: True): - msg_mock = MagicMock() - with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock): - with patch.multiple('main.exchange', - get_ticker=MagicMock(return_value={ - 'bid': 0.07256061, - 'ask': 0.072661, - 'last': 0.07256061 - }), - buy=MagicMock(return_value='mocked_order_id')): - init(self.conf, 'sqlite://') - - # Create some test data - trade = create_trade(15.0, exchange.Exchange.BITTREX) - self.assertTrue(trade) - trade.close_rate = 0.07256061 - trade.close_profit = 100.00 - trade.close_date = datetime.utcnow() - trade.open_order_id = None - trade.is_open = False - Trade.session.add(trade) - Trade.session.flush() - - _profit(bot=MagicBot(), update=self.update) - self.assertEqual(msg_mock.call_count, 2) - self.assertIn('(100.00%)', msg_mock.call_args_list[-1][0][0]) - - def test_3_forcesell_handle(self): - with patch.dict('main._CONF', self.conf): - with patch('main.get_buy_signal', side_effect=lambda _: True): - msg_mock = MagicMock() - with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock): - with patch.multiple('main.exchange', - get_ticker=MagicMock(return_value={ - 'bid': 0.07256061, - 'ask': 0.072661, - 'last': 0.07256061 - }), - buy=MagicMock(return_value='mocked_order_id')): - init(self.conf, 'sqlite://') - - # Create some test data - trade = create_trade(15.0, exchange.Exchange.BITTREX) - self.assertTrue(trade) - Trade.session.add(trade) - Trade.session.flush() - - self.update.message.text = '/forcesell 1' - _forcesell(bot=MagicBot(), update=self.update) - - self.assertEqual(msg_mock.call_count, 2) - self.assertIn('Selling [BTC/ETH]', msg_mock.call_args_list[-1][0][0]) - self.assertIn('0.072561', msg_mock.call_args_list[-1][0][0]) - - def test_4_performance_handle(self): - with patch.dict('main._CONF', self.conf): - with patch('main.get_buy_signal', side_effect=lambda _: True): - msg_mock = MagicMock() - with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock): - with patch.multiple('main.exchange', - get_ticker=MagicMock(return_value={ - 'bid': 0.07256061, - 'ask': 0.072661, - 'last': 0.07256061 - }), - buy=MagicMock(return_value='mocked_order_id')): - init(self.conf, 'sqlite://') - - # Create some test data - trade = create_trade(15.0, exchange.Exchange.BITTREX) - self.assertTrue(trade) - trade.close_rate = 0.07256061 - trade.close_profit = 100.00 - trade.close_date = datetime.utcnow() - trade.open_order_id = None - trade.is_open = False - Trade.session.add(trade) - Trade.session.flush() - - _performance(bot=MagicBot(), update=self.update) - self.assertEqual(msg_mock.call_count, 2) - self.assertIn('Performance', msg_mock.call_args_list[-1][0][0]) - self.assertIn('BTC_ETH 100.00%', msg_mock.call_args_list[-1][0][0]) - - def test_5_start_handle(self): - with patch.dict('main._CONF', self.conf): - msg_mock = MagicMock() - with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock): - init(self.conf, 'sqlite://') - - update_state(State.STOPPED) - self.assertEqual(get_state(), State.STOPPED) - _start(bot=MagicBot(), update=self.update) - self.assertEqual(get_state(), State.RUNNING) - self.assertEqual(msg_mock.call_count, 0) - - def test_6_stop_handle(self): - with patch.dict('main._CONF', self.conf): - msg_mock = MagicMock() - with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock): - init(self.conf, 'sqlite://') - - update_state(State.RUNNING) - self.assertEqual(get_state(), State.RUNNING) - _stop(bot=MagicBot(), update=self.update) - self.assertEqual(get_state(), State.STOPPED) - self.assertEqual(msg_mock.call_count, 1) - self.assertIn('Stopping trader', msg_mock.call_args_list[0][0][0]) - - def setUp(self): - self.update = Update(0) - self.update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0)) - - @classmethod - def setUpClass(cls): - validate(cls.conf, CONF_SCHEMA) - - -if __name__ == '__main__': - unittest.main()