用户提出看开仓之前的量与趋势方向。这是第三个位置——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>
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""Step 48:11 个币的开仓时刻到底挤不挤 —— 保证金要备多少、分散是不是真的。
|
||
|
||
用户问: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()
|