Merge pull request #11412 from freqtrade/feat/adjust_order_price

Add adjust_order_price callback
This commit is contained in:
Matthias
2025-02-24 07:02:27 +01:00
committed by GitHub
12 changed files with 356 additions and 77 deletions

View File

@@ -54,11 +54,13 @@ By default, the bot loop runs every few seconds (`internals.process_throttle_sec
* Check timeouts for open orders.
* Calls `check_entry_timeout()` strategy callback for open entry orders.
* Calls `check_exit_timeout()` strategy callback for open exit orders.
* Calls `adjust_entry_price()` strategy callback for open entry orders.
* Calls `adjust_order_price()` strategy callback for open orders.
* Calls `adjust_entry_price()` strategy callback for open entry orders. *only called when `adjust_order_price()` is not implemented*
* Calls `adjust_exit_price()` strategy callback for open exit orders. *only called when `adjust_order_price()` is not implemented*
* Verifies existing positions and eventually places exit orders.
* Considers stoploss, ROI and exit-signal, `custom_exit()` and `custom_stoploss()`.
* Determine exit-price based on `exit_pricing` configuration setting or by using the `custom_exit_price()` callback.
* Before a exit order is placed, `confirm_trade_exit()` strategy callback is called.
* Before an exit order is placed, `confirm_trade_exit()` strategy callback is called.
* Check position adjustments for open trades if enabled by calling `adjust_trade_position()` and place additional order if required.
* Check if trade-slots are still available (if `max_open_trades` is reached).
* Verifies entry signal trying to enter new positions.
@@ -80,7 +82,9 @@ This loop will be repeated again and again until the bot is stopped.
* Loops per candle simulating entry and exit points.
* Calls `bot_loop_start()` strategy callback.
* Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_entry_timeout()` / `check_exit_timeout()` strategy callbacks.
* Calls `adjust_entry_price()` strategy callback for open entry orders.
* Calls `adjust_order_price()` strategy callback for open orders.
* Calls `adjust_entry_price()` strategy callback for open entry orders. *only called when `adjust_order_price()` is not implemented!*
* Calls `adjust_exit_price()` strategy callback for open exit orders. *only called when `adjust_order_price()` is not implemented!*
* Check for trade entry signals (`enter_long` / `enter_short` columns).
* Confirm trade entry / exits (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy).
* Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle).

View File

