Files
Chan/research/step48_signal_timing.py
T
jackyu66gitandCursor 9f4d7fec73 11 币的开仓时刻:不是同时开,但同时开的那批 100% 同向
用户问 11 个币的开仓时间差距。1161 笔 / 208 天 / 实盘口径(深色 ∧ ATR≥8bp):

相邻两笔间隔中位 122 分钟,62.8% 超过 1 小时,每天仅 5.58 笔。
同时持仓数:90.9% 的时间空仓,7.5% 只有 1 仓,≥2 仓合计 1.7%,峰值 8。

所以绝大多数时候不会撞车,但左尾是硬的:7.4% 与前一笔同分钟、20.7% 在
5 分钟内。而同一分钟出现多笔的 72 个时刻里,方向完全一致的占 100%
(§3.31 此前测到的是 92.7%,全样本下更极端)。

这批同时开的仓不是分散,是同一笔押注被拆到几个币上做——名义 3 个仓位,
实质 3 倍单向敞口。由此两条:保证金不是约束(91% 时间空仓,峰值并发只占
0.1% 的时间),真问题是资金闲置;并发上限必须按同向净敞口设,按仓位个数
设等于默许成倍的单向敞口。

另:TRX 在实盘口径下 208 天只有 5 笔,ATR 门控几乎全刷掉,应从币池剔除,
实际可用是 10 个币。

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

177 lines
7.0 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(),
})
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}% —— 这部分不是分散,是同一笔押注被拆成几个币")
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()