Files
Chan/research/step54_session.py
T
jackyu66gitandCursor 1ffc4fbb18 时段(亚/欧/美)是噪声,周日效应真实但与 mom60 过滤不可加
用户问哪个时段更容易盈利。答案分两半:时段这个切法本身无效,换成星期几才
有东西,而那东西不该做成新过滤。

① 亚/欧/美三等分(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>
2026-08-28 19:23:39 +08:00

353 lines
15 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 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≥7step53 基准)": lambda x: (x.mom60 < 7).to_numpy(),
"砍 mom60≥7 + 周日": lambda x: ((x.mom60 < 7) &
(x.utc_dow != 6)).to_numpy(),
})
if __name__ == "__main__":
main()