Files
Chan/research/step63_b4_replay.py
T
jackyu66gitandCursor 84ee59ecfb 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>
2026-08-28 23:46:19 +08:00

247 lines
10 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.
"""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()