"""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 / step48:10 币 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 # 必须走 full:check_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()