Files
Chan/research/step68_live_window.py
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

266 lines
11 KiB
Python
Raw Permalink 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,并测三道过滤能否刷掉那批亏钱的额外信号。
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()