"""把 B4 按「中枢末笔是否已确认」拆开——之前的统计把两种混在一起了。 用户指出:B4 有两种,**没确认的会出现然后消失,确认的不会**。而我在 step63/68 里统计的是 `find_fast_bsp3` 的全部输出,它的返回列里根本没有确认标志 (entry_idx/direction/bo_idx/pb_idx/lag/depth/zg/zd/width_pct/occ/zone_i), **两种被混在一起了**,所以「额外信号 74%」这个数字不能直接拿来说实盘。 确认状态在更上游,`zones_from_zs_list`: sure_key = str(getattr(key_bi, "sure_time", "") or "") end_key = str(getattr(key_bi, "end_time", "") or "") avail = ts_of.get(sure_key) or ts_of.get(end_key) # ← 静默退回 end_time `ChanBI` 初始 `is_sure=False / sure_time=None`,确认时才 `set_is_sure(True, ...)`。 所以**末笔未确认时 available_ts 退回 end_time,而 end_time 随笔延伸而移动**, 中枢的可用时刻跟着漂 —— 这正是「出现然后消失」的那一种。笔一旦确认, `sure_time` 固定,中枢不再动。 这也解释了 §5.41 的数量级:中枢层面确认时刻只被改 6.5%,而我在信号层面看到 74%。 本脚本按 `zs.bi_list[-1].is_sure` 把信号拆成两组,分别看: 额外率 未确认组应显著高(会出现然后消失),已确认组应接近 0 收益 若亏损集中在未确认组,那么修法就是**信号侧加一道 is_sure 门**, 而不是动出场参数或放弃 B4 ⚠️ 同时要查的第二件事:**实盘路径到底交易哪一种。** `shadow_signal.py` 的三道 滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure 这一道**。若未确认组确实是亏损源, 且实盘没有挡它,那这道门就是要补的东西。 """ 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" / "step70_sure.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 _sure_map(zs_list) -> dict: """(zg, zd) -> 末笔是否已确认。zones_from_zs_list 会按 available_ts 重排, 索引对不上,所以用中枢边界当键接回去。""" m = {} for zs in zs_list: bis = getattr(zs, "bi_list", []) or [] if not bis: continue m[(round(float(zs.zg), 10), round(float(zs.zd), 10))] = \ bool(getattr(bis[-1], "is_sure", False)) return m 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) zsf = full.cal_bi_zs_list_pure(full.bi_list) if not zsf: return None sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zsf, cdf)) full_keys = {(int(r.entry_idx), int(r.direction)) for r in sig_full.itertuples()} if not sig_full.empty \ else set() 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, anchor = None, 0 for i in range(WIN, len(df)): 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 = zones_from_zs_list(zl, sub) if z is None or z.empty: continue sm = _sure_map(zl) 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)) z["z_sure"] = [ sm.get((round(float(a), 10), round(float(b), 10)), False) for a, b in zip(z["zg"], z["zd"])] 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 or "zone_i" not in s.columns: continue s = s.merge(z[["zone_i", "z_above", "z_below", "z_sure"]], 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": k in full_keys, "z_sure": bool(getattr(r, "z_sure", False)), "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) < 15: return None net = g.g.values - fee_of(g.r.values, g.c.values) gR = g.g.values / (SL * g.atr_pct.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() / taker_notional(g.r.values, g.c.values).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) rows = [] for k, g in d.groupby(d.z_sure): rows.append({"中枢末笔": "已确认" if k else "未确认", "信号数": len(g), "额外(全量没有)": int((~g.in_full).sum()), "额外率": f"{(~g.in_full).mean()*100:.1f}%"}) print(pd.DataFrame(rows).to_string(index=False)) print(""" 用户的判断:「没确认的会出现然后消失,确认的不会」。 若已确认组额外率接近 0 -> 判断成立,之前 74% 是把两种混在一起统计的结果。""") print("\n" + "=" * 92) print("【二】亏损是不是也集中在未确认组") print("=" * 92) rows = [] for k, g in d.groupby(d.z_sure): nm = "已确认" if k else "未确认" for lab, x in [("全部", g), ("三道滤网后", g[g.pass_all == 1])]: s = perf(x) if s: rows.append({"中枢末笔": nm, "口径": lab, **s}) print(pd.DataFrame(rows).to_string(index=False)) print("\n" + "=" * 92) print("【三】若补一道 is_sure 门,实盘口径会变成什么样") print("=" * 92) rows = [] for nm, x in [ ("现状:三道滤网", d[d.pass_all == 1]), ("**加 is_sure 门**", d[(d.pass_all == 1) & d.z_sure]), (" 对照:仅未确认", d[(d.pass_all == 1) & ~d.z_sure]), ]: s = perf(x) if s: rows.append({"口径": nm, **s}) print(pd.DataFrame(rows).to_string(index=False)) print(""" ⚠️ `shadow_signal.py` 的三道滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure**。 若加上这道门 PF 明显回升,那它就是要补进信号路径的东西。""") 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} 根 · {args.ltf}\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()