Files
Chan/research/step55_bsp12_probe.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

349 lines
16 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.
"""Step 55:一/二类买卖点的可行性探针——先量滞后,不急着跑收益。
用户提出把 B4 那套研究思路搬到一二类上。搬之前必须先过一道闸,理由写在
`Chan_BSP_TYPE` 的枚举注释里:**B4 存在的全部意义就是"几何位置同 B3/S3
但不等笔确认"**。也就是说这个项目早就付过"等笔确认"的学费。
而一类买卖点是最滞后的一种构造:它要中枢 `is_sure`、要离开笔 `is_sure`、
还要背驰判定(`check_bi_div` 读 `macd_hist`)。二类更靠后,要在一类之后再走
两笔。所以真正的问题不是"一二类赚不赚钱",而是:
你能在什么时候知道它,那时候价格还在不在。
`ChanBSP` 给了两个时刻,差值就是答案:
klc.end_time = leave_bi.end_klc 的收盘时刻,**极值所在**,理想入场点
sure_time = 笔被确认的时刻,**你最早能动手的时刻**
本步只回答三件事,跑得快、结论硬:
1. 一二类各有多少笔(对比 B4 的 5.3 笔/天 / 10 币)
2. 滞后多少根
3. 这段等待里价格跑掉多少个 ATR —— 这是"入场价漂移",直接从余量里扣
⚠️ 不在本步做收益回测。滞后若不可接受,收益怎么算都是假的:`walk_exits`
用的是信号根的次根开盘价,而那个价在一二类上根本拿不到。
"""
from __future__ import annotations
import argparse
import os
import sys
import warnings
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
pd.set_option("display.width", 340)
OUT = HERE / "out" / "step55_bsp12.feather"
# B4 的对照基线,来自 §3.31 / step4810 币 1m 实盘口径
B4_PER_DAY_10SYM = 5.3
# 出场结构与实盘完全一致,见 live/exit_params.py
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
def collect(sym: str, rows: int, tf: str = "1m") -> pd.DataFrame | None:
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
# 必须走 fullcheck_bi_div 读 macd_hist,那只在 full 下算。
# 而 TF_DF 自己**不填** bsp_list——web 的 analyze.py 是显式调
# find_all_bsp 的,这里照抄那条链,口径才对得上
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bi_zs = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bi_zs:
return None
bsp = chan.find_all_bsp(chan.bi_list, bi_zs)
if not bsp:
return None
# 索引映射有两个坑,都会静默给出错的下标:
# ① cdf.date 带时区(Asia/Shanghai),而 KLC 上的时间是**字符串**且
# 不带时区。它们本就是同一个时钟的墙上时间,所以去 tz 而不是硬贴。
# ② cdf.date 的单位是 datetime64[ms]`astype("int64")` 给的是毫秒,
# 而 `Timestamp.value` 是纳秒,差 1e6 倍——手工转整数会让
# searchsorted 全部落到末尾,且不报错。交给 DatetimeIndex 自己比。
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
if t.tz is not None:
t = t.tz_localize(None)
return int(didx.searchsorted(t))
op = cdf["open"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
atr = cdf["atr"].to_numpy(float)
n = len(cdf)
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
rec = []
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
# 极值根:笔末 KLC 的收盘时刻。KLC 是合并后的,取它覆盖的最后一根
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
a = atr[i_ext]
if not np.isfinite(a) or a <= 0:
continue
# 理想价:极值根收盘。可执行价:确认根的**次根开盘**——与
# walk_exits / 实盘的口径一致(信号根收盘后才下单)
px_ideal = cl[i_ext]
j = min(i_sure + 1, n - 1)
px_real = op[j]
rec.append({
"sym": sym, "tf": tf, "type": name, "dir": d,
"date_ext": dser.iloc[i_ext], "date_sure": dser.iloc[i_sure],
"i_ext": i_ext, "i_sure": i_sure,
"lag_bars": i_sure - i_ext,
"atr_bp": a / px_ideal * 1e4,
# 结构位:笔末 KLC 的实际极值,不是收盘。止损要挂在它外面
# 才谈得上「守住结构」,用收盘价会把插针那一段漏掉
"ext_px": float(b.klc.low if d == 1 else b.klc.high),
"entry_px": px_real,
# 等待期间价格顺着信号方向跑掉了多少(正 = 你追高/追空,吃亏)
"drift_atr": (px_real - px_ideal) * d / a,
"drift_bp": (px_real - px_ideal) * d / px_ideal * 1e4,
})
if not rec:
return None
r = pd.DataFrame(rec)
# 入场口径与实盘一致:确认根收盘后下单,walk_exits 用 entry_idx+1 的
# 开盘价,ATR 取 entry_idx 那根。所以 entry_idx = i_sure**不是**
# i_ext——用极值根等于假设你能买在笔的低点,那是未来函数
r = r[(r.i_sure < len(cdf) - 2) & np.isfinite(atr[r.i_sure.values])
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
if r.empty:
return None
sig = pd.DataFrame({"entry_idx": r.i_sure.values,
"direction": r.dir.values})
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
if len(res) != len(r):
print(f" {sym} {tf} 长度不齐 {len(res)} vs {len(r)},跳过",
flush=True)
return None
for k in ("g", "r", "c", "b"):
r[k] = res[f"{cfg}_{k}"].to_numpy()
# 反向对照:胜率远低于随机说明信号带信息但可能指反了。出场是路径依赖的,
# 不能把 g 取负号了事,必须拿相反方向重跑一遍
flip = pd.DataFrame({"entry_idx": r.i_sure.values,
"direction": -r.dir.values})
rf = walk_exits(cdf, flip, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
runners=(RUNNER,), runner_stops=(RSTOP,))
if len(rf) == len(r):
for k in ("g", "r", "c", "b"):
r["f_" + k] = rf[f"{cfg}_{k}"].to_numpy()
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
# 原始 ATR,供结构止损换算用。atr_pct 除过价格,换不回来
r["atr_at_entry"] = atr[r.i_sure.values]
return r
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def report(d: pd.DataFrame, span_days: float, n_sym: int) -> None:
print("\n" + "=" * 100)
print(f"########## 一、笔数:够不够做 ##########")
t = d.groupby("type").agg(笔数=("lag_bars", "size"))
t[f"{n_sym}币每天"] = (t["笔数"] / span_days).round(2)
# 必须按币归一再比。B4 那个 5.3 是 10 币的合计,直接拿 5 币的数去除
# 等于凭空把速率打对折
t["每币每天"] = (t["笔数"] / span_days / n_sym).round(3)
t["对B4倍数"] = (t["笔数"] / span_days / n_sym /
(B4_PER_DAY_10SYM / 10)).round(2)
print(t.to_string())
print(f"\n对照基线:B4 在 1m 上 10 币 {B4_PER_DAY_10SYM} 笔/天 = "
f"**{B4_PER_DAY_10SYM/10:.3f} 笔/币/天**。"
f"本表 {n_sym} 币 / 跨 {span_days:.0f} 天。")
print("\n" + "=" * 100)
print("########## 二、滞后:从极值到可动手,差多少根 ##########")
rows = []
for name, g in d.groupby("type"):
q = g.lag_bars.quantile([0.25, 0.5, 0.75, 0.9]).round(1)
rows.append({"类型": name, "笔数": len(g),
"滞后中位": q[0.5], "P25": q[0.25], "P75": q[0.75],
"P90": q[0.9], "均值": round(g.lag_bars.mean(), 1),
"最大": int(g.lag_bars.max())})
print(pd.DataFrame(rows).to_string(index=False))
print("\n1m 上 1 根 = 1 分钟。B4 的滞后是 0 根——它在信号根收盘即可下单。")
print("\n" + "=" * 100)
print("########## 三、等待的代价:价格跑掉了多少 ##########")
print("drift 为正 = 等待期间价格顺着信号方向走了,你只能追;这一段直接从"
"余量里扣")
print("⚠️ 「追不上占比」必然是 100%,那是定义决定的不是实测发现:笔之所以"
"在那里结束,\n 正是因为价格从那个极值反向走了——`bi.end_klc` 是极值"
"点,之后必然朝信号方向偏离。\n **有信息量的是幅度,不是符号。**")
rows = []
for name, g in d.groupby("type"):
q = g.drift_atr.quantile([0.25, 0.5, 0.75]).round(2)
rows.append({
"类型": name, "笔数": len(g),
"漂移中位(ATR)": q[0.5], "P25": q[0.25], "P75": q[0.75],
"漂移均值(ATR)": round(g.drift_atr.mean(), 2),
"漂移均值(bp)": round(g.drift_bp.mean(), 1),
"中位ATR(bp)": round(g.atr_bp.median(), 1),
"追不上占比": f"{(g.drift_atr > 0).mean()*100:.0f}%"})
print(pd.DataFrame(rows).to_string(index=False))
print("\n" + "=" * 100)
print("########## 四、和止损宽度比:漂移吃掉多少风险预算 ##########")
print("实盘止损是 2 ATR。若漂移中位已经是 1 ATR,等于你的止损只剩一半,"
"而目标位还在原处——盈亏比被腰斩。")
rows = []
for name, g in d.groupby("type"):
rows.append({
"类型": name,
"漂移/止损(2ATR)": f"{g.drift_atr.median()/2*100:.0f}%",
"漂移超过 1ATR 占比": f"{(g.drift_atr > 1).mean()*100:.0f}%",
"漂移超过 2ATR(已穿止损)": f"{(g.drift_atr > 2).mean()*100:.0f}%"})
print(pd.DataFrame(rows).to_string(index=False))
def profit(d: pd.DataFrame) -> None:
"""各类买卖点按实盘出场结构的实际表现。
B3 是校验锚:HANDOFF §4 已记引擎 `find_all_bsp` 的 B3/S3 是系统性亏损
PF 0.66、胜率 27.4%、t 18.76)。若这里 B3 跑出个漂亮数字,说明口径接错了,
先别信 B1/B2 的结果。
"""
from lib.exit_model import fee_of, taker_notional
if "g" not in d.columns:
print("\n(本次数据无回测列,跳过收益段;删掉 out/step55_bsp12.feather "
"重跑可得)")
return
x = d.dropna(subset=["g"]).copy()
x["net"] = x.g.values - fee_of(x.r.values, x.c.values)
x["gR"] = x.g.values / (SL * x.atr_pct.values)
x["R"] = x.net.values / (SL * x.atr_pct.values)
x["tn"] = taker_notional(x.r.values, x.c.values)
print("\n" + "=" * 100)
print("########## 五、按实盘出场结构(2/3/8/2/48)的实际表现 ##########")
rows = []
for name, g in x.groupby("type"):
if len(g) < 40:
continue
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
rows.append({
"类型": name, "笔数": len(g),
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"R夏普": round(g.R.mean() / g.R.std(ddof=1), 3),
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
"中位持仓": int(g.b.median()),
"t值": round(g.gR.mean() / (g.gR.std(ddof=1) / np.sqrt(len(g))), 2),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n⚠️ B3 是口径校验锚:HANDOFF §4 记的是 PF 0.66 / 胜率 27.4%。"
"这里若明显更好,先怀疑接错了再信 B1/B2。")
if "f_g" not in x.columns:
return
print("\n" + "=" * 100)
print("########## 六、反向对照:信号是「指反了」还是「没有边」 ##########")
print("胜率 18.6% 远低于 SL2/TP3 的随机基准约 40%,说明样本带信息。若只是"
"方向标反了,\n反手做应显著为正;若反手也不赚,那就是入场时点本身"
"已经过期,两边都吃滑点。")
y = x.copy()
y["fnet"] = y.f_g.values - fee_of(y.f_r.values, y.f_c.values)
y["fgR"] = y.f_g.values / (SL * y.atr_pct.values)
rows = []
for name, g in y.groupby("type"):
if len(g) < 40:
continue
w, o = g.fnet[g.fnet > 0].sum(), -g.fnet[g.fnet <= 0].sum()
rows.append({
"类型(反手)": name, "笔数": len(g),
"胜率": f"{(g.fnet > 0).mean()*100:.1f}%",
"毛R": round(g.fgR.mean(), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"t值": round(g.fgR.mean() / (g.fgR.std(ddof=1) / np.sqrt(len(g))),
2),
})
print(pd.DataFrame(rows).to_string(index=False))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m",
help="要测的周期。1m 上滞后的绝对根数与长周期相同,"
"但每根值的钱不同,所以周期是关键变量")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true")
args = ap.parse_args()
if args.reuse and OUT.exists():
d = pd.read_feather(OUT)
else:
syms = [s.strip() for s in args.symbols.split(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
print(f"[一二类可行性探针] {len(syms)}× {tfs} × {args.rows} 根"
f"full 模式,find_all_bsp 要 macd_hist\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, args.rows, t): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
f"{0 if r is None else len(r)}", flush=True)
if r is not None:
parts.append(r)
if not parts:
print("无结果")
return
d = pd.concat(parts, ignore_index=True)
d.to_feather(OUT)
d["date_ext"] = pd.to_datetime(d["date_ext"])
for tf, x in d.groupby("tf"):
span = (x.date_ext.max() - x.date_ext.min()).total_seconds() / 86400
print("\n" + "#" * 100)
print(f"########## {tf} —— {len(x)} 个买卖点 · "
f"{x.sym.nunique()} 币 · 跨 {span:.0f} 天 ##########")
report(x, span, x.sym.nunique())
profit(x)
if __name__ == "__main__":
main()