Compare commits
130 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
b115963a70 | ||
|
2e953a937d | ||
|
4e05691cab | ||
|
b5f58724a0 | ||
|
b83309b55d | ||
|
e8101a6da5 | ||
|
dd9cb008fb | ||
|
81f7172c4a | ||
|
bab59fbacd | ||
|
0f0b10b6cc | ||
|
8e68c5358e | ||
|
660f01b514 | ||
|
13537e3ce4 | ||
|
2963a90008 | ||
|
15b20b83fa | ||
|
1c3c316e45 | ||
|
517879382b | ||
|
bcd3340a80 | ||
|
12ae1e111e | ||
|
d3b3370f23 | ||
|
8f817a3634 | ||
|
cf79b15651 | ||
|
a4284351e3 | ||
|
906caf329b | ||
|
3db13fae13 | ||
|
274972f7af | ||
|
83fd27e031 | ||
|
3126dcfcea | ||
|
72aec6c320 | ||
|
b709ccbf53 | ||
|
7e99b13742 | ||
|
8b464033ff | ||
|
93c525a8fa | ||
|
54b15c1556 | ||
|
029f32af63 | ||
|
de13df6ede | ||
|
0de211674d | ||
|
f7a27c156c | ||
|
98f11fc7bb | ||
|
013e13e546 | ||
|
6ff26c561a | ||
|
c81358c291 | ||
|
ed34d9f22f | ||
|
ee05561ef3 | ||
|
69ae99406a | ||
|
0cfbb56b6c | ||
|
8960373f1c | ||
|
349a91bd92 | ||
|
991b43b7e5 | ||
|
a0fa6abcdc | ||
|
86501b43c0 | ||
|
80592970e9 | ||
|
567ed4ecda | ||
|
fafbb0abfe | ||
|
0f1a36b8e9 | ||
|
31c03cdce1 | ||
|
e01c85bb3a | ||
|
a1b91ad1ea | ||
|
6ce6018bb7 | ||
|
18eec0f4d4 | ||
|
32327c45c2 | ||
|
ba485fe2b2 | ||
|
f8084b117e | ||
|
abdddd5193 | ||
|
8eeb02e592 | ||
|
8555271102 | ||
|
d921bae75e | ||
|
a1388ef296 | ||
|
ddc7c94a1d | ||
|
e36444df27 | ||
|
0395c92260 | ||
|
f03395b90d | ||
|
20d5628786 | ||
|
57e089efd3 | ||
|
fbbde9de25 | ||
|
3d42b9fd75 | ||
|
adfae9e75c | ||
|
117dfbb563 | ||
|
e66dc8b027 | ||
|
ae0b49f532 | ||
|
a37ea13fd1 | ||
|
cc29126d61 | ||
|
810f2f9243 | ||
|
60e651cb4c | ||
|
472ce8566d | ||
|
27ac15f298 | ||
|
d12dba16db | ||
|
0f1d114c03 | ||
|
3e7700e9ac | ||
|
60615c232c | ||
|
3884cfb809 | ||
|
caa6e22e53 | ||
|
19f6ff330c | ||
|
8fdd127f72 | ||
|
0a5eba64e2 | ||
|
b82c4444b2 | ||
|
95a17b8f98 | ||
|
325f72fd91 | ||
|
a237225683 | ||
|
29b173f4e7 | ||
|
50a979161c | ||
|
264d71e29e | ||
|
a873688a44 | ||
|
7cc8533b8e | ||
|
04342acff1 | ||
|
c37df0e70d | ||
|
460dfa1031 | ||
|
08a1d3ca1d | ||
|
1daeed4a52 | ||
|
99724e2458 | ||
|
cd18629433 | ||
|
41510fdb32 | ||
|
9cb249610a | ||
|
543857ddb2 | ||
|
1e5b0e8726 | ||
|
0d0d822904 | ||
|
9ff4a7b205 | ||
|
0e96197a94 | ||
|
9b9d0250f7 | ||
|
4a35676794 | ||
|
465c91b9a9 | ||
|
60249af04c | ||
|
c3653dc417 | ||
|
3d61095ba4 | ||
|
7a0be94cde | ||
|
fad6427078 | ||
|
4dfde7f9a2 | ||
|
361bdd20d3 | ||
|
8bdace68f6 | ||
|
0e1eb20781 |
@@ -1,2 +1,5 @@
|
|||||||
[run]
|
[run]
|
||||||
omit = freqtrade/tests/*
|
omit =
|
||||||
|
scripts/*
|
||||||
|
freqtrade/tests/*
|
||||||
|
freqtrade/vendor/*
|
@@ -1,2 +1,3 @@
|
|||||||
[BASIC]
|
[BASIC]
|
||||||
good-names=logger
|
good-names=logger
|
||||||
|
ignore=vendor
|
@@ -4,6 +4,9 @@ os:
|
|||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- 3.6
|
- 3.6
|
||||||
|
env:
|
||||||
|
- BACKTEST=
|
||||||
|
- BACKTEST=true
|
||||||
addons:
|
addons:
|
||||||
apt:
|
apt:
|
||||||
packages:
|
packages:
|
||||||
|
54
README.md
54
README.md
@@ -16,15 +16,19 @@ and enter the telegram `token` and your `chat_id` in `config.json`
|
|||||||
|
|
||||||
Persistence is achieved through sqlite.
|
Persistence is achieved through sqlite.
|
||||||
|
|
||||||
#### Telegram RPC commands:
|
### Telegram RPC commands:
|
||||||
* /start: Starts the trader
|
* /start: Starts the trader
|
||||||
* /stop: Stops the trader
|
* /stop: Stops the trader
|
||||||
* /status: Lists all open trades
|
* /status [table]: Lists all open trades
|
||||||
|
* /count: Displays number of open trades
|
||||||
* /profit: Lists cumulative profit from all finished trades
|
* /profit: Lists cumulative profit from all finished trades
|
||||||
* /forcesell <trade_id>: Instantly sells the given trade (Ignoring `minimum_roi`).
|
* /forcesell <trade_id>|all: Instantly sells the given trade (Ignoring `minimum_roi`).
|
||||||
* /performance: Show performance of each finished trade grouped by pair
|
* /performance: Show performance of each finished trade grouped by pair
|
||||||
|
* /balance: Show account balance per currency
|
||||||
|
* /help: Show help message
|
||||||
|
* /version: Show version
|
||||||
|
|
||||||
#### Config
|
### Config
|
||||||
`minimal_roi` is a JSON object where the key is a duration
|
`minimal_roi` is a JSON object where the key is a duration
|
||||||
in minutes and the value is the minimum ROI in percent.
|
in minutes and the value is the minimum ROI in percent.
|
||||||
See the example below:
|
See the example below:
|
||||||
@@ -53,12 +57,18 @@ end up paying more then would probably have been necessary.
|
|||||||
The other values should be self-explanatory,
|
The other values should be self-explanatory,
|
||||||
if not feel free to raise a github issue.
|
if not feel free to raise a github issue.
|
||||||
|
|
||||||
#### Prerequisites
|
### Prerequisites
|
||||||
* python3.6
|
* python3.6
|
||||||
* sqlite
|
* sqlite
|
||||||
* [TA-lib](https://github.com/mrjbq7/ta-lib#dependencies) binaries
|
* [TA-lib](https://github.com/mrjbq7/ta-lib#dependencies) binaries
|
||||||
|
|
||||||
#### Install
|
### Install
|
||||||
|
|
||||||
|
#### Arch Linux
|
||||||
|
|
||||||
|
Use your favorite AUR helper and install `python-freqtrade-git`.
|
||||||
|
|
||||||
|
#### Manually
|
||||||
|
|
||||||
`master` branch contains the latest stable release.
|
`master` branch contains the latest stable release.
|
||||||
|
|
||||||
@@ -75,18 +85,9 @@ $ pip install -e .
|
|||||||
$ ./freqtrade/main.py
|
$ ./freqtrade/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
There is also an [article](https://www.sales4k.com/blockchain/high-frequency-trading-bot-tutorial/) about how to setup the bot (thanks [@gurghet](https://github.com/gurghet)).
|
There is also an [article](https://www.sales4k.com/blockchain/high-frequency-trading-bot-tutorial/) about how to setup the bot (thanks [@gurghet](https://github.com/gurghet)).*
|
||||||
|
|
||||||
#### Execute tests
|
\* *Note:* that article was written for an earlier version, so it may be outdated
|
||||||
|
|
||||||
```
|
|
||||||
$ pytest
|
|
||||||
```
|
|
||||||
This will by default skip the slow running backtest set. To run backtest set:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ BACKTEST=true pytest -s freqtrade/tests/test_backtesting.py
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
@@ -114,14 +115,14 @@ filesystem):
|
|||||||
|
|
||||||
```
|
```
|
||||||
$ cd ~/.freq
|
$ cd ~/.freq
|
||||||
$ touch tradesv2.sqlite
|
$ touch tradesv3.sqlite
|
||||||
$ docker run -d \
|
$ docker run -d \
|
||||||
--name freqtrade \
|
--name freqtrade \
|
||||||
-v ~/.freq/config.json:/freqtrade/config.json \
|
-v ~/.freq/config.json:/freqtrade/config.json \
|
||||||
-v ~/.freq/tradesv2.sqlite:/freqtrade/tradesv2.sqlite \
|
-v ~/.freq/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \
|
||||||
freqtrade
|
freqtrade
|
||||||
```
|
```
|
||||||
If you are using `dry_run=True` you need to bind `tradesv2.dry_run.sqlite` instead of `tradesv2.sqlite`.
|
If you are using `dry_run=True` it's not necessary to mount `tradesv3.sqlite`.
|
||||||
|
|
||||||
You can then use the following commands to monitor and manage your container:
|
You can then use the following commands to monitor and manage your container:
|
||||||
|
|
||||||
@@ -136,7 +137,18 @@ $ docker start freqtrade
|
|||||||
You do not need to rebuild the image for configuration
|
You do not need to rebuild the image for configuration
|
||||||
changes, it will suffice to edit `config.json` and restart the container.
|
changes, it will suffice to edit `config.json` and restart the container.
|
||||||
|
|
||||||
#### Contributing
|
### Execute tests
|
||||||
|
|
||||||
|
```
|
||||||
|
$ pytest
|
||||||
|
```
|
||||||
|
This will by default skip the slow running backtest set. To run backtest set:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ BACKTEST=true pytest -s freqtrade/tests/test_backtesting.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
|
||||||
Feel like our bot is missing a feature? We welcome your pull requests! Few pointers for contributions:
|
Feel like our bot is missing a feature? We welcome your pull requests! Few pointers for contributions:
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from freqtrade.main import main
|
from freqtrade.main import main
|
||||||
main()
|
main()
|
@@ -34,5 +34,8 @@
|
|||||||
"token": "token",
|
"token": "token",
|
||||||
"chat_id": "chat_id"
|
"chat_id": "chat_id"
|
||||||
},
|
},
|
||||||
"initial_state": "running"
|
"initial_state": "running",
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,3 +1,3 @@
|
|||||||
__version__ = '0.13.0'
|
__version__ = '0.14.2'
|
||||||
|
|
||||||
from . import main
|
from . import main
|
||||||
|
@@ -1,32 +1,29 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import talib.abstract as ta
|
import talib.abstract as ta
|
||||||
from pandas import DataFrame, to_datetime
|
from pandas import DataFrame, to_datetime
|
||||||
|
|
||||||
from freqtrade import exchange
|
from freqtrade.exchange import get_ticker_history
|
||||||
from freqtrade.exchange import Bittrex, get_ticker_history
|
|
||||||
from freqtrade.vendor.qtpylib.indicators import awesome_oscillator
|
from freqtrade.vendor.qtpylib.indicators import awesome_oscillator
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_ticker_dataframe(ticker: list) -> DataFrame:
|
def parse_ticker_dataframe(ticker: list) -> DataFrame:
|
||||||
"""
|
"""
|
||||||
Analyses the trend for the given pair
|
Analyses the trend for the given ticker history
|
||||||
:param pair: pair as str in format BTC_ETH or BTC-ETH
|
:param ticker: See exchange.get_ticker_history
|
||||||
:return: DataFrame
|
:return: DataFrame
|
||||||
"""
|
"""
|
||||||
df = DataFrame(ticker) \
|
columns = {'C': 'close', 'V': 'volume', 'O': 'open', 'H': 'high', 'L': 'low', 'T': 'date'}
|
||||||
|
frame = DataFrame(ticker) \
|
||||||
.drop('BV', 1) \
|
.drop('BV', 1) \
|
||||||
.rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'})
|
.rename(columns=columns)
|
||||||
df['date'] = to_datetime(df['date'], utc=True, infer_datetime_format=True)
|
frame['date'] = to_datetime(frame['date'], utc=True, infer_datetime_format=True)
|
||||||
df.sort_values('date', inplace=True)
|
frame.sort_values('date', inplace=True)
|
||||||
return df
|
return frame
|
||||||
|
|
||||||
|
|
||||||
def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
||||||
@@ -42,9 +39,7 @@ def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
|||||||
dataframe['sma'] = ta.SMA(dataframe, timeperiod=40)
|
dataframe['sma'] = ta.SMA(dataframe, timeperiod=40)
|
||||||
dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9)
|
dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9)
|
||||||
dataframe['mfi'] = ta.MFI(dataframe)
|
dataframe['mfi'] = ta.MFI(dataframe)
|
||||||
dataframe['cci'] = ta.CCI(dataframe)
|
|
||||||
dataframe['rsi'] = ta.RSI(dataframe)
|
dataframe['rsi'] = ta.RSI(dataframe)
|
||||||
dataframe['mom'] = ta.MOM(dataframe)
|
|
||||||
dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5)
|
dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5)
|
||||||
dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10)
|
dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10)
|
||||||
dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50)
|
dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50)
|
||||||
@@ -54,6 +49,9 @@ def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
|||||||
dataframe['macd'] = macd['macd']
|
dataframe['macd'] = macd['macd']
|
||||||
dataframe['macdsignal'] = macd['macdsignal']
|
dataframe['macdsignal'] = macd['macdsignal']
|
||||||
dataframe['macdhist'] = macd['macdhist']
|
dataframe['macdhist'] = macd['macdhist']
|
||||||
|
hilbert = ta.HT_SINE(dataframe)
|
||||||
|
dataframe['htsine'] = hilbert['sine']
|
||||||
|
dataframe['htleadsine'] = hilbert['leadsine']
|
||||||
return dataframe
|
return dataframe
|
||||||
|
|
||||||
|
|
||||||
@@ -81,14 +79,12 @@ def analyze_ticker(pair: str) -> DataFrame:
|
|||||||
add several TA indicators and buy signal to it
|
add several TA indicators and buy signal to it
|
||||||
:return DataFrame with ticker data and indicator data
|
:return DataFrame with ticker data and indicator data
|
||||||
"""
|
"""
|
||||||
minimum_date = arrow.utcnow().shift(hours=-24)
|
ticker_hist = get_ticker_history(pair)
|
||||||
data = get_ticker_history(pair, minimum_date)
|
if not ticker_hist:
|
||||||
dataframe = parse_ticker_dataframe(data['result'])
|
logger.warning('Empty ticker history for pair %s', pair)
|
||||||
|
return DataFrame()
|
||||||
if dataframe.empty:
|
|
||||||
logger.warning('Empty dataframe for pair %s', pair)
|
|
||||||
return dataframe
|
|
||||||
|
|
||||||
|
dataframe = parse_ticker_dataframe(ticker_hist)
|
||||||
dataframe = populate_indicators(dataframe)
|
dataframe = populate_indicators(dataframe)
|
||||||
dataframe = populate_buy_trend(dataframe)
|
dataframe = populate_buy_trend(dataframe)
|
||||||
return dataframe
|
return dataframe
|
||||||
@@ -101,7 +97,6 @@ def get_buy_signal(pair: str) -> bool:
|
|||||||
:return: True if pair is good for buying, False otherwise
|
:return: True if pair is good for buying, False otherwise
|
||||||
"""
|
"""
|
||||||
dataframe = analyze_ticker(pair)
|
dataframe = analyze_ticker(pair)
|
||||||
|
|
||||||
if dataframe.empty:
|
if dataframe.empty:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -115,55 +110,3 @@ def get_buy_signal(pair: str) -> bool:
|
|||||||
signal = latest['buy'] == 1
|
signal = latest['buy'] == 1
|
||||||
logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal)
|
logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal)
|
||||||
return signal
|
return signal
|
||||||
|
|
||||||
|
|
||||||
def plot_dataframe(dataframe: DataFrame, pair: str) -> None:
|
|
||||||
"""
|
|
||||||
Plots the given dataframe
|
|
||||||
:param dataframe: DataFrame
|
|
||||||
:param pair: pair as str
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
import matplotlib
|
|
||||||
|
|
||||||
matplotlib.use("Qt5Agg")
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
# Two subplots sharing x axis
|
|
||||||
fig, (ax1, ax2, ax3) = plt.subplots(3, sharex=True)
|
|
||||||
fig.suptitle(pair, fontsize=14, fontweight='bold')
|
|
||||||
ax1.plot(dataframe.index.values, dataframe['close'], label='close')
|
|
||||||
# ax1.plot(dataframe.index.values, dataframe['sell'], 'ro', label='sell')
|
|
||||||
ax1.plot(dataframe.index.values, dataframe['sma'], '--', label='SMA')
|
|
||||||
ax1.plot(dataframe.index.values, dataframe['tema'], ':', label='TEMA')
|
|
||||||
ax1.plot(dataframe.index.values, dataframe['blower'], '-.', label='BB low')
|
|
||||||
ax1.plot(dataframe.index.values, dataframe['buy_price'], 'bo', label='buy')
|
|
||||||
ax1.legend()
|
|
||||||
|
|
||||||
ax2.plot(dataframe.index.values, dataframe['adx'], label='ADX')
|
|
||||||
ax2.plot(dataframe.index.values, dataframe['mfi'], label='MFI')
|
|
||||||
# ax2.plot(dataframe.index.values, [25] * len(dataframe.index.values))
|
|
||||||
ax2.legend()
|
|
||||||
|
|
||||||
ax3.plot(dataframe.index.values, dataframe['fastk'], label='k')
|
|
||||||
ax3.plot(dataframe.index.values, dataframe['fastd'], label='d')
|
|
||||||
ax3.plot(dataframe.index.values, [20] * len(dataframe.index.values))
|
|
||||||
ax3.legend()
|
|
||||||
|
|
||||||
# Fine-tune figure; make subplots close to each other and hide x ticks for
|
|
||||||
# all but bottom plot.
|
|
||||||
fig.subplots_adjust(hspace=0)
|
|
||||||
plt.setp([a.get_xticklabels() for a in fig.axes[:-1]], visible=False)
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Install PYQT5==5.9 manually if you want to test this helper function
|
|
||||||
while True:
|
|
||||||
exchange.EXCHANGE = Bittrex({'key': '', 'secret': ''})
|
|
||||||
test_pair = 'BTC_ETH'
|
|
||||||
# for pair in ['BTC_ANT', 'BTC_ETH', 'BTC_GNT', 'BTC_ETC']:
|
|
||||||
# get_buy_signal(pair)
|
|
||||||
plot_dataframe(analyze_ticker(test_pair), test_pair)
|
|
||||||
time.sleep(60)
|
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from random import randint
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
|
from cachetools import cached, TTLCache
|
||||||
|
|
||||||
from freqtrade.exchange.bittrex import Bittrex
|
from freqtrade.exchange.bittrex import Bittrex
|
||||||
from freqtrade.exchange.interface import Exchange
|
from freqtrade.exchange.interface import Exchange
|
||||||
@@ -10,9 +12,12 @@ from freqtrade.exchange.interface import Exchange
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Current selected exchange
|
# Current selected exchange
|
||||||
EXCHANGE: Exchange = None
|
_API: Exchange = None
|
||||||
_CONF: dict = {}
|
_CONF: dict = {}
|
||||||
|
|
||||||
|
# Holds all open sell orders for dry_run
|
||||||
|
_DRY_RUN_OPEN_ORDERS: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
class Exchanges(enum.Enum):
|
class Exchanges(enum.Enum):
|
||||||
"""
|
"""
|
||||||
@@ -29,7 +34,7 @@ def init(config: dict) -> None:
|
|||||||
:param config: config to use
|
:param config: config to use
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
global _CONF, EXCHANGE
|
global _CONF, _API
|
||||||
|
|
||||||
_CONF.update(config)
|
_CONF.update(config)
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ def init(config: dict) -> None:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise RuntimeError('Exchange {} is not supported'.format(name))
|
raise RuntimeError('Exchange {} is not supported'.format(name))
|
||||||
|
|
||||||
EXCHANGE = exchange_class(exchange_config)
|
_API = exchange_class(exchange_config)
|
||||||
|
|
||||||
# Check if all pairs are available
|
# Check if all pairs are available
|
||||||
validate_pairs(config['exchange']['pair_whitelist'])
|
validate_pairs(config['exchange']['pair_whitelist'])
|
||||||
@@ -58,62 +63,113 @@ def validate_pairs(pairs: List[str]) -> None:
|
|||||||
:param pairs: list of pairs
|
:param pairs: list of pairs
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
markets = EXCHANGE.get_markets()
|
markets = _API.get_markets()
|
||||||
|
stake_cur = _CONF['stake_currency']
|
||||||
for pair in pairs:
|
for pair in pairs:
|
||||||
|
if not pair.startswith(stake_cur):
|
||||||
|
raise RuntimeError(
|
||||||
|
'Pair {} not compatible with stake_currency: {}'.format(pair, stake_cur)
|
||||||
|
)
|
||||||
if pair not in markets:
|
if pair not in markets:
|
||||||
raise RuntimeError('Pair {} is not available at {}'.format(pair, EXCHANGE.name.lower()))
|
raise RuntimeError('Pair {} is not available at {}'.format(pair, _API.name.lower()))
|
||||||
|
|
||||||
|
|
||||||
def buy(pair: str, rate: float, amount: float) -> str:
|
def buy(pair: str, rate: float, amount: float) -> str:
|
||||||
if _CONF['dry_run']:
|
if _CONF['dry_run']:
|
||||||
return 'dry_run'
|
global _DRY_RUN_OPEN_ORDERS
|
||||||
|
order_id = 'dry_run_buy_{}'.format(randint(0, 1e6))
|
||||||
|
_DRY_RUN_OPEN_ORDERS[order_id] = {
|
||||||
|
'pair': pair,
|
||||||
|
'rate': rate,
|
||||||
|
'amount': amount,
|
||||||
|
'type': 'LIMIT_BUY',
|
||||||
|
'remaining': 0.0,
|
||||||
|
'opened': arrow.utcnow().datetime,
|
||||||
|
'closed': arrow.utcnow().datetime,
|
||||||
|
}
|
||||||
|
return order_id
|
||||||
|
|
||||||
return EXCHANGE.buy(pair, rate, amount)
|
return _API.buy(pair, rate, amount)
|
||||||
|
|
||||||
|
|
||||||
def sell(pair: str, rate: float, amount: float) -> str:
|
def sell(pair: str, rate: float, amount: float) -> str:
|
||||||
if _CONF['dry_run']:
|
if _CONF['dry_run']:
|
||||||
return 'dry_run'
|
global _DRY_RUN_OPEN_ORDERS
|
||||||
|
order_id = 'dry_run_sell_{}'.format(randint(0, 1e6))
|
||||||
|
_DRY_RUN_OPEN_ORDERS[order_id] = {
|
||||||
|
'pair': pair,
|
||||||
|
'rate': rate,
|
||||||
|
'amount': amount,
|
||||||
|
'type': 'LIMIT_SELL',
|
||||||
|
'remaining': 0.0,
|
||||||
|
'opened': arrow.utcnow().datetime,
|
||||||
|
'closed': arrow.utcnow().datetime,
|
||||||
|
}
|
||||||
|
return order_id
|
||||||
|
|
||||||
return EXCHANGE.sell(pair, rate, amount)
|
return _API.sell(pair, rate, amount)
|
||||||
|
|
||||||
|
|
||||||
def get_balance(currency: str) -> float:
|
def get_balance(currency: str) -> float:
|
||||||
if _CONF['dry_run']:
|
if _CONF['dry_run']:
|
||||||
return 999.9
|
return 999.9
|
||||||
|
|
||||||
return EXCHANGE.get_balance(currency)
|
return _API.get_balance(currency)
|
||||||
|
|
||||||
|
|
||||||
def get_balances():
|
def get_balances():
|
||||||
return EXCHANGE.get_balances()
|
if _CONF['dry_run']:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _API.get_balances()
|
||||||
|
|
||||||
|
|
||||||
def get_ticker(pair: str) -> dict:
|
def get_ticker(pair: str) -> dict:
|
||||||
return EXCHANGE.get_ticker(pair)
|
return _API.get_ticker(pair)
|
||||||
|
|
||||||
|
|
||||||
def get_ticker_history(pair: str, minimum_date: arrow.Arrow):
|
@cached(TTLCache(maxsize=100, ttl=30))
|
||||||
return EXCHANGE.get_ticker_history(pair, minimum_date)
|
def get_ticker_history(pair: str, tick_interval: Optional[int] = 5) -> List[Dict]:
|
||||||
|
return _API.get_ticker_history(pair, tick_interval)
|
||||||
|
|
||||||
|
|
||||||
def cancel_order(order_id: str) -> None:
|
def cancel_order(order_id: str) -> None:
|
||||||
if _CONF['dry_run']:
|
if _CONF['dry_run']:
|
||||||
return
|
return
|
||||||
|
|
||||||
return EXCHANGE.cancel_order(order_id)
|
return _API.cancel_order(order_id)
|
||||||
|
|
||||||
|
|
||||||
def get_open_orders(pair: str) -> List[dict]:
|
def get_order(order_id: str) -> Dict:
|
||||||
if _CONF['dry_run']:
|
if _CONF['dry_run']:
|
||||||
return []
|
order = _DRY_RUN_OPEN_ORDERS[order_id]
|
||||||
|
order.update({
|
||||||
|
'id': order_id
|
||||||
|
})
|
||||||
|
return order
|
||||||
|
|
||||||
return EXCHANGE.get_open_orders(pair)
|
return _API.get_order(order_id)
|
||||||
|
|
||||||
|
|
||||||
def get_pair_detail_url(pair: str) -> str:
|
def get_pair_detail_url(pair: str) -> str:
|
||||||
return EXCHANGE.get_pair_detail_url(pair)
|
return _API.get_pair_detail_url(pair)
|
||||||
|
|
||||||
|
|
||||||
def get_markets() -> List[str]:
|
def get_markets() -> List[str]:
|
||||||
return EXCHANGE.get_markets()
|
return _API.get_markets()
|
||||||
|
|
||||||
|
|
||||||
|
def get_market_summaries() -> List[Dict]:
|
||||||
|
return _API.get_market_summaries()
|
||||||
|
|
||||||
|
|
||||||
|
def get_name() -> str:
|
||||||
|
return _API.name
|
||||||
|
|
||||||
|
|
||||||
|
def get_fee() -> float:
|
||||||
|
return _API.fee
|
||||||
|
|
||||||
|
|
||||||
|
def get_wallet_health() -> List[Dict]:
|
||||||
|
return _API.get_wallet_health()
|
||||||
|
@@ -1,15 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Dict
|
||||||
|
|
||||||
import arrow
|
from bittrex.bittrex import Bittrex as _Bittrex, API_V2_0, API_V1_1
|
||||||
import requests
|
|
||||||
from bittrex.bittrex import Bittrex as _Bittrex
|
|
||||||
|
|
||||||
from freqtrade.exchange.interface import Exchange
|
from freqtrade.exchange.interface import Exchange
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_API: _Bittrex = None
|
_API: _Bittrex = None
|
||||||
|
_API_V2: _Bittrex = None
|
||||||
_EXCHANGE_CONF: dict = {}
|
_EXCHANGE_CONF: dict = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -19,91 +18,131 @@ class Bittrex(Exchange):
|
|||||||
"""
|
"""
|
||||||
# Base URL and API endpoints
|
# Base URL and API endpoints
|
||||||
BASE_URL: str = 'https://www.bittrex.com'
|
BASE_URL: str = 'https://www.bittrex.com'
|
||||||
TICKER_METHOD: str = BASE_URL + '/Api/v2.0/pub/market/GetTicks'
|
|
||||||
PAIR_DETAIL_METHOD: str = BASE_URL + '/Market/Index'
|
PAIR_DETAIL_METHOD: str = BASE_URL + '/Market/Index'
|
||||||
# Ticker inveral
|
|
||||||
TICKER_INTERVAL: str = 'fiveMin'
|
|
||||||
# Sleep time to avoid rate limits, used in the main loop
|
|
||||||
SLEEP_TIME: float = 25
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sleep_time(self) -> float:
|
|
||||||
return self.SLEEP_TIME
|
|
||||||
|
|
||||||
def __init__(self, config: dict) -> None:
|
def __init__(self, config: dict) -> None:
|
||||||
global _API, _EXCHANGE_CONF
|
global _API, _API_V2, _EXCHANGE_CONF
|
||||||
|
|
||||||
_EXCHANGE_CONF.update(config)
|
_EXCHANGE_CONF.update(config)
|
||||||
_API = _Bittrex(api_key=_EXCHANGE_CONF['key'], api_secret=_EXCHANGE_CONF['secret'])
|
_API = _Bittrex(
|
||||||
|
api_key=_EXCHANGE_CONF['key'],
|
||||||
|
api_secret=_EXCHANGE_CONF['secret'],
|
||||||
|
calls_per_second=1,
|
||||||
|
api_version=API_V1_1,
|
||||||
|
)
|
||||||
|
_API_V2 = _Bittrex(
|
||||||
|
api_key=_EXCHANGE_CONF['key'],
|
||||||
|
api_secret=_EXCHANGE_CONF['secret'],
|
||||||
|
calls_per_second=1,
|
||||||
|
api_version=API_V2_0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fee(self) -> float:
|
||||||
|
# See https://bittrex.com/fees
|
||||||
|
return 0.0025
|
||||||
|
|
||||||
def buy(self, pair: str, rate: float, amount: float) -> str:
|
def buy(self, pair: str, rate: float, amount: float) -> str:
|
||||||
data = _API.buy_limit(pair.replace('_', '-'), amount, rate)
|
data = _API.buy_limit(pair.replace('_', '-'), amount, rate)
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message} params=({pair}, {rate}, {amount})'.format(
|
||||||
|
message=data['message'],
|
||||||
|
pair=pair,
|
||||||
|
rate=rate,
|
||||||
|
amount=amount))
|
||||||
return data['result']['uuid']
|
return data['result']['uuid']
|
||||||
|
|
||||||
def sell(self, pair: str, rate: float, amount: float) -> str:
|
def sell(self, pair: str, rate: float, amount: float) -> str:
|
||||||
data = _API.sell_limit(pair.replace('_', '-'), amount, rate)
|
data = _API.sell_limit(pair.replace('_', '-'), amount, rate)
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message} params=({pair}, {rate}, {amount})'.format(
|
||||||
|
message=data['message'],
|
||||||
|
pair=pair,
|
||||||
|
rate=rate,
|
||||||
|
amount=amount))
|
||||||
return data['result']['uuid']
|
return data['result']['uuid']
|
||||||
|
|
||||||
def get_balance(self, currency: str) -> float:
|
def get_balance(self, currency: str) -> float:
|
||||||
data = _API.get_balance(currency)
|
data = _API.get_balance(currency)
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message} params=({currency})'.format(
|
||||||
|
message=data['message'],
|
||||||
|
currency=currency))
|
||||||
return float(data['result']['Balance'] or 0.0)
|
return float(data['result']['Balance'] or 0.0)
|
||||||
|
|
||||||
def get_balances(self):
|
def get_balances(self):
|
||||||
data = _API.get_balances()
|
data = _API.get_balances()
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message}'.format(message=data['message']))
|
||||||
return data['result']
|
return data['result']
|
||||||
|
|
||||||
def get_ticker(self, pair: str) -> dict:
|
def get_ticker(self, pair: str) -> dict:
|
||||||
data = _API.get_ticker(pair.replace('_', '-'))
|
data = _API.get_ticker(pair.replace('_', '-'))
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message} params=({pair})'.format(
|
||||||
|
message=data['message'],
|
||||||
|
pair=pair))
|
||||||
|
if not data['result']['Bid'] or not data['result']['Ask'] or not data['result']['Last']:
|
||||||
|
raise RuntimeError('{message} params=({pair})'.format(
|
||||||
|
message=data['message'],
|
||||||
|
pair=pair))
|
||||||
return {
|
return {
|
||||||
'bid': float(data['result']['Bid']),
|
'bid': float(data['result']['Bid']),
|
||||||
'ask': float(data['result']['Ask']),
|
'ask': float(data['result']['Ask']),
|
||||||
'last': float(data['result']['Last']),
|
'last': float(data['result']['Last']),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None):
|
def get_ticker_history(self, pair: str, tick_interval: int) -> List[Dict]:
|
||||||
url = self.TICKER_METHOD
|
if tick_interval == 1:
|
||||||
headers = {
|
interval = 'oneMin'
|
||||||
# TODO: Set as global setting
|
elif tick_interval == 5:
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36'
|
interval = 'fiveMin'
|
||||||
}
|
else:
|
||||||
params = {
|
raise ValueError('Cannot parse tick_interval: {}'.format(tick_interval))
|
||||||
'marketName': pair.replace('_', '-'),
|
|
||||||
'tickInterval': self.TICKER_INTERVAL,
|
data = _API_V2.get_candles(pair.replace('_', '-'), interval)
|
||||||
# TODO: Timestamp has no effect on API response
|
|
||||||
'_': minimum_date.timestamp * 1000
|
# These sanity check are necessary because bittrex cannot keep their API stable.
|
||||||
}
|
if not data.get('result'):
|
||||||
data = requests.get(url, params=params, headers=headers).json()
|
return []
|
||||||
|
|
||||||
|
for prop in ['C', 'V', 'O', 'H', 'L', 'T']:
|
||||||
|
for tick in data['result']:
|
||||||
|
if prop not in tick.keys():
|
||||||
|
logger.warning('Required property %s not present in response', prop)
|
||||||
|
return []
|
||||||
|
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message} params=({pair})'.format(
|
||||||
return data
|
message=data['message'],
|
||||||
|
pair=pair))
|
||||||
|
|
||||||
|
return data['result']
|
||||||
|
|
||||||
|
def get_order(self, order_id: str) -> Dict:
|
||||||
|
data = _API.get_order(order_id)
|
||||||
|
if not data['success']:
|
||||||
|
raise RuntimeError('{message} params=({order_id})'.format(
|
||||||
|
message=data['message'],
|
||||||
|
order_id=order_id))
|
||||||
|
data = data['result']
|
||||||
|
return {
|
||||||
|
'id': data['OrderUuid'],
|
||||||
|
'type': data['Type'],
|
||||||
|
'pair': data['Exchange'].replace('-', '_'),
|
||||||
|
'opened': data['Opened'],
|
||||||
|
'rate': data['PricePerUnit'],
|
||||||
|
'amount': data['Quantity'],
|
||||||
|
'remaining': data['QuantityRemaining'],
|
||||||
|
'closed': data['Closed'],
|
||||||
|
}
|
||||||
|
|
||||||
def cancel_order(self, order_id: str) -> None:
|
def cancel_order(self, order_id: str) -> None:
|
||||||
data = _API.cancel(order_id)
|
data = _API.cancel(order_id)
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message} params=({order_id})'.format(
|
||||||
|
message=data['message'],
|
||||||
def get_open_orders(self, pair: str) -> List[dict]:
|
order_id=order_id))
|
||||||
data = _API.get_open_orders(pair.replace('_', '-'))
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), 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:
|
def get_pair_detail_url(self, pair: str) -> str:
|
||||||
return self.PAIR_DETAIL_METHOD + '?MarketName={}'.format(pair.replace('_', '-'))
|
return self.PAIR_DETAIL_METHOD + '?MarketName={}'.format(pair.replace('_', '-'))
|
||||||
@@ -111,5 +150,22 @@ class Bittrex(Exchange):
|
|||||||
def get_markets(self) -> List[str]:
|
def get_markets(self) -> List[str]:
|
||||||
data = _API.get_markets()
|
data = _API.get_markets()
|
||||||
if not data['success']:
|
if not data['success']:
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
raise RuntimeError('{message}'.format(message=data['message']))
|
||||||
return [m['MarketName'].replace('-', '_') for m in data['result']]
|
return [m['MarketName'].replace('-', '_') for m in data['result']]
|
||||||
|
|
||||||
|
def get_market_summaries(self) -> List[Dict]:
|
||||||
|
data = _API.get_market_summaries()
|
||||||
|
if not data['success']:
|
||||||
|
raise RuntimeError('{message}'.format(message=data['message']))
|
||||||
|
return data['result']
|
||||||
|
|
||||||
|
def get_wallet_health(self) -> List[Dict]:
|
||||||
|
data = _API_V2.get_wallet_health()
|
||||||
|
if not data['success']:
|
||||||
|
raise RuntimeError('{message}'.format(message=data['message']))
|
||||||
|
return [{
|
||||||
|
'Currency': entry['Health']['Currency'],
|
||||||
|
'IsActive': entry['Health']['IsActive'],
|
||||||
|
'LastChecked': entry['Health']['LastChecked'],
|
||||||
|
'Notice': entry['Currency'].get('Notice'),
|
||||||
|
} for entry in data['result']]
|
||||||
|
@@ -1,7 +1,5 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Optional
|
from typing import List, Dict
|
||||||
|
|
||||||
import arrow
|
|
||||||
|
|
||||||
|
|
||||||
class Exchange(ABC):
|
class Exchange(ABC):
|
||||||
@@ -14,11 +12,10 @@ class Exchange(ABC):
|
|||||||
return self.__class__.__name__
|
return self.__class__.__name__
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
def fee(self) -> float:
|
||||||
def sleep_time(self) -> float:
|
|
||||||
"""
|
"""
|
||||||
Sleep time in seconds for the main loop to avoid API rate limits.
|
Fee for placing an order
|
||||||
:return: float
|
:return: percentage in float
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -77,26 +74,38 @@ class Exchange(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None) -> dict:
|
def get_ticker_history(self, pair: str, tick_interval: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Gets ticker history for given pair.
|
Gets ticker history for given pair.
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
:param pair: Pair as str, format: BTC_ETC
|
||||||
:param minimum_date: Minimum date (optional)
|
:param tick_interval: ticker interval in minutes
|
||||||
|
:return: list, format: [
|
||||||
|
{
|
||||||
|
'O': float, (Open)
|
||||||
|
'H': float, (High)
|
||||||
|
'L': float, (Low)
|
||||||
|
'C': float, (Close)
|
||||||
|
'V': float, (Volume)
|
||||||
|
'T': datetime, (Time)
|
||||||
|
'BV': float, (Base Volume)
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_order(self, order_id: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Get order details for the given order_id.
|
||||||
|
:param order_id: ID as str
|
||||||
:return: dict, format: {
|
:return: dict, format: {
|
||||||
'success': bool,
|
'id': str,
|
||||||
'message': str,
|
'type': str,
|
||||||
'result': [
|
'pair': str,
|
||||||
{
|
'opened': str ISO 8601 datetime,
|
||||||
'O': float, (Open)
|
'closed': str ISO 8601 datetime,
|
||||||
'H': float, (High)
|
'rate': float,
|
||||||
'L': float, (Low)
|
'amount': float,
|
||||||
'C': float, (Close)
|
'remaining': int
|
||||||
'V': float, (Volume)
|
|
||||||
'T': datetime, (Time)
|
|
||||||
'BV': float, (Base Volume)
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -108,24 +117,6 @@ class Exchange(ABC):
|
|||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_open_orders(self, pair: str) -> List[dict]:
|
|
||||||
"""
|
|
||||||
Gets all open orders for given pair.
|
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
|
||||||
:return: List of dicts, format: [
|
|
||||||
{
|
|
||||||
'id': str,
|
|
||||||
'type': str,
|
|
||||||
'opened': datetime,
|
|
||||||
'rate': float,
|
|
||||||
'amount': float,
|
|
||||||
'remaining': int,
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_pair_detail_url(self, pair: str) -> str:
|
def get_pair_detail_url(self, pair: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -140,3 +131,41 @@ class Exchange(ABC):
|
|||||||
Returns all available markets.
|
Returns all available markets.
|
||||||
:return: List of all available pairs
|
:return: List of all available pairs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_market_summaries(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Returns a 24h market summary for all available markets
|
||||||
|
:return: list, format: [
|
||||||
|
{
|
||||||
|
'MarketName': str,
|
||||||
|
'High': float,
|
||||||
|
'Low': float,
|
||||||
|
'Volume': float,
|
||||||
|
'Last': float,
|
||||||
|
'TimeStamp': datetime,
|
||||||
|
'BaseVolume': float,
|
||||||
|
'Bid': float,
|
||||||
|
'Ask': float,
|
||||||
|
'OpenBuyOrders': int,
|
||||||
|
'OpenSellOrders': int,
|
||||||
|
'PrevDay': float,
|
||||||
|
'Created': datetime
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_wallet_health(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Returns a list of all wallet health information
|
||||||
|
:return: list, format: [
|
||||||
|
{
|
||||||
|
'Currency': str,
|
||||||
|
'IsActive': bool,
|
||||||
|
'LastChecked': str,
|
||||||
|
'Notice': str
|
||||||
|
},
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
@@ -1,35 +1,67 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Optional
|
|
||||||
from signal import signal, SIGINT, SIGABRT, SIGTERM
|
from signal import signal, SIGINT, SIGABRT, SIGTERM
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cachetools import cached, TTLCache
|
||||||
from jsonschema import validate
|
from jsonschema import validate
|
||||||
|
|
||||||
from freqtrade import __version__, exchange, persistence
|
from freqtrade import __version__, exchange, persistence
|
||||||
from freqtrade.analyze import get_buy_signal
|
from freqtrade.analyze import get_buy_signal
|
||||||
from freqtrade.misc import CONF_SCHEMA, State, get_state, update_state
|
from freqtrade.misc import CONF_SCHEMA, State, get_state, update_state, build_arg_parser, throttle
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
from freqtrade.rpc import telegram
|
from freqtrade.rpc import telegram
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG,
|
logger = logging.getLogger('freqtrade')
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_CONF = {}
|
_CONF = {}
|
||||||
|
|
||||||
|
|
||||||
def _process() -> None:
|
def refresh_whitelist(whitelist: Optional[List[str]] = None) -> None:
|
||||||
|
"""
|
||||||
|
Check wallet health and remove pair from whitelist if necessary
|
||||||
|
:param whitelist: a new whitelist (optional)
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
whitelist = whitelist or _CONF['exchange']['pair_whitelist']
|
||||||
|
|
||||||
|
sanitized_whitelist = []
|
||||||
|
health = exchange.get_wallet_health()
|
||||||
|
for status in health:
|
||||||
|
pair = '{}_{}'.format(_CONF['stake_currency'], status['Currency'])
|
||||||
|
if pair not in whitelist:
|
||||||
|
continue
|
||||||
|
if status['IsActive']:
|
||||||
|
sanitized_whitelist.append(pair)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
'Ignoring %s from whitelist (reason: %s).',
|
||||||
|
pair, status.get('Notice') or 'wallet is not active'
|
||||||
|
)
|
||||||
|
if _CONF['exchange']['pair_whitelist'] != sanitized_whitelist:
|
||||||
|
logger.debug('Using refreshed pair whitelist: %s ...', sanitized_whitelist)
|
||||||
|
_CONF['exchange']['pair_whitelist'] = sanitized_whitelist
|
||||||
|
|
||||||
|
|
||||||
|
def _process(dynamic_whitelist: Optional[bool] = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Queries the persistence layer for open trades and handles them,
|
Queries the persistence layer for open trades and handles them,
|
||||||
otherwise a new trade is created.
|
otherwise a new trade is created.
|
||||||
:return: None
|
:param: dynamic_whitelist: True is a dynamic whitelist should be generated (optional)
|
||||||
|
:return: True if a trade has been created or closed, False otherwise
|
||||||
"""
|
"""
|
||||||
|
state_changed = False
|
||||||
try:
|
try:
|
||||||
|
# Refresh whitelist based on wallet maintenance
|
||||||
|
refresh_whitelist(
|
||||||
|
gen_pair_whitelist(_CONF['stake_currency']) if dynamic_whitelist else None
|
||||||
|
)
|
||||||
# Query trades from persistence layer
|
# Query trades from persistence layer
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
if len(trades) < _CONF['max_open_trades']:
|
if len(trades) < _CONF['max_open_trades']:
|
||||||
@@ -38,28 +70,39 @@ def _process() -> None:
|
|||||||
trade = create_trade(float(_CONF['stake_amount']))
|
trade = create_trade(float(_CONF['stake_amount']))
|
||||||
if trade:
|
if trade:
|
||||||
Trade.session.add(trade)
|
Trade.session.add(trade)
|
||||||
|
state_changed = True
|
||||||
else:
|
else:
|
||||||
logging.info('Got no buy signal...')
|
logger.info(
|
||||||
|
'Checked all whitelisted currencies. '
|
||||||
|
'Found no suitable entry positions for buying. Will keep looking ...'
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.exception('Unable to create trade')
|
logger.exception('Unable to create trade')
|
||||||
|
|
||||||
for trade in trades:
|
for trade in trades:
|
||||||
# Check if there is already an open order for this trade
|
# Get order details for actual price per unit
|
||||||
orders = exchange.get_open_orders(trade.pair)
|
if trade.open_order_id:
|
||||||
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
# Update trade with order values
|
||||||
if orders:
|
logger.info('Got open order for %s', trade)
|
||||||
logger.info('There is an open order for: %s', orders[0])
|
trade.update(exchange.get_order(trade.open_order_id))
|
||||||
else:
|
|
||||||
# Update state
|
if not close_trade_if_fulfilled(trade):
|
||||||
trade.open_order_id = None
|
# Check if we can sell our current pair
|
||||||
# Check if this trade can be closed
|
state_changed = handle_trade(trade) or state_changed
|
||||||
if not close_trade_if_fulfilled(trade):
|
|
||||||
# Check if we can sell our current pair
|
Trade.session.flush()
|
||||||
handle_trade(trade)
|
except (requests.exceptions.RequestException, json.JSONDecodeError) as error:
|
||||||
Trade.session.flush()
|
msg = 'Got {} in _process(), retrying in 30 seconds...'.format(error.__class__.__name__)
|
||||||
except (ConnectionError, json.JSONDecodeError) as error:
|
|
||||||
msg = 'Got {} in _process()'.format(error.__class__.__name__)
|
|
||||||
logger.exception(msg)
|
logger.exception(msg)
|
||||||
|
time.sleep(30)
|
||||||
|
except RuntimeError:
|
||||||
|
telegram.send_msg('*Status:* Got RuntimeError:\n```\n{traceback}```{hint}'.format(
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
hint='Issue `/start` if you think it is safe to restart.'
|
||||||
|
))
|
||||||
|
logger.exception('Got RuntimeError. Stopping trader ...')
|
||||||
|
update_state(State.STOPPED)
|
||||||
|
return state_changed
|
||||||
|
|
||||||
|
|
||||||
def close_trade_if_fulfilled(trade: Trade) -> bool:
|
def close_trade_if_fulfilled(trade: Trade) -> bool:
|
||||||
@@ -75,28 +118,32 @@ def close_trade_if_fulfilled(trade: Trade) -> bool:
|
|||||||
and trade.close_rate is not None \
|
and trade.close_rate is not None \
|
||||||
and trade.open_order_id is None:
|
and trade.open_order_id is None:
|
||||||
trade.is_open = False
|
trade.is_open = False
|
||||||
logger.info('No open orders found and trade is fulfilled. Marking %s as closed ...', trade)
|
logger.info(
|
||||||
|
'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
|
||||||
|
trade
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def execute_sell(trade: Trade, current_rate: float) -> None:
|
def execute_sell(trade: Trade, limit: float) -> None:
|
||||||
"""
|
"""
|
||||||
Executes a sell for the given trade and current rate
|
Executes a limit sell for the given trade and limit
|
||||||
:param trade: Trade instance
|
:param trade: Trade instance
|
||||||
:param current_rate: current rate
|
:param limit: limit rate for the sell order
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
# Get available balance
|
# Execute sell and update trade record
|
||||||
currency = trade.pair.split('_')[1]
|
order_id = exchange.sell(str(trade.pair), limit, trade.amount)
|
||||||
balance = exchange.get_balance(currency)
|
trade.open_order_id = order_id
|
||||||
profit = trade.exec_sell_order(current_rate, balance)
|
|
||||||
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
fmt_exp_profit = round(trade.calc_profit(limit) * 100, 2)
|
||||||
|
message = '*{}:* Selling [{}]({}) with limit `{:.8f} (profit: ~{:.2f}%)`'.format(
|
||||||
trade.exchange,
|
trade.exchange,
|
||||||
trade.pair.replace('_', '/'),
|
trade.pair.replace('_', '/'),
|
||||||
exchange.get_pair_detail_url(trade.pair),
|
exchange.get_pair_detail_url(trade.pair),
|
||||||
trade.close_rate,
|
limit,
|
||||||
round(profit, 2)
|
fmt_exp_profit
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.info(message)
|
||||||
telegram.send_msg(message)
|
telegram.send_msg(message)
|
||||||
@@ -107,41 +154,35 @@ def should_sell(trade: Trade, current_rate: float, current_time: datetime) -> bo
|
|||||||
Based an earlier trade and current price and configuration, decides whether bot should sell
|
Based an earlier trade and current price and configuration, decides whether bot should sell
|
||||||
:return True if bot should sell at current rate
|
:return True if bot should sell at current rate
|
||||||
"""
|
"""
|
||||||
current_profit = (current_rate - trade.open_rate) / trade.open_rate
|
current_profit = trade.calc_profit(current_rate)
|
||||||
|
|
||||||
if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']):
|
if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']):
|
||||||
logger.debug('Stop loss hit.')
|
logger.debug('Stop loss hit.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
for duration, threshold in sorted(_CONF['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
|
# Check if time matches and current rate is above threshold
|
||||||
time_diff = (current_time - trade.open_date).total_seconds() / 60
|
time_diff = (current_time - trade.open_date).total_seconds() / 60
|
||||||
if time_diff > duration and current_profit > threshold:
|
if time_diff > float(duration) and current_profit > threshold:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit * 100.0)
|
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit * 100.0)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def handle_trade(trade: Trade) -> None:
|
def handle_trade(trade: Trade) -> bool:
|
||||||
"""
|
"""
|
||||||
Sells the current pair if the threshold is reached and updates the trade record.
|
Sells the current pair if the threshold is reached and updates the trade record.
|
||||||
:return: None
|
:return: True if trade has been sold, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
if not trade.is_open:
|
||||||
if not trade.is_open:
|
raise ValueError('attempt to handle closed trade: {}'.format(trade))
|
||||||
raise ValueError('attempt to handle closed trade: {}'.format(trade))
|
|
||||||
|
|
||||||
logger.debug('Handling open trade %s ...', trade)
|
logger.debug('Handling %s ...', trade)
|
||||||
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
if should_sell(trade, current_rate, datetime.utcnow()):
|
||||||
if should_sell(trade, current_rate, datetime.utcnow()):
|
execute_sell(trade, current_rate)
|
||||||
execute_sell(trade, current_rate)
|
return True
|
||||||
return
|
return False
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
logger.exception('Unable to handle open order')
|
|
||||||
|
|
||||||
|
|
||||||
def get_target_bid(ticker: Dict[str, float]) -> float:
|
def get_target_bid(ticker: Dict[str, float]) -> float:
|
||||||
@@ -158,12 +199,15 @@ def create_trade(stake_amount: float) -> Optional[Trade]:
|
|||||||
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
|
||||||
"""
|
"""
|
||||||
logger.info('Creating new trade with stake_amount: %f ...', stake_amount)
|
logger.info(
|
||||||
|
'Checking buy signals to create a new trade with stake_amount: %f ...',
|
||||||
|
stake_amount
|
||||||
|
)
|
||||||
whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist'])
|
whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist'])
|
||||||
# Check if stake_amount is fulfilled
|
# Check if stake_amount is fulfilled
|
||||||
if exchange.get_balance(_CONF['stake_currency']) < stake_amount:
|
if exchange.get_balance(_CONF['stake_currency']) < stake_amount:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'stake amount is not fulfilled (currency={}'.format(_CONF['stake_currency'])
|
'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
|
||||||
@@ -182,27 +226,30 @@ def create_trade(stake_amount: float) -> Optional[Trade]:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
open_rate = get_target_bid(exchange.get_ticker(pair))
|
# Calculate amount and subtract fee
|
||||||
amount = stake_amount / open_rate
|
fee = exchange.get_fee()
|
||||||
order_id = exchange.buy(pair, open_rate, amount)
|
buy_limit = get_target_bid(exchange.get_ticker(pair))
|
||||||
|
amount = (1 - fee) * stake_amount / buy_limit
|
||||||
|
|
||||||
|
order_id = exchange.buy(pair, buy_limit, amount)
|
||||||
# Create trade entity and return
|
# Create trade entity and return
|
||||||
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
|
message = '*{}:* Buying [{}]({}) with limit `{:.8f}`'.format(
|
||||||
exchange.EXCHANGE.name.upper(),
|
exchange.get_name().upper(),
|
||||||
pair.replace('_', '/'),
|
pair.replace('_', '/'),
|
||||||
exchange.get_pair_detail_url(pair),
|
exchange.get_pair_detail_url(pair),
|
||||||
open_rate
|
buy_limit
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.info(message)
|
||||||
telegram.send_msg(message)
|
telegram.send_msg(message)
|
||||||
|
# Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL
|
||||||
return Trade(pair=pair,
|
return Trade(pair=pair,
|
||||||
stake_amount=stake_amount,
|
stake_amount=stake_amount,
|
||||||
open_rate=open_rate,
|
|
||||||
open_date=datetime.utcnow(),
|
|
||||||
amount=amount,
|
amount=amount,
|
||||||
exchange=exchange.EXCHANGE.name.upper(),
|
fee=fee * 2,
|
||||||
open_order_id=order_id,
|
open_rate=buy_limit,
|
||||||
is_open=True)
|
open_date=datetime.utcnow(),
|
||||||
|
exchange=exchange.get_name().upper(),
|
||||||
|
open_order_id=order_id)
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
def init(config: dict, db_url: Optional[str] = None) -> None:
|
||||||
@@ -229,6 +276,23 @@ def init(config: dict, db_url: Optional[str] = None) -> None:
|
|||||||
signal(sig, cleanup)
|
signal(sig, cleanup)
|
||||||
|
|
||||||
|
|
||||||
|
@cached(TTLCache(maxsize=1, ttl=1800))
|
||||||
|
def gen_pair_whitelist(base_currency: str, topn: int = 20, key: str = 'BaseVolume') -> List[str]:
|
||||||
|
"""
|
||||||
|
Updates the whitelist with with a dynamically generated list
|
||||||
|
:param base_currency: base currency as str
|
||||||
|
:param topn: maximum number of returned results
|
||||||
|
:param key: sort key (defaults to 'BaseVolume')
|
||||||
|
:return: List of pairs
|
||||||
|
"""
|
||||||
|
summaries = sorted(
|
||||||
|
(s for s in exchange.get_market_summaries() if s['MarketName'].startswith(base_currency)),
|
||||||
|
key=lambda s: s.get(key) or 0.0,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
return [s['MarketName'].replace('-', '_') for s in summaries[:topn]]
|
||||||
|
|
||||||
|
|
||||||
def cleanup(*args, **kwargs) -> None:
|
def cleanup(*args, **kwargs) -> None:
|
||||||
"""
|
"""
|
||||||
Cleanup the application state und finish all pending tasks
|
Cleanup the application state und finish all pending tasks
|
||||||
@@ -242,49 +306,55 @@ def cleanup(*args, **kwargs) -> None:
|
|||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
|
|
||||||
def app(config: dict) -> None:
|
|
||||||
"""
|
|
||||||
Main loop 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(exchange.EXCHANGE.sleep_time)
|
|
||||||
old_state = new_state
|
|
||||||
except RuntimeError:
|
|
||||||
telegram.send_msg(
|
|
||||||
'*Status:* Got RuntimeError:\n```\n{}\n```'.format(traceback.format_exc())
|
|
||||||
)
|
|
||||||
logger.exception('RuntimeError. Trader stopped!')
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""
|
"""
|
||||||
Loads and validates the config and starts the main loop
|
Loads and validates the config and handles the main loop
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
global _CONF
|
global _CONF
|
||||||
with open('config.json') as file:
|
args = build_arg_parser().parse_args()
|
||||||
|
|
||||||
|
# Initialize logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=args.loglevel,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Starting freqtrade %s (loglevel=%s)',
|
||||||
|
__version__,
|
||||||
|
logging.getLevelName(args.loglevel)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load and validate configuration
|
||||||
|
with open(args.config) as file:
|
||||||
_CONF = json.load(file)
|
_CONF = json.load(file)
|
||||||
validate(_CONF, CONF_SCHEMA)
|
if 'internals' not in _CONF:
|
||||||
app(_CONF)
|
_CONF['internals'] = {}
|
||||||
|
logger.info('Validating configuration ...')
|
||||||
|
validate(_CONF, CONF_SCHEMA)
|
||||||
|
|
||||||
|
# Initialize all modules and start main loop
|
||||||
|
if args.dynamic_whitelist:
|
||||||
|
logger.info('Using dynamically generated whitelist. (--dynamic-whitelist detected)')
|
||||||
|
init(_CONF)
|
||||||
|
old_state = None
|
||||||
|
while True:
|
||||||
|
new_state = get_state()
|
||||||
|
# Log state transition
|
||||||
|
if new_state != old_state:
|
||||||
|
telegram.send_msg('*Status:* `{}`'.format(new_state.name.lower()))
|
||||||
|
logger.info('Changing state to: %s', new_state.name)
|
||||||
|
|
||||||
|
if new_state == State.STOPPED:
|
||||||
|
time.sleep(1)
|
||||||
|
elif new_state == State.RUNNING:
|
||||||
|
throttle(
|
||||||
|
_process,
|
||||||
|
min_secs=_CONF['internals'].get('process_throttle_secs', 10),
|
||||||
|
dynamic_whitelist=args.dynamic_whitelist,
|
||||||
|
)
|
||||||
|
old_state = new_state
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@@ -1,7 +1,15 @@
|
|||||||
|
import argparse
|
||||||
import enum
|
import enum
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import time
|
||||||
from wrapt import synchronized
|
from wrapt import synchronized
|
||||||
|
|
||||||
|
from freqtrade import __version__
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class State(enum.Enum):
|
class State(enum.Enum):
|
||||||
RUNNING = 0
|
RUNNING = 0
|
||||||
@@ -32,6 +40,57 @@ def get_state() -> State:
|
|||||||
return _STATE
|
return _STATE
|
||||||
|
|
||||||
|
|
||||||
|
def throttle(func: Callable[..., Any], min_secs: float, *args, **kwargs) -> Any:
|
||||||
|
"""
|
||||||
|
Throttles the given callable that it
|
||||||
|
takes at least `min_secs` to finish execution.
|
||||||
|
:param func: Any callable
|
||||||
|
:param min_secs: minimum execution time in seconds
|
||||||
|
:return: Any
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
end = time.time()
|
||||||
|
duration = max(min_secs - (end - start), 0.0)
|
||||||
|
logger.debug('Throttling %s for %.2f seconds', func.__name__, duration)
|
||||||
|
time.sleep(duration)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
""" Builds and returns an ArgumentParser instance """
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Simple High Frequency Trading Bot for crypto currencies'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-c', '--config',
|
||||||
|
help='specify configuration file (default: config.json)',
|
||||||
|
dest='config',
|
||||||
|
default='config.json',
|
||||||
|
type=str,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-v', '--verbose',
|
||||||
|
help='be verbose',
|
||||||
|
action='store_const',
|
||||||
|
dest='loglevel',
|
||||||
|
const=logging.DEBUG,
|
||||||
|
default=logging.INFO,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--version',
|
||||||
|
action='version',
|
||||||
|
version='%(prog)s {}'.format(__version__),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dynamic-whitelist',
|
||||||
|
help='dynamically generate and update whitelist based on 24h BaseVolume',
|
||||||
|
action='store_true',
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
# Required json-schema for user specified config
|
# Required json-schema for user specified config
|
||||||
CONF_SCHEMA = {
|
CONF_SCHEMA = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -71,6 +130,12 @@ CONF_SCHEMA = {
|
|||||||
'required': ['enabled', 'token', 'chat_id']
|
'required': ['enabled', 'token', 'chat_id']
|
||||||
},
|
},
|
||||||
'initial_state': {'type': 'string', 'enum': ['running', 'stopped']},
|
'initial_state': {'type': 'string', 'enum': ['running', 'stopped']},
|
||||||
|
'internals': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'process_throttle_secs': {'type': 'number'}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'definitions': {
|
'definitions': {
|
||||||
'exchange': {
|
'exchange': {
|
||||||
|
@@ -1,39 +1,45 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from decimal import Decimal, getcontext
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
import arrow
|
||||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create_engine
|
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create_engine
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm.scoping import scoped_session
|
from sqlalchemy.orm.scoping import scoped_session
|
||||||
from sqlalchemy.orm.session import sessionmaker
|
from sqlalchemy.orm.session import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from freqtrade import exchange
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CONF = {}
|
_CONF = {}
|
||||||
|
_DECL_BASE = declarative_base()
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
def init(config: dict, engine: Optional[Engine] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Initializes this module with the given config,
|
Initializes this module with the given config,
|
||||||
registers all known command handlers
|
registers all known command handlers
|
||||||
and starts polling for message updates
|
and starts polling for message updates
|
||||||
:param config: config to use
|
:param config: config to use
|
||||||
:param db_url: database connector string for sqlalchemy (Optional)
|
:param engine: database engine for sqlalchemy (Optional)
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
_CONF.update(config)
|
_CONF.update(config)
|
||||||
if not db_url:
|
if not engine:
|
||||||
if _CONF.get('dry_run', False):
|
if _CONF.get('dry_run', False):
|
||||||
db_url = 'sqlite:///tradesv2.dry_run.sqlite'
|
engine = create_engine('sqlite://',
|
||||||
|
connect_args={'check_same_thread': False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
echo=False)
|
||||||
else:
|
else:
|
||||||
db_url = 'sqlite:///tradesv2.sqlite'
|
engine = create_engine('sqlite:///tradesv3.sqlite')
|
||||||
|
|
||||||
engine = create_engine(db_url, echo=False)
|
|
||||||
session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
||||||
Trade.session = session()
|
Trade.session = session()
|
||||||
Trade.query = session.query_property()
|
Trade.query = session.query_property()
|
||||||
Base.metadata.create_all(engine)
|
_DECL_BASE.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
def cleanup() -> None:
|
def cleanup() -> None:
|
||||||
@@ -44,51 +50,63 @@ def cleanup() -> None:
|
|||||||
Trade.session.flush()
|
Trade.session.flush()
|
||||||
|
|
||||||
|
|
||||||
class Trade(Base):
|
class Trade(_DECL_BASE):
|
||||||
__tablename__ = 'trades'
|
__tablename__ = 'trades'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
exchange = Column(String, nullable=False)
|
exchange = Column(String, 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)
|
fee = Column(Float, nullable=False, default=0.0)
|
||||||
|
open_rate = Column(Float)
|
||||||
close_rate = Column(Float)
|
close_rate = Column(Float)
|
||||||
close_profit = Column(Float)
|
close_profit = Column(Float)
|
||||||
stake_amount = Column(Float, name='btc_amount', nullable=False)
|
stake_amount = Column(Float, nullable=False)
|
||||||
amount = Column(Float, nullable=False)
|
amount = Column(Float)
|
||||||
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
|
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
close_date = Column(DateTime)
|
close_date = Column(DateTime)
|
||||||
open_order_id = Column(String)
|
open_order_id = Column(String)
|
||||||
|
|
||||||
def __repr__(self):
|
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(
|
return 'Trade(id={}, pair={}, amount={}, open_rate={}, open_since={})'.format(
|
||||||
self.id,
|
self.id,
|
||||||
self.pair,
|
self.pair,
|
||||||
self.amount,
|
self.amount,
|
||||||
self.open_rate,
|
self.open_rate,
|
||||||
open_since
|
arrow.get(self.open_date).humanize() if self.is_open else 'closed'
|
||||||
)
|
)
|
||||||
|
|
||||||
def exec_sell_order(self, rate: float, amount: float) -> float:
|
def update(self, order: Dict) -> None:
|
||||||
"""
|
"""
|
||||||
Executes a sell for the given trade and updated the entity.
|
Updates this entity with amount and actual open/close rates.
|
||||||
:param rate: rate to sell for
|
:param order: order retrieved by exchange.get_order()
|
||||||
:param amount: amount to sell
|
:return: None
|
||||||
:return: current profit as percentage
|
|
||||||
"""
|
"""
|
||||||
profit = 100 * ((rate - self.open_rate) / self.open_rate)
|
if not order['closed']:
|
||||||
|
return
|
||||||
|
|
||||||
# Execute sell and update trade record
|
logger.debug('Updating trade (id=%d) ...', self.id)
|
||||||
order_id = exchange.sell(str(self.pair), rate, amount)
|
if order['type'] == 'LIMIT_BUY':
|
||||||
self.close_rate = rate
|
# Update open rate and actual amount
|
||||||
self.close_profit = profit
|
self.open_rate = order['rate']
|
||||||
self.close_date = datetime.utcnow()
|
self.amount = order['amount']
|
||||||
self.open_order_id = order_id
|
elif order['type'] == 'LIMIT_SELL':
|
||||||
|
# Set close rate and set actual profit
|
||||||
|
self.close_rate = order['rate']
|
||||||
|
self.close_profit = self.calc_profit()
|
||||||
|
self.close_date = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown order type: {}'.format(order['type']))
|
||||||
|
|
||||||
# Flush changes
|
self.open_order_id = None
|
||||||
Trade.session.flush()
|
|
||||||
return profit
|
def calc_profit(self, rate: Optional[float] = None) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the profit in percentage (including fee).
|
||||||
|
:param rate: rate to compare with (optional).
|
||||||
|
If rate is not set self.close_rate will be used
|
||||||
|
:return: profit in percentage as float
|
||||||
|
"""
|
||||||
|
getcontext().prec = 8
|
||||||
|
return float((Decimal(rate or self.close_rate) - Decimal(self.open_rate))
|
||||||
|
/ Decimal(self.open_rate) - Decimal(self.fee))
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Callable, Any
|
from typing import Callable, Any
|
||||||
|
from pandas import DataFrame
|
||||||
|
from tabulate import tabulate
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
from sqlalchemy import and_, func, text
|
from sqlalchemy import and_, func, text
|
||||||
@@ -8,7 +11,7 @@ from telegram import ParseMode, Bot, Update
|
|||||||
from telegram.error import NetworkError
|
from telegram.error import NetworkError
|
||||||
from telegram.ext import CommandHandler, Updater
|
from telegram.ext import CommandHandler, Updater
|
||||||
|
|
||||||
from freqtrade import exchange
|
from freqtrade import exchange, __version__
|
||||||
from freqtrade.misc import get_state, State, update_state
|
from freqtrade.misc import get_state, State, update_state
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@ logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
|||||||
logging.getLogger('telegram').setLevel(logging.INFO)
|
logging.getLogger('telegram').setLevel(logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_updater: Updater = None
|
_UPDATER: Updater = None
|
||||||
_CONF = {}
|
_CONF = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -29,13 +32,13 @@ def init(config: dict) -> None:
|
|||||||
:param config: config to use
|
:param config: config to use
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
global _updater
|
global _UPDATER
|
||||||
|
|
||||||
_CONF.update(config)
|
_CONF.update(config)
|
||||||
if not _CONF['telegram']['enabled']:
|
if not is_enabled():
|
||||||
return
|
return
|
||||||
|
|
||||||
_updater = Updater(token=config['telegram']['token'], workers=0)
|
_UPDATER = Updater(token=config['telegram']['token'], workers=0)
|
||||||
|
|
||||||
# Register command handler and start telegram message polling
|
# Register command handler and start telegram message polling
|
||||||
handles = [
|
handles = [
|
||||||
@@ -46,11 +49,13 @@ def init(config: dict) -> None:
|
|||||||
CommandHandler('stop', _stop),
|
CommandHandler('stop', _stop),
|
||||||
CommandHandler('forcesell', _forcesell),
|
CommandHandler('forcesell', _forcesell),
|
||||||
CommandHandler('performance', _performance),
|
CommandHandler('performance', _performance),
|
||||||
|
CommandHandler('count', _count),
|
||||||
CommandHandler('help', _help),
|
CommandHandler('help', _help),
|
||||||
|
CommandHandler('version', _version),
|
||||||
]
|
]
|
||||||
for handle in handles:
|
for handle in handles:
|
||||||
_updater.dispatcher.add_handler(handle)
|
_UPDATER.dispatcher.add_handler(handle)
|
||||||
_updater.start_polling(
|
_UPDATER.start_polling(
|
||||||
clean=True,
|
clean=True,
|
||||||
bootstrap_retries=3,
|
bootstrap_retries=3,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
@@ -67,7 +72,16 @@ def cleanup() -> None:
|
|||||||
Stops all running telegram threads.
|
Stops all running telegram threads.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
_updater.stop()
|
if not is_enabled():
|
||||||
|
return
|
||||||
|
_UPDATER.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled() -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if the telegram module is activated, False otherwise
|
||||||
|
"""
|
||||||
|
return bool(_CONF['telegram'].get('enabled', False))
|
||||||
|
|
||||||
|
|
||||||
def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]:
|
def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]:
|
||||||
@@ -79,15 +93,17 @@ def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[
|
|||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
bot, update = kwargs.get('bot') or args[0], kwargs.get('update') or 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):
|
# Reject unauthorized messages
|
||||||
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)
|
|
||||||
return command_handler(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
logger.info('Rejected unauthorized message from: %s', update.message.chat_id)
|
logger.info('Rejected unauthorized message from: %s', update.message.chat_id)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
logger.info('Executing handler: %s for chat_id: %s', command_handler.__name__, chat_id)
|
||||||
|
try:
|
||||||
|
return command_handler(*args, **kwargs)
|
||||||
|
except BaseException:
|
||||||
|
logger.exception('Exception occurred within Telegram module')
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -100,32 +116,39 @@ def _status(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Check if additional parameters are passed
|
||||||
|
params = update.message.text.replace('/status', '').split(' ') \
|
||||||
|
if update.message.text else []
|
||||||
|
if 'table' in params:
|
||||||
|
_status_table(bot, update)
|
||||||
|
return
|
||||||
|
|
||||||
# Fetch open trade
|
# Fetch open trade
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
if get_state() != State.RUNNING:
|
if get_state() != State.RUNNING:
|
||||||
send_msg('*Status:* `trader is not running`', bot=bot)
|
send_msg('*Status:* `trader is not running`', bot=bot)
|
||||||
elif not trades:
|
elif not trades:
|
||||||
send_msg('*Status:* `no active order`', bot=bot)
|
send_msg('*Status:* `no active trade`', bot=bot)
|
||||||
else:
|
else:
|
||||||
for trade in trades:
|
for trade in trades:
|
||||||
|
order = None
|
||||||
|
if trade.open_order_id:
|
||||||
|
order = exchange.get_order(trade.open_order_id)
|
||||||
# calculate profit and send message to user
|
# calculate profit and send message to user
|
||||||
current_rate = exchange.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 = trade.calc_profit(current_rate)
|
||||||
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(
|
fmt_close_profit = '{:.2f}%'.format(
|
||||||
round(trade.close_profit, 2)
|
round(trade.close_profit * 100, 2)
|
||||||
) if trade.close_profit else None
|
) if trade.close_profit else None
|
||||||
message = """
|
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}`
|
||||||
*Amount:* `{amount}`
|
*Amount:* `{amount}`
|
||||||
*Open Rate:* `{open_rate}`
|
*Open Rate:* `{open_rate:.8f}`
|
||||||
*Close Rate:* `{close_rate}`
|
*Close Rate:* `{close_rate}`
|
||||||
*Current Rate:* `{current_rate}`
|
*Current Rate:* `{current_rate:.8f}`
|
||||||
*Close Profit:* `{close_profit}`
|
*Close Profit:* `{close_profit}`
|
||||||
*Current Profit:* `{current_profit:.2f}%`
|
*Current Profit:* `{current_profit:.2f}%`
|
||||||
*Open Order:* `{open_order}`
|
*Open Order:* `{open_order}`
|
||||||
@@ -139,12 +162,51 @@ def _status(bot: Bot, update: Update) -> None:
|
|||||||
current_rate=current_rate,
|
current_rate=current_rate,
|
||||||
amount=round(trade.amount, 8),
|
amount=round(trade.amount, 8),
|
||||||
close_profit=fmt_close_profit,
|
close_profit=fmt_close_profit,
|
||||||
current_profit=round(current_profit, 2),
|
current_profit=round(current_profit * 100, 2),
|
||||||
open_order='{} ({})'.format(order['remaining'], order['type']) if order else None,
|
open_order='{} ({})'.format(
|
||||||
|
order['remaining'], order['type']
|
||||||
|
) if order else None,
|
||||||
)
|
)
|
||||||
send_msg(message, bot=bot)
|
send_msg(message, bot=bot)
|
||||||
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _status_table(bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /status table.
|
||||||
|
Returns the current TradeThread status in table format
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Fetch open trade
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
if get_state() != State.RUNNING:
|
||||||
|
send_msg('*Status:* `trader is not running`', bot=bot)
|
||||||
|
elif not trades:
|
||||||
|
send_msg('*Status:* `no active order`', bot=bot)
|
||||||
|
else:
|
||||||
|
trades_list = []
|
||||||
|
for trade in trades:
|
||||||
|
# calculate profit and send message to user
|
||||||
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
|
trades_list.append([
|
||||||
|
trade.id,
|
||||||
|
trade.pair,
|
||||||
|
shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)),
|
||||||
|
'{:.2f}'.format(100 * trade.calc_profit(current_rate))
|
||||||
|
])
|
||||||
|
|
||||||
|
columns = ['ID', 'Pair', 'Since', 'Profit']
|
||||||
|
df_statuses = DataFrame.from_records(trades_list, columns=columns)
|
||||||
|
df_statuses = df_statuses.set_index(columns[0])
|
||||||
|
|
||||||
|
message = tabulate(df_statuses, headers='keys', tablefmt='simple')
|
||||||
|
message = "<pre>{}</pre>".format(message)
|
||||||
|
|
||||||
|
send_msg(message, parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
|
|
||||||
@authorized_only
|
@authorized_only
|
||||||
def _profit(bot: Bot, update: Update) -> None:
|
def _profit(bot: Bot, update: Update) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -160,6 +222,8 @@ def _profit(bot: Bot, update: Update) -> None:
|
|||||||
profits = []
|
profits = []
|
||||||
durations = []
|
durations = []
|
||||||
for trade in trades:
|
for trade in trades:
|
||||||
|
if not trade.open_rate:
|
||||||
|
continue
|
||||||
if trade.close_date:
|
if trade.close_date:
|
||||||
durations.append((trade.close_date - trade.open_date).total_seconds())
|
durations.append((trade.close_date - trade.open_date).total_seconds())
|
||||||
if trade.close_profit:
|
if trade.close_profit:
|
||||||
@@ -167,9 +231,9 @@ def _profit(bot: Bot, update: Update) -> None:
|
|||||||
else:
|
else:
|
||||||
# Get current rate
|
# Get current rate
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
profit = trade.calc_profit(current_rate)
|
||||||
|
|
||||||
profit_amounts.append((profit / 100) * trade.stake_amount)
|
profit_amounts.append(profit * trade.stake_amount)
|
||||||
profits.append(profit)
|
profits.append(profit)
|
||||||
|
|
||||||
best_pair = Trade.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')) \
|
||||||
@@ -184,7 +248,7 @@ def _profit(bot: Bot, update: Update) -> None:
|
|||||||
|
|
||||||
bp_pair, bp_rate = best_pair
|
bp_pair, bp_rate = best_pair
|
||||||
markdown_msg = """
|
markdown_msg = """
|
||||||
*ROI:* `{profit_btc:.2f} ({profit:.2f}%)`
|
*ROI:* `{profit_btc:.8f} ({profit:.2f}%)`
|
||||||
*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}`
|
||||||
@@ -192,13 +256,13 @@ def _profit(bot: Bot, update: Update) -> None:
|
|||||||
*Best Performing:* `{best_pair}: {best_rate:.2f}%`
|
*Best Performing:* `{best_pair}: {best_rate:.2f}%`
|
||||||
""".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) * 100, 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 * 100, 2),
|
||||||
)
|
)
|
||||||
send_msg(markdown_msg, bot=bot)
|
send_msg(markdown_msg, bot=bot)
|
||||||
|
|
||||||
@@ -209,18 +273,21 @@ def _balance(bot: Bot, update: Update) -> None:
|
|||||||
Handler for /balance
|
Handler for /balance
|
||||||
Returns current account balance per crypto
|
Returns current account balance per crypto
|
||||||
"""
|
"""
|
||||||
output = ""
|
output = ''
|
||||||
balances = exchange.get_balances()
|
balances = [
|
||||||
|
c for c in exchange.get_balances()
|
||||||
|
if c['Balance'] or c['Available'] or c['Pending']
|
||||||
|
]
|
||||||
|
if not balances:
|
||||||
|
output = '`All balances are zero.`'
|
||||||
|
|
||||||
for currency in balances:
|
for currency in balances:
|
||||||
if not currency['Balance'] and not currency['Available'] and not currency['Pending']:
|
|
||||||
continue
|
|
||||||
output += """*Currency*: {Currency}
|
output += """*Currency*: {Currency}
|
||||||
*Available*: {Available}
|
*Available*: {Available}
|
||||||
*Balance*: {Balance}
|
*Balance*: {Balance}
|
||||||
*Pending*: {Pending}
|
*Pending*: {Pending}
|
||||||
|
|
||||||
""".format(**currency)
|
""".format(**currency)
|
||||||
|
|
||||||
send_msg(output)
|
send_msg(output)
|
||||||
|
|
||||||
|
|
||||||
@@ -268,38 +335,29 @@ def _forcesell(bot: Bot, update: Update) -> None:
|
|||||||
send_msg('`trader is not running`', bot=bot)
|
send_msg('`trader is not running`', bot=bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
trade_id = update.message.text.replace('/forcesell', '').strip()
|
||||||
trade_id = int(update.message.text
|
if trade_id == 'all':
|
||||||
.replace('/forcesell', '')
|
# Execute sell for all open orders
|
||||||
.strip())
|
for trade in Trade.query.filter(Trade.is_open.is_(True)).all():
|
||||||
# Query for trade
|
# Get current rate
|
||||||
trade = Trade.query.filter(and_(
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
Trade.id == trade_id,
|
from freqtrade.main import execute_sell
|
||||||
Trade.is_open.is_(True)
|
execute_sell(trade, current_rate)
|
||||||
)).first()
|
return
|
||||||
if not trade:
|
|
||||||
send_msg('There is no open trade with ID: `{}`'.format(trade_id))
|
|
||||||
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,
|
|
||||||
trade.pair.replace('_', '/'),
|
|
||||||
exchange.get_pair_detail_url(trade.pair),
|
|
||||||
trade.close_rate,
|
|
||||||
round(profit, 2)
|
|
||||||
)
|
|
||||||
logger.info(message)
|
|
||||||
send_msg(message)
|
|
||||||
|
|
||||||
except ValueError:
|
# Query for trade
|
||||||
send_msg('Invalid argument. Usage: `/forcesell <trade_id>`')
|
trade = Trade.query.filter(and_(
|
||||||
|
Trade.id == trade_id,
|
||||||
|
Trade.is_open.is_(True)
|
||||||
|
)).first()
|
||||||
|
if not trade:
|
||||||
|
send_msg('Invalid argument. See `/help` to view usage')
|
||||||
logger.warning('/forcesell: Invalid argument received')
|
logger.warning('/forcesell: Invalid argument received')
|
||||||
|
return
|
||||||
|
# Get current rate
|
||||||
|
current_rate = exchange.get_ticker(trade.pair)['bid']
|
||||||
|
from freqtrade.main import execute_sell
|
||||||
|
execute_sell(trade, current_rate)
|
||||||
|
|
||||||
|
|
||||||
@authorized_only
|
@authorized_only
|
||||||
@@ -321,13 +379,37 @@ def _performance(bot: Bot, update: Update) -> None:
|
|||||||
.order_by(text('profit_sum DESC')) \
|
.order_by(text('profit_sum DESC')) \
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
stats = '\n'.join('{index}. <code>{pair}\t{profit:.2f}%</code>'.format(
|
stats = '\n'.join('{index}.\t<code>{pair}\t{profit:.2f}%</code>'.format(
|
||||||
index=i + 1,
|
index=i + 1,
|
||||||
pair=pair,
|
pair=pair,
|
||||||
profit=round(rate, 2)
|
profit=round(rate * 100, 2)
|
||||||
) for i, (pair, rate) in enumerate(pair_rates))
|
) for i, (pair, rate) in enumerate(pair_rates))
|
||||||
|
|
||||||
message = '<b>Performance:</b>\n{}\n'.format(stats)
|
message = '<b>Performance:</b>\n{}'.format(stats)
|
||||||
|
logger.debug(message)
|
||||||
|
send_msg(message, parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _count(bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /count.
|
||||||
|
Returns the number of trades running
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
if get_state() != State.RUNNING:
|
||||||
|
send_msg('`trader is not running`', bot=bot)
|
||||||
|
return
|
||||||
|
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
|
||||||
|
message = tabulate({
|
||||||
|
'current': [len(trades)],
|
||||||
|
'max': [_CONF['max_open_trades']]
|
||||||
|
}, headers=['current', 'max'], tablefmt='simple')
|
||||||
|
message = "<pre>{}</pre>".format(message)
|
||||||
logger.debug(message)
|
logger.debug(message)
|
||||||
send_msg(message, parse_mode=ParseMode.HTML)
|
send_msg(message, parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
@@ -344,16 +426,43 @@ def _help(bot: Bot, update: Update) -> None:
|
|||||||
message = """
|
message = """
|
||||||
*/start:* `Starts the trader`
|
*/start:* `Starts the trader`
|
||||||
*/stop:* `Stops the trader`
|
*/stop:* `Stops the trader`
|
||||||
*/status:* `Lists all open trades`
|
*/status [table]:* `Lists all open trades`
|
||||||
|
*table :* `will display trades in a table`
|
||||||
*/profit:* `Lists cumulative profit from all finished trades`
|
*/profit:* `Lists cumulative profit from all finished trades`
|
||||||
*/forcesell <trade_id>:* `Instantly sells the given trade, regardless of profit`
|
*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, regardless of profit`
|
||||||
*/performance:* `Show performance of each finished trade grouped by pair`
|
*/performance:* `Show performance of each finished trade grouped by pair`
|
||||||
|
*/count:* `Show number of trades running compared to allowed number of trades`
|
||||||
*/balance:* `Show account balance per currency`
|
*/balance:* `Show account balance per currency`
|
||||||
*/help:* `This help message`
|
*/help:* `This help message`
|
||||||
|
*/version:* `Show version`
|
||||||
"""
|
"""
|
||||||
send_msg(message, bot=bot)
|
send_msg(message, bot=bot)
|
||||||
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _version(bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /version.
|
||||||
|
Show version information
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
send_msg('*Version:* `{}`'.format(__version__), bot=bot)
|
||||||
|
|
||||||
|
|
||||||
|
def shorten_date(date):
|
||||||
|
"""
|
||||||
|
Trim the date so it fits on small screens
|
||||||
|
"""
|
||||||
|
new_date = re.sub('seconds?', 'sec', date)
|
||||||
|
new_date = re.sub('minutes?', 'min', new_date)
|
||||||
|
new_date = re.sub('hours?', 'h', new_date)
|
||||||
|
new_date = re.sub('days?', 'd', new_date)
|
||||||
|
new_date = re.sub('^an?', '1', new_date)
|
||||||
|
return new_date
|
||||||
|
|
||||||
|
|
||||||
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
|
Send given markdown message
|
||||||
@@ -362,18 +471,17 @@ def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDO
|
|||||||
:param parse_mode: telegram parse mode
|
:param parse_mode: telegram parse mode
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if _CONF['telegram'].get('enabled', False):
|
if not is_enabled():
|
||||||
try:
|
return
|
||||||
bot = bot or _updater.bot
|
|
||||||
try:
|
bot = bot or _UPDATER.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(
|
# if this is the case we send the message again.
|
||||||
'Got Telegram NetworkError: %s! Trying one more time.',
|
logger.warning(
|
||||||
error.message
|
'Got Telegram NetworkError: %s! Trying one more time.',
|
||||||
)
|
error.message
|
||||||
bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
)
|
||||||
except Exception:
|
bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
||||||
logger.exception('Exception occurred within Telegram API')
|
|
||||||
|
149
freqtrade/tests/conftest.py
Normal file
149
freqtrade/tests/conftest.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jsonschema import validate
|
||||||
|
from telegram import Message, Chat, Update
|
||||||
|
|
||||||
|
from freqtrade.misc import CONF_SCHEMA
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def default_conf():
|
||||||
|
""" Returns validated configuration suitable for most tests """
|
||||||
|
configuration = {
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "BTC",
|
||||||
|
"stake_amount": 0.05,
|
||||||
|
"dry_run": True,
|
||||||
|
"minimal_roi": {
|
||||||
|
"40": 0.0,
|
||||||
|
"30": 0.01,
|
||||||
|
"20": 0.02,
|
||||||
|
"0": 0.04
|
||||||
|
},
|
||||||
|
"stoploss": -0.05,
|
||||||
|
"bid_strategy": {
|
||||||
|
"ask_last_balance": 0.0
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "bittrex",
|
||||||
|
"enabled": True,
|
||||||
|
"key": "key",
|
||||||
|
"secret": "secret",
|
||||||
|
"pair_whitelist": [
|
||||||
|
"BTC_ETH",
|
||||||
|
"BTC_TKN",
|
||||||
|
"BTC_TRST",
|
||||||
|
"BTC_SWT",
|
||||||
|
"BTC_BCC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": True,
|
||||||
|
"token": "token",
|
||||||
|
"chat_id": "0"
|
||||||
|
},
|
||||||
|
"initial_state": "running"
|
||||||
|
}
|
||||||
|
validate(configuration, CONF_SCHEMA)
|
||||||
|
return configuration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def backtest_conf():
|
||||||
|
return {
|
||||||
|
"minimal_roi": {
|
||||||
|
"40": 0.0,
|
||||||
|
"30": 0.01,
|
||||||
|
"20": 0.02,
|
||||||
|
"0": 0.04
|
||||||
|
},
|
||||||
|
"stoploss": -0.05
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def backdata():
|
||||||
|
result = {}
|
||||||
|
for pair in ['btc-neo', 'btc-eth', 'btc-omg', 'btc-edg', 'btc-pay',
|
||||||
|
'btc-pivx', 'btc-qtum', 'btc-mtl', 'btc-etc', 'btc-ltc']:
|
||||||
|
with open('freqtrade/tests/testdata/' + pair + '.json') as data_file:
|
||||||
|
result[pair] = json.load(data_file)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def update():
|
||||||
|
_update = Update(0)
|
||||||
|
_update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0))
|
||||||
|
return _update
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ticker():
|
||||||
|
return MagicMock(return_value={
|
||||||
|
'bid': 0.07256061,
|
||||||
|
'ask': 0.072661,
|
||||||
|
'last': 0.07256061,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def health():
|
||||||
|
return MagicMock(return_value=[{
|
||||||
|
'Currency': 'BTC',
|
||||||
|
'IsActive': True,
|
||||||
|
'LastChecked': '2017-11-13T20:15:00.00',
|
||||||
|
'Notice': None
|
||||||
|
}, {
|
||||||
|
'Currency': 'ETH',
|
||||||
|
'IsActive': True,
|
||||||
|
'LastChecked': '2017-11-13T20:15:00.00',
|
||||||
|
'Notice': None
|
||||||
|
}, {
|
||||||
|
'Currency': 'TRST',
|
||||||
|
'IsActive': True,
|
||||||
|
'LastChecked': '2017-11-13T20:15:00.00',
|
||||||
|
'Notice': None
|
||||||
|
}, {
|
||||||
|
'Currency': 'SWT',
|
||||||
|
'IsActive': True,
|
||||||
|
'LastChecked': '2017-11-13T20:15:00.00',
|
||||||
|
'Notice': None
|
||||||
|
}, {
|
||||||
|
'Currency': 'BCC',
|
||||||
|
'IsActive': False,
|
||||||
|
'LastChecked': '2017-11-13T20:15:00.00',
|
||||||
|
'Notice': None
|
||||||
|
}])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limit_buy_order():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_buy',
|
||||||
|
'type': 'LIMIT_BUY',
|
||||||
|
'pair': 'mocked',
|
||||||
|
'opened': datetime.utcnow(),
|
||||||
|
'rate': 0.07256061,
|
||||||
|
'amount': 206.43811673387373,
|
||||||
|
'remaining': 0.0,
|
||||||
|
'closed': datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limit_sell_order():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_sell',
|
||||||
|
'type': 'LIMIT_SELL',
|
||||||
|
'pair': 'mocked',
|
||||||
|
'opened': datetime.utcnow(),
|
||||||
|
'rate': 0.0802134,
|
||||||
|
'amount': 206.43811673387373,
|
||||||
|
'remaining': 0.0,
|
||||||
|
'closed': datetime.utcnow(),
|
||||||
|
}
|
@@ -11,17 +11,15 @@ from freqtrade.analyze import parse_ticker_dataframe, populate_buy_trend, popula
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def result():
|
def result():
|
||||||
with open('freqtrade/tests/testdata/btc-eth.json') as data_file:
|
with open('freqtrade/tests/testdata/btc-eth.json') as data_file:
|
||||||
data = json.load(data_file)
|
return parse_ticker_dataframe(json.load(data_file))
|
||||||
|
|
||||||
return parse_ticker_dataframe(data['result'])
|
|
||||||
|
|
||||||
|
|
||||||
def test_dataframe_has_correct_columns(result):
|
def test_dataframe_correct_columns(result):
|
||||||
assert result.columns.tolist() == \
|
assert result.columns.tolist() == \
|
||||||
['close', 'high', 'low', 'open', 'date', 'volume']
|
['close', 'high', 'low', 'open', 'date', 'volume']
|
||||||
|
|
||||||
|
|
||||||
def test_dataframe_has_correct_length(result):
|
def test_dataframe_correct_length(result):
|
||||||
assert len(result.index) == 5751
|
assert len(result.index) == 5751
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
# pragma pylint: disable=missing-docstring
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,20 +6,18 @@ import pytest
|
|||||||
import arrow
|
import arrow
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from freqtrade import exchange
|
||||||
from freqtrade.analyze import analyze_ticker
|
from freqtrade.analyze import analyze_ticker
|
||||||
|
from freqtrade.exchange import Bittrex
|
||||||
from freqtrade.main import should_sell
|
from freqtrade.main import should_sell
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot
|
logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot
|
||||||
|
|
||||||
|
|
||||||
def format_results(results):
|
def format_results(results):
|
||||||
return 'Made {} buys. Average profit {:.2f}%. Total profit was {:.3f}. Average duration {:.1f} mins.'.format(
|
return 'Made {} buys. Average profit {:.2f}%. Total profit was {:.3f}. Average duration {:.1f} mins.'.format(
|
||||||
len(results.index),
|
len(results.index), results.profit.mean() * 100.0, results.profit.sum(), results.duration.mean() * 5)
|
||||||
results.profit.mean() * 100.0,
|
|
||||||
results.profit.sum(),
|
|
||||||
results.duration.mean() * 5
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def print_pair_results(pair, results):
|
def print_pair_results(pair, results):
|
||||||
@@ -28,55 +25,41 @@ def print_pair_results(pair, results):
|
|||||||
print(format_results(results[results.currency == pair]))
|
print(format_results(results[results.currency == pair]))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def backtest(backtest_conf, backdata, mocker):
|
||||||
def pairs():
|
|
||||||
return ['btc-neo', 'btc-eth', 'btc-omg', 'btc-edg', 'btc-pay',
|
|
||||||
'btc-pivx', 'btc-qtum', 'btc-mtl', 'btc-etc', 'btc-ltc']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def conf():
|
|
||||||
return {
|
|
||||||
"minimal_roi": {
|
|
||||||
"50": 0.0,
|
|
||||||
"40": 0.01,
|
|
||||||
"30": 0.02,
|
|
||||||
"0": 0.045
|
|
||||||
},
|
|
||||||
"stoploss": -0.40
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def backtest(conf, pairs, mocker):
|
|
||||||
trades = []
|
trades = []
|
||||||
|
exchange._API = Bittrex({'key': '', 'secret': ''})
|
||||||
mocked_history = mocker.patch('freqtrade.analyze.get_ticker_history')
|
mocked_history = mocker.patch('freqtrade.analyze.get_ticker_history')
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', backtest_conf)
|
||||||
mocker.patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00'))
|
mocker.patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00'))
|
||||||
for pair in pairs:
|
for pair, pair_data in backdata.items():
|
||||||
with open('freqtrade/tests/testdata/'+pair+'.json') as data_file:
|
mocked_history.return_value = pair_data
|
||||||
data = json.load(data_file)
|
ticker = analyze_ticker(pair)[['close', 'date', 'buy']].copy()
|
||||||
mocked_history.return_value = data
|
# for each buy point
|
||||||
ticker = analyze_ticker(pair)[['close', 'date', 'buy']].copy()
|
for row in ticker[ticker.buy == 1].itertuples(index=True):
|
||||||
# for each buy point
|
trade = Trade(
|
||||||
for row in ticker[ticker.buy == 1].itertuples(index=True):
|
open_rate=row.close,
|
||||||
trade = Trade(open_rate=row.close, open_date=row.date, amount=1)
|
open_date=row.date,
|
||||||
# calculate win/lose forwards from buy point
|
amount=1,
|
||||||
for row2 in ticker[row.Index:].itertuples(index=True):
|
fee=exchange.get_fee() * 2
|
||||||
if should_sell(trade, row2.close, row2.date):
|
)
|
||||||
current_profit = (row2.close - trade.open_rate) / trade.open_rate
|
# calculate win/lose forwards from buy point
|
||||||
|
for row2 in ticker[row.Index:].itertuples(index=True):
|
||||||
|
if should_sell(trade, row2.close, row2.date):
|
||||||
|
current_profit = trade.calc_profit(row2.close)
|
||||||
|
|
||||||
trades.append((pair, current_profit, row2.Index - row.Index))
|
trades.append((pair, current_profit, row2.Index - row.Index))
|
||||||
break
|
break
|
||||||
labels = ['currency', 'profit', 'duration']
|
labels = ['currency', 'profit', 'duration']
|
||||||
results = DataFrame.from_records(trades, columns=labels)
|
results = DataFrame.from_records(trades, columns=labels)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set")
|
@pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set")
|
||||||
def test_backtest(conf, pairs, mocker, report=True):
|
def test_backtest(backtest_conf, backdata, mocker, report=True):
|
||||||
results = backtest(conf, pairs, mocker)
|
results = backtest(backtest_conf, backdata, mocker)
|
||||||
|
|
||||||
print('====================== BACKTESTING REPORT ================================')
|
print('====================== BACKTESTING REPORT ================================')
|
||||||
[print_pair_results(pair, results) for pair in pairs]
|
for pair in backdata:
|
||||||
|
print_pair_results(pair, results)
|
||||||
print('TOTAL OVER ALL TRADES:')
|
print('TOTAL OVER ALL TRADES:')
|
||||||
print(format_results(results))
|
print(format_results(results))
|
||||||
|
36
freqtrade/tests/test_exchange.py
Normal file
36
freqtrade/tests/test_exchange.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.exchange import validate_pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.get_markets = MagicMock(return_value=[
|
||||||
|
'BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT', 'BTC_BCC',
|
||||||
|
])
|
||||||
|
mocker.patch('freqtrade.exchange._API', api_mock)
|
||||||
|
mocker.patch.dict('freqtrade.exchange._CONF', default_conf)
|
||||||
|
validate_pairs(default_conf['exchange']['pair_whitelist'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs_not_available(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.get_markets = MagicMock(return_value=[])
|
||||||
|
mocker.patch('freqtrade.exchange._API', api_mock)
|
||||||
|
mocker.patch.dict('freqtrade.exchange._CONF', default_conf)
|
||||||
|
with pytest.raises(RuntimeError, match=r'not available'):
|
||||||
|
validate_pairs(default_conf['exchange']['pair_whitelist'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs_not_compatible(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.get_markets = MagicMock(return_value=['BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT'])
|
||||||
|
default_conf['stake_currency'] = 'ETH'
|
||||||
|
mocker.patch('freqtrade.exchange._API', api_mock)
|
||||||
|
mocker.patch.dict('freqtrade.exchange._CONF', default_conf)
|
||||||
|
with pytest.raises(RuntimeError, match=r'not compatible'):
|
||||||
|
validate_pairs(default_conf['exchange']['pair_whitelist'])
|
||||||
|
|
@@ -12,63 +12,47 @@ from pandas import DataFrame
|
|||||||
from freqtrade.tests.test_backtesting import backtest, format_results
|
from freqtrade.tests.test_backtesting import backtest, format_results
|
||||||
from freqtrade.vendor.qtpylib.indicators import crossed_above
|
from freqtrade.vendor.qtpylib.indicators import crossed_above
|
||||||
|
|
||||||
logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot
|
logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot
|
||||||
|
|
||||||
# set TARGET_TRADES to suit your number concurrent trades so its realistic to 20days of data
|
# set TARGET_TRADES to suit your number concurrent trades so its realistic to 20days of data
|
||||||
TARGET_TRADES = 1200
|
TARGET_TRADES = 1300
|
||||||
|
TOTAL_TRIES = 4
|
||||||
|
current_tries = 0
|
||||||
@pytest.fixture
|
|
||||||
def pairs():
|
|
||||||
return ['btc-neo', 'btc-eth', 'btc-omg', 'btc-edg', 'btc-pay',
|
|
||||||
'btc-pivx', 'btc-qtum', 'btc-mtl', 'btc-etc', 'btc-ltc']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def conf():
|
|
||||||
return {
|
|
||||||
"minimal_roi": {
|
|
||||||
"40": 0.0,
|
|
||||||
"30": 0.01,
|
|
||||||
"20": 0.02,
|
|
||||||
"0": 0.04
|
|
||||||
},
|
|
||||||
"stoploss": -0.05
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def buy_strategy_generator(params):
|
def buy_strategy_generator(params):
|
||||||
print(params)
|
|
||||||
|
|
||||||
def populate_buy_trend(dataframe: DataFrame) -> DataFrame:
|
def populate_buy_trend(dataframe: DataFrame) -> DataFrame:
|
||||||
conditions = []
|
conditions = []
|
||||||
# GUARDS AND TRENDS
|
# GUARDS AND TRENDS
|
||||||
if params['uptrend_long_ema']['enabled']:
|
if params['uptrend_long_ema']['enabled']:
|
||||||
conditions.append(dataframe['ema50'] > dataframe['ema100'])
|
conditions.append(dataframe['ema50'] > dataframe['ema100'])
|
||||||
|
if params['uptrend_short_ema']['enabled']:
|
||||||
|
conditions.append(dataframe['ema5'] > dataframe['ema10'])
|
||||||
if params['mfi']['enabled']:
|
if params['mfi']['enabled']:
|
||||||
conditions.append(dataframe['mfi'] < params['mfi']['value'])
|
conditions.append(dataframe['mfi'] < params['mfi']['value'])
|
||||||
if params['fastd']['enabled']:
|
if params['fastd']['enabled']:
|
||||||
conditions.append(dataframe['fastd'] < params['fastd']['value'])
|
conditions.append(dataframe['fastd'] < params['fastd']['value'])
|
||||||
if params['adx']['enabled']:
|
if params['adx']['enabled']:
|
||||||
conditions.append(dataframe['adx'] > params['adx']['value'])
|
conditions.append(dataframe['adx'] > params['adx']['value'])
|
||||||
if params['cci']['enabled']:
|
|
||||||
conditions.append(dataframe['cci'] < params['cci']['value'])
|
|
||||||
if params['rsi']['enabled']:
|
if params['rsi']['enabled']:
|
||||||
conditions.append(dataframe['rsi'] < params['rsi']['value'])
|
conditions.append(dataframe['rsi'] < params['rsi']['value'])
|
||||||
if params['over_sar']['enabled']:
|
if params['over_sar']['enabled']:
|
||||||
conditions.append(dataframe['close'] > dataframe['sar'])
|
conditions.append(dataframe['close'] > dataframe['sar'])
|
||||||
|
if params['green_candle']['enabled']:
|
||||||
|
conditions.append(dataframe['close'] > dataframe['open'])
|
||||||
if params['uptrend_sma']['enabled']:
|
if params['uptrend_sma']['enabled']:
|
||||||
prevsma = dataframe['sma'].shift(1)
|
prevsma = dataframe['sma'].shift(1)
|
||||||
conditions.append(dataframe['sma'] > prevsma)
|
conditions.append(dataframe['sma'] > prevsma)
|
||||||
|
|
||||||
prev_fastd = dataframe['fastd'].shift(1)
|
|
||||||
# TRIGGERS
|
# TRIGGERS
|
||||||
triggers = {
|
triggers = {
|
||||||
'lower_bb': dataframe['tema'] <= dataframe['blower'],
|
'lower_bb': dataframe['tema'] <= dataframe['blower'],
|
||||||
'faststoch10': (dataframe['fastd'] >= 10) & (prev_fastd < 10),
|
'faststoch10': (crossed_above(dataframe['fastd'], 10.0)),
|
||||||
'ao_cross_zero': (crossed_above(dataframe['ao'], 0.0)),
|
'ao_cross_zero': (crossed_above(dataframe['ao'], 0.0)),
|
||||||
'ema5_cross_ema10': (crossed_above(dataframe['ema5'], dataframe['ema10'])),
|
'ema5_cross_ema10': (crossed_above(dataframe['ema5'], dataframe['ema10'])),
|
||||||
'macd_cross_signal': (crossed_above(dataframe['macd'], dataframe['macdsignal'])),
|
'macd_cross_signal': (crossed_above(dataframe['macd'], dataframe['macdsignal'])),
|
||||||
|
'sar_reversal': (crossed_above(dataframe['close'], dataframe['sar'])),
|
||||||
|
'stochf_cross': (crossed_above(dataframe['fastk'], dataframe['fastd'])),
|
||||||
|
'ht_sine': (crossed_above(dataframe['htleadsine'], dataframe['htsine'])),
|
||||||
}
|
}
|
||||||
conditions.append(triggers.get(params['trigger']['type']))
|
conditions.append(triggers.get(params['trigger']['type']))
|
||||||
|
|
||||||
@@ -82,22 +66,25 @@ def buy_strategy_generator(params):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set")
|
@pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set")
|
||||||
def test_hyperopt(conf, pairs, mocker):
|
def test_hyperopt(backtest_conf, backdata, mocker):
|
||||||
mocked_buy_trend = mocker.patch('freqtrade.analyze.populate_buy_trend')
|
mocked_buy_trend = mocker.patch('freqtrade.analyze.populate_buy_trend')
|
||||||
|
|
||||||
def optimizer(params):
|
def optimizer(params):
|
||||||
mocked_buy_trend.side_effect = buy_strategy_generator(params)
|
mocked_buy_trend.side_effect = buy_strategy_generator(params)
|
||||||
|
|
||||||
results = backtest(conf, pairs, mocker)
|
results = backtest(backtest_conf, backdata, mocker)
|
||||||
|
|
||||||
result = format_results(results)
|
result = format_results(results)
|
||||||
print(result)
|
|
||||||
|
|
||||||
total_profit = results.profit.sum() * 1000
|
total_profit = results.profit.sum() * 1000
|
||||||
trade_count = len(results.index)
|
trade_count = len(results.index)
|
||||||
|
|
||||||
trade_loss = 1 - 0.8 * exp(-(trade_count - TARGET_TRADES) ** 2 / 10 ** 5)
|
trade_loss = 1 - 0.4 * exp(-(trade_count - TARGET_TRADES) ** 2 / 10 ** 5.2)
|
||||||
profit_loss = exp(-total_profit**3 / 10**11)
|
profit_loss = max(0, 1 - total_profit / 15000) # max profit 15000
|
||||||
|
|
||||||
|
global current_tries
|
||||||
|
current_tries += 1
|
||||||
|
print('{}/{}: {}'.format(current_tries, TOTAL_TRIES, result))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'loss': trade_loss + profit_loss,
|
'loss': trade_loss + profit_loss,
|
||||||
@@ -108,32 +95,36 @@ def test_hyperopt(conf, pairs, mocker):
|
|||||||
space = {
|
space = {
|
||||||
'mfi': hp.choice('mfi', [
|
'mfi': hp.choice('mfi', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True, 'value': hp.uniform('mfi-value', 5, 15)}
|
{'enabled': True, 'value': hp.quniform('mfi-value', 5, 25, 1)}
|
||||||
]),
|
]),
|
||||||
'fastd': hp.choice('fastd', [
|
'fastd': hp.choice('fastd', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True, 'value': hp.uniform('fastd-value', 5, 40)}
|
{'enabled': True, 'value': hp.quniform('fastd-value', 10, 50, 1)}
|
||||||
]),
|
]),
|
||||||
'adx': hp.choice('adx', [
|
'adx': hp.choice('adx', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True, 'value': hp.uniform('adx-value', 10, 30)}
|
{'enabled': True, 'value': hp.quniform('adx-value', 15, 50, 1)}
|
||||||
]),
|
|
||||||
'cci': hp.choice('cci', [
|
|
||||||
{'enabled': False},
|
|
||||||
{'enabled': True, 'value': hp.uniform('cci-value', -150, -100)}
|
|
||||||
]),
|
]),
|
||||||
'rsi': hp.choice('rsi', [
|
'rsi': hp.choice('rsi', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True, 'value': hp.uniform('rsi-value', 20, 30)}
|
{'enabled': True, 'value': hp.quniform('rsi-value', 20, 40, 1)}
|
||||||
]),
|
]),
|
||||||
'uptrend_long_ema': hp.choice('uptrend_long_ema', [
|
'uptrend_long_ema': hp.choice('uptrend_long_ema', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True}
|
{'enabled': True}
|
||||||
]),
|
]),
|
||||||
|
'uptrend_short_ema': hp.choice('uptrend_short_ema', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
'over_sar': hp.choice('over_sar', [
|
'over_sar': hp.choice('over_sar', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True}
|
{'enabled': True}
|
||||||
]),
|
]),
|
||||||
|
'green_candle': hp.choice('green_candle', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
'uptrend_sma': hp.choice('uptrend_sma', [
|
'uptrend_sma': hp.choice('uptrend_sma', [
|
||||||
{'enabled': False},
|
{'enabled': False},
|
||||||
{'enabled': True}
|
{'enabled': True}
|
||||||
@@ -144,11 +135,14 @@ def test_hyperopt(conf, pairs, mocker):
|
|||||||
{'type': 'ao_cross_zero'},
|
{'type': 'ao_cross_zero'},
|
||||||
{'type': 'ema5_cross_ema10'},
|
{'type': 'ema5_cross_ema10'},
|
||||||
{'type': 'macd_cross_signal'},
|
{'type': 'macd_cross_signal'},
|
||||||
|
{'type': 'sar_reversal'},
|
||||||
|
{'type': 'stochf_cross'},
|
||||||
|
{'type': 'ht_sine'},
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
trials = Trials()
|
trials = Trials()
|
||||||
best = fmin(fn=optimizer, space=space, algo=tpe.suggest, max_evals=40, trials=trials)
|
best = fmin(fn=optimizer, space=space, algo=tpe.suggest, max_evals=TOTAL_TRIES, trials=trials)
|
||||||
print('\n\n\n\n====================== HYPEROPT BACKTESTING REPORT ================================')
|
print('\n\n\n\n==================== HYPEROPT BACKTESTING REPORT ==============================')
|
||||||
print('Best parameters {}'.format(best))
|
print('Best parameters {}'.format(best))
|
||||||
newlist = sorted(trials.results, key=itemgetter('loss'))
|
newlist = sorted(trials.results, key=itemgetter('loss'))
|
||||||
print('Result: {}'.format(newlist[0]['result']))
|
print('Result: {}'.format(newlist[0]['result']))
|
||||||
|
@@ -1,91 +1,167 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
# pragma pylint: disable=missing-docstring
|
||||||
import copy
|
import copy
|
||||||
from unittest.mock import MagicMock, call
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from jsonschema import validate
|
import requests
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
from freqtrade.exchange import Exchanges
|
from freqtrade.exchange import Exchanges
|
||||||
from freqtrade.main import create_trade, handle_trade, close_trade_if_fulfilled, init, \
|
from freqtrade.main import create_trade, handle_trade, close_trade_if_fulfilled, init, \
|
||||||
get_target_bid
|
get_target_bid, _process
|
||||||
from freqtrade.misc import CONF_SCHEMA
|
from freqtrade.misc import get_state, State
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def test_process_trade_creation(default_conf, ticker, health, mocker):
|
||||||
def conf():
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
configuration = {
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
"max_open_trades": 3,
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
"stake_currency": "BTC",
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
"stake_amount": 0.05,
|
validate_pairs=MagicMock(),
|
||||||
"dry_run": True,
|
get_ticker=ticker,
|
||||||
"minimal_roi": {
|
get_wallet_health=health,
|
||||||
"2880": 0.005,
|
buy=MagicMock(return_value='mocked_limit_buy'))
|
||||||
"720": 0.01,
|
init(default_conf, create_engine('sqlite://'))
|
||||||
"0": 0.02
|
|
||||||
},
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
"bid_strategy": {
|
assert len(trades) == 0
|
||||||
"ask_last_balance": 0.0
|
|
||||||
},
|
result = _process()
|
||||||
"exchange": {
|
assert result is True
|
||||||
"name": "bittrex",
|
|
||||||
"enabled": True,
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
"key": "key",
|
assert len(trades) == 1
|
||||||
"secret": "secret",
|
trade = trades[0]
|
||||||
"pair_whitelist": [
|
assert trade is not None
|
||||||
"BTC_ETH",
|
assert trade.stake_amount == default_conf['stake_amount']
|
||||||
"BTC_TKN",
|
assert trade.is_open
|
||||||
"BTC_TRST",
|
assert trade.open_date is not None
|
||||||
"BTC_SWT",
|
assert trade.exchange == Exchanges.BITTREX.name
|
||||||
]
|
assert trade.open_rate == 0.072661
|
||||||
},
|
assert trade.amount == 0.6864067381401302
|
||||||
"telegram": {
|
|
||||||
"enabled": True,
|
|
||||||
"token": "token",
|
|
||||||
"chat_id": "chat_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validate(configuration, CONF_SCHEMA)
|
|
||||||
return configuration
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade(conf, mocker):
|
def test_process_exchange_failures(default_conf, ticker, health, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
buy_signal = mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
sleep_mock = mocker.patch('time.sleep', side_effect=lambda _: None)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_wallet_health=health,
|
||||||
|
buy=MagicMock(side_effect=requests.exceptions.RequestException))
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
result = _process()
|
||||||
|
assert result is False
|
||||||
|
assert sleep_mock.has_calls()
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_runtime_error(default_conf, ticker, health, mocker):
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=msg_mock)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_wallet_health=health,
|
||||||
|
buy=MagicMock(side_effect=RuntimeError))
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
assert get_state() == State.RUNNING
|
||||||
|
|
||||||
|
result = _process()
|
||||||
|
assert result is False
|
||||||
|
assert get_state() == State.STOPPED
|
||||||
|
assert 'RuntimeError' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_trade_handling(default_conf, ticker, limit_buy_order, health, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_wallet_health=health,
|
||||||
|
buy=MagicMock(return_value='mocked_limit_buy'),
|
||||||
|
get_order=MagicMock(return_value=limit_buy_order))
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
assert len(trades) == 0
|
||||||
|
result = _process()
|
||||||
|
assert result is True
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
assert len(trades) == 1
|
||||||
|
|
||||||
|
result = _process()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_trade(default_conf, ticker, limit_buy_order, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
validate_pairs=MagicMock(),
|
validate_pairs=MagicMock(),
|
||||||
get_ticker=MagicMock(return_value={
|
get_ticker=ticker,
|
||||||
'bid': 0.07256061,
|
buy=MagicMock(return_value='mocked_limit_buy'))
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
# Save state of current whitelist
|
# Save state of current whitelist
|
||||||
whitelist = copy.deepcopy(conf['exchange']['pair_whitelist'])
|
whitelist = copy.deepcopy(default_conf['exchange']['pair_whitelist'])
|
||||||
|
|
||||||
init(conf, 'sqlite://')
|
init(default_conf, create_engine('sqlite://'))
|
||||||
for pair in ['BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT']:
|
trade = create_trade(15.0)
|
||||||
trade = create_trade(15.0)
|
Trade.session.add(trade)
|
||||||
Trade.session.add(trade)
|
Trade.session.flush()
|
||||||
Trade.session.flush()
|
assert trade is not None
|
||||||
assert trade is not None
|
assert trade.stake_amount == 15.0
|
||||||
assert trade.open_rate == 0.072661
|
assert trade.is_open
|
||||||
assert trade.pair == pair
|
assert trade.open_date is not None
|
||||||
assert trade.exchange == Exchanges.BITTREX.name
|
assert trade.exchange == Exchanges.BITTREX.name
|
||||||
assert trade.amount == 206.43811673387373
|
|
||||||
assert trade.stake_amount == 15.0
|
|
||||||
assert trade.is_open
|
|
||||||
assert trade.open_date is not None
|
|
||||||
assert whitelist == conf['exchange']['pair_whitelist']
|
|
||||||
|
|
||||||
buy_signal.assert_has_calls(
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
[call('BTC_ETH'), call('BTC_TKN'), call('BTC_TRST'), call('BTC_SWT')]
|
trade.update(limit_buy_order)
|
||||||
)
|
|
||||||
|
assert trade.open_rate == 0.07256061
|
||||||
|
assert trade.amount == 206.43811673387373
|
||||||
|
|
||||||
|
assert whitelist == default_conf['exchange']['pair_whitelist']
|
||||||
|
|
||||||
|
|
||||||
def test_handle_trade(conf, mocker):
|
def test_create_trade_no_stake_amount(default_conf, ticker, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
buy=MagicMock(return_value='mocked_limit_buy'),
|
||||||
|
get_balance=MagicMock(return_value=default_conf['stake_amount'] * 0.5))
|
||||||
|
with pytest.raises(ValueError, match=r'.*stake amount.*'):
|
||||||
|
create_trade(default_conf['stake_amount'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_trade_no_pairs(default_conf, ticker, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
buy=MagicMock(return_value='mocked_limit_buy'))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=r'.*No pair in whitelist.*'):
|
||||||
|
conf = copy.deepcopy(default_conf)
|
||||||
|
conf['exchange']['pair_whitelist'] = []
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', conf)
|
||||||
|
create_trade(default_conf['stake_amount'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_trade(default_conf, limit_buy_order, limit_sell_order, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
validate_pairs=MagicMock(),
|
validate_pairs=MagicMock(),
|
||||||
@@ -94,18 +170,45 @@ def test_handle_trade(conf, mocker):
|
|||||||
'ask': 0.172661,
|
'ask': 0.172661,
|
||||||
'last': 0.17256061
|
'last': 0.17256061
|
||||||
}),
|
}),
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
buy=MagicMock(return_value='mocked_limit_buy'),
|
||||||
|
sell=MagicMock(return_value='mocked_limit_sell'))
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
trade = create_trade(15.0)
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
Trade.session.flush()
|
||||||
trade = Trade.query.filter(Trade.is_open.is_(True)).first()
|
trade = Trade.query.filter(Trade.is_open.is_(True)).first()
|
||||||
assert trade
|
assert trade
|
||||||
|
|
||||||
handle_trade(trade)
|
handle_trade(trade)
|
||||||
assert trade.close_rate == 0.17256061
|
assert trade.open_order_id == 'mocked_limit_sell'
|
||||||
assert trade.close_profit == 137.4872490056564
|
assert close_trade_if_fulfilled(trade) is False
|
||||||
|
|
||||||
|
# Simulate fulfilled LIMIT_SELL order for trade
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
|
||||||
|
assert trade.close_rate == 0.0802134
|
||||||
|
assert trade.close_profit == 0.10046755
|
||||||
assert trade.close_date is not None
|
assert trade.close_date is not None
|
||||||
assert trade.open_order_id == 'dry_run'
|
|
||||||
|
|
||||||
|
|
||||||
def test_close_trade(conf, mocker):
|
def test_close_trade(default_conf, ticker, limit_buy_order, limit_sell_order, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
buy=MagicMock(return_value='mocked_limit_buy'))
|
||||||
|
|
||||||
|
# Create trade and sell it
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
trade = create_trade(15.0)
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
|
||||||
|
Trade.session.add(trade)
|
||||||
|
Trade.session.flush()
|
||||||
trade = Trade.query.filter(Trade.is_open.is_(True)).first()
|
trade = Trade.query.filter(Trade.is_open.is_(True)).first()
|
||||||
assert trade
|
assert trade
|
||||||
|
|
||||||
@@ -115,6 +218,8 @@ def test_close_trade(conf, mocker):
|
|||||||
closed = close_trade_if_fulfilled(trade)
|
closed = close_trade_if_fulfilled(trade)
|
||||||
assert closed
|
assert closed
|
||||||
assert not trade.is_open
|
assert not trade.is_open
|
||||||
|
with pytest.raises(ValueError, match=r'.*closed trade.*'):
|
||||||
|
handle_trade(trade)
|
||||||
|
|
||||||
|
|
||||||
def test_balance_fully_ask_side(mocker):
|
def test_balance_fully_ask_side(mocker):
|
||||||
@@ -127,6 +232,6 @@ def test_balance_fully_last_side(mocker):
|
|||||||
assert get_target_bid({'ask': 20, 'last': 10}) == 10
|
assert get_target_bid({'ask': 20, 'last': 10}) == 10
|
||||||
|
|
||||||
|
|
||||||
def test_balance_when_last_bigger_than_ask(mocker):
|
def test_balance_bigger_last_ask(mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}})
|
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}})
|
||||||
assert get_target_bid({'ask': 5, 'last': 10}) == 5
|
assert get_target_bid({'ask': 5, 'last': 10}) == 5
|
||||||
|
20
freqtrade/tests/test_misc.py
Normal file
20
freqtrade/tests/test_misc.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring
|
||||||
|
import time
|
||||||
|
|
||||||
|
from freqtrade.misc import throttle
|
||||||
|
|
||||||
|
|
||||||
|
def test_throttle():
|
||||||
|
|
||||||
|
def func():
|
||||||
|
return 42
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
result = throttle(func, 0.1)
|
||||||
|
end = time.time()
|
||||||
|
|
||||||
|
assert result == 42
|
||||||
|
assert end - start > 0.1
|
||||||
|
|
||||||
|
result = throttle(func, -1)
|
||||||
|
assert result == 42
|
@@ -1,21 +1,66 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
# pragma pylint: disable=missing-docstring
|
||||||
|
import pytest
|
||||||
|
|
||||||
from freqtrade.exchange import Exchanges
|
from freqtrade.exchange import Exchanges
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
|
|
||||||
def test_exec_sell_order(mocker):
|
def test_update(limit_buy_order, limit_sell_order):
|
||||||
api_mock = mocker.patch('freqtrade.main.exchange.sell', side_effect='mocked_order_id')
|
|
||||||
trade = Trade(
|
trade = Trade(
|
||||||
pair='BTC_ETH',
|
pair='BTC_ETH',
|
||||||
stake_amount=1.00,
|
stake_amount=1.00,
|
||||||
open_rate=0.50,
|
fee=0.1,
|
||||||
amount=10.00,
|
|
||||||
exchange=Exchanges.BITTREX,
|
exchange=Exchanges.BITTREX,
|
||||||
open_order_id='mocked'
|
|
||||||
)
|
)
|
||||||
profit = trade.exec_sell_order(1.00, 10.00)
|
assert trade.open_order_id is None
|
||||||
api_mock.assert_called_once_with('BTC_ETH', 1.0, 10.0)
|
assert trade.open_rate is None
|
||||||
assert profit == 100.0
|
assert trade.close_profit is None
|
||||||
assert trade.close_rate == 1.0
|
assert trade.close_date is None
|
||||||
assert trade.close_profit == profit
|
|
||||||
|
trade.open_order_id = 'something'
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate == 0.07256061
|
||||||
|
assert trade.close_profit is None
|
||||||
|
assert trade.close_date is None
|
||||||
|
|
||||||
|
trade.open_order_id = 'something'
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate == 0.07256061
|
||||||
|
assert trade.close_profit == 0.00546755
|
||||||
assert trade.close_date is not None
|
assert trade.close_date is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_open_order(limit_buy_order):
|
||||||
|
trade = Trade(
|
||||||
|
pair='BTC_ETH',
|
||||||
|
stake_amount=1.00,
|
||||||
|
fee=0.1,
|
||||||
|
exchange=Exchanges.BITTREX,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate is None
|
||||||
|
assert trade.close_profit is None
|
||||||
|
assert trade.close_date is None
|
||||||
|
|
||||||
|
limit_buy_order['closed'] = False
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate is None
|
||||||
|
assert trade.close_profit is None
|
||||||
|
assert trade.close_date is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_invalid_order(limit_buy_order):
|
||||||
|
trade = Trade(
|
||||||
|
pair='BTC_ETH',
|
||||||
|
stake_amount=1.00,
|
||||||
|
fee=0.1,
|
||||||
|
exchange=Exchanges.BITTREX,
|
||||||
|
)
|
||||||
|
limit_buy_order['type'] = 'invalid'
|
||||||
|
with pytest.raises(ValueError, match=r'Unknown order type'):
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
@@ -1,76 +1,106 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
# pragma pylint: disable=missing-docstring, too-many-arguments, too-many-ancestors
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from random import randint
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from jsonschema import validate
|
from sqlalchemy import create_engine
|
||||||
from telegram import Bot, Update, Message, Chat
|
from telegram import Update, Message, Chat
|
||||||
|
from telegram.error import NetworkError
|
||||||
|
|
||||||
|
from freqtrade import __version__
|
||||||
from freqtrade.main import init, create_trade
|
from freqtrade.main import init, create_trade
|
||||||
from freqtrade.misc import update_state, State, get_state, CONF_SCHEMA
|
from freqtrade.misc import update_state, State, get_state
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
from freqtrade.rpc.telegram import _status, _profit, _forcesell, _performance, _start, _stop, _balance
|
from freqtrade.rpc import telegram
|
||||||
|
from freqtrade.rpc.telegram import (
|
||||||
|
_status, _status_table, _profit, _forcesell, _performance, _count, _start, _stop, _balance,
|
||||||
|
authorized_only, _help, is_enabled, send_msg,
|
||||||
|
_version)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def test_is_enabled(default_conf, mocker):
|
||||||
def conf():
|
mocker.patch.dict('freqtrade.rpc.telegram._CONF', default_conf)
|
||||||
configuration = {
|
default_conf['telegram']['enabled'] = False
|
||||||
"max_open_trades": 3,
|
assert is_enabled() is False
|
||||||
"stake_currency": "BTC",
|
|
||||||
"stake_amount": 0.05,
|
|
||||||
"dry_run": True,
|
|
||||||
"minimal_roi": {
|
|
||||||
"2880": 0.005,
|
|
||||||
"720": 0.01,
|
|
||||||
"0": 0.02
|
|
||||||
},
|
|
||||||
"bid_strategy": {
|
|
||||||
"ask_last_balance": 0.0
|
|
||||||
},
|
|
||||||
"exchange": {
|
|
||||||
"name": "bittrex",
|
|
||||||
"enabled": True,
|
|
||||||
"key": "key",
|
|
||||||
"secret": "secret",
|
|
||||||
"pair_whitelist": [
|
|
||||||
"BTC_ETH"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"telegram": {
|
|
||||||
"enabled": True,
|
|
||||||
"token": "token",
|
|
||||||
"chat_id": "0"
|
|
||||||
},
|
|
||||||
"initial_state": "running"
|
|
||||||
}
|
|
||||||
validate(configuration, CONF_SCHEMA)
|
|
||||||
return configuration
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def update():
|
|
||||||
_update = Update(0)
|
|
||||||
_update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0))
|
|
||||||
return _update
|
|
||||||
|
|
||||||
|
|
||||||
class MagicBot(MagicMock, Bot):
|
def test_init_disabled(default_conf, mocker):
|
||||||
pass
|
mocker.patch.dict('freqtrade.rpc.telegram._CONF', default_conf)
|
||||||
|
default_conf['telegram']['enabled'] = False
|
||||||
|
telegram.init(default_conf)
|
||||||
|
|
||||||
|
|
||||||
def test_status_handle(conf, update, mocker):
|
def test_authorized_only(default_conf, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.rpc.telegram._CONF', default_conf)
|
||||||
|
|
||||||
|
chat = Chat(0, 0)
|
||||||
|
update = Update(randint(1, 100))
|
||||||
|
update.message = Message(randint(1, 100), 0, datetime.utcnow(), chat)
|
||||||
|
state = {'called': False}
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def dummy_handler(*args, **kwargs) -> None:
|
||||||
|
state['called'] = True
|
||||||
|
|
||||||
|
dummy_handler(MagicMock(), update)
|
||||||
|
assert state['called'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorized_only_unauthorized(default_conf, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.rpc.telegram._CONF', default_conf)
|
||||||
|
|
||||||
|
chat = Chat(0xdeadbeef, 0)
|
||||||
|
update = Update(randint(1, 100))
|
||||||
|
update.message = Message(randint(1, 100), 0, datetime.utcnow(), chat)
|
||||||
|
state = {'called': False}
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def dummy_handler(*args, **kwargs) -> None:
|
||||||
|
state['called'] = True
|
||||||
|
|
||||||
|
dummy_handler(MagicMock(), update)
|
||||||
|
assert state['called'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorized_only_exception(default_conf, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.rpc.telegram._CONF', default_conf)
|
||||||
|
|
||||||
|
update = Update(randint(1, 100))
|
||||||
|
update.message = Message(randint(1, 100), 0, datetime.utcnow(), Chat(0, 0))
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def dummy_handler(*args, **kwargs) -> None:
|
||||||
|
raise Exception('test')
|
||||||
|
|
||||||
|
dummy_handler(MagicMock(), update)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_handle(default_conf, update, ticker, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
validate_pairs=MagicMock(),
|
validate_pairs=MagicMock(),
|
||||||
get_ticker=MagicMock(return_value={
|
get_ticker=ticker)
|
||||||
'bid': 0.07256061,
|
init(default_conf, create_engine('sqlite://'))
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
update_state(State.STOPPED)
|
||||||
}),
|
_status(bot=MagicMock(), update=update)
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
assert msg_mock.call_count == 1
|
||||||
init(conf, 'sqlite://')
|
assert 'trader is not running' in msg_mock.call_args_list[0][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
|
update_state(State.RUNNING)
|
||||||
|
_status(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'no active trade' in msg_mock.call_args_list[0][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
# Create some test data
|
# Create some test data
|
||||||
trade = create_trade(15.0)
|
trade = create_trade(15.0)
|
||||||
@@ -78,146 +108,435 @@ def test_status_handle(conf, update, mocker):
|
|||||||
Trade.session.add(trade)
|
Trade.session.add(trade)
|
||||||
Trade.session.flush()
|
Trade.session.flush()
|
||||||
|
|
||||||
_status(bot=MagicBot(), update=update)
|
# Trigger status while we have a fulfilled order for the open trade
|
||||||
|
_status(bot=MagicMock(), update=update)
|
||||||
|
|
||||||
assert msg_mock.call_count == 2
|
assert msg_mock.call_count == 2
|
||||||
assert '[BTC_ETH]' in msg_mock.call_args_list[-1][0][0]
|
assert '[BTC_ETH]' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
|
||||||
|
|
||||||
def test_profit_handle(conf, update, mocker):
|
def test_status_table_handle(default_conf, update, ticker, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
validate_pairs=MagicMock(),
|
validate_pairs=MagicMock(),
|
||||||
get_ticker=MagicMock(return_value={
|
get_ticker=ticker,
|
||||||
'bid': 0.07256061,
|
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
buy=MagicMock(return_value='mocked_order_id'))
|
||||||
init(conf, 'sqlite://')
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
update_state(State.STOPPED)
|
||||||
|
_status_table(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'trader is not running' in msg_mock.call_args_list[0][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
|
update_state(State.RUNNING)
|
||||||
|
_status_table(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'no active order' in msg_mock.call_args_list[0][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
# Create some test data
|
# Create some test data
|
||||||
trade = create_trade(15.0)
|
trade = create_trade(15.0)
|
||||||
assert trade
|
assert trade
|
||||||
trade.close_rate = 0.07256061
|
Trade.session.add(trade)
|
||||||
trade.close_profit = 100.00
|
Trade.session.flush()
|
||||||
|
|
||||||
|
_status_table(bot=MagicMock(), update=update)
|
||||||
|
|
||||||
|
text = re.sub('</?pre>', '', msg_mock.call_args_list[-1][0][0])
|
||||||
|
line = text.split("\n")
|
||||||
|
fields = re.sub('[ ]+', ' ', line[2].strip()).split(' ')
|
||||||
|
|
||||||
|
assert int(fields[0]) == 1
|
||||||
|
assert fields[1] == 'BTC_ETH'
|
||||||
|
assert msg_mock.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_profit_handle(default_conf, update, ticker, limit_buy_order, limit_sell_order, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker)
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
|
||||||
|
_profit(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'no closed trade' in msg_mock.call_args_list[0][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
trade = create_trade(15.0)
|
||||||
|
assert trade
|
||||||
|
|
||||||
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
_profit(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 2
|
||||||
|
assert 'no closed trade' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
|
# Simulate fulfilled LIMIT_SELL order for trade
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
|
||||||
trade.close_date = datetime.utcnow()
|
trade.close_date = datetime.utcnow()
|
||||||
trade.open_order_id = None
|
|
||||||
trade.is_open = False
|
trade.is_open = False
|
||||||
Trade.session.add(trade)
|
Trade.session.add(trade)
|
||||||
Trade.session.flush()
|
Trade.session.flush()
|
||||||
|
|
||||||
_profit(bot=MagicBot(), update=update)
|
_profit(bot=MagicMock(), update=update)
|
||||||
assert msg_mock.call_count == 2
|
assert msg_mock.call_count == 1
|
||||||
assert '(100.00%)' in msg_mock.call_args_list[-1][0][0]
|
assert '*ROI:* `1.50701325 (10.05%)`' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
assert 'Best Performing:* `BTC_ETH: 10.05%`' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
|
||||||
|
|
||||||
def test_forcesell_handle(conf, update, mocker):
|
def test_forcesell_handle(default_conf, update, ticker, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
validate_pairs=MagicMock(),
|
validate_pairs=MagicMock(),
|
||||||
get_ticker=MagicMock(return_value={
|
get_ticker=ticker)
|
||||||
'bid': 0.07256061,
|
init(default_conf, create_engine('sqlite://'))
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
# Create some test data
|
# Create some test data
|
||||||
trade = create_trade(15.0)
|
trade = create_trade(15.0)
|
||||||
assert trade
|
assert trade
|
||||||
|
|
||||||
Trade.session.add(trade)
|
Trade.session.add(trade)
|
||||||
Trade.session.flush()
|
Trade.session.flush()
|
||||||
|
|
||||||
update.message.text = '/forcesell 1'
|
update.message.text = '/forcesell 1'
|
||||||
_forcesell(bot=MagicBot(), update=update)
|
_forcesell(bot=MagicMock(), update=update)
|
||||||
|
|
||||||
assert msg_mock.call_count == 2
|
assert msg_mock.call_count == 2
|
||||||
assert 'Selling [BTC/ETH]' in msg_mock.call_args_list[-1][0][0]
|
assert 'Selling [BTC/ETH]' in msg_mock.call_args_list[-1][0][0]
|
||||||
assert '0.072561' in msg_mock.call_args_list[-1][0][0]
|
assert '0.07256061 (profit: ~-0.64%)' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
|
||||||
|
|
||||||
def test_performance_handle(conf, update, mocker):
|
def test_forcesell_all_handle(default_conf, update, ticker, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
validate_pairs=MagicMock(),
|
validate_pairs=MagicMock(),
|
||||||
get_ticker=MagicMock(return_value={
|
get_ticker=ticker)
|
||||||
'bid': 0.07256061,
|
init(default_conf, create_engine('sqlite://'))
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
# Create some test data
|
||||||
}),
|
for _ in range(4):
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
Trade.session.add(create_trade(15.0))
|
||||||
init(conf, 'sqlite://')
|
Trade.session.flush()
|
||||||
|
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
|
||||||
|
update.message.text = '/forcesell all'
|
||||||
|
_forcesell(bot=MagicMock(), update=update)
|
||||||
|
|
||||||
|
assert msg_mock.call_count == 4
|
||||||
|
for args in msg_mock.call_args_list:
|
||||||
|
assert '0.07256061 (profit: ~-0.64%)' in args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_forcesell_handle_invalid(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock())
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
|
||||||
|
# Trader is not running
|
||||||
|
update_state(State.STOPPED)
|
||||||
|
update.message.text = '/forcesell 1'
|
||||||
|
_forcesell(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'not running' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
# No argument
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
update_state(State.RUNNING)
|
||||||
|
update.message.text = '/forcesell'
|
||||||
|
_forcesell(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'Invalid argument' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
# Invalid argument
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
update_state(State.RUNNING)
|
||||||
|
update.message.text = '/forcesell 123456'
|
||||||
|
_forcesell(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'Invalid argument.' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_handle(
|
||||||
|
default_conf, update, ticker, limit_buy_order, limit_sell_order, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker)
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
|
||||||
# Create some test data
|
# Create some test data
|
||||||
trade = create_trade(15.0)
|
trade = create_trade(15.0)
|
||||||
assert trade
|
assert trade
|
||||||
trade.close_rate = 0.07256061
|
|
||||||
trade.close_profit = 100.00
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
# Simulate fulfilled LIMIT_SELL order for trade
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
|
||||||
trade.close_date = datetime.utcnow()
|
trade.close_date = datetime.utcnow()
|
||||||
trade.open_order_id = None
|
|
||||||
trade.is_open = False
|
trade.is_open = False
|
||||||
Trade.session.add(trade)
|
Trade.session.add(trade)
|
||||||
Trade.session.flush()
|
Trade.session.flush()
|
||||||
|
|
||||||
_performance(bot=MagicBot(), update=update)
|
_performance(bot=MagicMock(), update=update)
|
||||||
assert msg_mock.call_count == 2
|
assert msg_mock.call_count == 2
|
||||||
assert 'Performance' in msg_mock.call_args_list[-1][0][0]
|
assert 'Performance' in msg_mock.call_args_list[-1][0][0]
|
||||||
assert 'BTC_ETH 100.00%' in msg_mock.call_args_list[-1][0][0]
|
assert '<code>BTC_ETH\t10.05%</code>' in msg_mock.call_args_list[-1][0][0]
|
||||||
|
|
||||||
|
|
||||||
def test_start_handle(conf, update, mocker):
|
def test_count_handle(default_conf, update, ticker, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple(
|
||||||
mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock())
|
'freqtrade.main.telegram',
|
||||||
init(conf, 'sqlite://')
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
buy=MagicMock(return_value='mocked_order_id'))
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
update_state(State.STOPPED)
|
||||||
|
_count(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'not running' in msg_mock.call_args_list[0][0][0]
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
update_state(State.RUNNING)
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
Trade.session.add(create_trade(15.0))
|
||||||
|
Trade.session.flush()
|
||||||
|
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
_count(bot=MagicMock(), update=update)
|
||||||
|
|
||||||
|
msg = '<pre> current max\n--------- -----\n 1 {}</pre>'.format(
|
||||||
|
default_conf['max_open_trades']
|
||||||
|
)
|
||||||
|
assert msg in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_handle_invalid(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
validate_pairs=MagicMock())
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
|
||||||
|
# Trader is not running
|
||||||
|
update_state(State.STOPPED)
|
||||||
|
_performance(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'not running' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_handle(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock())
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
update_state(State.STOPPED)
|
update_state(State.STOPPED)
|
||||||
assert get_state() == State.STOPPED
|
assert get_state() == State.STOPPED
|
||||||
_start(bot=MagicBot(), update=update)
|
_start(bot=MagicMock(), update=update)
|
||||||
assert get_state() == State.RUNNING
|
assert get_state() == State.RUNNING
|
||||||
assert msg_mock.call_count == 0
|
assert msg_mock.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
def test_stop_handle(conf, update, mocker):
|
def test_start_handle_already_running(default_conf, update, mocker):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock())
|
_CONF=default_conf,
|
||||||
init(conf, 'sqlite://')
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock())
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
update_state(State.RUNNING)
|
update_state(State.RUNNING)
|
||||||
assert get_state() == State.RUNNING
|
assert get_state() == State.RUNNING
|
||||||
_stop(bot=MagicBot(), update=update)
|
_start(bot=MagicMock(), update=update)
|
||||||
|
assert get_state() == State.RUNNING
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'already running' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_handle(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock())
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
update_state(State.RUNNING)
|
||||||
|
assert get_state() == State.RUNNING
|
||||||
|
_stop(bot=MagicMock(), update=update)
|
||||||
assert get_state() == State.STOPPED
|
assert get_state() == State.STOPPED
|
||||||
assert msg_mock.call_count == 1
|
assert msg_mock.call_count == 1
|
||||||
assert 'Stopping trader' in msg_mock.call_args_list[0][0][0]
|
assert 'Stopping trader' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
def test_balance_handle(conf, update, mocker):
|
def test_stop_handle_already_stopped(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock())
|
||||||
|
init(default_conf, create_engine('sqlite://'))
|
||||||
|
update_state(State.STOPPED)
|
||||||
|
assert get_state() == State.STOPPED
|
||||||
|
_stop(bot=MagicMock(), update=update)
|
||||||
|
assert get_state() == State.STOPPED
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert 'already stopped' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_balance_handle(default_conf, update, mocker):
|
||||||
mock_balance = [{
|
mock_balance = [{
|
||||||
'Currency': 'BTC',
|
'Currency': 'BTC',
|
||||||
'Balance': 10.0,
|
'Balance': 10.0,
|
||||||
'Available': 12.0,
|
'Available': 12.0,
|
||||||
'Pending': 0.0,
|
'Pending': 0.0,
|
||||||
'CryptoAddress': 'XXXX'}]
|
'CryptoAddress': 'XXXX',
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
}, {
|
||||||
|
'Currency': 'ETH',
|
||||||
|
'Balance': 0.0,
|
||||||
|
'Available': 0.0,
|
||||||
|
'Pending': 0.0,
|
||||||
|
'CryptoAddress': 'XXXX',
|
||||||
|
}]
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
msg_mock = MagicMock()
|
msg_mock = MagicMock()
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
mocker.patch.multiple('freqtrade.main.exchange',
|
||||||
get_balances=MagicMock(return_value=mock_balance))
|
get_balances=MagicMock(return_value=mock_balance))
|
||||||
|
|
||||||
_balance(bot=MagicBot(), update=update)
|
_balance(bot=MagicMock(), update=update)
|
||||||
assert msg_mock.call_count == 1
|
assert msg_mock.call_count == 1
|
||||||
assert '*Currency*: BTC' in msg_mock.call_args_list[0][0][0]
|
assert '*Currency*: BTC' in msg_mock.call_args_list[0][0][0]
|
||||||
assert 'Balance' in msg_mock.call_args_list[0][0][0]
|
assert 'Balance' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_handle(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
|
||||||
|
_help(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert '*/help:* `This help message`' in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_handle(default_conf, update, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
msg_mock = MagicMock()
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock(),
|
||||||
|
send_msg=msg_mock)
|
||||||
|
|
||||||
|
_version(bot=MagicMock(), update=update)
|
||||||
|
assert msg_mock.call_count == 1
|
||||||
|
assert '*Version:* `{}`'.format(__version__) in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_msg(default_conf, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock())
|
||||||
|
bot = MagicMock()
|
||||||
|
send_msg('test', bot)
|
||||||
|
assert len(bot.method_calls) == 0
|
||||||
|
bot.reset_mock()
|
||||||
|
|
||||||
|
default_conf['telegram']['enabled'] = True
|
||||||
|
send_msg('test', bot)
|
||||||
|
assert len(bot.method_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_msg_network_error(default_conf, mocker):
|
||||||
|
mocker.patch.dict('freqtrade.main._CONF', default_conf)
|
||||||
|
mocker.patch.multiple('freqtrade.main.telegram',
|
||||||
|
_CONF=default_conf,
|
||||||
|
init=MagicMock())
|
||||||
|
default_conf['telegram']['enabled'] = True
|
||||||
|
bot = MagicMock()
|
||||||
|
bot.send_message = MagicMock(side_effect=NetworkError('Oh snap'))
|
||||||
|
with pytest.raises(NetworkError, match=r'Oh snap'):
|
||||||
|
send_msg('test', bot)
|
||||||
|
|
||||||
|
# Bot should've tried to send it twice
|
||||||
|
assert len(bot.method_calls) == 2
|
||||||
|
2
freqtrade/tests/testdata/btc-edg.json
vendored
2
freqtrade/tests/testdata/btc-edg.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-etc.json
vendored
2
freqtrade/tests/testdata/btc-etc.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-eth.json
vendored
2
freqtrade/tests/testdata/btc-eth.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-ltc.json
vendored
2
freqtrade/tests/testdata/btc-ltc.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-mtl.json
vendored
2
freqtrade/tests/testdata/btc-mtl.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-neo.json
vendored
2
freqtrade/tests/testdata/btc-neo.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-omg.json
vendored
2
freqtrade/tests/testdata/btc-omg.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-pay.json
vendored
2
freqtrade/tests/testdata/btc-pay.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-pivx.json
vendored
2
freqtrade/tests/testdata/btc-pivx.json
vendored
File diff suppressed because one or more lines are too long
2
freqtrade/tests/testdata/btc-qtum.json
vendored
2
freqtrade/tests/testdata/btc-qtum.json
vendored
File diff suppressed because one or more lines are too long
30
freqtrade/tests/testdata/download_backtest_data.py
vendored
Normal file → Executable file
30
freqtrade/tests/testdata/download_backtest_data.py
vendored
Normal file → Executable file
@@ -1,18 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
"""This script generate json data from bittrex"""
|
"""This script generate json data from bittrex"""
|
||||||
|
import json
|
||||||
|
from os import path
|
||||||
|
|
||||||
from urllib.request import urlopen
|
from freqtrade import exchange
|
||||||
|
from freqtrade.exchange import Bittrex
|
||||||
|
|
||||||
CURRENCIES = ["ok", "neo", "dash", "etc", "eth", "snt"]
|
PAIRS = ['BTC-OK', 'BTC-NEO', 'BTC-DASH', 'BTC-ETC', 'BTC-ETH', 'BTC-SNT']
|
||||||
OUTPUT_DIR = 'freqtrade/tests/testdata/'
|
TICKER_INTERVAL = 1 # ticker interval in minutes (currently implemented: 1 and 5)
|
||||||
|
OUTPUT_DIR = path.dirname(path.realpath(__file__))
|
||||||
|
|
||||||
for cur in CURRENCIES:
|
# Init Bittrex exchange
|
||||||
url1 = 'https://bittrex.com/Api/v2.0/pub/market/GetTicks?marketName=BTC-'
|
exchange._API = Bittrex({'key': '', 'secret': ''})
|
||||||
url = url1+cur+'&tickInterval=fiveMin'
|
|
||||||
x = urlopen(url)
|
for pair in PAIRS:
|
||||||
json_data = x.read()
|
data = exchange.get_ticker_history(pair, TICKER_INTERVAL)
|
||||||
json_str = str(json_data, 'utf-8')
|
filename = path.join(OUTPUT_DIR, '{}-{}m.json'.format(
|
||||||
output = OUTPUT_DIR + 'btc-'+cur+'.json'
|
pair.lower(),
|
||||||
with open(output, 'w') as file:
|
TICKER_INTERVAL,
|
||||||
file.write(json_str)
|
))
|
||||||
|
with open(filename, 'w') as fp:
|
||||||
|
json.dump(data, fp)
|
||||||
|
38
freqtrade/vendor/qtpylib/indicators.py
vendored
38
freqtrade/vendor/qtpylib/indicators.py
vendored
@@ -91,7 +91,7 @@ def session(df, start='17:00', end='16:00'):
|
|||||||
curr = prev = df[-1:].index[0].strftime('%Y-%m-%d')
|
curr = prev = df[-1:].index[0].strftime('%Y-%m-%d')
|
||||||
|
|
||||||
# globex/forex session
|
# globex/forex session
|
||||||
if is_same_day == False:
|
if not is_same_day:
|
||||||
prev = (datetime.strptime(curr, '%Y-%m-%d') -
|
prev = (datetime.strptime(curr, '%Y-%m-%d') -
|
||||||
timedelta(1)).strftime('%Y-%m-%d')
|
timedelta(1)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
@@ -117,13 +117,19 @@ def heikinashi(bars):
|
|||||||
bars['ha_high'] = bars.loc[:, ['high', 'ha_open', 'ha_close']].max(axis=1)
|
bars['ha_high'] = bars.loc[:, ['high', 'ha_open', 'ha_close']].max(axis=1)
|
||||||
bars['ha_low'] = bars.loc[:, ['low', 'ha_open', 'ha_close']].min(axis=1)
|
bars['ha_low'] = bars.loc[:, ['low', 'ha_open', 'ha_close']].min(axis=1)
|
||||||
|
|
||||||
return pd.DataFrame(index=bars.index, data={'open': bars['ha_open'],
|
return pd.DataFrame(
|
||||||
'high': bars['ha_high'], 'low': bars['ha_low'], 'close': bars['ha_close']})
|
index=bars.index,
|
||||||
|
data={
|
||||||
|
'open': bars['ha_open'],
|
||||||
|
'high': bars['ha_high'],
|
||||||
|
'low': bars['ha_low'],
|
||||||
|
'close': bars['ha_close']})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
|
|
||||||
def tdi(series, rsi_len=13, bollinger_len=34, rsi_smoothing=2, rsi_signal_len=7, bollinger_std=1.6185):
|
def tdi(series, rsi_len=13, bollinger_len=34, rsi_smoothing=2,
|
||||||
|
rsi_signal_len=7, bollinger_std=1.6185):
|
||||||
rsi_series = rsi(series, rsi_len)
|
rsi_series = rsi(series, rsi_len)
|
||||||
bb_series = bollinger_bands(rsi_series, bollinger_len, bollinger_std)
|
bb_series = bollinger_bands(rsi_series, bollinger_len, bollinger_std)
|
||||||
signal = sma(rsi_series, rsi_signal_len)
|
signal = sma(rsi_series, rsi_signal_len)
|
||||||
@@ -248,9 +254,9 @@ def rolling_std(series, window=200, min_periods=None):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
return series.rolling(window=window, min_periods=min_periods).std()
|
return series.rolling(window=window, min_periods=min_periods).std()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.Series(series).rolling(window=window, min_periods=min_periods).std()
|
return pd.Series(series).rolling(window=window, min_periods=min_periods).std()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.rolling_std(series, window=window, min_periods=min_periods)
|
return pd.rolling_std(series, window=window, min_periods=min_periods)
|
||||||
|
|
||||||
|
|
||||||
@@ -264,9 +270,9 @@ def rolling_mean(series, window=200, min_periods=None):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
return series.rolling(window=window, min_periods=min_periods).mean()
|
return series.rolling(window=window, min_periods=min_periods).mean()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.Series(series).rolling(window=window, min_periods=min_periods).mean()
|
return pd.Series(series).rolling(window=window, min_periods=min_periods).mean()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.rolling_mean(series, window=window, min_periods=min_periods)
|
return pd.rolling_mean(series, window=window, min_periods=min_periods)
|
||||||
|
|
||||||
|
|
||||||
@@ -277,9 +283,9 @@ def rolling_min(series, window=14, min_periods=None):
|
|||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return series.rolling(window=window, min_periods=min_periods).min()
|
return series.rolling(window=window, min_periods=min_periods).min()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.Series(series).rolling(window=window, min_periods=min_periods).min()
|
return pd.Series(series).rolling(window=window, min_periods=min_periods).min()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.rolling_min(series, window=window, min_periods=min_periods)
|
return pd.rolling_min(series, window=window, min_periods=min_periods)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,9 +296,9 @@ def rolling_max(series, window=14, min_periods=None):
|
|||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return series.rolling(window=window, min_periods=min_periods).min()
|
return series.rolling(window=window, min_periods=min_periods).min()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.Series(series).rolling(window=window, min_periods=min_periods).min()
|
return pd.Series(series).rolling(window=window, min_periods=min_periods).min()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.rolling_min(series, window=window, min_periods=min_periods)
|
return pd.rolling_min(series, window=window, min_periods=min_periods)
|
||||||
|
|
||||||
|
|
||||||
@@ -302,7 +308,7 @@ def rolling_weighted_mean(series, window=200, min_periods=None):
|
|||||||
min_periods = window if min_periods is None else min_periods
|
min_periods = window if min_periods is None else min_periods
|
||||||
try:
|
try:
|
||||||
return series.ewm(span=window, min_periods=min_periods).mean()
|
return series.ewm(span=window, min_periods=min_periods).mean()
|
||||||
except:
|
except BaseException:
|
||||||
return pd.ewma(series, span=window, min_periods=min_periods)
|
return pd.ewma(series, span=window, min_periods=min_periods)
|
||||||
|
|
||||||
|
|
||||||
@@ -457,7 +463,7 @@ def returns(series):
|
|||||||
try:
|
try:
|
||||||
res = (series / series.shift(1) -
|
res = (series / series.shift(1) -
|
||||||
1).replace([np.inf, -np.inf], float('NaN'))
|
1).replace([np.inf, -np.inf], float('NaN'))
|
||||||
except:
|
except BaseException:
|
||||||
res = nans(len(series))
|
res = nans(len(series))
|
||||||
|
|
||||||
return pd.Series(index=series.index, data=res)
|
return pd.Series(index=series.index, data=res)
|
||||||
@@ -469,7 +475,7 @@ def log_returns(series):
|
|||||||
try:
|
try:
|
||||||
res = np.log(series / series.shift(1)
|
res = np.log(series / series.shift(1)
|
||||||
).replace([np.inf, -np.inf], float('NaN'))
|
).replace([np.inf, -np.inf], float('NaN'))
|
||||||
except:
|
except BaseException:
|
||||||
res = nans(len(series))
|
res = nans(len(series))
|
||||||
|
|
||||||
return pd.Series(index=series.index, data=res)
|
return pd.Series(index=series.index, data=res)
|
||||||
@@ -482,7 +488,7 @@ def implied_volatility(series, window=252):
|
|||||||
logret = np.log(series / series.shift(1)
|
logret = np.log(series / series.shift(1)
|
||||||
).replace([np.inf, -np.inf], float('NaN'))
|
).replace([np.inf, -np.inf], float('NaN'))
|
||||||
res = numpy_rolling_std(logret, window) * np.sqrt(window)
|
res = numpy_rolling_std(logret, window) * np.sqrt(window)
|
||||||
except:
|
except BaseException:
|
||||||
res = nans(len(series))
|
res = nans(len(series))
|
||||||
|
|
||||||
return pd.Series(index=series.index, data=res)
|
return pd.Series(index=series.index, data=res)
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
-e git+https://github.com/ericsomdahl/python-bittrex.git@d7033d0#egg=python-bittrex
|
-e git+https://github.com/ericsomdahl/python-bittrex.git@0.2.0#egg=python-bittrex
|
||||||
SQLAlchemy==1.1.14
|
SQLAlchemy==1.1.14
|
||||||
python-telegram-bot==8.1.1
|
python-telegram-bot==8.1.1
|
||||||
arrow==0.10.0
|
arrow==0.10.0
|
||||||
|
cachetools==2.0.1
|
||||||
requests==2.18.4
|
requests==2.18.4
|
||||||
urllib3==1.22
|
urllib3==1.22
|
||||||
wrapt==1.10.11
|
wrapt==1.10.11
|
||||||
@@ -17,7 +18,8 @@ pytest-cov==2.5.1
|
|||||||
hyperopt==0.1
|
hyperopt==0.1
|
||||||
# do not upgrade networkx before this is fixed https://github.com/hyperopt/hyperopt/issues/325
|
# do not upgrade networkx before this is fixed https://github.com/hyperopt/hyperopt/issues/325
|
||||||
networkx==1.11
|
networkx==1.11
|
||||||
|
tabulate==0.8.1
|
||||||
|
|
||||||
# Required for plotting data
|
# Required for plotting data
|
||||||
#matplotlib==2.1.0
|
#matplotlib==2.1.0
|
||||||
#PYQT5==5.9
|
#PYQT5==5.9
|
||||||
|
51
scripts/plot_dataframe.py
Executable file
51
scripts/plot_dataframe.py
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import matplotlib # Install PYQT5 manually if you want to test this helper function
|
||||||
|
matplotlib.use("Qt5Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from freqtrade import exchange, analyze
|
||||||
|
|
||||||
|
|
||||||
|
def plot_analyzed_dataframe(pair: str) -> None:
|
||||||
|
"""
|
||||||
|
Calls analyze() and plots the returned dataframe
|
||||||
|
:param pair: pair as str
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Init Bittrex to use public API
|
||||||
|
exchange._API = exchange.Bittrex({'key': '', 'secret': ''})
|
||||||
|
dataframe = analyze.analyze_ticker(pair)
|
||||||
|
|
||||||
|
# Two subplots sharing x axis
|
||||||
|
fig, (ax1, ax2, ax3) = plt.subplots(3, sharex=True)
|
||||||
|
fig.suptitle(pair, fontsize=14, fontweight='bold')
|
||||||
|
ax1.plot(dataframe.index.values, dataframe['close'], label='close')
|
||||||
|
# ax1.plot(dataframe.index.values, dataframe['sell'], 'ro', label='sell')
|
||||||
|
ax1.plot(dataframe.index.values, dataframe['sma'], '--', label='SMA')
|
||||||
|
ax1.plot(dataframe.index.values, dataframe['tema'], ':', label='TEMA')
|
||||||
|
ax1.plot(dataframe.index.values, dataframe['blower'], '-.', label='BB low')
|
||||||
|
ax1.plot(dataframe.index.values, dataframe['buy_price'], 'bo', label='buy')
|
||||||
|
ax1.legend()
|
||||||
|
|
||||||
|
ax2.plot(dataframe.index.values, dataframe['adx'], label='ADX')
|
||||||
|
ax2.plot(dataframe.index.values, dataframe['mfi'], label='MFI')
|
||||||
|
# ax2.plot(dataframe.index.values, [25] * len(dataframe.index.values))
|
||||||
|
ax2.legend()
|
||||||
|
|
||||||
|
ax3.plot(dataframe.index.values, dataframe['fastk'], label='k')
|
||||||
|
ax3.plot(dataframe.index.values, dataframe['fastd'], label='d')
|
||||||
|
ax3.plot(dataframe.index.values, [20] * len(dataframe.index.values))
|
||||||
|
ax3.legend()
|
||||||
|
|
||||||
|
# Fine-tune figure; make subplots close to each other and hide x ticks for
|
||||||
|
# all but bottom plot.
|
||||||
|
fig.subplots_adjust(hspace=0)
|
||||||
|
plt.setp([a.get_xticklabels() for a in fig.axes[:-1]], visible=False)
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
plot_analyzed_dataframe('BTC_ETH')
|
||||||
|
|
34
setup.py
34
setup.py
@@ -1,5 +1,11 @@
|
|||||||
|
from sys import version_info
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
|
if version_info.major == 3 and version_info.minor < 6 or \
|
||||||
|
version_info.major < 3:
|
||||||
|
print('Your Python interpreter must be 3.6 or greater!')
|
||||||
|
exit(1)
|
||||||
|
|
||||||
from freqtrade import __version__
|
from freqtrade import __version__
|
||||||
|
|
||||||
|
|
||||||
@@ -15,21 +21,23 @@ setup(name='freqtrade',
|
|||||||
setup_requires=['pytest-runner'],
|
setup_requires=['pytest-runner'],
|
||||||
tests_require=['pytest', 'pytest-mock', 'pytest-cov'],
|
tests_require=['pytest', 'pytest-mock', 'pytest-cov'],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'python-bittrex==0.1.3',
|
'python-bittrex',
|
||||||
'SQLAlchemy==1.1.13',
|
'SQLAlchemy',
|
||||||
'python-telegram-bot==8.1.1',
|
'python-telegram-bot',
|
||||||
'arrow==0.10.0',
|
'arrow',
|
||||||
'requests==2.18.4',
|
'requests',
|
||||||
'urllib3==1.22',
|
'urllib3',
|
||||||
'wrapt==1.10.11',
|
'wrapt',
|
||||||
'pandas==0.20.3',
|
'pandas',
|
||||||
'scikit-learn==0.19.0',
|
'scikit-learn',
|
||||||
'scipy==0.19.1',
|
'scipy',
|
||||||
'jsonschema==2.6.0',
|
'jsonschema',
|
||||||
'TA-Lib==0.4.10',
|
'TA-Lib',
|
||||||
|
'tabulate',
|
||||||
|
'cachetools',
|
||||||
],
|
],
|
||||||
dependency_links=[
|
dependency_links=[
|
||||||
"git+https://github.com/ericsomdahl/python-bittrex.git@d7033d0#egg=python-bittrex-0.1.3"
|
"git+https://github.com/ericsomdahl/python-bittrex.git@0.2.0#egg=python-bittrex"
|
||||||
],
|
],
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
|
Reference in New Issue
Block a user