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) freqtrade.strategy.order_filled = MagicMock(return_value=None) 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 assert freqtrade.strategy.order_filled.call_count == 1 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(side_effect=[True, 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": ANY, } == 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