diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index bd0b131af..ca6f29078 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -20,7 +20,6 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie TemporaryError) from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import Order, PairLocks, Trade -from freqtrade.persistence.models import PairLock from freqtrade.plugins.protections.iprotection import ProtectionReturn from freqtrade.util.datetime_helpers import dt_now, dt_utc from freqtrade.worker import Worker @@ -1090,1070 +1089,6 @@ def test_execute_entry_min_leverage(mocker, default_conf_usdt, fee, limit_order, # assert trade.stake_amount == 2 -@pytest.mark.parametrize("is_short", [False, True]) -def test_add_stoploss_on_exchange(mocker, default_conf_usdt, limit_order, is_short, fee) -> None: - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(return_value=limit_order[entry_side(is_short)]), - get_fee=fee, - ) - order = limit_order[entry_side(is_short)] - mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True)) - mocker.patch(f'{EXMS}.fetch_order', return_value=order) - mocker.patch(f'{EXMS}.get_trades_for_order', return_value=[]) - - stoploss = MagicMock(return_value={'id': 13434334}) - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - - freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - trades = [trade] - - freqtrade.exit_positions(trades) - assert trade.has_open_sl_orders is True - assert stoploss.call_count == 1 - assert trade.is_open is True - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_short, - limit_order) -> None: - stop_order_dict = {'id': "13434334"} - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - # 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 - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - assert trade.is_open - assert trade.has_open_sl_orders is False - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert trade.open_sl_orders[-1].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 - - hanging_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order', hanging_stoploss_order) - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - hanging_stoploss_order.assert_called_once_with('13434334', trade.pair) - assert len(trade.open_sl_orders) == 1 - assert trade.open_sl_orders[-1].order_id == "13434334" - - # Third case: when stoploss was set but it was canceled for some reason - # should set a stoploss immediately and return False - caplog.clear() - trade.is_open = True - - canceled_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'canceled'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order', canceled_stoploss_order) - stoploss.reset_mock() - amount_before = trade.amount - - stop_order_dict.update({'id': "103_1"}) - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert len(trade.open_sl_orders) == 1 - assert trade.open_sl_orders[-1].order_id == "103_1" - assert trade.amount == amount_before - - # Fourth case: when stoploss is set and it is hit - # should return true as a trade actually happened - caplog.clear() - stop_order_dict.update({'id': "103_1"}) - - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - - stoploss_order_hit = MagicMock(return_value={ - 'id': "103_1", - 'status': 'closed', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': enter_order['amount'], - 'remaining': 0, - 'amount': enter_order['amount'], - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) - assert freqtrade.handle_stoploss_on_exchange(trade) is True - assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog) - assert len(trade.open_sl_orders) == 0 - assert trade.is_open is False - caplog.clear() - - mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) - trade.is_open = True - freqtrade.handle_stoploss_on_exchange(trade) - assert log_has('Unable to place a stoploss order on exchange.', caplog) - assert len(trade.open_sl_orders) == 0 - - # Fifth case: fetch_order returns InvalidOrder - # It should try to add stoploss order - stop_order_dict.update({'id': "105"}) - stoploss.reset_mock() - mocker.patch(f'{EXMS}.fetch_stoploss_order', side_effect=InvalidOrderException()) - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - freqtrade.handle_stoploss_on_exchange(trade) - assert len(trade.open_sl_orders) == 1 - assert stoploss.call_count == 1 - - # Sixth case: Closed Trade - # Should not create new order - trade.is_open = False - trade.open_sl_orders[-1].ft_is_open = False - stoploss.reset_mock() - mocker.patch(f'{EXMS}.fetch_order') - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert trade.has_open_sl_orders is False - assert stoploss.call_count == 0 - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, is_short, - limit_order) -> None: - stop_order_dict = {'id': "13434334"} - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - assert trade.is_open - assert trade.has_open_sl_orders is False - - # emergency exit triggered - # Trailing stop should not act anymore - stoploss_order_cancelled = MagicMock(side_effect=[{ - 'id': "107", - 'status': 'canceled', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'amount': enter_order['amount'], - 'filled': 0, - 'remaining': enter_order['amount'], - 'info': {'stopPrice': 22}, - }]) - trade.stoploss_last_update = dt_now() - timedelta(hours=1) - trade.stop_loss = 24 - trade.exit_reason = None - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='107', - status='open', - ) - ) - freqtrade.config['trailing_stop'] = True - stoploss = MagicMock(side_effect=InvalidOrderException()) - assert trade.has_open_sl_orders is True - Trade.commit() - mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', - side_effect=InvalidOrderException()) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_cancelled) - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert trade.has_open_sl_orders is False - assert trade.is_open is False - assert trade.exit_reason == str(ExitType.EMERGENCY_EXIT) - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_partial( - mocker, default_conf_usdt, fee, is_short, limit_order) -> None: - stop_order_dict = {'id': "101", "status": "open"} - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert trade.has_open_sl_orders is True - assert trade.open_sl_orders[-1].order_id == "101" - assert trade.amount == 30 - stop_order_dict.update({'id': "102"}) - # Stoploss on exchange is cancelled on exchange, but filled partially. - # Must update trade amount to guarantee successful exit. - stoploss_order_hit = MagicMock(return_value={ - 'id': "101", - 'status': 'canceled', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': trade.amount / 2, - 'remaining': trade.amount / 2, - 'amount': enter_order['amount'], - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) - assert freqtrade.handle_stoploss_on_exchange(trade) is False - # Stoploss filled partially ... - assert trade.amount == 15 - - assert trade.open_sl_orders[-1].order_id == "102" - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_partial_cancel_here( - mocker, default_conf_usdt, fee, is_short, limit_order, caplog, time_machine) -> None: - stop_order_dict = {'id': "101", "status": "open"} - time_machine.move_to(dt_now()) - default_conf_usdt['trailing_stop'] = True - stoploss = MagicMock(return_value=stop_order_dict) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss.call_count == 1 - assert trade.has_open_sl_orders is True - assert trade.open_sl_orders[-1].order_id == "101" - assert trade.amount == 30 - stop_order_dict.update({'id': "102"}) - # Stoploss on exchange is open. - # Freqtrade cancels the stop - but cancel returns a partial filled order. - stoploss_order_hit = MagicMock(return_value={ - 'id': "101", - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': 0, - 'remaining': trade.amount, - 'amount': enter_order['amount'], - }) - stoploss_order_cancel = MagicMock(return_value={ - 'id': "101", - 'status': 'canceled', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'filled': trade.amount / 2, - 'remaining': trade.amount / 2, - 'amount': enter_order['amount'], - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) - mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', stoploss_order_cancel) - time_machine.shift(timedelta(minutes=15)) - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - # Canceled Stoploss filled partially ... - assert log_has_re('Cancelling current stoploss on exchange.*', caplog) - - assert trade.has_open_sl_orders is True - assert trade.open_sl_orders[-1].order_id == "102" - assert trade.amount == 15 - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, is_short, - limit_order) -> None: - # Sixth case: stoploss order was cancelled but couldn't create new one - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - ) - mocker.patch.multiple( - EXMS, - fetch_stoploss_order=MagicMock(return_value={'status': 'canceled', 'id': '100'}), - create_stoploss=MagicMock(side_effect=ExchangeError()), - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - trade.is_open = True - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='100', - status='open', - ) - ) - assert trade - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert log_has_re(r'All Stoploss orders are cancelled, but unable to recreate one\.', caplog) - assert trade.has_open_sl_orders is False - assert trade.is_open is True - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_create_stoploss_order_invalid_order( - mocker, default_conf_usdt, caplog, fee, is_short, limit_order -): - open_order = limit_order[entry_side(is_short)] - order = limit_order[exit_side(is_short)] - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - create_order_mock = MagicMock(side_effect=[ - open_order, - order, - ]) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=create_order_mock, - get_fee=fee, - ) - mocker.patch.multiple( - EXMS, - fetch_order=MagicMock(return_value={'status': 'canceled'}), - create_stoploss=MagicMock(side_effect=InvalidOrderException()), - ) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - caplog.clear() - rpc_mock.reset_mock() - freqtrade.create_stoploss_order(trade, 200) - assert trade.has_open_sl_orders is False - assert trade.exit_reason == ExitType.EMERGENCY_EXIT.value - assert log_has("Unable to place a stoploss order on exchange. ", caplog) - assert log_has("Exiting the trade forcefully", caplog) - - # Should call a market sell - assert create_order_mock.call_count == 2 - assert create_order_mock.call_args[1]['ordertype'] == 'market' - assert create_order_mock.call_args[1]['pair'] == trade.pair - assert create_order_mock.call_args[1]['amount'] == trade.amount - - # Rpc is sending first buy, then sell - assert rpc_mock.call_count == 2 - assert rpc_mock.call_args_list[0][0][0]['exit_reason'] == ExitType.EMERGENCY_EXIT.value - assert rpc_mock.call_args_list[0][0][0]['order_type'] == 'market' - assert rpc_mock.call_args_list[0][0][0]['type'] == 'exit' - assert rpc_mock.call_args_list[1][0][0]['type'] == 'exit_fill' - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_create_stoploss_order_insufficient_funds( - mocker, default_conf_usdt, caplog, fee, limit_order, is_short -): - exit_order = limit_order[exit_side(is_short)]['id'] - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - - mock_insuf = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_insufficient_funds') - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - limit_order[entry_side(is_short)], - exit_order, - ]), - get_fee=fee, - fetch_order=MagicMock(return_value={'status': 'canceled'}), - ) - mocker.patch.multiple( - EXMS, - create_stoploss=MagicMock(side_effect=InsufficientFundsError()), - ) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - caplog.clear() - freqtrade.create_stoploss_order(trade, 200) - # stoploss_orderid was empty before - assert trade.has_open_sl_orders is False - assert mock_insuf.call_count == 1 - mock_insuf.reset_mock() - - freqtrade.create_stoploss_order(trade, 200) - # No change to stoploss-orderid - assert trade.has_open_sl_orders is False - assert mock_insuf.call_count == 1 - - -@pytest.mark.parametrize("is_short,bid,ask,stop_price,hang_price", [ - (False, [4.38, 4.16], [4.4, 4.17], ['2.0805', 4.4 * 0.95], 3), - (True, [1.09, 1.21], [1.1, 1.22], ['2.321', 1.09 * 1.05], 1.5), -]) -@pytest.mark.usefixtures("init_persistence") -def test_handle_stoploss_on_exchange_trailing( - mocker, default_conf_usdt, fee, is_short, bid, ask, limit_order, stop_price, hang_price, - time_machine, -) -> None: - # When trailing stoploss is set - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - start_dt = dt_now() - time_machine.move_to(start_dt, tick=False) - patch_RPCManager(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 2.19, - 'ask': 2.2, - 'last': 2.19, - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - ) - mocker.patch.multiple( - EXMS, - create_stoploss=stoploss, - stoploss_adjust=MagicMock(return_value=True), - ) - - # enabling TSL - default_conf_usdt['trailing_stop'] = True - - # disabling ROI - default_conf_usdt['minimal_roi']['0'] = 999999999 - - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 - - # setting stoploss_on_exchange_interval to 60 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 - - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - assert trade.has_open_sl_orders is False - trade.stoploss_last_update = dt_now() - timedelta(minutes=20) - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='100', - order_date=dt_now() - timedelta(minutes=20), - ) - ) - - stoploss_order_hanging = { - 'id': '100', - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': hang_price, - 'average': 2, - 'fee': {}, - 'amount': 0, - 'info': { - 'stopPrice': stop_price[0] - } - } - stoploss_order_cancel = deepcopy(stoploss_order_hanging) - stoploss_order_cancel['status'] = 'canceled' - - mocker.patch(f'{EXMS}.fetch_stoploss_order', return_value=stoploss_order_hanging) - mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=stoploss_order_cancel) - - # stoploss initially at 5% - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - assert len(trade.open_sl_orders) == 1 - - assert trade.open_sl_orders[-1].order_id == '13434334' - - # price jumped 2x - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': bid[0], - 'ask': ask[0], - 'last': bid[0], - }) - ) - - cancel_order_mock = MagicMock(return_value={ - 'id': '13434334', 'status': 'canceled', 'fee': {}, 'amount': trade.amount}) - stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) - mocker.patch(f'{EXMS}.fetch_stoploss_order') - mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) - mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - - # stoploss should not be updated as the interval is 60 seconds - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert len(trade.open_sl_orders) == 1 - cancel_order_mock.assert_not_called() - stoploss_order_mock.assert_not_called() - - # Move time by 10s ... so stoploss order should be replaced. - time_machine.move_to(start_dt + timedelta(minutes=10), tick=False) - - assert freqtrade.handle_trade(trade) is False - assert trade.stop_loss == stop_price[1] - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') - stoploss_order_mock.assert_called_once_with( - amount=30, - pair='ETH/USDT', - order_types=freqtrade.strategy.order_types, - stop_price=stop_price[1], - side=exit_side(is_short), - leverage=1.0 - ) - - # price fell below stoploss, so dry-run sells trade. - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': bid[1], - 'ask': ask[1], - 'last': bid[1], - }) - ) - mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', - return_value={'id': 'so1', 'status': 'canceled'}) - assert len(trade.open_sl_orders) == 1 - assert trade.open_sl_orders[-1].order_id == 'so1' - - assert freqtrade.handle_trade(trade) is True - assert trade.is_open is False - assert trade.has_open_sl_orders is False - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_handle_stoploss_on_exchange_trailing_error( - mocker, default_conf_usdt, fee, caplog, limit_order, is_short, time_machine -) -> None: - time_machine.move_to(dt_now() - timedelta(minutes=601)) - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - # When trailing stoploss is set - stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - patch_exchange(mocker) - - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - {'id': enter_order['id']}, - {'id': exit_order['id']}, - ]), - get_fee=fee, - create_stoploss=stoploss, - stoploss_adjust=MagicMock(return_value=True), - ) - - # enabling TSL - default_conf_usdt['trailing_stop'] = True - - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 - - # setting stoploss_on_exchange_interval to 60 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - trade.stop_loss = 0.2 - - stoploss_order_hanging = { - 'id': "abcd", - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'info': { - 'stopPrice': '0.1' - } - } - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=3, - order_id='abcd', - order_date=dt_now(), - ) - ) - mocker.patch(f'{EXMS}.cancel_stoploss_order', - side_effect=InvalidOrderException()) - mocker.patch(f'{EXMS}.fetch_stoploss_order', - return_value=stoploss_order_hanging) - time_machine.shift(timedelta(minutes=50)) - freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) - assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog) - - # Still try to create order - assert stoploss.call_count == 1 - # TODO: Is this actually correct ? This will create a new order every time, - assert len(trade.open_sl_orders) == 2 - - # Fail creating stoploss order - caplog.clear() - cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order') - mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) - time_machine.shift(timedelta(minutes=50)) - freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) - assert cancel_mock.call_count == 2 - assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) - - -def test_stoploss_on_exchange_price_rounding( - mocker, default_conf_usdt, fee, open_trade_usdt) -> None: - patch_RPCManager(mocker) - mocker.patch.multiple( - EXMS, - get_fee=fee, - ) - price_mock = MagicMock(side_effect=lambda p, s, **kwargs: int(s)) - stoploss_mock = MagicMock(return_value={'id': '13434334'}) - adjust_mock = MagicMock(return_value=False) - mocker.patch.multiple( - EXMS, - create_stoploss=stoploss_mock, - stoploss_adjust=adjust_mock, - price_to_precision=price_mock, - ) - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - open_trade_usdt.stop_loss = 222.55 - - freqtrade.handle_trailing_stoploss_on_exchange(open_trade_usdt, {}) - assert price_mock.call_count == 1 - assert adjust_mock.call_count == 1 - assert adjust_mock.call_args_list[0][0][0] == 222 - - -@pytest.mark.parametrize("is_short", [False, True]) -@pytest.mark.usefixtures("init_persistence") -def test_handle_stoploss_on_exchange_custom_stop( - mocker, default_conf_usdt, fee, is_short, limit_order -) -> None: - enter_order = limit_order[entry_side(is_short)] - exit_order = limit_order[exit_side(is_short)] - # When trailing stoploss is set - stoploss = MagicMock(return_value={'id': 13434334, 'status': 'open'}) - patch_RPCManager(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 1.9, - 'ask': 2.2, - 'last': 1.9 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - is_cancel_order_result_suitable=MagicMock(return_value=True), - ) - mocker.patch.multiple( - EXMS, - create_stoploss=stoploss, - stoploss_adjust=MagicMock(return_value=True), - ) - - # enabling TSL - default_conf_usdt['use_custom_stoploss'] = True - - # disabling ROI - default_conf_usdt['minimal_roi']['0'] = 999999999 - - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.custom_stoploss = lambda *args, **kwargs: -0.04 - - # setting stoploss_on_exchange_interval to 60 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 - - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - trade.is_open = True - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_date=dt_now() - timedelta(minutes=601), - order_id='100', - ) - ) - Trade.commit() - slo = { - 'id': '100', - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'info': { - 'stopPrice': '2.0805' - } - } - slo_canceled = deepcopy(slo) - slo_canceled.update({'status': 'canceled'}) - - def fetch_stoploss_order_mock(order_id, *args, **kwargs): - x = deepcopy(slo) - x['id'] = order_id - return x - - mocker.patch(f'{EXMS}.fetch_stoploss_order', MagicMock(fetch_stoploss_order_mock)) - mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=slo_canceled) - - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - # price jumped 2x - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': 4.38 if not is_short else 1.9 / 2, - 'ask': 4.4 if not is_short else 2.2 / 2, - 'last': 4.38 if not is_short else 1.9 / 2, - }) - ) - - cancel_order_mock = MagicMock() - stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) - mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) - mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - - # stoploss should not be updated as the interval is 60 seconds - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - cancel_order_mock.assert_not_called() - stoploss_order_mock.assert_not_called() - - assert freqtrade.handle_trade(trade) is False - assert trade.stop_loss == 4.4 * 0.96 if not is_short else 1.1 - assert trade.stop_loss_pct == -0.04 if not is_short else 0.04 - - # setting stoploss_on_exchange_interval to 0 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 - cancel_order_mock.assert_not_called() - stoploss_order_mock.assert_not_called() - - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') - # Long uses modified ask - offset, short modified bid + offset - stoploss_order_mock.assert_called_once_with( - amount=pytest.approx(trade.amount), - pair='ETH/USDT', - order_types=freqtrade.strategy.order_types, - stop_price=4.4 * 0.96 if not is_short else 0.95 * 1.04, - side=exit_side(is_short), - leverage=1.0 - ) - - # price fell below stoploss, so dry-run sells trade. - mocker.patch( - f'{EXMS}.fetch_ticker', - MagicMock(return_value={ - 'bid': 4.17, - 'ask': 4.19, - 'last': 4.17 - }) - ) - assert freqtrade.handle_trade(trade) is True - - -def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_order) -> None: - - enter_order = limit_order['buy'] - exit_order = limit_order['sell'] - enter_order['average'] = 2.19 - # When trailing stoploss is set - stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) - patch_RPCManager(mocker) - patch_exchange(mocker) - patch_edge(mocker) - edge_conf['max_open_trades'] = float('inf') - edge_conf['dry_run_wallet'] = 999.9 - edge_conf['exchange']['name'] = 'binance' - mocker.patch.multiple( - EXMS, - fetch_ticker=MagicMock(return_value={ - 'bid': 2.19, - 'ask': 2.2, - 'last': 2.19 - }), - create_order=MagicMock(side_effect=[ - enter_order, - exit_order, - ]), - get_fee=fee, - create_stoploss=stoploss, - ) - - # enabling TSL - edge_conf['trailing_stop'] = True - edge_conf['trailing_stop_positive'] = 0.01 - edge_conf['trailing_stop_positive_offset'] = 0.011 - - # disabling ROI - edge_conf['minimal_roi']['0'] = 999999999 - - freqtrade = FreqtradeBot(edge_conf) - - # enabling stoploss on exchange - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - - # setting stoploss - freqtrade.strategy.stoploss = -0.02 - - # setting stoploss_on_exchange_interval to 0 seconds - freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 - - patch_get_signal(freqtrade) - - freqtrade.active_pair_whitelist = freqtrade.edge.adjust(freqtrade.active_pair_whitelist) - - freqtrade.enter_positions() - trade = Trade.session.scalars(select(Trade)).first() - trade.is_open = True - - trade.stoploss_last_update = dt_now() - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='100', - ) - ) - - stoploss_order_hanging = MagicMock(return_value={ - 'id': '100', - 'status': 'open', - 'type': 'stop_loss_limit', - 'price': 3, - 'average': 2, - 'stopPrice': '2.178' - }) - - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hanging) - - # stoploss initially at 20% as edge dictated it. - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert pytest.approx(trade.stop_loss) == 1.76 - - cancel_order_mock = MagicMock() - stoploss_order_mock = MagicMock() - mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) - mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) - - # price goes down 5% - mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ - 'bid': 2.19 * 0.95, - 'ask': 2.2 * 0.95, - 'last': 2.19 * 0.95 - })) - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - # stoploss should remain the same - assert pytest.approx(trade.stop_loss) == 1.76 - - # stoploss on exchange should not be canceled - cancel_order_mock.assert_not_called() - - # price jumped 2x - mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ - 'bid': 4.38, - 'ask': 4.4, - 'last': 4.38 - })) - - assert freqtrade.handle_trade(trade) is False - assert freqtrade.handle_stoploss_on_exchange(trade) is False - - # stoploss should be set to 1% as trailing is on - assert trade.stop_loss == 4.4 * 0.99 - cancel_order_mock.assert_called_once_with('100', 'NEO/BTC') - stoploss_order_mock.assert_called_once_with( - amount=30, - pair='NEO/BTC', - order_types=freqtrade.strategy.order_types, - stop_price=4.4 * 0.99, - side='sell', - leverage=1.0 - ) - - @pytest.mark.parametrize('return_value,side_effect,log_message', [ (False, None, 'Found no enter signals for whitelisted currencies. Trying again...'), (None, DependencyException, 'Unable to create trade for ETH/USDT: ') @@ -3988,257 +2923,7 @@ def test_execute_trade_exit_custom_exit_price( } == last_msg -@pytest.mark.parametrize("is_short", [False, True]) -def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( - default_conf_usdt, ticker_usdt, fee, is_short, ticker_usdt_sell_down, - ticker_usdt_sell_up, mocker) -> None: - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - _dry_is_price_crossed=MagicMock(return_value=False), - ) - patch_whitelist(mocker, default_conf_usdt) - freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - # Create some test data - freqtrade.enter_positions() - - trade = Trade.session.scalars(select(Trade)).first() - assert trade.is_short == is_short - assert trade - - # Decrease the price and sell it - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt_sell_up if is_short else ticker_usdt_sell_down - ) - - default_conf_usdt['dry_run'] = True - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - # Setting trade stoploss to 0.01 - - trade.stop_loss = 2.0 * 1.01 if is_short else 2.0 * 0.99 - freqtrade.execute_trade_exit( - trade=trade, limit=trade.stop_loss, - exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) - - assert rpc_mock.call_count == 2 - last_msg = rpc_mock.call_args_list[-1][0][0] - - assert { - 'type': RPCMessageType.EXIT, - 'trade_id': 1, - 'exchange': 'Binance', - 'pair': 'ETH/USDT', - 'direction': 'Short' if trade.is_short else 'Long', - 'leverage': 1.0, - 'gain': 'loss', - 'limit': 2.02 if is_short else 1.98, - 'order_rate': 2.02 if is_short else 1.98, - 'amount': pytest.approx(29.70297029 if is_short else 30.0), - 'order_type': 'limit', - 'buy_tag': None, - 'enter_tag': None, - 'open_rate': 2.02 if is_short else 2.0, - 'current_rate': 2.2 if is_short else 2.0, - 'profit_amount': -0.3 if is_short else -0.8985, - 'profit_ratio': -0.00501253 if is_short else -0.01493766, - 'stake_currency': 'USDT', - 'quote_currency': 'USDT', - 'fiat_currency': 'USD', - 'base_currency': 'ETH', - 'exit_reason': ExitType.STOP_LOSS.value, - 'open_date': ANY, - 'close_date': ANY, - 'close_rate': ANY, - 'sub_trade': False, - 'cumulative_profit': 0.0, - 'stake_amount': pytest.approx(60), - 'is_final_exit': False, - 'final_profit_ratio': None, - } == last_msg - - -def test_execute_trade_exit_sloe_cancel_exception( - mocker, default_conf_usdt, ticker_usdt, fee, caplog) -> None: - freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - mocker.patch(f'{EXMS}.cancel_stoploss_order', side_effect=InvalidOrderException()) - mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=300)) - create_order_mock = MagicMock(side_effect=[ - {'id': '12345554'}, - {'id': '12345555'}, - ]) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - create_order=create_order_mock, - ) - - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - patch_get_signal(freqtrade) - freqtrade.enter_positions() - - trade = Trade.session.scalars(select(Trade)).first() - PairLock.session = MagicMock() - - freqtrade.config['dry_run'] = False - trade.orders.append( - Order( - ft_order_side='stoploss', - ft_pair=trade.pair, - ft_is_open=True, - ft_amount=trade.amount, - ft_price=trade.stop_loss, - order_id='abcd', - status='open', - ) - ) - - freqtrade.execute_trade_exit(trade=trade, limit=1234, - exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) - assert create_order_mock.call_count == 2 - assert log_has('Could not cancel stoploss order abcd for pair ETH/USDT', caplog) - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_execute_trade_exit_with_stoploss_on_exchange( - default_conf_usdt, ticker_usdt, fee, ticker_usdt_sell_up, is_short, mocker) -> None: - - default_conf_usdt['exchange']['name'] = 'binance' - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - stoploss = MagicMock(return_value={ - 'id': 123, - 'status': 'open', - 'info': { - 'foo': 'bar' - } - }) - mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_order_fee') - - cancel_order = MagicMock(return_value=True) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - amount_to_precision=lambda s, x, y: y, - price_to_precision=lambda s, x, y: y, - create_stoploss=stoploss, - cancel_stoploss_order=cancel_order, - _dry_is_price_crossed=MagicMock(side_effect=[True, False]), - ) - - freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) - - # Create some test data - freqtrade.enter_positions() - - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - assert trade - trades = [trade] - - freqtrade.manage_open_orders() - freqtrade.exit_positions(trades) - - # Increase the price and sell it - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt_sell_up - ) - - freqtrade.execute_trade_exit( - trade=trade, - limit=ticker_usdt_sell_up()['ask' if is_short else 'bid'], - exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS) - ) - - trade = Trade.session.scalars(select(Trade)).first() - trade.is_short = is_short - assert trade - assert cancel_order.call_count == 1 - assert rpc_mock.call_count == 4 - - -@pytest.mark.parametrize("is_short", [False, True]) -def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( - default_conf_usdt, ticker_usdt, fee, mocker, is_short) -> None: - default_conf_usdt['exchange']['name'] = 'binance' - rpc_mock = patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch.multiple( - EXMS, - fetch_ticker=ticker_usdt, - get_fee=fee, - amount_to_precision=lambda s, x, y: y, - price_to_precision=lambda s, x, y: y, - _dry_is_price_crossed=MagicMock(side_effect=[False, True]), - ) - - stoploss = MagicMock(return_value={ - 'id': 123, - 'info': { - 'foo': 'bar' - } - }) - - mocker.patch(f'{EXMS}.create_stoploss', stoploss) - - freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.strategy.order_types['stoploss_on_exchange'] = True - patch_get_signal(freqtrade, enter_long=not is_short, enter_short=is_short) - - # Create some test data - freqtrade.enter_positions() - freqtrade.manage_open_orders() - trade = Trade.session.scalars(select(Trade)).first() - trades = [trade] - assert trade.has_open_sl_orders is False - - freqtrade.exit_positions(trades) - assert trade - assert trade.has_open_sl_orders is True - assert not trade.has_open_orders - - # Assuming stoploss on exchange is hit - # trade should be sold at the price of stoploss, with exit_reason STOPLOSS_ON_EXCHANGE - stoploss_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": "buy" if is_short else "sell", - "price": 1.08801, - "amount": trade.amount, - "cost": 1.08801 * trade.amount, - "average": 1.08801, - "filled": trade.amount, - "remaining": 0.0, - "status": "closed", - "fee": None, - "trades": None - }) - mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_executed) - - freqtrade.exit_positions(trades) - assert trade.has_open_sl_orders is False - assert trade.is_open is False - assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value - assert rpc_mock.call_count == 4 - assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY - assert rpc_mock.call_args_list[1][0][0]['amount'] > 20 - assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL - assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL @pytest.mark.parametrize( diff --git a/tests/freqtradebot/test_stoploss_on_exchange.py b/tests/freqtradebot/test_stoploss_on_exchange.py new file mode 100644 index 000000000..325fe549f --- /dev/null +++ b/tests/freqtradebot/test_stoploss_on_exchange.py @@ -0,0 +1,1334 @@ +from copy import deepcopy +from datetime import timedelta +from unittest.mock import ANY, MagicMock + +import pytest +from sqlalchemy import select + +from freqtrade.enums import ExitCheckTuple, ExitType, RPCMessageType +from freqtrade.exceptions import ExchangeError, InsufficientFundsError, InvalidOrderException +from freqtrade.freqtradebot import FreqtradeBot +from freqtrade.persistence import Order, Trade +from freqtrade.persistence.models import PairLock +from freqtrade.util.datetime_helpers import dt_now +from tests.conftest import (EXMS, get_patched_freqtradebot, log_has, log_has_re, patch_edge, + patch_exchange, patch_get_signal, patch_whitelist) +from tests.conftest_trades import entry_side, exit_side +from tests.freqtradebot.test_freqtradebot import patch_RPCManager + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_add_stoploss_on_exchange(mocker, default_conf_usdt, limit_order, is_short, fee) -> None: + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(return_value=limit_order[entry_side(is_short)]), + get_fee=fee, + ) + order = limit_order[entry_side(is_short)] + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True)) + mocker.patch(f'{EXMS}.fetch_order', return_value=order) + mocker.patch(f'{EXMS}.get_trades_for_order', return_value=[]) + + stoploss = MagicMock(return_value={'id': 13434334}) + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + + freqtrade = FreqtradeBot(default_conf_usdt) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + trades = [trade] + + freqtrade.exit_positions(trades) + assert trade.has_open_sl_orders is True + assert stoploss.call_count == 1 + assert trade.is_open is True + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_short, + limit_order) -> None: + stop_order_dict = {'id': "13434334"} + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + # 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 + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + assert trade.is_open + assert trade.has_open_sl_orders is False + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert trade.open_sl_orders[-1].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 + + hanging_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order', hanging_stoploss_order) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + hanging_stoploss_order.assert_called_once_with('13434334', trade.pair) + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == "13434334" + + # Third case: when stoploss was set but it was canceled for some reason + # should set a stoploss immediately and return False + caplog.clear() + trade.is_open = True + + canceled_stoploss_order = MagicMock(return_value={'id': '13434334', 'status': 'canceled'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order', canceled_stoploss_order) + stoploss.reset_mock() + amount_before = trade.amount + + stop_order_dict.update({'id': "103_1"}) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == "103_1" + assert trade.amount == amount_before + + # Fourth case: when stoploss is set and it is hit + # should return true as a trade actually happened + caplog.clear() + stop_order_dict.update({'id': "103_1"}) + + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + + stoploss_order_hit = MagicMock(return_value={ + 'id': "103_1", + 'status': 'closed', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': enter_order['amount'], + 'remaining': 0, + 'amount': enter_order['amount'], + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) + assert freqtrade.handle_stoploss_on_exchange(trade) is True + assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog) + assert len(trade.open_sl_orders) == 0 + assert trade.is_open is False + caplog.clear() + + mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) + trade.is_open = True + freqtrade.handle_stoploss_on_exchange(trade) + assert log_has('Unable to place a stoploss order on exchange.', caplog) + assert len(trade.open_sl_orders) == 0 + + # Fifth case: fetch_order returns InvalidOrder + # It should try to add stoploss order + stop_order_dict.update({'id': "105"}) + stoploss.reset_mock() + mocker.patch(f'{EXMS}.fetch_stoploss_order', side_effect=InvalidOrderException()) + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + freqtrade.handle_stoploss_on_exchange(trade) + assert len(trade.open_sl_orders) == 1 + assert stoploss.call_count == 1 + + # Sixth case: Closed Trade + # Should not create new order + trade.is_open = False + trade.open_sl_orders[-1].ft_is_open = False + stoploss.reset_mock() + mocker.patch(f'{EXMS}.fetch_order') + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.has_open_sl_orders is False + assert stoploss.call_count == 0 + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_emergency(mocker, default_conf_usdt, fee, is_short, + limit_order) -> None: + stop_order_dict = {'id': "13434334"} + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + assert trade.is_open + assert trade.has_open_sl_orders is False + + # emergency exit triggered + # Trailing stop should not act anymore + stoploss_order_cancelled = MagicMock(side_effect=[{ + 'id': "107", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'amount': enter_order['amount'], + 'filled': 0, + 'remaining': enter_order['amount'], + 'info': {'stopPrice': 22}, + }]) + trade.stoploss_last_update = dt_now() - timedelta(hours=1) + trade.stop_loss = 24 + trade.exit_reason = None + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='107', + status='open', + ) + ) + freqtrade.config['trailing_stop'] = True + stoploss = MagicMock(side_effect=InvalidOrderException()) + assert trade.has_open_sl_orders is True + Trade.commit() + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', + side_effect=InvalidOrderException()) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_cancelled) + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.has_open_sl_orders is False + assert trade.is_open is False + assert trade.exit_reason == str(ExitType.EMERGENCY_EXIT) + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_partial( + mocker, default_conf_usdt, fee, is_short, limit_order) -> None: + stop_order_dict = {'id': "101", "status": "open"} + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "101" + assert trade.amount == 30 + stop_order_dict.update({'id': "102"}) + # Stoploss on exchange is cancelled on exchange, but filled partially. + # Must update trade amount to guarantee successful exit. + stoploss_order_hit = MagicMock(return_value={ + 'id': "101", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': trade.amount / 2, + 'remaining': trade.amount / 2, + 'amount': enter_order['amount'], + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + # Stoploss filled partially ... + assert trade.amount == 15 + + assert trade.open_sl_orders[-1].order_id == "102" + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_partial_cancel_here( + mocker, default_conf_usdt, fee, is_short, limit_order, caplog, time_machine) -> None: + stop_order_dict = {'id': "101", "status": "open"} + time_machine.move_to(dt_now()) + default_conf_usdt['trailing_stop'] = True + stoploss = MagicMock(return_value=stop_order_dict) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 1 + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "101" + assert trade.amount == 30 + stop_order_dict.update({'id': "102"}) + # Stoploss on exchange is open. + # Freqtrade cancels the stop - but cancel returns a partial filled order. + stoploss_order_hit = MagicMock(return_value={ + 'id': "101", + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': 0, + 'remaining': trade.amount, + 'amount': enter_order['amount'], + }) + stoploss_order_cancel = MagicMock(return_value={ + 'id': "101", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'filled': trade.amount / 2, + 'remaining': trade.amount / 2, + 'amount': enter_order['amount'], + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit) + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', stoploss_order_cancel) + time_machine.shift(timedelta(minutes=15)) + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + # Canceled Stoploss filled partially ... + assert log_has_re('Cancelling current stoploss on exchange.*', caplog) + + assert trade.has_open_sl_orders is True + assert trade.open_sl_orders[-1].order_id == "102" + assert trade.amount == 15 + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, is_short, + limit_order) -> None: + # Sixth case: stoploss order was cancelled but couldn't create new one + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + ) + mocker.patch.multiple( + EXMS, + fetch_stoploss_order=MagicMock(return_value={'status': 'canceled', 'id': '100'}), + create_stoploss=MagicMock(side_effect=ExchangeError()), + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + trade.is_open = True + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='100', + status='open', + ) + ) + assert trade + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert log_has_re(r'All Stoploss orders are cancelled, but unable to recreate one\.', caplog) + assert trade.has_open_sl_orders is False + assert trade.is_open is True + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_create_stoploss_order_invalid_order( + mocker, default_conf_usdt, caplog, fee, is_short, limit_order +): + open_order = limit_order[entry_side(is_short)] + order = limit_order[exit_side(is_short)] + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + create_order_mock = MagicMock(side_effect=[ + open_order, + order, + ]) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=create_order_mock, + get_fee=fee, + ) + mocker.patch.multiple( + EXMS, + fetch_order=MagicMock(return_value={'status': 'canceled'}), + create_stoploss=MagicMock(side_effect=InvalidOrderException()), + ) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + caplog.clear() + rpc_mock.reset_mock() + freqtrade.create_stoploss_order(trade, 200) + assert trade.has_open_sl_orders is False + assert trade.exit_reason == ExitType.EMERGENCY_EXIT.value + assert log_has("Unable to place a stoploss order on exchange. ", caplog) + assert log_has("Exiting the trade forcefully", caplog) + + # Should call a market sell + assert create_order_mock.call_count == 2 + assert create_order_mock.call_args[1]['ordertype'] == 'market' + assert create_order_mock.call_args[1]['pair'] == trade.pair + assert create_order_mock.call_args[1]['amount'] == trade.amount + + # Rpc is sending first buy, then sell + assert rpc_mock.call_count == 2 + assert rpc_mock.call_args_list[0][0][0]['exit_reason'] == ExitType.EMERGENCY_EXIT.value + assert rpc_mock.call_args_list[0][0][0]['order_type'] == 'market' + assert rpc_mock.call_args_list[0][0][0]['type'] == 'exit' + assert rpc_mock.call_args_list[1][0][0]['type'] == 'exit_fill' + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_create_stoploss_order_insufficient_funds( + mocker, default_conf_usdt, caplog, fee, limit_order, is_short +): + exit_order = limit_order[exit_side(is_short)]['id'] + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + mock_insuf = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_insufficient_funds') + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + limit_order[entry_side(is_short)], + exit_order, + ]), + get_fee=fee, + fetch_order=MagicMock(return_value={'status': 'canceled'}), + ) + mocker.patch.multiple( + EXMS, + create_stoploss=MagicMock(side_effect=InsufficientFundsError()), + ) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + caplog.clear() + freqtrade.create_stoploss_order(trade, 200) + # stoploss_orderid was empty before + assert trade.has_open_sl_orders is False + assert mock_insuf.call_count == 1 + mock_insuf.reset_mock() + + freqtrade.create_stoploss_order(trade, 200) + # No change to stoploss-orderid + assert trade.has_open_sl_orders is False + assert mock_insuf.call_count == 1 + + +@pytest.mark.parametrize("is_short,bid,ask,stop_price,hang_price", [ + (False, [4.38, 4.16], [4.4, 4.17], ['2.0805', 4.4 * 0.95], 3), + (True, [1.09, 1.21], [1.1, 1.22], ['2.321', 1.09 * 1.05], 1.5), +]) +@pytest.mark.usefixtures("init_persistence") +def test_handle_stoploss_on_exchange_trailing( + mocker, default_conf_usdt, fee, is_short, bid, ask, limit_order, stop_price, hang_price, + time_machine, +) -> None: + # When trailing stoploss is set + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + start_dt = dt_now() + time_machine.move_to(start_dt, tick=False) + patch_RPCManager(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 2.19, + 'ask': 2.2, + 'last': 2.19, + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + ) + mocker.patch.multiple( + EXMS, + create_stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), + ) + + # enabling TSL + default_conf_usdt['trailing_stop'] = True + + # disabling ROI + default_conf_usdt['minimal_roi']['0'] = 999999999 + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + assert trade.has_open_sl_orders is False + trade.stoploss_last_update = dt_now() - timedelta(minutes=20) + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='100', + order_date=dt_now() - timedelta(minutes=20), + ) + ) + + stoploss_order_hanging = { + 'id': '100', + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': hang_price, + 'average': 2, + 'fee': {}, + 'amount': 0, + 'info': { + 'stopPrice': stop_price[0] + } + } + stoploss_order_cancel = deepcopy(stoploss_order_hanging) + stoploss_order_cancel['status'] = 'canceled' + + mocker.patch(f'{EXMS}.fetch_stoploss_order', return_value=stoploss_order_hanging) + mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=stoploss_order_cancel) + + # stoploss initially at 5% + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + assert len(trade.open_sl_orders) == 1 + + assert trade.open_sl_orders[-1].order_id == '13434334' + + # price jumped 2x + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': bid[0], + 'ask': ask[0], + 'last': bid[0], + }) + ) + + cancel_order_mock = MagicMock(return_value={ + 'id': '13434334', 'status': 'canceled', 'fee': {}, 'amount': trade.amount}) + stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) + mocker.patch(f'{EXMS}.fetch_stoploss_order') + mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) + mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) + + # stoploss should not be updated as the interval is 60 seconds + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert len(trade.open_sl_orders) == 1 + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() + + # Move time by 10s ... so stoploss order should be replaced. + time_machine.move_to(start_dt + timedelta(minutes=10), tick=False) + + assert freqtrade.handle_trade(trade) is False + assert trade.stop_loss == stop_price[1] + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') + stoploss_order_mock.assert_called_once_with( + amount=30, + pair='ETH/USDT', + order_types=freqtrade.strategy.order_types, + stop_price=stop_price[1], + side=exit_side(is_short), + leverage=1.0 + ) + + # price fell below stoploss, so dry-run sells trade. + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': bid[1], + 'ask': ask[1], + 'last': bid[1], + }) + ) + mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', + return_value={'id': 'so1', 'status': 'canceled'}) + assert len(trade.open_sl_orders) == 1 + assert trade.open_sl_orders[-1].order_id == 'so1' + + assert freqtrade.handle_trade(trade) is True + assert trade.is_open is False + assert trade.has_open_sl_orders is False + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_handle_stoploss_on_exchange_trailing_error( + mocker, default_conf_usdt, fee, caplog, limit_order, is_short, time_machine +) -> None: + time_machine.move_to(dt_now() - timedelta(minutes=601)) + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + # When trailing stoploss is set + stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + patch_exchange(mocker) + + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + {'id': enter_order['id']}, + {'id': exit_order['id']}, + ]), + get_fee=fee, + create_stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), + ) + + # enabling TSL + default_conf_usdt['trailing_stop'] = True + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = 0.05 if is_short else -0.05 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + trade.stop_loss = 0.2 + + stoploss_order_hanging = { + 'id': "abcd", + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'info': { + 'stopPrice': '0.1' + } + } + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=3, + order_id='abcd', + order_date=dt_now(), + ) + ) + mocker.patch(f'{EXMS}.cancel_stoploss_order', + side_effect=InvalidOrderException()) + mocker.patch(f'{EXMS}.fetch_stoploss_order', + return_value=stoploss_order_hanging) + time_machine.shift(timedelta(minutes=50)) + freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) + assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/USDT.*", caplog) + + # Still try to create order + assert stoploss.call_count == 1 + # TODO: Is this actually correct ? This will create a new order every time, + assert len(trade.open_sl_orders) == 2 + + # Fail creating stoploss order + caplog.clear() + cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order') + mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError()) + time_machine.shift(timedelta(minutes=50)) + freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) + assert cancel_mock.call_count == 2 + assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) + + +def test_stoploss_on_exchange_price_rounding( + mocker, default_conf_usdt, fee, open_trade_usdt) -> None: + patch_RPCManager(mocker) + mocker.patch.multiple( + EXMS, + get_fee=fee, + ) + price_mock = MagicMock(side_effect=lambda p, s, **kwargs: int(s)) + stoploss_mock = MagicMock(return_value={'id': '13434334'}) + adjust_mock = MagicMock(return_value=False) + mocker.patch.multiple( + EXMS, + create_stoploss=stoploss_mock, + stoploss_adjust=adjust_mock, + price_to_precision=price_mock, + ) + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + open_trade_usdt.stop_loss = 222.55 + + freqtrade.handle_trailing_stoploss_on_exchange(open_trade_usdt, {}) + assert price_mock.call_count == 1 + assert adjust_mock.call_count == 1 + assert adjust_mock.call_args_list[0][0][0] == 222 + + +@pytest.mark.parametrize("is_short", [False, True]) +@pytest.mark.usefixtures("init_persistence") +def test_handle_stoploss_on_exchange_custom_stop( + mocker, default_conf_usdt, fee, is_short, limit_order +) -> None: + enter_order = limit_order[entry_side(is_short)] + exit_order = limit_order[exit_side(is_short)] + # When trailing stoploss is set + stoploss = MagicMock(return_value={'id': 13434334, 'status': 'open'}) + patch_RPCManager(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + is_cancel_order_result_suitable=MagicMock(return_value=True), + ) + mocker.patch.multiple( + EXMS, + create_stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), + ) + + # enabling TSL + default_conf_usdt['use_custom_stoploss'] = True + + # disabling ROI + default_conf_usdt['minimal_roi']['0'] = 999999999 + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.custom_stoploss = lambda *args, **kwargs: -0.04 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + trade.is_open = True + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_date=dt_now() - timedelta(minutes=601), + order_id='100', + ) + ) + Trade.commit() + slo = { + 'id': '100', + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'info': { + 'stopPrice': '2.0805' + } + } + slo_canceled = deepcopy(slo) + slo_canceled.update({'status': 'canceled'}) + + def fetch_stoploss_order_mock(order_id, *args, **kwargs): + x = deepcopy(slo) + x['id'] = order_id + return x + + mocker.patch(f'{EXMS}.fetch_stoploss_order', MagicMock(fetch_stoploss_order_mock)) + mocker.patch(f'{EXMS}.cancel_stoploss_order', return_value=slo_canceled) + + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + # price jumped 2x + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': 4.38 if not is_short else 1.9 / 2, + 'ask': 4.4 if not is_short else 2.2 / 2, + 'last': 4.38 if not is_short else 1.9 / 2, + }) + ) + + cancel_order_mock = MagicMock() + stoploss_order_mock = MagicMock(return_value={'id': 'so1', 'status': 'open'}) + mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) + mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) + + # stoploss should not be updated as the interval is 60 seconds + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() + + assert freqtrade.handle_trade(trade) is False + assert trade.stop_loss == 4.4 * 0.96 if not is_short else 1.1 + assert trade.stop_loss_pct == -0.04 if not is_short else 0.04 + + # setting stoploss_on_exchange_interval to 0 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 + cancel_order_mock.assert_not_called() + stoploss_order_mock.assert_not_called() + + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + cancel_order_mock.assert_called_once_with('13434334', 'ETH/USDT') + # Long uses modified ask - offset, short modified bid + offset + stoploss_order_mock.assert_called_once_with( + amount=pytest.approx(trade.amount), + pair='ETH/USDT', + order_types=freqtrade.strategy.order_types, + stop_price=4.4 * 0.96 if not is_short else 0.95 * 1.04, + side=exit_side(is_short), + leverage=1.0 + ) + + # price fell below stoploss, so dry-run sells trade. + mocker.patch( + f'{EXMS}.fetch_ticker', + MagicMock(return_value={ + 'bid': 4.17, + 'ask': 4.19, + 'last': 4.17 + }) + ) + assert freqtrade.handle_trade(trade) is True + + +def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_order) -> None: + + enter_order = limit_order['buy'] + exit_order = limit_order['sell'] + enter_order['average'] = 2.19 + # When trailing stoploss is set + stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'}) + patch_RPCManager(mocker) + patch_exchange(mocker) + patch_edge(mocker) + edge_conf['max_open_trades'] = float('inf') + edge_conf['dry_run_wallet'] = 999.9 + edge_conf['exchange']['name'] = 'binance' + mocker.patch.multiple( + EXMS, + fetch_ticker=MagicMock(return_value={ + 'bid': 2.19, + 'ask': 2.2, + 'last': 2.19 + }), + create_order=MagicMock(side_effect=[ + enter_order, + exit_order, + ]), + get_fee=fee, + create_stoploss=stoploss, + ) + + # enabling TSL + edge_conf['trailing_stop'] = True + edge_conf['trailing_stop_positive'] = 0.01 + edge_conf['trailing_stop_positive_offset'] = 0.011 + + # disabling ROI + edge_conf['minimal_roi']['0'] = 999999999 + + freqtrade = FreqtradeBot(edge_conf) + + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = -0.02 + + # setting stoploss_on_exchange_interval to 0 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 0 + + patch_get_signal(freqtrade) + + freqtrade.active_pair_whitelist = freqtrade.edge.adjust(freqtrade.active_pair_whitelist) + + freqtrade.enter_positions() + trade = Trade.session.scalars(select(Trade)).first() + trade.is_open = True + + trade.stoploss_last_update = dt_now() + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='100', + ) + ) + + stoploss_order_hanging = MagicMock(return_value={ + 'id': '100', + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'stopPrice': '2.178' + }) + + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hanging) + + # stoploss initially at 20% as edge dictated it. + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert pytest.approx(trade.stop_loss) == 1.76 + + cancel_order_mock = MagicMock() + stoploss_order_mock = MagicMock() + mocker.patch(f'{EXMS}.cancel_stoploss_order', cancel_order_mock) + mocker.patch(f'{EXMS}.create_stoploss', stoploss_order_mock) + + # price goes down 5% + mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ + 'bid': 2.19 * 0.95, + 'ask': 2.2 * 0.95, + 'last': 2.19 * 0.95 + })) + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + # stoploss should remain the same + assert pytest.approx(trade.stop_loss) == 1.76 + + # stoploss on exchange should not be canceled + cancel_order_mock.assert_not_called() + + # price jumped 2x + mocker.patch(f'{EXMS}.fetch_ticker', MagicMock(return_value={ + 'bid': 4.38, + 'ask': 4.4, + 'last': 4.38 + })) + + assert freqtrade.handle_trade(trade) is False + assert freqtrade.handle_stoploss_on_exchange(trade) is False + + # stoploss should be set to 1% as trailing is on + assert trade.stop_loss == 4.4 * 0.99 + cancel_order_mock.assert_called_once_with('100', 'NEO/BTC') + stoploss_order_mock.assert_called_once_with( + amount=30, + pair='NEO/BTC', + order_types=freqtrade.strategy.order_types, + stop_price=4.4 * 0.99, + side='sell', + leverage=1.0 + ) + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( + default_conf_usdt, ticker_usdt, fee, is_short, ticker_usdt_sell_down, + ticker_usdt_sell_up, mocker) -> None: + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + _dry_is_price_crossed=MagicMock(return_value=False), + ) + patch_whitelist(mocker, default_conf_usdt) + freqtrade = FreqtradeBot(default_conf_usdt) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + # Create some test data + freqtrade.enter_positions() + + trade = Trade.session.scalars(select(Trade)).first() + assert trade.is_short == is_short + assert trade + + # Decrease the price and sell it + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt_sell_up if is_short else ticker_usdt_sell_down + ) + + default_conf_usdt['dry_run'] = True + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + # Setting trade stoploss to 0.01 + + trade.stop_loss = 2.0 * 1.01 if is_short else 2.0 * 0.99 + freqtrade.execute_trade_exit( + trade=trade, limit=trade.stop_loss, + exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) + + assert rpc_mock.call_count == 2 + last_msg = rpc_mock.call_args_list[-1][0][0] + + assert { + 'type': RPCMessageType.EXIT, + 'trade_id': 1, + 'exchange': 'Binance', + 'pair': 'ETH/USDT', + 'direction': 'Short' if trade.is_short else 'Long', + 'leverage': 1.0, + 'gain': 'loss', + 'limit': 2.02 if is_short else 1.98, + 'order_rate': 2.02 if is_short else 1.98, + 'amount': pytest.approx(29.70297029 if is_short else 30.0), + 'order_type': 'limit', + 'buy_tag': None, + 'enter_tag': None, + 'open_rate': 2.02 if is_short else 2.0, + 'current_rate': 2.2 if is_short else 2.0, + 'profit_amount': -0.3 if is_short else -0.8985, + 'profit_ratio': -0.00501253 if is_short else -0.01493766, + 'stake_currency': 'USDT', + 'quote_currency': 'USDT', + 'fiat_currency': 'USD', + 'base_currency': 'ETH', + 'exit_reason': ExitType.STOP_LOSS.value, + 'open_date': ANY, + 'close_date': ANY, + 'close_rate': ANY, + 'sub_trade': False, + 'cumulative_profit': 0.0, + 'stake_amount': pytest.approx(60), + 'is_final_exit': False, + 'final_profit_ratio': None, + } == last_msg + + +def test_execute_trade_exit_sloe_cancel_exception( + mocker, default_conf_usdt, ticker_usdt, fee, caplog) -> None: + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + mocker.patch(f'{EXMS}.cancel_stoploss_order', side_effect=InvalidOrderException()) + mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=300)) + create_order_mock = MagicMock(side_effect=[ + {'id': '12345554'}, + {'id': '12345555'}, + ]) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + create_order=create_order_mock, + ) + + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade) + freqtrade.enter_positions() + + trade = Trade.session.scalars(select(Trade)).first() + PairLock.session = MagicMock() + + freqtrade.config['dry_run'] = False + trade.orders.append( + Order( + ft_order_side='stoploss', + ft_pair=trade.pair, + ft_is_open=True, + ft_amount=trade.amount, + ft_price=trade.stop_loss, + order_id='abcd', + status='open', + ) + ) + + freqtrade.execute_trade_exit(trade=trade, limit=1234, + exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)) + assert create_order_mock.call_count == 2 + assert log_has('Could not cancel stoploss order abcd for pair ETH/USDT', caplog) + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_execute_trade_exit_with_stoploss_on_exchange( + default_conf_usdt, ticker_usdt, fee, ticker_usdt_sell_up, is_short, mocker) -> None: + + default_conf_usdt['exchange']['name'] = 'binance' + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + stoploss = MagicMock(return_value={ + 'id': 123, + 'status': 'open', + 'info': { + 'foo': 'bar' + } + }) + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_order_fee') + + cancel_order = MagicMock(return_value=True) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + amount_to_precision=lambda s, x, y: y, + price_to_precision=lambda s, x, y: y, + create_stoploss=stoploss, + cancel_stoploss_order=cancel_order, + _dry_is_price_crossed=MagicMock(side_effect=[True, False]), + ) + + freqtrade = FreqtradeBot(default_conf_usdt) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + + # Create some test data + freqtrade.enter_positions() + + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + assert trade + trades = [trade] + + freqtrade.manage_open_orders() + freqtrade.exit_positions(trades) + + # Increase the price and sell it + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt_sell_up + ) + + freqtrade.execute_trade_exit( + trade=trade, + limit=ticker_usdt_sell_up()['ask' if is_short else 'bid'], + exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS) + ) + + trade = Trade.session.scalars(select(Trade)).first() + trade.is_short = is_short + assert trade + assert cancel_order.call_count == 1 + assert rpc_mock.call_count == 4 + + +@pytest.mark.parametrize("is_short", [False, True]) +def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( + default_conf_usdt, ticker_usdt, fee, mocker, is_short) -> None: + default_conf_usdt['exchange']['name'] = 'binance' + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + amount_to_precision=lambda s, x, y: y, + price_to_precision=lambda s, x, y: y, + _dry_is_price_crossed=MagicMock(side_effect=[False, True]), + ) + + stoploss = MagicMock(return_value={ + 'id': 123, + 'info': { + 'foo': 'bar' + } + }) + + mocker.patch(f'{EXMS}.create_stoploss', stoploss) + + freqtrade = FreqtradeBot(default_conf_usdt) + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade, enter_long=not is_short, enter_short=is_short) + + # Create some test data + freqtrade.enter_positions() + freqtrade.manage_open_orders() + trade = Trade.session.scalars(select(Trade)).first() + trades = [trade] + assert trade.has_open_sl_orders is False + + freqtrade.exit_positions(trades) + assert trade + assert trade.has_open_sl_orders is True + assert not trade.has_open_orders + + # Assuming stoploss on exchange is hit + # trade should be sold at the price of stoploss, with exit_reason STOPLOSS_ON_EXCHANGE + stoploss_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": "buy" if is_short else "sell", + "price": 1.08801, + "amount": trade.amount, + "cost": 1.08801 * trade.amount, + "average": 1.08801, + "filled": trade.amount, + "remaining": 0.0, + "status": "closed", + "fee": None, + "trades": None + }) + mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_executed) + + freqtrade.exit_positions(trades) + assert trade.has_open_sl_orders is False + assert trade.is_open is False + assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value + assert rpc_mock.call_count == 4 + assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY + assert rpc_mock.call_args_list[1][0][0]['amount'] > 20 + assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL + assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL