用户提出看开仓之前的量与趋势方向。这是第三个位置——step50 管信号根、 step52 管持仓中,这里管入场前。step48 采集端补 vpre10/vpre30/mom10/mom60。 先纠正一个错的心智模型(我和用户都以为的):B4/S4 不是回抽后进场。mom10 中位 +3.47 ATR,为负的只占 6%。信号触发时价格在前 10 根里已经顺着你的方向 走了三个多 ATR,2 ATR 止损是架在一段已经走完的行情后面。这也解释了 step50: 信号根是突破根,放量 = 追在最后一棒上。 ① 入场前的量单调,越小越好(样本外毛R,Q1→Q4):1.474 / 1.266 / 1.156 / 0.573。控 vr60 后仍成立(低量层 −0.284、高量层 −0.541),与 vr60 相关只有 +0.301,不是同一件事换个说法。 ② 入场前的动量是驼峰形,不是单调(mom60 样本外毛R,Q1→Q4):1.252 / 1.473 / 1.141 / 0.603。势要有——完全没动过的 Q1 也不如 Q2;但不能过头—— Q4 在发现期余量只剩 1.44bp,等于不能做。 驼峰形意味着中位数二分法会把它测没:控制表里 mom10 的毛R差是 +0.044, 看着无效,那是二分把 Q1+Q2 和 Q3+Q4 各自平均了。对非单调因子不要用中位数 分层做检验。 ③ 砍 mom60≥7 全面优于 §3.39 定的砍 vr60≥4(发现期): 等权 保留 100% 盈亏平衡 13.5bp R夏普 0.325 回撤 16.8 总R 596.6 砍 vr60≥4 保留 69% 盈亏平衡 15.6bp R夏普 0.411 回撤 10.8 总R 514.9 砍 mom60≥7 保留 78% 盈亏平衡 17.5bp R夏普 0.476 回撤 7.7 总R 657.7 每一项都赢,还多留 9 个点的笔数。更要紧的是总R 比不砍还高——被砍掉那 22% 期望为负,砍掉不是花钱买稳健,是纯赚。样本外同向。所以这个开关不需要等 影子测量:它在 0~20bp 每一档都不输等权。 附滑点决策表:5~12bp 区间砍 mom60≥7 通吃,只有 ≥15bp 才该上「三个都砍」, 而那时策略本身已在生死线上。 阈值 7 和 1.5 是贴着 Q4 边界取的整数,不是搜出来的,但也不是完全无关于数据 (看过分位表才取的整),上线前应确认阈值附近没有断崖敏感。 Co-authored-by: Cursor <cursoragent@cursor.com>
170 lines
7.0 KiB
Python
170 lines
7.0 KiB
Python
"""Step 53:开仓**之前**那段的量与走势方向。
|
||
|
||
用户提出的第三个位置。前两个已有定论:
|
||
step50 信号根放量 → 差(那根你是买方,付的是资金已推到的价)
|
||
step52 持仓中放量 → 好(那是资金来接你的货)
|
||
本步问:入场之前那几根呢?资金是不是已经在里面了。
|
||
|
||
这跟 B4/S4 的结构直接相关。它是回抽后转强,所以入场前那几根**通常逆着你走**
|
||
(mom10 多为负,负得多 = 回抽深)。那么「回抽时缩量」与「回抽时放量」是有
|
||
明确含义的区分——前者是没人卖,后者是真有人在卖。
|
||
|
||
四个因子,全部在信号根收盘时可知:
|
||
vpre10 / vpre30 前 10 / 30 根的相对量均值(已 shift(1),不含信号根)
|
||
mom10 / mom60 顺方向动量,ATR 为单位
|
||
|
||
⚠️ 必须控 `vr60`(信号根自身的量)。step50 已证明它是强负因子,不控的话
|
||
前段量会通过相关性借它的力,看着有效其实是同一件事。
|
||
"""
|
||
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
|
||
OUT = HERE / "out" / "step53_pre_entry.feather"
|
||
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
|
||
FACTORS = [("vpre10", "前10根量"), ("vpre30", "前30根量"),
|
||
("mom10", "前10根顺向动量"), ("mom60", "前60根顺向动量")]
|
||
|
||
|
||
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 stat(g: pd.DataFrame, lab: str, denom: int) -> dict:
|
||
if len(g) < 40:
|
||
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 quartiles(d: pd.DataFrame, col: str, name: str, label: str) -> None:
|
||
x = d[d[col].notna() & np.isfinite(d[col])]
|
||
if len(x) < 300:
|
||
print(f" {name}: 样本不足")
|
||
return
|
||
x = x.copy()
|
||
x["bin"] = pd.qcut(x[col], 4, labels=["Q1最低", "Q2", "Q3", "Q4最高"])
|
||
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, "中位", [med.get(b) for b in t["分组"]])
|
||
print(f"\n--- {label} / {name}({col})---")
|
||
print(t.to_string(index=False))
|
||
|
||
|
||
def control_vr60(d: pd.DataFrame, col: str, name: str) -> None:
|
||
"""在信号根量的高/低两半内部各切一次,看因子是否还独立成立。"""
|
||
x = d[d[col].notna() & np.isfinite(d[col]) & d.vr60.notna()].copy()
|
||
x["vr60半"] = np.where(x.vr60 >= x.vr60.median(), "信号根高量", "信号根低量")
|
||
rows = []
|
||
for half, g in x.groupby("vr60半"):
|
||
g = g.copy()
|
||
g["h"] = pd.qcut(g[col], 2, labels=["低", "高"])
|
||
lo, hi = g[g.h == "低"], g[g.h == "高"]
|
||
if min(len(lo), len(hi)) < 40:
|
||
continue
|
||
rows.append({"因子": name, "控制层": half, "笔数": 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 rows:
|
||
print(pd.DataFrame(rows).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)
|
||
|
||
from step50_volume import prep
|
||
|
||
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(f"回抽确认:mom10 中位 {d.mom10.median():+.2f} ATR,"
|
||
f"为负的占 {(d.mom10 < 0).mean()*100:.0f}%")
|
||
|
||
print("\n" + "=" * 118)
|
||
print("########## 一、四个因子各自看(样本外 / 发现期)##########")
|
||
for col, name in FACTORS:
|
||
for lab, part in (("样本外", oos), ("发现期", ins)):
|
||
quartiles(part, col, name, lab)
|
||
|
||
print("\n" + "=" * 118)
|
||
print("########## 二、控信号根自身的量(vr60)后是否还成立 ##########")
|
||
for col, name in FACTORS:
|
||
control_vr60(d, col, name)
|
||
|
||
print("\n" + "=" * 118)
|
||
print("########## 三、缩量回抽 vs 放量回抽 ##########")
|
||
x = d[d.vpre10.notna() & np.isfinite(d.vpre10) & d.mom10.notna()].copy()
|
||
x["回抽"] = np.where(x.mom10 < 0, "回抽(逆向)", "顺向进场")
|
||
x["前段量"] = np.where(x.vpre10 >= x.vpre10.median(), "放量", "缩量")
|
||
rows = []
|
||
for a in ("回抽(逆向)", "顺向进场"):
|
||
for b in ("缩量", "放量"):
|
||
g = x[(x.回抽 == a) & (x.前段量 == b)]
|
||
rows.append(stat(g, f"{a} × {b}", len(x)))
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
print("\n 与信号根量的相关性(防止是同一件事换个说法)")
|
||
for col, name in FACTORS:
|
||
v = x[[col, "vr60"]].dropna()
|
||
print(f" {name:<14} vs vr60 r = {v[col].corr(v.vr60):+.3f}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|