成交量假设方向是反的:高量入场毛R 0.794,低量 1.421

用户假设「有资金的趋势才是好趋势」,预期开仓根成交量越大越好。成交量在信号根
收盘时可知,符合 step48 立的「只用开仓时已知信息」纪律,是合法的可交易切法。
10 币 × 80 万根、实盘口径 3941 笔,结论与假设相反。

按 vr60(当根量 / 前 60 根均量)四分位:

  量最低(中位 0.63)  毛R 1.421  余量 27.18bp   ← 样本外
  量最高(中位 5.34)  毛R 0.794  余量 13.47bp

单调递减,且样本内外、两套量比基准(前 10 根 / 前 60 根)全部同向。稳健性达到
step49 那条的标准:ATR 四分位 4/4 同向、逐时段 7/7 同向,不是 ATR 换脸。

机制在出场结构里,伤害全在止损命中率:

  量最低  止盈 33.5%  止损 22.5%  超时 44.0%  赢时均R 1.830  亏时均R −1.098
  量最高  止盈 26.1%  止损 45.3%  超时 28.6%  赢时均R 1.651  亏时均R −1.106

亏损幅度四档全是 −1.10(止损就是止损),赢时均R 只降 10%,止损率翻倍是全部
损失来源。这里有个判别点:若只是「2 ATR 止损相对突然放大的波动太窄」的尺度
错配,超时单应按原比例分流进止盈和止损两侧;实际是超时(−15.4pp)和止盈
(−7.4pp)一起流进止损(+22.8pp)。方向本身在变差,不只是止损太窄。

为什么直觉会反:B4/S4 在突破根上进场。大量根意味着这一冲已经由别人的资金
完成,你在它的收盘价接手。「有资金」要能获利必须在资金到达之前进场,不是同时。

与「有前序」是两件独立的事(有前序组 vr10 中位 1.76 vs 无前序 1.46),可叠加:
低量 × 有前序 253 笔,毛R 1.398、余量 31.00bp,是目前见过最宽的执行容忍度。

step48 的采集加 vr10/vr60 两列。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-28 05:08:03 +08:00
co-authored by Cursor
parent 2b9a837a58
commit ef584b8d49
3 changed files with 256 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
"""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()