diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 7242e9c90..2292b7ed0 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -767,6 +767,7 @@ This callback is **not** called when there is an open order (either buy or sell) `adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible. Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. +Adjustment orders can be assigned with a tag by returning a 2 element Tuple, with the first element being the adjustment amount, and the 2nd element the tag (e.g. `return 250, 'increase_favorable_conditions'`). Modifications to leverage are not possible, and the stake-amount returned is assumed to be before applying leverage. @@ -833,7 +834,8 @@ class DigDeeperStrategy(IStrategy): min_stake: Optional[float], max_stake: float, current_entry_rate: float, current_exit_rate: float, current_entry_profit: float, current_exit_profit: float, - **kwargs) -> Optional[float]: + **kwargs + ) -> Union[Optional[float], Tuple[Optional[float], Optional[str]]]: """ Custom trade adjustment logic, returning the stake amount that a trade should be increased or decreased. @@ -859,11 +861,12 @@ class DigDeeperStrategy(IStrategy): :return float: Stake amount to adjust your trade, Positive values to increase position, Negative values to decrease position. Return None for no action. + Optionally, return a tuple with a 2nd element with an order reason """ if current_profit > 0.05 and trade.nr_of_successful_exits == 0: # Take half of the profit at +5% - return -(trade.stake_amount / 2) + return -(trade.stake_amount / 2), 'half_profit_5%' if current_profit > -0.05: return None @@ -891,7 +894,7 @@ class DigDeeperStrategy(IStrategy): stake_amount = filled_entries[0].stake_amount # This then calculates current safety order size stake_amount = stake_amount * (1 + (count_of_entries * 0.25)) - return stake_amount + return stake_amount, '1/3rd_increase' except Exception as exception: return None diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 0d7cef827..0eb1c608a 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -645,8 +645,7 @@ class FreqtradeBot(LoggingMixin): max_entry_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_entry_rate) stake_available = self.wallets.get_available_stake_amount() logger.debug(f"Calling adjust_trade_position for pair {trade.pair}") - stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, - default_retval=None, supress_error=True)( + stake_amount, order_tag = self.strategy._adjust_trade_position_internal( trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_entry_rate, current_profit=current_entry_profit, min_stake=min_entry_stake, @@ -665,7 +664,8 @@ class FreqtradeBot(LoggingMixin): else: logger.debug("Max adjustment entries is set to unlimited.") self.execute_entry(trade.pair, stake_amount, price=current_entry_rate, - trade=trade, is_short=trade.is_short, mode='pos_adjust') + trade=trade, is_short=trade.is_short, mode='pos_adjust', + enter_tag=order_tag) if stake_amount is not None and stake_amount < 0.0: # We should decrease our position @@ -684,7 +684,7 @@ class FreqtradeBot(LoggingMixin): return self.execute_trade_exit(trade, current_exit_rate, exit_check=ExitCheckTuple( - exit_type=ExitType.PARTIAL_EXIT), sub_trade_amt=amount) + exit_type=ExitType.PARTIAL_EXIT), sub_trade_amt=amount, exit_tag=order_tag) def _check_depth_of_market(self, pair: str, conf: Dict, side: SignalDirection) -> bool: """ @@ -782,6 +782,7 @@ class FreqtradeBot(LoggingMixin): leverage=leverage ) order_obj = Order.parse_from_ccxt_object(order, pair, side, amount, enter_limit_requested) + order_obj.ft_order_tag = enter_tag order_id = order['id'] order_status = order.get('status') logger.info(f"Order {order_id} was created for {pair} and status is {order_status}.") @@ -1753,6 +1754,7 @@ class FreqtradeBot(LoggingMixin): return False order_obj = Order.parse_from_ccxt_object(order, trade.pair, trade.exit_side, amount, limit) + order_obj.ft_order_tag = exit_reason trade.orders.append(order_obj) trade.exit_order_status = '' diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 2aa8a23d6..21e9c75cc 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -537,14 +537,14 @@ class Backtesting: min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1) max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate) stake_available = self.wallets.get_available_stake_amount() - stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, - default_retval=None, supress_error=True)( + stake_amount, order_tag = self.strategy._adjust_trade_position_internal( trade=trade, # type: ignore[arg-type] current_time=current_time, current_rate=current_rate, current_profit=current_profit, min_stake=min_stake, max_stake=min(max_stake, stake_available), current_entry_rate=current_rate, current_exit_rate=current_rate, - current_entry_profit=current_profit, current_exit_profit=current_profit) + current_entry_profit=current_profit, current_exit_profit=current_profit + ) # Check if we should increase our position if stake_amount is not None and stake_amount > 0.0: @@ -554,7 +554,8 @@ class Backtesting: check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment) if check_adjust_entry: pos_trade = self._enter_trade( - trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade) + trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade, + entry_tag1=order_tag) if pos_trade is not None: self.wallets.update() return pos_trade @@ -569,7 +570,7 @@ class Backtesting: if min_stake and remaining != 0 and remaining < min_stake: # Remaining stake is too low to be sold. return trade - exit_ = ExitCheckTuple(ExitType.PARTIAL_EXIT) + exit_ = ExitCheckTuple(ExitType.PARTIAL_EXIT, order_tag) pos_trade = self._get_exit_for_signal(trade, row, exit_, current_time, amount) if pos_trade is not None: order = pos_trade.orders[-1] @@ -681,11 +682,11 @@ class Backtesting: trade.exit_reason = exit_reason - return self._exit_trade(trade, row, close_rate, amount_) + return self._exit_trade(trade, row, close_rate, amount_, exit_reason) return None - def _exit_trade(self, trade: LocalTrade, sell_row: Tuple, - close_rate: float, amount: Optional[float] = None) -> Optional[LocalTrade]: + def _exit_trade(self, trade: LocalTrade, sell_row: Tuple, close_rate: float, + amount: float, exit_reason: Optional[str]) -> Optional[LocalTrade]: self.order_id_counter += 1 exit_candle_time = sell_row[DATE_IDX].to_pydatetime() order_type = self.strategy.order_types['exit'] @@ -712,6 +713,7 @@ class Backtesting: filled=0, remaining=amount, cost=amount * close_rate, + ft_order_tag=exit_reason, ) order._trade_bt = trade trade.orders.append(order) @@ -835,7 +837,9 @@ class Backtesting: stake_amount: Optional[float] = None, trade: Optional[LocalTrade] = None, requested_rate: Optional[float] = None, - requested_stake: Optional[float] = None) -> Optional[LocalTrade]: + requested_stake: Optional[float] = None, + entry_tag1: Optional[str] = None + ) -> Optional[LocalTrade]: """ :param trade: Trade to adjust - initial entry if None :param requested_rate: Adjusted entry rate @@ -843,7 +847,7 @@ class Backtesting: """ current_time = row[DATE_IDX].to_pydatetime() - entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None + entry_tag = entry_tag1 or (row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None) # let's call the custom entry price, using the open price as default price order_type = self.strategy.order_types['entry'] pos_adjust = trade is not None and requested_rate is None @@ -944,6 +948,7 @@ class Backtesting: filled=0, remaining=amount, cost=amount * propose_rate + trade.fee_open, + ft_order_tag=entry_tag, ) order._trade_bt = trade trade.orders.append(order) @@ -963,7 +968,8 @@ class Backtesting: # Ignore trade if entry-order did not fill yet continue exit_row = data[pair][-1] - self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount) + self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount, + ExitType.FORCE_EXIT.value) trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade) trade.close_date = exit_row[DATE_IDX].to_pydatetime() diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index bb6c04922..f4d5a7174 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -223,6 +223,7 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List): ft_amount = get_column_def(cols_order, 'ft_amount', 'coalesce(amount, 0.0)') ft_price = get_column_def(cols_order, 'ft_price', 'coalesce(price, 0.0)') ft_cancel_reason = get_column_def(cols_order, 'ft_cancel_reason', 'null') + ft_order_tag = get_column_def(cols_order, 'ft_order_tag', 'null') # sqlite does not support literals for booleans with engine.begin() as connection: @@ -230,13 +231,14 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List): insert into orders (id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, symbol, order_type, side, price, amount, filled, average, remaining, cost, stop_price, order_date, order_filled_date, order_update_date, ft_fee_base, funding_fee, - ft_amount, ft_price, ft_cancel_reason + ft_amount, ft_price, ft_cancel_reason, ft_order_tag ) select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, symbol, order_type, side, price, amount, filled, {average} average, remaining, cost, {stop_price} stop_price, order_date, order_filled_date, order_update_date, {ft_fee_base} ft_fee_base, {funding_fee} funding_fee, - {ft_amount} ft_amount, {ft_price} ft_price, {ft_cancel_reason} ft_cancel_reason + {ft_amount} ft_amount, {ft_price} ft_price, {ft_cancel_reason} ft_cancel_reason, + {ft_order_tag} ft_order_tag from {table_back_name} """)) @@ -331,8 +333,8 @@ def check_migrate(engine, decl_base, previous_tables) -> None: # if ('orders' not in previous_tables # or not has_column(cols_orders, 'funding_fee')): migrating = False - # if not has_column(cols_orders, 'ft_cancel_reason'): - if not has_column(cols_trades, 'funding_fee_running'): + # if not has_column(cols_trades, 'funding_fee_running'): + if not has_column(cols_orders, 'ft_order_tag'): migrating = True logger.info(f"Running database migration for trades - " f"backup: {table_back_name}, {order_table_bak_name}") diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 856a33abf..7e3cf970f 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -89,6 +89,8 @@ class Order(ModelBase): funding_fee: Mapped[Optional[float]] = mapped_column(Float(), nullable=True) ft_fee_base: Mapped[Optional[float]] = mapped_column(Float(), nullable=True) + ft_order_tag: Mapped[Optional[str]] = mapped_column(String(CUSTOM_TAG_MAX_LENGTH), + nullable=True) @property def order_date_utc(self) -> datetime: @@ -212,6 +214,10 @@ class Order(ModelBase): return order def to_json(self, entry_side: str, minified: bool = False) -> Dict[str, Any]: + """ + :param minified: If True, only return a subset of the data is returned. + Only used for backtesting. + """ resp = { 'amount': self.safe_amount, 'safe_price': self.safe_price, @@ -219,6 +225,7 @@ class Order(ModelBase): 'order_filled_timestamp': int(self.order_filled_date.replace( tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None, 'ft_is_entry': self.ft_order_side == entry_side, + 'ft_order_tag': self.ft_order_tag, } if not minified: resp.update({ @@ -1405,6 +1412,7 @@ class LocalTrade: ft_price=order["price"], remaining=order["remaining"], funding_fee=order.get("funding_fee", None), + ft_order_tag=order.get("ft_order_tag", None), ) trade.orders.append(order_obj) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 20a614798..791f70fa0 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -261,6 +261,7 @@ class OrderSchema(BaseModel): order_timestamp: Optional[int] = None order_filled_timestamp: Optional[int] = None ft_fee_base: Optional[float] = None + ft_order_tag: Optional[str] = None class TradeSchema(BaseModel): diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 7f10c2ea2..2630c3547 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -511,7 +511,8 @@ class IStrategy(ABC, HyperStrategyMixin): min_stake: Optional[float], max_stake: float, current_entry_rate: float, current_exit_rate: float, current_entry_profit: float, current_exit_profit: float, - **kwargs) -> Optional[float]: + **kwargs + ) -> Union[Optional[float], Tuple[Optional[float], Optional[str]]]: """ Custom trade adjustment logic, returning the stake amount that a trade should be increased or decreased. @@ -537,6 +538,7 @@ class IStrategy(ABC, HyperStrategyMixin): :return float: Stake amount to adjust your trade, Positive values to increase position, Negative values to decrease position. Return None for no action. + Optionally, return a tuple with a 2nd element with an order reason """ return None @@ -725,6 +727,36 @@ class IStrategy(ABC, HyperStrategyMixin): _ft_stop_uses_after_fill = False + def _adjust_trade_position_internal( + self, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, + min_stake: Optional[float], max_stake: float, + current_entry_rate: float, current_exit_rate: float, + current_entry_profit: float, current_exit_profit: float, + **kwargs + ) -> Tuple[Optional[float], str]: + """ + wrapper around adjust_trade_position to handle the return value + """ + resp = strategy_safe_wrapper(self.adjust_trade_position, + default_retval=(None, ''), supress_error=True)( + trade=trade, current_time=current_time, + current_rate=current_rate, current_profit=current_profit, + min_stake=min_stake, max_stake=max_stake, + current_entry_rate=current_entry_rate, current_exit_rate=current_exit_rate, + current_entry_profit=current_entry_profit, current_exit_profit=current_exit_profit, + **kwargs + ) + order_tag = '' + if isinstance(resp, tuple): + if len(resp) >= 1: + stake_amount = resp[0] + if len(resp) > 1: + order_tag = resp[1] or '' + else: + stake_amount = resp + return stake_amount, order_tag + def __informative_pairs_freqai(self) -> ListPairsWithTimeframes: """ Create informative-pairs needed for FreqAI diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 87e92071f..603fcc310 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -742,14 +742,18 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: 'orders': [ [ {'amount': 0.00957442, 'safe_price': 0.104445, 'ft_order_side': 'buy', - 'order_filled_timestamp': 1517251200000, 'ft_is_entry': True}, + 'order_filled_timestamp': 1517251200000, 'ft_is_entry': True, + 'ft_order_tag': ''}, {'amount': 0.00957442, 'safe_price': 0.10496853383458644, 'ft_order_side': 'sell', - 'order_filled_timestamp': 1517265300000, 'ft_is_entry': False} + 'order_filled_timestamp': 1517265300000, 'ft_is_entry': False, + 'ft_order_tag': 'roi'} ], [ {'amount': 0.0097064, 'safe_price': 0.10302485, 'ft_order_side': 'buy', - 'order_filled_timestamp': 1517283000000, 'ft_is_entry': True}, + 'order_filled_timestamp': 1517283000000, 'ft_is_entry': True, + 'ft_order_tag': ''}, {'amount': 0.0097064, 'safe_price': 0.10354126528822055, 'ft_order_side': 'sell', - 'order_filled_timestamp': 1517285400000, 'ft_is_entry': False} + 'order_filled_timestamp': 1517285400000, 'ft_is_entry': False, + 'ft_order_tag': 'roi'} ] ] }) diff --git a/tests/optimize/test_backtesting_adjust_position.py b/tests/optimize/test_backtesting_adjust_position.py index 56b04b3fd..7f7bbb29f 100644 --- a/tests/optimize/test_backtesting_adjust_position.py +++ b/tests/optimize/test_backtesting_adjust_position.py @@ -148,7 +148,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 1 # Increase position by 100 - backtesting.strategy.adjust_trade_position = MagicMock(return_value=100) + backtesting.strategy.adjust_trade_position = MagicMock(return_value=(100, 'PartIncrease')) trade = backtesting._get_adjust_trade_entry_for_candle(trade, row, current_time) @@ -156,6 +156,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera assert pytest.approx(trade.stake_amount) == 200.0 assert pytest.approx(trade.amount) == 95.23809524 * leverage assert len(trade.orders) == 2 + assert trade.orders[-1].ft_order_tag == 'PartIncrease' assert pytest.approx(trade.liquidation_price) == (0.1038916 if leverage == 1 else 1.2127791) # Reduce by more than amount - no change to trade. @@ -171,13 +172,14 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera assert pytest.approx(trade.liquidation_price) == (0.1038916 if leverage == 1 else 1.2127791) # Reduce position by 50 - backtesting.strategy.adjust_trade_position = MagicMock(return_value=-100) + backtesting.strategy.adjust_trade_position = MagicMock(return_value=(-100, 'partDecrease')) trade = backtesting._get_adjust_trade_entry_for_candle(trade, row, current_time) assert trade assert pytest.approx(trade.stake_amount) == 100.0 assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 3 + assert trade.orders[-1].ft_order_tag == 'partDecrease' assert trade.nr_of_successful_entries == 2 assert trade.nr_of_successful_exits == 1 assert pytest.approx(trade.liquidation_price) == (0.1038916 if leverage == 1 else 1.2127791) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 785efc522..ca81ea0e6 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -99,7 +99,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, 'is_open': False, 'pair': 'ETH/BTC', 'order_id': ANY, 'remaining': ANY, 'status': ANY, 'ft_is_entry': True, 'ft_fee_base': None, - 'funding_fee': ANY, + 'funding_fee': ANY, 'ft_order_tag': None, }], } mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index e61d5804d..8e17604ab 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -6725,11 +6725,15 @@ def test_check_and_call_adjust_trade_position(mocker, default_conf_usdt, fee, ca ) create_mock_trades(fee) caplog.set_level(logging.DEBUG) - freqtrade.strategy.adjust_trade_position = MagicMock(return_value=10) + 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 caplog.clear() - freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-10) + freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(-10, '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 + trade = Trade.get_trades(trade_filter=[Trade.id == 5]).first() + assert trade.orders[-1].ft_order_tag == 'partial_exit_c' diff --git a/tests/test_integration.py b/tests/test_integration.py index 2e7f38fc8..94253dffb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -536,7 +536,7 @@ def test_dca_order_adjust_entry_replace_fails( # Create DCA order for 2nd trade (so we have 2 open orders on 2 trades) # this 2nd order won't fill. - freqtrade.strategy.adjust_trade_position = MagicMock(return_value=20) + freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(20, 'PeNF')) freqtrade.process() @@ -627,12 +627,13 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera assert log_has_re( r"Remaining amount of \d\.\d+.* would be smaller than the minimum of 10.", caplog) - freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-20) + freqtrade.strategy.adjust_trade_position = MagicMock(return_value=(-20, 'PES')) freqtrade.process() trade = Trade.get_trades().first() assert len(trade.orders) == 2 assert trade.orders[-1].ft_order_side == 'sell' + assert trade.orders[-1].ft_order_tag == 'PES' assert pytest.approx(trade.stake_amount) == 40.198 assert pytest.approx(trade.amount) == 20.099 * leverage assert trade.open_rate == 2.0