A 股中隔夜端可能强烈反转日内端可能偏动量且收益/波动很大部分来自隔夜跳空。T1、涨跌停、集合竞价、散户占比高等制度与行为特征使得总收益有必要拆成隔夜和日内。这时尝试梳理如何拆分以及如何构造隔夜/日内累计动量因子并通过python代码进行示例。1 隔夜/日内累计动量因子首先分析为什么拆分隔夜和日内拆分的背景然后进一步给出相关因子定义及验证方式。1.1 为什么拆隔夜和日内这里首先把股票总收益精确拆成其中- 隔夜段昨收到今开close → open- 日内段今开到今收open → close但这两个部分的形成机制完全不同- 隔夜段(on)非交易时段信息、隔夜风险、散户情绪、集合竞价、外盘影响、公告效应。- 日内段(id)连续竞价、机构交易、流动性提供、订单流、日内动量/反转。Lou-Polk-Skouras 的核心发现是隔夜和日内收益对未来的预测方向可能相反。在 A 股版本中常见结论是- 隔夜端反转尤其是短期。- 日内端动量。- 总收益中隔夜波动/跳空占比很高。A 股中隔夜端可能强烈反转日内端可能偏动量且收益/波动很大部分来自隔夜跳空。A 股 T1、涨跌停、集合竞价、散户占比高等制度与行为特征使这种分解尤其有意义。所以 mom_on_12m可能ICIR为负mom_id_12m可能 ICIR 为正。也就是说隔夜和日内收益的预测方向可能完全不同拆开后可以针对不同因子采用不同方向比如用冻结样本内 ICIR 决定符号这是稳健做法。1.2 A 股背景T1当天买入不能当天卖出隔夜风险无法日内平仓。隔夜风险溢价和隔夜情绪更容易累积。- 涨跌停开盘涨停/跌停会导致无法成交隔夜跳空可能被放大。- 集合竞价开盘价由 9:15–9:25 集合竞价产生反映隔夜信息与情绪。- 散户占比高隔夜持仓受情绪、彩票偏好、过度反应影响容易导致隔夜反转。- 机构日内交易机构拆单、执行、流动性提供可能使日内收益呈现动量。- 做空受限所以回测用纯多头 zpos再通过市场对冲近似中性。1.3 因子定义这里进一步给出隔夜、日内相关因子的定义示例如下ret_12m总 12 月动量目的是做对照mom_on_12m过去 12 月隔夜累计可能反转ICIR负mom_id_12m过去 12 月日内累计可能动量ICIR 正mom_on_1m过去 1 月隔夜累计短期隔夜反转skewness偏度因子偏度因子表现较稳健这里作为基准idio_vol特质波动率特质波动率相对稳健这里作为对照ret_12m mom_on_12m mom_id_12m在收益层面成立但因子层面不一定完全可加因为复权、缺失值、窗口和 shift 处理可能略有差异。如果没有做正交化隔夜、日内、总动量之间可能高度相关。1.4 验证与货币化这里进一步给出如何验证和货币化的对应参数和过程。ICIR信息系数均值 / 标准差衡量预测稳定性。Rank ICIR秩相关抗极端值。spread_sharpe分组多空组合夏普衡量单调性和收益。recent_icir2023 后表现检查衰减。recent_sign_flip方向是否翻转。zpos纯多头仓位适合 A 股。rolling_hedge_beta60 日滚动 beta因果对冲避免前视。hedged_causal_sharpe对冲后夏普更接近可交易市场中性策略。最终adopt要求为统计上有预测力、分组上有收益、货币化后能打、近期不翻车。2 隔夜/日内因子实现示例以下代码是一个因子研究流水线核心目标是把股票总收益精确拆成隔夜收益和日内收益。然后构造隔夜/日内累计动量因子检验它们是否具有预测能力并在严格冻结样本内符号后做 2023–2025 的样本外货币化回测。最后与基准因子skewness做成本端 货币化端对比决定是否采纳。# ******** AI生成仅供教学和测试 ******** 隔夜(close→open) vs 日内(open→close) 收益分解. A 股文献: 收益几乎全部来自隔夜段, 且隔夜端强烈反转、日内端动量 (Lou-Polk-Skouras 2019 的 A 股版本)。把总收益做精确对数分解: logret_total log(close/prev_close) log(open/prev_close) log(close/open) logret_on logret_id (恒等, 无交叉项) 因子 (与 ret_12m 同口径, 12m250d 累计, 1m21d): * ret_12m : 总 12 月动量 (registry, pct_change(250).shift(1)) 对照 * mom_on_12m: 隔夜 12 月累计 * mom_id_12m: 日内 12 月累计 * mom_on_1m : 隔夜 1 月累计 (A 股短周期反转主要在隔夜) 符号由冻结样本内 ICIR 决定。口径: horizon20/rebal20, 冻结 ≤2022-12-31 → OOS 2023-01-01~2025-12-31, 纯多头 zpos 60d 因果对冲。FACTOR_REGISTRY 不修改。 import argparse import json import os import sys import h5py import numpy as np import pandas as pd sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ic_weighted_test as base # noqa: E402 import beta_neutral_test as btn # noqa: E402 from idio_vol_test import idio_volatility_factor # noqa: E402 from mfm.combine import combine_factors_icir_weight from mfm.data import load_data, load_members, universe_mask from mfm.factors import compute_factors, cross_sectional_normalize from mfm.pipelines.csi300_screen import CSI300ScreenConfig from mfm.validation import validate_factors NAMES (skewness, idio_vol, ret_12m, mom_on_12m, mom_id_12m, mom_on_1m) STOCK (ret_12m, mom_on_12m, mom_id_12m, mom_on_1m) def load_adjusted_open(h5_path: str, start: str, end: str) - pd.DataFrame: date x stock 后复权开盘价 $open / $factor (与 load_data 的 close 同口径). with h5py.File(h5_path, r) as f: grp f[data] dates pd.to_datetime(grp[axis1_level0][:]) stocks [s.decode() if isinstance(s, (bytes, np.bytes_)) else str(s) for s in grp[axis1_level1][:]] cols [c.decode() if isinstance(c, (bytes, np.bytes_)) else str(c) for c in grp[axis0][:]] vals grp[block0_values][:] label0 grp[axis1_label0][:] label1 grp[axis1_label1][:] mi pd.MultiIndex.from_arrays([dates[label0], [stocks[i] for i in label1]], names[date, stock]) df pd.DataFrame(vals, indexmi, columnscols) open_raw df[$open].unstack(levelstock).sort_index() factor df[$factor].unstack(levelstock).reindex_like(open_raw) open_adj open_raw / factor return open_adj.loc[(open_adj.index start) (open_adj.index end)] def main() - None: ap argparse.ArgumentParser(description__doc__.splitlines()[0]) ap.add_argument(--tag, requiredTrue) ap.add_argument(--instruments, defaultdata/cn_data/instruments/csi300.txt) ap.add_argument(--start, requiredTrue) ap.add_argument(--end, requiredTrue) ap.add_argument(--freeze, default2022-12-31) ap.add_argument(--oos-start, default2023-01-01) ap.add_argument(--outdir, default./tmp/on_id) args ap.parse_args() os.makedirs(args.outdir, exist_okTrue) cfg CSI300ScreenConfig( startargs.start, endargs.end, instruments_pathargs.instruments, out_dirargs.outdir, ) print(f[1/5] loading ({args.tag})...) prices_full, volumes_full load_data( sourceqlib, h5_pathcfg.h5_path, startcfg.start, endcfg.end, max_stockscfg.max_stocks ) segs load_members(cfg.instruments_path) mask_full universe_mask(segs, prices_full.index, prices_full.columns) member_cols mask_full.columns[mask_full.any(axis0)] prices prices_full[member_cols] volumes volumes_full.reindex(columnsmember_cols) mask mask_full[member_cols] returns prices.pct_change(fill_methodNone) dates prices.index print(f Universe avg members: {mask_full.sum(axis1).mean():.0f}; fever-member cols: {len(member_cols)}) print( loading adjusted $open...) open_adj load_adjusted_open(cfg.h5_path, cfg.start, cfg.end).reindex( indexprices.index, columnsmember_cols) print([2/5] session decomposition...) logret_on np.log(open_adj / prices.shift(1)) logret_id np.log(prices / open_adj) m_on logret_on.abs().stack().mean() m_id logret_id.abs().stack().mean() on_frac float(m_on / (m_on m_id)) print(f overnight share of daily |return| (magnitude): {on_frac:.1%}) mom compute_factors(prices, volumes, factor_names[skewness, ret_12m]) facs { skewness: mom[skewness].where(mask), idio_vol: idio_volatility_factor(prices).where(mask), ret_12m: mom[ret_12m].where(mask), mom_on_12m: logret_on.rolling(250, min_periods125).sum().shift(1).where(mask), mom_id_12m: logret_id.rolling(250, min_periods125).sum().shift(1).where(mask), mom_on_1m: logret_on.rolling(21, min_periods11).sum().shift(1).where(mask), } print([3/5] four-lens (full window 2023 flip)...) val validate_factors(facs, prices, horizoncfg.horizon, n_groups5) hold dates[dates 2023-01-01] val_h validate_factors( {n: facs[n].loc[hold] for n in NAMES}, prices.loc[hold], horizoncfg.horizon, n_groups5, ) four {} for n in NAMES: s val[ic_summary][n] r val[rank_ic_summary][n] sp val[spread_summary][n] h val_h[ic_summary][n] four[n] { icir: float(s[icir]), t_stat: float(s[t_stat]), rank_icir: float(r[icir]), spread_annualized: float(sp[annualized]), spread_sharpe: float(sp[sharpe]), recent_icir: float(h[icir]), recent_sign_flip: bool(np.sign(s[icir]) ! np.sign(h[icir])), } print(f {n:12s} ICIR{four[n][icir]:.4f} t{four[n][t_stat]:.2f} frankICIR{four[n][rank_icir]:.4f} | spread{four[n][spread_annualized]:.2%} fsharpe{four[n][spread_sharpe]:.2f} | 2023 {four[n][recent_icir]:.4f} fflip{four[n][recent_sign_flip]}) print(f[4/5] frozen OOS monetization (sign {args.freeze})...) cut dates[dates args.freeze] val_is validate_factors( {n: facs[n].loc[cut] for n in NAMES}, prices.loc[cut], horizoncfg.horizon, n_groups5, ) signs {n: 1.0 if val_is[ic_summary][n][icir] 0 else -1.0 for n in NAMES} oos_idx dates[dates args.oos_start] bm returns.where(mask).mean(axis1).shift(-1).loc[oos_idx] ret_next returns.shift(-1).loc[oos_idx] rebal oos_idx[:: cfg.rebal] zero pd.Series(0.0, indexbm.index) money {} for n in NAMES: norm cross_sectional_normalize(facs[n].loc[oos_idx]) comp combine_factors_icir_weight({n: norm}, {n: signs[n]}).where(mask.loc[oos_idx]) book btn.build_scheme_returns(comp, ret_next, rebal, cfg, zpos) bep btn.realized_beta(book, bm) broll btn.rolling_hedge_beta(book, bm) hc base.calc_metrics(btn.hedge(book, bm, broll), zero) money[n] { sign: signs[n], book_beta_expost: bep, hedged_causal_ann: float(hc[annual_return]), hedged_causal_sharpe: float(hc[sharpe_ratio]), hedged_causal_maxdd: float(hc[max_drawdown]), } print(f [{n}] sign {signs[n]:.0f} book_beta{bep:.2f} fhedged_caus ann{hc[annual_return]:.2%} sharpe{hc[sharpe_ratio]:.2f} fmaxDD{hc[max_drawdown]:.1%}) print([5/5] head-to-head vs skewness (P1 rule)...) sk four[skewness] skm money[skewness] verdict {} for n in STOCK: f four[n] fm money[n] cost_win abs(f[icir]) abs(sk[icir]) and abs(f[rank_icir]) abs(sk[rank_icir]) spread_adj f[spread_sharpe] * fm[sign] money_win spread_adj sk[spread_sharpe] and fm[hedged_causal_sharpe] skm[hedged_causal_sharpe] adopt cost_win and money_win and not f[recent_sign_flip] verdict[n] {cost_win: cost_win, money_win: money_win, flip: f[recent_sign_flip], adopt: bool(adopt)} print(f {n:12s} cost{cost_win} money{money_win} flip{f[recent_sign_flip]} f- adopt{YES if adopt else NO}) out { tag: args.tag, freeze: args.freeze, oos_window: [str(oos_idx[0].date()), str(oos_idx[-1].date())], overnight_share: on_frac, four_lens: four, money: money, verdict: verdict, } path os.path.join(args.outdir, fon_id_{args.tag}.json) with open(path, w) as fh: json.dump(out, fh, indent1, ensure_asciiFalse) print(f\nSaved - {path}) if __name__ __main__: main()这里是进一步的代码结构梳理。2.1 导入与全局定义首先导入如下因子skewness、idio_vol是对照/基准因子。ret_12m是总 12 月动量。mom_on_12m、mom_id_12m是隔夜/日内 12 月累计。mom_on_1m是隔夜 1 月累计用于捕捉 A 股短周期隔夜反转。STOCK是四个真正要与skewness做头对头比较的股票收益分解因子。NAMES (skewness, idio_vol, ret_12m, mom_on_12m, mom_id_12m, mom_on_1m)STOCK (ret_12m, mom_on_12m, mom_id_12m, mom_on_1m)2.2 数据读取数据读取函数具体为load_adjusted_open作用是从 qlib 的 h5 数据中读取后复权开盘价。open_adj open_raw / factor它假设项目里 load_data得到的close与$open / $factor同口径。这是整个隔夜/日内分解能否成立的关键如果开盘价和收盘价复权口径不一致log(open/prev_close)就会混入除权除息噪声。2.3 数据加载与股票池使用历史成分股segs构造动态股票池mask。member_cols是曾经进入过成分股的股票避免只保留当前成分股造成生存偏差。mask标记某日某股是否在成分内。后续因子大多.where(mask)表示只在当时成分股内有效。prices_full, volumes_full load_data(...)segs load_members(...)mask_full universe_mask(segs, prices_full.index, prices_full.columns)member_cols mask_full.columns[mask_full.any(axis0)]2.4 隔夜/日内收益分解这里计算logret_on昨收 → 今开logret_id今开 → 今收然后计算隔夜绝对收益均值占隔夜日内绝对收益均值的比例。logret_on np.log(open_adj / prices.shift(1))logret_id np.log(prices / open_adj)m_on logret_on.abs().stack().mean()m_id logret_id.abs().stack().mean()on_frac float(m_on / (m_on m_id))注意这里衡量的是绝对波动/跳空幅度占比不是累计收益贡献占比。所以overnight_share不能直接证明收益几乎全部来自隔夜只能说明隔夜段的绝对变动更大。2.5 因子构造因子构造关键点如下1rolling(250).sum()过去 250 个交易日累计。2shift(1)避免前视t 日使用截至 t-1 的累计收益。3mom_on_12m过去 12 月隔夜累计收益。4mom_id_12m过去 12 月日内累计收益。5mom_on_1m过去 1 月隔夜累计收益。6ret_12m作为总动量对照。由于对数收益可加隔夜累计和日内累计分别刻画了两个交易时段的长期价格压力。pythonmom compute_factors(prices, volumes, factor_names[skewness, ret_12m])facs {skewness: mom[skewness].where(mask),idio_vol: idio_volatility_factor(prices).where(mask),ret_12m: mom[ret_12m].where(mask),mom_on_12m: logret_on.rolling(250, min_periods125).sum().shift(1).where(mask),mom_id_12m: logret_id.rolling(250, min_periods125).sum().shift(1).where(mask),mom_on_1m: logret_on.rolling(21, min_periods11).sum().shift(1).where(mask),}2.6 四透镜验证对每个因子收集1ICIR2t_stat3rank_icir4分组多空 spread_annualized5spread_sharpe62023 年后recent_icir7是否近期符号翻转 recent_sign_flip这是四透镜统计显著性、秩稳健性、分组收益、近期稳定性。val validate_factors(facs, prices, horizoncfg.horizon, n_groups5)hold dates[dates 2023-01-01]val_h validate_factors(...)2.7 符号好货币化具体为冻结样本内符号样本外货币化cut dates[dates args.freeze]val_is validate_factors(...)signs {n: 1.0 if val_is[ic_summary][n][icir] 0 else -1.0 for n in NAMES}用 ≤2022-12-31 的样本内 ICIR 决定因子方向- ICIR 为正 → 因子值越大越好sign 1- ICIR 为负 → 反向使用sign -1然后 OOS 为 2023-01-01 到 2025-12-31。bm returns.where(mask).mean(axis1).shift(-1).loc[oos_idx]ret_next returns.shift(-1).loc[oos_idx]rebal oos_idx[:: cfg.rebal]bm等权成分股下一日收益作为市场基准。ret_next下一日个股收益。rebal每 20 日再平衡与horizon20对应。对每个因子做截面标准化等处理。norm cross_sectional_normalize(facs[n].loc[oos_idx])comp combine_factors_icir_weight({n: norm}, {n: signs[n]}).where(mask.loc[oos_idx])book btn.build_scheme_returns(comp, ret_next, rebal, cfg, zpos)bep btn.realized_beta(book, bm)broll btn.rolling_hedge_beta(book, bm)hc base.calc_metrics(btn.hedge(book, bm, broll), zero)这里做了1横截面标准化cross_sectional_normalize。2根据冻结符号生成单因子组合combine_factors_icir_weight3用 zpos方案构建纯多头组合符合 A 股做空受限现实。build_scheme_returns4计算事后beta beprealized_beta。5用滚动 60 日 beta broll做因果对冲即只用历史信息估计 betarolling_hedge_beta。6计算对冲后的年化收益、夏普、最大回撤具体为calc_metrics。2.8 与skewness头对头比较与skewness头对头比较代码如下cost_win abs(f[icir]) abs(sk[icir]) and abs(f[rank_icir]) abs(sk[rank_icir])spread_adj f[spread_sharpe] * fm[sign]money_win spread_adj sk[spread_sharpe] and fm[hedged_causal_sharpe] skm[hedged_causal_sharpe]adopt cost_win and money_win and not f[recent_sign_flip]决策规则为1成本端胜|ICIR| 和 |Rank ICIR| 都超过skewness。2货币化端胜调整方向后的 spread Sharpe 超过skewness且对冲后因果夏普也超过。3无近期符号翻转2023 后没有出现方向不稳定。三者同时满足才adopt YES最后保存 JSON 结果。3 潜在问题3.1 隔夜占比指标隔夜占比指标不等于收益贡献on_frac用的是logret_on.abs().mean() / (logret_on.abs().mean() logret_id.abs().mean())这是绝对变动幅度占比不是累计收益占比。若要验证收益几乎全部来自隔夜应计算或对指数/组合收益做累计分解。3.2 复权口径需严格验证必须检查open_adj open_raw / factor是否与load_data的close完全一致。如果$factor的定义是raw * factor adjusted那这里可能反了。一旦口径不一致隔夜收益会混入除权除息因子含义被污染。3.3 缺失值等处理不足缺失值、停牌、涨跌停处理不足代码直接np.log没有处理1价格为 0 或负2停牌导致 NaN3涨跌停无法成交4开盘集合竞价无成交这会让logret_on、logret_id出现inf或异常值进而污染rolling().sum()。3.4. 交易成本与可交易性未建模回测没有扣- 佣金、印花税、冲击成本- 涨跌停无法买入- T1 卖出限制- 股指期货基差、保证金、展期成本- 融券成本所以hedged_causal_sharpe是理想化结果。3.5 未做行业/风格中性只对冲了市场 beta没有控制- 行业暴露- 规模- 流动性- 波动率- 估值隔夜/日内因子可能与这些风格高度相关导致收益来自风格暴露而非独立 alpha。3.6 多重检验与数据窥探同时比较 4 个因子与skewness没有做多重检验校正。recent_sign_flip使用全窗口 vs 2023全窗口包含 OOS若用于决策存在前视嫌疑。3.7 隔夜/日内/总动量未正交化mom_on_12m mom_id_12m ≈ ret_12m三者高度相关。头对头比较时应做正交化或至少报告相关性否则难以判断增量信息。3.8 样本外窗口较短OOS 为 2023–2025只有约 3 年且包含特定市场环境。结论可能对市场状态敏感。reference---隔夜动量因子的增量检验探索https://blog.csdn.net/liliang199/article/details/166599863