Files
Chan/research/step58_struct_stop.py
T
jackyu66gitandCursor ed741c16b8 research: 一类线重开——病根是选样×止损的交互,不是没机会
用户问「修改 B1/B2 的识别规则呢」,查下来推翻了 §3.399 的关闭结论。

step65 先量机会本身:直接取 seg_list(不经过检测器),线段中位幅度 11.4 ATR,
扣掉 2.9 ATR 确认成本还剩 8.5,是止损的 4.26 倍,93% 的线段装得下。
五币四级别一致。**滞后不是瓶颈**,与原先预期相反。

但这与 §3.397 矛盾(空间在却拿不到),step66 给出解答:之前六次进攻每次只动
一半。完美选样配 2ATR 止损是 0.70/0.83,无选样配结构止损是 0.36,
**两个一起是 2.29(5m)/4.11(15m)**,胜率 36%→63% / 44%→70%。是交互不是叠加。

机制来自 MFE/MAE:真底那批逆向行程中位仅 2.24 ATR,2 ATR 止损打掉了 51~58%
的好单;非真底那批逆向行程中位 4.76,放宽只是亏更多。固定 2 ATR 同时做错两件事。

⚠️ 上界含两个未来函数(线段端点选样、sure_time 入场),是靶子不是策略。

顺带证伪一个我自己的猜测:step58 首轮用 r_scale=True 把 3.9 ATR 止损对应的
runner 目标推到 15.6 ATR(比整段行情还长)。加 --abs-targets 改绝对目标重跑,
PF 仍是 0.36,与 r_scale 版完全相同。首轮结论正确,只是理由错了。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:09:38 +08:00

