research: 因果回放推翻一类反手(step59),线段顶点验证识别有效但不够(step60)

step59 逐根回放:init_stream 预热后逐根 append_bar,每根重算笔中枢与 bsp,
记录信号首现根并按它入场。信号身份用「类型+极值KLC时刻」而非 sure_time,
后者正是会被重画的字段。必须逐根,分段重建等于多给信息。

结果把 §3.395 推翻了:召回 100%、幻影 0,即存在性是因果的;但首现根比全量
sure_time 晚中位 15 根、P90 31 根,0% 能准时拿到。按真实首现根入场,
B1 反手 PF 2.35→0.92、S1 2.75→1.30,t 0.20/0.64 完全不显著。

教训:sure_time 是引擎事后标注的确认时刻,不等于可执行时刻。任何拿它当
entry 的回测都要先过逐根回放。

step60 用线段终点当标准答案验证识别本身。对照组取所有同向笔端点——B1 按构造
就长在笔低点上,不设这个基准任何绝对命中率都无法解读。5m 上 B1 命中 30.3%
对基准 15.0%,提升 2.02 倍,S1 1.81 倍;B3/S3 恰为 0%,符合三类长在趋势
中段的预期,两者互为标签有效性旁证。

所以识别是对的,但精度只有 30%,且那 70% 噪声 PF 只有 0.08。更关键的是即使
用未来函数把精度提到 100%,命中组也只有 PF 0.70~0.83,仍不赚钱——因为入场价
已在结构底上方 2.90 ATR。一类线三层逐层否定后到此为止。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-28 21:38:59 +08:00
co-authored by Cursor
parent 9efbae6ade
commit e137d92b50
3 changed files with 569 additions and 1 deletions
+242
View File
@@ -0,0 +1,242 @@
"""一类买卖点到底有没有找到真正的趋势反转点?用线段顶点当标准答案。
用户提出:线段的终点就是该级别的趋势反转点,虽然它是未来函数,但可以拿来当
**标签**验证检测器,而不是拿来交易。这能把两件事分开:
检测器对不对 B1 是否真的落在趋势反转底上
能不能交易 step59 已证否(实盘首现比 sure_time 晚中位 15 根,PF 塌到 0.92)
若检测器对而只是慢,那问题是延迟,还有救;若检测器本身就没找到反转点,
这条线整个是死的。
⚠️ **对照组是这个测试的全部意义**。B1 按构造就长在笔的低点上,而笔低点本来
就有一定概率撞上线段底。所以要问的不是「B1 命中率多少」,而是
**「在所有同向笔端点里,B1 这个标签把命中率提高了多少倍」**。
没有这个基准,任何绝对数字都可以随便解读。
(同样的坑 fast_bsp 文档头踩过:回抽极值命中笔端点 19.3%,看着不低,
但随机基准是 22%,其实是负贡献。)
"""
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" / "step60_seg_truth.feather"
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
TOL = [0, 1, 2, 3, 5]
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
from chanlun import TF_DF
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
if df is None or len(df) < 5_000:
return None
chan = TF_DF(df, 1, tf, lean=False)
cdf = chan.dataframe
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
if not bz:
return None
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
dser = pd.to_datetime(cdf["date"])
if dser.dt.tz is not None:
dser = dser.dt.tz_localize(None)
didx = pd.DatetimeIndex(dser)
n = len(cdf)
def to_i(ts) -> int:
t = pd.Timestamp(ts)
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
# ---- 标准答案:线段终点。下降线段终点=真底,上升线段终点=真顶 ----
seg_bot, seg_top = [], []
for sg in getattr(chan, "seg_list", []) or []:
if sg.end_time is None:
continue
i = to_i(sg.end_time)
if not (0 <= i < n):
continue
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN else seg_top).append(i)
if not seg_bot or not seg_top:
return None
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
def near(i: int, d: int, tol: int) -> bool:
a = truth[d]
k = int(np.searchsorted(a, i))
for j in (k - 1, k):
if 0 <= j < len(a) and abs(int(a[j]) - i) <= tol:
return True
return False
rec = []
# ---- 对照组:所有笔端点。B1 本就长在笔低点上,基准必须同源 ----
for bi in chan.bi_list:
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
continue
d = 1 if str(bi.dir).endswith("DOWN") else -1 # 下降笔终点=低点
i = to_i(bi.end_klc.end_time)
if not (0 <= i < n):
continue
rec.append({"kind": "笔端点", "dir": d, "i_ext": i, "i_sure": -1})
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
for b in bsp:
tag = want.get(b.type)
if tag is None or b.sure_time is None:
continue
name, d = tag
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
if not (0 <= i_ext < n and 0 <= i_sure < n):
continue
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
"i_sure": i_sure})
r = pd.DataFrame(rec)
for tol in TOL:
r[f"hit{tol}"] = [near(i, d, tol)
for i, d in zip(r.i_ext, r.dir)]
# 命中线段顶点的那批一类,按原方向(抄底)做能不能赚
atr = cdf["atr"].to_numpy(float)
cl = cdf["close"].to_numpy(float)
sig = r[(r.kind.isin(["B1", "S1"])) & (r.i_sure >= 0)
& (r.i_sure < n - 2)].copy()
sig = sig[np.isfinite(atr[sig.i_sure.values])
& (atr[sig.i_sure.values] > 0)]
if not sig.empty:
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
res = walk_exits(cdf, pd.DataFrame({
"entry_idx": sig.i_sure.values,
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
if len(res) == len(sig):
for c in ("g", "r", "c"):
sig[c] = res[f"{cfg}_{c}"].to_numpy()
sig["atr_pct"] = (atr[sig.i_sure.values]
/ cl[sig.i_sure.values])
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "atr_pct"]],
on=["i_ext", "kind"], how="left")
r["sym"], r["tf"] = sym, tf
return r
except Exception as e: # noqa: BLE001
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
return None
def report(d: pd.DataFrame) -> None:
from lib.exit_model import fee_of, taker_notional
for tf, x in d.groupby("tf"):
print("\n" + "#" * 92)
print(f"########## {tf} · 线段顶点作为标准答案 ##########")
print("\n【命中率】i_ext 落在同向线段终点 ±tol 根内的比例")
rows = []
for kind in ["笔端点", "B1", "S1", "B3", "S3"]:
g = x[x.kind == kind]
if len(g) < 30:
continue
row = {"信号": kind, "样本": len(g)}
for tol in TOL:
row[f"±{tol}"] = f"{g[f'hit{tol}'].mean()*100:.1f}%"
rows.append(row)
t = pd.DataFrame(rows)
print(t.to_string(index=False))
base = x[x.kind == "笔端点"]
print("\n【提升倍数】相对「所有同向笔端点」这个基准。"
"≈1 就是没有信息量")
rows = []
for kind in ["B1", "S1", "B3", "S3"]:
g = x[x.kind == kind]
if len(g) < 30:
continue
row = {"信号": kind}
for tol in TOL:
b = base[base.dir.isin(g.dir.unique())][f"hit{tol}"].mean()
row[f"±{tol}"] = (round(g[f"hit{tol}"].mean() / b, 2)
if b > 0 else np.nan)
rows.append(row)
print(pd.DataFrame(rows).to_string(index=False))
if "g" not in x.columns:
continue
print("\n【命中 vs 未命中】一类按原方向(抄底/摸顶)做的表现,"
"tol=±2 根")
y = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"]).copy()
if len(y) < 60:
continue
rows = []
for hit, g in y.groupby(y.hit2):
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()
rows.append({
"命中线段顶点": "" if hit else "", "笔数": len(g),
"胜率": f"{(net > 0).mean()*100:.1f}%",
"毛R": round(gR.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),
})
print(pd.DataFrame(rows).to_string(index=False))
print("\n判读:若「命中」那组按原方向做显著为正,说明检测器是对的、"
"只是掺了太多噪声,\n值得找实时可判的过滤器;若两组都为负,"
"说明即使真站在线段底上,\n这个入场时点也已经太晚了。")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
ap.add_argument("--tfs", default="5m,15m")
ap.add_argument("--rows", type=int, default=200_000)
ap.add_argument("--workers", type=int, default=3)
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(",")]
tfs = [t.strip() for t in args.tfs.split(",")]
parts = []
with ProcessPoolExecutor(max_workers=args.workers) as ex:
fut = {ex.submit(collect, s, t, args.rows): (s, t)
for s in syms for t in tfs}
for i, f in enumerate(as_completed(fut), 1):
r = f.result()
s, t = fut[f]
print(f" [{i}/{len(fut)}] {s} {t} "
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()