Merge branch 'master' into parabolic-sar

This commit is contained in:
Janne Sinivirta 2017-09-03 16:43:20 +03:00
commit 44b9da5159
8 changed files with 180 additions and 158 deletions

View File

@ -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

View File

@ -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

View File

@ -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

40
main.py
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <id>.
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

145
utils.py
View File

@ -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))