"""Step 50:信号根的成交量是否预测质量 ——「有资金的趋势才是好趋势」。 用户假设:开仓时刻的成交量越大,说明有真金白银在推,趋势更可信。 这个假设满足 §3.31 立的纪律:信号根已收盘,其成交量在开仓那一刻可知, 是合规的可交易信息,不像「我是簇里第几个」那样含未来。 只测两件事,不做穷举(组合空间大,多测必出假阳性): 主假设 相对成交量越高,毛R 与滑点余量越好 次假设 它与 §3.31 的「有前序信号」是同一件事,还是两件独立的事 口径与 step48/49 一致。发现期(2026-01-30 起)与样本外分开报, 主假设若只在其中一边成立就不算通过。 """ 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", 340) SL = 2.0 GATE_BP = 8.0 WIN_MIN = 5 OUT = HERE / "out" / "step50_volume.feather" 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 prep(d: pd.DataFrame) -> 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) d["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) d["net"] = net d["gR"] = d.g.values / (SL * d.atr_pct.values) d["R"] = net / (SL * d.atr_pct.values) d["tn"] = taker_notional(d.r.values, d.c.values) return d def stat(g: pd.DataFrame, lab: str, denom: int) -> dict: if len(g) < 25: return {"分组": lab, "笔数": len(g), "备注": "样本不足"} w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum() return {"分组": lab, "笔数": len(g), "占比": f"{len(g)/denom*100:.0f}%", "胜率": f"{(g.net > 0).mean()*100:.1f}%", "毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3), "PF": round(w / o, 2) if o > 0 else np.inf, "余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2)} def by_volume(d: pd.DataFrame, col: str, label: str) -> None: x = d[d[col].notna() & np.isfinite(d[col])] if len(x) < 200: print(f" {label}: 样本不足") return x = x.copy() x["bin"] = pd.qcut(x[col], 4, labels=["量最低", "量中低", "量中高", "量最高"]) rows = [stat(g, str(b), len(x)) for b, g in x.groupby("bin", observed=True)] t = pd.DataFrame(rows) med = x.groupby("bin", observed=True)[col].median().round(2).to_dict() t.insert(1, f"{col}中位", [med.get(b) for b in t["分组"]]) print(f"\n--- {label} ---") print(t.to_string(index=False)) 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 = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy()) oos, ins = d[d.date < IS_START], d[d.date >= IS_START] print(f"\n实盘口径 {len(d)} 笔 | 样本外 {len(oos)} 发现期 {len(ins)}") print("\n" + "=" * 104) print("########## 主假设:相对成交量越高越好? ##########") for lab, part in (("样本外", oos), ("发现期", ins)): print(f"\n===== {lab} =====") by_volume(part, "vr10", f"{lab} / 量比 vs 前 10 根") by_volume(part, "vr60", f"{lab} / 量比 vs 前 60 根(换基准对照)") print("\n" + "=" * 104) print("########## 次假设:成交量与「有前序」是不是同一件事 ##########") x = d[d.vr10.notna() & np.isfinite(d.vr10)].copy() x["高量"] = x.vr10 >= x.vr10.median() x["有前序"] = x.prev >= 1 print("\n2×2(全样本)") rows = [] for hv in (False, True): for pv in (False, True): g = x[(x.高量 == hv) & (x.有前序 == pv)] rows.append(stat(g, f"{'高量' if hv else '低量'} × {'有前序' if pv else '无前序'}", len(x))) print(pd.DataFrame(rows).to_string(index=False)) print("\n" + "=" * 104) print("########## 混淆排查:量效应是不是 ATR 效应换了张脸 ##########") y = d[d.vr60.notna() & np.isfinite(d.vr60)].copy() y["atrQ"] = pd.qcut(y.atr_bp, 4, labels=["ATR-Q1", "Q2", "Q3", "Q4"]) rows = [] for q, g in y.groupby("atrQ", observed=True): g = g.copy() g["vq"] = pd.qcut(g.vr60, 2, labels=["低量", "高量"]) lo, hi = g[g.vq == "低量"], g[g.vq == "高量"] if min(len(lo), len(hi)) < 25: continue rows.append({"ATR分位": str(q), "笔数": len(g), "低量毛R": round(lo.gR.mean(), 3), "高量毛R": round(hi.gR.mean(), 3), "毛R差": round(hi.gR.mean() - lo.gR.mean(), 3), "低量余量": round(lo.net.mean() / lo.tn.mean() * 1e4, 2), "高量余量": round(hi.net.mean() / hi.tn.mean() * 1e4, 2), "方向": "低量更好" if lo.gR.mean() > hi.gR.mean() else "高量更好"}) print(pd.DataFrame(rows).to_string(index=False)) print("\n########## 逐时段稳定性(与 step49 同一根标尺)##########") y["半年"] = y.date.dt.to_period("2Q").astype(str) rows = [] for p, g in y.groupby("半年", observed=True): if len(g) < 120: continue g = g.copy() g["vq"] = pd.qcut(g.vr60, 2, labels=["低量", "高量"]) lo, hi = g[g.vq == "低量"], g[g.vq == "高量"] rows.append({"时段": p, "笔数": len(g), "低量毛R": round(lo.gR.mean(), 3), "高量毛R": round(hi.gR.mean(), 3), "低量余量": round(lo.net.mean() / lo.tn.mean() * 1e4, 2), "高量余量": round(hi.net.mean() / hi.tn.mean() * 1e4, 2), "方向": "低量更好" if lo.gR.mean() > hi.gR.mean() else "高量更好"}) t = pd.DataFrame(rows) print(t.to_string(index=False)) if len(t): print(f"\n{len(t)} 个时段中 {(t.方向 == '低量更好').sum()} 个低量更好") print("\n两者的相关性") print(f" 有前序组 vr10 中位 {x[x.有前序].vr10.median():.2f}" f" vs 无前序组 {x[~x.有前序].vr10.median():.2f}") print(f" 高量组里有前序占 {x[x.高量].有前序.mean()*100:.1f}%" f" vs 低量组 {x[~x.高量].有前序.mean()*100:.1f}%") if __name__ == "__main__": main()