mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-11-29 00:23:07 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user