学生用案例操作手册|量化多因子策略(FF5 框架、A 股、零编程)

本手册面向零基础同学,指导你“自己到 CSMAR 抓数 + 用 Excel/WPS 完成横截面多因子小案例”。你将独立完成:数据下载、口径对齐、因子工程(winsor→标准化→规模中性化→合成)、Rank‑IC 与分组评估。全流程不写代码。

为兼容工具差异,每一步均提供两套做法:

注:学校没有 CSMAR 行业分类权限。本手册附“静态行业映射表”,请手工建表使用;或退化为“全市场标准化”。


0. 目标产出与所需数据

建议样本:20 只 A 股 × 24–36 个月月度数据(名单见第 1 节)。


1. 样本股票与行业映射(手工建表,替代 CSMAR 行业)

请新建工作表 Industry_Lookup,建立两列:TickerIndustry,手工录入下表(或复制粘贴):

Ticker NameCN Industry
600519.SH 贵州茅台 食品饮料
000858.SZ 五粮液 食品饮料
603288.SH 海天味业 食品饮料
600036.SH 招商银行 银行
601318.SH 中国平安 保险
000333.SZ 美的集团 家电
300750.SZ 宁德时代 电力设备
002594.SZ 比亚迪 汽车
601012.SH 隆基绿能 电力设备
600309.SH 万华化学 化工
600276.SH 恒瑞医药 医药生物
300760.SZ 迈瑞医疗 医疗器械
002415.SZ 海康威视 电子/安防
600031.SH 三一重工 机械
601888.SH 中国中免 商贸零售
601899.SH 紫金矿业 有色金属
601668.SH 中国建筑 建筑
002714.SZ 牧原股份 农林牧渔
000002.SZ 万科A 房地产
600660.SH 福耀玻璃 汽车零部件

若你不想使用行业:把 Industry 一列全部填为 All,后续就做“全市场标准化”。

CSMAR 文本筛选用股票代码列表(直接粘贴)

600519.SH
000858.SZ
603288.SH
600036.SH
601318.SH
000333.SZ
300750.SZ
002594.SZ
601012.SH
600309.SH
600276.SH
300760.SZ
002415.SZ
600031.SH
601888.SH
601899.SH
601668.SH
002714.SZ
000002.SZ
600660.SH
600519
000858
603288
600036
601318
000333
300750
002594
601012
600309
600276
300760
002415
600031
601888
601899
601668
002714
000002
600660

2. 从 CSMAR 抓取数据(你自己操作)

以下为常见的 CSMAR 客户端菜单路径。不同高校界面略有差异,请以本地为准。

2.1 月度交易与市值(TRD_Mnth)

说明:优先用 Mretwd(复权月收益);若没有,将用“前复权收盘价”自算。

2.2 年度财务(资产负债表 + 利润表,合并报表口径)

小贴士:如果“归母权益/净利润”字段命名不同,请选择含义相同的口径;确保为“合并报表、年度”。


3. 新建工作簿与导入原始表

新建 MF-FF5-Ashare.xlsx,创建以下工作表并导入数据。

将三张表都转换为“表格”(Ctrl+T),命名:

合并 BS_Annual.xlsx 与 IS_Annual.xlsx(生成 Financials_Annual / TblFin)

目标:把两份“年度财务”合成一张标准表,列为:Ticker, FiscalYear, BookEquity, TotalAssets, NetIncome,并转为表对象 TblFin

1) 导入两张源表并规范列

2) 在两张表中提取 FiscalYear

3) 以资产负债表为“底表”,添加净利润

4) 去重与排序

5) 质检要点

完成后,TblFin 即为后续计算 AG 的基础表。

可直接复制的关键公式


4. 预处理与口径对齐(避免前视)

4.1 在 Data 表创建辅助列

A 版(XLOOKUP):

=IFERROR(XLOOKUP([@Ticker], TblInd[Ticker], TblInd[Industry], "All"), "All")

B 版(INDEX/MATCH):

=IFERROR(INDEX(TblInd[Industry], MATCH([@Ticker], TblInd[Ticker], 0)), "All")

A 版(动态数组,无需辅助列):

=LET(
  tk, [@Ticker],
  dt, [@Date],
  dts, FILTER(TblData[Date], TblData[Ticker]=tk),
  rets, FILTER(TblData[Mret], TblData[Ticker]=tk),
  IFERROR( XLOOKUP(TRUE, dts>dt, rets), NA() )
)

B 版(OFFSET + 分组判断):

=IFERROR(
  IF(AND([@Ticker]=OFFSET([@Ticker], 1, 0), OFFSET([@Date], 1, 0) > [@Date]),
     OFFSET([@Mret], 1, 0),
     NA()
  ),
  NA()
)

