Skip to content

Commit aceb3ac

Browse files
authored
Merge pull request freqtrade#10062 from Axel-CH/feature/proceed-exit-while-open-order
Feature: Proceed exit while having open order, for backtesting and live
2 parents e80ddca + c90cfa8 commit aceb3ac

10 files changed

+349
-35
lines changed

docs/strategy-callbacks.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ For performance reasons, it's disabled by default and freqtrade will show a warn
758758

759759
Additional orders also result in additional fees and those orders don't count towards `max_open_trades`.
760760

761-
This callback is **not** called when there is an open order (either buy or sell) waiting for execution.
761+
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.
762762

763763
`adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible.
764764

freqtrade/freqtradebot.py

+80-22
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@ def process_open_trade_positions(self):
730730
for trade in Trade.get_open_trades():
731731
# If there is any open orders, wait for them to finish.
732732
# TODO Remove to allow mul open orders
733-
if not trade.has_open_orders:
733+
if trade.has_open_position or trade.has_open_orders:
734734
# Do a wallets update (will be ratelimited to once per hour)
735735
self.wallets.update(False)
736736
try:
@@ -808,7 +808,10 @@ def check_and_call_adjust_trade_position(self, trade: Trade):
808808
)
809809

810810
if amount == 0.0:
811-
logger.info("Amount to exit is 0.0 due to exchange limits - not exiting.")
811+
logger.info(
812+
f"Wanted to exit of {stake_amount} amount, "
813+
"but exit amount is now 0.0 due to exchange limits - not exiting."
814+
)
812815
return
813816

814817
remaining = (trade.amount - amount) * current_exit_rate
@@ -923,6 +926,10 @@ def execute_entry(
923926
):
924927
logger.info(f"User denied entry for {pair}.")
925928
return False
929+
930+
if trade and self.handle_similar_open_order(trade, enter_limit_requested, amount, side):
931+
return False
932+
926933
order = self.exchange.create_order(
927934
pair=pair,
928935
ordertype=order_type,
@@ -1303,8 +1310,8 @@ def exit_positions(self, trades: list[Trade]) -> int:
13031310
logger.warning(
13041311
f"Unable to handle stoploss on exchange for {trade.pair}: {exception}"
13051312
)
1306-
# Check if we can exit our current pair
1307-
if not trade.has_open_orders and trade.is_open and self.handle_trade(trade):
1313+
# Check if we can exit our current position for this trade
1314+
if trade.has_open_position and trade.is_open and self.handle_trade(trade):
13081315
trades_closed += 1
13091316

13101317
except DependencyException as exception:
@@ -1448,9 +1455,7 @@ def handle_stoploss_on_exchange(self, trade: Trade) -> bool:
14481455
self.handle_protections(trade.pair, trade.trade_direction)
14491456
return True
14501457

1451-
if trade.has_open_orders or not trade.is_open:
1452-
# Trade has an open order, Stoploss-handling can't happen in this case
1453-
# as the Amount on the exchange is tied up in another trade.
1458+
if not trade.has_open_position or not trade.is_open:
14541459
# The trade can be closed already (sell-order fill confirmation came in this iteration)
14551460
return False
14561461

@@ -1718,30 +1723,75 @@ def replace_order(self, order: CcxtOrder, order_obj: Order | None, trade: Trade)
17181723
logger.warning(f"Unable to replace order for {trade.pair}: {exception}")
17191724
self.replace_order_failed(trade, f"Could not replace order for {trade}.")
17201725

1726+
def cancel_open_orders_of_trade(
1727+
self, trade: Trade, sides: list[str], reason: str, replacing: bool = False
1728+
) -> None:
1729+
"""
1730+
Cancel trade orders of specified sides that are currently open
1731+
:param trade: Trade object of the trade we're analyzing
1732+
:param reason: The reason for that cancellation
1733+
:param sides: The sides where cancellation should take place
1734+
:return: None
1735+
"""
1736+
1737+
for open_order in trade.open_orders:
1738+
try:
1739+
order = self.exchange.fetch_order(open_order.order_id, trade.pair)
1740+
except ExchangeError:
1741+
logger.info("Can't query order for %s due to %s", trade, traceback.format_exc())
1742+
continue
1743+
1744+
if order["side"] in sides:
1745+
if order["side"] == trade.entry_side:
1746+
self.handle_cancel_enter(trade, order, open_order, reason, replacing)
1747+
1748+
elif order["side"] == trade.exit_side:
1749+
self.handle_cancel_exit(trade, order, open_order, reason)
1750+
17211751
def cancel_all_open_orders(self) -> None:
17221752
"""
17231753
Cancel all orders that are currently open
17241754
:return: None
17251755
"""
17261756

17271757
for trade in Trade.get_open_trades():
1728-
for open_order in trade.open_orders:
1729-
try:
1730-
order = self.exchange.fetch_order(open_order.order_id, trade.pair)
1731-
except ExchangeError:
1732-
logger.info("Can't query order for %s due to %s", trade, traceback.format_exc())
1733-
continue
1758+
self.cancel_open_orders_of_trade(
1759+
trade, [trade.entry_side, trade.exit_side], constants.CANCEL_REASON["ALL_CANCELLED"]
1760+
)
17341761

1735-
if order["side"] == trade.entry_side:
1736-
self.handle_cancel_enter(
1737-
trade, order, open_order, constants.CANCEL_REASON["ALL_CANCELLED"]
1738-
)
1762+
Trade.commit()
17391763

1740-
elif order["side"] == trade.exit_side:
1741-
self.handle_cancel_exit(
1742-
trade, order, open_order, constants.CANCEL_REASON["ALL_CANCELLED"]
1764+
def handle_similar_open_order(
1765+
self, trade: Trade, price: float, amount: float, side: str
1766+
) -> bool:
1767+
"""
1768+
Keep existing open order if same amount and side otherwise cancel
1769+
:param trade: Trade object of the trade we're analyzing
1770+
:param price: Limit price of the potential new order
1771+
:param amount: Quantity of assets of the potential new order
1772+
:param side: Side of the potential new order
1773+
:return: True if an existing similar order was found
1774+
"""
1775+
if trade.has_open_orders:
1776+
oo = trade.select_order(side, True)
1777+
if oo is not None:
1778+
if (price == oo.price) and (side == oo.side) and (amount == oo.amount):
1779+
logger.info(
1780+
f"A similar open order was found for {trade.pair}. "
1781+
f"Keeping existing {trade.exit_side} order. {price=}, {amount=}"
17431782
)
1744-
Trade.commit()
1783+
return True
1784+
# cancel open orders of this trade if order is different
1785+
self.cancel_open_orders_of_trade(
1786+
trade,
1787+
[trade.entry_side, trade.exit_side],
1788+
constants.CANCEL_REASON["REPLACE"],
1789+
True,
1790+
)
1791+
Trade.commit()
1792+
return False
1793+
1794+
return False
17451795

17461796
def handle_cancel_enter(
17471797
self,
@@ -1924,7 +1974,11 @@ def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float:
19241974
return amount
19251975

19261976
trade_base_currency = self.exchange.get_pair_base_currency(pair)
1927-
wallet_amount = self.wallets.get_free(trade_base_currency)
1977+
# Free + Used - open orders will eventually still be canceled.
1978+
wallet_amount = self.wallets.get_free(trade_base_currency) + self.wallets.get_used(
1979+
trade_base_currency
1980+
)
1981+
19281982
logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}")
19291983
if wallet_amount >= amount:
19301984
return amount
@@ -2017,6 +2071,10 @@ def execute_trade_exit(
20172071
logger.info(f"User denied exit for {trade.pair}.")
20182072
return False
20192073

2074+
if trade.has_open_orders:
2075+
if self.handle_similar_open_order(trade, limit, amount, trade.exit_side):
2076+
return False
2077+
20202078
try:
20212079
# Execute sell and update trade record
20222080
order = self.exchange.create_order(

freqtrade/optimize/backtesting.py

+45-2
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,10 @@ def _exit_trade(
832832
amount = amount_to_contract_precision(
833833
amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size
834834
)
835+
836+
if self.handle_similar_order(trade, close_rate, amount, trade.exit_side, exit_candle_time):
837+
return None
838+
835839
order = Order(
836840
id=self.order_id_counter,
837841
ft_trade_id=trade.id,
@@ -1117,6 +1121,10 @@ def _enter_trade(
11171121
orders=[],
11181122
)
11191123
LocalTrade.add_bt_trade(trade)
1124+
elif self.handle_similar_order(
1125+
trade, propose_rate, amount, trade.entry_side, current_time
1126+
):
1127+
return None
11201128

11211129
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
11221130

@@ -1158,9 +1166,13 @@ def handle_left_open(
11581166
"""
11591167
for pair in open_trades.keys():
11601168
for trade in list(open_trades[pair]):
1161-
if trade.has_open_orders and trade.nr_of_successful_entries == 0:
1169+
if (
1170+
trade.has_open_orders and trade.nr_of_successful_entries == 0
1171+
) or not trade.has_open_position:
11621172
# Ignore trade if entry-order did not fill yet
1173+
LocalTrade.remove_bt_trade(trade)
11631174
continue
1175+
11641176
exit_row = data[pair][-1]
11651177
self._exit_trade(
11661178
trade, exit_row, exit_row[OPEN_IDX], trade.amount, ExitType.FORCE_EXIT.value
@@ -1215,6 +1227,37 @@ def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tup
12151227
# default maintain trade
12161228
return False
12171229

1230+
def cancel_open_orders(self, trade: LocalTrade, current_time: datetime):
1231+
"""
1232+
Cancel all open orders for the given trade.
1233+
"""
1234+
for order in [o for o in trade.orders if o.ft_is_open]:
1235+
if order.side == trade.entry_side:
1236+
self.canceled_entry_orders += 1
1237+
# elif order.side == trade.exit_side:
1238+
# self.canceled_exit_orders += 1
1239+
# canceled orders are removed from the trade
1240+
del trade.orders[trade.orders.index(order)]
1241+
1242+
def handle_similar_order(
1243+
self, trade: LocalTrade, price: float, amount: float, side: str, current_time: datetime
1244+
) -> bool:
1245+
"""
1246+
Handle similar order for the given trade.
1247+
"""
1248+
if trade.has_open_orders:
1249+
oo = trade.select_order(side, True)
1250+
if oo:
1251+
if (price == oo.price) and (side == oo.side) and (amount == oo.amount):
1252+
# logger.info(
1253+
# f"A similar open order was found for {trade.pair}. "
1254+
# f"Keeping existing {trade.exit_side} order. {price=}, {amount=}"
1255+
# )
1256+
return True
1257+
self.cancel_open_orders(trade, current_time)
1258+
1259+
return False
1260+
12181261
def check_order_cancel(
12191262
self, trade: LocalTrade, order: Order, current_time: datetime
12201263
) -> bool | None:
@@ -1400,7 +1443,7 @@ def backtest_loop_inner(
14001443
self.wallets.update()
14011444

14021445
# 4. Create exit orders (if any)
1403-
if not trade.has_open_orders:
1446+
if trade.has_open_position:
14041447
self._check_trade_exit(trade, row, current_time) # Place exit order if necessary
14051448

14061449
# 5. Process exit orders.

freqtrade/persistence/trade_model.py

+8
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ def __repr__(self):
191191
return (
192192
f"Order(id={self.id}, trade={self.ft_trade_id}, order_id={self.order_id}, "
193193
f"side={self.side}, filled={self.safe_filled}, price={self.safe_price}, "
194+
f"amount={self.amount}, "
194195
f"status={self.status}, date={self.order_date_utc:{DATETIME_PRINT_FORMAT}})"
195196
)
196197

@@ -599,6 +600,13 @@ def has_open_orders(self) -> bool:
599600
]
600601
return len(open_orders_wo_sl) > 0
601602

603+
@property
604+
def has_open_position(self) -> bool:
605+
"""
606+
True if there is an open position for this trade
607+
"""
608+
return self.amount > 0
609+
602610
@property
603611
def open_sl_orders(self) -> list[Order]:
604612
"""

tests/conftest.py

+23
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,29 @@ def get_markets():
988988
},
989989
"info": {},
990990
},
991+
"ETC/BTC": {
992+
"id": "ETCBTC",
993+
"symbol": "ETC/BTC",
994+
"base": "ETC",
995+
"quote": "BTC",
996+
"active": True,
997+
"spot": True,
998+
"swap": False,
999+
"linear": None,
1000+
"type": "spot",
1001+
"contractSize": None,
1002+
"precision": {"base": 8, "quote": 8, "amount": 2, "price": 7},
1003+
"limits": {
1004+
"amount": {"min": 0.01, "max": 90000000.0},
1005+
"price": {"min": 1e-07, "max": 1000.0},
1006+
"cost": {"min": 0.0001, "max": 9000000.0},
1007+
"leverage": {
1008+
"min": None,
1009+
"max": None,
1010+
},
1011+
},
1012+
"info": {},
1013+
},
9911014
"ETH/USDT": {
9921015
"id": "USDT-ETH",
9931016
"symbol": "ETH/USDT",

tests/freqtradebot/test_freqtradebot.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1257,7 +1257,7 @@ def test_enter_positions(
12571257
def test_exit_positions(mocker, default_conf_usdt, limit_order, is_short, caplog) -> None:
12581258
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
12591259

1260-
mocker.patch("freqtrade.freqtradebot.FreqtradeBot.handle_trade", MagicMock(return_value=True))
1260+
mocker.patch("freqtrade.freqtradebot.FreqtradeBot.handle_trade", MagicMock(return_value=False))
12611261
mocker.patch(f"{EXMS}.fetch_order", return_value=limit_order[entry_side(is_short)])
12621262
mocker.patch(f"{EXMS}.get_trades_for_order", return_value=[])
12631263

@@ -1329,6 +1329,7 @@ def test_exit_positions_exception(mocker, default_conf_usdt, limit_order, caplog
13291329
ft_price=trade.open_rate,
13301330
order_id=order_id,
13311331
ft_is_open=False,
1332+
filled=11,
13321333
)
13331334
)
13341335
Trade.session.add(trade)
@@ -5957,13 +5958,13 @@ def test_check_and_call_adjust_trade_position(mocker, default_conf_usdt, fee, ca
59575958
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(10, "aaaa"))
59585959
freqtrade.process_open_trade_positions()
59595960
assert log_has_re(r"Max adjustment entries for .* has been reached\.", caplog)
5960-
assert freqtrade.strategy.adjust_trade_position.call_count == 1
5961+
assert freqtrade.strategy.adjust_trade_position.call_count == 4
59615962

59625963
caplog.clear()
59635964
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(-0.0005, "partial_exit_c"))
59645965
freqtrade.process_open_trade_positions()
59655966
assert log_has_re(r"LIMIT_SELL has been fulfilled.*", caplog)
5966-
assert freqtrade.strategy.adjust_trade_position.call_count == 1
5967+
assert freqtrade.strategy.adjust_trade_position.call_count == 4
59675968
trade = Trade.get_trades(trade_filter=[Trade.id == 5]).first()
59685969
assert trade.orders[-1].ft_order_tag == "partial_exit_c"
59695970
assert trade.is_open

0 commit comments

Comments
 (0)