Merge branch 'release/0.9.0'
This commit is contained in:
commit
16b0a0aaab
28
.travis.yml
Normal file
28
.travis.yml
Normal file
@ -0,0 +1,28 @@
|
||||
sudo: false
|
||||
os:
|
||||
- linux
|
||||
|
||||
language: python
|
||||
python:
|
||||
- 3.6
|
||||
- nightly
|
||||
matrix:
|
||||
allow_failures:
|
||||
- python: nightly
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- libelf-dev
|
||||
- libdw-dev
|
||||
- binutils-dev
|
||||
|
||||
install:
|
||||
- wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz
|
||||
- tar zxvf ta-lib-0.4.0-src.tar.gz
|
||||
- cd ta-lib && ./configure && sudo make && sudo make install && cd ..
|
||||
- export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
||||
- pip install -r requirements.txt
|
||||
|
||||
script:
|
||||
- python -m unittest
|
@ -6,7 +6,6 @@ RUN apt-get -y install build-essential
|
||||
RUN wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz
|
||||
RUN tar zxvf ta-lib-0.4.0-src.tar.gz
|
||||
RUN cd ta-lib && ./configure && make && make install
|
||||
RUN pip install TA-Lib
|
||||
ENV LD_LIBRARY_PATH /usr/local/lib
|
||||
|
||||
RUN mkdir -p /freqtrade
|
||||
|
20
README.md
20
README.md
@ -1,4 +1,7 @@
|
||||
# freqtrade
|
||||
|
||||
[![Build Status](https://travis-ci.org/gcarq/freqtrade.svg?branch=develop)](https://travis-ci.org/gcarq/freqtrade)
|
||||
|
||||
Simple High frequency trading bot for crypto currencies.
|
||||
Currently supported exchanges: bittrex, poloniex (partly implemented)
|
||||
|
||||
@ -11,7 +14,7 @@ and enter the telegram `token` and your `chat_id` in `config.json`
|
||||
|
||||
Persistence is achieved through sqlite.
|
||||
|
||||
##### Telegram RPC commands:
|
||||
#### Telegram RPC commands:
|
||||
* /start: Starts the trader
|
||||
* /stop: Stops the trader
|
||||
* /status: Lists all open trades
|
||||
@ -19,7 +22,7 @@ Persistence is achieved through sqlite.
|
||||
* /forcesell <trade_id>: Instantly sells the given trade (Ignoring `minimum_roi`).
|
||||
* /performance: Show performance of each finished trade grouped by pair
|
||||
|
||||
##### Config
|
||||
#### Config
|
||||
`minimal_roi` is a JSON object where the key is a duration
|
||||
in minutes and the value is the minimum ROI in percent.
|
||||
See the example below:
|
||||
@ -37,15 +40,19 @@ See the example below:
|
||||
For example value `-0.10` will cause immediate sell if the
|
||||
profit dips below -10% for a given trade. This parameter is optional.
|
||||
|
||||
`initial_state` is an optional field that defines the initial application state.
|
||||
Possible values are `running` or `stopped`. (default=`running`)
|
||||
If the value is `stopped` the bot has to be started with `/start` first.
|
||||
|
||||
The other values should be self-explanatory,
|
||||
if not feel free to raise a github issue.
|
||||
|
||||
##### Prerequisites
|
||||
#### Prerequisites
|
||||
* python3.6
|
||||
* sqlite
|
||||
* [TA-lib](https://github.com/mrjbq7/ta-lib#dependencies) binaries
|
||||
|
||||
##### Install
|
||||
#### Install
|
||||
```
|
||||
$ cd freqtrade/
|
||||
# copy example config. Dont forget to insert your api keys
|
||||
@ -56,6 +63,11 @@ $ pip install -r requirements.txt
|
||||
$ ./main.py
|
||||
```
|
||||
|
||||
#### Execute tests
|
||||
|
||||
```
|
||||
$ python -m unittest
|
||||
```
|
||||
|
||||
#### Docker
|
||||
```
|
||||
|
80
analyze.py
80
analyze.py
@ -13,13 +13,10 @@ logging.basicConfig(level=logging.DEBUG,
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ticker_dataframe(pair: str) -> DataFrame:
|
||||
def get_ticker(pair: str, minimum_date: arrow.Arrow) -> dict:
|
||||
"""
|
||||
Analyses the trend for the given pair
|
||||
:param pair: pair as str in format BTC_ETH or BTC-ETH
|
||||
:return: DataFrame
|
||||
Request ticker data from Bittrex for a given currency pair
|
||||
"""
|
||||
minimum_date = arrow.now() - timedelta(hours=6)
|
||||
url = 'https://bittrex.com/Api/v2.0/pub/market/GetTicks'
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36',
|
||||
@ -32,17 +29,26 @@ def get_ticker_dataframe(pair: str) -> DataFrame:
|
||||
data = requests.get(url, params=params, headers=headers).json()
|
||||
if not data['success']:
|
||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
||||
return data
|
||||
|
||||
data = [{
|
||||
'close': t['C'],
|
||||
'volume': t['V'],
|
||||
'open': t['O'],
|
||||
'high': t['H'],
|
||||
'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 = DataFrame(json_normalize(data))
|
||||
|
||||
def parse_ticker_dataframe(ticker: list, minimum_date: arrow.Arrow) -> DataFrame:
|
||||
"""
|
||||
Analyses the trend for the given pair
|
||||
:param pair: pair as str in format BTC_ETH or BTC-ETH
|
||||
:return: DataFrame
|
||||
"""
|
||||
df = DataFrame(ticker) \
|
||||
.drop('BV', 1) \
|
||||
.rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'}) \
|
||||
.sort_values('date')
|
||||
return df[df['date'].map(arrow.get) > minimum_date]
|
||||
|
||||
|
||||
def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Adds several different TA indicators to the given DataFrame
|
||||
"""
|
||||
dataframe['close_30_ema'] = ta.EMA(dataframe, timeperiod=30)
|
||||
dataframe['close_90_ema'] = ta.EMA(dataframe, timeperiod=90)
|
||||
|
||||
@ -60,37 +66,42 @@ def get_ticker_dataframe(pair: str) -> DataFrame:
|
||||
return dataframe
|
||||
|
||||
|
||||
def populate_trends(dataframe: DataFrame) -> DataFrame:
|
||||
def populate_buy_trend(dataframe: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Populates the trends for the given dataframe
|
||||
Based on TA indicators, populates the buy trend for the given dataframe
|
||||
:param dataframe: DataFrame
|
||||
:return: DataFrame with populated trends
|
||||
"""
|
||||
"""
|
||||
dataframe.loc[
|
||||
(dataframe['stochrsi'] < 20)
|
||||
& (dataframe['close_30_ema'] > (1 + 0.0025) * dataframe['close_60_ema']),
|
||||
'underpriced'
|
||||
] = 1
|
||||
:return: DataFrame with buy column
|
||||
"""
|
||||
dataframe.loc[
|
||||
(dataframe['stochrsi'] < 20)
|
||||
& (dataframe['macd'] > dataframe['macds'])
|
||||
& (dataframe['close'] > dataframe['sar']),
|
||||
'underpriced'
|
||||
'buy'
|
||||
] = 1
|
||||
dataframe.loc[dataframe['underpriced'] == 1, 'buy'] = dataframe['close']
|
||||
dataframe.loc[dataframe['buy'] == 1, 'buy_price'] = dataframe['close']
|
||||
return dataframe
|
||||
|
||||
|
||||
def analyze_ticker(pair: str) -> DataFrame:
|
||||
"""
|
||||
Get ticker data for given currency pair, push it to a DataFrame and
|
||||
add several TA indicators and buy signal to it
|
||||
:return DataFrame with ticker data and indicator data
|
||||
"""
|
||||
minimum_date = arrow.utcnow().shift(hours=-6)
|
||||
data = get_ticker(pair, minimum_date)
|
||||
dataframe = parse_ticker_dataframe(data['result'], minimum_date)
|
||||
dataframe = populate_indicators(dataframe)
|
||||
dataframe = populate_buy_trend(dataframe)
|
||||
return dataframe
|
||||
|
||||
def get_buy_signal(pair: str) -> bool:
|
||||
"""
|
||||
Calculates a buy signal based on StochRSI indicator
|
||||
Calculates a buy signal based several technical analysis indicators
|
||||
:param pair: pair in format BTC_ANT or BTC-ANT
|
||||
:return: True if pair is underpriced, False otherwise
|
||||
:return: True if pair is good for buying, False otherwise
|
||||
"""
|
||||
dataframe = get_ticker_dataframe(pair)
|
||||
dataframe = populate_trends(dataframe)
|
||||
dataframe = analyze_ticker(pair)
|
||||
latest = dataframe.iloc[-1]
|
||||
|
||||
# Check if dataframe is out of date
|
||||
@ -98,7 +109,7 @@ def get_buy_signal(pair: str) -> bool:
|
||||
if signal_date < arrow.now() - timedelta(minutes=10):
|
||||
return False
|
||||
|
||||
signal = latest['underpriced'] == 1
|
||||
signal = latest['buy'] == 1
|
||||
logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal)
|
||||
return signal
|
||||
|
||||
@ -123,7 +134,7 @@ def plot_dataframe(dataframe: DataFrame, pair: str) -> None:
|
||||
ax1.plot(dataframe.index.values, dataframe['close_30_ema'], label='EMA(30)')
|
||||
ax1.plot(dataframe.index.values, dataframe['close_90_ema'], label='EMA(90)')
|
||||
# ax1.plot(dataframe.index.values, dataframe['sell'], 'ro', label='sell')
|
||||
ax1.plot(dataframe.index.values, dataframe['buy'], 'bo', label='buy')
|
||||
ax1.plot(dataframe.index.values, dataframe['buy_price'], 'bo', label='buy')
|
||||
ax1.legend()
|
||||
|
||||
ax2.plot(dataframe.index.values, dataframe['macd'], label='MACD')
|
||||
@ -145,11 +156,10 @@ def plot_dataframe(dataframe: DataFrame, pair: str) -> None:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Install PYQT5==5.9 manually if you want to test this helper function
|
||||
while True:
|
||||
pair = 'BTC_ANT'
|
||||
#for pair in ['BTC_ANT', 'BTC_ETH', 'BTC_GNT', 'BTC_ETC']:
|
||||
# get_buy_signal(pair)
|
||||
dataframe = get_ticker_dataframe(pair)
|
||||
dataframe = populate_trends(dataframe)
|
||||
plot_dataframe(dataframe, pair)
|
||||
plot_dataframe(analyze_ticker(pair), pair)
|
||||
time.sleep(60)
|
||||
|
@ -35,5 +35,6 @@
|
||||
"enabled": true,
|
||||
"token": "token",
|
||||
"chat_id": "chat_id"
|
||||
}
|
||||
},
|
||||
"initial_state": "running"
|
||||
}
|
144
exchange.py
144
exchange.py
@ -4,11 +4,13 @@ from typing import List
|
||||
|
||||
from bittrex.bittrex import Bittrex
|
||||
from poloniex import Poloniex
|
||||
from wrapt import synchronized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_exchange_api = None
|
||||
# Current selected exchange
|
||||
EXCHANGE = None
|
||||
_API = None
|
||||
_CONF = {}
|
||||
|
||||
|
||||
class Exchange(enum.Enum):
|
||||
@ -16,44 +18,41 @@ class Exchange(enum.Enum):
|
||||
BITTREX = 1
|
||||
|
||||
|
||||
class ApiWrapper(object):
|
||||
def init(config: dict) -> None:
|
||||
"""
|
||||
Wrapper for exchanges.
|
||||
Currently implemented:
|
||||
* Bittrex
|
||||
* Poloniex (partly)
|
||||
"""
|
||||
def __init__(self, config: dict):
|
||||
"""
|
||||
Initializes the ApiWrapper with the given config,
|
||||
Initializes this module with the given config,
|
||||
it does basic validation whether the specified
|
||||
exchange and pairs are valid.
|
||||
:param config: dict
|
||||
:param config: config to use
|
||||
:return: None
|
||||
"""
|
||||
self.dry_run = config['dry_run']
|
||||
if self.dry_run:
|
||||
global _API, EXCHANGE
|
||||
|
||||
_CONF.update(config)
|
||||
|
||||
if config['dry_run']:
|
||||
logger.info('Instance is running with dry_run enabled')
|
||||
|
||||
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'])
|
||||
EXCHANGE = Exchange.POLONIEX
|
||||
_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'])
|
||||
EXCHANGE = Exchange.BITTREX
|
||||
_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
|
||||
markets = self.get_markets()
|
||||
for pair in config[self.exchange.name.lower()]['pair_whitelist']:
|
||||
markets = get_markets()
|
||||
for pair in config[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:
|
||||
|
||||
def buy(pair: str, rate: float, amount: float) -> str:
|
||||
"""
|
||||
Places a limit buy order.
|
||||
:param pair: Pair as str, format: BTC_ETH
|
||||
@ -61,18 +60,19 @@ class ApiWrapper(object):
|
||||
:param amount: The amount to purchase
|
||||
:return: order_id of the placed buy order
|
||||
"""
|
||||
if self.dry_run:
|
||||
pass
|
||||
elif self.exchange == Exchange.POLONIEX:
|
||||
self.api.buy(pair, rate, amount)
|
||||
if _CONF['dry_run']:
|
||||
return 'dry_run'
|
||||
elif EXCHANGE == Exchange.POLONIEX:
|
||||
_API.buy(pair, rate, amount)
|
||||
# TODO: return order id
|
||||
elif self.exchange == Exchange.BITTREX:
|
||||
data = self.api.buy_limit(pair.replace('_', '-'), amount, rate)
|
||||
elif 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(self, pair: str, rate: float, amount: float) -> str:
|
||||
|
||||
def sell(pair: str, rate: float, amount: float) -> str:
|
||||
"""
|
||||
Places a limit sell order.
|
||||
:param pair: Pair as str, format: BTC_ETH
|
||||
@ -80,49 +80,51 @@ class ApiWrapper(object):
|
||||
:param amount: The amount to sell
|
||||
:return: None
|
||||
"""
|
||||
if self.dry_run:
|
||||
pass
|
||||
elif self.exchange == Exchange.POLONIEX:
|
||||
self.api.sell(pair, rate, amount)
|
||||
if _CONF['dry_run']:
|
||||
return 'dry_run'
|
||||
elif EXCHANGE == Exchange.POLONIEX:
|
||||
_API.sell(pair, rate, amount)
|
||||
# TODO: return order id
|
||||
elif self.exchange == Exchange.BITTREX:
|
||||
data = self.api.sell_limit(pair.replace('_', '-'), amount, rate)
|
||||
elif 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(self, currency: str) -> float:
|
||||
|
||||
def get_balance(currency: str) -> float:
|
||||
"""
|
||||
Get account balance.
|
||||
:param currency: currency as str, format: BTC
|
||||
:return: float
|
||||
"""
|
||||
if self.dry_run:
|
||||
if _CONF['dry_run']:
|
||||
return 999.9
|
||||
elif self.exchange == Exchange.POLONIEX:
|
||||
data = self.api.returnBalances()
|
||||
elif EXCHANGE == Exchange.POLONIEX:
|
||||
data = _API.returnBalances()
|
||||
return float(data[currency])
|
||||
elif self.exchange == Exchange.BITTREX:
|
||||
data = self.api.get_balance(currency)
|
||||
elif 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(self, pair: str) -> dict:
|
||||
|
||||
def get_ticker(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()
|
||||
if EXCHANGE == Exchange.POLONIEX:
|
||||
data = _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('_', '-'))
|
||||
elif EXCHANGE == Exchange.BITTREX:
|
||||
data = _API.get_ticker(pair.replace('_', '-'))
|
||||
if not data['success']:
|
||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
||||
return {
|
||||
@ -131,33 +133,35 @@ class ApiWrapper(object):
|
||||
'last': float(data['result']['Last']),
|
||||
}
|
||||
|
||||
def cancel_order(self, order_id: str) -> None:
|
||||
|
||||
def cancel_order(order_id: str) -> None:
|
||||
"""
|
||||
Cancel order for given order_id
|
||||
:param order_id: id as str
|
||||
:return: None
|
||||
"""
|
||||
if self.dry_run:
|
||||
if _CONF['dry_run']:
|
||||
pass
|
||||
elif self.exchange == Exchange.POLONIEX:
|
||||
elif EXCHANGE == Exchange.POLONIEX:
|
||||
raise NotImplemented('Not implemented')
|
||||
elif self.exchange == Exchange.BITTREX:
|
||||
data = self.api.cancel(order_id)
|
||||
elif EXCHANGE == Exchange.BITTREX:
|
||||
data = _API.cancel(order_id)
|
||||
if not data['success']:
|
||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
||||
|
||||
def get_open_orders(self, pair: str) -> List[dict]:
|
||||
|
||||
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 self.dry_run:
|
||||
if _CONF['dry_run']:
|
||||
return []
|
||||
elif self.exchange == Exchange.POLONIEX:
|
||||
elif EXCHANGE == Exchange.POLONIEX:
|
||||
raise NotImplemented('Not implemented')
|
||||
elif self.exchange == Exchange.BITTREX:
|
||||
data = self.api.get_open_orders(pair.replace('_', '-'))
|
||||
elif EXCHANGE == Exchange.BITTREX:
|
||||
data = _API.get_open_orders(pair.replace('_', '-'))
|
||||
if not data['success']:
|
||||
raise RuntimeError('BITTREX: {}'.format(data['message']))
|
||||
return [{
|
||||
@ -169,39 +173,29 @@ class ApiWrapper(object):
|
||||
'remaining': entry['QuantityRemaining'],
|
||||
} for entry in data['result']]
|
||||
|
||||
def get_pair_detail_url(self, pair: str) -> str:
|
||||
|
||||
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 self.exchange == Exchange.POLONIEX:
|
||||
if EXCHANGE == Exchange.POLONIEX:
|
||||
raise NotImplemented('Not implemented')
|
||||
elif self.exchange == Exchange.BITTREX:
|
||||
elif EXCHANGE == Exchange.BITTREX:
|
||||
return 'https://bittrex.com/Market/Index?MarketName={}'.format(pair.replace('_', '-'))
|
||||
|
||||
def get_markets(self) -> List[str]:
|
||||
|
||||
def get_markets() -> List[str]:
|
||||
"""
|
||||
Returns all available markets
|
||||
:return: list of all available pairs
|
||||
"""
|
||||
if self.exchange == Exchange.POLONIEX:
|
||||
if EXCHANGE == Exchange.POLONIEX:
|
||||
# TODO: implement
|
||||
raise NotImplemented('Not implemented')
|
||||
elif self.exchange == Exchange. BITTREX:
|
||||
data = self.api.get_markets()
|
||||
elif 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']]
|
||||
|
||||
|
||||
@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
|
||||
|
232
main.py
232
main.py
@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
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 jsonschema import validate
|
||||
|
||||
import exchange
|
||||
import persistence
|
||||
from persistence import Trade
|
||||
from analyze import get_buy_signal
|
||||
from persistence import Trade, Session
|
||||
from exchange import get_exchange_api, Exchange
|
||||
from rpc.telegram import TelegramHandler
|
||||
from utils import get_conf
|
||||
from misc import CONF_SCHEMA, get_state, State, update_state
|
||||
from rpc import telegram
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
@ -22,60 +22,26 @@ logger = logging.getLogger(__name__)
|
||||
__author__ = "gcarq"
|
||||
__copyright__ = "gcarq 2017"
|
||||
__license__ = "GPLv3"
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.9.0"
|
||||
|
||||
_CONF = {}
|
||||
|
||||
|
||||
CONFIG = get_conf()
|
||||
api_wrapper = get_exchange_api(CONFIG)
|
||||
|
||||
|
||||
class TradeThread(threading.Thread):
|
||||
def __init__(self):
|
||||
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:
|
||||
def _process() -> None:
|
||||
"""
|
||||
Queries the persistence layer for open trades and handles them,
|
||||
otherwise a new trade is created.
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
# Query trades from persistence layer
|
||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||
if len(trades) < CONFIG['max_open_trades']:
|
||||
if len(trades) < _CONF['max_open_trades']:
|
||||
try:
|
||||
# Create entity and execute trade
|
||||
trade = create_trade(float(CONFIG['stake_amount']), api_wrapper.exchange)
|
||||
trade = create_trade(float(_CONF['stake_amount']), exchange.EXCHANGE)
|
||||
if trade:
|
||||
Session.add(trade)
|
||||
Trade.session.add(trade)
|
||||
else:
|
||||
logging.info('Got no buy signal...')
|
||||
except ValueError:
|
||||
@ -83,45 +49,21 @@ class TradeThread(threading.Thread):
|
||||
|
||||
for trade in trades:
|
||||
# Check if there is already an open order for this trade
|
||||
orders = api_wrapper.get_open_orders(trade.pair)
|
||||
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
|
||||
|
||||
logger.info('There is an open order for: %s', orders[0])
|
||||
else:
|
||||
# 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 this trade can be closed
|
||||
if not close_trade_if_fulfilled(trade):
|
||||
# Check if we can sell our current pair
|
||||
handle_trade(trade)
|
||||
|
||||
# Initial stopped TradeThread instance
|
||||
_instance = TradeThread()
|
||||
|
||||
|
||||
@synchronized
|
||||
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
|
||||
:return: TradeThread instance
|
||||
"""
|
||||
global _instance
|
||||
if recreate and not _instance.is_alive():
|
||||
logger.debug('Creating thread instance...')
|
||||
_instance = TradeThread()
|
||||
return _instance
|
||||
Trade.session.flush()
|
||||
except (ConnectionError, json.JSONDecodeError) as error:
|
||||
msg = 'Got {} in _process()'.format(error.__class__.__name__)
|
||||
logger.exception(msg)
|
||||
|
||||
|
||||
def close_trade_if_fulfilled(trade: Trade) -> bool:
|
||||
@ -132,27 +74,37 @@ def close_trade_if_fulfilled(trade: Trade) -> bool:
|
||||
"""
|
||||
# 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 and not trade.open_order_id:
|
||||
if trade.close_profit is not None \
|
||||
and trade.close_date is not None \
|
||||
and trade.close_rate is not None \
|
||||
and trade.open_order_id is None:
|
||||
trade.is_open = False
|
||||
Session.flush()
|
||||
logger.info('No open orders found and trade is fulfilled. Marking %s as closed ...', trade)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def execute_sell(trade: Trade, current_rate: float) -> None:
|
||||
"""
|
||||
Executes a sell for the given trade and current rate
|
||||
:param trade: Trade instance
|
||||
:param current_rate: current rate
|
||||
:return: None
|
||||
"""
|
||||
# Get available balance
|
||||
currency = trade.pair.split('_')[1]
|
||||
balance = api_wrapper.get_balance(currency)
|
||||
balance = exchange.get_balance(currency)
|
||||
|
||||
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),
|
||||
exchange.get_pair_detail_url(trade.pair),
|
||||
trade.close_rate,
|
||||
round(profit, 2)
|
||||
)
|
||||
logger.info(message)
|
||||
TelegramHandler.send_msg(message)
|
||||
telegram.send_msg(message)
|
||||
|
||||
|
||||
def handle_trade(trade: Trade) -> None:
|
||||
@ -166,39 +118,41 @@ def handle_trade(trade: Trade) -> None:
|
||||
|
||||
logger.debug('Handling open trade %s ...', trade)
|
||||
# Get current rate
|
||||
current_rate = api_wrapper.get_ticker(trade.pair)['bid']
|
||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||
current_profit = 100.0 * ((current_rate - trade.open_rate) / trade.open_rate)
|
||||
|
||||
if 'stoploss' in CONFIG and current_profit < float(CONFIG['stoploss']) * 100.0:
|
||||
if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']) * 100.0:
|
||||
logger.debug('Stop loss hit.')
|
||||
execute_sell(trade, current_rate)
|
||||
return
|
||||
|
||||
for duration, threshold in sorted(CONFIG['minimal_roi'].items()):
|
||||
for duration, threshold in sorted(_CONF['minimal_roi'].items()):
|
||||
duration, threshold = float(duration), 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(trade, current_rate)
|
||||
return
|
||||
else:
|
||||
|
||||
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit)
|
||||
except ValueError:
|
||||
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,
|
||||
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
|
||||
:param _exchange: exchange to use
|
||||
"""
|
||||
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
|
||||
if api_wrapper.get_balance(CONFIG['stake_currency']) < stake_amount:
|
||||
raise ValueError('stake amount is not fulfilled (currency={}'.format(CONFIG['stake_currency']))
|
||||
if exchange.get_balance(_CONF['stake_currency']) < stake_amount:
|
||||
raise ValueError(
|
||||
'stake amount is not fulfilled (currency={}'.format(_CONF['stake_currency'])
|
||||
)
|
||||
|
||||
# Remove currently opened and latest pairs from whitelist
|
||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||
@ -213,37 +167,91 @@ def create_trade(stake_amount: float, exchange: Exchange) -> Optional[Trade]:
|
||||
raise ValueError('No pair in whitelist')
|
||||
|
||||
# Pick pair based on StochRSI buy signals
|
||||
for p in whitelist:
|
||||
if get_buy_signal(p):
|
||||
pair = p
|
||||
for _pair in whitelist:
|
||||
if get_buy_signal(_pair):
|
||||
pair = _pair
|
||||
break
|
||||
else:
|
||||
return None
|
||||
|
||||
open_rate = api_wrapper.get_ticker(pair)['ask']
|
||||
open_rate = exchange.get_ticker(pair)['ask']
|
||||
amount = stake_amount / open_rate
|
||||
exchange = exchange
|
||||
order_id = api_wrapper.buy(pair, open_rate, amount)
|
||||
order_id = exchange.buy(pair, open_rate, amount)
|
||||
|
||||
# Create trade entity and return
|
||||
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
|
||||
exchange.name,
|
||||
_exchange.name,
|
||||
pair.replace('_', '/'),
|
||||
api_wrapper.get_pair_detail_url(pair),
|
||||
exchange.get_pair_detail_url(pair),
|
||||
open_rate
|
||||
)
|
||||
logger.info(message)
|
||||
TelegramHandler.send_msg(message)
|
||||
telegram.send_msg(message)
|
||||
return Trade(pair=pair,
|
||||
btc_amount=stake_amount,
|
||||
open_rate=open_rate,
|
||||
open_date=datetime.utcnow(),
|
||||
amount=amount,
|
||||
exchange=exchange,
|
||||
open_order_id=order_id)
|
||||
exchange=_exchange,
|
||||
open_order_id=order_id,
|
||||
is_open=True)
|
||||
|
||||
|
||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
||||
"""
|
||||
Initializes all modules and updates the config
|
||||
:param config: config as dict
|
||||
:param db_url: database connector string for sqlalchemy (Optional)
|
||||
:return: None
|
||||
"""
|
||||
# Initialize all modules
|
||||
telegram.init(config)
|
||||
persistence.init(config, db_url)
|
||||
exchange.init(config)
|
||||
|
||||
# Set initial application state
|
||||
initial_state = config.get('initial_state')
|
||||
if initial_state:
|
||||
update_state(State[initial_state.upper()])
|
||||
else:
|
||||
update_state(State.STOPPED)
|
||||
|
||||
|
||||
def app(config: dict) -> None:
|
||||
"""
|
||||
Main function which handles the application state
|
||||
:param config: config as dict
|
||||
:return: None
|
||||
"""
|
||||
logger.info('Starting freqtrade %s', __version__)
|
||||
init(config)
|
||||
try:
|
||||
old_state = get_state()
|
||||
logger.info('Initial State: %s', old_state)
|
||||
telegram.send_msg('*Status:* `{}`'.format(old_state.name.lower()))
|
||||
while True:
|
||||
new_state = get_state()
|
||||
# Log state transition
|
||||
if new_state != old_state:
|
||||
telegram.send_msg('*Status:* `{}`'.format(new_state.name.lower()))
|
||||
logging.info('Changing state to: %s', new_state.name)
|
||||
|
||||
if new_state == State.STOPPED:
|
||||
time.sleep(1)
|
||||
elif new_state == State.RUNNING:
|
||||
_process()
|
||||
# We need to sleep here because otherwise we would run into bittrex rate limit
|
||||
time.sleep(25)
|
||||
old_state = new_state
|
||||
except RuntimeError:
|
||||
telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc()))
|
||||
logger.exception('RuntimeError. Trader stopped!')
|
||||
finally:
|
||||
telegram.send_msg('*Status:* `Trader has stopped`')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info('Starting freqtrade %s', __version__)
|
||||
TelegramHandler.listen()
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
with open('config.json') as file:
|
||||
_CONF = json.load(file)
|
||||
validate(_CONF, CONF_SCHEMA)
|
||||
app(_CONF)
|
||||
|
@ -1,16 +1,39 @@
|
||||
import json
|
||||
import logging
|
||||
import enum
|
||||
|
||||
from jsonschema import validate
|
||||
from wrapt import synchronized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cur_conf = None
|
||||
class State(enum.Enum):
|
||||
RUNNING = 0
|
||||
STOPPED = 1
|
||||
|
||||
|
||||
# Current application state
|
||||
_STATE = State.STOPPED
|
||||
|
||||
|
||||
@synchronized
|
||||
def update_state(state: State) -> None:
|
||||
"""
|
||||
Updates the application state
|
||||
:param state: new state
|
||||
:return: None
|
||||
"""
|
||||
global _STATE
|
||||
_STATE = state
|
||||
|
||||
|
||||
@synchronized
|
||||
def get_state() -> State:
|
||||
"""
|
||||
Gets the current application state
|
||||
:return:
|
||||
"""
|
||||
return _STATE
|
||||
|
||||
|
||||
# Required json-schema for user specified config
|
||||
_conf_schema = {
|
||||
CONF_SCHEMA = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'max_open_trades': {'type': 'integer', 'minimum': 1},
|
||||
@ -35,7 +58,8 @@ _conf_schema = {
|
||||
'chat_id': {'type': 'string'},
|
||||
},
|
||||
'required': ['enabled', 'token', 'chat_id']
|
||||
}
|
||||
},
|
||||
'initial_state': {'type': 'string', 'enum': ['running', 'stopped']},
|
||||
},
|
||||
'definitions': {
|
||||
'exchange': {
|
||||
@ -66,18 +90,3 @@ _conf_schema = {
|
||||
'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
|
@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
@ -7,26 +8,42 @@ from sqlalchemy.orm.session import sessionmaker
|
||||
|
||||
from sqlalchemy.types import Enum
|
||||
|
||||
from exchange import Exchange, get_exchange_api
|
||||
from utils import get_conf
|
||||
import exchange
|
||||
|
||||
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))
|
||||
Session = session()
|
||||
_CONF = {}
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
||||
"""
|
||||
Initializes this module with the given config,
|
||||
registers all known command handlers
|
||||
and starts polling for message updates
|
||||
:param config: config to use
|
||||
:param db_url: database connector string for sqlalchemy (Optional)
|
||||
:return: None
|
||||
"""
|
||||
_CONF.update(config)
|
||||
if not db_url:
|
||||
if _CONF.get('dry_run', False):
|
||||
db_url = 'sqlite:///tradesv2.dry_run.sqlite'
|
||||
else:
|
||||
db_url = 'sqlite:///tradesv2.sqlite'
|
||||
|
||||
engine = create_engine(db_url, 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)
|
||||
|
||||
|
||||
class Trade(Base):
|
||||
__tablename__ = 'trades'
|
||||
query = session.query_property()
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
exchange = Column(Enum(Exchange), nullable=False)
|
||||
exchange = Column(Enum(exchange.Exchange), nullable=False)
|
||||
pair = Column(String, nullable=False)
|
||||
is_open = Column(Boolean, nullable=False, default=True)
|
||||
open_rate = Column(Float, nullable=False)
|
||||
@ -39,12 +56,16 @@ class Trade(Base):
|
||||
open_order_id = Column(String)
|
||||
|
||||
def __repr__(self):
|
||||
if self.is_open:
|
||||
open_since = 'closed'
|
||||
else:
|
||||
open_since = round((datetime.utcnow() - self.open_date).total_seconds() / 60, 2)
|
||||
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)
|
||||
open_since
|
||||
)
|
||||
|
||||
def exec_sell_order(self, rate: float, amount: float) -> float:
|
||||
@ -57,12 +78,12 @@ class Trade(Base):
|
||||
profit = 100 * ((rate - self.open_rate) / self.open_rate)
|
||||
|
||||
# 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_profit = profit
|
||||
self.close_date = datetime.utcnow()
|
||||
self.open_order_id = order_id
|
||||
Session.flush()
|
||||
return profit
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
# Flush changes
|
||||
Trade.session.flush()
|
||||
return profit
|
||||
|
@ -8,8 +8,8 @@ urllib3==1.22
|
||||
wrapt==1.10.11
|
||||
pandas==0.20.3
|
||||
matplotlib==2.0.2
|
||||
PYQT5==5.9
|
||||
scikit-learn==0.19.0
|
||||
scipy==0.19.1
|
||||
jsonschema==2.6.0
|
||||
TA-Lib==0.4.10
|
||||
#PYQT5==5.9
|
@ -0,0 +1 @@
|
||||
from . import telegram
|
234
rpc/telegram.py
234
rpc/telegram.py
@ -3,15 +3,15 @@ from datetime import timedelta
|
||||
from typing import Callable, Any
|
||||
|
||||
import arrow
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy import and_, func, text
|
||||
from telegram.error import NetworkError
|
||||
from telegram.ext import CommandHandler, Updater
|
||||
from telegram import ParseMode, Bot, Update
|
||||
from wrapt import synchronized
|
||||
|
||||
from persistence import Trade, Session
|
||||
from exchange import get_exchange_api
|
||||
from utils import get_conf
|
||||
from misc import get_state, State, update_state
|
||||
from persistence import Trade
|
||||
|
||||
import exchange
|
||||
|
||||
# Remove noisy log messages
|
||||
logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
||||
@ -19,9 +19,43 @@ logging.getLogger('telegram').setLevel(logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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
|
||||
"""
|
||||
global _updater
|
||||
_updater = Updater(token=config['telegram']['token'], workers=0)
|
||||
|
||||
_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:
|
||||
_updater.dispatcher.add_handler(handle)
|
||||
_updater.start_polling(
|
||||
clean=True,
|
||||
bootstrap_retries=3,
|
||||
timeout=30,
|
||||
read_latency=60,
|
||||
)
|
||||
logger.info(
|
||||
'rpc.telegram is listening for following commands: %s',
|
||||
[h.command for h in handles]
|
||||
)
|
||||
|
||||
|
||||
def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]:
|
||||
@ -31,11 +65,12 @@ def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[
|
||||
:return: decorated function
|
||||
"""
|
||||
def wrapper(*args, **kwargs):
|
||||
bot, update = args[0], args[1]
|
||||
bot, update = kwargs.get('bot') or args[0], kwargs.get('update') or args[1]
|
||||
|
||||
if not isinstance(bot, Bot) or not isinstance(update, Update):
|
||||
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:
|
||||
logger.info('Executing handler: %s for chat_id: %s', command_handler.__name__, chat_id)
|
||||
return command_handler(*args, **kwargs)
|
||||
@ -44,10 +79,8 @@ def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[
|
||||
return wrapper
|
||||
|
||||
|
||||
class TelegramHandler(object):
|
||||
@staticmethod
|
||||
@authorized_only
|
||||
def _status(bot: Bot, update: Update) -> None:
|
||||
@authorized_only
|
||||
def _status(bot: Bot, update: Update) -> None:
|
||||
"""
|
||||
Handler for /status.
|
||||
Returns the current TradeThread status
|
||||
@ -57,19 +90,22 @@ class TelegramHandler(object):
|
||||
"""
|
||||
# Fetch open trade
|
||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||
from main import get_instance
|
||||
if not get_instance().is_alive():
|
||||
TelegramHandler.send_msg('*Status:* `trader is not running`', bot=bot)
|
||||
if get_state() != State.RUNNING:
|
||||
send_msg('*Status:* `trader is not running`', bot=bot)
|
||||
elif not trades:
|
||||
TelegramHandler.send_msg('*Status:* `no active order`', bot=bot)
|
||||
send_msg('*Status:* `no active order`', bot=bot)
|
||||
else:
|
||||
for trade in trades:
|
||||
# calculate profit and send message to user
|
||||
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)
|
||||
orders = api_wrapper.get_open_orders(trade.pair)
|
||||
orders = exchange.get_open_orders(trade.pair)
|
||||
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
||||
order = orders[0] if orders else None
|
||||
|
||||
fmt_close_profit = '{:.2f}%'.format(
|
||||
round(trade.close_profit, 2)
|
||||
) if trade.close_profit else None
|
||||
message = """
|
||||
*Trade ID:* `{trade_id}`
|
||||
*Current Pair:* [{pair}]({market_url})
|
||||
@ -79,26 +115,26 @@ class TelegramHandler(object):
|
||||
*Close Rate:* `{close_rate}`
|
||||
*Current Rate:* `{current_rate}`
|
||||
*Close Profit:* `{close_profit}`
|
||||
*Current Profit:* `{current_profit}%`
|
||||
*Current Profit:* `{current_profit:.2f}%`
|
||||
*Open Order:* `{open_order}`
|
||||
""".format(
|
||||
trade_id=trade.id,
|
||||
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(),
|
||||
open_rate=trade.open_rate,
|
||||
close_rate=trade.close_rate,
|
||||
current_rate=current_rate,
|
||||
amount=round(trade.amount, 8),
|
||||
close_profit='{}%'.format(round(trade.close_profit, 2)) if trade.close_profit else None,
|
||||
close_profit=fmt_close_profit,
|
||||
current_profit=round(current_profit, 2),
|
||||
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:
|
||||
|
||||
@authorized_only
|
||||
def _profit(bot: Bot, update: Update) -> None:
|
||||
"""
|
||||
Handler for /profit.
|
||||
Returns a cumulative profit statistics.
|
||||
@ -118,25 +154,30 @@ class TelegramHandler(object):
|
||||
profit = trade.close_profit
|
||||
else:
|
||||
# Get current rate
|
||||
current_rate = api_wrapper.get_ticker(trade.pair)['bid']
|
||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||
profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
||||
|
||||
profit_amounts.append((profit / 100) * trade.btc_amount)
|
||||
profits.append(profit)
|
||||
|
||||
bp_pair, bp_rate = Session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
||||
best_pair = 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') \
|
||||
.order_by(text('profit_sum DESC')) \
|
||||
.first()
|
||||
|
||||
if not best_pair:
|
||||
send_msg('*Status:* `no closed trade`', bot=bot)
|
||||
return
|
||||
|
||||
bp_pair, bp_rate = best_pair
|
||||
markdown_msg = """
|
||||
*ROI:* `{profit_btc} ({profit}%)`
|
||||
*ROI:* `{profit_btc:.2f} ({profit:.2f}%)`
|
||||
*Trade Count:* `{trade_count}`
|
||||
*First Trade opened:* `{first_trade_date}`
|
||||
*Latest Trade opened:* `{latest_trade_date}`
|
||||
*Avg. Duration:* `{avg_duration}`
|
||||
*Best Performing:* `{best_pair}: {best_rate}%`
|
||||
*Best Performing:* `{best_pair}: {best_rate:.2f}%`
|
||||
""".format(
|
||||
profit_btc=round(sum(profit_amounts), 8),
|
||||
profit=round(sum(profits), 2),
|
||||
@ -147,11 +188,11 @@ class TelegramHandler(object):
|
||||
best_pair=bp_pair,
|
||||
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:
|
||||
|
||||
@authorized_only
|
||||
def _start(bot: Bot, update: Update) -> None:
|
||||
"""
|
||||
Handler for /start.
|
||||
Starts TradeThread
|
||||
@ -159,15 +200,14 @@ class TelegramHandler(object):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
from main import get_instance
|
||||
if get_instance().is_alive():
|
||||
TelegramHandler.send_msg('*Status:* `already running`', bot=bot)
|
||||
if get_state() == State.RUNNING:
|
||||
send_msg('*Status:* `already running`', bot=bot)
|
||||
else:
|
||||
get_instance(recreate=True).start()
|
||||
update_state(State.RUNNING)
|
||||
|
||||
@staticmethod
|
||||
@authorized_only
|
||||
def _stop(bot: Bot, update: Update) -> None:
|
||||
|
||||
@authorized_only
|
||||
def _stop(bot: Bot, update: Update) -> None:
|
||||
"""
|
||||
Handler for /stop.
|
||||
Stops TradeThread
|
||||
@ -175,16 +215,15 @@ class TelegramHandler(object):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
from main import get_instance
|
||||
if get_instance().is_alive():
|
||||
TelegramHandler.send_msg('`Stopping trader ...`', bot=bot)
|
||||
get_instance().stop()
|
||||
if get_state() == State.RUNNING:
|
||||
send_msg('`Stopping trader ...`', bot=bot)
|
||||
update_state(State.STOPPED)
|
||||
else:
|
||||
TelegramHandler.send_msg('*Status:* `already stopped`', bot=bot)
|
||||
send_msg('*Status:* `already stopped`', bot=bot)
|
||||
|
||||
@staticmethod
|
||||
@authorized_only
|
||||
def _forcesell(bot: Bot, update: Update) -> None:
|
||||
|
||||
@authorized_only
|
||||
def _forcesell(bot: Bot, update: Update) -> None:
|
||||
"""
|
||||
Handler for /forcesell <id>.
|
||||
Sells the given trade at current price
|
||||
@ -192,9 +231,8 @@ class TelegramHandler(object):
|
||||
: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)
|
||||
if get_state() != State.RUNNING:
|
||||
send_msg('`trader is not running`', bot=bot)
|
||||
return
|
||||
|
||||
try:
|
||||
@ -207,32 +245,32 @@ class TelegramHandler(object):
|
||||
Trade.is_open.is_(True)
|
||||
)).first()
|
||||
if not trade:
|
||||
TelegramHandler.send_msg('There is no open trade with ID: `{}`'.format(trade_id))
|
||||
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']
|
||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||
# Get available balance
|
||||
currency = trade.pair.split('_')[1]
|
||||
balance = api_wrapper.get_balance(currency)
|
||||
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('_', '/'),
|
||||
api_wrapper.get_pair_detail_url(trade.pair),
|
||||
exchange.get_pair_detail_url(trade.pair),
|
||||
trade.close_rate,
|
||||
round(profit, 2)
|
||||
)
|
||||
logger.info(message)
|
||||
TelegramHandler.send_msg(message)
|
||||
send_msg(message)
|
||||
|
||||
except ValueError:
|
||||
TelegramHandler.send_msg('Invalid argument. Usage: `/forcesell <trade_id>`')
|
||||
send_msg('Invalid argument. Usage: `/forcesell <trade_id>`')
|
||||
logger.warning('/forcesell: Invalid argument received')
|
||||
|
||||
@staticmethod
|
||||
@authorized_only
|
||||
def _performance(bot: Bot, update: Update) -> None:
|
||||
|
||||
@authorized_only
|
||||
def _performance(bot: Bot, update: Update) -> None:
|
||||
"""
|
||||
Handler for /performance.
|
||||
Shows a performance statistic from finished trades
|
||||
@ -240,63 +278,28 @@ class TelegramHandler(object):
|
||||
: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)
|
||||
if get_state() != State.RUNNING:
|
||||
send_msg('`trader is not running`', bot=bot)
|
||||
return
|
||||
pair_rates = Session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
||||
|
||||
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') \
|
||||
.order_by(text('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))
|
||||
stats = '\n'.join('{index}. <code>{pair}\t{profit:.2f}%</code>'.format(
|
||||
index=i + 1,
|
||||
pair=pair,
|
||||
profit=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)
|
||||
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:
|
||||
def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None:
|
||||
"""
|
||||
Send given markdown message
|
||||
:param msg: message
|
||||
@ -304,15 +307,18 @@ class TelegramHandler(object):
|
||||
:param parse_mode: telegram parse mode
|
||||
:return: None
|
||||
"""
|
||||
if conf['telegram'].get('enabled', False):
|
||||
if _CONF['telegram'].get('enabled', False):
|
||||
try:
|
||||
bot = bot or TelegramHandler.get_updater(conf).bot
|
||||
bot = bot or _updater.bot
|
||||
try:
|
||||
bot.send_message(conf['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||
bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||
except NetworkError as error:
|
||||
# Sometimes the telegram server resets the current connection,
|
||||
# if this is the case we send the message again.
|
||||
logger.warning('Got Telegram NetworkError: %s! Trying one more time.', error.message)
|
||||
bot.send_message(conf['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||
logger.warning(
|
||||
'Got Telegram NetworkError: %s! Trying one more time.',
|
||||
error.message
|
||||
)
|
||||
bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||
except Exception:
|
||||
logger.exception('Exception occurred within Telegram API')
|
||||
|
0
test/__init__.py
Normal file
0
test/__init__.py
Normal file
49
test/test_analyze.py
Normal file
49
test/test_analyze.py
Normal file
@ -0,0 +1,49 @@
|
||||
# pragma pylint: disable=missing-docstring
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pandas import DataFrame
|
||||
import arrow
|
||||
from analyze import parse_ticker_dataframe, populate_buy_trend, populate_indicators, analyze_ticker, get_buy_signal
|
||||
|
||||
RESULT_BITTREX = {
|
||||
'success': True,
|
||||
'message': '',
|
||||
'result': [
|
||||
{'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 22.17210568, 'T': '2017-08-30T10:40:00', 'BV': 0.01448082},
|
||||
{'O': 0.00066194, 'H': 0.00066195, 'L': 0.00066194, 'C': 0.00066195, 'V': 33.4727437, 'T': '2017-08-30T10:34:00', 'BV': 0.02215696},
|
||||
{'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 53.85127609, 'T': '2017-08-30T10:37:00', 'BV': 0.0351708},
|
||||
{'O': 0.00066194, 'H': 0.00066194, 'L': 0.00065311, 'C': 0.00065311, 'V': 46.29210665, 'T': '2017-08-30T10:42:00', 'BV': 0.03063118},
|
||||
]
|
||||
}
|
||||
|
||||
class TestAnalyze(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.result = parse_ticker_dataframe(RESULT_BITTREX['result'], arrow.get('2017-08-30T10:00:00'))
|
||||
|
||||
def test_1_dataframe_has_correct_columns(self):
|
||||
self.assertEqual(self.result.columns.tolist(),
|
||||
['close', 'high', 'low', 'open', 'date', 'volume'])
|
||||
|
||||
def test_2_orders_by_date(self):
|
||||
self.assertEqual(self.result['date'].tolist(),
|
||||
['2017-08-30T10:34:00',
|
||||
'2017-08-30T10:37:00',
|
||||
'2017-08-30T10:40:00',
|
||||
'2017-08-30T10:42:00'])
|
||||
|
||||
def test_3_populates_buy_trend(self):
|
||||
dataframe = populate_buy_trend(populate_indicators(self.result))
|
||||
self.assertTrue('buy' in dataframe.columns)
|
||||
self.assertTrue('buy_price' in dataframe.columns)
|
||||
|
||||
def test_4_returns_latest_buy_signal(self):
|
||||
buydf = DataFrame([{'buy': 1, 'date': arrow.utcnow()}])
|
||||
with patch('analyze.analyze_ticker', return_value=buydf):
|
||||
self.assertEqual(get_buy_signal('BTC-ETH'), True)
|
||||
buydf = DataFrame([{'buy': 0, 'date': arrow.utcnow()}])
|
||||
with patch('analyze.analyze_ticker', return_value=buydf):
|
||||
self.assertEqual(get_buy_signal('BTC-ETH'), False)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
105
test/test_main.py
Normal file
105
test/test_main.py
Normal file
@ -0,0 +1,105 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
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, 'sqlite://')
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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()
|
198
test/test_telegram.py
Normal file
198
test/test_telegram.py
Normal file
@ -0,0 +1,198 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime
|
||||
|
||||
from jsonschema import validate
|
||||
from telegram import Bot, Update, Message, Chat
|
||||
|
||||
import exchange
|
||||
from main import init, create_trade
|
||||
from misc import CONF_SCHEMA, update_state, State, get_state
|
||||
from persistence import Trade
|
||||
from rpc.telegram import _status, _profit, _forcesell, _performance, _start, _stop
|
||||
|
||||
|
||||
class MagicBot(MagicMock, Bot):
|
||||
pass
|
||||
|
||||
|
||||
class TestTelegram(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": "0"
|
||||
},
|
||||
"initial_state": "running"
|
||||
}
|
||||
|
||||
def test_1_status_handle(self):
|
||||
with patch.dict('main._CONF', self.conf):
|
||||
with patch('main.get_buy_signal', side_effect=lambda _: True):
|
||||
msg_mock = MagicMock()
|
||||
with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock):
|
||||
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, 'sqlite://')
|
||||
|
||||
# Create some test data
|
||||
trade = create_trade(15.0, exchange.Exchange.BITTREX)
|
||||
self.assertTrue(trade)
|
||||
Trade.session.add(trade)
|
||||
Trade.session.flush()
|
||||
|
||||
_status(bot=MagicBot(), update=self.update)
|
||||
self.assertEqual(msg_mock.call_count, 2)
|
||||
self.assertIn('[BTC_ETH]', msg_mock.call_args_list[-1][0][0])
|
||||
|
||||
def test_2_profit_handle(self):
|
||||
with patch.dict('main._CONF', self.conf):
|
||||
with patch('main.get_buy_signal', side_effect=lambda _: True):
|
||||
msg_mock = MagicMock()
|
||||
with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock):
|
||||
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, 'sqlite://')
|
||||
|
||||
# Create some test data
|
||||
trade = create_trade(15.0, exchange.Exchange.BITTREX)
|
||||
self.assertTrue(trade)
|
||||
trade.close_rate = 0.07256061
|
||||
trade.close_profit = 100.00
|
||||
trade.close_date = datetime.utcnow()
|
||||
trade.open_order_id = None
|
||||
trade.is_open = False
|
||||
Trade.session.add(trade)
|
||||
Trade.session.flush()
|
||||
|
||||
_profit(bot=MagicBot(), update=self.update)
|
||||
self.assertEqual(msg_mock.call_count, 2)
|
||||
self.assertIn('(100.00%)', msg_mock.call_args_list[-1][0][0])
|
||||
|
||||
def test_3_forcesell_handle(self):
|
||||
with patch.dict('main._CONF', self.conf):
|
||||
with patch('main.get_buy_signal', side_effect=lambda _: True):
|
||||
msg_mock = MagicMock()
|
||||
with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock):
|
||||
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, 'sqlite://')
|
||||
|
||||
# Create some test data
|
||||
trade = create_trade(15.0, exchange.Exchange.BITTREX)
|
||||
self.assertTrue(trade)
|
||||
Trade.session.add(trade)
|
||||
Trade.session.flush()
|
||||
|
||||
self.update.message.text = '/forcesell 1'
|
||||
_forcesell(bot=MagicBot(), update=self.update)
|
||||
|
||||
self.assertEqual(msg_mock.call_count, 2)
|
||||
self.assertIn('Selling [BTC/ETH]', msg_mock.call_args_list[-1][0][0])
|
||||
self.assertIn('0.072561', msg_mock.call_args_list[-1][0][0])
|
||||
|
||||
def test_4_performance_handle(self):
|
||||
with patch.dict('main._CONF', self.conf):
|
||||
with patch('main.get_buy_signal', side_effect=lambda _: True):
|
||||
msg_mock = MagicMock()
|
||||
with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock):
|
||||
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, 'sqlite://')
|
||||
|
||||
# Create some test data
|
||||
trade = create_trade(15.0, exchange.Exchange.BITTREX)
|
||||
self.assertTrue(trade)
|
||||
trade.close_rate = 0.07256061
|
||||
trade.close_profit = 100.00
|
||||
trade.close_date = datetime.utcnow()
|
||||
trade.open_order_id = None
|
||||
trade.is_open = False
|
||||
Trade.session.add(trade)
|
||||
Trade.session.flush()
|
||||
|
||||
_performance(bot=MagicBot(), update=self.update)
|
||||
self.assertEqual(msg_mock.call_count, 2)
|
||||
self.assertIn('Performance', msg_mock.call_args_list[-1][0][0])
|
||||
self.assertIn('BTC_ETH 100.00%', msg_mock.call_args_list[-1][0][0])
|
||||
|
||||
def test_5_start_handle(self):
|
||||
with patch.dict('main._CONF', self.conf):
|
||||
msg_mock = MagicMock()
|
||||
with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock):
|
||||
init(self.conf, 'sqlite://')
|
||||
|
||||
update_state(State.STOPPED)
|
||||
self.assertEqual(get_state(), State.STOPPED)
|
||||
_start(bot=MagicBot(), update=self.update)
|
||||
self.assertEqual(get_state(), State.RUNNING)
|
||||
self.assertEqual(msg_mock.call_count, 0)
|
||||
|
||||
def test_6_stop_handle(self):
|
||||
with patch.dict('main._CONF', self.conf):
|
||||
msg_mock = MagicMock()
|
||||
with patch.multiple('main.telegram', _CONF=self.conf, init=MagicMock(), send_msg=msg_mock):
|
||||
init(self.conf, 'sqlite://')
|
||||
|
||||
update_state(State.RUNNING)
|
||||
self.assertEqual(get_state(), State.RUNNING)
|
||||
_stop(bot=MagicBot(), update=self.update)
|
||||
self.assertEqual(get_state(), State.STOPPED)
|
||||
self.assertEqual(msg_mock.call_count, 1)
|
||||
self.assertIn('Stopping trader', msg_mock.call_args_list[0][0][0])
|
||||
|
||||
def setUp(self):
|
||||
self.update = Update(0)
|
||||
self.update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0))
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
validate(cls.conf, CONF_SCHEMA)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
Loading…
Reference in New Issue
Block a user