如果没有 Mret 字段:用“前复权收盘价”计算 Ret = AdjClose/上期AdjClose - 1,再右移一月得到 Ret_t1

4.2 在 Financials_Annual 计算年度投资率 AG

新增列 AG(按同一股票相邻年份):

A 版(XLOOKUP+FILTER):

=LET(
 tk, [@Ticker],
 yr, [@FiscalYear],
 lastA, XLOOKUP(yr-1, FILTER(TblFin[FiscalYear], TblFin[Ticker]=tk),
                     FILTER(TblFin[TotalAssets], TblFin[Ticker]=tk)),
 IFERROR([@TotalAssets]/lastA - 1, NA())
)

B 版(辅助键列):

=IFERROR([@TotalAssets] / INDEX(TblFin[TotalAssets], MATCH([@Ticker]&([@FiscalYear]-1), TblFin[Key], 0)) - 1, NA())

4.3 将上一年度财务映射到月度 Data

TblData 中新增三列:BookEquityNetIncomeAG(均来自 Year_Fin 对应年度)

A 版:

= XLOOKUP(1, (TblFin[Ticker]=[@Ticker])*(TblFin[FiscalYear]=[@Year_Fin]), TblFin[BookEquity])
= XLOOKUP(1, (TblFin[Ticker]=[@Ticker])*(TblFin[FiscalYear]=[@Year_Fin]), TblFin[NetIncome])
= XLOOKUP(1, (TblFin[Ticker]=[@Ticker])*(TblFin[FiscalYear]=[@Year_Fin]), TblFin[AG])

B 版(用 Key):

=INDEX(TblFin[BookEquity], MATCH([@KeyFin], TblFin[Key], 0))
=INDEX(TblFin[NetIncome], MATCH([@KeyFin], TblFin[Key], 0))
=INDEX(TblFin[AG], MATCH([@KeyFin], TblFin[Key], 0))

检查点:随机抽 1 只股票,确认 2023 年财报值被用于 2024 年 1–12 月,避免前视。


5. 构造原始描述子(Factors_Raw)

TblData 的关键列复制/引用到 Factors_RawDate, Ticker, Industry, MktCap, LogMktCap, Ret_t1, BookEquity, NetIncome, AG

新增三个描述子(“越大越好”):

BM     = IFERROR([@BookEquity]/[@MktCap], NA())
ROE    = IFERROR([@NetIncome]/[@BookEquity], NA())
AG_pos = IFERROR(-1*[@AG], NA())

注意:若 BookEquity <= 0ROE 可能异常,后续 winsor 会处理极端值。


6. 行业内去极值(1%–99% Winsor)

Standardize 表拷入 Factors_Raw 的列,并对每个 Date × Industry 分组分别处理三列:BMROEAG_pos

A 版(FILTER):

=LET(
  d, [@Date],
  ind, [@Industry],
  x_raw, FILTER([ROE], ([Date]=d)*([Industry]=ind)),
  x, FILTER(IFERROR(x_raw, ""), IFERROR(x_raw, "")<>""),  /* 剔除 #N/A */
  MIN(MAX([@ROE], PERCENTILE.INC(x, 0.01)), PERCENTILE.INC(x, 0.99))
)

B 版(数组公式,输入后按 Ctrl+Shift+Enter;WPS 经典版同理):

=MIN( MAX([@BM],
  PERCENTILE.INC(IF((Standardize[Date]=[@Date])*(Standardize[Industry]=[@Industry]), Standardize[BM]), 0.01)),
  PERCENTILE.INC(IF((Standardize[Date]=[@Date])*(Standardize[Industry]=[@Industry]), Standardize[BM]), 0.99))
)

ROEAG_pos 同样处理,得到 BM_win, ROE_win, AGpos_win

如果你将 Industry 全设为 “All”,上述“组内”即为“全市场”。


7. 行业内秩→正态分位标准化

将 winsor 后的数值转为近似标准正态分数 zz。以 BM_win 为例:

A 版:

=LET(
  d, [@Date], ind, [@Industry],
  x, FILTER(Standardize[BM_win], (Standardize[Date]=d)*(Standardize[Industry]=ind)),
  r, RANK.AVG([@BM_win], x, 1),
  n, ROWS(x),
  NORM.S.INV((r-0.5)/n)
)

B 版(CSE):

=LET(
  r, RANK.AVG([@BM_win], IF((Standardize[Date]=[@Date])*(Standardize[Industry]=[@Industry]), Standardize[BM_win]), 1),
  n, COUNTIFS(Standardize[Date],[@Date], Standardize[Industry],[@Industry]),
  NORM.S.INV((r-0.5)/n)
)

