diff --git a/config_full.json.example b/config_full.json.example index b0719bcc6..6134e9cad 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -36,7 +36,8 @@ "order_types": { "buy": "limit", "sell": "limit", - "stoploss": "market" + "stoploss": "market", + "stoploss_on_exchange": "false" }, "exchange": { "name": "bittrex", diff --git a/docs/configuration.md b/docs/configuration.md index 1e144e5af..5b8baa43b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,7 @@ The table below will list all configuration parameters. | `ask_strategy.use_order_book` | false | No | Allows selling of open traded pair using the rates in Order Book Asks. | `ask_strategy.order_book_min` | 0 | No | Bot will scan from the top min to max Order Book Asks searching for a profitable rate. | `ask_strategy.order_book_max` | 0 | No | Bot will scan from the top min to max Order Book Asks searching for a profitable rate. -| `order_types` | None | No | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`). +| `order_types` | None | No | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`). | `exchange.name` | bittrex | Yes | Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename). | `exchange.key` | key | No | API key to use for the exchange. Only required when you are in production mode. | `exchange.secret` | secret | No | API secret to use for the exchange. Only required when you are in production mode. @@ -141,17 +141,18 @@ end up paying more then would probably have been necessary. ### Understand order_types -`order_types` contains a dict mapping order-types to market-types. This allows to buy using limit orders, sell using limit-orders, and create stoploss orders using market. +`order_types` contains a dict mapping order-types to market-types as well as stoploss on or off exchange type. This allows to buy using limit orders, sell using limit-orders, and create stoploss orders using market. It also allows to set the stoploss "on exchange" which means stoploss order would be placed immediately once the buy order is fulfilled. This can be set in the configuration or in the strategy. Configuration overwrites strategy configurations. -If this is configured, all 3 values (`"buy"`, `"sell"` and `"stoploss"`) need to be present, otherwise the bot warn about it and will fail to start. +If this is configured, all 4 values (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`) need to be present, otherwise the bot warn about it and will fail to start. The below is the default which is used if this is not configured in either Strategy or configuration. ``` json "order_types": { "buy": "limit", "sell": "limit", - "stoploss": "market" + "stoploss": "market", + "stoploss_on_exchange": False }, ``` diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 055fee3b2..f8fb91240 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -13,7 +13,7 @@ DEFAULT_HYPEROPT = 'DefaultHyperOpts' DEFAULT_DB_PROD_URL = 'sqlite:///tradesv3.sqlite' DEFAULT_DB_DRYRUN_URL = 'sqlite://' UNLIMITED_STAKE_AMOUNT = 'unlimited' -REQUIRED_ORDERTYPES = ['buy', 'sell', 'stoploss'] +REQUIRED_ORDERTYPES = ['buy', 'sell', 'stoploss', 'stoploss_on_exchange'] ORDERTYPE_POSSIBILITIES = ['limit', 'market'] @@ -109,9 +109,10 @@ CONF_SCHEMA = { 'properties': { 'buy': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'sell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES} + 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'stoploss_on_exchange': {'type': 'boolean'} }, - 'required': ['buy', 'sell', 'stoploss'] + 'required': ['buy', 'sell', 'stoploss', 'stoploss_on_exchange'] }, 'exchange': {'$ref': '#/definitions/exchange'}, 'edge': {'$ref': '#/definitions/edge'}, diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 4573e461c..65e912a1f 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -228,6 +228,12 @@ class Exchange(object): raise OperationalException( f'Exchange {self.name} does not support market orders.') + if order_types.get('stoploss_on_exchange'): + if self.name is not 'Binance': + raise OperationalException( + 'On exchange stoploss is not supported for %s.' % self.name + ) + def exchange_has(self, endpoint: str) -> bool: """ Checks if exchange implements a specific API endpoint. @@ -334,6 +340,61 @@ class Exchange(object): except ccxt.BaseError as e: raise OperationalException(e) + def stoploss_limit(self, pair: str, amount: float, stop_price: float, rate: float) -> Dict: + """ + creates a stoploss limit order. + NOTICE: it is not supported by all exchanges. only binance is tested for now. + """ + + # Set the precision for amount and price(rate) as accepted by the exchange + amount = self.symbol_amount_prec(pair, amount) + rate = self.symbol_price_prec(pair, rate) + stop_price = self.symbol_price_prec(pair, stop_price) + + # Ensure rate is less than stop price + if stop_price <= rate: + raise OperationalException( + 'In stoploss limit order, stop price should be more than limit price') + + if self._conf['dry_run']: + order_id = f'dry_run_buy_{randint(0, 10**6)}' + self._dry_run_open_orders[order_id] = { + 'info': {}, + 'id': order_id, + 'pair': pair, + 'price': stop_price, + 'amount': amount, + 'type': 'stop_loss_limit', + 'side': 'sell', + 'remaining': amount, + 'datetime': arrow.utcnow().isoformat(), + 'status': 'open', + 'fee': None + } + return self._dry_run_open_orders[order_id] + + try: + return self._api.create_order(pair, 'stop_loss_limit', 'sell', + amount, rate, {'stopPrice': stop_price}) + + except ccxt.InsufficientFunds as e: + raise DependencyException( + f'Insufficient funds to place stoploss limit order on market {pair}. ' + f'Tried to put a stoploss amount {amount} with ' + f'stop {stop_price} and limit {rate} (total {rate*amount}).' + f'Message: {e}') + except ccxt.InvalidOrder as e: + raise DependencyException( + f'Could not place stoploss limit order on market {pair}.' + f'Tried to place stoploss amount {amount} with ' + f'stop {stop_price} and limit {rate} (total {rate*amount}).' + f'Message: {e}') + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not place stoploss limit order due to {e.__class__.__name__}. Message: {e}') + except ccxt.BaseError as e: + raise OperationalException(e) + @retrier def get_balance(self, currency: str) -> float: if self._conf['dry_run']: diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 5271a3d77..62b0a0d2c 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -54,6 +54,7 @@ class FreqtradeBot(object): # Init objects self.config = config self.strategy: IStrategy = StrategyResolver(self.config).strategy + self.rpc: RPCManager = RPCManager(self) self.persistence = None self.exchange = Exchange(self.config) @@ -455,6 +456,7 @@ class FreqtradeBot(object): 'stake_currency': stake_currency, 'fiat_currency': fiat_currency }) + # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') trade = Trade( @@ -471,6 +473,7 @@ class FreqtradeBot(object): strategy=self.strategy.get_strategy_name(), ticker_interval=constants.TICKER_INTERVAL_MINUTES[self.config['ticker_interval']] ) + Trade.session.add(trade) Trade.session.flush() @@ -519,6 +522,12 @@ class FreqtradeBot(object): trade.update(order) + if self.strategy.order_types.get('stoploss_on_exchange') and trade.is_open: + result = self.handle_stoploss_on_exchange(trade) + if result: + self.wallets.update() + return result + if trade.is_open and trade.open_order_id is None: # Check if we can sell our current pair result = self.handle_trade(trade) @@ -623,6 +632,47 @@ class FreqtradeBot(object): logger.info('Found no sell signals for whitelisted currencies. Trying again..') return False + def handle_stoploss_on_exchange(self, trade: Trade) -> bool: + """ + Check if trade is fulfilled in which case the stoploss + on exchange should be added immediately if stoploss on exchnage + is enabled. + """ + + result = False + + # If trade is open and the buy order is fulfilled but there is no stoploss, + # then we add a stoploss on exchange + if not trade.open_order_id and not trade.stoploss_order_id: + if self.edge: + stoploss = self.edge.stoploss(pair=trade.pair) + else: + stoploss = self.strategy.stoploss + + stop_price = trade.open_rate * (1 + stoploss) + + # limit price should be less than stop price. + # 0.98 is arbitrary here. + limit_price = stop_price * 0.98 + + stoploss_order_id = self.exchange.stoploss_limit( + pair=trade.pair, amount=trade.amount, stop_price=stop_price, rate=limit_price + )['id'] + trade.stoploss_order_id = str(stoploss_order_id) + + # Or the trade open and there is already a stoploss on exchange. + # so we check if it is hit ... + elif trade.stoploss_order_id: + logger.debug('Handling stoploss on exchange %s ...', trade) + order = self.exchange.get_order(trade.stoploss_order_id, trade.pair) + if order['status'] == 'closed': + trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value + trade.update(order) + result = True + else: + result = False + return result + def check_sell(self, trade: Trade, sell_rate: float, buy: bool, sell: bool) -> bool: if self.edge: stoploss = self.edge.stoploss(trade.pair) @@ -747,6 +797,11 @@ class FreqtradeBot(object): sell_type = 'sell' if sell_reason in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS): sell_type = 'stoploss' + + # First cancelling stoploss on exchange ... + if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: + self.exchange.cancel_order(trade.stoploss_order_id, trade.pair) + # Execute sell and update trade record order_id = self.exchange.sell(pair=str(trade.pair), ordertype=self.strategy.order_types[sell_type], diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index 51a8129fb..592a88acb 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -82,7 +82,7 @@ def check_migrate(engine) -> None: logger.debug(f'trying {table_back_name}') # Check for latest column - if not has_column(cols, 'ticker_interval'): + if not has_column(cols, 'stoploss_order_id'): logger.info(f'Running database migration - backup available as {table_back_name}') fee_open = get_column_def(cols, 'fee_open', 'fee') @@ -91,6 +91,7 @@ def check_migrate(engine) -> None: close_rate_requested = get_column_def(cols, 'close_rate_requested', 'null') stop_loss = get_column_def(cols, 'stop_loss', '0.0') initial_stop_loss = get_column_def(cols, 'initial_stop_loss', '0.0') + stoploss_order_id = get_column_def(cols, 'stoploss_order_id', 'null') max_rate = get_column_def(cols, 'max_rate', '0.0') sell_reason = get_column_def(cols, 'sell_reason', 'null') strategy = get_column_def(cols, 'strategy', 'null') @@ -106,7 +107,7 @@ def check_migrate(engine) -> None: (id, exchange, pair, is_open, fee_open, fee_close, open_rate, open_rate_requested, close_rate, close_rate_requested, close_profit, stake_amount, amount, open_date, close_date, open_order_id, - stop_loss, initial_stop_loss, max_rate, sell_reason, strategy, + stop_loss, initial_stop_loss, stoploss_order_id, max_rate, sell_reason, strategy, ticker_interval ) select id, lower(exchange), @@ -122,7 +123,8 @@ def check_migrate(engine) -> None: {close_rate_requested} close_rate_requested, close_profit, stake_amount, amount, open_date, close_date, open_order_id, {stop_loss} stop_loss, {initial_stop_loss} initial_stop_loss, - {max_rate} max_rate, {sell_reason} sell_reason, {strategy} strategy, + {stoploss_order_id} stoploss_order_id, {max_rate} max_rate, + {sell_reason} sell_reason, {strategy} strategy, {ticker_interval} ticker_interval from {table_back_name} """) @@ -177,6 +179,8 @@ class Trade(_DECL_BASE): stop_loss = Column(Float, nullable=True, default=0.0) # absolute value of the initial stop loss initial_stop_loss = Column(Float, nullable=True, default=0.0) + # stoploss order id which is on exchange + stoploss_order_id = Column(String, nullable=True, index=True) # absolute value of the highest reached price max_rate = Column(Float, nullable=True, default=0.0) sell_reason = Column(String, nullable=True) @@ -249,6 +253,10 @@ class Trade(_DECL_BASE): self.open_order_id = None elif order_type == 'limit' and order['side'] == 'sell': self.close(order['price']) + elif order_type == 'stop_loss_limit': + self.stoploss_order_id = None + logger.info('STOP_LOSS_LIMIT is hit for %s.', self) + self.close(order['average']) else: raise ValueError(f'Unknown order type: {order_type}') cleanup() diff --git a/freqtrade/strategy/default_strategy.py b/freqtrade/strategy/default_strategy.py index b282a5938..9c850a8be 100644 --- a/freqtrade/strategy/default_strategy.py +++ b/freqtrade/strategy/default_strategy.py @@ -32,7 +32,8 @@ class DefaultStrategy(IStrategy): order_types = { 'buy': 'limit', 'sell': 'limit', - 'stoploss': 'limit' + 'stoploss': 'limit', + 'stoploss_on_exchange': False } def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 139bcd8be..141dd996c 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -33,6 +33,7 @@ class SellType(Enum): """ ROI = "roi" STOP_LOSS = "stop_loss" + STOPLOSS_ON_EXCHANGE = "stoploss_on_exchange" TRAILING_STOP_LOSS = "trailing_stop_loss" SELL_SIGNAL = "sell_signal" FORCE_SELL = "force_sell" @@ -74,7 +75,8 @@ class IStrategy(ABC): order_types: Dict = { 'buy': 'limit', 'sell': 'limit', - 'stoploss': 'limit' + 'stoploss': 'limit', + 'stoploss_on_exchange': False } # run "populate_indicators" only for new candle @@ -221,11 +223,17 @@ class IStrategy(ABC): # Set current rate to low for backtesting sell current_rate = low or rate current_profit = trade.calc_profit_percent(current_rate) - stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade, - current_time=date, current_profit=current_profit, - force_stoploss=force_stoploss) + + if self.order_types.get('stoploss_on_exchange'): + stoplossflag = SellCheckTuple(sell_flag=False, sell_type=SellType.NONE) + else: + stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade, + current_time=date, current_profit=current_profit, + force_stoploss=force_stoploss) + if stoplossflag.sell_flag: return stoplossflag + # Set current rate to low for backtesting sell current_rate = high or rate current_profit = trade.calc_profit_percent(current_rate) diff --git a/freqtrade/tests/conftest.py b/freqtrade/tests/conftest.py index f7fe697b8..0c38019e3 100644 --- a/freqtrade/tests/conftest.py +++ b/freqtrade/tests/conftest.py @@ -26,20 +26,21 @@ def log_has(line, logs): False) -def patch_exchange(mocker, api_mock=None) -> None: +def patch_exchange(mocker, api_mock=None, id='bittrex') -> None: mocker.patch('freqtrade.exchange.Exchange._load_markets', MagicMock(return_value={})) mocker.patch('freqtrade.exchange.Exchange.validate_timeframes', MagicMock()) mocker.patch('freqtrade.exchange.Exchange.validate_ordertypes', MagicMock()) - mocker.patch('freqtrade.exchange.Exchange.name', PropertyMock(return_value="Bittrex")) - mocker.patch('freqtrade.exchange.Exchange.id', PropertyMock(return_value="bittrex")) + mocker.patch('freqtrade.exchange.Exchange.id', PropertyMock(return_value=id)) + mocker.patch('freqtrade.exchange.Exchange.name', PropertyMock(return_value=id.title())) + if api_mock: mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) else: mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock()) -def get_patched_exchange(mocker, config, api_mock=None) -> Exchange: - patch_exchange(mocker, api_mock) +def get_patched_exchange(mocker, config, api_mock=None, id='bittrex') -> Exchange: + patch_exchange(mocker, api_mock, id) exchange = Exchange(config) return exchange diff --git a/freqtrade/tests/exchange/test_exchange.py b/freqtrade/tests/exchange/test_exchange.py index dbb8d4ec2..d1f391266 100644 --- a/freqtrade/tests/exchange/test_exchange.py +++ b/freqtrade/tests/exchange/test_exchange.py @@ -362,18 +362,41 @@ def test_validate_order_types(default_conf, mocker): mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) mocker.patch('freqtrade.exchange.Exchange._load_markets', MagicMock(return_value={})) mocker.patch('freqtrade.exchange.Exchange.validate_timeframes', MagicMock()) - default_conf['order_types'] = {'buy': 'limit', 'sell': 'limit', 'stoploss': 'market'} + mocker.patch('freqtrade.exchange.Exchange.name', 'Bittrex') + default_conf['order_types'] = { + 'buy': 'limit', + 'sell': 'limit', + 'stoploss': 'market', + 'stoploss_on_exchange': False + } + Exchange(default_conf) type(api_mock).has = PropertyMock(return_value={'createMarketOrder': False}) mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) - default_conf['order_types'] = {'buy': 'limit', 'sell': 'limit', 'stoploss': 'market'} + default_conf['order_types'] = { + 'buy': 'limit', + 'sell': 'limit', + 'stoploss': 'market', + 'stoploss_on_exchange': 'false' + } with pytest.raises(OperationalException, match=r'Exchange .* does not support market orders.'): Exchange(default_conf) + default_conf['order_types'] = { + 'buy': 'limit', + 'sell': 'limit', + 'stoploss': 'limit', + 'stoploss_on_exchange': True + } + + with pytest.raises(OperationalException, + match=r'On exchange stoploss is not supported for .*'): + Exchange(default_conf) + def test_validate_order_types_not_in_config(default_conf, mocker): api_mock = MagicMock() @@ -1122,3 +1145,85 @@ def test_get_fee(default_conf, mocker): ccxt_exceptionhandlers(mocker, default_conf, api_mock, 'get_fee', 'calculate_fee') + + +def test_stoploss_limit_order(default_conf, mocker): + api_mock = MagicMock() + order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6)) + order_type = 'stop_loss_limit' + + api_mock.create_order = MagicMock(return_value={ + 'id': order_id, + 'info': { + 'foo': 'bar' + } + }) + + default_conf['dry_run'] = False + mocker.patch('freqtrade.exchange.Exchange.symbol_amount_prec', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.symbol_price_prec', lambda s, x, y: y) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') + + with pytest.raises(OperationalException): + order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=190, rate=200) + + api_mock.create_order.reset_mock() + + order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + + assert 'id' in order + assert 'info' in order + assert order['id'] == order_id + assert api_mock.create_order.call_args[0][0] == 'ETH/BTC' + assert api_mock.create_order.call_args[0][1] == order_type + assert api_mock.create_order.call_args[0][2] == 'sell' + assert api_mock.create_order.call_args[0][3] == 1 + assert api_mock.create_order.call_args[0][4] == 200 + assert api_mock.create_order.call_args[0][5] == {'stopPrice': 220} + + # test exception handling + with pytest.raises(DependencyException): + api_mock.create_order = MagicMock(side_effect=ccxt.InsufficientFunds) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + + with pytest.raises(DependencyException): + api_mock.create_order = MagicMock(side_effect=ccxt.InvalidOrder) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + + with pytest.raises(TemporaryError): + api_mock.create_order = MagicMock(side_effect=ccxt.NetworkError) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + + with pytest.raises(OperationalException): + api_mock.create_order = MagicMock(side_effect=ccxt.BaseError) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + + +def test_stoploss_limit_order_dry_run(default_conf, mocker): + api_mock = MagicMock() + order_type = 'stop_loss_limit' + default_conf['dry_run'] = True + mocker.patch('freqtrade.exchange.Exchange.symbol_amount_prec', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.symbol_price_prec', lambda s, x, y: y) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') + + with pytest.raises(OperationalException): + order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=190, rate=200) + + api_mock.create_order.reset_mock() + + order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + + assert 'id' in order + assert 'info' in order + assert 'type' in order + + assert order['type'] == order_type + assert order['price'] == 220 + assert order['amount'] == 1 diff --git a/freqtrade/tests/strategy/test_strategy.py b/freqtrade/tests/strategy/test_strategy.py index 80bd9e120..271fe4d32 100644 --- a/freqtrade/tests/strategy/test_strategy.py +++ b/freqtrade/tests/strategy/test_strategy.py @@ -189,7 +189,8 @@ def test_strategy_override_order_types(caplog): order_types = { 'buy': 'market', 'sell': 'limit', - 'stoploss': 'limit' + 'stoploss': 'limit', + 'stoploss_on_exchange': True, } config = { @@ -199,13 +200,14 @@ def test_strategy_override_order_types(caplog): resolver = StrategyResolver(config) assert resolver.strategy.order_types - for method in ['buy', 'sell', 'stoploss']: + for method in ['buy', 'sell', 'stoploss', 'stoploss_on_exchange']: assert resolver.strategy.order_types[method] == order_types[method] assert ('freqtrade.resolvers.strategy_resolver', logging.INFO, "Override strategy 'order_types' with value in config file:" - " {'buy': 'market', 'sell': 'limit', 'stoploss': 'limit'}." + " {'buy': 'market', 'sell': 'limit', 'stoploss': 'limit'," + " 'stoploss_on_exchange': True}." ) in caplog.record_tuples config = { @@ -263,13 +265,13 @@ def test_call_deprecated_function(result, monkeypatch): assert resolver.strategy._sell_fun_len == 2 indicator_df = resolver.strategy.advise_indicators(result, metadata=metadata) - assert type(indicator_df) is DataFrame + assert isinstance(indicator_df, DataFrame) assert 'adx' in indicator_df.columns buydf = resolver.strategy.advise_buy(result, metadata=metadata) - assert type(buydf) is DataFrame + assert isinstance(buydf, DataFrame) assert 'buy' in buydf.columns selldf = resolver.strategy.advise_sell(result, metadata=metadata) - assert type(selldf) is DataFrame + assert isinstance(selldf, DataFrame) assert 'sell' in selldf diff --git a/freqtrade/tests/test_freqtradebot.py b/freqtrade/tests/test_freqtradebot.py index eb5336c61..a3638d08a 100644 --- a/freqtrade/tests/test_freqtradebot.py +++ b/freqtrade/tests/test_freqtradebot.py @@ -874,6 +874,100 @@ def test_execute_buy(mocker, default_conf, fee, markets, limit_buy_order) -> Non assert call_args['amount'] == stake_amount / fix_price +def test_add_stoploss_on_exchange(mocker, default_conf, limit_buy_order) -> None: + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True)) + mocker.patch('freqtrade.exchange.Exchange.get_order', return_value=limit_buy_order) + mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[]) + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount', + return_value=limit_buy_order['amount']) + + stoploss_limit = MagicMock(return_value={'id': 13434334}) + mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_limit) + + freqtrade = FreqtradeBot(default_conf) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + trade = MagicMock() + trade.open_order_id = None + trade.stoploss_order_id = None + trade.is_open = True + + freqtrade.process_maybe_execute_sell(trade) + assert trade.stoploss_order_id == '13434334' + assert stoploss_limit.call_count == 1 + assert trade.is_open is True + + +def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, + markets, limit_buy_order, limit_sell_order) -> None: + stoploss_limit = MagicMock(return_value={'id': 13434334}) + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_ticker=MagicMock(return_value={ + 'bid': 0.00001172, + 'ask': 0.00001173, + 'last': 0.00001172 + }), + buy=MagicMock(return_value={'id': limit_buy_order['id']}), + sell=MagicMock(return_value={'id': limit_sell_order['id']}), + get_fee=fee, + get_markets=markets, + stoploss_limit=stoploss_limit + ) + freqtrade = FreqtradeBot(default_conf) + patch_get_signal(freqtrade) + + # First case: when stoploss is not yet set but the order is open + # should get the stoploss order id immediately + # and should return false as no trade actually happened + trade = MagicMock() + trade.is_open = True + trade.open_order_id = None + trade.stoploss_order_id = None + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss_limit.call_count == 1 + assert trade.stoploss_order_id == "13434334" + + # Second case: when stoploss is set but it is not yet hit + # should do nothing and return false + trade.is_open = True + trade.open_order_id = None + trade.stoploss_order_id = 100 + + hanging_stoploss_order = MagicMock(return_value={'status': 'open'}) + mocker.patch('freqtrade.exchange.Exchange.get_order', hanging_stoploss_order) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.stoploss_order_id == 100 + + # Third case: when stoploss is set and it is hit + # should unset stoploss_order_id and return true + # as a trade actually happened + freqtrade.create_trade() + trade = Trade.query.first() + trade.is_open = True + trade.open_order_id = None + trade.stoploss_order_id = 100 + assert trade + + stoploss_order_hit = MagicMock(return_value={ + 'status': 'closed', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2 + }) + mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_order_hit) + assert freqtrade.handle_stoploss_on_exchange(trade) is True + assert log_has('STOP_LOSS_LIMIT is hit for {}.'.format(trade), caplog.record_tuples) + assert trade.stoploss_order_id is None + assert trade.is_open is False + + def test_process_maybe_execute_buy(mocker, default_conf) -> None: freqtrade = get_patched_freqtradebot(mocker, default_conf) @@ -1468,6 +1562,129 @@ def test_execute_sell_down(default_conf, ticker, fee, ticker_sell_down, markets, } == last_msg +def test_execute_sell_with_stoploss_on_exchange(default_conf, + ticker, fee, ticker_sell_up, + markets, mocker) -> None: + + default_conf['exchange']['name'] = 'binance' + rpc_mock = patch_RPCManager(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + _load_markets=MagicMock(return_value={}), + get_ticker=ticker, + get_fee=fee, + get_markets=markets + ) + + stoploss_limit = MagicMock(return_value={ + 'id': 123, + 'info': { + 'foo': 'bar' + } + }) + + cancel_order = MagicMock(return_value=True) + + mocker.patch('freqtrade.exchange.Exchange.symbol_amount_prec', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.symbol_price_prec', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_limit) + mocker.patch('freqtrade.exchange.Exchange.cancel_order', cancel_order) + + freqtrade = FreqtradeBot(default_conf) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade) + + # Create some test data + freqtrade.create_trade() + + trade = Trade.query.first() + assert trade + + freqtrade.process_maybe_execute_sell(trade) + + # Increase the price and sell it + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_ticker=ticker_sell_up + ) + + freqtrade.execute_sell(trade=trade, limit=ticker_sell_up()['bid'], + sell_reason=SellType.SELL_SIGNAL) + + trade = Trade.query.first() + assert trade + assert cancel_order.call_count == 1 + assert rpc_mock.call_count == 2 + + +def test_may_execute_sell_after_stoploss_on_exchange_hit(default_conf, + ticker, fee, + limit_buy_order, + markets, mocker) -> None: + default_conf['exchange']['name'] = 'binance' + rpc_mock = patch_RPCManager(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + _load_markets=MagicMock(return_value={}), + get_ticker=ticker, + get_fee=fee, + get_markets=markets + ) + + stoploss_limit = MagicMock(return_value={ + 'id': 123, + 'info': { + 'foo': 'bar' + } + }) + + mocker.patch('freqtrade.exchange.Exchange.symbol_amount_prec', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.symbol_price_prec', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_limit) + + freqtrade = FreqtradeBot(default_conf) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade) + + # Create some test data + freqtrade.create_trade() + trade = Trade.query.first() + freqtrade.process_maybe_execute_sell(trade) + assert trade + assert trade.stoploss_order_id == '123' + assert trade.open_order_id is None + + # Assuming stoploss on exchnage is hit + # stoploss_order_id should become None + # and trade should be sold at the price of stoploss + stoploss_limit_executed = MagicMock(return_value={ + "id": "123", + "timestamp": 1542707426845, + "datetime": "2018-11-20T09:50:26.845Z", + "lastTradeTimestamp": None, + "symbol": "BTC/USDT", + "type": "stop_loss_limit", + "side": "sell", + "price": 1.08801, + "amount": 90.99181074, + "cost": 99.0000000032274, + "average": 1.08801, + "filled": 90.99181074, + "remaining": 0.0, + "status": "closed", + "fee": None, + "trades": None + }) + mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_limit_executed) + + freqtrade.process_maybe_execute_sell(trade) + assert trade.stoploss_order_id is None + assert trade.is_open is False + print(trade.sell_reason) + assert trade.sell_reason == SellType.STOPLOSS_ON_EXCHANGE.value + assert rpc_mock.call_count == 1 + + def test_execute_sell_without_conf_sell_up(default_conf, ticker, fee, ticker_sell_up, markets, mocker) -> None: rpc_mock = patch_RPCManager(mocker) diff --git a/freqtrade/tests/test_persistence.py b/freqtrade/tests/test_persistence.py index 5e0647dff..d0a209f40 100644 --- a/freqtrade/tests/test_persistence.py +++ b/freqtrade/tests/test_persistence.py @@ -426,6 +426,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog): max_rate FLOAT, sell_reason VARCHAR, strategy VARCHAR, + ticker_interval INTEGER, PRIMARY KEY (id), CHECK (is_open IN (0, 1)) );""" @@ -471,6 +472,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog): assert trade.sell_reason is None assert trade.strategy is None assert trade.ticker_interval is None + assert trade.stoploss_order_id is None assert log_has("trying trades_bak1", caplog.record_tuples) assert log_has("trying trades_bak2", caplog.record_tuples) assert log_has("Running database migration - backup available as trades_bak2", diff --git a/user_data/strategies/test_strategy.py b/user_data/strategies/test_strategy.py index fd2e9ab75..e7804e683 100644 --- a/user_data/strategies/test_strategy.py +++ b/user_data/strategies/test_strategy.py @@ -7,7 +7,7 @@ from pandas import DataFrame # Add your lib to import here import talib.abstract as ta import freqtrade.vendor.qtpylib.indicators as qtpylib -import numpy # noqa +import numpy # noqa # This class is a sample. Feel free to customize it. @@ -52,7 +52,8 @@ class TestStrategy(IStrategy): order_types = { 'buy': 'limit', 'sell': 'limit', - 'stoploss': 'market' + 'stoploss': 'market', + 'stoploss_on_exchange': False } def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: