Merge pull request #10062 from Axel-CH/feature/proceed-exit-while-open-order

Feature: Proceed exit while having open order, for backtesting and live
This commit is contained in:
Matthias
2025-01-18 08:10:33 +01:00
committed by GitHub
10 changed files with 350 additions and 36 deletions
+23
View File
@@ -988,6 +988,29 @@ def get_markets():
},
"info": {},
},
"ETC/BTC": {
"id": "ETCBTC",
"symbol": "ETC/BTC",
"base": "ETC",
"quote": "BTC",
"active": True,
"spot": True,
"swap": False,
"linear": None,
"type": "spot",
"contractSize": None,
"precision": {"base": 8, "quote": 8, "amount": 2, "price": 7},
"limits": {
"amount": {"min": 0.01, "max": 90000000.0},
"price": {"min": 1e-07, "max": 1000.0},
"cost": {"min": 0.0001, "max": 9000000.0},
"leverage": {
"min": None,
"max": None,
},
},
"info": {},
},
"ETH/USDT": {
"id": "USDT-ETH",
"symbol": "ETH/USDT",
+4 -3
View File
@@ -1257,7 +1257,7 @@ def test_enter_positions(
def test_exit_positions(mocker, default_conf_usdt, limit_order, is_short, caplog) -> None:
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
mocker.patch("freqtrade.freqtradebot.FreqtradeBot.handle_trade", MagicMock(return_value=True))
mocker.patch("freqtrade.freqtradebot.FreqtradeBot.handle_trade", MagicMock(return_value=False))
mocker.patch(f"{EXMS}.fetch_order", return_value=limit_order[entry_side(is_short)])
mocker.patch(f"{EXMS}.get_trades_for_order", return_value=[])
@@ -1329,6 +1329,7 @@ def test_exit_positions_exception(mocker, default_conf_usdt, limit_order, caplog
ft_price=trade.open_rate,
order_id=order_id,
ft_is_open=False,
filled=11,
)
)
Trade.session.add(trade)
@@ -5957,13 +5958,13 @@ def test_check_and_call_adjust_trade_position(mocker, default_conf_usdt, fee, ca
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(10, "aaaa"))
freqtrade.process_open_trade_positions()
assert log_has_re(r"Max adjustment entries for .* has been reached\.", caplog)
assert freqtrade.strategy.adjust_trade_position.call_count == 1
assert freqtrade.strategy.adjust_trade_position.call_count == 4
caplog.clear()
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(-0.0005, "partial_exit_c"))
freqtrade.process_open_trade_positions()
assert log_has_re(r"LIMIT_SELL has been fulfilled.*", caplog)
assert freqtrade.strategy.adjust_trade_position.call_count == 1
assert freqtrade.strategy.adjust_trade_position.call_count == 4
trade = Trade.get_trades(trade_filter=[Trade.id == 5]).first()
assert trade.orders[-1].ft_order_tag == "partial_exit_c"
assert trade.is_open
+141 -3
View File
@@ -436,7 +436,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
# Replace new order with diff. order at a lower price
freqtrade.strategy.adjust_entry_price = MagicMock(return_value=1.95)
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=None)
freqtrade.process()
trade = Trade.get_trades().first()
assert len(trade.orders) == 4
@@ -478,10 +478,14 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
assert pytest.approx(trade.amount) == 91.689215 * leverage
assert pytest.approx(trade.orders[-1].amount) == 91.689215 * leverage
assert freqtrade.strategy.adjust_entry_price.call_count == 0
# Process again, should not adjust entry price
freqtrade.process()
trade = Trade.get_trades().first()
assert trade.orders[-2].status == "closed"
assert len(trade.orders) == 5
assert trade.orders[-1].side == trade.exit_side
assert trade.orders[-1].status == "open"
assert trade.orders[-1].price == 2.02
# Adjust entry price cannot be called - this is an exit order
@@ -532,7 +536,7 @@ def test_dca_order_adjust_entry_replace_fails(
freqtrade.process()
assert freqtrade.strategy.adjust_trade_position.call_count == 1
assert freqtrade.strategy.adjust_trade_position.call_count == 2
trades = Trade.session.scalars(
select(Trade)
.where(Order.ft_is_open.is_(True))
@@ -677,7 +681,11 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera
assert trade.orders[-1].ft_order_side == "sell"
assert pytest.approx(trade.stake_amount) == 40
assert trade.is_open is False
assert log_has_re("Amount to exit is 0.0 due to exchange limits - not exiting.", caplog)
assert log_has_re(
"Wanted to exit of -0.01 amount, but exit amount is now 0.0 due to exchange limits "
"- not exiting.",
caplog,
)
expected_profit = starting_amount - 60 + trade.realized_profit
assert pytest.approx(freqtrade.wallets.get_free("USDT")) == expected_profit
if spot:
@@ -685,3 +693,133 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera
else:
# total won't change in futures mode, only free / used will.
assert freqtrade.wallets.get_total("USDT") == starting_amount + trade.realized_profit
@pytest.mark.parametrize("leverage", [1, 2])
@pytest.mark.parametrize("is_short", [False, True])
def test_dca_handle_similar_open_order(
default_conf_usdt, ticker_usdt, is_short, leverage, fee, mocker, caplog
) -> None:
default_conf_usdt["position_adjustment_enable"] = True
default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = "isolated"
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
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,
)
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
mocker.patch(f"{EXMS}.get_max_leverage", return_value=10)
mocker.patch(f"{EXMS}.get_funding_fees", return_value=0)
mocker.patch(f"{EXMS}.get_maintenance_ratio_and_amt", return_value=(0, 0))
patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short)
freqtrade.strategy.custom_entry_price = lambda **kwargs: ticker_usdt["ask"] * 0.96
freqtrade.strategy.leverage = MagicMock(return_value=leverage)
freqtrade.strategy.custom_exit = MagicMock(return_value=False)
freqtrade.strategy.minimal_roi = {0: 0.2}
# Create trade and initial entry order
freqtrade.enter_positions()
assert len(Trade.get_trades().all()) == 1
trade: Trade = Trade.get_trades().first()
assert len(trade.orders) == 1
assert trade.orders[-1].side == trade.entry_side
assert trade.orders[-1].status == "open"
assert trade.has_open_orders
# Process - shouldn't do anything
freqtrade.process()
# Doesn't try to exit, as we're not in a position yet
assert freqtrade.strategy.custom_exit.call_count == 0
# Adjust with new price, cancel initial entry order and place new one
freqtrade.strategy.adjust_entry_price = MagicMock(return_value=1.99)
freqtrade.strategy.ft_check_timed_out = MagicMock(return_value=False)
freqtrade.process()
trade = Trade.get_trades().first()
freqtrade.strategy.ft_check_timed_out = MagicMock(return_value=False)
assert len(trade.orders) == 2
assert len(trade.open_orders) == 1
# Adjust with new amount, should cancel and replace existing order
freqtrade.strategy.adjust_trade_position = MagicMock(
return_value=21
) # -(trade.stake_amount * 0.5)
freqtrade.process()
trade = Trade.get_trades().first()
assert len(trade.orders) == 3
assert len(trade.open_orders) == 1
# Fill entry order
assert freqtrade.strategy.custom_exit.call_count == 0
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=True)
freqtrade.process()
trade = Trade.get_trades().first()
assert trade.amount > 0
assert freqtrade.strategy.custom_exit.call_count == 1
freqtrade.strategy.custom_exit.reset_mock()
# Should Create a new exit order
freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=2)
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-2)
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
freqtrade.process()
trade = Trade.get_trades().first()
assert trade.orders[-2].status == "closed"
assert trade.orders[-1].status == "open"
assert trade.orders[-1].side == trade.exit_side
assert len(trade.orders) == 5
assert len(trade.open_orders) == 1
assert freqtrade.strategy.custom_exit.call_count == 1
freqtrade.strategy.custom_exit.reset_mock()
# Adjust with new exit amount, should cancel and replace existing exit order
freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=3)
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-3)
freqtrade.process()
trade = Trade.get_trades().first()
# Even with open order, trying to exit...
assert freqtrade.strategy.custom_exit.call_count == 1
freqtrade.strategy.custom_exit.reset_mock()
assert trade.orders[-2].status == "canceled"
assert len(trade.orders) == 6
assert len(trade.open_orders) == 1
# Adjust with new exit price, should cancel and replace existing exit order
freqtrade.strategy.custom_exit_price = MagicMock(return_value=1.95)
freqtrade.process()
# Even with open order, trying to exit...
assert freqtrade.strategy.custom_exit.call_count == 1
freqtrade.strategy.custom_exit.reset_mock()
trade = Trade.get_trades().first()
assert trade.orders[-2].status == "canceled"
assert len(trade.orders) == 7
assert len(trade.open_orders) == 1
similar_msg = r"A similar open order was found for.*"
assert not log_has_re(similar_msg, caplog)
# Adjust with same params, should keep existing order as price and amount are similar
freqtrade.strategy.custom_exit_price = MagicMock(return_value=1.95)
freqtrade.process()
trade = Trade.get_trades().first()
assert log_has_re(similar_msg, caplog)
assert len(trade.orders) == 7
assert len(trade.open_orders) == 1
+1
View File
@@ -45,6 +45,7 @@ class BTContainer(NamedTuple):
leverage: float = 1.0
timeout: int | None = None
adjust_entry_price: float | None = None
adjust_trade_position: list[float] | None = None
def _get_frame_time_from_offset(offset):
+42 -2
View File
@@ -1185,6 +1185,39 @@ tc56 = BTContainer(
)
# Test 57: Custom-entry-price for position adjustment which won't fill
# Causing the negative adjustment to cancel the unfilled order and exit partially
tc57 = BTContainer(
data=[
# D O H L C V EL XL ES Xs BT
[0, 5000, 5050, 4950, 5000, 6172, 1, 0, 0, 0],
[1, 4598, 5200, 4498, 5000, 6172, 0, 0, 0, 0],
[2, 4900, 5250, 4900, 5100, 6172, 0, 0, 0, 0], # Enhance position, but won't fill
[3, 5100, 5100, 4650, 4750, 6172, 0, 0, 0, 0],
[4, 4750, 4950, 4650, 4750, 6172, 0, 0, 0, 0],
[5, 4750, 4950, 4650, 4750, 6172, 0, 1, 0, 0],
[6, 4750, 4950, 4650, 4750, 6172, 0, 0, 0, 0],
],
stop_loss=-0.2,
roi={"0": 0.50},
profit_perc=0.033,
use_exit_signal=True,
timeout=1000,
custom_entry_price=4600,
adjust_trade_position=[
None,
0.001,
None,
-0.0001, # Cancels the above unfilled order and exits partially
None,
None,
],
trades=[
BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=6, is_short=False),
],
)
TESTS = [
tc0,
tc1,
@@ -1243,6 +1276,7 @@ TESTS = [
tc54,
tc55,
tc56,
tc57,
]
@@ -1289,7 +1323,13 @@ def test_backtest_results(default_conf, mocker, caplog, data: BTContainer) -> No
backtesting.strategy.custom_entry_price = MagicMock(return_value=data.custom_entry_price)
if data.custom_exit_price:
backtesting.strategy.custom_exit_price = MagicMock(return_value=data.custom_exit_price)
backtesting.strategy.adjust_entry_price = MagicMock(return_value=data.adjust_entry_price)
if data.adjust_trade_position:
backtesting.strategy.position_adjustment_enable = True
backtesting.strategy.adjust_trade_position = MagicMock(
side_effect=data.adjust_trade_position
)
if data.adjust_entry_price:
backtesting.strategy.adjust_entry_price = MagicMock(return_value=data.adjust_entry_price)
backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss
backtesting.strategy.leverage = lambda **kwargs: data.leverage
@@ -1317,6 +1357,6 @@ def test_backtest_results(default_conf, mocker, caplog, data: BTContainer) -> No
assert res.close_date == _get_frame_time_from_offset(trade.close_tick)
assert res.is_short == trade.is_short
assert len(LocalTrade.bt_trades) == len(data.trades)
assert len(LocalTrade.bt_trades_open) == 0
assert len(LocalTrade.bt_trades_open) == 0, "Left open trade"
backtesting.cleanup()
del backtesting
+4 -2
View File
@@ -60,6 +60,7 @@ def test_trade_custom_data(fee, use_db):
def test_trade_custom_data_strategy_compat(mocker, default_conf_usdt, fee):
mocker.patch(f"{EXMS}.get_rate", return_value=0.50)
mocker.patch("freqtrade.freqtradebot.FreqtradeBot.get_real_amount", return_value=None)
mocker.patch("freqtrade.freqtradebot.FreqtradeBot.handle_cancel_exit", return_value=True)
default_conf_usdt["minimal_roi"] = {"0": 100}
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
@@ -85,8 +86,9 @@ def test_trade_custom_data_strategy_compat(mocker, default_conf_usdt, fee):
trade_after = Trade.get_trades_proxy(pair="ADA/USDT")[0]
assert trade_after.get_custom_data("test_str") == "test_value"
assert trade_after.get_custom_data("test_int") == 1
# 2 open pairs eligible for exit
assert ff_spy.call_count == 2
# 2 trades filled entry, with open exit order
# 1 trade with filled entry order
assert ff_spy.call_count == 3
assert trade_after.exit_reason == "test_value_1"