Files
Chan/research/step50_volume.py
T
jackyu66gitandCursor ef584b8d49 成交量假设方向是反的:高量入场毛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>
2026-08-28 05:08:03 +08:00

191 lines
7.9 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.
"""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()