Files
Chan/research/step48_signal_timing.py
T
jackyu66gitandCursor 06928d1d5f 开仓前那段:量要小、势要有但不能过头,换掉 vr60 那个开关
用户提出看开仓之前的量与趋势方向。这是第三个位置——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>
2026-08-28 15:55:49 +08:00

274 lines
12 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 4811 个币的开仓时刻到底挤不挤 —— 保证金要备多少、分散是不是真的。
用户问:11 个币对开仓时间差距有多少,不是同时开吧。
这个数决定三件事:
1. 保证金峰值。若真是同时开 11 个,按每笔固定风险算的名义额会叠到很高
2. "多币 = 分散"是否成立。§3.31 已知同时发生的信号 92.7% 同向——
那种情况下多开的仓不是分散,是加杠杆
3. 资金利用率。若大部分时间空仓,那"资金量不够"就不是真约束
口径与实盘一致:深色(同向 ∧ 阶梯)∧ ATR≥8bp,持仓按回测实际出场根数
(1m 上 1 根 = 1 分钟),不是一律按 48 根上限。
"""
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
OUT = HERE / "out" / "step48_signal_times.feather"
def collect(sym: str, rows: int) -> pd.DataFrame | None:
import warnings as _w
_w.filterwarnings("ignore")
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent))
from chanlun import TF_DF
from chanlun.analysis.fast_bsp import (
add_zone_ladder, attach_htf_agree, attach_zone_ladder,
build_htf_zones, find_fast_bsp3, htf_fx_timeline,
)
from lib.data import fetch_ohlcv
from lib.exit_model import cfg_name, walk_exits
try:
df = fetch_ohlcv(f"{sym}/USDT:USDT", "1m", rows)
if df is None or len(df) < 50_000:
return None
chan = TF_DF(df, 1, "1m", lean=True)
cdf = chan.dataframe
zones = build_htf_zones(cdf, "1m", chan=chan)
if zones.empty:
return None
zl = add_zone_ladder(zones.reset_index(drop=True))
sig = find_fast_bsp3(cdf, zl)
if sig.empty:
return None
dh = fetch_ohlcv(f"{sym}/USDT:USDT", "5m", 10 ** 9)
ch = TF_DF(dh, 1, "5m", lean=True)
sig = attach_zone_ladder(attach_htf_agree(sig, cdf, htf_fx_timeline(ch, ch.dataframe)), zl)
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB],
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
idx = sig["entry_idx"].to_numpy().astype(int)
atr = cdf["atr"].to_numpy(float)[idx]
close = cdf["close"].to_numpy(float)[idx]
vol = cdf["volume"]
vr = vol / vol.rolling(60, min_periods=10).mean()
vr60 = vr.to_numpy(float)
# shift(1) 把信号根自己排除在外
vpre10 = vr.rolling(10, min_periods=5).mean().shift(1).to_numpy(float)
vpre30 = vr.rolling(30, min_periods=15).mean().shift(1).to_numpy(float)
c_all = cdf["close"].to_numpy(float)
a_all = cdf["atr"].to_numpy(float)
d_all = sig["direction"].to_numpy().astype(int)
def mom(k: int) -> np.ndarray:
"""顺方向动量(ATR 单位)。用全序列算好再取下标,避免逐笔切片。"""
prev = np.concatenate([np.full(k, np.nan), c_all[:-k]])
with np.errstate(invalid="ignore", divide="ignore"):
m = (c_all - prev) / a_all
out = np.full(len(idx), np.nan)
out[:] = m[idx] * d_all
return out
return pd.DataFrame({
"sym": sym,
"date": cdf["date"].to_numpy()[idx],
"dir": sig["direction"].to_numpy(),
"atr_bp": atr / close * 1e4,
"htf": sig["htf_agree"].to_numpy(),
"lad": sig["ladder_ok"].to_numpy(),
"hold": res[f"{cfg}_b"].to_numpy(),
# 毛利与出场原因:扎堆的交易赚不赚钱要靠这几列,别只存时刻
"g": res[f"{cfg}_g"].to_numpy(),
"r": res[f"{cfg}_r"].to_numpy(),
"c": res[f"{cfg}_c"].to_numpy(),
"atr_pct": atr / close,
# 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。
# vr10 是引擎自带口径(前 10 根均量),vr60 换个基准做稳健性对照。
"vr10": cdf["volume_ratio"].to_numpy(float)[idx],
"vr60": vr60[idx],
# 开仓**之前**那段的量与走势。vpre 已 shift(1),不含信号根本身——
# 信号根的量单独由 vr60 承担,两者混在一起就分不清是哪一段在起作用。
"vpre10": vpre10[idx],
"vpre30": vpre30[idx],
# 顺方向动量,ATR 为单位。B4/S4 是回抽后转强,所以 mom10 多为负
# (入场前那几根逆着你走);负得多 = 回抽深。
"mom10": mom(10),
"mom60": mom(60),
})
except Exception as e:
print(f" {sym} 失败: {e!r}", flush=True)
return None
def analyse(d: pd.DataFrame, label: str) -> None:
d = d.sort_values("date").reset_index(drop=True)
span = (d.date.max() - d.date.min()).total_seconds() / 86400
print("\n" + "=" * 96)
print(f"########## {label}{d.sym.nunique()} 币 / {len(d)} 笔 / 跨 {span:.0f} 天 ##########")
print(f"组合 {len(d) / span:.2f} 笔每天 —— 平均每 {span * 24 / len(d):.1f} 小时才 1 笔")
g = d.date.diff().dt.total_seconds().dropna() / 60
print("\n相邻两笔间隔(分钟)")
qs = [(q, g.quantile(q)) for q in (0.05, 0.10, 0.25, 0.50, 0.75, 0.90)]
print(" " + " ".join(f"{int(q*100)}%:{v:.0f}" for q, v in qs))
for lab, m in (("同一分钟", g == 0), ("≤5 分钟", g <= 5),
("≤60 分钟", g <= 60), (">1 小时", g > 60)):
print(f" {lab:<9}{m.sum():>5}{m.mean()*100:>5.1f}%")
# 并发持仓:按回测实际持仓根数
ev = []
for t, h in zip(d.date, d.hold):
ev.append((t, 1))
ev.append((t + pd.Timedelta(minutes=float(h)), -1))
ev.sort()
cur, prev, dur = 0, None, {}
for t, delta in ev:
if prev is not None and t > prev:
dur[cur] = dur.get(cur, 0) + (t - prev).total_seconds()
cur += delta
prev = t
tot = sum(dur.values())
print("\n同时持仓数的时间占比")
for k in sorted(dur):
if dur[k] / tot > 0.0005:
print(f" {k:>2} 个: {dur[k]/tot*100:>5.1f}%")
print(f" 最大并发 {max(dur)} 个;有仓位的时间仅占 {(1-dur.get(0,0)/tot)*100:.1f}%")
# 同时开仓时的方向一致性——这决定"多币"到底是分散还是加杠杆
same = d.groupby(d.date)["dir"].agg(["count", "nunique"])
multi = same[same["count"] > 1]
if len(multi):
agree = (multi["nunique"] == 1).mean()
print(f"\n同一分钟出现多笔的时刻 {len(multi)} 个,其中方向完全一致的占 "
f"{agree*100:.1f}% —— 这部分不是分散,是同一笔押注被拆成几个币")
if "g" in d.columns:
profit_by_cluster(d)
def profit_by_cluster(d: pd.DataFrame) -> None:
"""扎堆的交易赚不赚钱。
必须把「同一分钟」和「错开几分钟」分开看——8 币预试中两者结论相反:
错开的是全样本最好的一档,同分钟的反而略差于孤立。混在一起会得出错误结论。
"""
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)
n0 = np.searchsorted(t, t, "right") - np.searchsorted(t, t, "left")
n5 = np.searchsorted(t, t + 5, "right") - np.searchsorted(t, t - 5, "left")
net = d.g.values - fee_of(d.r.values, d.c.values)
R = net / (SL * d.atr_pct.values)
gR = d.g.values / (SL * d.atr_pct.values)
tn = taker_notional(d.r.values, d.c.values)
def row(m, lab):
if m.sum() < 20:
return {"分组": lab, "笔数": int(m.sum()), "备注": "样本不足"}
nn, rr, gg, tt = net[m], R[m], gR[m], tn[m]
w, o = nn[nn > 0].sum(), -nn[nn <= 0].sum()
return {"分组": 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),
"R夏普": round(rr.mean() / rr.std(ddof=1), 3),
"PF": round(w / o, 2) if o > 0 else np.inf,
"余量bp": round(nn.mean() / tt.mean() * 1e4, 2)}
print("\n扎堆的交易赚不赚钱")
print(pd.DataFrame([
row((n0 == 1) & (n5 == 1), "真孤立(±5 分钟内无同伴)"),
row((n0 == 1) & (n5 > 1), "错开:5 分钟内有同伴但不同分钟"),
row(n0 > 1, "同一分钟撞在一起"),
]).to_string(index=False))
# 簇级:同一波行情里的几笔高度相关,逐笔统计会把有效样本算多
clu = (d.date.diff().dt.total_seconds().fillna(9e9) > 300).cumsum()
c = pd.DataFrame({"clu": clu, "R": R, "gR": gR, "net": net}).groupby("clu")
agg = c.agg(n=("R", "size"), R=("R", "mean"), gR=("gR", "mean"))
print(f"\n簇级(±5 分钟合为一簇,避免重复计数):单笔簇 {(agg.n==1).sum()}、"
f"多笔簇 {(agg.n>1).sum()}")
for lab, m in (("单笔簇", agg.n == 1), ("多笔簇", agg.n > 1)):
g = agg[m]
if len(g) < 10:
continue
print(f" {lab:<5} {len(g):>4} 簇 簇均毛R {g.gR.mean():.3f} "
f"簇均净R {g.R.mean():.3f} 簇级R夏普 {g.R.mean()/g.R.std(ddof=1):.3f}")
win = c["net"].apply(lambda s: (s > 0).all())
lose = c["net"].apply(lambda s: (s <= 0).all())
multi_idx = agg.index[agg.n > 1]
if len(multi_idx):
aw, al = win[multi_idx].mean(), lose[multi_idx].mean()
print(f" 多笔簇内:全赢 {aw*100:.1f}%、全输 {al*100:.1f}%、"
f"有赢有输 {(1-aw-al)*100:.1f}% —— 簇内风险不可分散,但偏度有利")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA,TRX")
ap.add_argument("--rows", type=int, default=300_000)
ap.add_argument("--workers", type=int, default=3)
ap.add_argument("--reuse", action="store_true", help="直接读已存的 feather")
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"])
dark = (d["htf"] == 1.0) & d["lad"]
analyse(d[dark & (d.atr_bp >= GATE_BP)], "实盘口径:深色 ∧ ATR≥8bp")
analyse(d[dark], "对照:深色但不加 ATR 门控")
print("\n########## 逐币笔数(实盘口径)##########")
dd = d[dark & (d.atr_bp >= GATE_BP)]
t = dd.groupby("sym").agg(笔数=("date", "size"), 中位持仓分钟=("hold", "median")).sort_values("笔数", ascending=False)
span = (dd.date.max() - dd.date.min()).total_seconds() / 86400
t["每天笔数"] = (t["笔数"] / span).round(3)
print(t.to_string())
if __name__ == "__main__":
main()