major refactoring to allow proper testing
This commit includes: * Reducing complexity of modules * Remove unneeded wrapper classes * Implement init() for each module which initializes everything based on the config * Implement some basic tests
This commit is contained in:
parent
1dc1018356
commit
3473fd3c90
354
exchange.py
354
exchange.py
@ -4,11 +4,13 @@ from typing import List
|
|||||||
|
|
||||||
from bittrex.bittrex import Bittrex
|
from bittrex.bittrex import Bittrex
|
||||||
from poloniex import Poloniex
|
from poloniex import Poloniex
|
||||||
from wrapt import synchronized
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_exchange_api = None
|
|
||||||
|
cur_exchange = None
|
||||||
|
_api = None
|
||||||
|
_conf = {}
|
||||||
|
|
||||||
|
|
||||||
class Exchange(enum.Enum):
|
class Exchange(enum.Enum):
|
||||||
@ -16,192 +18,184 @@ class Exchange(enum.Enum):
|
|||||||
BITTREX = 1
|
BITTREX = 1
|
||||||
|
|
||||||
|
|
||||||
class ApiWrapper(object):
|
def init(config: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Wrapper for exchanges.
|
Initializes this module with the given config,
|
||||||
Currently implemented:
|
it does basic validation whether the specified
|
||||||
* Bittrex
|
exchange and pairs are valid.
|
||||||
* Poloniex (partly)
|
:param config: config to use
|
||||||
|
:return: None
|
||||||
"""
|
"""
|
||||||
def __init__(self, config: dict):
|
global _api, cur_exchange
|
||||||
"""
|
|
||||||
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']
|
|
||||||
if self.dry_run:
|
|
||||||
logger.info('Instance is running with dry_run enabled')
|
|
||||||
|
|
||||||
use_poloniex = config.get('poloniex', {}).get('enabled', False)
|
_conf.update(config)
|
||||||
use_bittrex = config.get('bittrex', {}).get('enabled', False)
|
|
||||||
|
|
||||||
if use_poloniex:
|
if config['dry_run']:
|
||||||
self.exchange = Exchange.POLONIEX
|
logger.info('Instance is running with dry_run enabled')
|
||||||
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
|
|
||||||
raise RuntimeError('No exchange specified. Aborting!')
|
|
||||||
|
|
||||||
# Check if all pairs are available
|
use_poloniex = config.get('poloniex', {}).get('enabled', False)
|
||||||
markets = self.get_markets()
|
use_bittrex = config.get('bittrex', {}).get('enabled', False)
|
||||||
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:
|
if use_poloniex:
|
||||||
"""
|
cur_exchange = Exchange.POLONIEX
|
||||||
Places a limit buy order.
|
_api = Poloniex(key=config['poloniex']['key'], secret=config['poloniex']['secret'])
|
||||||
:param pair: Pair as str, format: BTC_ETH
|
elif use_bittrex:
|
||||||
:param rate: Rate limit for order
|
cur_exchange = Exchange.BITTREX
|
||||||
:param amount: The amount to purchase
|
_api = Bittrex(api_key=config['bittrex']['key'], api_secret=config['bittrex']['secret'])
|
||||||
:return: order_id of the placed buy order
|
else:
|
||||||
"""
|
raise RuntimeError('No exchange specified. Aborting!')
|
||||||
if self.dry_run:
|
|
||||||
pass
|
|
||||||
elif self.exchange == Exchange.POLONIEX:
|
|
||||||
self.api.buy(pair, rate, amount)
|
|
||||||
# TODO: return order id
|
|
||||||
elif self.exchange == Exchange.BITTREX:
|
|
||||||
data = self.api.buy_limit(pair.replace('_', '-'), amount, rate)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
|
||||||
return data['result']['uuid']
|
|
||||||
|
|
||||||
def sell(self, pair: str, rate: float, amount: float) -> str:
|
# Check if all pairs are available
|
||||||
"""
|
markets = get_markets()
|
||||||
Places a limit sell order.
|
for pair in config[cur_exchange.name.lower()]['pair_whitelist']:
|
||||||
:param pair: Pair as str, format: BTC_ETH
|
if pair not in markets:
|
||||||
:param rate: Rate limit for order
|
raise RuntimeError('Pair {} is not available at Poloniex'.format(pair))
|
||||||
:param amount: The amount to sell
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if self.dry_run:
|
|
||||||
pass
|
|
||||||
elif self.exchange == Exchange.POLONIEX:
|
|
||||||
self.api.sell(pair, rate, amount)
|
|
||||||
# TODO: return order id
|
|
||||||
elif self.exchange == Exchange.BITTREX:
|
|
||||||
data = self.api.sell_limit(pair.replace('_', '-'), amount, rate)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
|
||||||
return data['result']['uuid']
|
|
||||||
|
|
||||||
def get_balance(self, currency: str) -> float:
|
|
||||||
"""
|
|
||||||
Get account balance.
|
|
||||||
:param currency: currency as str, format: BTC
|
|
||||||
:return: float
|
|
||||||
"""
|
|
||||||
if self.dry_run:
|
|
||||||
return 999.9
|
|
||||||
elif 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: str) -> dict:
|
|
||||||
"""
|
|
||||||
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 cancel_order(self, order_id: str) -> None:
|
|
||||||
"""
|
|
||||||
Cancel order for given order_id
|
|
||||||
:param order_id: id as str
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if self.dry_run:
|
|
||||||
pass
|
|
||||||
elif self.exchange == Exchange.POLONIEX:
|
|
||||||
raise NotImplemented('Not implemented')
|
|
||||||
elif self.exchange == Exchange.BITTREX:
|
|
||||||
data = self.api.cancel(order_id)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
|
||||||
|
|
||||||
def get_open_orders(self, pair: str) -> List[dict]:
|
|
||||||
"""
|
|
||||||
Get all open orders for given pair.
|
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
|
||||||
:return: list of dicts
|
|
||||||
"""
|
|
||||||
if self.dry_run:
|
|
||||||
return []
|
|
||||||
elif 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 [{
|
|
||||||
'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:
|
|
||||||
"""
|
|
||||||
Returns the market detail url for the given pair
|
|
||||||
:param pair: pair as str, format: BTC_ANT
|
|
||||||
:return: url as str
|
|
||||||
"""
|
|
||||||
if self.exchange == Exchange.POLONIEX:
|
|
||||||
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 buy(pair: str, rate: float, amount: float) -> str:
|
||||||
def get_exchange_api(conf: dict) -> ApiWrapper:
|
|
||||||
"""
|
"""
|
||||||
Returns the current exchange api or instantiates a new one
|
Places a limit buy order.
|
||||||
:return: exchange.ApiWrapper
|
: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
|
||||||
"""
|
"""
|
||||||
global _exchange_api
|
if _conf['dry_run']:
|
||||||
if not _exchange_api:
|
return 'dry_run'
|
||||||
_exchange_api = ApiWrapper(conf)
|
elif cur_exchange == Exchange.POLONIEX:
|
||||||
return _exchange_api
|
_api.buy(pair, rate, amount)
|
||||||
|
# TODO: return order id
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
_api.sell(pair, rate, amount)
|
||||||
|
# TODO: return order id
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
data = _api.returnBalances()
|
||||||
|
return float(data[currency])
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
data = _api.returnTicker()
|
||||||
|
return {
|
||||||
|
'bid': float(data[pair]['highestBid']),
|
||||||
|
'ask': float(data[pair]['lowestAsk']),
|
||||||
|
'last': float(data[pair]['last'])
|
||||||
|
}
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
raise NotImplemented('Not implemented')
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
raise NotImplemented('Not implemented')
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
raise NotImplemented('Not implemented')
|
||||||
|
elif cur_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 cur_exchange == Exchange.POLONIEX:
|
||||||
|
# TODO: implement
|
||||||
|
raise NotImplemented('Not implemented')
|
||||||
|
elif cur_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']]
|
||||||
|
263
main.py
263
main.py
@ -1,19 +1,23 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from jsonschema import validate
|
||||||
from requests import ConnectionError
|
from requests import ConnectionError
|
||||||
from wrapt import synchronized
|
from wrapt import synchronized
|
||||||
|
|
||||||
|
import exchange
|
||||||
|
import persistence
|
||||||
|
from rpc import telegram
|
||||||
from analyze import get_buy_signal
|
from analyze import get_buy_signal
|
||||||
from persistence import Trade, Session
|
from persistence import Trade
|
||||||
from exchange import get_exchange_api, Exchange
|
from misc import conf_schema
|
||||||
from rpc.telegram import TelegramHandler
|
|
||||||
from utils import get_conf
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG,
|
logging.basicConfig(level=logging.DEBUG,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
@ -25,103 +29,79 @@ __license__ = "GPLv3"
|
|||||||
__version__ = "0.8.0"
|
__version__ = "0.8.0"
|
||||||
|
|
||||||
|
|
||||||
CONFIG = get_conf()
|
class State(enum.Enum):
|
||||||
api_wrapper = get_exchange_api(CONFIG)
|
RUNNING = 0
|
||||||
|
PAUSED = 1
|
||||||
|
TERMINATE = 2
|
||||||
|
|
||||||
|
|
||||||
class TradeThread(threading.Thread):
|
_conf = {}
|
||||||
def __init__(self):
|
_cur_state = State.RUNNING
|
||||||
super().__init__()
|
|
||||||
self._should_stop = False
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
""" stops the trader thread """
|
|
||||||
self._should_stop = True
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
"""
|
|
||||||
Threaded main function
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
TelegramHandler.send_msg('*Status:* `trader started`')
|
|
||||||
logger.info('Trader started')
|
|
||||||
while not self._should_stop:
|
|
||||||
try:
|
|
||||||
self._process()
|
|
||||||
except (ConnectionError, JSONDecodeError, ValueError) as error:
|
|
||||||
msg = 'Got {} during _process()'.format(error.__class__.__name__)
|
|
||||||
logger.exception(msg)
|
|
||||||
finally:
|
|
||||||
Session.flush()
|
|
||||||
time.sleep(25)
|
|
||||||
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() -> None:
|
|
||||||
"""
|
|
||||||
Queries the persistence layer for open trades and handles them,
|
|
||||||
otherwise a new trade is created.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
# Query trades from persistence layer
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
if len(trades) < CONFIG['max_open_trades']:
|
|
||||||
try:
|
|
||||||
# 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('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)
|
|
||||||
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
|
||||||
if orders:
|
|
||||||
msg = 'There exists an open order for {}: Order(total={}, remaining={}, type={}, id={})' \
|
|
||||||
.format(
|
|
||||||
trade,
|
|
||||||
round(orders[0]['amount'], 8),
|
|
||||||
round(orders[0]['remaining'], 8),
|
|
||||||
orders[0]['type'],
|
|
||||||
orders[0]['id'])
|
|
||||||
logger.info(msg)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update state
|
|
||||||
trade.open_order_id = None
|
|
||||||
# Check if this trade can be marked as closed
|
|
||||||
if close_trade_if_fulfilled(trade):
|
|
||||||
logger.info('No open orders found and trade is fulfilled. Marking %s as closed ...', trade)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if we can sell our current pair
|
|
||||||
handle_trade(trade)
|
|
||||||
|
|
||||||
# Initial stopped TradeThread instance
|
|
||||||
_instance = TradeThread()
|
|
||||||
|
|
||||||
|
|
||||||
@synchronized
|
@synchronized
|
||||||
def get_instance(recreate: bool=False) -> TradeThread:
|
def update_state(state: State) -> None:
|
||||||
"""
|
"""
|
||||||
Get the current instance of this thread. This is a singleton.
|
Updates the application state
|
||||||
:param recreate: Must be True if you want to start the instance
|
:param state: new state
|
||||||
:return: TradeThread instance
|
:return: None
|
||||||
"""
|
"""
|
||||||
global _instance
|
global _cur_state
|
||||||
if recreate and not _instance.is_alive():
|
_cur_state = state
|
||||||
logger.debug('Creating thread instance...')
|
|
||||||
_instance = TradeThread()
|
|
||||||
return _instance
|
@synchronized
|
||||||
|
def get_state() -> State:
|
||||||
|
"""
|
||||||
|
Gets the current application state
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return _cur_state
|
||||||
|
|
||||||
|
|
||||||
|
def _process() -> None:
|
||||||
|
"""
|
||||||
|
Queries the persistence layer for open trades and handles them,
|
||||||
|
otherwise a new trade is created.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Query trades from persistence layer
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
if len(trades) < _conf['max_open_trades']:
|
||||||
|
try:
|
||||||
|
# Create entity and execute trade
|
||||||
|
trade = create_trade(float(_conf['stake_amount']), exchange.cur_exchange)
|
||||||
|
if trade:
|
||||||
|
Trade.session.add(trade)
|
||||||
|
else:
|
||||||
|
logging.info('Got no buy signal...')
|
||||||
|
except ValueError:
|
||||||
|
logger.exception('Unable to create trade')
|
||||||
|
|
||||||
|
for trade in trades:
|
||||||
|
# Check if there is already an open order for this trade
|
||||||
|
orders = exchange.get_open_orders(trade.pair)
|
||||||
|
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
||||||
|
if orders:
|
||||||
|
msg = 'There exists an open order for {}: Order(total={}, remaining={}, type={}, id={})' \
|
||||||
|
.format(
|
||||||
|
trade,
|
||||||
|
round(orders[0]['amount'], 8),
|
||||||
|
round(orders[0]['remaining'], 8),
|
||||||
|
orders[0]['type'],
|
||||||
|
orders[0]['id'])
|
||||||
|
logger.info(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
trade.open_order_id = None
|
||||||
|
# Check if this trade can be marked as closed
|
||||||
|
if close_trade_if_fulfilled(trade):
|
||||||
|
logger.info('No open orders found and trade is fulfilled. Marking %s as closed ...', trade)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if we can sell our current pair
|
||||||
|
handle_trade(trade)
|
||||||
|
|
||||||
|
|
||||||
def close_trade_if_fulfilled(trade: Trade) -> bool:
|
def close_trade_if_fulfilled(trade: Trade) -> bool:
|
||||||
@ -134,7 +114,6 @@ def close_trade_if_fulfilled(trade: Trade) -> bool:
|
|||||||
# we can close this trade.
|
# we can close this trade.
|
||||||
if trade.close_profit and trade.close_date and trade.close_rate and not trade.open_order_id:
|
if trade.close_profit and trade.close_date and trade.close_rate and not trade.open_order_id:
|
||||||
trade.is_open = False
|
trade.is_open = False
|
||||||
Session.flush()
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -150,14 +129,14 @@ def handle_trade(trade: Trade) -> None:
|
|||||||
|
|
||||||
logger.debug('Handling open trade %s ...', trade)
|
logger.debug('Handling open trade %s ...', trade)
|
||||||
# Get current rate
|
# Get current rate
|
||||||
current_rate = api_wrapper.get_ticker(trade.pair)['bid']
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
||||||
|
|
||||||
# Get available balance
|
# Get available balance
|
||||||
currency = trade.pair.split('_')[1]
|
currency = trade.pair.split('_')[1]
|
||||||
balance = api_wrapper.get_balance(currency)
|
balance = exchange.get_balance(currency)
|
||||||
|
|
||||||
for duration, threshold in sorted(CONFIG['minimal_roi'].items()):
|
for duration, threshold in sorted(_conf['minimal_roi'].items()):
|
||||||
duration, threshold = float(duration), float(threshold)
|
duration, threshold = float(duration), float(threshold)
|
||||||
# Check if time matches and current rate is above threshold
|
# Check if time matches and current rate is above threshold
|
||||||
time_diff = (datetime.utcnow() - trade.open_date).total_seconds() / 60
|
time_diff = (datetime.utcnow() - trade.open_date).total_seconds() / 60
|
||||||
@ -167,12 +146,12 @@ def handle_trade(trade: Trade) -> None:
|
|||||||
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
||||||
trade.exchange.name,
|
trade.exchange.name,
|
||||||
trade.pair.replace('_', '/'),
|
trade.pair.replace('_', '/'),
|
||||||
api_wrapper.get_pair_detail_url(trade.pair),
|
exchange.get_pair_detail_url(trade.pair),
|
||||||
trade.close_rate,
|
trade.close_rate,
|
||||||
round(profit, 2)
|
round(profit, 2)
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.info(message)
|
||||||
TelegramHandler.send_msg(message)
|
telegram.send_msg(message)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit)
|
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit)
|
||||||
@ -180,18 +159,18 @@ def handle_trade(trade: Trade) -> None:
|
|||||||
logger.exception('Unable to handle open order')
|
logger.exception('Unable to handle open order')
|
||||||
|
|
||||||
|
|
||||||
def create_trade(stake_amount: float, exchange: Exchange) -> Optional[Trade]:
|
def create_trade(stake_amount: float, _exchange: exchange.Exchange) -> Optional[Trade]:
|
||||||
"""
|
"""
|
||||||
Checks the implemented trading indicator(s) for a randomly picked 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
|
if one pair triggers the buy_signal a new trade record gets created
|
||||||
:param stake_amount: amount of btc to spend
|
:param stake_amount: amount of btc to spend
|
||||||
:param exchange: exchange to use
|
:param _exchange: exchange to use
|
||||||
"""
|
"""
|
||||||
logger.info('Creating new trade with stake_amount: %f ...', stake_amount)
|
logger.info('Creating new trade with stake_amount: %f ...', stake_amount)
|
||||||
whitelist = CONFIG[exchange.name.lower()]['pair_whitelist']
|
whitelist = _conf[_exchange.name.lower()]['pair_whitelist']
|
||||||
# Check if btc_amount is fulfilled
|
# Check if btc_amount is fulfilled
|
||||||
if api_wrapper.get_balance(CONFIG['stake_currency']) < stake_amount:
|
if exchange.get_balance(_conf['stake_currency']) < stake_amount:
|
||||||
raise ValueError('stake amount is not fulfilled (currency={}'.format(CONFIG['stake_currency']))
|
raise ValueError('stake amount is not fulfilled (currency={}'.format(_conf['stake_currency']))
|
||||||
|
|
||||||
# Remove currently opened and latest pairs from whitelist
|
# Remove currently opened and latest pairs from whitelist
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
@ -213,30 +192,78 @@ def create_trade(stake_amount: float, exchange: Exchange) -> Optional[Trade]:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
open_rate = api_wrapper.get_ticker(pair)['ask']
|
open_rate = exchange.get_ticker(pair)['ask']
|
||||||
amount = stake_amount / open_rate
|
amount = stake_amount / open_rate
|
||||||
exchange = exchange
|
order_id = exchange.buy(pair, open_rate, amount)
|
||||||
order_id = api_wrapper.buy(pair, open_rate, amount)
|
|
||||||
|
|
||||||
# Create trade entity and return
|
# Create trade entity and return
|
||||||
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
|
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
|
||||||
exchange.name,
|
_exchange.name,
|
||||||
pair.replace('_', '/'),
|
pair.replace('_', '/'),
|
||||||
api_wrapper.get_pair_detail_url(pair),
|
exchange.get_pair_detail_url(pair),
|
||||||
open_rate
|
open_rate
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.info(message)
|
||||||
TelegramHandler.send_msg(message)
|
telegram.send_msg(message)
|
||||||
return Trade(pair=pair,
|
return Trade(pair=pair,
|
||||||
btc_amount=stake_amount,
|
btc_amount=stake_amount,
|
||||||
open_rate=open_rate,
|
open_rate=open_rate,
|
||||||
|
open_date=datetime.utcnow(),
|
||||||
amount=amount,
|
amount=amount,
|
||||||
exchange=exchange,
|
exchange=_exchange,
|
||||||
open_order_id=order_id)
|
open_order_id=order_id,
|
||||||
|
is_open=True)
|
||||||
|
|
||||||
|
|
||||||
|
def init(config: dict) -> None:
|
||||||
|
"""
|
||||||
|
Initializes all modules and updates the config
|
||||||
|
:param config: config as dict
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
global _conf
|
||||||
|
|
||||||
|
# Initialize all modules
|
||||||
|
telegram.init(config)
|
||||||
|
persistence.init(config)
|
||||||
|
exchange.init(config)
|
||||||
|
_conf.update(config)
|
||||||
|
|
||||||
|
|
||||||
|
def app(config: dict) -> None:
|
||||||
|
|
||||||
|
logger.info('Starting freqtrade %s', __version__)
|
||||||
|
init(config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
telegram.send_msg('*Status:* `trader started`')
|
||||||
|
logger.info('Trader started')
|
||||||
|
while True:
|
||||||
|
state = get_state()
|
||||||
|
if state == State.TERMINATE:
|
||||||
|
return
|
||||||
|
elif state == State.PAUSED:
|
||||||
|
time.sleep(1)
|
||||||
|
elif state == State.RUNNING:
|
||||||
|
try:
|
||||||
|
_process()
|
||||||
|
except (ConnectionError, JSONDecodeError, ValueError) as error:
|
||||||
|
msg = 'Got {} during _process()'.format(error.__class__.__name__)
|
||||||
|
logger.exception(msg)
|
||||||
|
finally:
|
||||||
|
time.sleep(25)
|
||||||
|
except (RuntimeError, JSONDecodeError):
|
||||||
|
telegram.send_msg(
|
||||||
|
'*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc())
|
||||||
|
)
|
||||||
|
logger.exception('RuntimeError. Stopping trader ...')
|
||||||
|
finally:
|
||||||
|
telegram.send_msg('*Status:* `Trader has stopped`')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logger.info('Starting freqtrade %s', __version__)
|
with open('config.json') as file:
|
||||||
TelegramHandler.listen()
|
conf = json.load(file)
|
||||||
while True:
|
validate(conf, conf_schema)
|
||||||
time.sleep(0.5)
|
app(conf)
|
||||||
|
|
||||||
|
@ -1,16 +1,6 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from jsonschema import validate
|
|
||||||
from wrapt import synchronized
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_cur_conf = None
|
|
||||||
|
|
||||||
|
|
||||||
# Required json-schema for user specified config
|
# Required json-schema for user specified config
|
||||||
_conf_schema = {
|
conf_schema = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
'max_open_trades': {'type': 'integer'},
|
'max_open_trades': {'type': 'integer'},
|
||||||
@ -65,18 +55,3 @@ _conf_schema = {
|
|||||||
'telegram'
|
'telegram'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@synchronized
|
|
||||||
def get_conf(filename: str='config.json') -> dict:
|
|
||||||
"""
|
|
||||||
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(_cur_conf, _conf_schema)
|
|
||||||
return _cur_conf
|
|
@ -5,27 +5,48 @@ from sqlalchemy.ext.declarative import declarative_base
|
|||||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||||
from sqlalchemy.types import Enum
|
from sqlalchemy.types import Enum
|
||||||
|
|
||||||
from exchange import Exchange, get_exchange_api
|
import exchange
|
||||||
from utils import get_conf
|
|
||||||
|
|
||||||
|
_db_handle = None
|
||||||
|
_session = None
|
||||||
|
_conf = {}
|
||||||
|
|
||||||
conf = get_conf()
|
|
||||||
if conf.get('dry_run', False):
|
|
||||||
db_handle = 'sqlite:///tradesv2.dry_run.sqlite'
|
|
||||||
else:
|
|
||||||
db_handle = 'sqlite:///tradesv2.sqlite'
|
|
||||||
|
|
||||||
engine = create_engine(db_handle, echo=False)
|
|
||||||
Session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def init(config: dict) -> None:
|
||||||
|
"""
|
||||||
|
Initializes this module with the given config,
|
||||||
|
registers all known command handlers
|
||||||
|
and starts polling for message updates
|
||||||
|
:param config: config to use
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
global _db_handle, _session
|
||||||
|
_conf.update(config)
|
||||||
|
if _conf.get('dry_run', False):
|
||||||
|
_db_handle = 'sqlite:///tradesv2.dry_run.sqlite'
|
||||||
|
else:
|
||||||
|
_db_handle = 'sqlite:///tradesv2.sqlite'
|
||||||
|
|
||||||
|
engine = create_engine(_db_handle, echo=False)
|
||||||
|
_session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
||||||
|
Trade.session = _session
|
||||||
|
Trade.query = _session.query_property()
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
return _session
|
||||||
|
|
||||||
|
|
||||||
class Trade(Base):
|
class Trade(Base):
|
||||||
__tablename__ = 'trades'
|
__tablename__ = 'trades'
|
||||||
|
|
||||||
query = Session.query_property()
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
exchange = Column(Enum(Exchange), nullable=False)
|
exchange = Column(Enum(exchange.Exchange), nullable=False)
|
||||||
pair = Column(String, nullable=False)
|
pair = Column(String, nullable=False)
|
||||||
is_open = Column(Boolean, nullable=False, default=True)
|
is_open = Column(Boolean, nullable=False, default=True)
|
||||||
open_rate = Column(Float, nullable=False)
|
open_rate = Column(Float, nullable=False)
|
||||||
@ -56,12 +77,10 @@ class Trade(Base):
|
|||||||
profit = 100 * ((rate - self.open_rate) / self.open_rate)
|
profit = 100 * ((rate - self.open_rate) / self.open_rate)
|
||||||
|
|
||||||
# Execute sell and update trade record
|
# Execute sell and update trade record
|
||||||
order_id = get_exchange_api(conf).sell(self.pair, rate, amount)
|
order_id = exchange.sell(str(self.pair), rate, amount)
|
||||||
self.close_rate = rate
|
self.close_rate = rate
|
||||||
self.close_profit = profit
|
self.close_profit = profit
|
||||||
self.close_date = datetime.utcnow()
|
self.close_date = datetime.utcnow()
|
||||||
self.open_order_id = order_id
|
self.open_order_id = order_id
|
||||||
Session.flush()
|
|
||||||
return profit
|
return profit
|
||||||
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
from . import telegram
|
503
rpc/telegram.py
503
rpc/telegram.py
@ -9,9 +9,9 @@ from telegram.ext import CommandHandler, Updater
|
|||||||
from telegram import ParseMode, Bot, Update
|
from telegram import ParseMode, Bot, Update
|
||||||
from wrapt import synchronized
|
from wrapt import synchronized
|
||||||
|
|
||||||
from persistence import Trade, Session
|
from persistence import Trade
|
||||||
from exchange import get_exchange_api
|
|
||||||
from utils import get_conf
|
import exchange
|
||||||
|
|
||||||
# Remove noisy log messages
|
# Remove noisy log messages
|
||||||
logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
||||||
@ -19,9 +19,38 @@ logging.getLogger('telegram').setLevel(logging.INFO)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_updater = None
|
_updater = None
|
||||||
|
_conf = {}
|
||||||
|
|
||||||
conf = get_conf()
|
|
||||||
api_wrapper = get_exchange_api(conf)
|
def init(config: dict) -> None:
|
||||||
|
"""
|
||||||
|
Initializes this module with the given config,
|
||||||
|
registers all known command handlers
|
||||||
|
and starts polling for message updates
|
||||||
|
:param config: config to use
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
_conf.update(config)
|
||||||
|
|
||||||
|
# Register command handler and start telegram message polling
|
||||||
|
handles = [
|
||||||
|
CommandHandler('status', _status),
|
||||||
|
CommandHandler('profit', _profit),
|
||||||
|
CommandHandler('start', _start),
|
||||||
|
CommandHandler('stop', _stop),
|
||||||
|
CommandHandler('forcesell', _forcesell),
|
||||||
|
CommandHandler('performance', _performance),
|
||||||
|
]
|
||||||
|
for handle in handles:
|
||||||
|
get_updater(_conf).dispatcher.add_handler(handle)
|
||||||
|
get_updater(_conf).start_polling(
|
||||||
|
clean=True,
|
||||||
|
bootstrap_retries=3,
|
||||||
|
timeout=30,
|
||||||
|
read_latency=60,
|
||||||
|
)
|
||||||
|
logger.info('rpc.telegram is listening for following commands: {}'
|
||||||
|
.format([h.command for h in handles]))
|
||||||
|
|
||||||
|
|
||||||
def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]:
|
def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]:
|
||||||
@ -35,7 +64,7 @@ def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[
|
|||||||
if not isinstance(bot, Bot) or not isinstance(update, Update):
|
if not isinstance(bot, Bot) or not isinstance(update, Update):
|
||||||
raise ValueError('Received invalid Arguments: {}'.format(*args))
|
raise ValueError('Received invalid Arguments: {}'.format(*args))
|
||||||
|
|
||||||
chat_id = int(conf['telegram']['chat_id'])
|
chat_id = int(_conf['telegram']['chat_id'])
|
||||||
if int(update.message.chat_id) == chat_id:
|
if int(update.message.chat_id) == chat_id:
|
||||||
logger.info('Executing handler: %s for chat_id: %s', command_handler.__name__, chat_id)
|
logger.info('Executing handler: %s for chat_id: %s', command_handler.__name__, chat_id)
|
||||||
return command_handler(*args, **kwargs)
|
return command_handler(*args, **kwargs)
|
||||||
@ -44,33 +73,31 @@ def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class TelegramHandler(object):
|
@authorized_only
|
||||||
@staticmethod
|
def _status(bot: Bot, update: Update) -> None:
|
||||||
@authorized_only
|
"""
|
||||||
def _status(bot: Bot, update: Update) -> None:
|
Handler for /status.
|
||||||
"""
|
Returns the current TradeThread status
|
||||||
Handler for /status.
|
:param bot: telegram bot
|
||||||
Returns the current TradeThread status
|
:param update: message update
|
||||||
:param bot: telegram bot
|
:return: None
|
||||||
:param update: message update
|
"""
|
||||||
:return: None
|
# Fetch open trade
|
||||||
"""
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
# Fetch open trade
|
from main import get_state, State
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
if not get_state() == State.RUNNING:
|
||||||
from main import get_instance
|
send_msg('*Status:* `trader is not running`', bot=bot)
|
||||||
if not get_instance().is_alive():
|
elif not trades:
|
||||||
TelegramHandler.send_msg('*Status:* `trader is not running`', bot=bot)
|
send_msg('*Status:* `no active order`', bot=bot)
|
||||||
elif not trades:
|
else:
|
||||||
TelegramHandler.send_msg('*Status:* `no active order`', bot=bot)
|
for trade in trades:
|
||||||
else:
|
# calculate profit and send message to user
|
||||||
for trade in trades:
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
# calculate profit and send message to user
|
current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
||||||
current_rate = api_wrapper.get_ticker(trade.pair)['bid']
|
orders = exchange.get_open_orders(trade.pair)
|
||||||
current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
||||||
orders = api_wrapper.get_open_orders(trade.pair)
|
order = orders[0] if orders else None
|
||||||
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
message = """
|
||||||
order = orders[0] if orders else None
|
|
||||||
message = """
|
|
||||||
*Trade ID:* `{trade_id}`
|
*Trade ID:* `{trade_id}`
|
||||||
*Current Pair:* [{pair}]({market_url})
|
*Current Pair:* [{pair}]({market_url})
|
||||||
*Open Since:* `{date}`
|
*Open Since:* `{date}`
|
||||||
@ -81,239 +108,213 @@ class TelegramHandler(object):
|
|||||||
*Close Profit:* `{close_profit}`
|
*Close Profit:* `{close_profit}`
|
||||||
*Current Profit:* `{current_profit}%`
|
*Current Profit:* `{current_profit}%`
|
||||||
*Open Order:* `{open_order}`
|
*Open Order:* `{open_order}`
|
||||||
""".format(
|
""".format(
|
||||||
trade_id=trade.id,
|
trade_id=trade.id,
|
||||||
pair=trade.pair,
|
pair=trade.pair,
|
||||||
market_url=api_wrapper.get_pair_detail_url(trade.pair),
|
market_url=exchange.get_pair_detail_url(trade.pair),
|
||||||
date=arrow.get(trade.open_date).humanize(),
|
date=arrow.get(trade.open_date).humanize(),
|
||||||
open_rate=trade.open_rate,
|
open_rate=trade.open_rate,
|
||||||
close_rate=trade.close_rate,
|
close_rate=trade.close_rate,
|
||||||
current_rate=current_rate,
|
current_rate=current_rate,
|
||||||
amount=round(trade.amount, 8),
|
amount=round(trade.amount, 8),
|
||||||
close_profit='{}%'.format(round(trade.close_profit, 2)) if trade.close_profit else None,
|
close_profit='{}%'.format(round(trade.close_profit, 2)) if trade.close_profit else None,
|
||||||
current_profit=round(current_profit, 2),
|
current_profit=round(current_profit, 2),
|
||||||
open_order='{} ({})'.format(order['remaining'], order['type']) if order else None,
|
open_order='{} ({})'.format(order['remaining'], order['type']) if order else None,
|
||||||
)
|
)
|
||||||
TelegramHandler.send_msg(message, bot=bot)
|
send_msg(message, bot=bot)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@authorized_only
|
|
||||||
def _profit(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
|
||||||
Handler for /profit.
|
|
||||||
Returns a cumulative profit statistics.
|
|
||||||
:param bot: telegram bot
|
|
||||||
:param update: message update
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
trades = Trade.query.order_by(Trade.id).all()
|
|
||||||
|
|
||||||
profit_amounts = []
|
@authorized_only
|
||||||
profits = []
|
def _profit(bot: Bot, update: Update) -> None:
|
||||||
durations = []
|
"""
|
||||||
for trade in trades:
|
Handler for /profit.
|
||||||
if trade.close_date:
|
Returns a cumulative profit statistics.
|
||||||
durations.append((trade.close_date - trade.open_date).total_seconds())
|
:param bot: telegram bot
|
||||||
if trade.close_profit:
|
:param update: message update
|
||||||
profit = trade.close_profit
|
:return: None
|
||||||
else:
|
"""
|
||||||
# Get current rate
|
trades = Trade.query.order_by(Trade.id).all()
|
||||||
current_rate = api_wrapper.get_ticker(trade.pair)['bid']
|
|
||||||
profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
|
||||||
|
|
||||||
profit_amounts.append((profit / 100) * trade.btc_amount)
|
profit_amounts = []
|
||||||
profits.append(profit)
|
profits = []
|
||||||
|
durations = []
|
||||||
|
for trade in trades:
|
||||||
|
if trade.close_date:
|
||||||
|
durations.append((trade.close_date - trade.open_date).total_seconds())
|
||||||
|
if trade.close_profit:
|
||||||
|
profit = trade.close_profit
|
||||||
|
else:
|
||||||
|
# Get current rate
|
||||||
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
|
profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
||||||
|
|
||||||
bp_pair, bp_rate = Session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
profit_amounts.append((profit / 100) * trade.btc_amount)
|
||||||
.filter(Trade.is_open.is_(False)) \
|
profits.append(profit)
|
||||||
.group_by(Trade.pair) \
|
|
||||||
.order_by('profit_sum DESC') \
|
|
||||||
.first()
|
|
||||||
|
|
||||||
markdown_msg = """
|
bp_pair, bp_rate = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
||||||
|
.filter(Trade.is_open.is_(False)) \
|
||||||
|
.group_by(Trade.pair) \
|
||||||
|
.order_by('profit_sum DESC') \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
markdown_msg = """
|
||||||
*ROI:* `{profit_btc} ({profit}%)`
|
*ROI:* `{profit_btc} ({profit}%)`
|
||||||
*Trade Count:* `{trade_count}`
|
*Trade Count:* `{trade_count}`
|
||||||
*First Trade opened:* `{first_trade_date}`
|
*First Trade opened:* `{first_trade_date}`
|
||||||
*Latest Trade opened:* `{latest_trade_date}`
|
*Latest Trade opened:* `{latest_trade_date}`
|
||||||
*Avg. Duration:* `{avg_duration}`
|
*Avg. Duration:* `{avg_duration}`
|
||||||
*Best Performing:* `{best_pair}: {best_rate}%`
|
*Best Performing:* `{best_pair}: {best_rate}%`
|
||||||
""".format(
|
""".format(
|
||||||
profit_btc=round(sum(profit_amounts), 8),
|
profit_btc=round(sum(profit_amounts), 8),
|
||||||
profit=round(sum(profits), 2),
|
profit=round(sum(profits), 2),
|
||||||
trade_count=len(trades),
|
trade_count=len(trades),
|
||||||
first_trade_date=arrow.get(trades[0].open_date).humanize(),
|
first_trade_date=arrow.get(trades[0].open_date).humanize(),
|
||||||
latest_trade_date=arrow.get(trades[-1].open_date).humanize(),
|
latest_trade_date=arrow.get(trades[-1].open_date).humanize(),
|
||||||
avg_duration=str(timedelta(seconds=sum(durations) / float(len(durations)))).split('.')[0],
|
avg_duration=str(timedelta(seconds=sum(durations) / float(len(durations)))).split('.')[0],
|
||||||
best_pair=bp_pair,
|
best_pair=bp_pair,
|
||||||
best_rate=round(bp_rate, 2),
|
best_rate=round(bp_rate, 2),
|
||||||
)
|
)
|
||||||
TelegramHandler.send_msg(markdown_msg, bot=bot)
|
send_msg(markdown_msg, bot=bot)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@authorized_only
|
|
||||||
def _start(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
|
||||||
Handler for /start.
|
|
||||||
Starts TradeThread
|
|
||||||
:param bot: telegram bot
|
|
||||||
:param update: message update
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
from main import get_instance
|
|
||||||
if get_instance().is_alive():
|
|
||||||
TelegramHandler.send_msg('*Status:* `already running`', bot=bot)
|
|
||||||
else:
|
|
||||||
get_instance(recreate=True).start()
|
|
||||||
|
|
||||||
@staticmethod
|
@authorized_only
|
||||||
@authorized_only
|
def _start(bot: Bot, update: Update) -> None:
|
||||||
def _stop(bot: Bot, update: Update) -> None:
|
"""
|
||||||
"""
|
Handler for /start.
|
||||||
Handler for /stop.
|
Starts TradeThread
|
||||||
Stops TradeThread
|
:param bot: telegram bot
|
||||||
:param bot: telegram bot
|
:param update: message update
|
||||||
:param update: message update
|
:return: None
|
||||||
:return: None
|
"""
|
||||||
"""
|
from main import get_state, State, update_state
|
||||||
from main import get_instance
|
if get_state() == State.RUNNING:
|
||||||
if get_instance().is_alive():
|
send_msg('*Status:* `already running`', bot=bot)
|
||||||
TelegramHandler.send_msg('`Stopping trader ...`', bot=bot)
|
else:
|
||||||
get_instance().stop()
|
update_state(State.RUNNING)
|
||||||
else:
|
|
||||||
TelegramHandler.send_msg('*Status:* `already stopped`', bot=bot)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@authorized_only
|
@authorized_only
|
||||||
def _forcesell(bot: Bot, update: Update) -> None:
|
def _stop(bot: Bot, update: Update) -> None:
|
||||||
"""
|
"""
|
||||||
Handler for /forcesell <id>.
|
Handler for /stop.
|
||||||
Sells the given trade at current price
|
Stops TradeThread
|
||||||
:param bot: telegram bot
|
:param bot: telegram bot
|
||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
from main import get_instance
|
from main import get_state, State, update_state
|
||||||
if not get_instance().is_alive():
|
if get_state() == State.RUNNING:
|
||||||
TelegramHandler.send_msg('`trader is not running`', bot=bot)
|
send_msg('`Stopping trader ...`', bot=bot)
|
||||||
|
update_state(State.PAUSED)
|
||||||
|
else:
|
||||||
|
send_msg('*Status:* `already stopped`', bot=bot)
|
||||||
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _forcesell(bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /forcesell <id>.
|
||||||
|
Sells the given trade at current price
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from main import get_state, State
|
||||||
|
if get_state() != State.RUNNING:
|
||||||
|
send_msg('`trader is not running`', bot=bot)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
trade_id = int(update.message.text
|
||||||
|
.replace('/forcesell', '')
|
||||||
|
.strip())
|
||||||
|
# Query for trade
|
||||||
|
trade = Trade.query.filter(and_(
|
||||||
|
Trade.id == trade_id,
|
||||||
|
Trade.is_open.is_(True)
|
||||||
|
)).first()
|
||||||
|
if not trade:
|
||||||
|
send_msg('There is no open trade with ID: `{}`'.format(trade_id))
|
||||||
return
|
return
|
||||||
|
# Get current rate
|
||||||
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
|
# Get available balance
|
||||||
|
currency = trade.pair.split('_')[1]
|
||||||
|
balance = exchange.get_balance(currency)
|
||||||
|
# Execute sell
|
||||||
|
profit = trade.exec_sell_order(current_rate, balance)
|
||||||
|
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
||||||
|
trade.exchange.name,
|
||||||
|
trade.pair.replace('_', '/'),
|
||||||
|
exchange.get_pair_detail_url(trade.pair),
|
||||||
|
trade.close_rate,
|
||||||
|
round(profit, 2)
|
||||||
|
)
|
||||||
|
logger.info(message)
|
||||||
|
send_msg(message)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
send_msg('Invalid argument. Usage: `/forcesell <trade_id>`')
|
||||||
|
logger.warning('/forcesell: Invalid argument received')
|
||||||
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _performance(bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /performance.
|
||||||
|
Shows a performance statistic from finished trades
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from main import get_state, State
|
||||||
|
if get_state() != State.RUNNING:
|
||||||
|
send_msg('`trader is not running`', bot=bot)
|
||||||
|
return
|
||||||
|
|
||||||
|
pair_rates = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
||||||
|
.filter(Trade.is_open.is_(False)) \
|
||||||
|
.group_by(Trade.pair) \
|
||||||
|
.order_by('profit_sum DESC') \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
stats = '\n'.join('{}. <code>{}\t{}%</code>'.format(i + 1, pair, round(rate, 2)) for i, (pair, rate) in enumerate(pair_rates))
|
||||||
|
|
||||||
|
message = '<b>Performance:</b>\n{}\n'.format(stats)
|
||||||
|
logger.debug(message)
|
||||||
|
send_msg(message, parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
|
|
||||||
|
@synchronized
|
||||||
|
def get_updater(config: dict) -> Updater:
|
||||||
|
"""
|
||||||
|
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=config['telegram']['token'], workers=0)
|
||||||
|
return _updater
|
||||||
|
|
||||||
|
|
||||||
|
def send_msg(msg: str, bot: Bot=None, parse_mode: ParseMode=ParseMode.MARKDOWN) -> None:
|
||||||
|
"""
|
||||||
|
Send given markdown message
|
||||||
|
:param msg: message
|
||||||
|
:param bot: alternative bot
|
||||||
|
:param parse_mode: telegram parse mode
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
if _conf['telegram'].get('enabled', False):
|
||||||
try:
|
try:
|
||||||
trade_id = int(update.message.text
|
bot = bot or get_updater(_conf).bot
|
||||||
.replace('/forcesell', '')
|
|
||||||
.strip())
|
|
||||||
# Query for trade
|
|
||||||
trade = Trade.query.filter(and_(
|
|
||||||
Trade.id == trade_id,
|
|
||||||
Trade.is_open.is_(True)
|
|
||||||
)).first()
|
|
||||||
if not trade:
|
|
||||||
TelegramHandler.send_msg('There is no open trade with ID: `{}`'.format(trade_id))
|
|
||||||
return
|
|
||||||
# Get current rate
|
|
||||||
current_rate = api_wrapper.get_ticker(trade.pair)['bid']
|
|
||||||
# Get available balance
|
|
||||||
currency = trade.pair.split('_')[1]
|
|
||||||
balance = api_wrapper.get_balance(currency)
|
|
||||||
# Execute sell
|
|
||||||
profit = trade.exec_sell_order(current_rate, balance)
|
|
||||||
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
|
||||||
trade.exchange.name,
|
|
||||||
trade.pair.replace('_', '/'),
|
|
||||||
api_wrapper.get_pair_detail_url(trade.pair),
|
|
||||||
trade.close_rate,
|
|
||||||
round(profit, 2)
|
|
||||||
)
|
|
||||||
logger.info(message)
|
|
||||||
TelegramHandler.send_msg(message)
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
TelegramHandler.send_msg('Invalid argument. Usage: `/forcesell <trade_id>`')
|
|
||||||
logger.warning('/forcesell: Invalid argument received')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@authorized_only
|
|
||||||
def _performance(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
|
||||||
Handler for /performance.
|
|
||||||
Shows a performance statistic from finished trades
|
|
||||||
:param bot: telegram bot
|
|
||||||
:param update: message update
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
from main import get_instance
|
|
||||||
if not get_instance().is_alive():
|
|
||||||
TelegramHandler.send_msg('`trader is not running`', bot=bot)
|
|
||||||
return
|
|
||||||
|
|
||||||
pair_rates = Session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
|
||||||
.filter(Trade.is_open.is_(False)) \
|
|
||||||
.group_by(Trade.pair) \
|
|
||||||
.order_by('profit_sum DESC') \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
stats = '\n'.join('{}. <code>{}\t{}%</code>'.format(i + 1, pair, round(rate, 2)) for i, (pair, rate) in enumerate(pair_rates))
|
|
||||||
|
|
||||||
message = '<b>Performance:</b>\n{}\n'.format(stats)
|
|
||||||
logger.debug(message)
|
|
||||||
TelegramHandler.send_msg(message, parse_mode=ParseMode.HTML)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@synchronized
|
|
||||||
def get_updater(config: dict) -> Updater:
|
|
||||||
"""
|
|
||||||
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=config['telegram']['token'], workers=0)
|
|
||||||
return _updater
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def listen() -> None:
|
|
||||||
"""
|
|
||||||
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),
|
|
||||||
CommandHandler('forcesell', TelegramHandler._forcesell),
|
|
||||||
CommandHandler('performance', TelegramHandler._performance),
|
|
||||||
]
|
|
||||||
for handle in handles:
|
|
||||||
TelegramHandler.get_updater(conf).dispatcher.add_handler(handle)
|
|
||||||
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: str, bot: Bot=None, parse_mode: ParseMode=ParseMode.MARKDOWN) -> None:
|
|
||||||
"""
|
|
||||||
Send given markdown message
|
|
||||||
:param msg: message
|
|
||||||
:param bot: alternative bot
|
|
||||||
:param parse_mode: telegram parse mode
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if conf['telegram'].get('enabled', False):
|
|
||||||
try:
|
try:
|
||||||
bot = bot or TelegramHandler.get_updater(conf).bot
|
bot.send_message(_conf['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||||
try:
|
except NetworkError as error:
|
||||||
bot.send_message(conf['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
# Sometimes the telegram server resets the current connection,
|
||||||
except NetworkError as error:
|
# if this is the case we send the message again.
|
||||||
# Sometimes the telegram server resets the current connection,
|
logger.warning('Got Telegram NetworkError: %s! Trying one more time.', error.message)
|
||||||
# if this is the case we send the message again.
|
bot.send_message(_conf['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||||
logger.warning('Got Telegram NetworkError: %s! Trying one more time.', error.message)
|
except Exception:
|
||||||
bot.send_message(conf['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
logger.exception('Exception occurred within Telegram API')
|
||||||
except Exception:
|
|
||||||
logger.exception('Exception occurred within Telegram API')
|
|
||||||
|
0
test/__init__.py
Normal file
0
test/__init__.py
Normal file
112
test/test_main.py
Normal file
112
test/test_main.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import os
|
||||||
|
from jsonschema import validate
|
||||||
|
|
||||||
|
import exchange
|
||||||
|
from main import create_trade, handle_trade, close_trade_if_fulfilled, init
|
||||||
|
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
|
||||||
|
},
|
||||||
|
"poloniex": {
|
||||||
|
"enabled": False,
|
||||||
|
"key": "key",
|
||||||
|
"secret": "secret",
|
||||||
|
"pair_whitelist": []
|
||||||
|
},
|
||||||
|
"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)
|
||||||
|
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.btc_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)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
validate(cls.conf, conf_schema)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
try:
|
||||||
|
os.remove('./tradesv2.dry_run.sqlite')
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
28
test/test_persistence.py
Normal file
28
test/test_persistence.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
|
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',
|
||||||
|
btc_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()
|
Loading…
Reference in New Issue
Block a user