ROE_winAGpos_win 同理,得到 z_BMz_ROEz_AGpos

完全规避溢出的方案 A:用“辅助键列 + 单值聚合”(严格平均秩版,无数组溢出)

思路:为每个分组 Date×Industry 生成稳定的键 Key_DI,并用只返回“单个标量”的聚合(SUMPRODUCT)来计算组内严格的升序“平均秩”(tie-average),全程不使用会产生溢出的动态数组函数,从而彻底规避 #SPILL!。同时,通过对潜在错误值加“哨兵值”与 ISNUMBER 过滤,避免 #N/A 传播。

1. 新增辅助键列

2. 严格平均秩的 RankBase 计算(无溢出、抗错误)

你当前采用的“严格平均秩”实现如下,以 BM_win 为例。该实现不依赖 -0.5 连续性修正,而是精确计算“小于本行”的数量与“等于本行”的数量,并取中点作为平均秩:

3. 同组有效样本量 N(只统计数值)

为了正确归一化秩为分位,样本量应仅统计“可参与”的数值项:

=SUMPRODUCT(--([Key_DI]=[@Key_DI]), --ISNUMBER([BM_win]))

BM_win 替换为 ROE_winAGpos_win 可得到对应的样本量。

4. 最终 z 分数(秩 → 分位 → 正态逆)

将平均秩除以有效样本量得到累计分位,再用标准正态逆映射为 z 值:

实用建议


8. 规模中性化(对数市值残差)

对每个 Date,将 z_*LogMktCap 做单变量回归,取残差:z=zβlog(MktCap)z^{\perp} = z - \beta \cdot \log(\text{MktCap}),其中 β=Cov(z,logM)Var(logM)\beta = \frac{\mathrm{Cov}(z, \log M)}{\mathrm{Var}(\log M)}

A 版(全市场维度):

=LET(
 d, [@Date],
 z, FILTER(Neutralize[z_BM], Neutralize[Date]=d),
 s, FILTER(Neutralize[LogMktCap], Neutralize[Date]=d),
 beta, COVARIANCE.S(z, s)/VAR.S(s),
 [@z_BM] - beta*[@LogMktCap]
)

B 版(CSE):

=[@z_BM] - (
   COVARIANCE.S(
     IF(Neutralize[Date]=[@Date], Neutralize[z_BM]),
     IF(Neutralize[Date]=[@Date], Neutralize[LogMktCap])
   )
   / VAR.S(IF(Neutralize[Date]=[@Date], Neutralize[LogMktCap]))
) * [@LogMktCap]

z_ROEz_AGpos 同理,得到 z_BM_perpz_ROE_perpz_AGpos_perp

进阶:也可选择在“Date × Industry”维度做规模中性化(更严谨,但样本少时不稳)。


9. 合成综合分数(等权)

Composite 表计算:

Score = AVERAGE([@z_BM_perp], [@z_ROE_perp], [@z_AGpos_perp])

课堂默认等权;IR 加权、风险平价等进阶放到后续课程。


10. 研究级评估(一):每月 Rank‑IC

Composite 表按 Date 组内分别对 ScoreRet_t1 排秩,再求相关。

新增辅助列:

A 版:

=LET(d, [@Date],
 x, FILTER(Composite[Score], Composite[Date]=d),
 RANK.AVG([@Score], x, 0))

B 版(CSE):

=RANK.AVG([@Score], IF(Composite[Date]=[@Date], Composite[Score]), 0)

Research_Eval 建立“唯一月份”列表:

对每个 Month 计算 Rank‑IC:

A 版:

=CORREL(
  FILTER(Composite[Rank_Score], Composite[Date]=MonthCell),
  FILTER(Composite[Rank_Ret],   Composite[Date]=MonthCell)
)

B 版(透视替代):

汇总:


11. 研究级评估(二):五分位分组与多空组合回报

Composite 表打分组标签(每个 Date 组内按 Score 分 5 组):

A 版:

Perc_Score = LET(d,[@Date], x, FILTER(Composite[Score], Composite[Date]=d),
                 PERCENTRANK.INC(x, [@Score]))
Bucket     = MIN(5, 1 + INT(5 * [@Perc_Score]))

或者

=LET(
  d, [@Date],
  x, FILTER(Composite[Score], Composite[Date]=d),
  r, RANK.EQ([@Score], x, 1),
  n, ROWS(x),
  1 + INT(5 * (r - 1) / n)
)

B 版(CSE):

Perc_Score = PERCENTRANK.INC(IF(Composite[Date]=[@Date], Composite[Score]), [@Score])
Bucket     = MIN(5, 1 + INT(5 * [@Perc_Score]))

