用户问哪个时段更容易盈利。答案分两半:时段这个切法本身无效,换成星期几才 有东西,而那东西不该做成新过滤。 ① 亚/欧/美三等分(UTC 0/8/16),毛R 1.127 / 1.006 / 1.079,段间极差 0.122, 置换检验 p=0.0895——随便把 24 小时切三份,9% 的概率能切出这么大的差。更要命 的是美盘两个时期反号:样本外 1.158(最好)→ 发现期 0.885(最差),噪声的 典型指纹。按真实开盘时刻切五段、把欧美重叠单列,结论一样。 这里不是输给 ATR 混淆。亚盘 ATR 中位确实最低(12.5 vs 14.1),本来最该是 混淆源,但控 ATR 后段间极差 0.101/0.108,和无条件的 0.122 几乎一样——时段 不是 ATR 的代理,它本来就小。 ② 星期几有信号,集中在周日:毛R 0.893、胜率 66.6%、PF 2.76、余量 13.08bp, 对照周五 1.228 / 74.7% / 4.78 / 23.44。置换检验周日 p=0.0079、周末 p=0.0246, 两个时期方向一致,10 个币里 7 个周末更差(BTC 最甚 -0.336)。但幅度在发现期 塌了大半(-0.168 → -0.039)。 ③ 关键在重叠。施加 step53 的 mom60<7 之后,周末差从 -0.132 缩到 -0.058、 周日从 -0.199 缩到 -0.104。重叠不在笔数上(mom60≥7 在周末占 22.9%、工作日 21.3%,几乎一样),是伤害重叠:周末真正亏钱的是那些追已走完行情的单子。 周末流动性薄,追高的代价被放大——这和 §3.391「势不能过头」是同一件事在另一 个维度上的投影。 ④ 所以不加。决策表(发现期总R)显示 mom60≥7 + 周日 在 0~20bp 每一档都输给 mom60≥7 单用(5bp: 574 vs 658;10bp: 343 vs 394;15bp: 113 vs 131),叠加 只是白丢 10% 笔数。单用砍周日也要 12bp 以上才赢过等权。 这一步的价值是排除。「美盘流动性好该更赚」这种直觉很难自证伪,跑完才知道它 连随机切分都跑不赢;而顺手捞到的周日效应统计上真实,却因与已有过滤重叠而 不可加——显著和值得做是两件事,中间隔着一张决策表。 分析全部复用 step53 的 feather,未重跑采集。 Co-authored-by: Cursor <cursoragent@cursor.com>
353 lines
15 KiB
Python
353 lines
15 KiB
Python
"""Step 54:开仓时刻落在哪个时段——亚盘 / 欧盘 / 美盘谁更赚。
|
||
|
||
用户问的是时段,但这个问题有两个必须先堵的坑,否则很容易得出一个假的结论:
|
||
|
||
① ATR 混淆。亚盘波动天然低,而低 ATR 的信号因固定成本吃亏是已知结论
|
||
(HANDOFF §3.5:余量 = 净收益 / taker名义额,ATR 越小分母越小)。
|
||
所以"亚盘差"完全可能只是"亚盘 ATR 低"换个说法。必须在 ATR 分层内部再看。
|
||
② mom60 混淆。step53 刚定论 mom60 是强因子(Q4 最差),而美盘开盘那几个
|
||
小时最容易出现已经走完一大段的行情。不控的话时段会借它的力。
|
||
|
||
所以本步的顺序是:先逐小时看(不设边界,边界是人定的、最容易带出想要的结论),
|
||
再聚合成时段,最后在 ATR 与 mom60 的分层内部复核。
|
||
|
||
样本内外沿用 step53 的切法(IS_START),任何只在一边成立的都不认。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import warnings
|
||
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", 340)
|
||
|
||
SL = 2.0
|
||
GATE_BP = 8.0
|
||
SRC = HERE / "out" / "step53_pre_entry.feather"
|
||
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
|
||
|
||
# 时段用 UTC 定义。三段等分是加密市场的通行切法,且不重叠——重叠定义会让
|
||
# 同一笔进两个桶,比较就没有意义了。真实的开盘时刻(伦敦 08:00、纽约 13:30)
|
||
# 落在段内而非段首,所以另有一张逐小时表兜底,防止边界把结论切出来
|
||
SESSIONS = [("亚盘", 0, 8), ("欧盘", 8, 16), ("美盘", 16, 24)]
|
||
# 稳健性对照:按真实开盘时刻切,并把欧美重叠那段单列
|
||
SESSIONS_ALT = [("亚盘", 0, 7), ("欧盘", 7, 13), ("欧美重叠", 13, 17),
|
||
("美盘", 17, 21), ("淡时段", 21, 24)]
|
||
|
||
|
||
def stat(g: pd.DataFrame, lab: str, denom: int) -> dict:
|
||
if len(g) < 40:
|
||
return {"分组": lab, "笔数": len(g), "备注": "样本不足"}
|
||
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
|
||
return {"分组": lab, "笔数": len(g), "占比": f"{len(g)/denom*100:.0f}%",
|
||
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
|
||
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
|
||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
|
||
"中位ATRbp": round(g.atr_bp.median(), 1)}
|
||
|
||
|
||
def label_session(h: pd.Series, table) -> pd.Series:
|
||
out = pd.Series("?", index=h.index, dtype=object)
|
||
for name, a, b in table:
|
||
out[(h >= a) & (h < b)] = name
|
||
return out
|
||
|
||
|
||
def hourly(d: pd.DataFrame) -> None:
|
||
"""逐小时。先看这个再谈时段——时段边界是人定的,逐小时不是。"""
|
||
print("\n" + "=" * 100)
|
||
print("########## 一、逐小时(UTC),不设时段边界 ##########")
|
||
rows = []
|
||
for h, g in d.groupby("utc_h"):
|
||
oos, ins = g[g.date < IS_START], g[g.date >= IS_START]
|
||
rows.append({
|
||
"UTC时": h, "北京时": (h + 8) % 24, "笔数": len(g),
|
||
"毛R": round(g.gR.mean(), 3),
|
||
"净均R": round(g.R.mean(), 3),
|
||
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
|
||
"中位ATRbp": round(g.atr_bp.median(), 1),
|
||
"样本外毛R": round(oos.gR.mean(), 3) if len(oos) >= 30 else None,
|
||
"发现期毛R": round(ins.gR.mean(), 3) if len(ins) >= 30 else None,
|
||
})
|
||
t = pd.DataFrame(rows)
|
||
print(t.to_string(index=False))
|
||
print(f"\n每小时平均只有 {len(d)/24:.0f} 笔,单个小时的数是噪声,"
|
||
f"看形状不要看单点。")
|
||
|
||
|
||
def sessions(d: pd.DataFrame, table, title: str) -> None:
|
||
print("\n" + "=" * 100)
|
||
print(f"########## {title} ##########")
|
||
d = d.copy()
|
||
d["seg"] = label_session(d.utc_h, table)
|
||
order = [n for n, _, _ in table]
|
||
for lab, part in (("全样本", d), ("样本外", d[d.date < IS_START]),
|
||
("发现期", d[d.date >= IS_START])):
|
||
rows = [stat(part[part.seg == n], n, len(part)) for n in order]
|
||
print(f"\n--- {lab}({len(part)} 笔)---")
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
|
||
def control(d: pd.DataFrame, col: str, name: str, table) -> None:
|
||
"""在混淆变量的高/低两半内部各看一次时段。
|
||
|
||
时段若只是 ATR(或 mom60)的代理,分层后段间差异会塌掉。
|
||
"""
|
||
x = d[d[col].notna() & np.isfinite(d[col])].copy()
|
||
x["seg"] = label_session(x.utc_h, table)
|
||
x["层"] = np.where(x[col] >= x[col].median(), f"{name}高", f"{name}低")
|
||
order = [n for n, _, _ in table]
|
||
rows = []
|
||
for lay, g in x.groupby("层"):
|
||
r = {"控制层": lay, "笔数": len(g)}
|
||
for n in order:
|
||
s = g[g.seg == n]
|
||
r[n] = round(s.gR.mean(), 3) if len(s) >= 40 else None
|
||
vals = [r[n] for n in order if r[n] is not None]
|
||
r["极差"] = round(max(vals) - min(vals), 3) if len(vals) > 1 else None
|
||
rows.append(r)
|
||
print(f"\n--- 控 {name}({col})后的段间毛R ---")
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
|
||
def permutation(d: pd.DataFrame, table, n_iter: int = 20_000,
|
||
seed: int = 0) -> None:
|
||
"""段间极差有没有超出随机分组的水平。
|
||
|
||
24 个小时聚成 3 段,本来就会因为噪声产生一定的段间差异。不做这一步就没法
|
||
区分"时段有效"与"任意切三份都能切出这么大的差"。
|
||
"""
|
||
rng = np.random.default_rng(seed)
|
||
seg = label_session(d.utc_h, table).to_numpy()
|
||
g = d.gR.to_numpy()
|
||
order = [n for n, _, _ in table]
|
||
obs_means = np.array([g[seg == n].mean() for n in order])
|
||
obs = obs_means.max() - obs_means.min()
|
||
cnt = 0
|
||
for _ in range(n_iter):
|
||
p = rng.permutation(seg)
|
||
m = np.array([g[p == n].mean() for n in order])
|
||
if m.max() - m.min() >= obs:
|
||
cnt += 1
|
||
print(f"\n置换检验:实测段间毛R极差 {obs:.3f},"
|
||
f"随机打乱 {n_iter} 次里有 {cnt/n_iter*100:.2f}% 达到或超过它 "
|
||
f"→ p = {cnt/n_iter:.4f}")
|
||
|
||
|
||
NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||
|
||
|
||
def weekday(d: pd.DataFrame) -> None:
|
||
print("\n" + "=" * 100)
|
||
print("########## 四、星期几(按 UTC)##########")
|
||
rows = []
|
||
for k, g in d.groupby("utc_dow"):
|
||
oos, ins = g[g.date < IS_START], g[g.date >= IS_START]
|
||
r = stat(g, NAMES[k], len(d))
|
||
# 样本内外必须并排看。时段那一节正是靠这一列拆穿"美盘最好"的
|
||
r["样本外毛R"] = round(oos.gR.mean(), 3) if len(oos) >= 30 else None
|
||
r["发现期毛R"] = round(ins.gR.mean(), 3) if len(ins) >= 30 else None
|
||
rows.append(r)
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
print("\n--- 工作日 vs 周末 ---")
|
||
for lab, part in (("全样本", d), ("样本外", d[d.date < IS_START]),
|
||
("发现期", d[d.date >= IS_START])):
|
||
we = part.utc_dow >= 5
|
||
t = pd.DataFrame([stat(part[~we], "工作日", len(part)),
|
||
stat(part[we], "周末", len(part))])
|
||
print(f"\n{lab}({len(part)} 笔)")
|
||
print(t.to_string(index=False))
|
||
|
||
|
||
def weekend_controls(d: pd.DataFrame) -> None:
|
||
"""周末效应是不是 ATR / mom60 / 币种 的代理。
|
||
|
||
周末 ATR 中位比工作日低 1bp,而低 ATR 吃亏是已知的,所以必须分层复核。
|
||
"""
|
||
print("\n" + "=" * 100)
|
||
print("########## 五、周末效应的混淆检查 ##########")
|
||
x = d.copy()
|
||
x["周末"] = np.where(x.utc_dow >= 5, "周末", "工作日")
|
||
for col, name in (("atr_bp", "ATR"), ("mom60", "前60根动量"),
|
||
("vpre10", "前10根量")):
|
||
g = x[x[col].notna() & np.isfinite(x[col])].copy()
|
||
g["层"] = np.where(g[col] >= g[col].median(), f"{name}高", f"{name}低")
|
||
rows = []
|
||
for lay, s in g.groupby("层"):
|
||
wd, we = s[s.周末 == "工作日"], s[s.周末 == "周末"]
|
||
if min(len(wd), len(we)) < 40:
|
||
continue
|
||
rows.append({"控制层": lay, "工作日毛R": round(wd.gR.mean(), 3),
|
||
"周末毛R": round(we.gR.mean(), 3),
|
||
"差": round(we.gR.mean() - wd.gR.mean(), 3),
|
||
"工作日余量": round(wd.net.mean()/wd.tn.mean()*1e4, 2),
|
||
"周末余量": round(we.net.mean()/we.tn.mean()*1e4, 2)})
|
||
print(f"\n--- 控 {name} ---")
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
print("\n--- 逐币:周末差是普遍的还是少数币带的 ---")
|
||
rows = []
|
||
for sym, s in x.groupby("sym"):
|
||
wd, we = s[s.周末 == "工作日"], s[s.周末 == "周末"]
|
||
if min(len(wd), len(we)) < 25:
|
||
continue
|
||
rows.append({"币": sym, "工作日笔数": len(wd), "周末笔数": len(we),
|
||
"工作日毛R": round(wd.gR.mean(), 3),
|
||
"周末毛R": round(we.gR.mean(), 3),
|
||
"差": round(we.gR.mean() - wd.gR.mean(), 3)})
|
||
t = pd.DataFrame(rows).sort_values("差")
|
||
print(t.to_string(index=False))
|
||
neg = (t["差"] < 0).sum()
|
||
print(f"\n{neg}/{len(t)} 个币周末更差")
|
||
|
||
|
||
def overlap_with_mom60(d: pd.DataFrame) -> None:
|
||
"""周末的劣势是不是已经被 step53 的 `mom60≥7` 过滤吃掉了。
|
||
|
||
这是决定"要不要再加一条时间过滤"的关键:两个过滤若砍的是同一批单子,
|
||
叠加只会白丢笔数。控制表已有暗示——mom60 低的那层周末差只有 -0.069,
|
||
高的那层 -0.169。
|
||
"""
|
||
print("\n" + "=" * 100)
|
||
print("########## 六、周末效应与 mom60 过滤的重叠 ##########")
|
||
x = d.copy()
|
||
x["周末"] = np.where(x.utc_dow >= 5, "周末", "工作日")
|
||
x["周日"] = np.where(x.utc_dow == 6, "周日", "其余")
|
||
|
||
print("\n--- 高动量(mom60≥7,step53 要砍的那批)在各组里的占比 ---")
|
||
for col in ("周末", "周日"):
|
||
t = x.groupby(col).apply(
|
||
lambda g: pd.Series({
|
||
"笔数": len(g),
|
||
"mom60≥7占比": f"{(g.mom60 >= 7).mean()*100:.1f}%",
|
||
"中位mom60": round(g.mom60.median(), 2)}))
|
||
print(t.to_string())
|
||
|
||
print("\n--- 施加 mom60<7 之后,周末差还剩多少 ---")
|
||
rows = []
|
||
for lab, sub in (("过滤前(全部)", x),
|
||
("过滤后(mom60<7)", x[x.mom60 < 7])):
|
||
for col in ("周末", "周日"):
|
||
a = sub[sub[col] == sub[col].unique()[0]]
|
||
hi = sub[sub[col].isin(["周末", "周日"])]
|
||
lo = sub[sub[col].isin(["工作日", "其余"])]
|
||
if min(len(hi), len(lo)) < 40:
|
||
continue
|
||
rows.append({
|
||
"口径": lab, "对比": f"{col} vs 其余",
|
||
"差组笔数": len(hi), "差组毛R": round(hi.gR.mean(), 3),
|
||
"对照毛R": round(lo.gR.mean(), 3),
|
||
"毛R差": round(hi.gR.mean() - lo.gR.mean(), 3),
|
||
"差组余量": round(hi.net.mean()/hi.tn.mean()*1e4, 2),
|
||
"对照余量": round(lo.net.mean()/lo.tn.mean()*1e4, 2)})
|
||
print(pd.DataFrame(rows).drop_duplicates().to_string(index=False))
|
||
|
||
|
||
def perm_binary(d: pd.DataFrame, mask: np.ndarray, lab: str,
|
||
n_iter: int = 20_000, seed: int = 0) -> None:
|
||
"""两组均值差的置换检验。"""
|
||
rng = np.random.default_rng(seed)
|
||
g = d.gR.to_numpy()
|
||
obs = g[mask].mean() - g[~mask].mean()
|
||
n = int(mask.sum())
|
||
cnt = 0
|
||
for _ in range(n_iter):
|
||
idx = rng.permutation(len(g))[:n]
|
||
m = np.zeros(len(g), bool)
|
||
m[idx] = True
|
||
if abs(g[m].mean() - g[~m].mean()) >= abs(obs):
|
||
cnt += 1
|
||
print(f"\n置换检验({lab}):实测毛R差 {obs:+.3f},"
|
||
f"随机分组 {n_iter} 次里 {cnt/n_iter*100:.2f}% 达到或超过 "
|
||
f"→ p = {cnt/n_iter:.4f}")
|
||
|
||
|
||
def decision(d: pd.DataFrame, cuts: dict) -> None:
|
||
"""若砍掉某些时段,在不同真实滑点下的总R。
|
||
|
||
沿用 step53 的决策表口径:砍掉一批信号既省成本也丢收益,哪边大取决于
|
||
真实滑点,所以必须按滑点扫一遍,不能只报一个数。
|
||
"""
|
||
print("\n" + "=" * 100)
|
||
print("########## 七、把时段做成过滤的决策表(发现期总R)##########")
|
||
ins = d[d.date >= IS_START]
|
||
slips = [0, 5, 8, 10, 12, 15, 18, 20]
|
||
rows = []
|
||
for lab, mask in cuts.items():
|
||
m = mask(ins)
|
||
sub = ins[m]
|
||
r = {"方案": lab, "保留": f"{m.mean()*100:.0f}%"}
|
||
for s in slips:
|
||
# 滑点只打在 taker 腿上,口径与 lib.exit_model 一致
|
||
net = sub.net - sub.tn * s / 1e4
|
||
r[f"{s}bp"] = round((net / (SL * sub.atr_pct)).sum(), 0)
|
||
rows.append(r)
|
||
print(pd.DataFrame(rows).to_string(index=False))
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--src", default=str(SRC))
|
||
args = ap.parse_args()
|
||
|
||
from step50_volume import prep
|
||
|
||
d = pd.read_feather(args.src)
|
||
d["date"] = pd.to_datetime(d["date"])
|
||
d = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy())
|
||
utc = d.date.dt.tz_convert("UTC")
|
||
d["utc_h"] = utc.dt.hour
|
||
d["utc_dow"] = utc.dt.dayofweek
|
||
|
||
span = (d.date.max() - d.date.min()).days
|
||
print(f"实盘口径 {len(d)} 笔 · {d.sym.nunique()} 币 · 跨 {span} 天 "
|
||
f"({d.date.min():%Y-%m-%d} → {d.date.max():%Y-%m-%d})")
|
||
print(f"样本外 {(d.date < IS_START).sum()} · "
|
||
f"发现期 {(d.date >= IS_START).sum()}")
|
||
print("⚠️ 持仓最长 48 分钟,跨段的笔按**入场时刻**归属——那是唯一可操作的口径")
|
||
|
||
hourly(d)
|
||
sessions(d, SESSIONS, "二、三段等分(UTC 0/8/16)")
|
||
permutation(d, SESSIONS)
|
||
sessions(d, SESSIONS_ALT, "三、稳健性对照:按真实开盘时刻切五段")
|
||
|
||
print("\n" + "=" * 100)
|
||
print("########## 混淆检查:时段是不是 ATR / mom60 的代理 ##########")
|
||
for col, name in (("atr_bp", "ATR"), ("mom60", "前60根动量"),
|
||
("vpre10", "前10根量")):
|
||
control(d, col, name, SESSIONS)
|
||
|
||
weekday(d)
|
||
perm_binary(d, (d.utc_dow >= 5).to_numpy(), "周末 vs 工作日")
|
||
perm_binary(d, (d.utc_dow == 6).to_numpy(), "周日 vs 其余")
|
||
weekend_controls(d)
|
||
overlap_with_mom60(d)
|
||
|
||
decision(d, {
|
||
"等权(现状)": lambda x: np.ones(len(x), bool),
|
||
"砍周日": lambda x: (x.utc_dow != 6).to_numpy(),
|
||
"砍周末": lambda x: (x.utc_dow < 5).to_numpy(),
|
||
"砍欧盘": lambda x: ((x.utc_h < 8) | (x.utc_h >= 16)).to_numpy(),
|
||
"砍 mom60≥7(step53 基准)": lambda x: (x.mom60 < 7).to_numpy(),
|
||
"砍 mom60≥7 + 周日": lambda x: ((x.mom60 < 7) &
|
||
(x.utc_dow != 6)).to_numpy(),
|
||
})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|