Files
Chan/research/step58_struct_stop.py
T
jackyu66gitandCursor 9efbae6ade research: 结构止损对照(step58),止损确实太紧但不是主因
用户提出 B1 做多的止损可能挂在 B2 低点上方,于是在那次正常回抽处被打掉。
观察成立且比预估严重:入场价到结构极值中位 2.90 ATR,2ATR 止损落在结构位
内侧 0.90 ATR,88.6% 的一类做多价格不用回踩到前低就已出局。

修掉有改善但救不活:结构位外侧 1.5ATR 止损把胜率从 16.7% 抬到 33.1%、
PF 从 0.22 到 0.36,仍深度为负。同口径下反手做空 PF 2.15~2.53。

做这个对照时目标必须随止损等比放大(恒定 1.5R 减半/4R 收尾),否则放宽止损
却不放大目标会把盈亏比压到 1 以下,测出来的「放宽无效」是自证的。故
walk_exits 不能用,自带了逐笔止损的模拟器。

最有说服力的读法是结构+1.5 那档:止损宽达 4.4 ATR 仍有 67% 被打掉,即
三分之二的一类信号会在 48 根内跌破结构低点再多走 1.5 ATR。缠论里「B2 回踩
不破前低」在本市场多数时候不成立,趋势确实越过第二类买卖点继续走原方向。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:31:06 +08:00

191 lines
8.9 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)
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("目标随止损等比放大,恒定 1.5R 减半 / 4R 收尾,与实盘的 2/3/8 同构。")
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)
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()