diff --git a/research/HANDOFF.md b/research/HANDOFF.md index 4ef8d9e..708b1bc 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -315,6 +315,28 @@ push = z_above if direction == 1 else z_below > 凡用到「这一簇共几个币」「我是第几个」「簇跨度多长」的,都含未来信息, > 回测漂亮但实盘拿不到。 +**✅ 唯一可交易的那条已通过样本外验证(step49)**:10 币 × 80 万根, +2025-02 ~ 2026-08 共 3941 笔,其中发现期(step48 用过的最近 208 天)1161 笔、 +样本外 2780 笔。 + +| | 无前序 | 有前序 | 差 | +|---|---|---|---| +| 样本外 毛R | 1.086 | **1.275** | +0.189 | +| 样本外 余量 | 18.83bp | **24.89bp** | +6.06bp | +| 发现期 余量 | 14.14bp | 21.96bp | +7.82bp | + +**逐时段 6/6 全部同向**(余量差 12.52 / 9.10 / 3.82 / 3.89 / 6.95 / 9.25bp, +中位 8.03)。发现期毛R 差只有 0.085,**样本外反而放大到 0.189——不是过拟合 +衰减,是发现期恰好偏保守**。占比很稳,各时段 13~19%。 + +ATR 混淆已排除:`有前序` 的 ATR 确实略高(中位 15.04 vs 13.13bp),但按 ATR +四分位分层后 **4/4 层同向**,层内余量差 3.29 / 8.39 / 5.16 / 4.14bp,与不分层 +的 +6.06 同量级。且毛R 本身是 ATR 归一化指标,其 +0.19 不可能是 ATR 假象。 + +**可用方式**:这 16% 的信号能多容忍约 6bp 执行成本。可用于加仓位权重, +或对这批放宽 ATR 门控——ATR 最低层里 `有前序` 余量仍有 14.92bp(对照 11.63), +提示门控对它们可以更松。**但放宽门控尚未回测,先别改。** + > ⚠️ **「同分钟 vs 错开」是看过数据之后才划的切法,不是事先定的。** > 208 天、158 个簇,post-hoc 切分容易切出噪声。**拿它当仓位规则前必须换一段 > 时间验证。** 目前可确定的只有「扎堆整体更好」(簇级也成立); diff --git a/research/step49_cluster_oos.py b/research/step49_cluster_oos.py new file mode 100644 index 0000000..28659d3 --- /dev/null +++ b/research/step49_cluster_oos.py @@ -0,0 +1,161 @@ +"""Step 49:簇内「有无前序信号」的样本外验证。 + +step48 在最近 208 天上发现:开仓时若过去 5 分钟内已有别的币发过信号, +该笔的滑点余量 21.88bp,明显高于无前序的 14.13bp(+55%)。毛R 只差 8.8%, +所以值得追的是余量这一条——1m 的生死线就在执行成本上。 + +但那是发现期内的数字,且「过去 5 分钟」这个窗口是我挑的。本步用更长的历史, +把发现期之外的时段单独拿出来看同一个差距还在不在。 + +只验这一个假设。158 个簇撑不起更多——每多验一个,假阳性概率就涨一截。 +判据:OOS 各时段里 `有前序` 的余量应稳定高于 `无前序`,且方向一致。 +""" +from __future__ import annotations + +import argparse +import os +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") +for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"): + os.environ.setdefault(v, "1") + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE.parent)) +pd.set_option("display.width", 320) + +SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48 +GATE_BP = 8.0 +WIN_MIN = 5 # 前序窗口,与 step48 一致 +OUT = HERE / "out" / "step49_cluster_oos.feather" +# 发现期:step48 用的就是最近这段 +IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai") + + +def collect(sym: str, rows: int): + import warnings as _w + _w.filterwarnings("ignore") + sys.path.insert(0, str(HERE)) + sys.path.insert(0, str(HERE.parent)) + from step48_signal_timing import collect as _c + return _c(sym, rows) + + +def split_stats(d: pd.DataFrame, label: str) -> pd.DataFrame: + from lib.exit_model import fee_of, taker_notional + + d = d.sort_values("date").reset_index(drop=True) + t = d.date.values.astype("datetime64[m]").astype(np.int64) + # 只看过去:严格早于本笔、且在 WIN_MIN 分钟内。这是开仓时真正可知的信息。 + prev = np.searchsorted(t, t, "left") - np.searchsorted(t, t - WIN_MIN, "left") + net = d.g.values - fee_of(d.r.values, d.c.values) + gR = d.g.values / (SL * d.atr_pct.values) + R = net / (SL * d.atr_pct.values) + tn = taker_notional(d.r.values, d.c.values) + + rows = [] + for m, lab in ((prev == 0, "无前序"), (prev >= 1, "有前序")): + if m.sum() < 15: + rows.append({"时段": label, "分组": lab, "笔数": int(m.sum()), "备注": "样本不足"}) + continue + nn, rr, gg, tt = net[m], R[m], gR[m], tn[m] + w, o = nn[nn > 0].sum(), -nn[nn <= 0].sum() + rows.append({ + "时段": label, "分组": lab, "笔数": int(m.sum()), + "占比": f"{m.mean()*100:.0f}%", + "胜率": f"{(nn > 0).mean()*100:.1f}%", + "毛R": round(gg.mean(), 3), "净均R": round(rr.mean(), 3), + "PF": round(w / o, 2) if o > 0 else np.inf, + "余量bp": round(nn.mean() / tt.mean() * 1e4, 2), + }) + return pd.DataFrame(rows) + + +def report(d: pd.DataFrame) -> None: + print("\n" + "=" * 100) + print("########## 逐时段:有前序 vs 无前序 ##########") + tabs, deltas = [], [] + d["半年"] = d.date.dt.to_period("2Q").astype(str) + periods = sorted(d["半年"].unique()) + for p in periods: + g = d[d["半年"] == p] + if len(g) < 60: + continue + t = split_stats(g, p) + tabs.append(t) + if len(t) == 2 and "余量bp" in t.columns and t["余量bp"].notna().all(): + a = t[t.分组 == "无前序"]["余量bp"].iloc[0] + b = t[t.分组 == "有前序"]["余量bp"].iloc[0] + ga = t[t.分组 == "无前序"]["毛R"].iloc[0] + gb = t[t.分组 == "有前序"]["毛R"].iloc[0] + deltas.append({"时段": p, "笔数": len(g), + "无前序余量": a, "有前序余量": b, "余量差": round(b - a, 2), + "无前序毛R": ga, "有前序毛R": gb, "毛R差": round(gb - ga, 3), + "方向": "✅ 同向" if b > a else "❌ 反向"}) + if tabs: + print(pd.concat(tabs, ignore_index=True).to_string(index=False)) + if deltas: + print("\n########## 判据:余量差是否稳定为正 ##########") + dd = pd.DataFrame(deltas) + print(dd.to_string(index=False)) + ok = (dd["余量差"] > 0).sum() + print(f"\n{len(dd)} 个时段中 {ok} 个方向一致(有前序余量更高)") + print(f"余量差 中位 {dd['余量差'].median():.2f}bp、均值 {dd['余量差'].mean():.2f}bp") + print(f"毛R差 中位 {dd['毛R差'].median():.3f}、均值 {dd['毛R差'].mean():.3f}") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA") + ap.add_argument("--rows", type=int, default=800_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(): + d = pd.read_feather(OUT) + else: + syms = [s.strip() for s in args.symbols.split(",")] + print(f"[簇内前序 样本外验证] {len(syms)} 币 × {args.rows} 根 1m\n", flush=True) + parts = [] + with ProcessPoolExecutor(max_workers=args.workers) as ex: + fut = {ex.submit(collect, s, args.rows): s for s in syms} + for i, f in enumerate(as_completed(fut), 1): + r = f.result() + print(f" [{i}/{len(syms)}] {fut[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) + d.to_feather(OUT) + + d["date"] = pd.to_datetime(d["date"]) + d = d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy() + print(f"实盘口径 {len(d)} 笔,{d.date.min():%Y-%m-%d} ~ {d.date.max():%Y-%m-%d}") + + oos = d[d.date < IS_START] + ins = d[d.date >= IS_START] + print(f" 发现期(step48 用过): {len(ins)} 笔") + print(f" 样本外 : {len(oos)} 笔") + + print("\n" + "=" * 100) + print("########## 样本外整体(发现期之前的全部数据)##########") + if len(oos) > 60: + print(split_stats(oos, "样本外").to_string(index=False)) + print("\n########## 发现期(复现 step48)##########") + if len(ins) > 60: + print(split_stats(ins, "发现期").to_string(index=False)) + report(d) + + +if __name__ == "__main__": + main()