research: B4 时点干净,但实盘做的不是回测那批单(实盘口径 PF 0.73 vs 回测 4.21)

step63 因果回放:B4 的时点完全干净——100% 召回、100% 准时、零滞后,
一类那个把 PF 从 2.35 打到 0.92 的坑(§3.396)B4 没有。

但回放多产出 592 个全量口径里不存在的信号,PF 0.46 / t −5.03。
step68 按实盘口径复测(2001 根滚动窗口、每 500 根 init_stream、只做当根收盘,
逐行对齐 shadow_signal.py),并把三道滤网全测一遍:

  无过滤    789 笔,额外占比 75%,PF 0.64
  三道全开  195 笔,额外占比 74%,PF 0.73

滤网把成交量砍掉 75% 却几乎不改变额外信号占比——按同比例刷掉好的和坏的。
拆开看:回测里也有的 51 笔 PF 4.21/t+5.90,回测里没有的 144 笔 PF 0.26/t−6.21。

即回测报的 4.21 拿不到:那 51 笔要事后全量重算才能识别,实盘当下分不出来。
机制是重画——实时算出的中枢,数据变多后被修正掉。与 §3.396 同类:
一类错在时点,B4 错在存在性。

顺带闭掉一条待办:2001 根窗口与 2万→4万根增长窗口跑出完全相同的 789/197/592,
窗口左边界效应判为否。

限定:本轮是 5m/30m 而实盘跑 1m,需单独复验;同向过滤器被开了未来函数后门
(HTF 时间线用全量算),真实盘只会更差。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-28 23:46:19 +08:00
co-authored by Cursor
parent 93ed315a4b
commit 84ee59ecfb
3 changed files with 578 additions and 4 deletions
+246
View File
@@ -0,0 +1,246 @@
"""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()