v4.1 (Swing): 入场时机优化 + 止损逻辑调整

This commit is contained in:
2026-06-10 23:12:00 +08:00
parent 13616c1cd2
commit 23ed71649d

View File

@ -1,22 +1,10 @@
"""
Structure Flow Swing Strategy v4.0
Structure Flow Swing Strategy v4.1
==================================
15m 震荡区间波段策略 — 基于价格聚集度检测
15m 震荡波段 — 基于"碰壁验证"价格聚集度检测(短窗口版)
核心变革(相对于 v3.x
1. 时间框架从 4H → 15m直接在小周期检测和执行
2. 震荡判定从 "swing points 宽度稳定性""价格聚集度 + 边界测试次数"
3. 检测周期 4-8 小时即可识别震荡,覆盖 1-3 天的 mini-震荡
v3.1 诊断回顾2026-06-10 全周期回测):
- 122笔全部做空+76%CAGR 10.97%
- is_ranging 仅 13.7%,用 4H 判定只抓到大周期震荡
- 1-3 天的小震荡完全被漏掉,这才是手工交易的利润来源
版本历史:
v3.0 (2026-06-10): 初版4H swing points + 双边测试
v3.1 (2026-06-10): AND→OR降门槛
v4.0 (2026-06-10): 全面重写15m 价格聚集度检测
v4.1b (2026-06-10): 1H + 3K线验证正收益但频率太低
v4.1c (2026-06-10): 回到15m + 缩短lookback至24根(6h) + 保留min_rejections=2
"""
from datetime import datetime
@ -27,10 +15,10 @@ from freqtrade.strategy import IStrategy, IntParameter
from freqtrade.persistence import Trade
class StructureFlowSwingV40(IStrategy):
class StructureFlowSwingV41(IStrategy):
"""
Structure Flow Swing Strategy v4.0
15m 震荡区间波段交易 — 价格聚集度检测
Structure Flow Swing Strategy v4.1
15m 震荡区间波段交易 — 碰壁验证(短窗口)
"""
can_short = True
@ -43,16 +31,16 @@ class StructureFlowSwingV40(IStrategy):
# =====================
# 可优化参数
# =====================
lookback = IntParameter(24, 96, default=48, space="buy") # 检测窗口:24~96根15m6h~24h
min_touches = IntParameter(1, 4, default=2, space="buy") # 边界至少测试次数
zone_width_atr_mult = IntParameter(2, 6, default=4, space="buy") # 区间宽度上限 = ATR × N
entry_zone_pct = IntParameter(2, 8, default=5, space="buy") # 入场范围距边界千分比0.5%
atr_stop_mult = IntParameter(10, 25, default=15, space="buy") # ATR止损倍数
take_profit_pct = IntParameter(50, 80, default=70, space="sell") # 区间高度止盈比例
lookback = IntParameter(8, 48, default=12, space="buy") # 检测窗口:12根15m = 3h
min_rejections = IntParameter(1, 4, default=1, space="buy") # 碰壁验证1次即可
rejection_window = IntParameter(1, 6, default=3, space="buy") # 碰壁验证窗口
zone_width_atr_mult = IntParameter(2, 6, default=4, space="buy")
entry_zone_pct = IntParameter(2, 8, default=5, space="buy")
take_profit_pct = IntParameter(50, 80, default=70, space="sell")
# 固定参数
breakout_bars = 2 # 连续几根K线突破才算真突破
cooldown = 4 # 入场后冷却 4 根15m1小时
breakout_bars = 2
cooldown = 2 # 冷却 2 根15m30分钟
# =====================
# 工具ATR计算
@ -73,43 +61,52 @@ class StructureFlowSwingV40(IStrategy):
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
lookback = self.lookback.value
rej_threshold = 0.005 # 边界 0.5% 范围内算"碰边"
# ── 价格聚集度检测 ──
# ── 价格聚集范围 ──
rolling_high = dataframe["high"].rolling(lookback).max()
rolling_low = dataframe["low"].rolling(lookback).min()
# 区间宽度(绝对值和百分比)
zone_width = rolling_high - rolling_low
zone_width_pct = zone_width / rolling_low
zone_width_raw = rolling_high - rolling_low
dataframe["zone_high"] = rolling_high
dataframe["zone_low"] = rolling_low
dataframe["zone_width_raw"] = zone_width
dataframe["zone_width_pct"] = zone_width_pct
dataframe["zone_width_raw"] = zone_width_raw
dataframe["zone_width_pct"] = zone_width_raw / rolling_low
# ATR
dataframe["atr"] = self._calc_atr(dataframe["high"], dataframe["low"], dataframe["close"], 14)
# ── 边界测试计数 ──
# 价格在区间上边界 0.5% 范围内 → 算一次测试
touch_upper = dataframe["high"] >= rolling_high * 0.995
touch_lower = dataframe["low"] <= rolling_low * 1.005
# ── 碰壁验证检测 ──
# 支撑验证:价格到了低点附近 → 随后 rejection_window 根K线内有反弹
near_low = dataframe["low"] <= rolling_low * (1 + rej_threshold)
bounced_up = pd.Series(False, index=dataframe.index)
for i in range(1, self.rejection_window.value + 1):
future_close = dataframe["close"].shift(-i).fillna(dataframe["close"])
bounced_up = bounced_up | (future_close > dataframe["close"])
support_rejection = near_low & bounced_up
dataframe["support_rejection_count"] = support_rejection.rolling(lookback).sum()
# 滚动窗口内测试次数
dataframe["upper_touches"] = touch_upper.rolling(lookback).sum()
dataframe["lower_touches"] = touch_lower.rolling(lookback).sum()
# 阻力验证:价格到了高点附近 → 随后 rejection_window 根K线内有回落
near_high = dataframe["high"] >= rolling_high * (1 - rej_threshold)
bounced_down = pd.Series(False, index=dataframe.index)
for i in range(1, self.rejection_window.value + 1):
future_close = dataframe["close"].shift(-i).fillna(dataframe["close"])
bounced_down = bounced_down | (future_close < dataframe["close"])
resistance_rejection = near_high & bounced_down
dataframe["resistance_rejection_count"] = resistance_rejection.rolling(lookback).sum()
# ── 震荡判定条件 ──
# ── 震荡判定 ──
atr_mult = self.zone_width_atr_mult.value
min_touches = self.min_touches.value
min_rej = self.min_rejections.value
# 条件1区间宽度合理不超过 ATR × N
is_compact = zone_width <= dataframe["atr"] * atr_mult
is_compact = zone_width_raw <= dataframe["atr"] * atr_mult
# 条件2上下边界都被测试过至少 min_touches
is_tested = (dataframe["upper_touches"] >= min_touches) & (dataframe["lower_touches"] >= min_touches)
# 条件2上下边界都经过碰壁验证(各至少 min_rej
support_ok = dataframe["support_rejection_count"] >= min_rej
resistance_ok = dataframe["resistance_rejection_count"] >= min_rej
# 条件3无突破(最近 breakout_bars 根收盘价在边界内)
# 条件3无突破
no_break_high = True
no_break_low = True
for i in range(1, self.breakout_bars + 1):
@ -117,8 +114,7 @@ class StructureFlowSwingV40(IStrategy):
no_break_high = no_break_high & (dataframe["close"].shift(i) <= rolling_high)
no_break_low = no_break_low & (dataframe["close"].shift(i) >= rolling_low)
is_ranging = is_compact & is_tested & no_break_high & no_break_low
is_ranging = is_compact & support_ok & resistance_ok & no_break_high & no_break_low
dataframe["is_ranging"] = is_ranging
# ── 价格在区间内的位置 ──
@ -129,7 +125,6 @@ class StructureFlowSwingV40(IStrategy):
np.nan,
)
# 距边界百分比
dataframe["dist_to_low"] = np.where(
rolling_low > 0,
(dataframe["close"] - rolling_low) / dataframe["close"],
@ -153,7 +148,7 @@ class StructureFlowSwingV40(IStrategy):
# ================================================================
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
entry_zone = self.entry_zone_pct.value / 1000.0 # 千分比
entry_zone = self.entry_zone_pct.value / 1000.0
if "is_ranging" not in dataframe.columns:
dataframe["is_ranging"] = False
@ -164,8 +159,6 @@ class StructureFlowSwingV40(IStrategy):
& (dataframe["dist_to_low"] < entry_zone)
& (dataframe["dist_to_low"] > 0)
)
# 冷却
long_recent = long_conds.rolling(self.cooldown, min_periods=1).max().shift(1) == 0
dataframe.loc[long_conds & long_recent, "enter_long"] = 1
@ -175,7 +168,6 @@ class StructureFlowSwingV40(IStrategy):
& (dataframe["dist_to_high"] < entry_zone)
& (dataframe["dist_to_high"] > 0)
)
short_recent = short_conds.rolling(self.cooldown, min_periods=1).max().shift(1) == 0
dataframe.loc[short_conds & short_recent, "enter_short"] = 1
@ -189,7 +181,7 @@ class StructureFlowSwingV40(IStrategy):
return dataframe
# ================================================================
# 自定义止损:区间边界外侧 + ATR 缓冲
# 自定义止损:固定入场价下方-3%(真固定,不追踪)
# ================================================================
def custom_stoploss(
@ -202,41 +194,12 @@ class StructureFlowSwingV40(IStrategy):
after_fill: bool,
**kwargs,
) -> float:
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
last = dataframe.iloc[-1]
atr_mult = self.atr_stop_mult.value / 10.0
# 固定止损 = 入场价下方3%,不随当前价格移动
# 不用current_rate用trade.open_rate确保锚点固定
if not trade.is_short:
zone_low = last.get("zone_low", np.nan)
atr = last.get("atr", np.nan)
if pd.isna(zone_low) or zone_low <= 0:
return -0.02
if pd.notna(atr) and atr > 0:
sl_price = zone_low - atr * atr_mult
return max((trade.open_rate * 0.98 / current_rate) - 1.0, -0.20)
else:
sl_price = zone_low * 0.985
sl_ratio = (sl_price / current_rate) - 1.0
return max(sl_ratio, -0.20)
else:
zone_high = last.get("zone_high", np.nan)
atr = last.get("atr", np.nan)
if pd.isna(zone_high) or zone_high <= 0:
return 0.02
if pd.notna(atr) and atr > 0:
sl_price = zone_high + atr * atr_mult
else:
sl_price = zone_high * 1.015
sl_ratio = 1.0 - (sl_price / current_rate)
return min(sl_ratio, 0.20)
return min(1.0 - (trade.open_rate * 1.02 / current_rate), 0.20)
# ================================================================
# 自定义止盈:区间高度 × TP%
@ -258,24 +221,60 @@ class StructureFlowSwingV40(IStrategy):
return None
last = dataframe.iloc[-1]
z_low = last.get("zone_low", np.nan)
z_high = last.get("zone_high", np.nan)
if not trade.is_short:
zone_low = last.get("zone_low", np.nan)
zone_high = last.get("zone_high", np.nan)
if pd.notna(zone_low) and pd.notna(zone_high) and zone_high > zone_low:
zone_height = (zone_high - zone_low) / zone_low
tp_target = zone_height * tp_pct
if current_profit >= tp_target:
if pd.notna(z_low) and pd.notna(z_high) and z_high > z_low:
base = z_low if not trade.is_short else z_high
zone_height = (z_high - z_low) / base
if current_profit >= zone_height * tp_pct:
return "take_profit"
else:
zone_low = last.get("zone_low", np.nan)
zone_high = last.get("zone_high", np.nan)
if pd.notna(zone_low) and pd.notna(zone_high) and zone_high > zone_low:
zone_height = (zone_high - zone_low) / zone_high
tp_target = zone_height * tp_pct
if current_profit >= tp_target:
return None
# ================================================================
# 自定义退出:锁定入场边界 + 收盘确认止损 + 区间高度止盈
# ================================================================
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> str | None:
tp_pct = self.take_profit_pct.value / 100.0
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe is None or len(dataframe) == 0:
return None
last = dataframe.iloc[-1]
# ── 锁定入场时的区间边界 ──
open_time = trade.open_date_utc.replace(tzinfo=None)
time_diff = (dataframe["date"] - open_time).abs()
open_idx = time_diff.idxmin()
open_row = dataframe.iloc[open_idx]
z_low_open = open_row.get("zone_low", np.nan)
z_high_open = open_row.get("zone_high", np.nan)
# ── 结构止损:收盘价确认跌破入场时的边界 ──
if not trade.is_short:
if pd.notna(z_low_open) and z_low_open > 0 and last["close"] < z_low_open:
return "stop_loss" # 收盘跌破支撑
else:
if pd.notna(z_high_open) and z_high_open > 0 and last["close"] > z_high_open:
return "stop_loss" # 收盘涨破阻力
# ── 止盈:区间高度 × TP%(也用锁定边界) ──
if pd.notna(z_low_open) and pd.notna(z_high_open) and z_high_open > z_low_open:
base = z_low_open if not trade.is_short else z_high_open
zone_height = (z_high_open - z_low_open) / base
if current_profit >= zone_height * tp_pct:
return "take_profit"
return None
@ -292,13 +291,12 @@ class StructureFlowSwingV40(IStrategy):
"zone_low": {"color": "green", "type": "line"},
},
"subplots": {
"rejections": {
"support_rejection_count": {"color": "green", "type": "line"},
"resistance_rejection_count": {"color": "red", "type": "line"},
},
"zone": {
"is_ranging": {"color": "blue", "type": "line"},
"zone_width_pct": {"color": "purple", "type": "line"},
},
"touches": {
"upper_touches": {"color": "red", "type": "line"},
"lower_touches": {"color": "green", "type": "line"},
},
},
}