Skip to content

Commit

Permalink
Merge pull request freqtrade#10062 from Axel-CH/feature/proceed-exit-…
Browse files Browse the repository at this point in the history
…while-open-order

Feature: Proceed exit while having open order, for backtesting and live
  • Loading branch information
xmatthias authored Jan 18, 2025
2 parents e80ddca + c90cfa8 commit aceb3ac
Show file tree
Hide file tree
Showing 10 changed files with 349 additions and 35 deletions.
2 changes: 1 addition & 1 deletion docs/strategy-callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
102 changes: 80 additions & 22 deletions freqtrade/freqtradebot.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@ def process_open_trade_positions(self):
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:
Expand Down Expand Up @@ -808,7 +808,10 @@ def check_and_call_adjust_trade_position(self, trade: Trade):
)

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
Expand Down Expand Up @@ -923,6 +926,10 @@ def execute_entry(
):
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,
Expand Down Expand Up @@ -1303,8 +1310,8 @@ def exit_positions(self, trades: list[Trade]) -> int:
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:
Expand Down Expand Up @@ -1448,9 +1455,7 @@ def handle_stoploss_on_exchange(self, trade: Trade) -> bool:
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

Expand Down Expand Up @@ -1718,30 +1723,75 @@ def replace_order(self, order: CcxtOrder, order_obj: Order | None, trade: Trade)
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
:return: None
"""

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"]
)
Trade.commit()

elif order["side"] == trade.exit_side:
self.handle_cancel_exit(
trade, order, open_order, constants.CANCEL_REASON["ALL_CANCELLED"]
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=}"
)
Trade.commit()
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,
Expand Down Expand Up @@ -1924,7 +1974,11 @@ def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float:
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
Expand Down Expand Up @@ -2017,6 +2071,10 @@ def execute_trade_exit(
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(
Expand Down
47 changes: 45 additions & 2 deletions freqtrade/optimize/backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,10 @@ def _exit_trade(
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,
Expand Down Expand Up @@ -1117,6 +1121,10 @@ def _enter_trade(
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)

Expand Down Expand Up @@ -1158,9 +1166,13 @@ def handle_left_open(
"""
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
Expand Down Expand Up @@ -1215,6 +1227,37 @@ def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: tup
# 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:
Expand Down Expand Up @@ -1400,7 +1443,7 @@ def backtest_loop_inner(
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.
Expand Down
8 changes: 8 additions & 0 deletions freqtrade/persistence/trade_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ def __repr__(self):
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}})"
)

Expand Down Expand Up @@ -599,6 +600,13 @@ def has_open_orders(self) -> bool:
]
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]:
"""
Expand Down
23 changes: 23 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions tests/freqtradebot/test_freqtradebot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[])

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Loading

0 comments on commit aceb3ac

Please sign in to comment.