From e5d693ed4cbeade9bf69ce8364cf1a974dd83efd Mon Sep 17 00:00:00 2001 From: gcarq Date: Fri, 12 May 2017 19:11:56 +0200 Subject: [PATCH] initial commit --- .gitignore | 3 + config.json.example | 35 ++++ exchange.py | 152 +++++++++++++++++ main.py | 207 ++++++++++++++++++++++++ persistence.py | 49 ++++++ requirements.txt | 6 + rpc/__init__.py | 0 rpc/__pycache__/__init__.cpython-36.pyc | Bin 0 -> 133 bytes rpc/__pycache__/telegram.cpython-36.pyc | Bin 0 -> 6567 bytes rpc/telegram.py | 202 +++++++++++++++++++++++ utils.py | 72 +++++++++ 11 files changed, 726 insertions(+) create mode 100644 .gitignore create mode 100644 config.json.example create mode 100644 exchange.py create mode 100644 main.py create mode 100644 persistence.py create mode 100644 requirements.txt create mode 100644 rpc/__init__.py create mode 100644 rpc/__pycache__/__init__.cpython-36.pyc create mode 100644 rpc/__pycache__/telegram.cpython-36.pyc create mode 100644 rpc/telegram.py create mode 100644 utils.py 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 0000000000000000000000000000000000000000..a2af425d6f79e393149322a51d32731a1a688147 GIT binary patch literal 133 zcmXr!<>d-lBofI01dl-k3@`#24nSPY0whuxf*CX!{Z=v*frJsnFC+bo{M=Oi;*ylK z%p8Lv{qp>x?BasN@fyrldR{i1?o{rLFIyv&mLc)fzkTO2mI`6;D2sdgZ< Iih-B`0D>GJ5&!@I literal 0 HcmV?d00001 diff --git a/rpc/__pycache__/telegram.cpython-36.pyc b/rpc/__pycache__/telegram.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0acfc5cefc0f221231bb046175c2f274d214ab27 GIT binary patch literal 6567 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 Wv<;jb&k|aakZU;jGL4FEm;MXBgQ