用户假设「有资金的趋势才是好趋势」,预期开仓根成交量越大越好。成交量在信号根 收盘时可知,符合 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>
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
"""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()
|