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
+265
View File
@@ -0,0 +1,265 @@
"""按**实盘口径**重放 B4,并测三道过滤能否刷掉那批亏钱的额外信号。
step63 的结论是一好一坏:
好 时点干净 —— 100% 召回、100% 准时、零滞后(一类是 0% 准时、+15 根)
坏 回放多出 592 个全量口径没有的信号,PF 0.46 / t 5.03,混合后 0.64 < 1
但 step63 有两个口径问题,本脚本一并修掉:
① 窗口不对。回测用全量 45000 根一次算完,step63 用 2 万涨到 4 万根的增长窗口,
**而实盘用 2001 根滚动窗口、每 500 根 init_stream 拉回**`shadow_signal.py`
的 MAX_GROW)。三种口径的中枢结构都不一样。这也是 HANDOFF 里挂着的
「回测用全量历史建中枢、实盘用 2000 根窗口」那条待办。
顺带:窗口封顶后单步成本恒定,不再是 step63 那个平方级(143ms@2万根 ->
292ms@4万根),所以本脚本快得多。
② 没测过滤。step63 跑的是裸信号,而实盘有三道滤网。ATR 门控已单独测过——
它刷掉 7.9% 的额外信号却刷掉 10.7% 的好信号,PF 纹丝不动。剩下两道要测。
**一处刻意的简化,方向是保守的**:大级别分型时间线用全量历史算(真实盘的 HTF
也会重画)。这等于**给同向过滤器开了未来函数的后门**。若连这样都刷不掉额外信号,
结论只会更强。
"""
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" / "step68_live_window.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
def _ladder(zones: pd.DataFrame) -> pd.DataFrame:
z = zones.copy()
pg, pdn = z["zg"].shift(), z["zd"].shift()
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
z["zone_i"] = np.arange(len(z))
return z
def replay(sym: str, ltf: str, htf: str, rows: 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
from lib.fx_signal import extract_fx_signals, signals_to_frame
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
if df is None or len(df) < WIN + steps + 100:
return None
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
full = TF_DF(df, 1, ltf)
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()
# 大级别分型时间线:全量算(见模块 docstring 的「刻意简化」)
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
tl = None
if dh is not None and len(dh) > 500:
ch = TF_DF(dh, 1, htf)
tl = htf_fx_timeline(
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
ch.dataframe)
rec: dict[tuple, dict] = {}
chan = None
anchor = 0
for i in range(WIN, len(df)):
# 实盘的窗口纪律:2001 根起,长过 MAX_GROW 就 init_stream 拉回
if chan is None or (i - anchor) >= MAX_GROW:
w = df.iloc[i - WIN + 1:i + 1].copy()
chan = TF_DF(w, 1, ltf)
chan.init_stream(w, 1, ltf)
anchor = i
else:
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 = _ladder(zones_from_zs_list(zl, sub))
if z.empty:
continue
s = find_fast_bsp3(sub, z)
if s is None or s.empty:
continue
last = len(sub) - 1
s = s[s["entry_idx"].astype(int) == last] # 实盘只做当根
if s.empty:
continue
if "zone_i" in s.columns:
s = s.merge(z[["zone_i", "z_above", "z_below"]],
on="zone_i", how="left")
if tl is not None:
s = attach_htf_context(s, sub, tl, "h1")
except Exception: # noqa: BLE001
continue
for r in s.itertuples():
k = (i, int(r.direction))
if k in rec:
continue
push = getattr(r, "z_above" if r.direction == 1
else "z_below", None)
ag = getattr(r, "h1_agree", 0)
rec[k] = {
"sym": sym, "entry_idx": i, "direction": int(r.direction),
"in_full": (i, int(r.direction)) in full_keys,
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
"h1_agree": int(ag) if pd.notna(ag) else 0,
}
if not rec:
return None
r = pd.DataFrame(list(rec.values()))
from lib.exit_model import cfg_name, walk_exits
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
r = r[(r.entry_idx < len(cdf) - 2)
& np.isfinite(atr[r.entry_idx.values])
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
if r.empty:
return None
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": r.entry_idx.values,
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
if len(res) != len(r):
return None
for c in ("g", "r", "c"):
r[c] = res[f"{cfg}_{c}"].to_numpy()
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
& (r.gate_ok == 1)).astype(int)
return r
except Exception as e: # noqa: BLE001
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
return None
def perf(g: pd.DataFrame) -> dict | None:
from lib.exit_model import fee_of, taker_notional
if len(g) < 20:
return None
net = g.g.values - fee_of(g.r.values, g.c.values)
gR = g.g.values / (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}%",
"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("【一】实盘窗口下还有多少额外信号")
print("=" * 92)
print(f"回放信号 {len(d)} · 其中全量口径也有 {int(d.in_full.sum())} · "
f"**额外 {int((~d.in_full).sum())}**")
print(f"(step63 的增长窗口口径:197 / 592)")
print("\n" + "=" * 92)
print("【二】三道过滤能不能刷掉额外信号 —— 这决定实盘是否在亏钱")
print("=" * 92)
rows = []
for nm, m in [("① 无过滤", None), ("② 仅同向", d.h1_agree == 1),
("③ 仅阶梯", d.ladder_ok == 1),
("④ 仅ATR门控", d.gate_ok == 1),
("⑤ 三道全开(实盘口径)", d.pass_all == 1)]:
x = d if m is None else d[m]
if x.empty:
continue
ex, bt = x[~x.in_full], x[x.in_full]
row = {"过滤": nm, "留下": len(x),
"额外占比": f"{(~x.in_full).mean()*100:.0f}%"}
for lab, g in [("全部", x), ("额外", ex), ("回测口径", bt)]:
s = perf(g)
row[f"{lab}PF"] = "" if s is None else s["PF"]
row[f"{lab}n"] = len(g)
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
print("\n" + "=" * 92)
print("【三】实盘口径(三道全开)的完整表现")
print("=" * 92)
rows = []
for nm, g in [("实盘会做的全部", d[d.pass_all == 1]),
(" 其中额外的", d[(d.pass_all == 1) & ~d.in_full]),
(" 其中回测也有的", d[(d.pass_all == 1) & d.in_full])]:
s = perf(g)
if s:
rows.append({"分组": nm, **s})
print(pd.DataFrame(rows).to_string(index=False))
print("""
判读:「实盘会做的全部」PF > 1 -> 实盘安全,额外信号被滤网挡住了
PF < 1 -> **实盘在做一批回测里不存在、且亏钱的信号**,要立刻处理""")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--ltf", default="5m")
ap.add_argument("--htf", default="30m")
ap.add_argument("--rows", type=int, default=45_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"[实盘口径回放] {len(syms)}× {args.steps} 根 · "
f"{WIN} 根滚动窗口 / 每 {MAX_GROW} 根重建\n", flush=True)
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
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()