commit e5d693ed4cbeade9bf69ce8364cf1a974dd83efd Author: gcarq Date: Fri May 12 19:11:56 2017 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c15821078 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.json +preprocessor.py +*.sqlite diff --git a/config.json.example b/config.json.example new file mode 100644 index 000000000..ffff69012 --- /dev/null +++ b/config.json.example @@ -0,0 +1,35 @@ +{ + "stake_amount": 0.05, + "dry_run": false, + "trade_thresholds": { + "2880": 0.005, + "1440": 0.01, + "720": 0.03, + "360": 0.05, + "0": 0.10 + }, + "poloniex": { + "enabled": false, + "key": "key", + "secret": "secret", + "pair_whitelist": [] + }, + "bittrex": { + "enabled": true, + "key": "key", + "secret": "secret", + "pair_whitelist": [ + "BTC_MLN", + "BTC_TRST", + "BTC_TIME", + "BTC_NXS", + "BTC_GBYTE", + "BTC_SNGLS" + ] + }, + "telegram": { + "enabled": true, + "token": "token", + "chat_id": "chat_id" + } +} \ No newline at end of file diff --git a/exchange.py b/exchange.py new file mode 100644 index 000000000..c86e8ec05 --- /dev/null +++ b/exchange.py @@ -0,0 +1,152 @@ +import enum +import threading + +from bittrex.bittrex import Bittrex +from poloniex import Poloniex + + +_lock = threading.Condition() +_exchange_api = None + + +def get_exchange_api(conf): + """ + Returns the current exchange api or instantiates a new one + :return: exchange.ApiWrapper + """ + global _exchange_api + _lock.acquire() + if not _exchange_api: + _exchange_api = ApiWrapper(conf) + _lock.release() + return _exchange_api + + +class Exchange(enum.Enum): + POLONIEX = 0 + BITTREX = 1 + + +class ApiWrapper(object): + """ + Wrapper for exchanges. + Currently implemented: + * Bittrex + * Poloniex (partly) + """ + def __init__(self, config): + """ + Initializes the ApiWrapper with the given config, it does not validate those values. + :param config: dict + """ + self.dry_run = config['dry_run'] + + use_poloniex = config.get('poloniex', {}).get('enabled', False) + use_bittrex = config.get('bittrex', {}).get('enabled', False) + + if use_poloniex: + self.exchange = Exchange.POLONIEX + self.api = Poloniex( + key=config['poloniex']['key'], + secret=config['poloniex']['secret'] + ) + elif use_bittrex: + self.exchange = Exchange.BITTREX + self.api = Bittrex( + api_key=config['bittrex']['key'], + api_secret=config['bittrex']['secret'] + ) + else: + self.api = None + + def buy(self, pair, rate, amount): + """ + 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 + """ + if self.dry_run: + pass + elif self.exchange == Exchange.POLONIEX: + self.api.buy(pair, rate, amount) + elif self.exchange == Exchange.BITTREX: + data = self.api.buy_limit(pair.replace('_', '-'), amount, rate) + if not data['success']: + raise RuntimeError('BITTREX: {}'.format(data['message'])) + + def sell(self, pair, rate, amount): + """ + 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 self.dry_run: + pass + elif self.exchange == Exchange.POLONIEX: + self.api.sell(pair, rate, amount) + elif self.exchange == Exchange.BITTREX: + data = self.api.sell_limit(pair.replace('_', '-'), amount, rate) + if not data['success']: + raise RuntimeError('BITTREX: {}'.format(data['message'])) + + def get_balance(self, currency): + """ + Get account balance. + :param currency: currency as str, format: BTC + :return: float + """ + if self.exchange == Exchange.POLONIEX: + data = self.api.returnBalances() + return float(data[currency]) + elif self.exchange == Exchange.BITTREX: + data = self.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(self, pair): + """ + Get Ticker for given pair. + :param pair: Pair as str, format: BTC_ETC + :return: dict + """ + if self.exchange == Exchange.POLONIEX: + data = self.api.returnTicker() + return { + 'bid': float(data[pair]['highestBid']), + 'ask': float(data[pair]['lowestAsk']), + 'last': float(data[pair]['last']) + } + elif self.exchange == Exchange.BITTREX: + data = self.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 get_open_orders(self, pair): + """ + Get all open orders for given pair. + :param pair: Pair as str, format: BTC_ETC + :return: list of dicts + """ + if self.exchange == Exchange.POLONIEX: + raise NotImplemented('Not implemented') + elif self.exchange == Exchange.BITTREX: + data = self.api.get_open_orders(pair.replace('_', '-')) + if not data['success']: + raise RuntimeError('BITTREX: {}'.format(data['message'])) + return [{ + 'type': entry['OrderType'], + 'opened': entry['Opened'], + 'rate': entry['PricePerUnit'], + 'amount': entry['Quantity'], + 'remaining': entry['QuantityRemaining'], + } for entry in data['result']] diff --git a/main.py b/main.py new file mode 100644 index 000000000..01905e501 --- /dev/null +++ b/main.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +import logging +import random +import threading +import time +import traceback +from datetime import datetime + +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +from persistence import Trade, Session +from exchange import get_exchange_api +from rpc.telegram import TelegramHandler +from utils import get_conf + +__author__ = "gcarq" +__copyright__ = "gcarq 2017" +__license__ = "custom" +__version__ = "0.4" + + +conf = get_conf() +api_wrapper = get_exchange_api(conf) + +_lock = threading.Condition() +_instance = None +_should_stop = False + + +class TradeThread(threading.Thread): + @staticmethod + def get_instance(recreate=False): + """ + Get the current instance of this thread. This is a singleton. + :param recreate: Must be True if you want to start the instance + :return: TradeThread instance + """ + global _instance, _should_stop + _lock.acquire() + if _instance is None or (not _instance.is_alive() and recreate): + _should_stop = False + _instance = TradeThread() + _lock.release() + return _instance + + @staticmethod + def stop(): + """ + Sets stop signal for the current instance + :return: None + """ + global _should_stop + _lock.acquire() + _should_stop = True + _lock.release() + + def run(self): + """ + Threaded main function + :return: None + """ + try: + TelegramHandler.send_msg('*Status:* `trader started`') + while not _should_stop: + try: + # Query trades from persistence layer + trade = Trade.query.filter(Trade.is_open.is_(True)).first() + if trade: + # Check if there is already an open order for this pair + open_orders = api_wrapper.get_open_orders(trade.pair) + if open_orders: + msg = 'There is already an open order for this trade. (total: {}, remaining: {}, type: {})'\ + .format( + round(open_orders[0]['amount'], 8), + round(open_orders[0]['remaining'], 8), + open_orders[0]['type'] + ) + logger.info(msg) + elif close_trade_if_fulfilled(trade): + logger.info('No open orders found and close values are set. Marking trade as closed ...') + else: + # Maybe sell with current rate + handle_trade(trade) + else: + # Prepare entity and execute trade + Session.add(create_trade(float(conf['stake_amount']), api_wrapper.exchange)) + except ValueError: + logger.exception('ValueError') + except RuntimeError: + TelegramHandler.send_msg('RuntimeError. Stopping trader ...'.format(traceback.format_exc())) + logger.exception('RuntimeError. Stopping trader ...') + Session.flush() + return + finally: + Session.flush() + time.sleep(25) + finally: + TelegramHandler.send_msg('*Status:* `trader has stopped`') + + +def close_trade_if_fulfilled(trade): + """ + Checks if the trade is closable, and if so it is being closed. + :param trade: Trade + :return: True if trade has been closed else False + """ + # If we don't have an open order and the close rate is already set, + # we can close this trade. + if trade.close_profit and trade.close_date and trade.close_rate: + trade.is_open = False + return True + return False + + +def handle_trade(trade): + """ + Sells the current pair if the threshold is reached + and updates the trade record. + :return: current instance + """ + try: + if not trade.is_open: + raise ValueError('attempt to handle closed trade: {}'.format(trade)) + + logger.debug('Handling open trade {} ...'.format(trade)) + # Get current rate + current_rate = api_wrapper.get_ticker(trade.pair)['last'] + current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate) + + # Get available balance + currency = trade.pair.split('_')[1] + balance = api_wrapper.get_balance(currency) + + for duration, threshold in sorted(conf['trade_thresholds'].items()): + duration = float(duration) + threshold = float(threshold) + # Check if time matches and current rate is above threshold + time_diff = (datetime.utcnow() - trade.open_date).total_seconds() / 60 + if time_diff > duration and current_rate > (1 + threshold) * trade.open_rate: + + # Execute sell and update trade record + api_wrapper.sell(trade.pair, current_rate, balance) + trade.close_rate = current_rate + trade.close_profit = current_profit + trade.close_date = datetime.utcnow() + + message = '*{}:* Selling {} at rate `{:f} (profit: {}%)`'.format( + trade.exchange.name, + trade.pair.replace('_', '/'), + trade.close_rate, + round(current_profit, 2) + ) + logger.info(message) + TelegramHandler.send_msg(message) + return + else: + logger.debug('Threshold not reached. (cur_profit: {}%)'.format(round(current_profit, 2))) + except ValueError: + logger.exception('Unable to handle open order') + + +def create_trade(stake_amount: float, exchange): + """ + Creates a new trade record with a random pair + :param stake_amount: amount of btc to spend + :param exchange: exchange to use + """ + # Whitelist sanity check + whitelist = conf[exchange.name.lower()]['pair_whitelist'] + if not whitelist or not isinstance(whitelist, list): + raise ValueError('No usable pair in whitelist.') + # Check if btc_amount is fulfilled + if api_wrapper.get_balance('BTC') < stake_amount: + raise ValueError('BTC amount is not fulfilled.') + # Pick random pair and execute trade + idx = random.randint(0, len(whitelist) - 1) + pair = whitelist[idx] + open_rate = api_wrapper.get_ticker(pair)['last'] + amount = stake_amount / open_rate + exchange = exchange + api_wrapper.buy(pair, open_rate, amount) + + trade = Trade( + pair=pair, + btc_amount=stake_amount, + open_rate=open_rate, + amount=amount, + exchange=exchange, + ) + message = '*{}:* Buying {} at rate `{:f}`'.format( + trade.exchange.name, + trade.pair.replace('_', '/'), + trade.open_rate + ) + logger.info(message) + TelegramHandler.send_msg(message) + return trade + + +if __name__ == '__main__': + logger.info('Starting marginbot {}'.format(__version__)) + TelegramHandler.listen() + while True: + time.sleep(0.1) diff --git a/persistence.py b/persistence.py new file mode 100644 index 000000000..6f53f8014 --- /dev/null +++ b/persistence.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.types import Enum + +from exchange import Exchange + + +def create_session(base, filename): + """ + Creates sqlite database and setup tables. + :return: sqlalchemy Session + """ + engine = create_engine(filename, echo=False) + base.metadata.create_all(engine) + return scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True)) + + +Base = declarative_base() +Session = create_session(Base, filename='sqlite:///tradesv2.sqlite') + + +class Trade(Base): + __tablename__ = 'trades' + + query = Session.query_property() + + id = Column(Integer, primary_key=True) + exchange = Column(Enum(Exchange), nullable=False) + pair = Column(String, nullable=False) + is_open = Column(Boolean, nullable=False, default=True) + open_rate = Column(Float, nullable=False) + close_rate = Column(Float) + close_profit = Column(Float) + btc_amount = Column(Float, nullable=False) + amount = Column(Float, nullable=False) + open_date = Column(DateTime, nullable=False, default=datetime.utcnow) + close_date = Column(DateTime) + + def __repr__(self): + return 'Trade(id={}, pair={}, amount={}, open_rate={}, open_since={})'.format( + self.id, + self.pair, + self.amount, + self.open_rate, + 'closed' if not self.is_open else round((datetime.utcnow() - self.open_date).total_seconds() / 60, 2) + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d7be2ecdc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +-e git+https://github.com/s4w3d0ff/python-poloniex.git#egg=Poloniex +-e git+https://github.com/ericsomdahl/python-bittrex.git#egg=python-bittrex +SQLAlchemy==1.1.9 +python-telegram-bot==5.3.1 +arrow==0.10.0 +requests==2.14.2 \ No newline at end of file diff --git a/rpc/__init__.py b/rpc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rpc/__pycache__/__init__.cpython-36.pyc b/rpc/__pycache__/__init__.cpython-36.pyc new file mode 100644 index 000000000..a2af425d6 Binary files /dev/null and b/rpc/__pycache__/__init__.cpython-36.pyc differ diff --git a/rpc/__pycache__/telegram.cpython-36.pyc b/rpc/__pycache__/telegram.cpython-36.pyc new file mode 100644 index 000000000..0acfc5cef Binary files /dev/null and b/rpc/__pycache__/telegram.cpython-36.pyc differ diff --git a/rpc/telegram.py b/rpc/telegram.py new file mode 100644 index 000000000..3fb3655fb --- /dev/null +++ b/rpc/telegram.py @@ -0,0 +1,202 @@ +import threading + +import arrow +from datetime import timedelta + +import logging +from telegram.ext import CommandHandler, Updater +from telegram import ParseMode + +from persistence import Trade +from exchange import get_exchange_api +from utils import get_conf + +logger = logging.getLogger(__name__) + +_lock = threading.Condition() +_updater = None + +conf = get_conf() +api_wrapper = get_exchange_api(conf) + + +class TelegramHandler(object): + @staticmethod + def get_updater(conf): + """ + Returns the current telegram updater instantiates a new one + :param conf: + :return: telegram.ext.Updater + """ + global _updater + _lock.acquire() + if not _updater: + _updater = Updater(token=conf['telegram']['token'], workers=0) + _lock.release() + return _updater + + @staticmethod + def listen(): + """ + Registers all known command handlers and starts polling for message updates + :return: None + """ + # Register command handler and start telegram message polling + handles = [CommandHandler('status', TelegramHandler._status), + CommandHandler('profit', TelegramHandler._profit), + CommandHandler('start', TelegramHandler._start), + CommandHandler('stop', TelegramHandler._stop)] + for handle in handles: + TelegramHandler.get_updater(conf).dispatcher.add_handler(handle) + TelegramHandler.get_updater(conf).start_polling() + + @staticmethod + def _is_correct_scope(update): + """ + Checks if it is save to process the given update + :param update: + :return: True if valid else False + """ + # Only answer to our chat + return int(update.message.chat_id) == int(conf['telegram']['chat_id']) + + @staticmethod + def send_msg(markdown_message, bot=None): + """ + Send given markdown message + :param markdown_message: message + :param bot: alternative bot + :return: None + """ + if conf['telegram'].get('enabled', False): + try: + bot = bot or TelegramHandler.get_updater(conf).bot + bot.send_message( + chat_id=conf['telegram']['chat_id'], + text=markdown_message, + parse_mode=ParseMode.MARKDOWN, + ) + except Exception: + logger.exception('Exception occurred within telegram api') + + @staticmethod + def _status(bot, update): + """ + Handler for /status + :param bot: telegram bot + :param update: message update + :return: None + """ + if not TelegramHandler._is_correct_scope(update): + return + + # Fetch open trade + trade = Trade.query.filter(Trade.is_open.is_(True)).first() + + from main import TradeThread + if not TradeThread.get_instance().is_alive(): + message = '*Status:* `trader stopped`' + elif not trade: + message = '*Status:* `no active order`' + else: + # calculate profit and send message to user + current_rate = api_wrapper.get_ticker(trade.pair)['last'] + current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate) + open_orders = api_wrapper.get_open_orders(trade.pair) + order = open_orders[0] if open_orders else None + message = """ +*Current Pair:* [{pair}](https://bittrex.com/Market/Index?MarketName={pair}) +*Open Since:* `{date}` +*Amount:* `{amount}` +*Open Rate:* `{open_rate}` +*Close Rate:* `{close_rate}` +*Current Rate:* `{current_rate}` +*Close Profit:* `{close_profit}%` +*Current Profit:* `{current_profit}%` +*Open Order:* `{open_order}` + """.format( + pair=trade.pair.replace('_', '-'), + date=arrow.get(trade.open_date).humanize(), + open_rate=trade.open_rate, + close_rate=trade.close_rate, + current_rate=current_rate, + amount=round(trade.amount, 8), + close_profit=round(trade.close_profit, 2) if trade.close_profit else 'None', + current_profit=round(current_profit, 2), + open_order='{} ({})'.format( + order['remaining'], + order['type'] + ) if order else None, + ) + TelegramHandler.send_msg(message, bot=bot) + + @staticmethod + def _profit(bot, update): + """ + Handler for /profit + :param bot: telegram bot + :param update: message update + :return: None + """ + if not TelegramHandler._is_correct_scope(update): + return + trades = Trade.query.filter(Trade.is_open.is_(False)).all() + trade_count = len(trades) + profit_amount = sum((t.close_profit / 100) * t.btc_amount for t in trades) + profit = sum(t.close_profit for t in trades) + avg_stake_amount = sum(t.btc_amount for t in trades) / float(trade_count) + durations_hours = [(t.close_date - t.open_date).total_seconds() / 3600.0 for t in trades] + avg_duration = sum(durations_hours) / float(len(durations_hours)) + + markdown_msg = """ +*Total Balance:* `{total_amount} BTC` +*Total Profit:* `{profit_btc} BTC ({profit}%)` +*Trade Count:* `{trade_count}` +*First Action:* `{first_trade_date}` +*Latest Action:* `{latest_trade_date}` +*Avg. Stake Amount:* `{avg_open_amount} BTC` +*Avg. Duration:* `{avg_duration}` + """.format( + total_amount=round(api_wrapper.get_balance('BTC'), 8), + profit_btc=round(profit_amount, 8), + profit=round(profit, 2), + trade_count=trade_count, + first_trade_date=arrow.get(trades[0].open_date).humanize(), + latest_trade_date=arrow.get(trades[-1].open_date).humanize(), + avg_open_amount=round(avg_stake_amount, 8), + avg_duration=str(timedelta(hours=avg_duration)).split('.')[0], + ) + TelegramHandler.send_msg(markdown_msg, bot=bot) + + @staticmethod + def _start(bot, update): + """ + Handler for /start + :param bot: telegram bot + :param update: message update + :return: None + """ + if not TelegramHandler._is_correct_scope(update): + return + from main import TradeThread + if TradeThread.get_instance().is_alive(): + TelegramHandler.send_msg('*Status:* `already running`', bot=bot) + return + else: + TradeThread.get_instance(recreate=True).start() + + @staticmethod + def _stop(bot, update): + """ + Handler for /stop + :param bot: telegram bot + :param update: message update + :return: None + """ + if not TelegramHandler._is_correct_scope(update): + return + from main import TradeThread + if TradeThread.get_instance().is_alive(): + TradeThread.stop() + else: + TelegramHandler.send_msg('*Status:* `already stopped`', bot=bot) diff --git a/utils.py b/utils.py new file mode 100644 index 000000000..f9a9cad27 --- /dev/null +++ b/utils.py @@ -0,0 +1,72 @@ +import json +import logging + +logger = logging.getLogger(__name__) + +_CUR_CONF = None + + +def get_conf(): + """ + Loads the config into memory and returns the instance of it + :return: dict + """ + global _CUR_CONF + if not _CUR_CONF: + with open('config.json') as fp: + _CUR_CONF = json.load(fp) + validate_conf(_CUR_CONF) + 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('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('trade_thresholds'), dict): + raise ValueError('trade_thresholds must be a dict') + if not isinstance(conf.get('trade_thresholds'), dict): + raise ValueError('trade_thresholds must be a dict') + + for i, (minutes, threshold) in enumerate(conf.get('trade_thresholds').items()): + if not isinstance(minutes, str): + raise ValueError('trade_thresholds[{}].key must be a string'.format(i)) + if not isinstance(threshold, float): + raise ValueError('trade_thresholds[{}].value must be a float'.format(i)) + + 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 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 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 ...')