Files
Chan/research/step48_signal_timing.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

247 lines
11 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]
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": (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean()
).to_numpy(float)[idx],
})
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()