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

View File

@@ -758,7 +758,7 @@ For performance reasons, it's disabled by default and freqtrade will show a warn
Additional orders also result in additional fees and those orders don't count towards `max_open_trades`.
This callback is **not** called when there is an open order (either buy or sell) waiting for execution.
This callback is also called when there is an open order (either buy or sell) waiting for execution - and will cancel the existing open order to place a new order if the amount, price or direction is different.
`adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible.

View File

@@ -730,7 +730,7 @@ class FreqtradeBot(LoggingMixin):
for trade in Trade.get_open_trades():
# If there is any open orders, wait for them to finish.
# TODO Remove to allow mul open orders
if not trade.has_open_orders:
if trade.has_open_position or trade.has_open_orders:
# Do a wallets update (will be ratelimited to once per hour)
self.wallets.update(False)
try:
@@ -808,7 +808,10 @@ class FreqtradeBot(LoggingMixin):
)
if amount == 0.0:
logger.info("Amount to exit is 0.0 due to exchange limits - not exiting.")
logger.info(
f"Wanted to exit of {stake_amount} amount, "
"but exit amount is now 0.0 due to exchange limits - not exiting."
)
return
remaining = (trade.amount - amount) * current_exit_rate
@@ -923,6 +926,10 @@ class FreqtradeBot(LoggingMixin):
):
logger.info(f"User denied entry for {pair}.")
return False
if trade and self.handle_similar_open_order(trade, enter_limit_requested, amount, side):
return False
order = self.exchange.create_order(
pair=pair,
ordertype=order_type,
@@ -1303,8 +1310,8 @@ class FreqtradeBot(LoggingMixin):
logger.warning(
f"Unable to handle stoploss on exchange for {trade.pair}: {exception}"
)
# Check if we can exit our current pair
if not trade.has_open_orders and trade.is_open and self.handle_trade(trade):
# Check if we can exit our current position for this trade
if trade.has_open_position and trade.is_open and self.handle_trade(trade):
trades_closed += 1
except DependencyException as exception:
@@ -1448,9 +1455,7 @@ class FreqtradeBot(LoggingMixin):
self.handle_protections(trade.pair, trade.trade_direction)
return True
if trade.has_open_orders or not trade.is_open:
# Trade has an open order, Stoploss-handling can't happen in this case
# as the Amount on the exchange is tied up in another trade.
if not trade.has_open_position or not trade.is_open:
# The trade can be closed already (sell-order fill confirmation came in this iteration)
return False
@@ -1718,6 +1723,31 @@ class FreqtradeBot(LoggingMixin):
logger.warning(f"Unable to replace order for {trade.pair}: {exception}")
self.replace_order_failed(trade, f"Could not replace order for {trade}.")
def cancel_open_orders_of_trade(
self, trade: Trade, sides: list[str], reason: str, replacing: bool = False
) -> None:
"""
Cancel trade orders of specified sides that are currently open
:param trade: Trade object of the trade we're analyzing
:param reason: The reason for that cancellation
:param sides: The sides where cancellation should take place
:return: None
"""
for open_order in trade.open_orders:
try:
order = self.exchange.fetch_order(open_order.order_id, trade.pair)
except ExchangeError:
logger.info("Can't query order for %s due to %s", trade, traceback.format_exc())
continue
if order["side"] in sides:
if order["side"] == trade.entry_side:
self.handle_cancel_enter(trade, order, open_order, reason, replacing)
elif order["side"] == trade.exit_side:
self.handle_cancel_exit(trade, order, open_order, reason)
def cancel_all_open_orders(self) -> None:
"""
Cancel all orders that are currently open
@@ -1725,24 +1755,44 @@ class FreqtradeBot(LoggingMixin):
"""
for trade in Trade.get_open_trades():
for open_order in trade.open_orders:
try:
order = self.exchange.fetch_order(open_order.order_id, trade.pair)
except ExchangeError:
logger.info("Can't query order for %s due to %s", trade, traceback.format_exc())
continue
self.cancel_open_orders_of_trade(
trade, [trade.entry_side, trade.exit_side], constants.CANCEL_REASON["ALL_CANCELLED"]
)
if order["side"] == trade.entry_side:
self.handle_cancel_enter(
trade, order, open_order, constants.CANCEL_REASON["ALL_CANCELLED"]
)
elif order["side"] == trade.exit_side:
self.handle_cancel_exit(
trade, order, open_order, constants.CANCEL_REASON["ALL_CANCELLED"]
)
Trade.commit()
def handle_similar_open_order(
self, trade: Trade, price: float, amount: float, side: str
) -> bool:
"""
Keep existing open order if same amount and side otherwise cancel
:param trade: Trade object of the trade we're analyzing
:param price: Limit price of the potential new order
:param amount: Quantity of assets of the potential new order
:param side: Side of the potential new order
:return: True if an existing similar order was found
"""
if trade.has_open_orders:
oo = trade.select_order(side, True)
if oo is not None:
if (price == oo.price) and (side == oo.side) and (amount == oo.amount):
logger.info(
f"A similar open order was found for {trade.pair}. "
f"Keeping existing {trade.exit_side} order. {price=}, {amount=}"
)
return True
# cancel open orders of this trade if order is different
self.cancel_open_orders_of_trade(
trade,
[trade.entry_side, trade.exit_side],
constants.CANCEL_REASON["REPLACE"],
True,
)
Trade.commit()
return False
return False
def handle_cancel_enter(
self,
trade: Trade,
@@ -1924,7 +1974,11 @@ class FreqtradeBot(LoggingMixin):
return amount
trade_base_currency = self.exchange.get_pair_base_currency(pair)
wallet_amount = self.wallets.get_free(trade_base_currency)
# Free + Used - open orders will eventually still be canceled.
wallet_amount = self.wallets.get_free(trade_base_currency) + self.wallets.get_used(
trade_base_currency
)
logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}")
if wallet_amount >= amount:
return amount
@@ -2017,6 +2071,10 @@ class FreqtradeBot(LoggingMixin):
logger.info(f"User denied exit for {trade.pair}.")
return False
if trade.has_open_orders:
if self.handle_similar_open_order(trade, limit, amount, trade.exit_side):
return False
try:
# Execute sell and update trade record
order = self.exchange.create_order(

View File

@@ -832,6 +832,10 @@ class Backtesting:
amount = amount_to_contract_precision(
amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size
)
if self.handle_similar_order(trade, close_rate, amount, trade.exit_side, exit_candle_time):
return None
order = Order(
id=self.order_id_counter,
ft_trade_id=trade.id,
@@ -1117,6 +1121,10 @@ class Backtesting:
orders=[],
)
LocalTrade.add_bt_trade(trade)
elif self.handle_similar_order(
trade, propose_rate, amount, trade.entry_side, current_time
):
return None
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
@@ -1158,9 +1166,13 @@ class Backtesting:
"""
for pair in open_trades.keys():
for trade in list(open_trades[pair]):
if trade.has_open_orders and trade.nr_of_successful_entries == 0:
if (
trade.has_open_orders and trade.nr_of_successful_entries == 0
) or not trade.has_open_position:
# Ignore trade if entry-order did not fill yet
LocalTrade.remove_bt_trade(trade)
continue
exit_row = data[pair][-1]
self._exit_trade(
trade, exit_row, exit_row[OPEN_IDX], trade.amount, ExitType.FORCE_EXIT.value
@@ -1215,6 +1227,37 @@ class Backtesting:
# default maintain trade
return False
def cancel_open_orders(self, trade: LocalTrade, current_time: datetime):
"""
Cancel all open orders for the given trade.
"""
for order in [o for o in trade.orders if o.ft_is_open]:
if order.side == trade.entry_side:
self.canceled_entry_orders += 1
# elif order.side == trade.exit_side:
# self.canceled_exit_orders += 1
# canceled orders are removed from the trade
del trade.orders[trade.orders.index(order)]
def handle_similar_order(
self, trade: LocalTrade, price: float, amount: float, side: str, current_time: datetime
) -> bool:
"""
Handle similar order for the given trade.
"""
if trade.has_open_orders:
oo = trade.select_order(side, True)
if oo:
if (price == oo.price) and (side == oo.side) and (amount == oo.amount):
# logger.info(
# f"A similar open order was found for {trade.pair}. "
# f"Keeping existing {trade.exit_side} order. {price=}, {amount=}"
# )
return True
self.cancel_open_orders(trade, current_time)
return False
def check_order_cancel(
self, trade: LocalTrade, order: Order, current_time: datetime
) -> bool | None:
@@ -1400,7 +1443,7 @@ class Backtesting:
self.wallets.update()
# 4. Create exit orders (if any)
if not trade.has_open_orders:
if trade.has_open_position:
self._check_trade_exit(trade, row, current_time) # Place exit order if necessary
# 5. Process exit orders.

View File

@@ -191,6 +191,7 @@ class Order(ModelBase):
return (
f"Order(id={self.id}, trade={self.ft_trade_id}, order_id={self.order_id}, "
f"side={self.side}, filled={self.safe_filled}, price={self.safe_price}, "
f"amount={self.amount}, "
f"status={self.status}, date={self.order_date_utc:{DATETIME_PRINT_FORMAT}})"
)
@@ -599,6 +600,13 @@ class LocalTrade:
]
return len(open_orders_wo_sl) > 0
@property
def has_open_position(self) -> bool:
"""
True if there is an open position for this trade
"""
return self.amount > 0
@property
def open_sl_orders(self) -> list[Order]:
"""

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",

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

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

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):

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

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"