Files
Chan/research/step48_signal_timing.py
T
jackyu66gitandCursor 2a59dcb9dc 扎堆开仓反而更赚,但好处全在「错开几分钟」那一档,同分钟的最差
承接上一条:既然同时开的必然同向,那关键是这批交易比孤立的好还是坏。
11 币 / 1161 笔 / 实盘口径:

  真孤立(±5 分钟内无同伴)  763 笔  胜率 65.5%  毛R 0.832  PF 2.83
  错开:5 分钟内但不同分钟   240 笔  胜率 82.1%  毛R 1.443  PF 7.31
  同一分钟撞在一起          158 笔  胜率 63.3%  毛R 0.777  PF 2.20

必须把两者分开——结论相反,混在一起会得出错误判断。错开的是全样本最好的
一档,同分钟的反而略差于孤立组。机制上:错开 = 行情从某个币扩散开,后发是
对先发的确认;同分钟 = 全市场同时被一个冲击打中,即追高。

簇级复核(±5 分钟合一簇,排除重复计数):多笔簇簇均毛R 1.180 vs 单笔簇
0.832,簇级 R 夏普 0.848 vs 0.459,结论不是重复计数撑起来的。多笔簇内
全赢 59.5%、全输 10.1%,簇内风险不可分散但偏度有利。

集中度上两类没差别(整簇同向 99.4%),差别纯在收益。所以「限制最多 N 个
并发仓位」把两类一视同仁是错的,它们期望收益差 1.9 倍。

注意:同分钟 vs 错开是看过数据后才划的切法,不是事先定的,208 天 158 个簇
容易切出噪声。当仓位规则用之前必须换一段时间验证。目前只有「扎堆整体更好」
是稳的(簇级也成立)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 04:24:31 +08:00

242 lines
10 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,
})
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()