Research_Eval 汇总每月各组次期收益:

Ret_Qk_t   = AVERAGEIFS(Composite[Ret_t1], Composite[Date], MonthCell, Composite[Bucket], k)
LongShort  = Ret_Q5_t - Ret_Q1_t

累计曲线:

Cum_Q5 = PRODUCT(1 + range_Q5) - 1
Cum_Q1 = PRODUCT(1 + range_Q1) - 1
Cum_LS = PRODUCT(1 + range_LS) - 1

或逐期累计列:

=IF(ROW()=ROW(first), 1+Ret_Q5_t, previous_cell*(1+Ret_Q5_t))

绘图:插入折线图展示 Rank‑IC、Q1..Q5、Q5−Q1 累计收益。


12. 质量控制清单(交作业前逐条自检)


15. 关键公式速查(复制即用)

注意:Data 表务必按 TickerDate 升序排序,以确保 Ret_t1 按“下一期”正确移位。

A版:=LET(tk,[@Ticker],yr,[@FiscalYear],lastA,XLOOKUP(yr-1,FILTER(TblFin[FiscalYear],TblFin[Ticker]=tk),FILTER(TblFin[TotalAssets],TblFin[Ticker]=tk)), IFERROR([@TotalAssets]/lastA-1,NA()))
B版:=IFERROR([@TotalAssets]/INDEX(TblFin[TotalAssets], MATCH([@Ticker]&([@FiscalYear]-1), TblFin[Key], 0))-1, NA())
A版:=XLOOKUP(1,(TblFin[Ticker]=[@Ticker])*(TblFin[FiscalYear]=[@Year_Fin]), TblFin[BookEquity])
B版:=INDEX(TblFin[BookEquity], MATCH([@KeyFin], TblFin[Key], 0))
A版:=LET(tk,[@Ticker],dt,[@Date], XLOOKUP(dt, FILTER(TblData[Date],TblData[Ticker]=tk), FILTER(TblData[Mret],TblData[Ticker]=tk), , 1))
B版:=IF(AND([@Ticker]=OFFSET([@Ticker],-1,0), [@Date]>OFFSET([@Date],-1,0)), OFFSET([@Mret],-1,0), NA())
A版:=LET(d,[@Date],ind,[@Industry],x,FILTER(Standardize[BM],(Standardize[Date]=d)*(Standardize[Industry]=ind)), MIN(MAX([@BM],PERCENTILE.INC(x,0.01)), PERCENTILE.INC(x,0.99)))
B版(CSE):=MIN(MAX([@BM], PERCENTILE.INC(IF((Standardize[Date]=[@Date])*(Standardize[Industry]=[@Industry]),Standardize[BM]),0.01)), PERCENTILE.INC(IF((Standardize[Date]=[@Date])*(Standardize[Industry]=[@Industry]),Standardize[BM]),0.99))
A版:=LET(d,[@Date],ind,[@Industry],x,FILTER(Standardize[BM_win],(Standardize[Date]=d)*(Standardize[Industry]=ind)), r,RANK.AVG([@BM_win],x,0), n,ROWS(x), NORM.S.INV((r-0.5)/n))
B版(CSE):=LET(r,RANK.AVG([@BM_win], IF((Standardize[Date]=[@Date])*(Standardize[Industry]=[@Industry]),Standardize[BM_win]),0), n,COUNTIFS(Standardize[Date],[@Date],Standardize[Industry],[@Industry]), NORM.S.INV((r-0.5)/n))
A版:=LET(d,[@Date], z,FILTER(Standardize[z_BM],Standardize[Date]=d), s,FILTER(Standardize[LogMktCap],Standardize[Date]=d), beta,COVARIANCE.S(z,s)/VAR.S(s), [@z_BM]-beta*[@LogMktCap])
B版(CSE):=[@z_BM]-( COVARIANCE.S(IF(Standardize[Date]=[@Date],Standardize[z_BM]), IF(Standardize[Date]=[@Date],Standardize[LogMktCap])) / VAR.S(IF(Standardize[Date]=[@Date],Standardize[LogMktCap])) )*[@LogMktCap]
A版:=CORREL(FILTER(Composite[Rank_Score], Composite[Date]=MonthCell), FILTER(Composite[Rank_Ret], Composite[Date]=MonthCell))
A版:Perc=LET(d,[@Date],x,FILTER(Composite[Score],Composite[Date]=d), PERCENTRANK.INC(x,[@Score])) ; Bucket=1+INT(5*Perc)
B版(CSE):Perc=PERCENTRANK.INC(IF(Composite[Date]=[@Date],Composite[Score]), [@Score]) ; Bucket=1+INT(5*Perc)