Files
Chan/research/step60_seg_truth.py
T
jackyu66gitandCursor e137d92b50 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>
2026-08-28 21:38:59 +08:00

243 lines
9.8 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.
"""一类买卖点到底有没有找到真正的趋势反转点?用线段顶点当标准答案。
用户提出:线段的终点就是该级别的趋势反转点,虽然它是未来函数,但可以拿来当
**标签**验证检测器,而不是拿来交易。这能把两件事分开:
检测器对不对 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()