]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
fix(quickadapter): preserve valid partial-exit remainder (#118)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 26 Jul 2026 19:05:39 +0000 (21:05 +0200)
committerGitHub <noreply@github.com>
Sun, 26 Jul 2026 19:05:39 +0000 (21:05 +0200)
Trigger the take-profit partial exit at freqtrade's executable current_exit_rate
instead of the entry-side current_rate, and size the partial-exit remainder so it
survives freqtrade's minimum-stake guard.

- Evaluate the take-profit trigger and the remaining position at current_exit_rate
  (the rate the exit actually fills at); the previous current_rate is the entry rate
  in this callback and could fire a long exit below its bid target (symmetric short).
- freqtrade passes min_entry_stake as min_stake but rejects the partial exit when the
  remaining value is below the larger min_exit_stake. Size the remainder against a
  proven upper bound min_exit_stake <= min_stake * max(current_exit_rate/current_entry_rate,
  1/(1-|stoploss|)) (holds for both the cost- and amount-driven minimum), with a small
  rounding margin, so the shrunk remainder clears the guard. Leverage cancels; amount
  precision and final validation are left to freqtrade.

quickadapter/user_data/strategies/QuickAdapterV3.py

index ee292d34dcd57b59c5021d66dd2328f6e07f5665..2b5629f36ff2211a65b02e7d5e061d59eeed9e7e 100644 (file)
@@ -215,6 +215,10 @@ class QuickAdapterV3(IStrategy):
     # samples the acceleration t-statistic is structurally NaN.
     _MIN_PNL_MOMENTUM_WINDOW_SIZE: Final[int] = 4
 
+    # Rounding margin so the sized partial-exit remainder clears freqtrade's
+    # strict `remaining < min_exit_stake` guard.
+    _PARTIAL_EXIT_MIN_STAKE_MARGIN: Final[float] = 1e-3
+
     minimal_roi = {str(timeframe_minutes * 864): -1}
 
     # FreqAI is crashing if minimal_roi is a property
@@ -1689,7 +1693,7 @@ class QuickAdapterV3(IStrategy):
         )
 
         trade_partial_exit = QuickAdapterV3.can_take_profit(
-            trade, current_rate, trade_take_profit_price
+            trade, current_exit_rate, trade_take_profit_price
         )
         if not trade_partial_exit:
             self.throttle_callback(
@@ -1697,26 +1701,45 @@ class QuickAdapterV3(IStrategy):
                 current_time=current_time,
                 callback=lambda: logger.info(
                     f"[{pair}] Trade {trade.trade_direction} stage {trade_exit_stage} | "
-                    f"Take Profit: {format_number(trade_take_profit_price)}, Rate: {format_number(current_rate)}"
+                    f"Take Profit: {format_number(trade_take_profit_price)}, Rate: {format_number(current_exit_rate)}"
                 ),
             )
         if trade_partial_exit:
-            if min_stake is None:
-                min_stake = 0.0
-            if min_stake > trade.stake_amount:
-                return None
             trade_stake_percent = QuickAdapterV3.partial_exit_stages[trade_exit_stage][
                 1
             ]
             trade_partial_stake_amount = trade_stake_percent * trade.stake_amount
-            remaining_stake_amount = trade.stake_amount - trade_partial_stake_amount
-            if remaining_stake_amount < min_stake:
-                initial_trade_partial_stake_amount = trade_partial_stake_amount
-                trade_partial_stake_amount = trade.stake_amount - min_stake
-                logger.info(
-                    f"[{pair}] Trade {trade.trade_direction} stage {trade_exit_stage} | "
-                    f"Partial stake amount adjusted from {format_number(initial_trade_partial_stake_amount)} to {format_number(trade_partial_stake_amount)} to respect min_stake {format_number(min_stake)}"
+            if min_stake is not None and min_stake > 0:
+                current_position_value = trade.amount * current_exit_rate
+                # min_stake is freqtrade's min_entry_stake, but its exit guard uses
+                # the larger min_exit_stake. For both the cost- and amount-driven
+                # minimum, min_exit_stake <= min_stake * max(exit/entry, 1/(1-|sl|)),
+                # so this upper bound keeps the shrunk remainder above the guard.
+                min_exit_stake_bound = (
+                    min_stake
+                    * max(
+                        current_exit_rate / current_entry_rate,
+                        1.0 / (1.0 - abs(self.stoploss)),
+                    )
+                    * (1.0 + QuickAdapterV3._PARTIAL_EXIT_MIN_STAKE_MARGIN)
+                )
+                if current_position_value <= min_exit_stake_bound:
+                    return None
+                remaining_position_value = current_position_value * (
+                    1 - trade_stake_percent
                 )
+                if remaining_position_value < min_exit_stake_bound:
+                    initial_trade_partial_stake_amount = trade_partial_stake_amount
+                    trade_partial_stake_amount = trade.stake_amount * (
+                        1 - min_exit_stake_bound / current_position_value
+                    )
+                    logger.info(
+                        f"[{pair}] Trade {trade.trade_direction} stage "
+                        f"{trade_exit_stage} | partial stake "
+                        f"{format_number(initial_trade_partial_stake_amount)} -> "
+                        f"{format_number(trade_partial_stake_amount)} to preserve "
+                        f"min_exit_stake_bound {format_number(min_exit_stake_bound)}"
+                    )
             return (
                 -trade_partial_stake_amount,
                 QuickAdapterV3._take_profit_order_tag(