diff --git a/.coveragerc b/.coveragerc index 3d5e5889f..9e4dc2c18 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,4 @@ [run] -omit = freqtrade/tests/* +omit = + freqtrade/tests/* + freqtrade/vendor/* \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index eddcbf7a5..a876357c4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,2 +1,3 @@ [BASIC] good-names=logger +ignore=vendor \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 461324f45..1d6a6a9b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,9 @@ os: language: python python: - 3.6 +env: + - BACKTEST= + - BACKTEST=true addons: apt: packages: diff --git a/MANIFEST.in b/MANIFEST.in index ef776087e..63508c05d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,5 @@ include LICENSE include README.md include config.json.example -include freqtrade/exchange/*.py -include freqtrade/rpc/*.py -include freqtrade/tests/*.py +recursive-include freqtrade *.py include freqtrade/tests/testdata/*.json diff --git a/README.md b/README.md index aa6d1a506..ee738cb28 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ and enter the telegram `token` and your `chat_id` in `config.json` Persistence is achieved through sqlite. -#### Telegram RPC commands: +### Telegram RPC commands: * /start: Starts the trader * /stop: Stops the trader * /status [table]: Lists all open trades @@ -25,7 +25,7 @@ Persistence is achieved through sqlite. * /forcesell : Instantly sells the given trade (Ignoring `minimum_roi`). * /performance: Show performance of each finished trade grouped by pair -#### Config +### Config `minimal_roi` is a JSON object where the key is a duration in minutes and the value is the minimum ROI in percent. See the example below: @@ -54,12 +54,18 @@ end up paying more then would probably have been necessary. The other values should be self-explanatory, if not feel free to raise a github issue. -#### Prerequisites +### Prerequisites * python3.6 * sqlite * [TA-lib](https://github.com/mrjbq7/ta-lib#dependencies) binaries -#### Install +### Install + +#### Arch Linux + +Use your favorite AUR helper and install `python-freqtrade-git`. + +#### Manually `master` branch contains the latest stable release. @@ -76,18 +82,9 @@ $ pip install -e . $ ./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 - -``` -$ pytest -``` -This will by default skip the slow running backtest set. To run backtest set: - -``` -$ BACKTEST=true pytest -s freqtrade/tests/test_backtesting.py -``` +\* *Note:* that article was written for an earlier version, so it may be outdated #### Docker @@ -137,7 +134,18 @@ $ docker start freqtrade You do not need to rebuild the image for configuration 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: diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 44a21be08..916143251 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,3 +1,3 @@ -__version__ = '0.12.0' +__version__ = '0.13.0' from . import main diff --git a/freqtrade/analyze.py b/freqtrade/analyze.py index 1eeb8408d..73365969d 100644 --- a/freqtrade/analyze.py +++ b/freqtrade/analyze.py @@ -5,10 +5,10 @@ from datetime import timedelta import arrow import talib.abstract as ta from pandas import DataFrame, to_datetime -from qtpylib.indicators import awesome_oscillator, crossed_above from freqtrade import exchange from freqtrade.exchange import Bittrex, get_ticker_history +from freqtrade.vendor.qtpylib.indicators import awesome_oscillator logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -17,8 +17,8 @@ logger = logging.getLogger(__name__) def parse_ticker_dataframe(ticker: list) -> DataFrame: """ - Analyses the trend for the given pair - :param pair: pair as str in format BTC_ETH or BTC-ETH + Analyses the trend for the given ticker history + :param ticker: See exchange.get_ticker_history :return: DataFrame """ df = DataFrame(ticker) \ @@ -43,8 +43,17 @@ def populate_indicators(dataframe: DataFrame) -> DataFrame: dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9) dataframe['mfi'] = ta.MFI(dataframe) dataframe['cci'] = ta.CCI(dataframe) + dataframe['rsi'] = ta.RSI(dataframe) + dataframe['mom'] = ta.MOM(dataframe) + dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5) + dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10) + dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50) + dataframe['ema100'] = ta.EMA(dataframe, timeperiod=100) dataframe['ao'] = awesome_oscillator(dataframe) - + macd = ta.MACD(dataframe) + dataframe['macd'] = macd['macd'] + dataframe['macdsignal'] = macd['macdsignal'] + dataframe['macdhist'] = macd['macdhist'] return dataframe @@ -152,7 +161,7 @@ def plot_dataframe(dataframe: DataFrame, pair: str) -> None: if __name__ == '__main__': # Install PYQT5==5.9 manually if you want to test this helper function while True: - exchange.EXCHANGE = Bittrex({'key': '', 'secret': ''}) + exchange._API = Bittrex({'key': '', 'secret': ''}) test_pair = 'BTC_ETH' # for pair in ['BTC_ANT', 'BTC_ETH', 'BTC_GNT', 'BTC_ETC']: # get_buy_signal(pair) diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 77a2d4b84..4807ff295 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -1,6 +1,6 @@ import enum import logging -from typing import List +from typing import List, Dict import arrow @@ -10,7 +10,7 @@ from freqtrade.exchange.interface import Exchange logger = logging.getLogger(__name__) # Current selected exchange -EXCHANGE: Exchange = None +_API: Exchange = None _CONF: dict = {} @@ -29,7 +29,7 @@ def init(config: dict) -> None: :param config: config to use :return: None """ - global _CONF, EXCHANGE + global _CONF, _API _CONF.update(config) @@ -45,7 +45,7 @@ def init(config: dict) -> None: except KeyError: raise RuntimeError('Exchange {} is not supported'.format(name)) - EXCHANGE = exchange_class(exchange_config) + _API = exchange_class(exchange_config) # Check if all pairs are available validate_pairs(config['exchange']['pair_whitelist']) @@ -58,58 +58,86 @@ def validate_pairs(pairs: List[str]) -> None: :param pairs: list of pairs :return: None """ - markets = EXCHANGE.get_markets() + markets = _API.get_markets() for pair in pairs: 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: if _CONF['dry_run']: - return 'dry_run' + return 'dry_run_buy' - return EXCHANGE.buy(pair, rate, amount) + return _API.buy(pair, rate, amount) def sell(pair: str, rate: float, amount: float) -> str: if _CONF['dry_run']: - return 'dry_run' + return 'dry_run_sell' - return EXCHANGE.sell(pair, rate, amount) + return _API.sell(pair, rate, amount) def get_balance(currency: str) -> float: if _CONF['dry_run']: return 999.9 - return EXCHANGE.get_balance(currency) + return _API.get_balance(currency) + + +def get_balances(): + if _CONF['dry_run']: + return [] + + return _API.get_balances() 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): - return EXCHANGE.get_ticker_history(pair, minimum_date) + return _API.get_ticker_history(pair, minimum_date) def cancel_order(order_id: str) -> None: if _CONF['dry_run']: 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']: - return [] + return { + 'id': 'dry_run_sell', + 'type': 'LIMIT_SELL', + 'pair': 'mocked', + 'opened': arrow.utcnow().datetime, + 'rate': 0.07256060, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': arrow.utcnow().datetime, + } - return EXCHANGE.get_open_orders(pair) + return _API.get_order(order_id) 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]: - return EXCHANGE.get_markets() + return _API.get_markets() + + +def get_name() -> str: + return _API.name + + +def get_sleep_time() -> float: + return _API.sleep_time + + +def get_fee() -> float: + return _API.fee diff --git a/freqtrade/exchange/bittrex.py b/freqtrade/exchange/bittrex.py index cb85aaf87..111f05606 100644 --- a/freqtrade/exchange/bittrex.py +++ b/freqtrade/exchange/bittrex.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import List, Optional, Dict import arrow import requests @@ -36,6 +36,11 @@ class Bittrex(Exchange): _EXCHANGE_CONF.update(config) _API = _Bittrex(api_key=_EXCHANGE_CONF['key'], api_secret=_EXCHANGE_CONF['secret']) + @property + def fee(self) -> float: + # See https://bittrex.com/fees + return 0.0025 + def buy(self, pair: str, rate: float, amount: float) -> str: data = _API.buy_limit(pair.replace('_', '-'), amount, rate) if not data['success']: @@ -54,6 +59,12 @@ class Bittrex(Exchange): raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) return float(data['result']['Balance'] or 0.0) + def get_balances(self): + data = _API.get_balances() + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + return data['result'] + def get_ticker(self, pair: str) -> dict: data = _API.get_ticker(pair.replace('_', '-')) if not data['success']: @@ -81,24 +92,27 @@ class Bittrex(Exchange): raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) return data + def get_order(self, order_id: str) -> Dict: + data = _API.get_order(order_id) + if not data['success']: + raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) + 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: data = _API.cancel(order_id) if not data['success']: raise RuntimeError('{}: {}'.format(self.name.upper(), data['message'])) - def get_open_orders(self, pair: str) -> List[dict]: - 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: return self.PAIR_DETAIL_METHOD + '?MarketName={}'.format(pair.replace('_', '-')) diff --git a/freqtrade/exchange/interface.py b/freqtrade/exchange/interface.py index 114ac9a6f..364b5afa4 100644 --- a/freqtrade/exchange/interface.py +++ b/freqtrade/exchange/interface.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional +from typing import List, Optional, Dict import arrow @@ -13,6 +13,14 @@ class Exchange(ABC): """ return self.__class__.__name__ + @property + def fee(self) -> float: + """ + Fee for placing an order + :return: percentage in float + """ + return 0.0 + @property @abstractmethod def sleep_time(self) -> float: @@ -49,6 +57,21 @@ class Exchange(ABC): :return: float """ + @abstractmethod + def get_balances(self) -> List[dict]: + """ + Gets account balances across currencies + :return: List of dicts, format: [ + { + 'Currency': str, + 'Balance': float, + 'Available': float, + 'Pending': float, + } + ... + ] + """ + @abstractmethod def get_ticker(self, pair: str) -> dict: """ @@ -85,6 +108,22 @@ class Exchange(ABC): } """ + 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: { + 'id': str, + 'type': str, + 'pair': str, + 'opened': str ISO 8601 datetime, + 'closed': str ISO 8601 datetime, + 'rate': float, + 'amount': float, + 'remaining': int + } + """ + @abstractmethod def cancel_order(self, order_id: str) -> None: """ @@ -93,24 +132,6 @@ class Exchange(ABC): :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 def get_pair_detail_url(self, pair: str) -> str: """ diff --git a/freqtrade/main.py b/freqtrade/main.py index 68277adaa..7c89da55f 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import Dict, Optional from signal import signal, SIGINT, SIGABRT, SIGTERM +import requests from jsonschema import validate from freqtrade import __version__, exchange, persistence @@ -44,22 +45,21 @@ def _process() -> None: logger.exception('Unable to create trade') for trade in trades: - # Check if there is already an open order for this trade - orders = exchange.get_open_orders(trade.pair) - orders = [o for o in orders if o['id'] == trade.open_order_id] - if orders: - logger.info('There is an open order for: %s', orders[0]) - else: - # Update state - trade.open_order_id = None - # Check if this trade can be closed - if not close_trade_if_fulfilled(trade): - # Check if we can sell our current pair - handle_trade(trade) - Trade.session.flush() - except (ConnectionError, json.JSONDecodeError) as error: - msg = 'Got {} in _process()'.format(error.__class__.__name__) + # Get order details for actual price per unit + if trade.open_order_id: + # Update trade with order values + logger.info('Got open order for %s', trade) + trade.update(exchange.get_order(trade.open_order_id)) + + if not close_trade_if_fulfilled(trade): + # Check if we can sell our current pair + handle_trade(trade) + + Trade.session.flush() + except (requests.exceptions.ConnectionError, json.JSONDecodeError) as error: + msg = 'Got {} in _process(), retrying in 30 seconds...'.format(error.__class__.__name__) logger.exception(msg) + time.sleep(30) def close_trade_if_fulfilled(trade: Trade) -> bool: @@ -80,23 +80,25 @@ def close_trade_if_fulfilled(trade: Trade) -> bool: 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 current_rate: current rate + :param limit: limit rate for the sell order :return: None """ - # Get available balance - currency = trade.pair.split('_')[1] - balance = exchange.get_balance(currency) - profit = trade.exec_sell_order(current_rate, balance) - message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format( + # Execute sell and update trade record + order_id = exchange.sell(str(trade.pair), limit, trade.amount) + trade.open_order_id = order_id + trade.close_date = datetime.utcnow() + + fmt_exp_profit = round(trade.calc_profit(limit) * 100, 2) + message = '*{}:* Selling [{}]({}) with limit `{:f} (profit: ~{}%)`'.format( trade.exchange, trade.pair.replace('_', '/'), exchange.get_pair_detail_url(trade.pair), - trade.close_rate, - round(profit, 2) + limit, + fmt_exp_profit ) logger.info(message) telegram.send_msg(message) @@ -107,17 +109,15 @@ 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 :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']): logger.debug('Stop loss hit.') return True for duration, threshold in sorted(_CONF['minimal_roi'].items()): - duration, threshold = float(duration), float(threshold) # Check if time matches and current rate is above threshold time_diff = (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 logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit * 100.0) @@ -133,7 +133,7 @@ def handle_trade(trade: Trade) -> None: if not trade.is_open: 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'] if should_sell(trade, current_rate, datetime.utcnow()): @@ -163,7 +163,7 @@ def create_trade(stake_amount: float) -> Optional[Trade]: # Check if stake_amount is fulfilled if exchange.get_balance(_CONF['stake_currency']) < stake_amount: 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 @@ -182,25 +182,29 @@ def create_trade(stake_amount: float) -> Optional[Trade]: else: return None - open_rate = get_target_bid(exchange.get_ticker(pair)) - amount = stake_amount / open_rate - order_id = exchange.buy(pair, open_rate, amount) + # Calculate amount and subtract fee + fee = exchange.get_fee() + 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 - message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format( - exchange.EXCHANGE.name.upper(), + message = '*{}:* Buying [{}]({}) with limit `{:f}`'.format( + exchange.get_name().upper(), pair.replace('_', '/'), exchange.get_pair_detail_url(pair), - open_rate + buy_limit ) logger.info(message) telegram.send_msg(message) + # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL return Trade(pair=pair, stake_amount=stake_amount, - open_rate=open_rate, - open_date=datetime.utcnow(), amount=amount, - exchange=exchange.EXCHANGE.name.upper(), + fee=fee * 2, + open_rate=buy_limit, + open_date=datetime.utcnow(), + exchange=exchange.get_name().upper(), open_order_id=order_id, is_open=True) @@ -266,7 +270,7 @@ def app(config: dict) -> None: 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) + time.sleep(exchange.get_sleep_time()) old_state = new_state except RuntimeError: telegram.send_msg( diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index fa51b1349..c122d830e 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -1,16 +1,19 @@ +import logging 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.ext.declarative import declarative_base from sqlalchemy.orm.scoping import scoped_session from sqlalchemy.orm.session import sessionmaker -from sqlalchemy.types import Enum -from freqtrade import exchange +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) _CONF = {} - Base = declarative_base() @@ -26,9 +29,9 @@ def init(config: dict, db_url: Optional[str] = None) -> None: _CONF.update(config) if not db_url: if _CONF.get('dry_run', False): - db_url = 'sqlite:///tradesv2.dry_run.sqlite' + db_url = 'sqlite:///tradesv3.dry_run.sqlite' else: - db_url = 'sqlite:///tradesv2.sqlite' + db_url = 'sqlite:///tradesv3.sqlite' engine = create_engine(db_url, echo=False) session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True)) @@ -52,44 +55,55 @@ class Trade(Base): exchange = Column(String, nullable=False) pair = Column(String, nullable=False) 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_profit = Column(Float) - stake_amount = Column(Float, name='btc_amount', nullable=False) - amount = Column(Float, nullable=False) + stake_amount = Column(Float, nullable=False) + amount = Column(Float) open_date = Column(DateTime, nullable=False, default=datetime.utcnow) close_date = Column(DateTime) open_order_id = Column(String) def __repr__(self): - if self.is_open: - open_since = 'closed' - else: - open_since = round((datetime.utcnow() - self.open_date).total_seconds() / 60, 2) return 'Trade(id={}, pair={}, amount={}, open_rate={}, open_since={})'.format( self.id, self.pair, self.amount, self.open_rate, - 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. - :param rate: rate to sell for - :param amount: amount to sell - :return: current profit as percentage + Updates this entity with amount and actual open/close rates. + :param order: order retrieved by exchange.get_order() + :return: None """ - profit = 100 * ((rate - self.open_rate) / self.open_rate) + if not order['closed']: + return - # Execute sell and update trade record - order_id = exchange.sell(str(self.pair), rate, amount) - self.close_rate = rate - self.close_profit = profit - self.close_date = datetime.utcnow() - self.open_order_id = order_id + logger.debug('Updating trade (id=%d) ...', self.id) + if order['type'] == 'LIMIT_BUY': + # Update open rate and actual amount + self.open_rate = order['rate'] + self.amount = order['amount'] + elif order['type'] == 'LIMIT_SELL': + # Set close rate and set actual profit + self.close_rate = order['rate'] + self.close_profit = self.calc_profit() + else: + raise ValueError('Unknown order type: {}'.format(order['type'])) - # Flush changes - Trade.session.flush() - return profit + self.open_order_id = None + + 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)) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 4497f68eb..ca565d16d 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -35,7 +35,7 @@ def init(config: dict) -> None: global _updater _CONF.update(config) - if not _CONF['telegram']['enabled']: + if not is_enabled(): return _updater = Updater(token=config['telegram']['token'], workers=0) @@ -44,6 +44,7 @@ def init(config: dict) -> None: handles = [ CommandHandler('status', _status), CommandHandler('profit', _profit), + CommandHandler('balance', _balance), CommandHandler('start', _start), CommandHandler('stop', _stop), CommandHandler('forcesell', _forcesell), @@ -70,9 +71,18 @@ def cleanup() -> None: Stops all running telegram threads. :return: None """ + 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]: """ Decorator to check if the message comes from the correct chat_id @@ -116,18 +126,15 @@ def _status(bot: Bot, update: Update) -> None: 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) + send_msg('*Status:* `no active trade`', bot=bot) else: for trade in trades: + order = exchange.get_order(trade.open_order_id) # calculate profit and send message to user current_rate = exchange.get_ticker(trade.pair)['bid'] - current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_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 - + current_profit = trade.calc_profit(current_rate) fmt_close_profit = '{:.2f}%'.format( - round(trade.close_profit, 2) + round(trade.close_profit * 100, 2) ) if trade.close_profit else None message = """ *Trade ID:* `{trade_id}` @@ -150,8 +157,10 @@ def _status(bot: Bot, update: Update) -> None: current_rate=current_rate, amount=round(trade.amount, 8), close_profit=fmt_close_profit, - current_profit=round(current_profit, 2), - open_order='{} ({})'.format(order['remaining'], order['type']) if order else None, + current_profit=round(current_profit * 100, 2), + open_order='{} ({})'.format( + order['remaining'], order['type'] + ) if order else None, ) send_msg(message, bot=bot) @@ -214,6 +223,8 @@ def _profit(bot: Bot, update: Update) -> None: profits = [] durations = [] for trade in trades: + if not trade.open_rate: + continue if trade.close_date: durations.append((trade.close_date - trade.open_date).total_seconds()) if trade.close_profit: @@ -221,9 +232,9 @@ def _profit(bot: Bot, update: Update) -> None: else: # Get current rate 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) best_pair = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \ @@ -238,25 +249,49 @@ def _profit(bot: Bot, update: Update) -> None: bp_pair, bp_rate = best_pair markdown_msg = """ -*ROI:* `{profit_btc:.2f} ({profit:.2f}%)` +*ROI:* `{profit_btc:.6f} ({profit:.2f}%)` *Trade Count:* `{trade_count}` *First Trade opened:* `{first_trade_date}` *Latest Trade opened:* `{latest_trade_date}` *Avg. Duration:* `{avg_duration}` *Best Performing:* `{best_pair}: {best_rate:.2f}%` +{dry_run_info} """.format( profit_btc=round(sum(profit_amounts), 8), - profit=round(sum(profits), 2), + profit=round(sum(profits) * 100, 2), trade_count=len(trades), first_trade_date=arrow.get(trades[0].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], best_pair=bp_pair, - best_rate=round(bp_rate, 2), + best_rate=round(bp_rate * 100, 2), + dry_run_info='\n*NOTE:* These values are mocked because *dry_run* is enabled!' + if _CONF['dry_run'] else '' ) send_msg(markdown_msg, bot=bot) +@authorized_only +def _balance(bot: Bot, update: Update) -> None: + """ + Handler for /balance + Returns current account balance per crypto + """ + output = "" + balances = exchange.get_balances() + for currency in balances: + if not currency['Balance'] and not currency['Available'] and not currency['Pending']: + continue + output += """*Currency*: {Currency} +*Available*: {Available} +*Balance*: {Balance} +*Pending*: {Pending} + +""".format(**currency) + + send_msg(output) + + @authorized_only def _start(bot: Bot, update: Update) -> None: """ @@ -315,20 +350,8 @@ def _forcesell(bot: Bot, update: Update) -> None: 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) + from freqtrade.main import execute_sell + execute_sell(trade, current_rate) except ValueError: send_msg('Invalid argument. Usage: `/forcesell `') @@ -357,10 +380,14 @@ def _performance(bot: Bot, update: Update) -> None: stats = '\n'.join('{index}. {pair}\t{profit:.2f}%'.format( index=i + 1, pair=pair, - profit=round(rate, 2) + profit=round(rate * 100, 2) ) for i, (pair, rate) in enumerate(pair_rates)) - message = 'Performance:\n{}\n'.format(stats) + message = 'Performance:\n{}\n{}'.format( + stats, + 'NOTE: These values are mocked because dry_run is enabled.' + if _CONF['dry_run'] else '' + ) logger.debug(message) send_msg(message, parse_mode=ParseMode.HTML) @@ -403,6 +430,7 @@ def _help(bot: Bot, update: Update) -> None: */forcesell :* `Instantly sells the given trade, regardless of profit` */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` */help:* `This help message` """ send_msg(message, bot=bot) @@ -428,18 +456,19 @@ def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDO :param parse_mode: telegram parse mode :return: None """ - if _CONF['telegram'].get('enabled', False): + if not is_enabled(): + return + try: + bot = bot or _updater.bot try: - bot = bot or _updater.bot - try: - bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode) - except NetworkError as error: - # Sometimes the telegram server resets the current connection, - # if this is the case we send the message again. - logger.warning( - 'Got Telegram NetworkError: %s! Trying one more time.', - error.message - ) - bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode) - except Exception: - logger.exception('Exception occurred within Telegram API') + bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode) + except NetworkError as error: + # Sometimes the telegram server resets the current connection, + # if this is the case we send the message again. + logger.warning( + 'Got Telegram NetworkError: %s! Trying one more time.', + error.message + ) + bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode) + except Exception: + logger.exception('Exception occurred within Telegram API') diff --git a/freqtrade/tests/test_analyze.py b/freqtrade/tests/test_analyze.py index 0c363e4a3..18d232eef 100644 --- a/freqtrade/tests/test_analyze.py +++ b/freqtrade/tests/test_analyze.py @@ -7,6 +7,7 @@ from pandas import DataFrame from freqtrade.analyze import parse_ticker_dataframe, populate_buy_trend, populate_indicators, \ get_buy_signal + @pytest.fixture def result(): with open('freqtrade/tests/testdata/btc-eth.json') as data_file: @@ -14,18 +15,22 @@ def result(): return parse_ticker_dataframe(data['result']) + def test_dataframe_has_correct_columns(result): assert result.columns.tolist() == \ ['close', 'high', 'low', 'open', 'date', 'volume'] + def test_dataframe_has_correct_length(result): assert len(result.index) == 5751 + def test_populates_buy_trend(result): dataframe = populate_buy_trend(populate_indicators(result)) assert 'buy' in dataframe.columns assert 'buy_price' in dataframe.columns + def test_returns_latest_buy_signal(mocker): buydf = DataFrame([{'buy': 1, 'date': datetime.today()}]) mocker.patch('freqtrade.analyze.analyze_ticker', return_value=buydf) diff --git a/freqtrade/tests/test_backtesting.py b/freqtrade/tests/test_backtesting.py index 29a9627a3..bf6b81a52 100644 --- a/freqtrade/tests/test_backtesting.py +++ b/freqtrade/tests/test_backtesting.py @@ -7,11 +7,14 @@ import pytest import arrow from pandas import DataFrame +from freqtrade import exchange from freqtrade.analyze import analyze_ticker +from freqtrade.exchange import Bittrex from freqtrade.main import should_sell 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): return 'Made {} buys. Average profit {:.2f}%. Total profit was {:.3f}. Average duration {:.1f} mins.'.format( @@ -21,15 +24,18 @@ def format_results(results): results.duration.mean() * 5 ) + def print_pair_results(pair, results): print('For currency {}:'.format(pair)) print(format_results(results[results.currency == pair])) + @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 { @@ -42,23 +48,29 @@ def conf(): "stoploss": -0.40 } + def backtest(conf, pairs, mocker): trades = [] + exchange._API = Bittrex({'key': '', 'secret': ''}) + mocked_history = mocker.patch('freqtrade.analyze.get_ticker_history') mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00')) for pair in pairs: with open('freqtrade/tests/testdata/'+pair+'.json') as data_file: - data = json.load(data_file) - - mocker.patch('freqtrade.analyze.get_ticker_history', return_value=data) - mocker.patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00')) + mocked_history.return_value = json.load(data_file) ticker = analyze_ticker(pair)[['close', 'date', 'buy']].copy() # for each buy point for row in ticker[ticker.buy == 1].itertuples(index=True): - trade = Trade(open_rate=row.close, open_date=row.date, amount=1) + trade = Trade( + open_rate=row.close, + open_date=row.date, + amount=1, + fee=exchange.get_fee()*2 + ) # 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 = (row2.close - trade.open_rate) / trade.open_rate + current_profit = trade.calc_profit(row2.close) trades.append((pair, current_profit, row2.Index - row.Index)) break @@ -66,11 +78,13 @@ def backtest(conf, pairs, mocker): results = DataFrame.from_records(trades, columns=labels) return results + @pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set") def test_backtest(conf, pairs, mocker, report=True): results = backtest(conf, pairs, mocker) print('====================== BACKTESTING REPORT ================================') - [print_pair_results(pair, results) for pair in pairs] + for pair in pairs: + print_pair_results(pair, results) print('TOTAL OVER ALL TRADES:') print(format_results(results)) diff --git a/freqtrade/tests/test_hyperopt.py b/freqtrade/tests/test_hyperopt.py index fa1c0fab1..ad2e5564c 100644 --- a/freqtrade/tests/test_hyperopt.py +++ b/freqtrade/tests/test_hyperopt.py @@ -1,18 +1,18 @@ # pragma pylint: disable=missing-docstring -from operator import itemgetter import logging import os from functools import reduce from math import exp -import pytest -from pandas import DataFrame -from qtpylib.indicators import crossed_above +from operator import itemgetter +import pytest from hyperopt import fmin, tpe, hp, Trials, STATUS_OK +from pandas import DataFrame from freqtrade.tests.test_backtesting import backtest, format_results +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 TARGET_TRADES = 1200 @@ -23,6 +23,7 @@ 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 { @@ -35,15 +36,15 @@ def conf(): "stoploss": -0.05 } + def buy_strategy_generator(params): print(params) + def populate_buy_trend(dataframe: DataFrame) -> DataFrame: conditions = [] # GUARDS AND TRENDS - if params['below_sma']['enabled']: - conditions.append(dataframe['close'] < dataframe['sma']) - if params['over_sma']['enabled']: - conditions.append(dataframe['close'] > dataframe['sma']) + if params['uptrend_long_ema']['enabled']: + conditions.append(dataframe['ema50'] > dataframe['ema100']) if params['mfi']['enabled']: conditions.append(dataframe['mfi'] < params['mfi']['value']) if params['fastd']['enabled']: @@ -52,6 +53,8 @@ def buy_strategy_generator(params): conditions.append(dataframe['adx'] > params['adx']['value']) if params['cci']['enabled']: conditions.append(dataframe['cci'] < params['cci']['value']) + if params['rsi']['enabled']: + conditions.append(dataframe['rsi'] < params['rsi']['value']) if params['over_sar']['enabled']: conditions.append(dataframe['close'] > dataframe['sar']) if params['uptrend_sma']['enabled']: @@ -64,6 +67,8 @@ def buy_strategy_generator(params): 'lower_bb': dataframe['tema'] <= dataframe['blower'], 'faststoch10': (dataframe['fastd'] >= 10) & (prev_fastd < 10), 'ao_cross_zero': (crossed_above(dataframe['ao'], 0.0)), + 'ema5_cross_ema10': (crossed_above(dataframe['ema5'], dataframe['ema10'])), + 'macd_cross_signal': (crossed_above(dataframe['macd'], dataframe['macdsignal'])), } conditions.append(triggers.get(params['trigger']['type'])) @@ -75,11 +80,14 @@ def buy_strategy_generator(params): return dataframe return populate_buy_trend + @pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set") def test_hyperopt(conf, pairs, mocker): + mocked_buy_trend = mocker.patch('freqtrade.analyze.populate_buy_trend') + def optimizer(params): - buy_strategy = buy_strategy_generator(params) - mocker.patch('freqtrade.analyze.populate_buy_trend', side_effect=buy_strategy) + mocked_buy_trend.side_effect = buy_strategy_generator(params) + results = backtest(conf, pairs, mocker) result = format_results(results) @@ -100,25 +108,25 @@ def test_hyperopt(conf, pairs, mocker): space = { 'mfi': hp.choice('mfi', [ {'enabled': False}, - {'enabled': True, 'value': hp.uniform('mfi-value', 2, 40)} + {'enabled': True, 'value': hp.uniform('mfi-value', 5, 15)} ]), 'fastd': hp.choice('fastd', [ {'enabled': False}, - {'enabled': True, 'value': hp.uniform('fastd-value', 2, 40)} + {'enabled': True, 'value': hp.uniform('fastd-value', 5, 40)} ]), 'adx': hp.choice('adx', [ {'enabled': False}, - {'enabled': True, 'value': hp.uniform('adx-value', 2, 40)} + {'enabled': True, 'value': hp.uniform('adx-value', 10, 30)} ]), 'cci': hp.choice('cci', [ {'enabled': False}, - {'enabled': True, 'value': hp.uniform('cci-value', -200, -100)} + {'enabled': True, 'value': hp.uniform('cci-value', -150, -100)} ]), - 'below_sma': hp.choice('below_sma', [ + 'rsi': hp.choice('rsi', [ {'enabled': False}, - {'enabled': True} + {'enabled': True, 'value': hp.uniform('rsi-value', 20, 30)} ]), - 'over_sma': hp.choice('over_sma', [ + 'uptrend_long_ema': hp.choice('uptrend_long_ema', [ {'enabled': False}, {'enabled': True} ]), @@ -133,11 +141,13 @@ def test_hyperopt(conf, pairs, mocker): 'trigger': hp.choice('trigger', [ {'type': 'lower_bb'}, {'type': 'faststoch10'}, - {'type': 'ao_cross_zero'} + {'type': 'ao_cross_zero'}, + {'type': 'ema5_cross_ema10'}, + {'type': 'macd_cross_signal'}, ]), } 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=4, trials=trials) print('\n\n\n\n====================== HYPEROPT BACKTESTING REPORT ================================') print('Best parameters {}'.format(best)) newlist = sorted(trials.results, key=itemgetter('loss')) diff --git a/freqtrade/tests/test_main.py b/freqtrade/tests/test_main.py index 050d21ad4..b49a685b6 100644 --- a/freqtrade/tests/test_main.py +++ b/freqtrade/tests/test_main.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring import copy +from datetime import datetime from unittest.mock import MagicMock, call import pytest @@ -48,6 +49,7 @@ def conf(): validate(configuration, CONF_SCHEMA) return configuration + def test_create_trade(conf, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) buy_signal = mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) @@ -59,29 +61,43 @@ def test_create_trade(conf, mocker): 'ask': 0.072661, 'last': 0.07256061 }), - buy=MagicMock(return_value='mocked_order_id')) + buy=MagicMock(return_value='mocked_limit_buy')) # Save state of current whitelist whitelist = copy.deepcopy(conf['exchange']['pair_whitelist']) init(conf, 'sqlite://') - for pair in ['BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT']: + for _ in ['BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT']: trade = create_trade(15.0) Trade.session.add(trade) Trade.session.flush() assert trade is not None - assert trade.open_rate == 0.072661 - assert trade.pair == pair - 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 trade.exchange == Exchanges.BITTREX.name + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update({ + 'id': 'mocked_limit_buy', + 'type': 'LIMIT_BUY', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.072661, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + + assert trade.open_rate == 0.072661 + assert trade.amount == 206.43811673387373 + assert whitelist == conf['exchange']['pair_whitelist'] buy_signal.assert_has_calls( [call('BTC_ETH'), call('BTC_TKN'), call('BTC_TRST'), call('BTC_SWT')] ) + def test_handle_trade(conf, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock()) @@ -92,14 +108,29 @@ def test_handle_trade(conf, mocker): 'ask': 0.172661, 'last': 0.17256061 }), - buy=MagicMock(return_value='mocked_order_id')) + sell=MagicMock(return_value='mocked_limit_sell')) trade = Trade.query.filter(Trade.is_open.is_(True)).first() assert trade + handle_trade(trade) + assert trade.open_order_id == 'mocked_limit_sell' + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update({ + 'id': 'mocked_sell_limit', + 'type': 'LIMIT_SELL', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.17256061, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + assert trade.close_rate == 0.17256061 - assert trade.close_profit == 137.4872490056564 + assert trade.close_profit == 1.3698725 assert trade.close_date is not None - assert trade.open_order_id == 'dry_run' + def test_close_trade(conf, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) @@ -113,14 +144,17 @@ def test_close_trade(conf, mocker): assert closed assert not trade.is_open + def test_balance_fully_ask_side(mocker): mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 0.0}}) assert get_target_bid({'ask': 20, 'last': 10}) == 20 + def test_balance_fully_last_side(mocker): mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}) assert get_target_bid({'ask': 20, 'last': 10}) == 10 + def test_balance_when_last_bigger_than_ask(mocker): mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}}) assert get_target_bid({'ask': 5, 'last': 10}) == 5 diff --git a/freqtrade/tests/test_persistence.py b/freqtrade/tests/test_persistence.py deleted file mode 100644 index 8cf280130..000000000 --- a/freqtrade/tests/test_persistence.py +++ /dev/null @@ -1,20 +0,0 @@ -# pragma pylint: disable=missing-docstring -from freqtrade.exchange import Exchanges -from freqtrade.persistence import Trade - -def test_exec_sell_order(mocker): - api_mock = mocker.patch('freqtrade.main.exchange.sell', side_effect='mocked_order_id') - trade = Trade( - pair='BTC_ETH', - stake_amount=1.00, - open_rate=0.50, - amount=10.00, - exchange=Exchanges.BITTREX, - open_order_id='mocked' - ) - profit = trade.exec_sell_order(1.00, 10.00) - api_mock.assert_called_once_with('BTC_ETH', 1.0, 10.0) - assert profit == 100.0 - assert trade.close_rate == 1.0 - assert trade.close_profit == profit - assert trade.close_date is not None diff --git a/freqtrade/tests/test_telegram.py b/freqtrade/tests/test_telegram.py index 74924d5c8..9e45aa39d 100644 --- a/freqtrade/tests/test_telegram.py +++ b/freqtrade/tests/test_telegram.py @@ -11,12 +11,9 @@ from telegram import Bot, Update, Message, Chat from freqtrade.main import init, create_trade from freqtrade.misc import update_state, State, get_state, CONF_SCHEMA from freqtrade.persistence import Trade -from freqtrade.rpc.telegram import _status, _status_table, _profit, _forcesell, _performance, \ - _count, _start, _stop - -logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO) -logging.getLogger('telegram').setLevel(logging.INFO) -logger = logging.getLogger(__name__) +from freqtrade.rpc.telegram import ( + _status, _status_table, _profit, _forcesell, _performance, _count, _start, _stop, _balance +) @pytest.fixture @@ -54,6 +51,7 @@ def conf(): validate(configuration, CONF_SCHEMA) return configuration + @pytest.fixture def update(): _update = Update(0) @@ -69,7 +67,10 @@ def test_status_handle(conf, update, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) msg_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) mocker.patch.multiple('freqtrade.main.exchange', validate_pairs=MagicMock(), get_ticker=MagicMock(return_value={ @@ -86,8 +87,26 @@ def test_status_handle(conf, update, mocker): Trade.session.add(trade) Trade.session.flush() + # Trigger status while we don't know the open_rate yet _status(bot=MagicBot(), update=update) - assert msg_mock.call_count == 2 + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update({ + 'id': 'mocked_limit_buy', + 'type': 'LIMIT_BUY', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.07256060, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + Trade.session.flush() + + # Trigger status while we have a fulfilled order for the open trade + _status(bot=MagicBot(), update=update) + + assert msg_mock.call_count == 3 assert '[BTC_ETH]' in msg_mock.call_args_list[-1][0][0] @@ -127,7 +146,10 @@ def test_profit_handle(conf, update, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) msg_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) mocker.patch.multiple('freqtrade.main.exchange', validate_pairs=MagicMock(), get_ticker=MagicMock(return_value={ @@ -135,14 +157,36 @@ def test_profit_handle(conf, update, mocker): 'ask': 0.072661, 'last': 0.07256061 }), - buy=MagicMock(return_value='mocked_order_id')) + buy=MagicMock(return_value='mocked_limit_buy')) init(conf, 'sqlite://') # Create some test data trade = create_trade(15.0) assert trade - trade.close_rate = 0.07256061 - trade.close_profit = 100.00 + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update({ + 'id': 'mocked_limit_buy', + 'type': 'LIMIT_BUY', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.07256061, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + # Simulate fulfilled LIMIT_SELL order for trade + trade.update({ + 'id': 'mocked_limit_sell', + 'type': 'LIMIT_SELL', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.0802134, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + trade.close_date = datetime.utcnow() trade.open_order_id = None trade.is_open = False @@ -151,13 +195,18 @@ def test_profit_handle(conf, update, mocker): _profit(bot=MagicBot(), update=update) assert msg_mock.call_count == 2 - assert '(100.00%)' in msg_mock.call_args_list[-1][0][0] + assert '*ROI:* `1.507013 (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): mocker.patch.dict('freqtrade.main._CONF', conf) mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) msg_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) mocker.patch.multiple('freqtrade.main.exchange', validate_pairs=MagicMock(), get_ticker=MagicMock(return_value={ @@ -171,6 +220,19 @@ def test_forcesell_handle(conf, update, mocker): # Create some test data trade = create_trade(15.0) assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update({ + 'id': 'mocked_limit_buy', + 'type': 'LIMIT_BUY', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.07256060, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + Trade.session.add(trade) Trade.session.flush() @@ -179,13 +241,17 @@ def test_forcesell_handle(conf, update, mocker): assert msg_mock.call_count == 2 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.072561 (profit: ~-0.5%)' in msg_mock.call_args_list[-1][0][0] + def test_performance_handle(conf, update, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True) msg_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) mocker.patch.multiple('freqtrade.main.exchange', validate_pairs=MagicMock(), get_ticker=MagicMock(return_value={ @@ -199,10 +265,32 @@ def test_performance_handle(conf, update, mocker): # Create some test data trade = create_trade(15.0) assert trade - trade.close_rate = 0.07256061 - trade.close_profit = 100.00 + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update({ + 'id': 'mocked_limit_buy', + 'type': 'LIMIT_BUY', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.07256061, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update({ + 'id': 'mocked_limit_sell', + 'type': 'LIMIT_SELL', + 'pair': 'mocked', + 'opened': datetime.utcnow(), + 'rate': 0.0802134, + 'amount': 206.43811673387373, + 'remaining': 0.0, + 'closed': datetime.utcnow(), + }) + trade.close_date = datetime.utcnow() - trade.open_order_id = None trade.is_open = False Trade.session.add(trade) Trade.session.flush() @@ -210,7 +298,8 @@ def test_performance_handle(conf, update, mocker): _performance(bot=MagicBot(), update=update) assert msg_mock.call_count == 2 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 'BTC_ETH\t10.05%' in msg_mock.call_args_list[-1][0][0] + def test_count_handle(conf, update, mocker): @@ -245,8 +334,13 @@ def test_count_handle(conf, update, mocker): def test_start_handle(conf, update, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) msg_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) - mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock()) + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + _CONF=conf, + init=MagicMock()) init(conf, 'sqlite://') update_state(State.STOPPED) @@ -255,11 +349,17 @@ def test_start_handle(conf, update, mocker): assert get_state() == State.RUNNING assert msg_mock.call_count == 0 + def test_stop_handle(conf, update, mocker): mocker.patch.dict('freqtrade.main._CONF', conf) msg_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock) - mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock()) + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + _CONF=conf, + init=MagicMock()) init(conf, 'sqlite://') update_state(State.RUNNING) @@ -268,3 +368,25 @@ def test_stop_handle(conf, update, mocker): assert get_state() == State.STOPPED assert msg_mock.call_count == 1 assert 'Stopping trader' in msg_mock.call_args_list[0][0][0] + + +def test_balance_handle(conf, update, mocker): + mock_balance = [{ + 'Currency': 'BTC', + 'Balance': 10.0, + 'Available': 12.0, + 'Pending': 0.0, + 'CryptoAddress': 'XXXX'}] + mocker.patch.dict('freqtrade.main._CONF', conf) + msg_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.telegram', + _CONF=conf, + init=MagicMock(), + send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + get_balances=MagicMock(return_value=mock_balance)) + + _balance(bot=MagicBot(), update=update) + assert msg_mock.call_count == 1 + assert '*Currency*: BTC' in msg_mock.call_args_list[0][0][0] + assert 'Balance' in msg_mock.call_args_list[0][0][0] diff --git a/freqtrade/vendor/__init__.py b/freqtrade/vendor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/freqtrade/vendor/qtpylib/__init__.py b/freqtrade/vendor/qtpylib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/qtpylib/indicators.py b/freqtrade/vendor/qtpylib/indicators.py similarity index 100% rename from qtpylib/indicators.py rename to freqtrade/vendor/qtpylib/indicators.py