""" ================================================================================ FeeOptMomentum5m — Fee-Optimized Momentum + Minute-Level Execution Strategy ================================================================================ Target : Net positive alpha after 0.10–0.20% round-trip fees per trade Platform : Roostoo / Freqtrade IStrategy v3 Timeframe: 5m execution | 15m & 30m signal layers | BTC 30m regime -------------------------------------------------------------------------------- ARCHITECTURE — THREE LAYERS -------------------------------------------------------------------------------- Layer 1 · BTC Regime Filter (30m) ─ Only enter when BTC is in Risk-On or weak Risk-On. ─ On Risk-Off: suppress all new entries. ─ On Hard Bear (BTC drops >4% in 3h): trigger emergency exit. Levels: STRONG_BULL=2, WEAK_BULL=1, NEUTRAL=0, BEAR=-1 Layer 2 · Momentum Pre-Screen (15m / 30m proxy) ─ Coin must show positive 6h return > 0.5% ─ Volume must expand vs 10h average (≥1.5×) ─ Price must be above 20-bar rolling average ─ Breakout from recent 3h high (15m proxy) ─ RSI between 45–78 (momentum, not overbought) Output: momentum_score composite; only trade coins above threshold. Layer 3 · 5m Execution (Fee-Aware Entry) ─ Mode A Micro-breakout: price breaks 30m high + volume surge ─ Mode B Pullback continuation: already broken out on 15m, pulls back to EMA5/VWAP, then resumes up ─ Anti-chase: ignore bars with |Δprice| > 3% ─ Minimum edge gate: momentum_score > threshold (no noise trades) -------------------------------------------------------------------------------- FEE MODEL & MINIMUM EDGE -------------------------------------------------------------------------------- maker 0.05% + maker 0.05% = 0.10% round-trip (normal) taker 0.10% + taker 0.10% = 0.20% round-trip (emergency) Minimum target per trade: ≥ 0.50% (5× maker round-trip buffer) First ROI tier: 0.6% — smallest fee-safe take-profit level -------------------------------------------------------------------------------- ENTRY LOGIC -------------------------------------------------------------------------------- All of the following must be true simultaneously: A. btc_regime ≥ 0 (Layer 1: not in bear) B. momentum_15m > 0.5% (Layer 2: 6h uptrend) C. vol_15m_ratio > 1.5 (Layer 2: volume expanding) D. close > ema20_5m (Layer 2: trend aligned) E. RSI(7) in [45, 78] (Layer 2: not overbought) F. micro_breakout OR pullback_cont (Layer 3: execution signal) G. bar_return < 3% (anti-chase filter) H. momentum_score > 2.0 (minimum edge gate) Scale-in via adjust_trade_position: Initial entry: 40% of trade budget Add-on: +60% when floating profit > 0.3% (breakout confirmed) -------------------------------------------------------------------------------- EXIT LOGIC (priority order) -------------------------------------------------------------------------------- 1. Hard stop : -0.6% (stoploss parameter) 2. Regime fail : BTC turns BEAR → taker exit (emergency) 3. Momentum decay: MACD histogram flips negative + volume fades 4. EMA cross-down: ema5 crosses below ema20 on 5m 5. Trailing stop: activated after +0.6%, trails -0.8% from peak 6. Time stop : held >60m with <0.3% profit → cut at -0.3% 7. ROI tiers : +0.6% @10h / +0.9% @5h / +1.2% @2.5h / +1.5% instant -------------------------------------------------------------------------------- BACKTEST RESULTS (standalone engine, Binance 5m data, fee=0.1%, slip=0.05%) -------------------------------------------------------------------------------- See FeeOptMomentum5m_Report.md for full results and parameter analysis. -------------------------------------------------------------------------------- KEY PARAMETERS (most sensitive, tune these first) -------------------------------------------------------------------------------- MIN_EDGE 0.005 minimum momentum_score gate MOM_THRESHOLD 0.005 minimum 6h coin return to qualify (0.5%) VOL_SURGE_15M 1.5 minimum 15m volume expansion ratio EXEC_BREAKOUT 6 5m bars for micro-breakout channel (30m) TRAILING_STOP_PCT 0.008 trailing stop distance after breakeven TIME_STOP_BARS 12 bars without progress → forced exit (60m) ================================================================================ """ from __future__ import annotations import numpy as np import pandas as pd from datetime import datetime from pandas import DataFrame from freqtrade.strategy import IStrategy try: import talib.abstract as ta HAS_TALIB = True except ImportError: HAS_TALIB = False class FeeOptMomentum5m(IStrategy): """ Fee-Optimized Momentum Strategy with 5-minute Precision Execution. Three-layer signal architecture: Layer 1 BTC regime filter (30m) Layer 2 Momentum pre-screen (15m / 30m proxy via rolling windows) Layer 3 5m execution (micro-breakout or pullback continuation) Fee-first design: minimum 0.5% edge gate, maker-preferred exits, tight -0.6% stop to maintain favorable loss/win asymmetry. """ INTERFACE_VERSION: int = 3 can_short: bool = False timeframe = "5m" # ── ROI table ────────────────────────────────────────────────────────────── # All levels are net-positive after 0.10% maker round-trip fees. # +0.6% is the minimum fee-safe profit floor (6× maker round-trip). minimal_roi = { "0": 0.015, # +1.5% — take immediately on strong breakout "30": 0.012, # +1.2% — after 30 bars (150m ≈ 2.5h) "60": 0.009, # +0.9% — after 60 bars (~5h) "120": 0.006, # +0.6% — after 120 bars (~10h) fee breakeven floor } stoploss = -0.006 # Hard stop -0.6% (covers maker round-trip × 6) use_custom_stoploss = True # Trailing + time-based stop via custom_stoploss trailing_stop = False # Managed manually in custom_stoploss startup_candle_count: int = 300 # warm-up for 48h momentum proxies process_only_new_candles = True use_exit_signal = True exit_profit_only = False ignore_roi_if_entry_signal = False # ═══════════════════════════════════════════════════════════════════════════ # STRATEGY CONSTANTS (adjust these for parameter search) # ═══════════════════════════════════════════════════════════════════════════ # ── Fee model ────────────────────────────────────────────────────────────── MAKER_FEE = 0.0005 # 0.05% per leg TAKER_FEE = 0.001 # 0.10% per leg MIN_EDGE = 0.005 # 0.50% net minimum per trade # ── Layer 1: BTC regime (30m) ────────────────────────────────────────────── BTC_EMA_FAST = 20 # × 30m = 10h fast EMA BTC_EMA_SLOW = 50 # × 30m = 25h slow EMA BTC_RETURN_BARS = 6 # × 30m = 3h return window for BTC momentum check # ── Layer 2: Momentum pre-screen (15m & 30m proxy on 5m bars) ───────────── # 15m approximation: rolling windows 3× the 15m period counts MOM_WINDOW_15M = 24 # 24 × 15m = 6h momentum → 72 × 5m bars BREAKOUT_15M = 12 # 12 × 15m = 3h breakout → 36 × 5m bars VOL_AVG_15M = 20 # 20 × 15m baseline → 60 × 5m bars VOL_SURGE_15M = 1.5 # 15m volume must be ≥1.5× 10h average MOM_THRESHOLD = 0.005 # minimum 6h return to qualify (0.5%) RSI_SCREEN_MIN = 45 # Layer 2 RSI lower bound RSI_SCREEN_MAX = 78 # Layer 2 RSI upper bound (not overbought) # 30m approximation: rolling windows 6× the 30m period counts MOM_WINDOW_30M = 48 # 48 × 30m = 24h → 288 × 5m bars # ── Layer 3: 5m execution ───────────────────────────────────────────────── EMA_FAST_5M = 5 # 5 × 5m = 25m fast EMA EMA_SLOW_5M = 20 # 20 × 5m = 100m slow EMA EXEC_BREAKOUT = 6 # 6 × 5m = 30m micro-breakout channel EXEC_VOL_SURGE = 1.3 # 5m volume must be ≥1.3× baseline at entry RSI_EXEC_MIN = 40 # execution RSI lower bound RSI_EXEC_MAX = 75 # execution RSI upper bound ANTI_CHASE_MAX = 0.03 # reject entry if last bar moved >3% MIN_SCORE_GATE = 2.0 # momentum_score gate for minimum edge enforcement # ── Exit / stop parameters ──────────────────────────────────────────────── TRAILING_STOP_PCT = 0.008 # 0.8% trail distance (activated after breakeven) BREAKEVEN_PCT = 0.006 # activate trailing after floating profit > 0.6% TIME_STOP_MIN = 60 # minutes without progress before time-stop TIME_STOP_PROFIT = 0.003 # profit threshold for time-stop (0.3%) TIME_STOP_CUT = 0.003 # stop at -0.3% if time-stop fires # ── Position sizing constants ───────────────────────────────────────────── # Initial entry = 40% of stake; add-on = 60% of stake on confirmation INITIAL_STAKE_RATIO = 0.4 # fraction for first entry ADDON_PROFIT_MIN = 0.003 # floating profit needed before scaling in (+0.3%) # ═══════════════════════════════════════════════════════════════════════════ # PLOT CONFIG # ═══════════════════════════════════════════════════════════════════════════ plot_config = { "main_plot": { "ema5_5m": {"color": "#1E90FF"}, "ema20_5m": {"color": "#FF8C00"}, "exec_high": {"color": "#32CD32", "type": "line"}, "vwap_approx": {"color": "#FFD700", "type": "line"}, }, "subplots": { "RSI": {"rsi7_5m": {"color": "#9370DB"}}, "Momentum": { "momentum_15m": {"color": "#FF4500"}, "momentum_30m": {"color": "#228B22"}, }, "Score": {"momentum_score": {"color": "#DC143C", "type": "bar"}}, "Regime": {"btc_regime": {"color": "#4682B4", "type": "bar"}}, "Vol Ratio": {"vol_ratio_5m": {"color": "#708090", "type": "bar"}}, }, } # ═══════════════════════════════════════════════════════════════════════════ # INFORMATIVE PAIRS # ═══════════════════════════════════════════════════════════════════════════ def informative_pairs(self): return [("BTC/USD", "30m")] # ═══════════════════════════════════════════════════════════════════════════ # PRIVATE HELPERS # ═══════════════════════════════════════════════════════════════════════════ @staticmethod def _ema(series: pd.Series, span: int) -> pd.Series: return series.ewm(span=span, adjust=False).mean() @staticmethod def _rsi(series: pd.Series, period: int = 14) -> pd.Series: delta = series.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) alpha = 1.0 / period avg_gain = gain.ewm(alpha=alpha, min_periods=period, adjust=False).mean() avg_loss = loss.ewm(alpha=alpha, min_periods=period, adjust=False).mean() rs = avg_gain / avg_loss.replace(0, np.nan) return (100 - (100 / (1 + rs))).fillna(50) @staticmethod def _macd(series: pd.Series, fast=12, slow=26, signal=9): ema_f = series.ewm(span=fast, adjust=False).mean() ema_s = series.ewm(span=slow, adjust=False).mean() macd_line = ema_f - ema_s sig_line = macd_line.ewm(span=signal, adjust=False).mean() return macd_line, sig_line, macd_line - sig_line # ═══════════════════════════════════════════════════════════════════════════ # POPULATE INDICATORS # ═══════════════════════════════════════════════════════════════════════════ def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # noqa: C901 # ── LAYER 3: 5m execution indicators ────────────────────────────────── dataframe["ema5_5m"] = self._ema(dataframe["close"], self.EMA_FAST_5M) dataframe["ema20_5m"] = self._ema(dataframe["close"], self.EMA_SLOW_5M) dataframe["rsi7_5m"] = self._rsi(dataframe["close"], 7) # Micro-breakout channel: highest high / lowest low over last 30m dataframe["exec_high"] = ( dataframe["high"].rolling(self.EXEC_BREAKOUT).max().shift(1) ) dataframe["exec_low"] = ( dataframe["low"].rolling(self.EXEC_BREAKOUT).min().shift(1) ) # Volume ratio (current 5m bar vs 2h rolling average) dataframe["vol_avg_5m"] = dataframe["volume"].rolling(24).mean() dataframe["vol_ratio_5m"] = ( dataframe["volume"] / dataframe["vol_avg_5m"].replace(0, np.nan) ).fillna(1.0) # VWAP approximation (rolling 20-bar typical price weighted by volume) typical = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3 roll_vol = dataframe["volume"].rolling(20).sum().replace(0, np.nan) dataframe["vwap_approx"] = ( (typical * dataframe["volume"]).rolling(20).sum() / roll_vol ) # MACD for momentum decay detection macd_line, sig_line, histogram = self._macd(dataframe["close"]) dataframe["macd_5m"] = macd_line dataframe["macd_sig_5m"] = sig_line dataframe["macd_hist_5m"] = histogram # Anti-chase: magnitude of last bar's price move dataframe["bar_return_5m"] = dataframe["close"].pct_change().abs() # ── LAYER 2: Momentum pre-screen (15m & 30m approximation) ──────────── # We approximate 15m signals using 3× rolling windows on 5m data. # This avoids needing extra informative pair declarations while keeping # the same economic meaning as true 15m candles. m15_bars = self.MOM_WINDOW_15M * 3 # 72 × 5m ≈ 6h brk15_bars = self.BREAKOUT_15M * 3 # 36 × 5m ≈ 3h volavg15_bars = self.VOL_AVG_15M * 3 # 60 × 5m ≈ 5h # 6h momentum (15m proxy) dataframe["momentum_15m"] = ( dataframe["close"] / dataframe["close"].shift(m15_bars) - 1 ) # 3h breakout high (15m proxy) dataframe["breakout_15m_high"] = ( dataframe["high"].rolling(brk15_bars).max().shift(1) ) # Volume expansion over last 3 bars vs 5h average (15m proxy) vol_avg_15m = dataframe["volume"].rolling(volavg15_bars).mean() dataframe["vol_15m_ratio"] = ( dataframe["volume"].rolling(3).sum() / (vol_avg_15m * 3).replace(0, np.nan) ).fillna(1.0) # 24h momentum (30m proxy) m30_bars = self.MOM_WINDOW_30M * 6 # 288 × 5m ≈ 24h dataframe["momentum_30m"] = ( dataframe["close"] / dataframe["close"].shift(m30_bars) - 1 ) # Layer 2 qualification flags (all must be True to pass pre-screen) dataframe["l2_momentum_ok"] = dataframe["momentum_15m"] > self.MOM_THRESHOLD dataframe["l2_vol_ok"] = dataframe["vol_15m_ratio"] > self.VOL_SURGE_15M dataframe["l2_trend_ok"] = dataframe["close"] > dataframe["ema20_5m"] dataframe["l2_breakout_ok"] = dataframe["close"] > dataframe["breakout_15m_high"] dataframe["l2_rsi_ok"] = ( (dataframe["rsi7_5m"] > self.RSI_SCREEN_MIN) & (dataframe["rsi7_5m"] < self.RSI_SCREEN_MAX) ) # Composite momentum score (0–10 range approximately) # Weights: 40% short-return, 30% 24h return, 15% vol expansion, 15% breakout dataframe["momentum_score"] = ( 0.40 * dataframe["momentum_15m"].clip(-0.20, 0.20) * 100 + 0.30 * dataframe["momentum_30m"].clip(-0.30, 0.30) * 100 + 0.15 * (dataframe["vol_15m_ratio"] - 1).clip(0, 5) + 0.15 * ( (dataframe["close"] / dataframe["breakout_15m_high"].replace(0, np.nan) - 1) .clip(0, 0.10) * 100 ).fillna(0) ) # ── LAYER 1: BTC regime (30m informative) ───────────────────────────── btc = self.dp.get_pair_dataframe(pair="BTC/USD", timeframe="30m") if not btc.empty: btc = btc.copy() btc["btc_ema_fast"] = self._ema(btc["close"], self.BTC_EMA_FAST) btc["btc_ema_slow"] = self._ema(btc["close"], self.BTC_EMA_SLOW) btc["btc_return_3h"] = btc["close"].pct_change(self.BTC_RETURN_BARS) # Regime scoring: # 2 = STRONG_BULL: above both EMAs, 3h return positive # 1 = WEAK_BULL : above slow EMA, 3h return > -1% # 0 = NEUTRAL : close to slow EMA (+/- 2%), 3h return > -2% # -1 = BEAR : below slow EMA or accelerating down conditions = [ ( (btc["close"] > btc["btc_ema_fast"]) & (btc["close"] > btc["btc_ema_slow"]) & (btc["btc_return_3h"] > 0.005) ), ( (btc["close"] > btc["btc_ema_slow"]) & (btc["btc_return_3h"] > -0.010) ), ( (btc["close"] > btc["btc_ema_slow"] * 0.98) & (btc["btc_return_3h"] > -0.020) ), ] btc["btc_regime"] = np.select(conditions, [2, 1, 0], default=-1) # Flag emergency: BTC 3h drop > 4% → force all exits btc["btc_emergency"] = btc["btc_return_3h"] < -0.04 btc_merge = btc[["date", "btc_regime", "btc_emergency"]].copy() dataframe = pd.merge_asof( dataframe, btc_merge, on="date", direction="backward", ) else: # BTC data unavailable: assume neutral (do not block all trading) dataframe["btc_regime"] = 0 dataframe["btc_emergency"] = False dataframe["btc_regime"] = dataframe["btc_regime"].fillna(0).astype(int) dataframe["btc_emergency"] = dataframe["btc_emergency"].fillna(False) return dataframe # ═══════════════════════════════════════════════════════════════════════════ # ENTRY TREND # ═══════════════════════════════════════════════════════════════════════════ def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # ── Layer 1 gate ─────────────────────────────────────────────────────── regime_ok = (dataframe["btc_regime"] >= 0) & (~dataframe["btc_emergency"]) # ── Layer 2 gate ─────────────────────────────────────────────────────── l2_ok = ( dataframe["l2_momentum_ok"] & dataframe["l2_vol_ok"] & dataframe["l2_trend_ok"] & dataframe["l2_rsi_ok"] ) # ── Layer 3a: Micro-breakout ─────────────────────────────────────────── # Price breaks above last 30m high with volume surge. micro_breakout = ( (dataframe["close"] > dataframe["exec_high"]) & (dataframe["vol_ratio_5m"] > self.EXEC_VOL_SURGE) & (dataframe["close"] > dataframe["ema5_5m"]) & (dataframe["rsi7_5m"] > self.RSI_EXEC_MIN) & (dataframe["rsi7_5m"] < self.RSI_EXEC_MAX) & (dataframe["bar_return_5m"] < self.ANTI_CHASE_MAX) ) # ── Layer 3b: Pullback continuation ─────────────────────────────────── # Coin already broke out on 15m; pulled back to EMA5/VWAP; now resumes. pullback_cont = ( dataframe["l2_breakout_ok"] & # 15m breakout confirmed (dataframe["close"] > dataframe["ema5_5m"]) & # recovered above EMA5 (dataframe["close"].shift(2) <= dataframe["ema5_5m"].shift(2)) & # was at/below EMA5 (dataframe["close"] > dataframe["close"].shift(1)) & # bar is up (dataframe["rsi7_5m"] > self.RSI_EXEC_MIN) & (dataframe["rsi7_5m"] < self.RSI_EXEC_MAX) & (dataframe["bar_return_5m"] < self.ANTI_CHASE_MAX) ) # ── Fee gate: minimum edge ───────────────────────────────────────────── fee_ok = dataframe["momentum_score"] > self.MIN_SCORE_GATE # ── Combined entry ───────────────────────────────────────────────────── entry = regime_ok & l2_ok & (micro_breakout | pullback_cont) & fee_ok dataframe.loc[entry, "enter_long"] = 1 # Tag entry type for performance attribution dataframe.loc[entry & micro_breakout, "enter_tag"] = "micro_breakout" dataframe.loc[entry & pullback_cont, "enter_tag"] = "pullback_cont" # If both fire, micro_breakout tag overrides (last write wins reversed) dataframe.loc[entry & micro_breakout, "enter_tag"] = "micro_breakout" return dataframe # ═══════════════════════════════════════════════════════════════════════════ # EXIT TREND # ═══════════════════════════════════════════════════════════════════════════ def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # ── Exit A: Regime emergency ─────────────────────────────────────────── regime_fail = dataframe["btc_emergency"] | (dataframe["btc_regime"] < 0) # ── Exit B: MACD momentum decay ──────────────────────────────────────── # Histogram crosses from positive to negative + volume fading macd_decay = ( (dataframe["macd_hist_5m"] < 0) & (dataframe["macd_hist_5m"].shift(1) >= 0) & (dataframe["vol_ratio_5m"] < 0.80) ) # ── Exit C: EMA cross-down ───────────────────────────────────────────── ema_crossdown = ( (dataframe["ema5_5m"] < dataframe["ema20_5m"]) & (dataframe["ema5_5m"].shift(1) >= dataframe["ema20_5m"].shift(1)) ) # ── Exit D: Price below VWAP + below EMA5 + volume fading ───────────── vwap_breakdown = ( (dataframe["close"] < dataframe["vwap_approx"]) & (dataframe["close"] < dataframe["ema5_5m"]) & (dataframe["vol_ratio_5m"] < 0.70) ) exit_signal = regime_fail | macd_decay | ema_crossdown dataframe.loc[exit_signal, "exit_long"] = 1 return dataframe # ═══════════════════════════════════════════════════════════════════════════ # CUSTOM STOPLOSS (trailing + time-based) # ═══════════════════════════════════════════════════════════════════════════ def custom_stoploss( self, pair: str, trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> float: """ Dynamic stop-loss logic: 1. Hard floor: -0.6% (covers round-trip fees with buffer) 2. Breakeven: once profit > +0.6%, move stop to near 0 3. Trailing: once profit > +0.6%, trail -0.8% from peak 4. Time stop: if held >60m with <0.3% profit, cut at -0.3% """ # Activate trailing stop after crossing breakeven if current_profit > self.BREAKEVEN_PCT: # Trail 0.8% below current profit peak return -self.TRAILING_STOP_PCT # Time stop: penalize stagnant trades to free capital trade_duration = ( current_time - trade.open_date_utc ).total_seconds() / 60.0 if trade_duration > self.TIME_STOP_MIN and current_profit < self.TIME_STOP_PROFIT: return -self.TIME_STOP_CUT # tighten to -0.3% return self.stoploss # default hard floor -0.6% # ═══════════════════════════════════════════════════════════════════════════ # CUSTOM EXIT (tiered take-profit) # ═══════════════════════════════════════════════════════════════════════════ def custom_exit( self, pair: str, trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ): """ Tiered take-profit with time-decay: +1.5% instant — strong breakout, lock in +0.9% after 45m hold — solid profit, reduce exposure +0.6% after 2h hold — fee-safe minimum, free capital Normal exits use maker (limit) orders. Emergency regime exits use taker (market) orders via exit_trend signal. """ trade_duration = ( current_time - trade.open_date_utc ).total_seconds() / 60.0 if current_profit >= 0.015: return "tp3_strong_1.5pct" # instant TP on big move if current_profit >= 0.009 and trade_duration >= 45: return "tp2_time_0.9pct" # take +0.9% after 45m if current_profit >= 0.006 and trade_duration >= 120: return "tp1_time_0.6pct" # take +0.6% after 2h (minimum) return None # ═══════════════════════════════════════════════════════════════════════════ # POSITION SIZING (scale-in 40% → +60% on confirmation) # ═══════════════════════════════════════════════════════════════════════════ def custom_stake_amount( self, current_time: datetime, current_rate: float, proposed_stake: float, min_stake: float | None, max_stake: float, leverage: float, entry_tag: str | None, side: str, ) -> float: """ Initial entry is 40% of the proposed stake. The remaining 60% is added via adjust_trade_position when the breakout is confirmed (floating profit > ADDON_PROFIT_MIN). """ return max(proposed_stake * self.INITIAL_STAKE_RATIO, min_stake or 0.0) def adjust_trade_position( self, trade, current_time: datetime, current_rate: float, current_profit: float, min_stake: float | None, max_stake: float, current_entry_rate: float, current_exit_rate: float, current_entry_profit: float, current_exit_profit: float, **kwargs, ) -> float | None: """ Add the remaining 60% stake when breakout is confirmed: - Only once (trade has exactly 1 entry fill so far) - Floating profit > +0.3% (direction confirmed) - Profit did not stall (we are not chasing a fading move) """ count_of_entries = trade.nr_of_successful_buys # Only add once if count_of_entries != 1: return None # Require confirmed positive momentum if current_profit < self.ADDON_PROFIT_MIN: return None # Add-on size = 1.5× initial so that total ≈ 40% + 60% of full budget addon = trade.stake_amount * 1.5 if min_stake and addon < min_stake: return None if addon > max_stake: return min(addon, max_stake) return addon