diff --git a/README.md b/README.md index bd01adcf2..e8997a017 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ See the example below: "1440": 0.01, # Sell after 24 hours if there is at least 1% profit "720": 0.02, # Sell after 12 hours if there is at least 2% profit "360": 0.02, # Sell after 6 hours if there is at least 2% profit - "0": 0.025 # Sell immediatly if there is at least 2.5% profit + "0": 0.025 # Sell immediately if there is at least 2.5% profit }, ``` @@ -38,7 +38,7 @@ The other values should be self-explanatory, if not feel free to raise a github issue. ##### Prerequisites -* python3 +* python3.6 * sqlite * [TA-lib](https://github.com/mrjbq7/ta-lib#dependencies) binaries diff --git a/analyze.py b/analyze.py index df7414368..bbee924ac 100644 --- a/analyze.py +++ b/analyze.py @@ -4,7 +4,7 @@ import logging import arrow import requests from pandas.io.json import json_normalize -from stockstats import StockDataFrame +from pandas import DataFrame import talib.abstract as ta @@ -13,11 +13,11 @@ logging.basicConfig(level=logging.DEBUG, logger = logging.getLogger(__name__) -def get_ticker_dataframe(pair): +def get_ticker_dataframe(pair: str) -> DataFrame: """ Analyses the trend for the given pair :param pair: pair as str in format BTC_ETH or BTC-ETH - :return: StockDataFrame + :return: DataFrame """ minimum_date = arrow.now() - timedelta(hours=6) url = 'https://bittrex.com/Api/v2.0/pub/market/GetTicks' @@ -41,35 +41,37 @@ def get_ticker_dataframe(pair): 'low': t['L'], 'date': t['T'], } for t in sorted(data['result'], key=lambda k: k['T']) if arrow.get(t['T']) > minimum_date] - dataframe = StockDataFrame(json_normalize(data)) + dataframe = DataFrame(json_normalize(data)) dataframe['sar'] = ta.SAR(dataframe, 0.02, 0.2) # calculate StochRSI - window = 14 - rsi = dataframe['rsi_{}'.format(window)] - rolling = rsi.rolling(window=window, center=False) - low = rolling.min() - high = rolling.max() - dataframe['stochrsi'] = (rsi - low) / (high - low) + stochrsi = ta.STOCHRSI(dataframe) + dataframe['stochrsi'] = stochrsi['fastd'] # values between 0-100, not 0-1 + + macd = ta.MACD(dataframe) + dataframe['macd'] = macd['macd'] + dataframe['macds'] = macd['macdsignal'] + dataframe['macdh'] = macd['macdhist'] + return dataframe -def populate_trends(dataframe): +def populate_trends(dataframe: DataFrame) -> DataFrame: """ Populates the trends for the given dataframe - :param dataframe: StockDataFrame - :return: StockDataFrame with populated trends + :param dataframe: DataFrame + :return: DataFrame with populated trends """ """ dataframe.loc[ - (dataframe['stochrsi'] < 0.20) + (dataframe['stochrsi'] < 20) & (dataframe['close_30_ema'] > (1 + 0.0025) * dataframe['close_60_ema']), 'underpriced' ] = 1 """ dataframe.loc[ - (dataframe['stochrsi'] < 0.20) + (dataframe['stochrsi'] < 20) & (dataframe['macd'] > dataframe['macds']) & (dataframe['close'] > dataframe['sar']), 'underpriced' @@ -78,7 +80,7 @@ def populate_trends(dataframe): return dataframe -def get_buy_signal(pair): +def get_buy_signal(pair: str) -> bool: """ Calculates a buy signal based on StochRSI indicator :param pair: pair in format BTC_ANT or BTC-ANT @@ -98,10 +100,10 @@ def get_buy_signal(pair): return signal -def plot_dataframe(dataframe, pair): +def plot_dataframe(dataframe: DataFrame, pair: str) -> None: """ Plots the given dataframe - :param dataframe: StockDataFrame + :param dataframe: DataFrame :param pair: pair as str :return: None """ @@ -129,8 +131,8 @@ def plot_dataframe(dataframe, pair): ax2.legend() ax3.plot(dataframe.index.values, dataframe['stochrsi'], label='StochRSI') - ax3.plot(dataframe.index.values, [0.80] * len(dataframe.index.values)) - ax3.plot(dataframe.index.values, [0.20] * len(dataframe.index.values)) + ax3.plot(dataframe.index.values, [80] * len(dataframe.index.values)) + ax3.plot(dataframe.index.values, [20] * len(dataframe.index.values)) ax3.legend() # Fine-tune figure; make subplots close to each other and hide x ticks for diff --git a/exchange.py b/exchange.py index 5b4761625..4385073a1 100644 --- a/exchange.py +++ b/exchange.py @@ -1,5 +1,7 @@ import enum import logging +from typing import List + from bittrex.bittrex import Bittrex from poloniex import Poloniex from wrapt import synchronized @@ -9,18 +11,6 @@ logger = logging.getLogger(__name__) _exchange_api = None -@synchronized -def get_exchange_api(conf): - """ - Returns the current exchange api or instantiates a new one - :return: exchange.ApiWrapper - """ - global _exchange_api - if not _exchange_api: - _exchange_api = ApiWrapper(conf) - return _exchange_api - - class Exchange(enum.Enum): POLONIEX = 0 BITTREX = 1 @@ -33,9 +23,11 @@ class ApiWrapper(object): * Bittrex * Poloniex (partly) """ - def __init__(self, config): + def __init__(self, config: dict): """ - Initializes the ApiWrapper with the given config, it does not validate those values. + Initializes the ApiWrapper with the given config, + it does basic validation whether the specified + exchange and pairs are valid. :param config: dict """ self.dry_run = config['dry_run'] @@ -53,14 +45,21 @@ class ApiWrapper(object): self.api = Bittrex(api_key=config['bittrex']['key'], api_secret=config['bittrex']['secret']) else: self.api = None + raise RuntimeError('No exchange specified. Aborting!') - def buy(self, pair, rate, amount): + # Check if all pairs are available + markets = self.get_markets() + for pair in config[self.exchange.name.lower()]['pair_whitelist']: + if pair not in markets: + raise RuntimeError('Pair {} is not available at Poloniex'.format(pair)) + + 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: None + :return: order_id of the placed buy order """ if self.dry_run: pass @@ -73,7 +72,7 @@ class ApiWrapper(object): raise RuntimeError('BITTREX: {}'.format(data['message'])) return data['result']['uuid'] - def sell(self, pair, rate, amount): + def sell(self, pair: str, rate: float, amount: float) -> str: """ Places a limit sell order. :param pair: Pair as str, format: BTC_ETH @@ -92,7 +91,7 @@ class ApiWrapper(object): raise RuntimeError('BITTREX: {}'.format(data['message'])) return data['result']['uuid'] - def get_balance(self, currency): + def get_balance(self, currency: str) -> float: """ Get account balance. :param currency: currency as str, format: BTC @@ -109,7 +108,7 @@ class ApiWrapper(object): raise RuntimeError('BITTREX: {}'.format(data['message'])) return float(data['result']['Balance'] or 0.0) - def get_ticker(self, pair): + def get_ticker(self, pair: str) -> dict: """ Get Ticker for given pair. :param pair: Pair as str, format: BTC_ETC @@ -132,7 +131,7 @@ class ApiWrapper(object): 'last': float(data['result']['Last']), } - def cancel_order(self, order_id): + def cancel_order(self, order_id: str) -> None: """ Cancel order for given order_id :param order_id: id as str @@ -147,7 +146,7 @@ class ApiWrapper(object): if not data['success']: raise RuntimeError('BITTREX: {}'.format(data['message'])) - def get_open_orders(self, pair): + def get_open_orders(self, pair: str) -> List[dict]: """ Get all open orders for given pair. :param pair: Pair as str, format: BTC_ETC @@ -170,7 +169,7 @@ class ApiWrapper(object): 'remaining': entry['QuantityRemaining'], } for entry in data['result']] - def get_pair_detail_url(self, pair): + 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_ANT @@ -180,3 +179,29 @@ class ApiWrapper(object): raise NotImplemented('Not implemented') elif self.exchange == Exchange.BITTREX: return 'https://bittrex.com/Market/Index?MarketName={}'.format(pair.replace('_', '-')) + + def get_markets(self) -> List[str]: + """ + Returns all available markets + :return: list of all available pairs + """ + if self.exchange == Exchange.POLONIEX: + # TODO: implement + raise NotImplemented('Not implemented') + elif self.exchange == Exchange. BITTREX: + data = self.api.get_markets() + if not data['success']: + raise RuntimeError('BITTREX: {}'.format(data['message'])) + return [m['MarketName'].replace('-', '_') for m in data['result']] + + +@synchronized +def get_exchange_api(conf: dict) -> ApiWrapper: + """ + Returns the current exchange api or instantiates a new one + :return: exchange.ApiWrapper + """ + global _exchange_api + if not _exchange_api: + _exchange_api = ApiWrapper(conf) + return _exchange_api diff --git a/main.py b/main.py index bef1753a0..a97419c94 100755 --- a/main.py +++ b/main.py @@ -5,11 +5,13 @@ import time import traceback from datetime import datetime from json import JSONDecodeError +from typing import Optional + from requests import ConnectionError from wrapt import synchronized from analyze import get_buy_signal from persistence import Trade, Session -from exchange import get_exchange_api +from exchange import get_exchange_api, Exchange from rpc.telegram import TelegramHandler from utils import get_conf @@ -32,11 +34,11 @@ class TradeThread(threading.Thread): super().__init__() self._should_stop = False - def stop(self): + def stop(self) -> None: """ stops the trader thread """ self._should_stop = True - def run(self): + def run(self) -> None: """ Threaded main function :return: None @@ -53,14 +55,14 @@ class TradeThread(threading.Thread): finally: Session.flush() time.sleep(25) - except (RuntimeError, JSONDecodeError) as e: + except (RuntimeError, JSONDecodeError): TelegramHandler.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc())) logger.exception('RuntimeError. Stopping trader ...') finally: TelegramHandler.send_msg('*Status:* `Trader has stopped`') @staticmethod - def _process(): + def _process() -> None: """ Queries the persistence layer for open trades and handles them, otherwise a new trade is created. @@ -69,11 +71,16 @@ class TradeThread(threading.Thread): # Query trades from persistence layer trades = Trade.query.filter(Trade.is_open.is_(True)).all() if len(trades) < CONFIG['max_open_trades']: - # Create entity and execute trade try: - Session.add(create_trade(float(CONFIG['stake_amount']), api_wrapper.exchange)) + # Create entity and execute trade + trade = create_trade(float(CONFIG['stake_amount']), api_wrapper.exchange) + if trade: + Session.add(trade) + else: + logging.info('Got no buy signal...') except ValueError: - logger.exception('ValueError during trade creation') + logger.exception('Unable to create trade') + for trade in trades: # Check if there is already an open order for this trade orders = api_wrapper.get_open_orders(trade.pair) @@ -102,8 +109,9 @@ class TradeThread(threading.Thread): # Initial stopped TradeThread instance _instance = TradeThread() + @synchronized -def get_instance(recreate=False): +def get_instance(recreate: bool=False) -> TradeThread: """ Get the current instance of this thread. This is a singleton. :param recreate: Must be True if you want to start the instance @@ -111,13 +119,12 @@ def get_instance(recreate=False): """ global _instance if recreate and not _instance.is_alive(): - logger.debug('Creating TradeThread instance') - _should_stop = False + logger.debug('Creating thread instance...') _instance = TradeThread() return _instance -def close_trade_if_fulfilled(trade): +def close_trade_if_fulfilled(trade: Trade) -> bool: """ Checks if the trade is closable, and if so it is being closed. :param trade: Trade @@ -132,7 +139,7 @@ def close_trade_if_fulfilled(trade): return False -def handle_trade(trade): +def handle_trade(trade: Trade) -> None: """ Sells the current pair if the threshold is reached and updates the trade record. :return: None @@ -173,9 +180,10 @@ def handle_trade(trade): logger.exception('Unable to handle open order') -def create_trade(stake_amount: float, exchange): +def create_trade(stake_amount: float, exchange: Exchange) -> Optional[Trade]: """ - Creates a new trade record with a random pair + 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 """ @@ -203,7 +211,7 @@ def create_trade(stake_amount: float, exchange): pair = p break else: - raise ValueError('No buy signal from pairs: {}'.format(', '.join(whitelist))) + return None open_rate = api_wrapper.get_ticker(pair)['ask'] amount = stake_amount / open_rate diff --git a/persistence.py b/persistence.py index c5a1c593d..f8c954983 100644 --- a/persistence.py +++ b/persistence.py @@ -46,7 +46,7 @@ class Trade(Base): 'closed' if not self.is_open else round((datetime.utcnow() - self.open_date).total_seconds() / 60, 2) ) - def exec_sell_order(self, rate, amount): + def exec_sell_order(self, rate: float, amount: float) -> float: """ Executes a sell for the given trade and updated the entity. :param rate: rate to sell for diff --git a/requirements.txt b/requirements.txt index 55467bb38..fcd6c3954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,5 @@ matplotlib==2.0.2 PYQT5==5.9 scikit-learn==0.19.0 scipy==0.19.1 -stockstats==0.2.0 +jsonschema==2.6.0 TA-Lib==0.4.10 \ No newline at end of file diff --git a/rpc/telegram.py b/rpc/telegram.py index 838ddd527..efb8746fa 100644 --- a/rpc/telegram.py +++ b/rpc/telegram.py @@ -1,5 +1,6 @@ import logging from datetime import timedelta +from typing import Callable, Any import arrow from sqlalchemy import and_, func @@ -23,7 +24,7 @@ conf = get_conf() api_wrapper = get_exchange_api(conf) -def authorized_only(command_handler): +def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]: """ Decorator to check if the message comes from the correct chat_id :param command_handler: Telegram CommandHandler @@ -46,7 +47,7 @@ def authorized_only(command_handler): class TelegramHandler(object): @staticmethod @authorized_only - def _status(bot, update): + def _status(bot: Bot, update: Update) -> None: """ Handler for /status. Returns the current TradeThread status @@ -97,7 +98,7 @@ class TelegramHandler(object): @staticmethod @authorized_only - def _profit(bot, update): + def _profit(bot: Bot, update: Update) -> None: """ Handler for /profit. Returns a cumulative profit statistics. @@ -150,7 +151,7 @@ class TelegramHandler(object): @staticmethod @authorized_only - def _start(bot, update): + def _start(bot: Bot, update: Update) -> None: """ Handler for /start. Starts TradeThread @@ -166,7 +167,7 @@ class TelegramHandler(object): @staticmethod @authorized_only - def _stop(bot, update): + def _stop(bot: Bot, update: Update) -> None: """ Handler for /stop. Stops TradeThread @@ -183,7 +184,7 @@ class TelegramHandler(object): @staticmethod @authorized_only - def _forcesell(bot, update): + def _forcesell(bot: Bot, update: Update) -> None: """ Handler for /forcesell . Sells the given trade at current price @@ -231,7 +232,7 @@ class TelegramHandler(object): @staticmethod @authorized_only - def _performance(bot, update): + def _performance(bot: Bot, update: Update) -> None: """ Handler for /performance. Shows a performance statistic from finished trades @@ -258,19 +259,19 @@ class TelegramHandler(object): @staticmethod @synchronized - def get_updater(conf): + def get_updater(config: dict) -> Updater: """ - Returns the current telegram updater instantiates a new one - :param conf: + Returns the current telegram updater or instantiates a new one + :param config: dict :return: telegram.ext.Updater """ global _updater if not _updater: - _updater = Updater(token=conf['telegram']['token'], workers=0) + _updater = Updater(token=config['telegram']['token'], workers=0) return _updater @staticmethod - def listen(): + def listen() -> None: """ Registers all known command handlers and starts polling for message updates :return: None @@ -286,12 +287,17 @@ class TelegramHandler(object): ] for handle in handles: TelegramHandler.get_updater(conf).dispatcher.add_handler(handle) - TelegramHandler.get_updater(conf).start_polling(clean=True, bootstrap_retries=3) + TelegramHandler.get_updater(conf).start_polling( + clean=True, + bootstrap_retries=3, + timeout=30, + read_latency=60, + ) logger.info('TelegramHandler is listening for following commands: {}' .format([h.command for h in handles])) @staticmethod - def send_msg(msg, bot=None, parse_mode=ParseMode.MARKDOWN): + def send_msg(msg: str, bot: Bot=None, parse_mode: ParseMode=ParseMode.MARKDOWN) -> None: """ Send given markdown message :param msg: message diff --git a/utils.py b/utils.py index e87119b5b..e7bb006fd 100644 --- a/utils.py +++ b/utils.py @@ -1,101 +1,82 @@ import json import logging +from jsonschema import validate from wrapt import synchronized -from bittrex.bittrex import Bittrex logger = logging.getLogger(__name__) _cur_conf = None +# Required json-schema for user specified config +_conf_schema = { + 'type': 'object', + 'properties': { + 'max_open_trades': {'type': 'integer'}, + 'stake_currency': {'type': 'string'}, + 'stake_amount': {'type': 'number'}, + 'dry_run': {'type': 'boolean'}, + 'minimal_roi': { + 'type': 'object', + 'patternProperties': { + '^[0-9.]+$': {'type': 'number'} + }, + 'minProperties': 1 + }, + 'poloniex': {'$ref': '#/definitions/exchange'}, + 'bittrex': {'$ref': '#/definitions/exchange'}, + 'telegram': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'token': {'type': 'string'}, + 'chat_id': {'type': 'string'}, + }, + 'required': ['enabled', 'token', 'chat_id'] + } + }, + 'definitions': { + 'exchange': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'key': {'type': 'string'}, + 'secret': {'type': 'string'}, + 'pair_whitelist': { + 'type': 'array', + 'items': {'type': 'string'}, + 'uniqueItems': True + } + }, + 'required': ['enabled', 'key', 'secret', 'pair_whitelist'] + } + }, + 'anyOf': [ + {'required': ['poloniex']}, + {'required': ['bittrex']} + ], + 'required': [ + 'max_open_trades', + 'stake_currency', + 'stake_amount', + 'dry_run', + 'minimal_roi', + 'telegram' + ] +} + + @synchronized -def get_conf(filename='config.json'): +def get_conf(filename: str='config.json') -> dict: """ - Loads the config into memory and returns the instance of it + Loads the config into memory validates it + and returns the singleton instance :return: dict """ global _cur_conf if not _cur_conf: with open(filename) as file: _cur_conf = json.load(file) - validate_conf(_cur_conf) + validate(_cur_conf, _conf_schema) return _cur_conf - - -def validate_conf(conf): - """ - Validates if the minimal possible config is provided - :param conf: config as dict - :return: None, raises ValueError if something is wrong - """ - if not isinstance(conf.get('max_open_trades'), int): - raise ValueError('max_open_trades must be a int') - if not isinstance(conf.get('stake_currency'), str): - raise ValueError('stake_currency must be a str') - if not isinstance(conf.get('stake_amount'), float): - raise ValueError('stake_amount must be a float') - if not isinstance(conf.get('dry_run'), bool): - raise ValueError('dry_run must be a boolean') - if not isinstance(conf.get('minimal_roi'), dict): - raise ValueError('minimal_roi must be a dict') - - for index, (minutes, threshold) in enumerate(conf.get('minimal_roi').items()): - if not isinstance(minutes, str): - raise ValueError('minimal_roi[{}].key must be a string'.format(index)) - if not isinstance(threshold, float): - raise ValueError('minimal_roi[{}].value must be a float'.format(index)) - - if conf.get('telegram'): - telegram = conf.get('telegram') - if not isinstance(telegram.get('token'), str): - raise ValueError('telegram.token must be a string') - if not isinstance(telegram.get('chat_id'), str): - raise ValueError('telegram.chat_id must be a string') - - if conf.get('poloniex'): - poloniex = conf.get('poloniex') - if not isinstance(poloniex.get('key'), str): - raise ValueError('poloniex.key must be a string') - if not isinstance(poloniex.get('secret'), str): - raise ValueError('poloniex.secret must be a string') - if not isinstance(poloniex.get('pair_whitelist'), list): - raise ValueError('poloniex.pair_whitelist must be a list') - if poloniex.get('enabled', False): - raise ValueError('poloniex is currently not implemented') - #if not poloniex.get('pair_whitelist'): - # raise ValueError('poloniex.pair_whitelist must contain some pairs') - - if conf.get('bittrex'): - bittrex = conf.get('bittrex') - if not isinstance(bittrex.get('key'), str): - raise ValueError('bittrex.key must be a string') - if not isinstance(bittrex.get('secret'), str): - raise ValueError('bittrex.secret must be a string') - if not isinstance(bittrex.get('pair_whitelist'), list): - raise ValueError('bittrex.pair_whitelist must be a list') - if bittrex.get('enabled', False): - if not bittrex.get('pair_whitelist'): - raise ValueError('bittrex.pair_whitelist must contain some pairs') - validate_bittrex_pairs(bittrex.get('pair_whitelist')) - - if conf.get('poloniex', {}).get('enabled', False) \ - and conf.get('bittrex', {}).get('enabled', False): - raise ValueError('Cannot use poloniex and bittrex at the same time') - - logger.info('Config is valid ...') - - -def validate_bittrex_pairs(pairs): - """ - Validates if all given pairs exist on bittrex - :param pairs: list of str - :return: None - """ - data = Bittrex(None, None).get_markets() - if not data['success']: - raise RuntimeError('BITTREX: {}'.format(data['message'])) - available_markets = [market['MarketName'].replace('-', '_')for market in data['result']] - for pair in pairs: - if pair not in available_markets: - raise ValueError('Invalid pair: {}'.format(pair))