Merge pull request #9955 from Axel-CH/feature/trade-lifecycle-callbacks

Feature: trade lifecycle callbacks
This commit is contained in:
Matthias
2024-03-28 19:35:53 +01:00
committed by GitHub
9 changed files with 83 additions and 5 deletions

View File

@@ -33,7 +33,6 @@ For spot pairs, naming will be `base/quote` (e.g. `ETH/USDT`).
For futures pairs, naming will be `base/quote:settle` (e.g. `ETH/USDT:USDT`).
## Bot execution logic
Starting freqtrade in dry-run or live mode (using `freqtrade trade`) will start the bot and start the bot iteration loop.
@@ -50,7 +49,9 @@ By default, the bot loop runs every few seconds (`internals.process_throttle_sec
* Call `populate_indicators()`
* Call `populate_entry_trend()`
* Call `populate_exit_trend()`
* Check timeouts for open orders.
* Update trades open order state from exchange.
* Call `order_filled()` strategy callback for filled orders.
* 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.
@@ -86,8 +87,10 @@ This loop will be repeated again and again until the bot is stopped.
* In Margin and Futures mode, `leverage()` strategy callback is called to determine the desired leverage.
* Determine stake size by calling the `custom_stake_amount()` callback.
* Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested.
* Call `order_filled()` strategy callback for filled entry orders.
* Call `custom_stoploss()` and `custom_exit()` to find custom exit points.
* For exits based on exit-signal, custom-exit and partial exits: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle).
* Call `order_filled()` strategy callback for filled exit orders.
* Generate backtest report output
!!! Note

View File

@@ -19,6 +19,7 @@ Currently available callbacks:
* [`adjust_trade_position()`](#adjust-trade-position)
* [`adjust_entry_price()`](#adjust-entry-price)
* [`leverage()`](#leverage-callback)
* [`order_filled()`](#order-filled-callback)
!!! Tip "Callback calling sequence"
You can find the callback calling sequence in [bot-basics](bot-basics.md#bot-execution-logic)
@@ -1022,3 +1023,33 @@ class AwesomeStrategy(IStrategy):
All profit calculations include leverage. Stoploss / ROI also include leverage in their calculation.
Defining a stoploss of 10% at 10x leverage would trigger the stoploss with a 1% move to the downside.
## Order filled Callback
The `order_filled()` callback may be used to perform specific actions based on the current trade state after an order is filled.
It will be called independently of the order type (entry, exit, stoploss or position adjustment).
Assuming that your strategy needs to store the high value of the candle at trade entry, this is possible with this callback as the following example show.
``` python
class AwesomeStrategy(IStrategy):
def order_filled(self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs) -> None:
"""
Called right after an order fills.
Will be called for all order types (entry, exit, stoploss, position adjustment).
:param pair: Pair for trade
:param trade: trade object.
:param order: Order object.
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
"""
# Obtain pair dataframe (just to show how to access it)
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
last_candle = dataframe.iloc[-1].squeeze()
if (trade.nr_of_successful_entries == 1) and (order.ft_order_side == trade.entry_side):
trade.set_custom_data(key='entry_candle_high', value=last_candle['high'])
return None
```

View File

@@ -1944,6 +1944,9 @@ class FreqtradeBot(LoggingMixin):
def _update_trade_after_fill(self, trade: Trade, order: Order) -> Trade:
if order.status in constants.NON_OPEN_EXCHANGE_STATES:
strategy_safe_wrapper(
self.strategy.order_filled, default_retval=None)(
pair=trade.pair, trade=trade, order=order, current_time=datetime.now(timezone.utc))
# If a entry order was closed, force update on stoploss on exchange
if order.ft_order_side == trade.entry_side:
trade = self.cancel_stoploss_on_exchange(trade)

View File

@@ -603,6 +603,11 @@ class Backtesting:
if order and self._get_order_filled(order.ft_price, row):
order.close_bt_order(current_date, trade)
self._run_funding_fees(trade, current_date, force=True)
strategy_safe_wrapper(
self.strategy.order_filled,
default_retval=None)(
pair=trade.pair, trade=trade, # type: ignore[arg-type]
order=order, current_time=current_date)
if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount):
# trade is still open

View File

@@ -372,6 +372,19 @@ class IStrategy(ABC, HyperStrategyMixin):
"""
return True
def order_filled(self, pair: str, trade: Trade, order: Order,
current_time: datetime, **kwargs) -> None:
"""
Called right after an order fills.
Will be called for all order types (entry, exit, stoploss, position adjustment).
:param pair: Pair for trade
:param trade: trade object.
:param order: Order object.
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
"""
pass
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, after_fill: bool, **kwargs) -> Optional[float]:
"""

View File

@@ -300,3 +300,17 @@ def leverage(self, pair: str, current_time: datetime, current_rate: float,
:return: A leverage amount, which is between 1.0 and max_leverage.
"""
return 1.0
def order_filled(self, pair: str, trade: 'Trade', order: 'Order',
current_time: datetime, **kwargs) -> None:
"""
Called right after an order fills.
Will be called for all order types (entry, exit, stoploss, position adjustment).
:param pair: Pair for trade
:param trade: trade object.
:param order: Order object.
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
"""
pass

View File

@@ -1233,6 +1233,7 @@ def test_update_trade_state(mocker, default_conf_usdt, limit_order, is_short, ca
order_id=order_id,
))
freqtrade.strategy.order_filled = MagicMock(return_value=None)
assert not freqtrade.update_trade_state(trade, None)
assert log_has_re(r'Orderid for trade .* is empty.', caplog)
caplog.clear()
@@ -1243,6 +1244,7 @@ def test_update_trade_state(mocker, default_conf_usdt, limit_order, is_short, ca
caplog.clear()
assert not trade.has_open_orders
assert trade.amount == order['amount']
assert freqtrade.strategy.order_filled.call_count == 1
mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount', return_value=0.01)
assert trade.amount == 30.0
@@ -1260,11 +1262,13 @@ def test_update_trade_state(mocker, default_conf_usdt, limit_order, is_short, ca
limit_buy_order_usdt_new['filled'] = 0.0
limit_buy_order_usdt_new['status'] = 'canceled'
freqtrade.strategy.order_filled = MagicMock(return_value=None)
mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount', side_effect=ValueError)
mocker.patch(f'{EXMS}.fetch_order', return_value=limit_buy_order_usdt_new)
res = freqtrade.update_trade_state(trade, order_id)
# Cancelled empty
assert res is True
assert freqtrade.strategy.order_filled.call_count == 0
@pytest.mark.parametrize("is_short", [False, True])

View File

@@ -146,10 +146,12 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_
'amount': enter_order['amount'],
})
mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit)
freqtrade.strategy.order_filled = MagicMock(return_value=None)
assert freqtrade.handle_stoploss_on_exchange(trade) is True
assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog)
assert len(trade.open_sl_orders) == 0
assert trade.is_open is False
assert freqtrade.strategy.order_filled.call_count == 1
caplog.clear()
mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError())

View File

@@ -698,6 +698,7 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None:
data = history.load_data(datadir=testdatadir, timeframe='5m', pairs=['UNITTEST/BTC'],
timerange=timerange)
processed = backtesting.strategy.advise_all_indicators(data)
backtesting.strategy.order_filled = MagicMock()
min_date, max_date = get_timerange(processed)
result = backtesting.backtest(
@@ -760,6 +761,8 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None:
pd.testing.assert_frame_equal(results, expected)
assert 'orders' in results.columns
data_pair = processed[pair]
# Called once per order
assert backtesting.strategy.order_filled.call_count == 4
for _, t in results.iterrows():
assert len(t['orders']) == 2
ln = data_pair.loc[data_pair["date"] == t["open_date"]]