diff --git a/research/HANDOFF.md b/research/HANDOFF.md index 4ddcba7..730e1db 100644 --- a/research/HANDOFF.md +++ b/research/HANDOFF.md @@ -868,6 +868,72 @@ mom10 同形状(Q2 最好、Q4 最差),样本内外一致。**势要有— > 但也**不是完全无关于数据**(我看过分位表才取的整)。真上线前应在更早的 > 时段上确认阈值附近没有断崖式敏感。 +### 3.392 时段(亚/欧/美)没用,周日有效但已被 mom60 吃掉(step54) + +用户问哪个时段更容易盈利。答案分两半:**时段这个切法本身无效,但换成星期几 +就有东西——只是那东西不该做成新过滤。** + +**① 亚/欧/美三段:差异在噪声量级,且美盘两个时期反号** + +| UTC 0/8/16 三等分 | 笔数 | 毛R | 余量bp | 中位ATRbp | 样本外毛R | 发现期毛R | +|---|---|---|---|---|---|---| +| 亚盘 | 1249 | 1.127 | 18.18 | 12.5 | 1.153 | 1.057 | +| 欧盘 | 1414 | 1.006 | 18.43 | 14.1 | 1.044 | 0.926 | +| 美盘 | 1278 | 1.079 | 18.73 | 13.6 | **1.158** | **0.885** | + +**美盘在样本外是最好的一段、在发现期是最差的一段**,这是噪声的典型指纹。 +段间毛R极差 0.122,**置换检验 p = 0.0895**——随便把 24 小时切成三份, +有 9% 的概率切出这么大的差。按真实开盘时刻切五段(含欧美重叠单列)结论一样。 + +⚠️ 这里**不是**输给了 ATR 混淆。控 ATR 后段间极差 0.101 / 0.108,和无条件的 +0.122 几乎一样——时段效应不是 ATR 的代理,它只是本来就小。逐小时表里最差的 +20 UTC(0.802)与最好的 22 UTC(1.262)差得很大,但每小时只有 108~275 笔, +是噪声,别照着挑时辰。 + +**② 星期几才有信号,集中在周日** + +| | 笔数 | 胜率 | 毛R | PF | 余量bp | 样本外毛R | 发现期毛R | +|---|---|---|---|---|---|---|---| +| 周五 | 601 | 74.7% | **1.228** | 4.78 | 23.44 | 1.359 | 0.862 | +| 周四 | 623 | 73.5% | 1.150 | 4.56 | 20.64 | 1.104 | 1.252 | +| 周日 | 470 | 66.6% | **0.893** | **2.76** | **13.08** | 0.955 | 0.772 | +| 工作日 | 3133 | 72.6% | 1.095 | 4.01 | 19.38 | 1.151 | 0.959 | +| 周末 | 808 | 67.9% | 0.963 | 3.08 | 14.86 | 0.983 | 0.920 | + +置换检验:周末 vs 工作日 **p = 0.0246**,周日 vs 其余 **p = 0.0079**。 +方向在两个时期一致,10 个币里 7 个周末更差(BTC 最甚 −0.336)。 +⚠️ 但**幅度在发现期塌了大半**(工作日−周末:样本外 −0.168 → 发现期 −0.039)。 + +**③ 关键:周末的劣势有一半住在 `mom60≥7` 那批单子里,step53 已经砍掉了** + +| | 周末 vs 工作日 | 周日 vs 其余 | +|---|---|---| +| 过滤前 | −0.132 | −0.199 | +| **施加 `mom60<7` 后** | **−0.058** | **−0.104** | + +注意重叠**不在笔数上**:`mom60≥7` 在周末占 22.9%、工作日 21.3%,几乎一样。 +是**伤害**重叠——周末真正亏钱的是那些追已走完行情的单子。周末流动性薄, +追高的代价被放大,这跟 §3.391「势不能过头」是同一件事在另一个维度上的投影。 + +**④ 所以不要加这条过滤。决策表(发现期总R):** + +| 方案 | 保留 | 0bp | 5bp | 8bp | 10bp | 12bp | 15bp | +|---|---|---|---|---|---|---|---| +| 等权 | 100% | 946 | 597 | 387 | 247 | 107 | −102 | +| 砍周日 | 86% | 844 | 543 | 362 | 241 | 120 | −60 | +| 砍周末 | 78% | 747 | 473 | 309 | 200 | 90 | −74 | +| 砍欧盘 | 60% | 579 | 363 | 234 | 148 | 61 | −68 | +| **砍 mom60≥7(§3.391)** | 78% | **921** | **658** | **500** | **394** | **289** | **131** | +| 砍 mom60≥7 + 周日 | 68% | 804 | 574 | 435 | 343 | 251 | 113 | + +**`砍 mom60≥7 + 周日` 在 0~20bp 每一档都输给 `砍 mom60≥7` 单用**——叠加只是 +白丢 10% 的笔数。单用「砍周日」也要到 12bp 以上才赢过等权。 + +> **这一步的价值是排除,不是发现。** 用户问时段,直觉上「美盘流动性好该更赚」 +> 很难自证伪,跑完才知道它连随机切分都跑不赢。而顺手捞到的周日效应统计上真实 +> (p=0.008),却因为和已有过滤重叠而不可加——**"显著"和"值得做"是两件事, +> 中间隔着一张决策表。** + ### 3.4 alpha 的来源(step32 消融) 逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位, @@ -1009,6 +1075,8 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算 | **`available_ts` 改取 `bis[2]`(中枢成立即可用)以消除右边缘重画** | 重画确实从 6.5% 降到 1.2%、滞后 2.16→2.01,**但 alpha 被打成零**:实盘口径毛 R 0.933 → **−0.000**(扣费前就没了 edge),PF 3.22→0.80,余量 15.07→−2.20bp,8/8 币全变差。中枢没发育完就下注,支撑/压力还没立住。**「等中枢最后一笔」就是 alpha 本身,重画是必付代价。**推论:任何以「让信号更早确定」为目标的改动,先测毛 R,不要只看重画率和滞后 | step47 §5.42 | | **挑「信号根成交量大」的信号(有资金推的趋势)** | **方向完全相反**:vr60 四分位上毛R 单调 1.421→0.794、余量 27.18→13.47bp,样本内外 / 两套量比基准 / ATR 4 分位 4/4 / 逐时段 7/7 全部同向。止损率 22.5%→45.3% 翻倍,且**止盈率同时下降**(说明是方向变差,不是止损太窄)。根因:B4/S4 在突破根上进场,大量根 = 这一冲已经由别人的资金走完,你在收盘价接盘。**低量那一档才是好的**,可反向用作仓位权重 | step50 §3.31 | | **持仓中放量就平仓(把成交量当出场信号)** | **方向也是反的,且比入场那条更干净**:毛R 1.068 → 0.617(vr60≥3)/ 0.858(≥5)/ 1.009(≥8),**阈值越高越接近基线,最优点是「永不触发」**。触发的那批若不平,止盈率 45.7%、止损率 16.6%(全体基线 30.1% / 33.1%),**放量根标记的是最好的单子不是最差的**,平掉每笔让出 +0.484R。幸存者偏差已控(按持仓 ≥K 根分层 5/5 同向)。同一事件入场为负、持仓为正——入场时你是买方,持仓时资金是来接你货的 | step52 §3.31 | +| **按时段交易(亚/欧/美)** | **差异在噪声量级**:段间毛R极差 0.122,置换检验 **p=0.0895**(随便切三份有 9% 概率切出这么大)。**美盘样本外 1.158 最好、发现期 0.885 最差**,两个时期反号。不是 ATR 混淆——控 ATR 后极差 0.101/0.108,和无条件的 0.122 一样,它本来就小 | step54 §3.392 | +| **砍周日/周末** | 统计上真实(周日 p=0.0079、周末 p=0.0246,7/10 币同向),**但不可加**:伤害的一半住在 `mom60≥7` 那批里,§3.391 已砍。施加 `mom60<7` 后周末差 −0.132 → −0.058。决策表上 `mom60≥7 + 周日` 在 0~20bp **每一档**都输给 `mom60≥7` 单用 | step54 §3.392 | | 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 | --- @@ -2051,6 +2119,7 @@ API 限流风险隔离三个好处。 | 51 | **因子化仓位**(把 49/50 做成权重)→ 量因子该做成**开关**不是权重(硬砍 vs 加权:R夏普 0.496/0.442、并发 6.00/7.98);**前序因子不该进仓位**,边际收益买不起集中度。优势形态是削尾不是抬均值 | | 52 | **持仓中放量出场**(用户提议)→ 反的,最优点是永不触发;放量根标记的是止盈率翻倍的好单。自带模拟器与 `walk_exits` 逐笔对拍,10/10 币通过 | | 53 | **开仓前的量与趋势方向**(用户提议)⭐ → 前段量单调(越小越好,独立于信号根量);前段动量**驼峰形**(Q2 最好、Q4 最差)。`砍 mom60≥7` 全面优于 §3.39 的 `砍 vr60≥4`,且总R 比不砍还高。附滑点决策表 | +| 54 | **开仓时段(亚/欧/美)**(用户提议)→ **无效**,置换检验 p=0.09 且美盘两期反号。星期几有效(周日 p=0.008)但与 `mom60` 过滤伤害重叠,叠加在每一档滑点上都更差。**排除型结论** | 输出都在 `research/out/`。**2026-08-27 清理过一轮**:step1~20 的输出(早期方法论 已被推翻,含未来函数偏差)与旧口径备份一并删除,只留支撑当前结论的证据。 diff --git a/research/step54_session.py b/research/step54_session.py new file mode 100644 index 0000000..d7d21de --- /dev/null +++ b/research/step54_session.py @@ -0,0 +1,352 @@ +"""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()