因子合成 — z-score / 行业中性化 / Composite
为什么单因子加总会失效、cross-sectional z-score 与 time-series z-score 的区别、winsorize 的 trade-off、行业中性化的两种实现、方向化与单调性的处理、composite score 的权重设定
日期: 2026-06-10 方向: Phase 2 / 因子合成 阶段: Phase 2: 策略实战 + AI 信号 标签: #ZScore #Winsorize #IndustryNeutralization #CrossSection #FactorComposite #GICS
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 为什么单因子加总会失效、cross-sectional z-score 与 time-series z-score 的区别、winsorize 的 trade-off、行业中性化的两种实现、方向化与单调性的处理、composite score 的权重设定 |
| 实操 | 在 Day 28-31 的 SP100 因子表上跑完整 pipeline:raw factor → winsorize → cross-sectional z → industry-neutral → composite;输出每月 long/short 组合 |
| 产出 | TR-DAY32 笔记 + factor_pipeline.py 模块 + composite_score 月度面板表 + 4 因子单因子 vs 合成 IC 对比 |
一、为什么不能把因子直接加起来
1.1 一个具体到刺眼的反例
我现在手里有 4 个因子值,准备给一只股票评分:
| 股票 | 12-1 动量 | B/M(book-to-market) | 60d 波动率 | ROE |
|---|---|---|---|---|
| NVDA | 0.78 | 0.02 | 0.42 | 0.36 |
| BAC | -0.12 | 0.95 | 0.18 | 0.11 |
如果我天真地写 score = momentum + value + low_vol + quality:
- NVDA 得分 = 0.78 + 0.02 + (-0.42) + 0.36 = 0.74
- BAC 得分 = -0.12 + 0.95 + (-0.18) + 0.11 = 0.76
两个数字接近。但这个分数没有任何含义——
- 量纲完全不同:动量是收益率([-1, +1] 区间),B/M 是会计比率([0, 10]+),波动率是年化标准差([0.1, 1.0]),ROE 是百分比([-1, +1])
- 方向不一致:动量越高越好,波动率越低越好(小盘成长除外),如果不取负号就反向贡献
- 分布形状不同:B/M 是重尾右偏(几只小盘 deep-value 股 B/M = 8),动量是接近正态,波动率重尾——把它们直接相加,实际上是按「数值大者权重大」做了隐式加权
核心认知:因子合成的本质是「把不同 metric 投影到同一 common scale,再线性组合」。这不是量化独有的问题,PM 工作里任何多指标决策都有同样的坑——CAC(dollar)+ LTV(dollar)+ NPS([-100, 100] 整数)+ Activation Rate([0, 1] 比例)也不能直接相加。
1.2 把这个 PM 直觉翻译到量化语言
| PM 多指标决策 | 量化因子合成 |
|---|---|
| 「CAC 低、LTV 高、留存好的渠道优先」 | 「value 高、momentum 高、low-vol 高的股票优先」 |
| 直接把数字加起来 → CAC 的美元数压倒一切 | 直接把因子加起来 → B/M 的量纲压倒一切 |
| 标准化到 0-100 分数再加权 | z-score 到 N(0,1) 再加权 |
| 头部 10% 渠道扎堆同一类型(如全是 paid search) | 头部 decile 扎堆同一行业(如全是 tech) |
| 按品类内部 rank 再合成 | 行业中性化 |
这个对应关系不是比喻——两个工作的数学本体是同一个:在异质 metric 上做加权排序。
二、z-score:cross-sectional 才是对的
2.1 公式与「在哪个维度算」
z-score 公式所有人都会:
z_i = (x_i - μ) / σ
但在哪个集合上算 μ 和 σ 是新手最容易搞错的:
| 算法 | 集合 | 何时用 | 何时错 |
|---|---|---|---|
| Time-series z | 同一只股票,过去 N 期 | 个股层面的 mean reversion、择时 | 横截面排序时用了 → 错 |
| Cross-sectional z | 同一时间点,所有股票 | 横截面选股、组合构建 | 时间序列异常检测时用了 → 错 |
| Pool z(pooled) | 所有时间所有股票 | 极少用 | 几乎总是错的(混淆两种波动来源) |
举例说明区别。假设我有 3 只股票,3 个月的动量数据:
| 月份 | NVDA | XOM | KO |
|---|---|---|---|
| 2026-04 | 0.30 | 0.10 | 0.05 |
| 2026-05 | 0.40 | 0.05 | -0.02 |
| 2026-06 | 0.78 | -0.15 | 0.03 |
Time-series z(NVDA 自己跟自己比):
μ_NVDA = (0.30 + 0.40 + 0.78) / 3 = 0.493
σ_NVDA = 0.249
z_NVDA(2026-06) = (0.78 - 0.493) / 0.249 = 1.15
→ 含义:NVDA 6 月的动量比它自己历史均值高 1.15 个标准差。
Cross-sectional z(2026-06 月所有股票互相比):
μ_2026-06 = (0.78 + (-0.15) + 0.03) / 3 = 0.22
σ_2026-06 = 0.479
z_NVDA(2026-06) = (0.78 - 0.22) / 0.479 = 1.17
z_XOM(2026-06) = (-0.15 - 0.22) / 0.479 = -0.77
z_KO(2026-06) = (0.03 - 0.22) / 0.479 = -0.40
→ 含义:NVDA 在 6 月所有股票中位于 +1.17 σ 位置。这才是「这个月该 long 谁、short 谁」的可比信号。
做横截面选股,永远用 cross-sectional z。time-series z 只在做单股 mean-reversion 或者 vol-targeting 时才有意义。
2.2 cross-sectional 的隐含假设
cross-sectional z 隐含两个假设,violation 时要小心:
- 当期 universe 是「同质群体」:把 SP100 和 Russell Microcap 放一起算 z 会污染统计——大盘和微盘的因子分布根本不同。我们的 universe 是 SP100,相对同质,OK。
- σ 当期非 0:极端情况下,比如所有股票动量都被强制 clipping 到同一值,σ → 0,z 爆炸。代码里要加
if std < 1e-8: return zeros的护栏。
三、rank-based 标准化:抗异常值的替代方案
3.1 为什么 z-score 在金融数据上经常翻车
金融数据重尾、偏斜、间断有跳跃。直接 z-score 会被极值带歪:
举个例子:100 只股票,99 只的 B/M 在 [0.1, 2.0],1 只 deep-value 落难股 B/M = 12.0。
μ ≈ 1.05 + (12.0 * 0.01) = 1.16 # 这只极端值把 mean 拉高 0.11
σ ≈ 1.18 # 把 std 拉高更多
→ 99 只正常股票的 z 全被压缩到接近 0 的窄区间,真正 long-short 该看到的差异被淹没了。
3.2 rank-based 的做法
step 1: rank_i = rank(x_i) / N # 转成 [0, 1] 的 percentile
step 2: z_i = Φ^-1(rank_i) # 标准正态分布的逆 CDF
效果:
- 极值(B/M = 12)只被 rank 到「最高位」,转 z 后是 ~2.3(约 99 percentile),不会爆
- 中间 99 只股票的 z 重新拉开
trade-off:
| 维度 | z-score | rank-based |
|---|---|---|
| 保留原始信息量 | ✓ | ✗(只剩顺序) |
| 抗异常值 | ✗ | ✓ |
| 假设 | 近似正态 | 无 |
| 适合 | 已经 winsorize 过的因子 | 重尾因子(B/M、market cap、accruals) |
| 计算成本 | 低 | 略高 |
实务做法:动量、ROE 这类相对正态的用 z-score;B/M、市值、流动性这类重尾的用 rank-based;或者所有因子统一用 rank-based(牺牲信息但稳定,AQR、Citadel 部分策略就是这种)。
四、Winsorize:截断 outlier 但不丢样本
4.1 为什么不直接 drop outlier
drop outlier 在 PM 工作里看似常见(「这个用户是机器人,剔除」),但在因子构建上几乎总是错的:
- 量化 universe 本来就小(SP100 只有 100 只)。drop 一个就 -1%
- 异常值可能是真信号(NVDA 12-1 动量 = 0.78 不是 bug,是真的)
- drop 会让 universe 不稳定,月度组合换手率虚高
winsorize = 把 outlier 截断到某分位数,保留样本数。
4.2 截断点的 trade-off
| 截断 | 影响 | 何时用 |
|---|---|---|
| 1% / 99% | 温和,几乎不动正常分布 | 多数因子默认 |
| 5% / 95% | 强力,会损失 tail 信号 | 因子本身就有明显厚尾且无价值 |
| 2.5% / 97.5% | 折中 | 不确定时的稳妥选择 |
| 不截断 | 保留全部信号 | 因子已经接受过 log 变换或 rank 变换 |
关键洞察:截断点不是越严越好。如果你的策略本身想捕捉「极端值带来的 alpha」(如 PEAD 用财报后异动),过度 winsorize 会把你想要的信号截掉。
4.3 截断的方向
def winsorize(x, lower=0.01, upper=0.99):
lo = x.quantile(lower)
hi = x.quantile(upper)
return x.clip(lower=lo, upper=hi)
注意:先 winsorize 再 z-score,顺序反了的话 z 已经被极值污染,winsorize 就晚了。
五、行业中性化:让 momentum 不要把 tech 选光
5.1 不做行业中性化的后果
2026 年的 SP100,如果只看 12-1 动量 z-score 排序:
| 排名 | 股票 | 行业 | 动量 z |
|---|---|---|---|
| 1 | NVDA | Tech | 2.1 |
| 2 | AVGO | Tech | 1.9 |
| 3 | META | Tech | 1.7 |
| 4 | GOOGL | Tech | 1.5 |
| 5 | MSFT | Tech | 1.4 |
| 6 | TSLA | Consumer Disc | 1.3 |
| 7 | AAPL | Tech | 1.2 |
| ... | ... | ... | ... |
→ Top decile(前 10 只)有 8 只 tech。这不是因子在选股,是行业 beta 在伪装成 alpha。一旦 tech 行业回调,整个组合一起崩。
5.2 两种行业中性化方法
方法 A:组内 z-score
对每个 GICS 行业 g:
z_i = (x_i - μ_g) / σ_g
→ 每个行业内部从高到低排,行业之间不可比。
方法 B:减去行业中位数 / 均值
adj_x_i = x_i - median_g(x)
然后对 adj_x 做全局 cross-sectional z
→ 移除「行业平均的因子载荷」,保留行业内相对差异。
对比:
| 方法 | 优势 | 劣势 |
|---|---|---|
| A 组内 z | 实现简单,等权抽取每个行业 | 行业内只有 1-2 只股票时 σ 不稳 |
| B 减中位数 | 数学上更干净,可与全局 z 衔接 | 仍假设行业内分布相似 |
实务我会用 B——它和后续 multi-factor model 的残差分析(risk model)一脉相承。
5.3 GICS vs SIC vs NAICS 怎么选
| 体系 | 维护方 | 层级 | 优势 | 劣势 |
|---|---|---|---|---|
| GICS | S&P + MSCI | 11 sector / 25 industry group / 74 industry | 现代化、机构标准 | 商业授权 |
| SIC | US Census(停更) | 4 位数 | 历史悠久 | 1987 后未大改、错配科技 |
| NAICS | 北美三国 | 6 位数 | 政府统计完整 | 金融业划分粗 |
| BICS | Bloomberg | 类似 GICS | Bloomberg 用户友好 | 锁在 Bloomberg |
我们用 GICS 11 sector 层级:tech / financials / health care / consumer disc / consumer staples / industrials / energy / utilities / real estate / materials / communication services。Yahoo Finance API 直接返回 sector 字段,免费可用。
坑:GICS 在 2018 年把 Facebook(META)从 tech 划到了 communication services,把 Visa 留在了 IT。如果你的回测跨越 2018,要用 historical GICS mapping 而不是当期映射,否则有 lookahead bias。
六、方向化:每个因子都应该「越高越好」
6.1 因子的「自然方向」
| 因子 | 原始计算 | 越高越好 / 越低越好 | 合成前要怎么处理 |
|---|---|---|---|
| 12-1 动量 | r(-12, -1) | 越高越好 | 不变 |
| Value | B/M | 越高越好 | 不变 |
| Value(错用 P/B) | P/B | 越低越好 | 取倒数或加负号 |
| Low volatility | σ(60d) | 越低越好 | 取负:-σ |
| Quality(ROE) | ROE | 越高越好 | 不变 |
| Quality(Accruals) | 应计 / 总资产 | 越低越好 | 取负 |
| Size(小盘溢价) | log(mkt_cap) | 越低越好 | 取负 |
实务原则:在 winsorize 之前先方向化。这样合成时 composite = w1*z1 + w2*z2 + ... + wn*zn,所有 w_i 都是正数,逻辑清晰。
6.2 单调性的隐含假设
线性合成假设:「因子 z 增加 1,预期收益线性增加」。这不是总成立。
| 因子 | 单调? | 备注 |
|---|---|---|
| Momentum | 大致单调,前 12 个月 | 反转效应在最近 1 个月(所以是 12-1 不是 12-0) |
| Value | 极端 deep-value 反而差 | U 型,前 5% 落难股要剔除 |
| Low-vol | 单调 | 学术界 well-documented |
| Quality | 单调 | OK |
Day 32 我先假设单调,Day 35-40 做风格分析时再用 quintile 检测真实单调性。
七、合成:权重怎么定
7.1 三种主流方案
| 方案 | 公式 | 优势 | 劣势 |
|---|---|---|---|
| Equal-weight | 1/N each | 无 overfit 风险 | 忽略因子相关性和有效性差异 |
| IC-weighted | w_i ∝ IC_i | 用历史预测力分配权重 | 有 lookahead 风险,IC 估计噪声大 |
| Risk parity | w_i ∝ 1/σ(z_i) | 各因子等风险贡献 | 不区分有效因子和噪声因子 |
Day 32 用 equal-weight:4 个因子各 25%。原因:
- 我没有足够长的历史 IC 数据来稳定估计 w
- 学术研究表明 equal-weight 在 out-of-sample 经常不输 optimized
- 简单 → 易归因,知道是哪个因子在贡献
7.2 防止「因子相关性」搞砸合成
如果 4 个因子相关性都是 0.7+,合成等于把同一个信号重复 4 次,没有分散效果。Day 32 后我会算 correlation matrix:
| Mom | Val | LowVol | Quality | |
|---|---|---|---|---|
| Mom | 1.0 | -0.3 | -0.4 | 0.1 |
| Val | -0.3 | 1.0 | 0.2 | -0.1 |
| LowVol | -0.4 | 0.2 | 1.0 | 0.4 |
| Quality | 0.1 | -0.1 | 0.4 | 1.0 |
理想:因子两两相关性 < 0.5,且有正有负。上表是金融文献的典型值,比较健康。
八、把整条 pipeline 写成代码
8.1 模块化设计
factor_pipeline.py
├── direction_align(df, factor_directions) # 第 1 步:方向化
├── winsorize(df, lower, upper) # 第 2 步:截断
├── industry_neutralize(df, sector_col, method) # 第 3 步:行业中性化
├── cross_sectional_z(df, method='zscore') # 第 4 步:横截面 z(或 rank)
├── composite(df, weights) # 第 5 步:加权合成
└── factor_pipeline(df, config) # orchestrator
8.2 实际代码
# factor_pipeline.py
"""
Day 32 — factor composition pipeline.
Input: long-format DataFrame with columns
[date, ticker, sector, momentum, value_bm, vol_60d, roe]
Output: same DataFrame + columns [composite_z, decile]
"""
import numpy as np
import pandas as pd
from scipy.stats import norm
FACTOR_DIRECTIONS = {
'momentum': +1, # higher = better, no change
'value_bm': +1, # B/M higher = better
'vol_60d': -1, # lower vol = better, flip sign
'roe': +1, # higher ROE = better
}
FACTOR_WEIGHTS = {
'momentum': 0.25,
'value_bm': 0.25,
'vol_60d': 0.25,
'roe': 0.25,
}
def direction_align(df: pd.DataFrame, directions: dict) -> pd.DataFrame:
out = df.copy()
for col, sign in directions.items():
out[col] = sign * out[col]
return out
def winsorize_xs(s: pd.Series, lower: float = 0.01, upper: float = 0.99) -> pd.Series:
"""Cross-sectional winsorize: clip to quantiles within the given Series."""
if s.isna().all() or len(s.dropna()) < 5:
return s
lo, hi = s.quantile([lower, upper])
return s.clip(lower=lo, upper=hi)
def industry_neutralize(df: pd.DataFrame, factor_cols, sector_col='sector') -> pd.DataFrame:
"""Subtract sector median per cross-section."""
out = df.copy()
for col in factor_cols:
out[col] = out.groupby([out.index, sector_col])[col].transform(
lambda s: s - s.median()
)
return out
def cross_sectional_z(s: pd.Series, method: str = 'zscore') -> pd.Series:
"""Compute z-score on a per-cross-section basis. Caller groups by date."""
s = s.dropna()
if len(s) < 5:
return pd.Series(np.nan, index=s.index)
if method == 'zscore':
std = s.std(ddof=0)
if std < 1e-8:
return pd.Series(0.0, index=s.index)
return (s - s.mean()) / std
elif method == 'rank':
# rank → percentile → inverse normal CDF
ranks = s.rank(pct=True).clip(0.005, 0.995) # avoid +/- inf
return pd.Series(norm.ppf(ranks), index=s.index)
else:
raise ValueError(f"Unknown method: {method}")
def composite(df: pd.DataFrame, z_cols, weights: dict) -> pd.Series:
"""Weighted sum of z-scored factors."""
score = np.zeros(len(df))
for col, w in weights.items():
z_col = f'z_{col}'
score += w * df[z_col].fillna(0).values # missing factor → 0 contribution
return pd.Series(score, index=df.index)
def factor_pipeline(df_raw: pd.DataFrame,
factor_cols: list,
directions: dict = FACTOR_DIRECTIONS,
weights: dict = FACTOR_WEIGHTS,
method: str = 'zscore',
winsor: tuple = (0.01, 0.99),
use_industry_neutral: bool = True) -> pd.DataFrame:
"""
Run the full factor pipeline.
Assumes df_raw is long-format with multi-index [date, ticker].
"""
df = df_raw.copy()
# 1. direction align
df = direction_align(df, directions)
# 2. cross-sectional winsorize (group by date)
for col in factor_cols:
df[col] = df.groupby(level='date')[col].transform(
lambda s: winsorize_xs(s, *winsor)
)
# 3. industry neutralize (group by date + sector, subtract median)
if use_industry_neutral:
df = industry_neutralize(df, factor_cols)
# 4. cross-sectional z (group by date)
for col in factor_cols:
df[f'z_{col}'] = df.groupby(level='date')[col].transform(
lambda s: cross_sectional_z(s, method=method)
)
# 5. composite
df['composite_z'] = composite(df, factor_cols, weights)
# 6. assign deciles (1 = worst, 10 = best) per cross-section
df['decile'] = df.groupby(level='date')['composite_z'].transform(
lambda s: pd.qcut(s.rank(method='first'), 10, labels=False) + 1
)
return df
if __name__ == '__main__':
# Smoke test
np.random.seed(42)
dates = pd.date_range('2024-01-31', '2025-12-31', freq='M')
tickers = [f'STK{i:03d}' for i in range(100)]
sectors = np.random.choice(
['Tech', 'Fin', 'Health', 'Energy', 'Cons'], 100
)
idx = pd.MultiIndex.from_product([dates, tickers], names=['date', 'ticker'])
df = pd.DataFrame(index=idx)
df['sector'] = np.tile(sectors, len(dates))
df['momentum'] = np.random.normal(0.05, 0.20, len(idx))
df['value_bm'] = np.random.lognormal(0, 0.5, len(idx))
df['vol_60d'] = np.random.uniform(0.15, 0.60, len(idx))
df['roe'] = np.random.normal(0.10, 0.15, len(idx))
out = factor_pipeline(
df,
factor_cols=['momentum', 'value_bm', 'vol_60d', 'roe'],
)
print(out[['composite_z', 'decile']].groupby('decile').agg(['mean', 'count']).head())
print(out.loc[dates[-1]].sort_values('composite_z', ascending=False).head(10))
8.3 关键设计选择
| 选择 | 为什么 |
|---|---|
| 长格式 + MultiIndex | groupby('date') 一行解决横截面 |
| 缺失值在 composite 阶段 fillna(0) | 因子缺失等于「该因子无信息」,给中性贡献;比 dropna 损失样本好 |
decile 用 pd.qcut(rank(method='first')) | 处理 ties,每个十分位严格 10% |
norm.ppf 截断到 [0.005, 0.995] | 避免 percentile = 0 或 1 时 ppf 返回 ±inf |
| 函数化而非 class | Day 33 回测要逐月调用,无状态更好测 |
九、常见坑总结
9.1 NaN 处理:dropna 是个陷阱
| NaN 处理 | 影响 |
|---|---|
| 单因子 dropna | SP100 财报缺漏会让 universe 当月剩 90 只 |
| 跨因子 dropna | 4 个因子任一缺漏即 drop,universe 可能剩 60 只 |
| fillna with 0 in composite stage | ✓ 推荐:因子缺漏 = 中性贡献,保留 universe |
| fillna with sector median | 更激进,假设缺漏值等于行业平均 |
Day 32 选择 fillna(0) at composite stage:保 universe 完整 + 透明,缺漏因子等于「这只股票该因子无信号」。
9.2 lookahead bias 的陷阱
| 数据 | 何时可见 | 我用哪个时点 |
|---|---|---|
| 12-1 动量 | t 月底,立即可见 | t 月底 |
| B/M(季报数据) | 报告日期 + 45 天 | 用 lagged value |
| ROE | 同上 | lagged |
| 60d 波动 | t 月底立即可见 | t 月底 |
⚠️ never use 「month-end」 fundamental data unless you 100% confirm publication date。回测里加 45 天 lag 是安全的近似。
9.3 industry mapping 时点
历史 GICS 映射会变(如 META 从 Tech → Communication Services)。回测时如果用「当期 GICS」映射 2017 年的数据,会有未来信息泄漏。Day 32 因为 universe 锁定在最近 SP100、回测只跨 2024-2026 两年,影响很小,但要在脚本注释里写明这是简化假设。
9.4 rebalance 频率 vs z-window
cross-sectional z 算法本身不依赖时间窗口,但多久 rebalance 一次决定 holding period:
- 月度 rebalance(我们的选择):每月底重算 z 和 composite,调仓
- 季度 rebalance:换手低、税收效率高,但反应慢
- 日度 rebalance:换手爆炸、佣金吃干、信噪比低,不推荐
Day 32 锁定月度。Day 33 回测会显式建模换手率和成本。
十、Day 32 单因子 IC vs Composite IC 对比预期
| 因子组合 | 预期 monthly IC | 备注 |
|---|---|---|
| 单 momentum | 0.04 | SP100 是大盘股,momentum IC 不高 |
| 单 value | 0.03 | 当下 value 周期不强 |
| 单 low-vol | 0.05 | SP100 上 low-vol 一直 ok |
| 单 quality | 0.03 | ROE 在大盘的 IC 偏弱 |
| Composite (equal-weight) | 0.06-0.08 | 分散效应 |
数字纯属示意——Day 33 会实测。重点是预期合成 IC > 任何单因子 IC,这是 ensemble 的根本理由。
如果实测合成 IC < max(单因子 IC),三种可能:
- 因子间相关性太高(合成没有分散效应)
- 某个因子是噪声(拉低均值)
- 实现 bug(最常见)
十一、PM 视角:今天学到的迁移性思考
-
「common scale」问题不是量化独有的:任何多指标排序的产品决策都有这个坑。我做电商时给商户算「优质度分数」(GMV + 复购率 + 投诉率 + DSR)也用过 z-score,但当时没想到要 winsorize 和分品类中性化——回头看,那个分数被「头部 TOP10 商户」拉偏了,对腰部商户的区分度其实很差。今天复盘相当于补课。
-
「方向化」是产品语义的事,不是数学的事:把波动率取负号、把 P/B 取倒数——这些转换不是数学要求,是业务直觉的编码。同理,PM 在合成指标时也要先回答「这个指标到底是越高越好还是越低越好」。NPS 越高越好;客诉率越低越好;CAC「太低」可能反而意味着获客质量差。
-
行业中性化 ≈ 产品分品类公平比较:不分品类比较「美妆 GMV」和「家电 GMV」是耍流氓,因为客单价差 10 倍。每个 vertical 内部 normalize 再合成才公平。这个直觉所有零售 PM 都有,搬到量化只是换了名字。
-
Equal-weight 的产品哲学:我以前总觉得「精调权重」才是高级。但在 noisy 环境下,equal-weight 经常打败 optimized(Markowitz portfolio 1/N anomaly),原因是 optimizer 在估计噪声而不是信号。这对应到产品工作就是「越复杂的评分卡越容易 overfit 历史,越简单越能泛化」——这是个被反复验证的方法论。
-
「数据缺失给中性贡献而不是 drop sample」:这条 PM 直觉很强。用户没填 NPS,我们不能把他从所有分析里 drop,而是把他的 NPS 视为「未知 → 行业均值」或「未知 → 中性」。量化里因子缺漏处理同理。
十二、明日预告
Day 33: 完整月度回测 — 含税、含佣金、含滑点、含换手成本
- vectorbt 或 自写月度 rebalance 回测引擎
- 等权 long top decile / short bottom decile 组合
- 加入:交易佣金、bid-ask 一半滑点、$0.0035/share IBKR Pro 费率、30% 股息预提税
- 输出:cumulative return、年化、Sharpe、Sortino、最大回撤、月度换手率
- 对比:composite vs SPY benchmark
- 用 walk-forward 做样本外检验(fit 18 个月 → test 6 个月,滚动)
- 关键问题:Day 32 的 composite IC 0.06 在含税含费后还剩多少 net alpha?
实际执行记录
启动一项填一项,时间戳 + 卡点。
- [hh:mm] 读取 Day 28-31 的 SP100 因子面板数据 — ...
- [hh:mm] 跑 direction_align 验证:vol_60d 是否变负 — ...
- [hh:mm] 跑 winsorize:检查 NVDA 动量是否被截到 99% quantile — ...
- [hh:mm] 跑 industry_neutralize:Tech sector top stocks 是否还集中 — ...
- [hh:mm] 跑 cross-sectional z:每月 mean ≈ 0, std ≈ 1 验证 — ...
- [hh:mm] 输出 composite_z + decile 月度面板 — ...
- [hh:mm] 对比 z-score vs rank-based 两套结果差异 — ...
- [hh:mm] 检查 correlation matrix:4 因子两两相关性 — ...
- 卡点 / 学到的:
总字数:约 6,400 字 今日完成度:理论 ✓ / 代码 ✓ / 实操(执行 pipeline + 保存输出)/ 笔记 ✓