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/freqtrade/analyze.py b/freqtrade/analyze.py index b99a67809..73365969d 100644 --- a/freqtrade/analyze.py +++ b/freqtrade/analyze.py @@ -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) \ @@ -161,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 b424660bf..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,62 +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(): - return EXCHANGE.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 0b70e7a3b..306a7b698 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']: @@ -87,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 7c6f30be2..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: @@ -100,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: """ @@ -108,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 dc9078d5f..c122d830e 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -1,15 +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 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() @@ -25,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)) @@ -51,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 b18cb821d..9f2559000 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -114,18 +114,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}` @@ -148,8 +145,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) @@ -169,6 +168,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: @@ -176,9 +177,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')) \ @@ -193,21 +194,24 @@ 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) @@ -291,20 +295,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 `') @@ -333,10 +325,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) diff --git a/freqtrade/tests/test_backtesting.py b/freqtrade/tests/test_backtesting.py index 4bd04e721..deabebfb9 100644 --- a/freqtrade/tests/test_backtesting.py +++ b/freqtrade/tests/test_backtesting.py @@ -11,7 +11,7 @@ from freqtrade.analyze import analyze_ticker 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): @@ -63,7 +63,7 @@ def backtest(conf, pairs, mocker): # 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 diff --git a/freqtrade/tests/test_hyperopt.py b/freqtrade/tests/test_hyperopt.py index 5fedff519..5053cbfcd 100644 --- a/freqtrade/tests/test_hyperopt.py +++ b/freqtrade/tests/test_hyperopt.py @@ -12,7 +12,7 @@ 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 diff --git a/freqtrade/tests/test_main.py b/freqtrade/tests/test_main.py index 67e66bd23..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 @@ -60,23 +61,36 @@ 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( @@ -94,14 +108,28 @@ 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): diff --git a/freqtrade/tests/test_persistence.py b/freqtrade/tests/test_persistence.py deleted file mode 100644 index 6a74ad715..000000000 --- a/freqtrade/tests/test_persistence.py +++ /dev/null @@ -1,21 +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 354abb086..4270ba21c 100644 --- a/freqtrade/tests/test_telegram.py +++ b/freqtrade/tests/test_telegram.py @@ -46,6 +46,7 @@ def conf(): validate(configuration, CONF_SCHEMA) return configuration + @pytest.fixture def update(): _update = Update(0) @@ -78,8 +79,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] @@ -95,14 +114,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 @@ -111,7 +152,8 @@ 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): @@ -132,6 +174,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() @@ -140,7 +195,7 @@ 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): @@ -161,10 +216,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() @@ -172,7 +249,7 @@ 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_start_handle(conf, update, mocker):