@@ -934,28 +934,25 @@ class DigDeeperStrategy(IStrategy):
The total profit for this trade was 950$ on a 3350$ investment (`100@8$ + 100@9$ + 150@11$`). As such - the final relative profit is 28.35% (`950 / 3350`).
## Adjust Entry Price
## Adjust order Price
The `adjust_entry_price()` callback may be used by strategy developer to refresh/replace limit orders upon arrival of new candles.
The `adjust_order_price()` callback may be used by strategy developer to refresh/replace limit orders upon arrival of new candles.
This callback is called once every iteration unless the order has been (re)placed within the current candle - limiting the maximum (re)placement of each order to once per candle.
This also means that the first call will be at the start of the next candle after the initial order was placed.
Be aware that `custom_entry_price()` is still the one dictating initial entry limit order price target at the time of entry trigger.
Be aware that `custom_entry_price()`/`custom_exit_price()` is still the one dictating initial limit order price target at the time of the signal.
Orders can be cancelled out of this callback by returning `None`.
Returning `current_order_rate` will keep the order on the exchange "as is".
Returning any other price will cancel the existing order, and replace it with a new order.
The trade open-date (`trade.open_date_utc`) will remain at the time of the very first order placed.
Please make sure to be aware of this - and eventually adjust your logic in other callbacks to account for this, and use the date of the first filled order instead.
If the cancellation of the original order fails, then the order will not be replaced - though the order will most likely have been canceled on exchange. Having this happen on initial entries will result in the deletion of the order, while on position adjustment orders, it'll result in the trade size remaining as is.
If the order has been partially filled, the order will not be replaced. You can however use [`adjust_trade_position()`](#adjust-trade-position) to adjust the trade size to the full, expected position size, should this be necessary / desired.
If the order has been partially filled, the order will not be replaced. You can however use [`adjust_trade_position()`](#adjust-trade-position) to adjust the trade size to the expected position size, should this be necessary / desired.
!!! Warning "Regular timeout"
Entry `unfilledtimeout` mechanism (as well as `check_entry_timeout()`) takes precedence over this.
Entry Orders that are cancelled via the above methods will not have this callback called. Be sure to update timeout values to match your expectations.
Entry `unfilledtimeout` mechanism (as well as `check_entry_timeout()`/`check_exit_timeout()`) takes precedence over this callback.
Orders that are cancelled via the above methods will not have this callback called. Be sure to update timeout values to match your expectations.
```python
# Default imports
@@ -964,14 +961,26 @@ class AwesomeStrategy(IStrategy):
# ... populate_* methods
def adjust_entry_price(self, trade: Trade, order: Order | None, pair: str,
current_time: datetime, proposed_rate: float, current_order_rate: float,
entry_tag: str | None, side: str, **kwargs) -> float:
def adjust_order_price(
self,
trade: Trade,
order: Order | None,
pair: str,
current_time: datetime,
proposed_rate: float,
current_order_rate: float,
entry_tag: str | None,
side: str,
is_entry: bool,
**kwargs,
) -> float:
"""
Entry price re-adjustment logic, returning the user desired limit price.
Exit and entry order price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
When not implemented by a strategy, returns current_order_rate as default.
If current_order_rate is returned then the existing order is maintained.
If None is returned then order gets canceled but not replaced by a new one.
@@ -983,14 +992,16 @@ class AwesomeStrategy(IStrategy):
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: "long" or "short" - indicating the direction of the proposed trade
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param is_entry: True if the order is an entry order, False if it's an exit order.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
"""
# Limit orders to use and follow SMA200 as price target for the first 10 minutes since entry trigger for BTC/USDT pair.
# Limit entry orders to use and follow SMA200 as price target for the first 10 minutes since entry trigger for BTC/USDT pair.
if (
pair == "BTC/USDT"
is_entry
and pair == "BTC/USDT"
and entry_tag == "long_sma200"
and side == "long"
and (current_time - timedelta(minutes=10)) <= trade.open_date_utc
@@ -1007,6 +1018,26 @@ class AwesomeStrategy(IStrategy):
return current_order_rate
```
!!! danger "Incompatibility with `adjust_*_price()`"
If you have both `adjust_order_price()` and `adjust_entry_price()`/`adjust_exit_price()` implemented, only `adjust_order_price()` will be used.
If you need to adjust entry/exit prices, you can either implement the logic in `adjust_order_price()`, or use the split `adjust_entry_price()` / `adjust_exit_price()` callbacks, but not both.
Mixing these is not supported and will raise an error during bot startup.
### Adjust Entry Price
The `adjust_entry_price()` callback may be used by strategy developer to refresh/replace entry limit orders upon arrival.
It's a sub-set of `adjust_order_price()` and is called only for entry orders.
All remaining behavior is identical to `adjust_order_price()`.
The trade open-date (`trade.open_date_utc`) will remain at the time of the very first order placed.
Please make sure to be aware of this - and eventually adjust your logic in other callbacks to account for this, and use the date of the first filled order instead.
### Adjust Exit Price
The `adjust_exit_price()` callback may be used by strategy developer to refresh/replace exit limit orders upon arrival.
It's a sub-set of `adjust_order_price()` and is called only for exit orders.
All remaining behavior is identical to `adjust_order_price()`.
## Leverage Callback
When trading in markets that allow leverage, this method must return the desired Leverage (Defaults to 1 -> No leverage).

View File

@@ -1607,27 +1607,29 @@ class FreqtradeBot(LoggingMixin):
self.replace_order(order, open_order, trade)
def handle_cancel_order(
self, order: CcxtOrder, order_obj: Order, trade: Trade, reason: str
) -> None:
self, order: CcxtOrder, order_obj: Order, trade: Trade, reason: str, replacing: bool = False
) -> bool:
"""
Check if current analyzed order timed out and cancel if necessary.
:param order: Order dict grabbed with exchange.fetch_order()
:param order_obj: Order object from the database.
:param trade: Trade object.
:return: None
:return: True if the order was canceled, False otherwise.
"""
if order["side"] == trade.entry_side:
self.handle_cancel_enter(trade, order, order_obj, reason)
return self.handle_cancel_enter(trade, order, order_obj, reason, replacing)
else:
canceled = self.handle_cancel_exit(trade, order, order_obj, reason)
canceled_count = trade.get_canceled_exit_order_count()
max_timeouts = self.config.get("unfilledtimeout", {}).get("exit_timeout_count", 0)
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
logger.warning(
f"Emergency exiting trade {trade}, as the exit order "
f"timed out {max_timeouts} times. force selling {order['amount']}."
)
self.emergency_exit(trade, order["price"], order["amount"])
if not replacing:
canceled_count = trade.get_canceled_exit_order_count()
max_timeouts = self.config.get("unfilledtimeout", {}).get("exit_timeout_count", 0)
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
logger.warning(
f"Emergency exiting trade {trade}, as the exit order "
f"timed out {max_timeouts} times. force selling {order['amount']}."
)
self.emergency_exit(trade, order["price"], order["amount"])
return canceled
def emergency_exit(
self, trade: Trade, price: float, sub_trade_amt: float | None = None
@@ -1663,9 +1665,9 @@ class FreqtradeBot(LoggingMixin):
def replace_order(self, order: CcxtOrder, order_obj: Order | None, trade: Trade) -> None:
"""
Check if current analyzed entry order should be replaced or simply cancelled.
To simply cancel the existing order(no replacement) adjust_entry_price() should return None
To maintain existing order adjust_entry_price() should return order_obj.price
To replace existing order adjust_entry_price() should return desired price for limit order
To simply cancel the existing order(no replacement) adjust_order_price() should return None
To maintain existing order adjust_order_price() should return order_obj.price
To replace existing order adjust_order_price() should return desired price for limit order
:param order: Order dict grabbed with exchange.fetch_order()
:param order_obj: Order object.
:param trade: Trade object.
@@ -1679,17 +1681,17 @@ class FreqtradeBot(LoggingMixin):
self.strategy.timeframe, latest_candle_open_date
)
# Check if new candle
if (
order_obj
and order_obj.side == trade.entry_side
and latest_candle_close_date > order_obj.order_date_utc
):
if order_obj and latest_candle_close_date > order_obj.order_date_utc:
is_entry = order_obj.side == trade.entry_side
# New candle
proposed_rate = self.exchange.get_rate(
trade.pair, side="entry", is_short=trade.is_short, refresh=True
trade.pair,
side="entry" if is_entry else "exit",
is_short=trade.is_short,
refresh=True,
)
adjusted_entry_price = strategy_safe_wrapper(
self.strategy.adjust_entry_price, default_retval=order_obj.safe_placement_price
adjusted_price = strategy_safe_wrapper(
self.strategy.adjust_order_price, default_retval=order_obj.safe_placement_price
)(
trade=trade,
order=order_obj,
@@ -1699,36 +1701,51 @@ class FreqtradeBot(LoggingMixin):
current_order_rate=order_obj.safe_placement_price,
entry_tag=trade.enter_tag,
side=trade.trade_direction,
is_entry=is_entry,
)
replacing = True
cancel_reason = constants.CANCEL_REASON["REPLACE"]
if not adjusted_entry_price:
if not adjusted_price:
replacing = False
cancel_reason = constants.CANCEL_REASON["USER_CANCEL"]
if order_obj.safe_placement_price != adjusted_entry_price:
if order_obj.safe_placement_price != adjusted_price:
# cancel existing order if new price is supplied or None
res = self.handle_cancel_enter(
trade, order, order_obj, cancel_reason, replacing=replacing
res = self.handle_cancel_order(
order, order_obj, trade, cancel_reason, replacing=replacing
)
if not res:
self.replace_order_failed(
trade, f"Could not fully cancel order for {trade}, therefore not replacing."
)
return
if adjusted_entry_price:
if adjusted_price:
# place new order only if new price is supplied
try:
if not self.execute_entry(
pair=trade.pair,
stake_amount=(
order_obj.safe_remaining * order_obj.safe_price / trade.leverage
),
price=adjusted_entry_price,
trade=trade,
is_short=trade.is_short,
mode="replace",
):
if is_entry:
succeeded = self.execute_entry(
pair=trade.pair,
stake_amount=(
order_obj.safe_remaining * order_obj.safe_price / trade.leverage
),
price=adjusted_price,
trade=trade,
is_short=trade.is_short,
mode="replace",
)
else:
succeeded = self.execute_trade_exit(
trade,
adjusted_price,
exit_check=ExitCheckTuple(
exit_type=ExitType.CUSTOM_EXIT,
exit_reason=order_obj.ft_order_tag or "order_replaced",
),
ordertype="limit",
sub_trade_amt=order_obj.safe_remaining,
)
if not succeeded:
self.replace_order_failed(
trade, f"Could not replace order for {trade}."
)

View File

@@ -396,6 +396,8 @@ class Backtesting:
self.canceled_trade_entries = 0
self.canceled_entry_orders = 0
self.replaced_entry_orders = 0
self.canceled_exit_orders = 0
self.replaced_exit_orders = 0
self.dataprovider.clear_cache()
if enable_protections:
self._load_protections(self.strategy)
@@ -1234,8 +1236,8 @@ class Backtesting:
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
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)]
@@ -1299,9 +1301,10 @@ class Backtesting:
Returns True if the trade should be deleted.
"""
# only check on new candles for open entry orders
if order.side == trade.entry_side and current_time > order.order_date_utc:
if current_time > order.order_date_utc:
is_entry = order.side == trade.entry_side
requested_rate = strategy_safe_wrapper(
self.strategy.adjust_entry_price, default_retval=order.ft_price
self.strategy.adjust_order_price, default_retval=order.ft_price
)(
trade=trade, # type: ignore[arg-type]
order=order,
@@ -1311,6 +1314,7 @@ class Backtesting:
current_order_rate=order.ft_price,
entry_tag=trade.enter_tag,
side=trade.trade_direction,
is_entry=is_entry,
) # default value is current order price
# cancel existing order whenever a new rate is requested (or None)
@@ -1319,22 +1323,35 @@ class Backtesting:
return False
else:
del trade.orders[trade.orders.index(order)]
self.canceled_entry_orders += 1
if is_entry:
self.canceled_entry_orders += 1
else:
self.canceled_exit_orders += 1
# place new order if result was not None
if requested_rate:
self._enter_trade(
pair=trade.pair,
row=row,
trade=trade,
requested_rate=requested_rate,
requested_stake=(order.safe_remaining * order.ft_price / trade.leverage),
direction="short" if trade.is_short else "long",
)
if is_entry:
self._enter_trade(
pair=trade.pair,
row=row,
trade=trade,
requested_rate=requested_rate,
requested_stake=(order.safe_remaining * order.ft_price / trade.leverage),
direction="short" if trade.is_short else "long",
)
self.replaced_entry_orders += 1
else:
self._exit_trade(
trade=trade,
sell_row=row,
close_rate=requested_rate,
amount=order.safe_remaining,
exit_reason=order.ft_order_tag,
)
self.replaced_exit_orders += 1
# Delete trade if no successful entries happened (if placing the new order failed)
if not trade.has_open_orders and trade.nr_of_successful_entries == 0:
if not trade.has_open_orders and is_entry and trade.nr_of_successful_entries == 0:
return True
self.replaced_entry_orders += 1
else:
# assumption: there can't be multiple open entry orders at any given time
return trade.nr_of_successful_entries == 0

View File

@@ -242,6 +242,14 @@ class StrategyResolver(IResolver):
if has_after_fill:
strategy._ft_stop_uses_after_fill = True
if check_override(strategy, IStrategy, "adjust_order_price") and (
check_override(strategy, IStrategy, "adjust_entry_price")
or check_override(strategy, IStrategy, "adjust_exit_price")
):
raise OperationalException(
"If you implement `adjust_order_price`, `adjust_entry_price` and "
"`adjust_exit_price` will not be used. Please pick one approach for your strategy."
)
return strategy
@staticmethod

View File

@@ -690,6 +690,104 @@ class IStrategy(ABC, HyperStrategyMixin):
"""
return current_order_rate
def adjust_exit_price(
self,
trade: Trade,
order: Order | None,
pair: str,
current_time: datetime,
proposed_rate: float,
current_order_rate: float,
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
"""
Exit price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
When not implemented by a strategy, returns current_order_rate as default.
If current_order_rate is returned then the existing order is maintained.
If None is returned then order gets canceled but not replaced by a new one.
:param pair: Pair that's currently analyzed
:param trade: Trade object.
:param order: Order object
:param current_time: datetime object, containing the current datetime
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
"""
return current_order_rate
def adjust_order_price(
self,
trade: Trade,
order: Order | None,
pair: str,
current_time: datetime,
proposed_rate: float,
current_order_rate: float,
entry_tag: str | None,
side: str,
is_entry: bool,
**kwargs,
) -> float:
"""
Exit and entry order price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
When not implemented by a strategy, returns current_order_rate as default.
If current_order_rate is returned then the existing order is maintained.
If None is returned then order gets canceled but not replaced by a new one.
:param pair: Pair that's currently analyzed
:param trade: Trade object.
:param order: Order object
:param current_time: datetime object, containing the current datetime
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param is_entry: True if the order is an entry order, False if it's an exit order.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
"""
if is_entry:
return self.adjust_entry_price(
trade=trade,
order=order,
pair=pair,
current_time=current_time,
proposed_rate=proposed_rate,
current_order_rate=current_order_rate,
entry_tag=entry_tag,
side=side,
**kwargs,
)
else:
return self.adjust_exit_price(
trade=trade,
order=order,
pair=pair,
current_time=current_time,
proposed_rate=proposed_rate,
current_order_rate=current_order_rate,
entry_tag=entry_tag,
side=side,
**kwargs,
)
def leverage(
self,
pair: str,

View File

@@ -40,7 +40,7 @@ def custom_entry_price(
"""
return proposed_rate
def adjust_entry_price(
def adjust_order_price(
self,
trade: Trade,
order: Order | None,
@@ -50,10 +50,11 @@ def adjust_entry_price(
current_order_rate: float,
entry_tag: str | None,
side: str,
is_entry: bool,
**kwargs,
) -> float:
"""
Entry price re-adjustment logic, returning the user desired limit price.
Exit and entry order price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
and not timed out on subsequent candles after entry trigger.
@@ -71,6 +72,7 @@ def adjust_entry_price(
:param current_order_rate: Rate of the existing order in place.
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param is_entry: True if the order is an entry order, False if it's an exit order.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided

View File

@@ -436,6 +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_exit_price = MagicMock(side_effect=ValueError)
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=None)
freqtrade.process()
trade = Trade.get_trades().first()
@@ -445,6 +446,8 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
assert pytest.approx(trade.stake_amount) == 60
assert trade.orders[-1].price == 1.95
assert pytest.approx(trade.orders[-1].cost) == 120 * leverage
assert freqtrade.strategy.adjust_entry_price.call_count == 1
assert freqtrade.strategy.adjust_exit_price.call_count == 0
# Fill DCA order
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=None)
@@ -469,6 +472,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
freqtrade.strategy.custom_exit = MagicMock(return_value="Exit now")
freqtrade.strategy.adjust_entry_price = MagicMock(return_value=2.02)
freqtrade.strategy.adjust_exit_price = MagicMock(side_effect=ValueError)
freqtrade.process()
trade = Trade.get_trades().first()
assert len(trade.orders) == 5
@@ -478,8 +482,9 @@ 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
assert freqtrade.strategy.adjust_exit_price.call_count == 0
# Process again, should not adjust entry price
# Process again, should not adjust price
freqtrade.process()
trade = Trade.get_trades().first()
@@ -490,6 +495,21 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
assert trade.orders[-1].price == 2.02
# Adjust entry price cannot be called - this is an exit order
assert freqtrade.strategy.adjust_entry_price.call_count == 0
assert freqtrade.strategy.adjust_exit_price.call_count == 1
freqtrade.strategy.adjust_exit_price = MagicMock(return_value=2.03)
# Process again, should adjust exit price
freqtrade.process()
trade = Trade.get_trades().first()
assert trade.orders[-2].status == "canceled"
assert len(trade.orders) == 6
assert trade.orders[-1].side == trade.exit_side
assert trade.orders[-1].status == "open"
assert trade.orders[-1].price == 2.03
assert freqtrade.strategy.adjust_entry_price.call_count == 0
assert freqtrade.strategy.adjust_exit_price.call_count == 1
@pytest.mark.parametrize("leverage", [1, 2])

View File

@@ -45,6 +45,7 @@ class BTContainer(NamedTuple):
leverage: float = 1.0
timeout: int | None = None
adjust_entry_price: float | None = None
adjust_exit_price: float | None = None
adjust_trade_position: list[float] | None = None

View File

@@ -1217,6 +1217,46 @@ tc57 = BTContainer(
],
)
# Test 58: Custom-exit-price short - below all candles
tc58 = BTContainer(
data=[
# D O H L C V EL XL ES Xs BT
[0, 5000, 5050, 4950, 5000, 6172, 0, 0, 1, 0],
[1, 5000, 5200, 4951, 5000, 6172, 0, 0, 0, 0], # enter trade (signal on last candle)
[2, 4900, 5250, 4900, 5100, 6172, 0, 0, 0, 1], # Exit - delayed
[3, 5100, 5100, 4650, 4750, 6172, 0, 0, 0, 0], #
[4, 4750, 5100, 4350, 4750, 6172, 0, 0, 0, 0],
],
stop_loss=-0.10,
roi={"0": 1.00},
profit_perc=-0.01,
use_exit_signal=True,
timeout=1000,
custom_exit_price=4300,
adjust_exit_price=5050,
trades=[BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=4, is_short=True)],
)
# Test 59: Custom-exit-price above all candles - readjust order
tc59 = BTContainer(
data=[
# D O H L C V EL XL ES Xs BT
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 4951, 5000, 6172, 0, 0],
[2, 4900, 5250, 4500, 5100, 6172, 0, 1], # exit
[3, 5100, 5100, 4650, 4750, 6172, 0, 0], # order readjust
[4, 4750, 4950, 4350, 4750, 6172, 0, 0],
],
stop_loss=-0.2,
roi={"0": 0.10},
profit_perc=-0.02,
use_exit_signal=True,
timeout=1000,
custom_exit_price=5300,
adjust_exit_price=4900,
trades=[BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=4, is_short=False)],
)
TESTS = [
tc0,
@@ -1277,6 +1317,8 @@ TESTS = [
tc55,
tc56,
tc57,
tc58,
tc59,
]
@@ -1330,6 +1372,8 @@ def test_backtest_results(default_conf, mocker, caplog, data: BTContainer) -> No
)
if data.adjust_entry_price:
backtesting.strategy.adjust_entry_price = MagicMock(return_value=data.adjust_entry_price)
if data.adjust_exit_price:
backtesting.strategy.adjust_exit_price = MagicMock(return_value=data.adjust_exit_price)
backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss
backtesting.strategy.leverage = lambda **kwargs: data.leverage

View File

@@ -21,10 +21,12 @@ class TestStrategyNoImplementSell(TestStrategyNoImplements):
return super().populate_entry_trend(dataframe, metadata)
class TestStrategyImplementCustomSell(TestStrategyNoImplementSell):
class TestStrategyImplementEmptyWorking(TestStrategyNoImplementSell):
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
return super().populate_exit_trend(dataframe, metadata)
class TestStrategyImplementCustomSell(TestStrategyImplementEmptyWorking):
def custom_sell(
self,
pair: str,
@@ -55,3 +57,34 @@ class TestStrategyImplementSellTimeout(TestStrategyNoImplementSell):
self, pair: str, trade, order: Order, current_time: datetime, **kwargs
) -> bool:
return False
class TestStrategyAdjustOrderPrice(TestStrategyImplementEmptyWorking):
def adjust_entry_price(
self,
trade,
order,
pair,
current_time,
proposed_rate,
current_order_rate,
entry_tag,
side,
**kwargs,
):
return proposed_rate
def adjust_order_price(
self,
trade,
order,
pair,
current_time,
proposed_rate,
current_order_rate,
entry_tag,
side,
is_entry,
**kwargs,
):
return proposed_rate

View File

@@ -460,6 +460,10 @@ def test_missing_implements(default_conf, caplog):
):
StrategyResolver.load_strategy(default_conf)
default_conf["strategy"] = "TestStrategyAdjustOrderPrice"
with pytest.raises(OperationalException, match=r"If you implement `adjust_order_price`.*"):
StrategyResolver.load_strategy(default_conf)
def test_call_deprecated_function(default_conf):
default_location = Path(__file__).parent / "strats/broken_strats/"