step48 那条唯一可交易的线索(开仓时过去 5 分钟内已有别的币发过信号, 则滑点余量更高)用更长历史验证。10 币 × 80 万根,2025-02 ~ 2026-08 共 3941 笔,发现期 1161 笔、样本外 2780 笔。 样本外 无前序 2326 笔 毛R 1.086 PF 3.91 余量 18.83bp 样本外 有前序 454 笔 毛R 1.275 PF 4.60 余量 24.89bp 逐时段 6/6 全部同向,余量差中位 8.03bp。发现期毛R 差只有 0.085,样本外 放大到 0.189——不是过拟合衰减,是发现期恰好偏保守。占比各时段 13~19%,很稳。 ATR 混淆已排除:有前序的 ATR 确实略高(中位 15.04 vs 13.13bp),但按四分位 分层后 4/4 层同向,层内余量差 3.29/8.39/5.16/4.14bp,与不分层的 +6.06 同 量级。毛R 本身是 ATR 归一化指标,其 +0.19 不可能是 ATR 假象。 可用方式:这 16% 的信号多容忍约 6bp 执行成本,可加仓位权重,或对这批放宽 ATR 门控(最低 ATR 层里有前序余量仍有 14.92bp vs 对照 11.63)。放宽门控尚未 回测,先别改。 Co-authored-by: Cursor <cursoragent@cursor.com>
162 lines
6.6 KiB
Python
162 lines
6.6 KiB
Python
"""Step 49:簇内「有无前序信号」的样本外验证。
|
||
|
||
step48 在最近 208 天上发现:开仓时若过去 5 分钟内已有别的币发过信号,
|
||
该笔的滑点余量 21.88bp,明显高于无前序的 14.13bp(+55%)。毛R 只差 8.8%,
|
||
所以值得追的是余量这一条——1m 的生死线就在执行成本上。
|
||
|
||
但那是发现期内的数字,且「过去 5 分钟」这个窗口是我挑的。本步用更长的历史,
|
||
把发现期之外的时段单独拿出来看同一个差距还在不在。
|
||
|
||
只验这一个假设。158 个簇撑不起更多——每多验一个,假阳性概率就涨一截。
|
||
判据:OOS 各时段里 `有前序` 的余量应稳定高于 `无前序`,且方向一致。
|
||
"""
|
||
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", 320)
|
||
|
||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||
GATE_BP = 8.0
|
||
WIN_MIN = 5 # 前序窗口,与 step48 一致
|
||
OUT = HERE / "out" / "step49_cluster_oos.feather"
|
||
# 发现期:step48 用的就是最近这段
|
||
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 split_stats(d: pd.DataFrame, label: str) -> 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)
|
||
# 只看过去:严格早于本笔、且在 WIN_MIN 分钟内。这是开仓时真正可知的信息。
|
||
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)
|
||
gR = d.g.values / (SL * d.atr_pct.values)
|
||
R = net / (SL * d.atr_pct.values)
|
||
tn = taker_notional(d.r.values, d.c.values)
|
||
|
||
rows = []
|
||
for m, lab in ((prev == 0, "无前序"), (prev >= 1, "有前序")):
|
||
if m.sum() < 15:
|
||
rows.append({"时段": label, "分组": lab, "笔数": int(m.sum()), "备注": "样本不足"})
|
||
continue
|
||
nn, rr, gg, tt = net[m], R[m], gR[m], tn[m]
|
||
w, o = nn[nn > 0].sum(), -nn[nn <= 0].sum()
|
||
rows.append({
|
||
"时段": label, "分组": lab, "笔数": int(m.sum()),
|
||
"占比": f"{m.mean()*100:.0f}%",
|
||
"胜率": f"{(nn > 0).mean()*100:.1f}%",
|
||
"毛R": round(gg.mean(), 3), "净均R": round(rr.mean(), 3),
|
||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||
"余量bp": round(nn.mean() / tt.mean() * 1e4, 2),
|
||
})
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
def report(d: pd.DataFrame) -> None:
|
||
print("\n" + "=" * 100)
|
||
print("########## 逐时段:有前序 vs 无前序 ##########")
|
||
tabs, deltas = [], []
|
||
d["半年"] = d.date.dt.to_period("2Q").astype(str)
|
||
periods = sorted(d["半年"].unique())
|
||
for p in periods:
|
||
g = d[d["半年"] == p]
|
||
if len(g) < 60:
|
||
continue
|
||
t = split_stats(g, p)
|
||
tabs.append(t)
|
||
if len(t) == 2 and "余量bp" in t.columns and t["余量bp"].notna().all():
|
||
a = t[t.分组 == "无前序"]["余量bp"].iloc[0]
|
||
b = t[t.分组 == "有前序"]["余量bp"].iloc[0]
|
||
ga = t[t.分组 == "无前序"]["毛R"].iloc[0]
|
||
gb = t[t.分组 == "有前序"]["毛R"].iloc[0]
|
||
deltas.append({"时段": p, "笔数": len(g),
|
||
"无前序余量": a, "有前序余量": b, "余量差": round(b - a, 2),
|
||
"无前序毛R": ga, "有前序毛R": gb, "毛R差": round(gb - ga, 3),
|
||
"方向": "✅ 同向" if b > a else "❌ 反向"})
|
||
if tabs:
|
||
print(pd.concat(tabs, ignore_index=True).to_string(index=False))
|
||
if deltas:
|
||
print("\n########## 判据:余量差是否稳定为正 ##########")
|
||
dd = pd.DataFrame(deltas)
|
||
print(dd.to_string(index=False))
|
||
ok = (dd["余量差"] > 0).sum()
|
||
print(f"\n{len(dd)} 个时段中 {ok} 个方向一致(有前序余量更高)")
|
||
print(f"余量差 中位 {dd['余量差'].median():.2f}bp、均值 {dd['余量差'].mean():.2f}bp")
|
||
print(f"毛R差 中位 {dd['毛R差'].median():.3f}、均值 {dd['毛R差'].mean():.3f}")
|
||
|
||
|
||
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 = d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy()
|
||
print(f"实盘口径 {len(d)} 笔,{d.date.min():%Y-%m-%d} ~ {d.date.max():%Y-%m-%d}")
|
||
|
||
oos = d[d.date < IS_START]
|
||
ins = d[d.date >= IS_START]
|
||
print(f" 发现期(step48 用过): {len(ins)} 笔")
|
||
print(f" 样本外 : {len(oos)} 笔")
|
||
|
||
print("\n" + "=" * 100)
|
||
print("########## 样本外整体(发现期之前的全部数据)##########")
|
||
if len(oos) > 60:
|
||
print(split_stats(oos, "样本外").to_string(index=False))
|
||
print("\n########## 发现期(复现 step48)##########")
|
||
if len(ins) > 60:
|
||
print(split_stats(ins, "发现期").to_string(index=False))
|
||
report(d)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|