Fee-Optimized Momentum Strategy with 5-minute Precision Execution.
Timeframe
5m
Direction
Long Only
Stoploss
-0.6%
Trailing Stop
No
ROI
0m: 1.5%, 30m: 1.2%, 60m: 0.9%, 120m: 0.6%
Interface Version
N/A
Startup Candles
N/A
Indicators
4
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
"""
================================================================================
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