diff --git a/strategy.py b/strategy.py index b3772a9..30a27b2 100644 --- a/strategy.py +++ b/strategy.py @@ -1,259 +1,130 @@ -""" -Structure Flow Scalp — 震荡市剥头皮策略 -========================================== -基于Al Brooks价格行为学: -- 在已识别的震荡区间内,支撑位做多、阻力位做空 -- 15m级别支撑/阻力决定交易区间,5m级别入场 -- 100x全仓杠杆,每次10%仓位 -- 区间高度40%止盈,15m支撑/阻力外侧0.3%止损 +# structure_flow_momentum_scalp.py +# 顺趋势剥头皮策略 v2.0 +# +# 核心思路:不再在S/R处做反向交易接飞刀,而是顺趋势方向,等回调后入场。 +# +# ┌─────────────────────────────────────────────────────────────┐ +# │ 15m趋势方向判断(EMA20 vs EMA50) │ +# │ ↓ │ +# │ 上升趋势 → 只等5m回调到EMA20/支撑附近 → 止跌信号 → 做多 │ +# │ 下降趋势 → 只等5m反弹到EMA20/阻力附近 → 止涨信号 → 做空 │ +# │ ↓ │ +# │ 止损:ATR×1.0 | 止盈:ATR×1.5 | 时间止损:60分钟 │ +# └─────────────────────────────────────────────────────────────┘ +# +# v2.0 (2026-06-10): 初始版本,完全重写 -变更记录: - v1 (2026-06-10): 初版,基于v2.2b核心逻辑重构 - v1.1 (2026-06-10): 支撑阻力从4H改为15m - v1.2 (2026-06-10): 去掉4H趋势强度判断(冗余);启用100x全仓杠杆,10%仓位 - v1.3 (2026-06-10): 代码审查修复——移除populate_exit_trend死循环,NaN安全,杠杆上限 - v1.4 (2026-06-10): EMA动态S/R + 入场锁定S/R——止损止盈使用入场时的锁定值,不追最新 - v1.5 (2026-06-10): 扩展入场信号 + 追踪止损保护 + 延长活S/R窗口 - v1.6 (2026-06-10): 止损改为ATR动态计算——绑入场价,不绑支撑位;追踪改为ATR×0.5自适应 -""" - -from datetime import datetime -import numpy as np -import pandas as pd +from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, informative from pandas import DataFrame -from freqtrade.strategy import IStrategy, IntParameter, informative +import pandas as pd +import numpy as np +from datetime import datetime from freqtrade.persistence import Trade -class StructureFlowScalp(IStrategy): +class StructureFlowMomentumScalp(IStrategy): """ - 震荡市剥头皮策略 — 5m框架,100x全仓杠杆。 - 去掉4H趋势强度判断——15m支撑阻力本身就是最好的过滤器。 + 顺趋势剥头皮策略 v2.0 + + 核心逻辑: + - 15m EMA趋势方向过滤,只做顺趋势方向的单 + - 5m 回调到EMA20或S/R支撑/阻力区域时,等待K线信号确认后入场 + - 止损 ATR×1.0,止盈 ATR×1.5,时间止损 60 分钟 + - 不做方向猜测,不吃鱼头鱼尾,只吃回调结束那一小段 """ - can_short = True - stoploss = -0.15 - use_custom_stoploss = True - use_custom_exit = True - minimal_roi = {"0": 100} - max_open_trades = 1 + # ── 时间框架 ── timeframe = "5m" - # ===================== - # 杠杆设置 - 全仓 100x - # ===================== + # ── 交易参数 ── + can_short = True + max_open_trades = 1 + stake_amount = "unlimited" + use_custom_stoploss = True + use_exit_signal = False # 出场完全由 custom_stoploss + custom_exit 管理 - def leverage(self, pair: str, current_time: datetime, current_rate: float, - proposed_leverage: float, max_leverage: float, side: str, - **kwargs) -> float: - """返回固定 100x 杠杆,不超过交易所允许的最大值""" - return min(100.0, max_leverage) + # ── 合约参数 ── + margin_mode = "cross" + trading_mode = "futures" - # ===================== - # 工具:查找入场K线(锁定S/R用) - # ===================== - - def _get_entry_row(self, dataframe: DataFrame, trade: Trade) -> pd.Series | None: - """ - 从 dataframe 中找到入场 trade 对应的 K 线行。 - 兼容 live/dry_run(DatetimeIndex)和 backtesting(RangeIndex + date 列)两种模式。 - """ - if 'date' in dataframe.columns: - # Backtesting 模式:dataframe 有 date 列,index 是 int - entry_mask = pd.to_datetime(dataframe['date']) <= trade.open_date - if not entry_mask.any(): - return None - return dataframe[entry_mask].iloc[-1] - else: - # Live/Dry-run 模式:index 是 DatetimeIndex - try: - entry_idx = dataframe.index.get_indexer([trade.open_date], method="pad") - if entry_idx[0] < 0 or entry_idx[0] >= len(dataframe): - return None - return dataframe.iloc[entry_idx[0]] - except (TypeError, ValueError): - return None - - # ===================== - # 可优化参数 - # ===================== - - # 15m支撑阻力计算窗口 - swing_lookback_15m = IntParameter(5, 15, default=10, space="buy") - pin_bar_wick_ratio = IntParameter(50, 70, default=60, space="buy") + # ── 可优化参数 ── + # 趋势检测 + trend_ema_period = IntParameter(10, 30, default=20, space="buy") + # 回调确认幅度 + pullback_deviation = DecimalParameter(0.2, 1.0, default=0.5, decimals=1, space="buy") + # 入场冷却期 cooldown_bars = IntParameter(2, 8, default=3, space="buy") + # K线形态灵敏度 + pin_bar_wick_ratio = IntParameter(50, 80, default=60, space="buy") + # 止损ATR倍数 + atr_mult_stop = DecimalParameter(0.8, 2.0, default=1.0, decimals=1, space="sell") + # 止盈ATR倍数 + atr_mult_tp = DecimalParameter(1.0, 3.0, default=1.5, decimals=1, space="sell") - # 区间高度止盈比例(%) - profit_zone_pct = IntParameter(20, 60, default=40, space="buy") + # ── 常数 ── + time_stop_minutes = 60 # 最大持仓时间 - # ===================== - # 工具:Swing Point 检测 - # ===================== - - @staticmethod - def _detect_swing_points( - high: pd.Series, - low: pd.Series, - window: int = 5, - ) -> tuple[pd.Series, pd.Series]: - n = len(high) - sh = pd.Series(np.nan, index=high.index, dtype=float) - sl = pd.Series(np.nan, index=low.index, dtype=float) - - for i in range(window, n - window): - if high.iloc[i] > high.iloc[i - window : i].max() and high.iloc[i] > high.iloc[i + 1 : i + window + 1].max(): - sh.iloc[i] = high.iloc[i] - if low.iloc[i] < low.iloc[i - window : i].min() and low.iloc[i] < low.iloc[i + 1 : i + window + 1].min(): - sl.iloc[i] = low.iloc[i] - - return sh, sl - - # ===================== - # 工具:结构分析 - # ===================== - - def _build_structure( - self, - high: pd.Series, - low: pd.Series, - close: pd.Series, - swing_high: pd.Series, - swing_low: pd.Series, - ) -> DataFrame: - n = len(high) - - trend_up_arr = np.full(n, False) - trend_down_arr = np.full(n, False) - nearest_support = np.full(n, np.nan) - nearest_resistance = np.full(n, np.nan) - - sh_prices = [] - sl_prices = [] - - for i in range(n): - if pd.notna(swing_high.iloc[i]): - sh_prices.append(swing_high.iloc[i]) - if len(sh_prices) > 4: - sh_prices.pop(0) - - if pd.notna(swing_low.iloc[i]): - sl_prices.append(swing_low.iloc[i]) - if len(sl_prices) > 4: - sl_prices.pop(0) - - if len(sh_prices) >= 2 and len(sl_prices) >= 2: - if sh_prices[-1] > sh_prices[-2] and sl_prices[-1] > sl_prices[-2]: - trend_up_arr[i] = True - elif sh_prices[-1] < sh_prices[-2] and sl_prices[-1] < sl_prices[-2]: - trend_down_arr[i] = True - elif i > 0: - trend_up_arr[i] = trend_up_arr[i - 1] - trend_down_arr[i] = trend_down_arr[i - 1] - elif i > 0: - trend_up_arr[i] = trend_up_arr[i - 1] - trend_down_arr[i] = trend_down_arr[i - 1] - - if sl_prices: - # EMA平滑:不取最后一个,而是对最近swing lows做指数加权 - # alpha=0.3,每个新swing point向它移动30%,有"惯性"不跳变 - ema_s = sl_prices[0] - for p in sl_prices[1:]: - ema_s = 0.3 * p + 0.7 * ema_s - nearest_support[i] = ema_s - if sh_prices: - ema_r = sh_prices[0] - for p in sh_prices[1:]: - ema_r = 0.3 * p + 0.7 * ema_r - nearest_resistance[i] = ema_r - - return DataFrame({ - "trend_up": trend_up_arr, - "trend_down": trend_down_arr, - "support": nearest_support, - "resistance": nearest_resistance, - }, index=high.index) - - # ===================== - # 工具:K线形态检测 - # ===================== - - @staticmethod - def _detect_candle_patterns( - open_: pd.Series, - high: pd.Series, - low: pd.Series, - close: pd.Series, - pin_bar_wick_ratio: float = 0.6, - ) -> tuple[pd.Series, pd.Series, pd.Series, pd.Series]: - body = (close - open_).abs() - total_range = (high - low).replace(0, 0.0001) - - upper_wick = high - close.where(close > open_, open_) - lower_wick = open_.where(close > open_, close) - low - is_pin = (upper_wick + lower_wick) / total_range > pin_bar_wick_ratio - - bullish_pin = is_pin & (close > open_) & (lower_wick > upper_wick) - bearish_pin = is_pin & (close < open_) & (upper_wick > lower_wick) - - prev_open = open_.shift(1) - prev_close = close.shift(1) - bullish_engulf = (close > prev_open) & (open_ < prev_close) & (close > open_) - bearish_engulf = (close < prev_open) & (open_ > prev_close) & (close < open_) - - return bullish_pin, bearish_pin, bullish_engulf, bearish_engulf + # ── 保护性止损 ── + stoploss = -0.10 # 硬止损 10% # ================================================================ - # 信息时间框架 — 15m 短期支撑阻力(核心过滤器) + # 杠杆 + # ================================================================ + + def leverage( + self, pair: str, current_time: datetime, current_rate: float, + proposed_leverage: float, max_leverage: float, side: str, + **kwargs, + ) -> float: + """20x 杠杆起步,验证胜率后再上量""" + return min(20.0, max_leverage) + + # ================================================================ + # 信息时间框架 — 15m 趋势判断 + S/R # ================================================================ @informative("15m") def populate_indicators_15m( self, dataframe: DataFrame, metadata: dict ) -> DataFrame: - sh, sl = self._detect_swing_points( - dataframe["high"], dataframe["low"], - self.swing_lookback_15m.value, - ) - structure = self._build_structure( - dataframe["high"], dataframe["low"], dataframe["close"], - sh, sl, - ) - dataframe["support"] = structure["support"] - dataframe["resistance"] = structure["resistance"] + """15m级别:EMA趋势方向 + swing point S/R。""" - # ── 活支撑检查(15根15m ≈ 3.75小时,震荡市中支撑可长期有效)── - touched_support = ( - (dataframe["low"] <= dataframe["support"] * 1.005) & - (dataframe["low"] >= dataframe["support"] * 0.995) - ) - held_support = dataframe["close"] > dataframe["support"] - support_tested_and_held = touched_support & held_support - dataframe["support_alive"] = support_tested_and_held.rolling(15, min_periods=1).max() > 0 + # ── EMA 趋势方向 ── + ema_period = self.trend_ema_period.value + dataframe["ema_fast"] = dataframe["close"].ewm(span=ema_period, adjust=False).mean() + dataframe["ema_slow"] = dataframe["close"].ewm(span=ema_period * 2.5, adjust=False).mean() - # ── 活阻力检查(15根窗口)── - touched_resistance = ( - (dataframe["high"] >= dataframe["resistance"] * 0.995) & - (dataframe["high"] <= dataframe["resistance"] * 1.005) - ) - held_resistance = dataframe["close"] < dataframe["resistance"] - resistance_tested_and_held = touched_resistance & held_resistance - dataframe["resistance_alive"] = resistance_tested_and_held.rolling(15, min_periods=1).max() > 0 + dataframe["trend_up"] = dataframe["ema_fast"] > dataframe["ema_slow"] + dataframe["trend_down"] = dataframe["ema_fast"] < dataframe["ema_slow"] - # 区间高度(用于止盈计算) - dataframe["zone_height"] = (dataframe["resistance"] - dataframe["support"]).fillna(0) + # ── Swing Point 支撑/阻力 ── + high = dataframe["high"].tolist() + low = dataframe["low"].tolist() + close = dataframe["close"].tolist() + + sh, sl = self._detect_swing_points(high, low, window=5) + trend_up_arr, trend_down_arr, support_arr, resistance_arr = self._build_structure( + high, low, close, sh, sl, + ) + + dataframe["trend_up_sp"] = trend_up_arr + dataframe["trend_down_sp"] = trend_down_arr + # EMA平滑S/R(避免跳变) + dataframe["support"] = self._ema_smooth(support_arr, alpha=0.3) + dataframe["resistance"] = self._ema_smooth(resistance_arr, alpha=0.3) return dataframe # ================================================================ - # 主时间框架 — 5m 指标 + # 主框架 — 5m 级别指标 # ================================================================ def populate_indicators( self, dataframe: DataFrame, metadata: dict ) -> DataFrame: - """5m级别:ATR + K线形态 + 信号整合。""" + """5m级别:ATR + K线形态 + EMA趋势整合。""" - # ── ATR(14) — 用于动态止损,根据市场波动自适应 ── + # ── ATR(14) ── high = dataframe["high"] low = dataframe["low"] close = dataframe["close"] @@ -264,170 +135,136 @@ class StructureFlowScalp(IStrategy): (low - prev_close).abs(), ], axis=1).max(axis=1) dataframe["atr"] = tr.rolling(14).mean() + atr_mean = dataframe["atr"].mean() + dataframe["atr"] = dataframe["atr"].fillna(atr_mean) + # ── K线形态 ── bullish_pin, bearish_pin, bullish_engulf, bearish_engulf = ( self._detect_candle_patterns( - dataframe["open"], - dataframe["high"], - dataframe["low"], - dataframe["close"], - self.pin_bar_wick_ratio.value / 100.0, + dataframe["open"], dataframe["high"], dataframe["low"], + dataframe["close"], self.pin_bar_wick_ratio.value / 100.0, ) ) - dataframe["bullish_pinbar"] = bullish_pin - dataframe["bearish_pinbar"] = bearish_pin - dataframe["bullish_engulfing"] = bullish_engulf - dataframe["bearish_engulfing"] = bearish_engulf + dataframe["bullish_signal"] = bullish_pin | bullish_engulf + dataframe["bearish_signal"] = bearish_pin | bearish_engulf - # ── 扩展信号:长下影线(比pinbar更宽松,只要下影线>总范围50%) ── - total_range = (dataframe["high"] - dataframe["low"]).replace(0, 0.0001) - body = (dataframe["close"] - dataframe["open"]).abs() - # 下影线 = min(open, close) - low - lower_wick = ( - dataframe[["open", "close"]].min(axis=1) - dataframe["low"] - ) - # 上影线 = high - max(open, close) - upper_wick = ( - dataframe["high"] - dataframe[["open", "close"]].max(axis=1) - ) - # 长下影线:下影线>总范围50% 且 下影线>上影线 - long_lower_wick = ( - (lower_wick / total_range > 0.5) & - (lower_wick > upper_wick) - ) - dataframe["long_lower_wick"] = long_lower_wick + # ── 5m EMA(用于短期拉回确认) ── + dataframe["ema5"] = close.ewm(span=5, adjust=False).mean() + dataframe["ema8"] = close.ewm(span=8, adjust=False).mean() - # ── 扩展信号:支撑位附近的强力反弹阳线 ── - # 条件:价格在支撑0.5%范围内 + 阳线 + 实体>0.2% - if "support_15m" in dataframe.columns: - near_support = ( - (dataframe["low"] <= dataframe["support_15m"] * 1.005) & - (dataframe["low"] >= dataframe["support_15m"] * 0.995) - ) - is_bullish = dataframe["close"] > dataframe["open"] - body_pct = body / dataframe["open"] - strong_recovery = near_support & is_bullish & (body_pct > 0.002) - else: - strong_recovery = pd.Series(False, index=dataframe.index) - dataframe["strong_recovery"] = strong_recovery + # ── 布尔列NaN填充 ── + for col in ["bullish_signal", "bearish_signal"]: + dataframe[col] = dataframe[col].fillna(False) - # ── 综合止跌/止涨信号(扩展后) ── - dataframe["bullish_signal"] = ( - bullish_pin | bullish_engulf | long_lower_wick | strong_recovery - ) - dataframe["bearish_signal"] = ( - bearish_pin | bearish_engulf - ) - # 做空对称:阻力位附近的强力下跌阴线 - if "resistance_15m" in dataframe.columns: - near_resistance = ( - (dataframe["high"] >= dataframe["resistance_15m"] * 0.995) & - (dataframe["high"] <= dataframe["resistance_15m"] * 1.005) - ) - is_bearish = dataframe["close"] < dataframe["open"] - body_pct = body / dataframe["open"] - strong_rejection = near_resistance & is_bearish & (body_pct > 0.002) - else: - strong_rejection = pd.Series(False, index=dataframe.index) - dataframe["strong_rejection"] = strong_rejection - dataframe["bearish_signal"] = ( - bearish_pin | bearish_engulf | strong_rejection - ) + return dataframe - # NaN 安全处理 - bool_cols = [ - "support_alive_15m", "resistance_alive_15m", - "bullish_signal", "bearish_signal", + # ================================================================ + # 入场逻辑 + # ================================================================ + + def populate_entry_trend( + self, dataframe: DataFrame, metadata: dict + ) -> DataFrame: + """ + 入场逻辑。 + + 只做顺趋势回调入场,不做S/R反向交易: + + 做多条件: + 1. 15m 上升趋势(EMA_fast > EMA_slow) + 2. 5m 价格回调到15m EMA_fast 或 支撑位附近 + 3. 5m K线止跌信号(pinbar/engulfing) + + 做空条件(对称): + 1. 15m 下降趋势 + 2. 5m 价格反弹到15m EMA_fast 或 阻力位附近 + 3. 5m K线止涨信号 + """ + cooldown = self.cooldown_bars.value + dev = self.pullback_deviation.value / 100.0 # 0.5% → 0.005 + + # ── 必要列检查 ── + required = [ + "ema_fast_15m", "trend_up_15m", "trend_down_15m", + "support_15m", "resistance_15m", ] - for col in bool_cols: + for col in required: + if col not in dataframe.columns: + return dataframe + + # ── 布尔列填充 ── + for col in [ + "bullish_signal", "bearish_signal", + "trend_up_15m", "trend_down_15m", + ]: if col in dataframe.columns: dataframe[col] = dataframe[col].fillna(False) - # ATR fillna(前14根无ATR值用均值填补) - if "atr" in dataframe.columns: - atr_mean = dataframe["atr"].mean() - dataframe["atr"] = dataframe["atr"].fillna(atr_mean) + # ═══════════════════════════════════════════════════════════ + # 做多:上升趋势 + 回调到EMA/支撑 + 止跌信号 + # ═══════════════════════════════════════════════════════════ - return dataframe + # 条件1:15m 上升趋势 + trend_up = dataframe["trend_up_15m"] - # ===================== - # 入场信号 - # ===================== - - def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - """ - 入场逻辑(5m 时间框架)。 - - 不做4H趋势判断——15m支撑阻力本身就是过滤器: - - 趋势强时价格直接突破15m S/R,不会在支撑/阻力附近停留 - - 在支撑/阻力附近停留 = 震荡市 - - 入场条件(3个,去掉了冗余的4H趋势判断): - - 做多:价格贴近15m支撑 + 支撑有效 + K线止跌信号 - - 做空:价格贴近15m阻力 + 阻力有效 + K线止涨信号 - - 出场只依赖 custom_stoploss 和 custom_exit,不需要 D1 结构反转退出。 - (去掉 populate_exit_trend:震荡市入场 → D1 非上升趋势 → 立即出场 的死循环) - """ - cooldown = self.cooldown_bars.value - - # NaN 安全处理 — 如果 15m informative 列还没对齐,直接跳过本根 K 线 - required_cols = ["support_15m", "resistance_15m", - "support_alive_15m", "resistance_alive_15m"] - for col in required_cols: - if col not in dataframe.columns: - return dataframe # 数据尚未就绪,跳过 - - for col in ["bullish_signal", "bearish_signal", - "support_alive_15m", "resistance_alive_15m"]: - dataframe[col] = dataframe[col].fillna(False) - - # ── 做多 ── - # 条件:价格贴近15m支撑(0.5%范围内)- 使用 low 而非 open - # 因为支撑测试看的是价格是否到达支撑位,不是开盘在哪 + # 条件2:价格在EMA20或支撑位附近(回调到顺趋势的支撑区) + near_ema = ( + (dataframe["low"] <= dataframe["ema_fast_15m"] * (1.0 + dev * 0.5)) & + (dataframe["low"] >= dataframe["ema_fast_15m"] * (1.0 - dev * 2.0)) + ) near_support = ( - (dataframe["low"] <= dataframe["support_15m"] * 1.005) & - (dataframe["low"] >= dataframe["support_15m"] * 0.995) + (dataframe["low"] <= dataframe["support_15m"] * (1.0 + dev)) & + (dataframe["low"] >= dataframe["support_15m"] * (1.0 - dev)) ) + pullback_long = near_ema | near_support - long_conditions = ( - near_support - & dataframe["support_alive_15m"] - & dataframe["bullish_signal"] + # 条件3:K线止跌信号 + signal_long = dataframe["bullish_signal"] + + # 综合入场 + enter_long = trend_up & pullback_long & signal_long + long_recent = enter_long.rolling(cooldown, min_periods=1).max().shift(1) == 0 + dataframe.loc[enter_long & long_recent, "enter_long"] = 1 + + # ═══════════════════════════════════════════════════════════ + # 做空:下降趋势 + 反弹到EMA/阻力 + 止涨信号 + # ═══════════════════════════════════════════════════════════ + + # 条件1:15m 下降趋势 + trend_down = dataframe["trend_down_15m"] + + # 条件2:价格在EMA20或阻力位附近(反弹到顺趋势的阻力区) + near_ema_short = ( + (dataframe["high"] >= dataframe["ema_fast_15m"] * (1.0 - dev * 0.5)) & + (dataframe["high"] <= dataframe["ema_fast_15m"] * (1.0 + dev * 2.0)) ) - - long_recent = long_conditions.rolling(cooldown, min_periods=1).max().shift(1) == 0 - dataframe.loc[long_conditions & long_recent, "enter_long"] = 1 - - # ── 做空 ── - # 条件:价格贴近15m阻力(0.5%范围内)- 使用 high 而非 open near_resistance = ( - (dataframe["high"] >= dataframe["resistance_15m"] * 0.995) & - (dataframe["high"] <= dataframe["resistance_15m"] * 1.005) + (dataframe["high"] >= dataframe["resistance_15m"] * (1.0 - dev)) & + (dataframe["high"] <= dataframe["resistance_15m"] * (1.0 + dev)) ) + pullback_short = near_ema_short | near_resistance - short_conditions = ( - near_resistance - & dataframe["resistance_alive_15m"] - & dataframe["bearish_signal"] - ) + # 条件3:K线止涨信号 + signal_short = dataframe["bearish_signal"] - short_recent = short_conditions.rolling(cooldown, min_periods=1).max().shift(1) == 0 - dataframe.loc[short_conditions & short_recent, "enter_short"] = 1 + # 综合入场 + enter_short = trend_down & pullback_short & signal_short + short_recent = enter_short.rolling(cooldown, min_periods=1).max().shift(1) == 0 + dataframe.loc[enter_short & short_recent, "enter_short"] = 1 return dataframe - # ===================== - # exit_trend(freqtrade 2025.11 要求必须实现,即使 use_custom_exit=True) - # ===================== + # ================================================================ + # exit_trend(freqtrade 2025.11 强制要求,即使 use_exit_signal=False) + # ================================================================ def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - """退出逻辑完全由 custom_stoploss + custom_exit 管理。""" + """出场完全由 custom_stoploss + custom_exit 管理。""" return dataframe - # ===================== - # 动态止损 — 入场价 - ATR×2.0(基于市场波动,非固定比例) - # ===================== + # ================================================================ + # 出场 — 止损(ATR动态) + # ================================================================ def custom_stoploss( self, @@ -440,87 +277,39 @@ class StructureFlowScalp(IStrategy): **kwargs, ) -> float: """ - 止损锚定入场价,宽度根据市场波动(ATR)动态计算,而非固定比例。 + 止损 = 入场价 ± ATR × atr_mult_stop - 核心逻辑: - - 做多止损 = entry_price - ATR_5m × 2.0 - - 做空止损 = entry_price + ATR_5m × 2.0 - - ATR值从入场时的K线锁定,持仓期间不漂移 - - 为什么用ATR不用固定比例: - - ATR自动适应市场:波动大时止损放宽免误扫,波动小时收紧控风险 - - 固定比例是拍脑袋,ATR是算出来的 - - 追踪保护(v1.6 ATR自适应版): - - 利润达止盈目标50%:上移到保本(入场价) - - 利润达止盈目标80%:启动ATR×0.5窄追踪 + - ATR值从入场K线锁定,持仓期间不变 + - 做多:entry_price - (locked_atr × mult) + - 做空:entry_price + (locked_atr × mult) + - 配20x杠杆,ATR×1.0 ≈ 对应约 $3.7 止损(当前5m ATR~$3.74) """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or len(dataframe) == 0: return -0.02 if not trade.is_short else 0.02 - # 查找入场时的 K 线,锁定当时的 ATR 值 entry_row = self._get_entry_row(dataframe, trade) if entry_row is None: return -0.02 if not trade.is_short else 0.02 - # 锁定入场时的 ATR 值,用于全程止损/追踪计算(不追最新,防止漂移) - atr_value = entry_row.get("atr", np.nan) - if pd.isna(atr_value) or atr_value <= 0: + atr = entry_row.get("atr", np.nan) + if pd.isna(atr) or atr <= 0: return -0.02 if not trade.is_short else 0.02 + mult = self.atr_mult_stop.value + if not trade.is_short: - # 做多:止损 = 入场价 - ATR × 2.0 - base_sl_price = trade.open_rate - (atr_value * 2.0) - base_sl = (base_sl_price / trade.open_rate) - 1.0 - base_sl = max(base_sl, -0.15) - - # 追踪保护:需要入场行计算止盈目标 - support = entry_row.get("support_15m", np.nan) - resistance = entry_row.get("resistance_15m", np.nan) - if (not pd.isna(support) and not pd.isna(resistance) - and resistance > support and current_profit > 0): - zone_height = resistance - support - tp_target = (zone_height * self.profit_zone_pct.value / 100.0) / trade.open_rate - - if current_profit >= tp_target * 0.8: - # 利润达止盈80%:ATR自适应窄追踪 - trail_price = current_rate - (atr_value * 0.5) - trail_ratio = (trail_price / trade.open_rate) - 1.0 - return max(trail_ratio, base_sl) - elif current_profit >= tp_target * 0.5: - # 利润达止盈50%:保本 - return max(0.0, base_sl) - - return base_sl + sl_price = trade.open_rate - (atr * mult) + sl_ratio = (sl_price / trade.open_rate) - 1.0 + return max(sl_ratio, -self.stoploss) else: - # 做空:止损 = 入场价 + ATR × 2.0 - base_sl_price = trade.open_rate + (atr_value * 2.0) - base_sl = 1.0 - (base_sl_price / trade.open_rate) - base_sl = min(base_sl, 0.15) + sl_price = trade.open_rate + (atr * mult) + sl_ratio = 1.0 - (sl_price / trade.open_rate) + return min(sl_ratio, self.stoploss) - # 追踪保护(做空对称) - support = entry_row.get("support_15m", np.nan) - resistance = entry_row.get("resistance_15m", np.nan) - if (not pd.isna(support) and not pd.isna(resistance) - and resistance > support and current_profit > 0): - zone_height = resistance - support - tp_target = (zone_height * self.profit_zone_pct.value / 100.0) / trade.open_rate - - if current_profit >= tp_target * 0.8: - # ATR自适应窄追踪(做空对称) - trail_price = current_rate + (atr_value * 0.5) - trail_ratio = (trail_price / trade.open_rate) - 1.0 - return min(trail_ratio, base_sl) - elif current_profit >= tp_target * 0.5: - # 保本 - return min(0.0, base_sl) - - return base_sl - - # ===================== - # 区间高度止盈 - # ===================== + # ================================================================ + # 出场 — 止盈(ATR动态)+ 时间止损 + # ================================================================ def custom_exit( self, @@ -532,58 +321,195 @@ class StructureFlowScalp(IStrategy): **kwargs, ) -> str | None: """ - 当利润达到入场时锁定的15m区间高度的设定比例时止盈。 - - 使用入场时锁定的S/R值计算区间高度(zone_height),而非最新的值: - - 入场后如果区间收缩,止盈目标不会跟着变小 - - 让入场时确定的止盈逻辑"钉死" - - profit_zone_pct 默认40%,即锁定区间高度的40% + 出场逻辑: + 1. ATR止盈:利润达到入场时锁定的 ATR × atr_mult_tp → 止盈 + 2. 时间止损:持仓超过 time_stop_minutes → 强制出场 """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or len(dataframe) == 0: return None - # 查找入场时的 K 线,锁定当时的 S/R 值 entry_row = self._get_entry_row(dataframe, trade) if entry_row is None: return None - support = entry_row.get("support_15m", np.nan) - resistance = entry_row.get("resistance_15m", np.nan) - - if pd.isna(support) or pd.isna(resistance) or resistance <= support: + atr = entry_row.get("atr", np.nan) + if pd.isna(atr) or atr <= 0: return None - # 用锁定的区间高度计算止盈目标(不随市场漂移) - locked_zone_height = resistance - support - target_pct = (locked_zone_height * self.profit_zone_pct.value / 100.0) / trade.open_rate + # 1. ATR 止盈 + tp_mult = self.atr_mult_tp.value + tp_ratio = (atr * tp_mult) / trade.open_rate - if current_profit >= target_pct: - return "zone_tp" + if current_profit >= tp_ratio: + return "atr_tp" + + # 2. 时间止损 + elapsed = (current_time - trade.open_date).total_seconds() / 60.0 + if elapsed >= self.time_stop_minutes: + return "time_stop" return None - # ===================== - # Plot config - # ===================== + # ================================================================ + # 工具函数 + # ================================================================ - @staticmethod - def plot_config() -> dict: - return { - "main_plot": { - "support_15m": {"color": "green", "type": "line"}, - "resistance_15m": {"color": "red", "type": "line"}, - }, - "subplots": { - "signals": { - "bullish_pinbar": {"color": "green", "type": "scatter"}, - "bearish_pinbar": {"color": "red", "type": "scatter"}, - "bullish_signal": {"color": "lime", "type": "scatter"}, - "bearish_signal": {"color": "orange", "type": "scatter"}, - }, - "filters": { - "support_alive_15m": {"color": "green", "type": "line"}, - "resistance_alive_15m": {"color": "red", "type": "line"}, - }, - }, - } + def _detect_swing_points( + self, highs: list, lows: list, window: int = 5 + ): + """ + Swing High / Swing Low 检测。 + + 当一根K线的最高价高于其两侧window根K线的最高价时,标记为Swing High。 + Swing Low同理。 + """ + n = len(highs) + swing_high = [np.nan] * n + swing_low = [np.nan] * n + + for i in range(window, n - window): + # Swing High + is_high = True + for j in range(i - window, i + window + 1): + if j == i: + continue + if highs[j] >= highs[i]: + is_high = False + break + if is_high: + swing_high[i] = highs[i] + + # Swing Low + is_low = True + for j in range(i - window, i + window + 1): + if j == i: + continue + if lows[j] <= lows[i]: + is_low = False + break + if is_low: + swing_low[i] = lows[i] + + return swing_high, swing_low + + def _build_structure( + self, highs: list, lows: list, closes: list, + swing_high: list, swing_low: list, + ): + """构建趋势结构和支撑/阻力位。""" + n = len(highs) + trend_up = [False] * n + trend_down = [False] * n + support = [np.nan] * n + resistance = [np.nan] * n + + # 用最近4个swing point的位置判断 + last_sh_idx = -1 + last_sl_idx = -1 + prev_sh = [] + prev_sl = [] + + for i in range(n): + if not np.isnan(swing_high[i]): + prev_sh.append(swing_high[i]) + last_sh_idx = i + if len(prev_sh) > 4: + prev_sh.pop(0) + + if not np.isnan(swing_low[i]): + prev_sl.append(swing_low[i]) + last_sl_idx = i + if len(prev_sl) > 4: + prev_sl.pop(0) + + # 趋势判断:最新的HH > 次新的HH = 上升趋势中的higher high + if len(prev_sh) >= 2 and prev_sh[-1] > prev_sh[-2]: + trend_up[i] = True + + # 趋势判断:最新的LL < 次新的LL = 下降趋势中的lower low + if len(prev_sl) >= 2 and prev_sl[-1] < prev_sl[-2]: + trend_down[i] = True + + # 支撑 = 最近的有效Swing Low(EMA平滑后在调用侧处理) + if prev_sl: + support[i] = prev_sl[-1] + if prev_sh: + resistance[i] = prev_sh[-1] + + return trend_up, trend_down, support, resistance + + def _ema_smooth(self, values: list, alpha: float = 0.3): + """对数组做EMA平滑,避免跳变。""" + result = [np.nan] * len(values) + ema = None + for i, v in enumerate(values): + if pd.isna(v) or v is None: + if ema is not None: + result[i] = ema + continue + if ema is None: + ema = v + else: + ema = alpha * v + (1 - alpha) * ema + result[i] = ema + return np.array(result) + + def _detect_candle_patterns( + self, opens, highs, lows, closes, wick_ratio=0.6, + ): + """检测K线形态:pinbar(锤子线/射击星)和吞没形态。""" + n = len(opens) + bullish_pin = [False] * n + bearish_pin = [False] * n + bullish_engulf = [False] * n + bearish_engulf = [False] * n + + for i in range(n): + o, h, l, c = opens[i], highs[i], lows[i], closes[i] + total_range = h - l if h > l else 0.001 + + is_bullish = c > o + is_bearish = c < o + + body = abs(c - o) + upper_wick = h - max(c, o) + lower_wick = min(c, o) - l + + # Pinbar:影线 > total_range × wick_ratio + if is_bullish and lower_wick / total_range > wick_ratio: + bullish_pin[i] = True + if is_bearish and upper_wick / total_range > wick_ratio: + bearish_pin[i] = True + + # 吞没形态 + if i > 0: + prev_o = opens[i - 1] + prev_c = closes[i - 1] + if is_bullish and c > prev_o and o < prev_c: + bullish_engulf[i] = True + if is_bearish and c < prev_o and o > prev_c: + bearish_engulf[i] = True + + return ( + pd.Series(bullish_pin), + pd.Series(bearish_pin), + pd.Series(bullish_engulf), + pd.Series(bearish_engulf), + ) + + def _get_entry_row(self, dataframe: DataFrame, trade: Trade): + """查找入场K线行,兼容live/backtesting两种模式。""" + if "date" in dataframe.columns: + entry_mask = pd.to_datetime(dataframe["date"]) <= trade.open_date + if not entry_mask.any(): + return None + return dataframe[entry_mask].iloc[-1] + else: + try: + idx = dataframe.index.get_indexer([trade.open_date], method="pad") + if idx[0] < 0 or idx[0] >= len(dataframe): + return None + return dataframe.iloc[idx[0]] + except (TypeError, ValueError): + return None