"""B4 主线的因果回放:实盘信号的时点是不是干净的。 step59 在一类上抓到一个会骗人的坑:`sure_time` 是引擎**事后**标注的确认时刻, 不等于可执行时刻,用它当 entry 的回测 PF 虚高一倍以上。B4 正在跑真钱, 必须过同一关。 **先验比一类好,但方向要说清**(§5.41 的代码审计): 一类 `sure_time` 直接当**入场时刻** -> 早了就是虚高,回测被高估 B4 `available_ts` 只是**扫描起点** -> 晚了只会漏信号,回测偏保守 §5.41 实测全量的 `available_ts` 系统性**更晚**(`bis[-1]` 取的是中枢结束而非 形成,中位晚 62 分钟)。所以预期是「回测保守」而非「回测虚高」。但那是 300 时点抽样 + 代码审计,不是逐根验证,而且留了个未知: **实盘会产出更多、更早的信号,那部分的质量不在回测统计里。** 本脚本逐根重放,同时量两边: 准时率 全量信号在其 entry_idx 当根就能算出来的比例 迟到 首现晚于 entry_idx 的,实盘只能在更差的价位追 额外信号 回放发得出、全量却没有的 —— §5.41 预言存在,但没人统计过它们赚不赚 ⚠️ 性能:每根扫全部中枢跑不完。一个中枢只能在其 available_ts 之后 scan 根内 出信号,所以每根只需把窗口内的中枢喂给 `find_fast_bsp3`。这是等价裁剪, 不改变结果。 """ from __future__ import annotations import argparse 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") HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE.parent)) OUT = HERE / "out" / "step63_b4_replay.feather" SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48 SCAN = 200 def replay(sym: str, tf: str, rows: int, warm: int, steps: int) -> pd.DataFrame | None: from chanlun import TF_DF from chanlun.analysis.fast_bsp import ( ensure_timestamp, find_fast_bsp3, zones_from_zs_list, ) from lib.data import fetch_ohlcv try: df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows) if df is None or len(df) < warm + steps + 100: return None df = df.iloc[-(warm + steps):].reset_index(drop=True) # ---- 全量口径:回测就是这么算的 ---- full = TF_DF(df, 1, tf) cdf = ensure_timestamp(full.dataframe) zs_full = full.cal_bi_zs_list_pure(full.bi_list) if not zs_full: return None sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf)) full_keys = {(int(r.entry_idx), int(r.direction)) for r in sig_full.itertuples()} if not sig_full.empty \ else set() # ---- 回放口径:逐根重算 zones 再扫 ---- chan = TF_DF(df.iloc[:warm].copy(), 1, tf) chan.init_stream(df.iloc[:warm].copy(), 1, tf) first_seen: dict[tuple, int] = {} for i in range(warm, len(df)): chan.append_bar(df.iloc[i]) try: zl = chan.cal_bi_zs_list_pure(chan.bi_list) if not zl: continue sub = ensure_timestamp(chan.dataframe) z = zones_from_zs_list(zl, sub) if z.empty: continue # 等价裁剪:available_ts 早于 scan 根之前的中枢,其扫描窗口 # 已经过去,不可能在本根产出新信号 lo = sub["timestamp"].to_numpy()[max(0, len(sub) - SCAN - 2)] z = z[z.available_ts >= lo] if z.empty: continue s = find_fast_bsp3(sub, z, scan=SCAN) except Exception: # noqa: BLE001 continue if s is None or s.empty: continue for r in s.itertuples(): k = (int(r.entry_idx), int(r.direction)) if k not in first_seen: first_seen[k] = i rec = [] for k in set(full_keys) | set(first_seen): e, d = k if e < warm: # 预热段不计入 continue seen = first_seen.get(k) rec.append({ "sym": sym, "tf": tf, "entry_idx": e, "direction": d, "in_full": k in full_keys, "in_replay": seen is not None, "i_seen": -1 if seen is None else seen, "late": (np.nan if seen is None else seen - e), }) if not rec: return None r = pd.DataFrame(rec) # 实盘真正会做的:首现根入场(首现==entry_idx 即准时) from lib.exit_model import cfg_name, walk_exits atr = cdf["atr"].to_numpy(float) cl = cdf["close"].to_numpy(float) n = len(cdf) cfg = cfg_name(SL, RUNNER, MAXB, RSTOP) live = r[r.in_replay & (r.i_seen < n - 2)].copy() live = live[np.isfinite(atr[live.i_seen.values]) & (atr[live.i_seen.values] > 0)] if not live.empty: res = walk_exits(cdf, pd.DataFrame({ "entry_idx": live.i_seen.values, "direction": live.direction.values}), [SL], [RUNNER], [MAXB], scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,)) if len(res) == len(live): for c in ("g", "r", "c"): live[c] = res[f"{cfg}_{c}"].to_numpy() live["atr_pct"] = (atr[live.i_seen.values] / cl[live.i_seen.values]) r = r.merge(live[["entry_idx", "direction", "g", "r", "c", "atr_pct"]], on=["entry_idx", "direction"], how="left") return r except Exception as e: # noqa: BLE001 print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True) return None def perf(g: pd.DataFrame) -> dict: from lib.exit_model import fee_of, taker_notional net = g.g.values - fee_of(g.r.values, g.c.values) gR = g.g.values / (SL * g.atr_pct.values) R = net / (SL * g.atr_pct.values) tn = taker_notional(g.r.values, g.c.values) w, o = net[net > 0].sum(), -net[net <= 0].sum() return { "笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%", "毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3), "PF": round(w / o, 2) if o > 0 else np.inf, "余量bp": round(net.mean() / tn.mean() * 1e4, 2), "t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2), } def report(d: pd.DataFrame) -> None: print("\n" + "=" * 92) print("########## 一、准时率与额外信号 ##########") rows = [] for tf, x in d.groupby("tf"): both = x[x.in_full & x.in_replay] rows.append({ "tf": tf, "全量信号": int(x.in_full.sum()), "回放信号": int(x.in_replay.sum()), "召回": f"{len(both)/max(int(x.in_full.sum()),1)*100:.1f}%", "准时(首现==entry)": f"{(both.late == 0).mean()*100:.1f}%", "迟到中位": (f"{both.late[both.late > 0].median():.0f} 根" if (both.late > 0).any() else "—"), "额外信号": int((x.in_replay & ~x.in_full).sum()), }) print(pd.DataFrame(rows).to_string(index=False)) print("\n额外信号 = 回放发得出、全量没有的。§5.41 预言它们存在" "(实盘 available_ts 更早 -> 信号更多更早),本表给出数量。") if "g" not in d.columns: return print("\n" + "=" * 92) print("########## 二、分组收益:回测口径 vs 实盘口径 ##########") for tf, x in d.groupby("tf"): y = x.dropna(subset=["g"]) if len(y) < 30: continue print(f"\n--- {tf} ---") rows = [] for nm, g in [ ("全部回放信号(=实盘会做的)", y[y.in_replay]), ("其中 准时的", y[y.in_replay & (y.late == 0)]), ("其中 迟到的", y[y.in_replay & (y.late > 0)]), ("其中 额外的(全量没有)", y[y.in_replay & ~y.in_full]), ("回测口径(全量∩回放)", y[y.in_full & y.in_replay]), ]: if len(g) >= 30: rows.append({"分组": nm, **perf(g)}) print(pd.DataFrame(rows).to_string(index=False)) print("\n判读:若「全部回放信号」的 PF 不低于「回测口径」,说明 B4 的时点" "是干净的,\n且 §5.41 说的『回测偏保守』成立 —— 实盘拿到的反而更多。" "\n若额外信号那组显著更差,那就是回测没统计到的隐性成本。") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE") ap.add_argument("--tf", default="5m") ap.add_argument("--rows", type=int, default=45_000) ap.add_argument("--warm", type=int, default=20_000) ap.add_argument("--steps", type=int, default=20_000) ap.add_argument("--workers", type=int, default=5) ap.add_argument("--reuse", action="store_true") args = ap.parse_args() if args.reuse and OUT.exists(): report(pd.read_feather(OUT)) return syms = [s.strip() for s in args.symbols.split(",")] print(f"[B4 因果回放] {len(syms)} 币 × {args.steps} 根逐根重放\n", flush=True) parts = [] with ProcessPoolExecutor(max_workers=args.workers) as ex: fut = {ex.submit(replay, s, args.tf, args.rows, args.warm, args.steps): s for s in syms} for i, f in enumerate(as_completed(fut), 1): r = f.result() print(f" [{i}/{len(syms)}] {fut[f]} " 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) OUT.parent.mkdir(exist_ok=True) d.to_feather(OUT) report(d) if __name__ == "__main__": main()