197 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""结构止损:一类做多是被「止损太紧」打死的,还是趋势真的继续?
用户的观察:B1 做多的入场价 P 在结构低点 L 上方约 1.68 ATR,而固定止损是
P 2 ATR —— **只在 L 下方 0.32 ATR**。缠论里 B2 正是「回踩不破 L」的那次
机会,属于预期之内的正常回抽。止损只留 0.32 ATR,一根插针就打掉,而那恰恰
是该加仓的位置。
这与「趋势继续向下」是两个不同的失败模式,且**预测相反**:
止损太紧 把止损放到 L 下方足够远 -> 多头应被救活
趋势继续 放宽止损只是亏得更多,且反手做空应持续为正
本脚本用逐笔的结构止损(挂在 L 外侧 margin 个 ATR)重跑,直接区分这两者。
`walk_exits` 只支持全局固定止损,故这里自带模拟器;出场结构与实盘一致:
减半于 SCALE_AT、剩余半仓目标 RUNNER、剩余半仓止损回到初始止损、MAXB 超时。
"""
from __future__ import annotations
import argparse
import sys
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from lib.data import fetch_ohlcv # noqa: E402
from lib.exit_model import fee_of, taker_notional # noqa: E402
SRC = HERE / "out" / "step55_bsp12.feather"
SCALE_AT, RUNNER, MAXB = 3.0, 8.0, 48
def simulate(cdf: pd.DataFrame, trades: pd.DataFrame,
stop_atr: np.ndarray, r_scale: bool = True) -> pd.DataFrame:
"""逐笔止损宽度的出场模拟。stop_atr 是每笔各自的初始止损(ATR 倍数)。
与 `lib.exit_model.walk_exits` 的结构对齐:先减半仓于 SCALE_AT,剩余半仓
看 RUNNER 或回到初始止损;未减仓则整仓在止损/超时了结。同根内止损优先于
获利目标 —— 分辨不了根内先后时,按不利的一侧算,不给回测送分。
`r_scale=True` 时目标按 **R 倍数**而非 ATR 固定值放置:实盘的 2/3/8 等于
在 1.5R 减半、4R 收尾,放宽止损却不放大目标会把盈亏比压到 1 以下,
那样测出来的「放宽无效」是自证的。默认按 R 等比放大才是公平对照。
"""
k_scale = SCALE_AT / 2.0 if r_scale else None
k_run = RUNNER / 2.0 if r_scale else None
high = cdf["high"].to_numpy(float)
low = cdf["low"].to_numpy(float)
open_ = cdf["open"].to_numpy(float)
close = cdf["close"].to_numpy(float)
n = len(cdf)
out = []
ent = trades.entry_idx.astype(int).to_numpy()
dirs = trades.direction.astype(int).to_numpy()
atrs = trades.atr_at_entry.to_numpy(float)
for k in range(len(trades)):
s, d, a, sl = ent[k], dirs[k], atrs[k], stop_atr[k]
e = s + 1
if e >= n - 1 or not np.isfinite(sl) or sl <= 0 or not np.isfinite(a):
out.append((np.nan, "skip", 0, 0))
continue
tgt = sl * k_scale if r_scale else SCALE_AT
run = sl * k_run if r_scale else RUNNER
entry = open_[e]
end = min(e + MAXB, n - 1)
half_done = False
g, why, bars = None, "timeout", end - e
for j in range(e, end + 1):
adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a
ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a
if ret >= sl: # 同根内止损优先
g = ((-sl * a) / entry) * (0.5 if half_done else 1.0)
if half_done:
g += (tgt * a / entry) * 0.5
why, bars = "stop", j - e
break
if not half_done and adv >= tgt:
half_done = True
if half_done and adv >= run:
g = (tgt * 0.5 + run * 0.5) * a / entry
why, bars = "target", j - e
break
if g is None:
px = close[end]
r = (px - entry) * d / entry
g = (tgt * a / entry) * 0.5 + r * 0.5 if half_done else r
why, bars = ("timeout_half" if half_done else "timeout"), end - e
out.append((g, why, int(half_done), bars))
return pd.DataFrame(out, columns=["g", "r", "c", "b"])
def stats(g: pd.Series, r: pd.Series, c: pd.Series, atr_pct: np.ndarray,
sl_used: np.ndarray) -> dict:
net = g.values - fee_of(r.values, c.values)
# R 用每笔自己的止损宽度归一,否则宽止损会被系统性低估风险
denom = sl_used * atr_pct
R = net / denom
tn = taker_notional(r.values, c.values)
w, o = net[net > 0].sum(), -net[net <= 0].sum()
return {
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
"净均R": round(np.nanmean(R), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
"t值": round(np.nanmean(R) / (np.nanstd(R, ddof=1)
/ np.sqrt(len(R))), 2),
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--tf", default="5m")
ap.add_argument("--rows", type=int, default=200_000)
# step65 发现线段中位幅度只有 11.4 ATR,而 r_scale 会把 3.9 ATR 的结构止损
# 对应的 runner 目标推到 15.6 ATR —— 比整段行情还长,永远打不到。
# 本脚本首轮全程用了 r_scale=True,等于「放宽止损」和「目标够不着」同时生效,
# 两个效应互相抵消,那轮的结论无效。绝对目标才是与 11.4 ATR 相容的口径。
ap.add_argument("--abs-targets", action="store_true",
help="目标位用绝对 ATR(3/8)而非按止损等比放大")
args = ap.parse_args()
d = pd.read_feather(SRC)
if "ext_px" not in d.columns:
print("step55 数据缺 ext_px/entry_px,先重跑 step55")
return
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
print(f"[结构止损] {args.tf} · 一类 {len(d)}\n")
print("=" * 92)
print("########## 一、几何:固定 2ATR 止损离结构位有多远 ##########")
# 入场价到结构极值的距离,用入场根的 ATR 归一(与 walk_exits 同口径)
d["to_ext"] = (d.entry_px - d.ext_px) * d.dir / d.atr_at_entry
d["margin_2atr"] = 2.0 - d.to_ext
print(f"入场价到结构极值 (ATR) 中位 {d.to_ext.median():.2f} "
f"P25 {d.to_ext.quantile(.25):.2f} P75 {d.to_ext.quantile(.75):.2f}")
print(f"2ATR 止损在结构位外侧留的余地 (ATR) 中位 "
f"{d.margin_2atr.median():.2f}")
inside = (d.margin_2atr <= 0).mean()
print(f"**止损落在结构位以内(还没到极值就被打掉)的占比:{inside*100:.1f}%**")
print(" —— 这部分交易,价格连回踩到前低都不用,就已经出局了。")
print("\n" + "=" * 92)
print("########## 二、把止损挂到结构位外侧,多头能不能救活 ##########")
print("margin = 止损挂在结构极值外侧几个 ATR。对照组是现行的固定 2 ATR。")
from chanlun import TF_DF
rows = []
cache = {}
for sym in sorted(d.sym.unique()):
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
cache[(sym, args.tf)] = TF_DF(df, 1, args.tf).dataframe
print(f"目标位口径:{'绝对 3/8 ATR(装得进 11.4 ATR 的线段)' if args.abs_targets else '随止损等比放大(恒定 1.5R/4R'}")
for flip in (False, True):
for label, margin in [("固定2ATR", None), ("结构+0.25", 0.25),
("结构+0.5", 0.5), ("结构+1.0", 1.0),
("结构+1.5", 1.5)]:
gs, rs, cs, ap_, sl_ = [], [], [], [], []
for sym, x in d.groupby("sym"):
cdf = cache[(sym, args.tf)]
x = x.reset_index(drop=True)
t = pd.DataFrame({
"entry_idx": x.i_sure.values,
"direction": (-x.dir.values if flip else x.dir.values),
"atr_at_entry": x.atr_at_entry.values})
# 反手时结构位在**盈利**方向,不能拿它当止损;止损改挂在
# 入场价另一侧同样宽度处,否则两组比的不是同一个东西
sl = (np.full(len(x), 2.0) if margin is None
else (x.to_ext.values + margin))
res = simulate(cdf, t, sl, r_scale=not args.abs_targets)
ok = res.g.notna().values
gs.append(res.g[ok])
rs.append(res.r[ok])
cs.append(res.c[ok])
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
sl_.append(sl[ok])
rows.append({"方向": "反手" if flip else "原方向", "止损": label,
"中位宽度ATR": round(float(np.median(
np.concatenate(sl_))), 2),
**stats(pd.concat(gs, ignore_index=True),
pd.concat(rs, ignore_index=True),
pd.concat(cs, ignore_index=True),
np.concatenate(ap_), np.concatenate(sl_))})
print(pd.DataFrame(rows).to_string(index=False))
print("\n判读:若「止损太紧」是主因,放宽到结构位外侧应让原方向 PF 越过 1。"
"\n若原方向放宽后仍在 1 以下、而反手始终显著为正,"
"说明趋势确实在继续,\n那么该做的是反手,加宽止损只是少亏一点。")
if __name__ == "__main__":
main()