财报基本面数据,涵盖价值、盈利、投资、应计、规模等经典异象,是投资关注的重要指标。
这里示例从PIT(Point-in-Time)对齐的财报数据中,计算一组原始因子值,
并与市值面板结合,生成日期×股票的因子面板。
后续可在此基础上做标准化、中性化、ICIR 检验和组合构建。
1 基本面因子
基本面因子,直接涉及财报基本面数据,其核心是
从PIT对齐财报数据中,计算一组原始因子值,并与市值面板结合,生成日期 × 股票的因子面板。
这里直接通过代码的方式,分析和示例这一过程。
以下代码就是这样的一个一个基本面因子计算模块。
它不负责因子方向、标准化、中性化或最终采纳,这些留给后续验证阶段
"""Fundamental cross-sectional factors (P5, M3).
Operate on point-in-time (PIT) joined fundamentals. The source of truth is a
reports table (code, report_period, announce_date, statement fields); derived
TTM / YoY series are computed per code over report periods, then PIT-joined on
announce_date, then combined with a market-cap panel into factors.
Signs are NOT hardcoded here --- this module only computes raw factor values; the
ICIR sign and adoption decision live in the validation stage (S3/S4/S5).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from mfm.data.fund_synthetic import generate_synthetic_fundamentals
TTM_FIELDS = ["net_profit", "oper_cashflow", "revenue", "cogs"]
def _ttm_trailing_4q(sub: pd.DataFrame, f: str) -> pd.Series:
"""Trailing-4-quarter (364d window, lower bound exclusive) sum of `f`.
Correct on real data where quarters can be missing: sums reports whose
report_period is within the trailing ~1 year, requiring >= 4 reports, so a
3-quarter stretch is left NaN rather than mis-labelled "TTM".
"""
periods = sub["report_period"].to_numpy()
vals = sub[f].to_numpy(dtype=float)
out = np.full(len(sub), np.nan)
day = np.timedelta64(364, "D")
for i in range(len(sub)):
lo = periods[i] - day
m = (periods > lo) & (periods <= periods[i])
if m.sum() >= 4:
out[i] = np.nansum(vals[m])
return pd.Series(out, index=sub.index)
def _yoy_asset_growth(sub: pd.DataFrame) -> pd.Series:
"""YoY total-asset growth vs the same calendar quarter one year earlier."""
periods = pd.to_datetime(sub["report_period"])
vals = sub["total_assets"].to_numpy(dtype=float)
yr, mo, dy = periods.dt.year, periods.dt.month, periods.dt.day
look = {(y, m, d): v for y, m, d, v in zip(yr, mo, dy, vals)}
out = []
for y, m, d, v in zip(yr, mo, dy, vals):
pv = look.get((y - 1, m, d))
out.append(np.nan if (pv is None or pv == 0) else v / pv - 1.0)
return pd.Series(out, index=sub.index)
def derive_report_fields(reports: pd.DataFrame) -> pd.DataFrame:
"""Add trailing-4-quarter TTM and YoY asset-growth per (code, report)."""
df = reports.copy()
# Robust-date handling: eastmoney mixes 'YYYY-MM-DD' with 'YYYY-MM-DD 00:00:00'.
df["report_period"] = pd.to_datetime(df["report_period"], format="mixed", errors="coerce").astype("datetime64[ns]")
df["announce_date"] = pd.to_datetime(df["announce_date"], format="mixed", errors="coerce").astype("datetime64[ns]")
df = df.dropna(subset=["report_period"]).sort_values(["code", "report_period"])
g = df.groupby("code", sort=False, group_keys=False)
for f in TTM_FIELDS:
df[f"{f}_ttm"] = g.apply(lambda s: _ttm_trailing_4q(s, f))
df["asset_growth_yoy"] = g.apply(_yoy_asset_growth)
return df
def pit_field_panel(reports, dates, codes, field):
"""date x stock PIT panel: latest announced `field` per stock as of t."""
r = reports[["code", "announce_date", field]].copy()
r["announce_date"] = pd.to_datetime(r["announce_date"], format="mixed", errors="coerce").astype("datetime64[ns]")
ser = r.dropna(subset=[field, "announce_date"]).sort_values("announce_date")
out = pd.DataFrame(np.nan, index=dates, columns=codes, dtype=float)
q = pd.DataFrame({"t": pd.to_datetime(dates).astype("datetime64[ns]")})
for c in codes:
sub = ser[ser["code"] == c]
if sub.empty:
continue
sub = sub[["announce_date", field]].rename(
columns={"announce_date": "t", field: "v"}
)
merged = pd.merge_asof(q, sub, on="t", direction="backward")
out[c] = merged["v"].values
return out
def compute_panels(reports, mcap, dates, codes):
"""PIT panels for the fields the factors need, plus the market-cap panel."""
dr = derive_report_fields(reports)
panels = {}
for f in [
"total_equity",
"total_assets",
"net_profit_ttm",
"oper_cashflow_ttm",
"revenue_ttm",
"cogs_ttm",
"asset_growth_yoy",
]:
panels[f] = pit_field_panel(dr, dates, codes, f)
panels["mcap"] = mcap.reindex(index=dates, columns=codes)
return panels
# --- factor functions (raw values only; signs decided in validation) ---------
def bm(p):
return p["total_equity"] / p["mcap"]
def ep(p):
return p["net_profit_ttm"] / p["mcap"]
def cfp(p):
return p["oper_cashflow_ttm"] / p["mcap"]
def sp(p):
return p["revenue_ttm"] / p["mcap"]
def roe(p):
return p["net_profit_ttm"] / p["total_equity"]
def asset_growth(p):
return p["asset_growth_yoy"]
def accruals(p):
return (p["net_profit_ttm"] - p["oper_cashflow_ttm"]) / p["total_assets"]
def gross_margin(p):
return (p["revenue_ttm"] - p["cogs_ttm"]) / p["revenue_ttm"]
def log_mcap(p):
return np.log(p["mcap"])
FUND_REGISTRY = {
"bm": bm,
"ep": ep,
"cfp": cfp,
"sp": sp,
"roe": roe,
"asset_growth": asset_growth,
"accruals": accruals,
"gross_margin": gross_margin,
"log_mcap": log_mcap,
}
def main() -> None:
synth = generate_synthetic_fundamentals(n_stocks=30, seed=5)
reports, mcap, codes = synth["reports"], synth["market_cap"], synth["codes"]
dates = mcap.index
panels = compute_panels(reports, mcap, dates, codes)
factors = {name: fn(panels) for name, fn in FUND_REGISTRY.items()}
for name, f in factors.items():
finite = float(np.isfinite(f.values).mean())
print(f" {name:12s} shape={f.shape} finite={finite:.2%}")
assert set(factors) == set(FUND_REGISTRY)
assert all(f.shape == (len(dates), len(codes)) for f in factors.values())
bmv = factors["bm"].values
assert (bmv[np.isfinite(bmv)] > 0).all(), "bm must be positive"
np.testing.assert_allclose(
factors["log_mcap"].values, np.log(mcap.values), equal_nan=True
)
print(f"M3 SMOKE OK: {len(FUND_REGISTRY)} factors computed on PIT-joined panels")
if __name__ == "__main__":
main()
代码模块的核心流程是:
1)财报数据衍生
对每只股票按报告期计算 TTM(滚动 4 季度)和 YoY(同比)指标。
2)PIT 面板构建
按公告日announce_date做 as-of join,确保在任意日期t只使用当时已经公告的财务数据。
3)因子计算
将PIT面板与市值面板结合,计算B/M、E/P、CFP、S/P、ROE、资产增长、应计、毛利率、对数市值等原始因子。
4)冒烟测试
用合成数据验证形状、有限值比例、符号约束等。
代码不硬编码因子方向,因为因子IC符号可能随市场、时段变化,最终由验证阶段的ICIR决定。
1.1 财报数据衍生
1)常量与导入
TTM_FIELDS = "net_profit", "oper_cashflow", "revenue", "cogs"
这些是需要计算TTM的报表字段:
净利润、经营现金流、营业收入、营业成本。后续因子会用到它们的 TTM 值。
2)滚动4季度求和
滚动 4 季度求和的实现函数是_ttm_trailing_4q。
def _ttm_trailing_4q(sub, f):
day = np.timedelta64(364, "D")
for i in range(len(sub)):
lo = periodsi - day
m = (periods > lo) & (periods <= periodsi)
if m.sum() >= 4:
outi = np.nansum(valsm)
其计算逻辑为对每个报告期,向前找364天内的所有报告,若数量 ≥ 4,则求和作为TTM。
使用 364 天而不是 365 天,是为了避免边界问题,因为 4 个季度约 364 天。
下界排他>,上界包含 <=,窗口为(报告期 - 364天, 报告期]。
要求至少 4 份报告,防止只有 3 个季度时被误标为TTM。
用 np.nansum容忍缺失值。
TTM 用于平滑季节性,反映最近一年的盈利能力、现金流和收入规模。
相比单季度,TTM 更稳定,适合横截面比较。
3)总资产同比增速
总资产同比增速的实现函数是_yoy_asset_growth
look = {(y, m, d): v for ...}
pv = look.get((y - 1, m, d))
out.append(np.nan if (pv is None or pv == 0) else v / pv - 1.0)
按去年同一日历季度查找总资产,计算同比增速。若去年缺失或为 0,则返回 NaN。
资产增长是投资因子的代理变量。Fama-French五因子模型中的投资因子认为,资产扩张较快的公司未来收益可能较低,即投资保守的公司有溢价。
4)衍生报表字段
衍生报表字段是derive_report_fields。
df"report_period" = pd.to_datetime(..., format="mixed", errors="coerce")
df"announce_date" = pd.to_datetime(..., format="mixed", errors="coerce")
df = df.dropna(subset="report_period").sort_values("code", "report_period")
g = df.groupby("code", sort=False, group_keys=False)
for f in TTM_FIELDS:
dff"{f}_ttm" = g.apply(lambda s: _ttm_trailing_4q(s, f))
df"asset_growth_yoy" = g.apply(_yoy_asset_growth)
处理混合日期格式,如 YYYY-MM-DD和YYYY-MM-DD 00:00:00。
按code和report_period排序,保证滚动计算顺序正确。
为每个TTM字段生成 {field}_ttm,并生成 asset_growth_yoy。
1.2 PIT面板
1)PIT面板
PIT 面板对应pit_field_panel
ser = r.dropna(subset=field, "announce_date").sort_values("announce_date")
out = pd.DataFrame(np.nan, index=dates, columns=codes, dtype=float)
q = pd.DataFrame({"t": pd.to_datetime(dates).astype("datetime64ns")})
for c in codes:
sub = serser\["code" == c]
sub = sub\["announce_date", field].rename(columns={"announce_date": "t", field: "v"})
merged = pd.merge_asof(q, sub, on="t", direction="backward")
outc = merged"v".values
核心是merge_asof(..., direction="backward"),
对每个日期 t,找announce_date <= t的最近一条记录。
这保证了Point-in-Time对齐,避免使用未来公告的财务数据。
财报有报告期和公告日。
例如 2023 年年报的报告期是 2023-12-31,但公告日可能是 2024-03-31。
在 2024-01-01 时,投资者还看不到这份年报。
PIT连接是回测和因子研究的基础,防止前视偏差(look-ahead bias)。
2)构建所需面板
构建所需面板的实现见compute_panels。
for f in ["total_equity", "total_assets", "net_profit_ttm", "oper_cashflow_ttm",
"revenue_ttm", "cogs_ttm", "asset_growth_yoy"]:
panelsf = pit_field_panel(dr, dates, codes, f)
panels"mcap" = mcap.reindex(index=dates, columns=codes)
目的是将衍生后的报表字段逐个转成 PIT 面板,并加入市值面板。
1.3 因子计算
1)因子函数梳理
在代码中,因子覆盖了价值、盈利、投资、质量、应计和规模等主要风格。
| 因子 | 公式 | 含义 |
|---|---|---|
bm |
total_equity/mcap |
账面市值比,价值因子 |
ep |
net_profit_ttm/mcap |
盈利收益率,价值/盈利因子 |
cfp |
oper_cashflow_ttm/mcap |
现金流收益率 |
sp |
revenue_ttm/mcap |
销售收益率 |
roe |
net_profit_ttm/total_equity |
净资产收益率,盈利能力 |
asset_growth |
asset_growth_yoy |
资产同比增速,投资因子 |
accruals |
(net_profit_ttm-oper_cashflow_ttm)/total_assets |
应计项目 |
gross_margin |
(revenue_ttm-cogs_ttm)/revenue_ttm |
毛利率,质量/盈利 |
log_mcap |
log(mcap) |
对数市值,规模因子 |
在此基础上,注册表与主函数如下。
FUND_REGISTRY = {"bm": bm, "ep": ep, ...}
注册表模式便于遍历、扩展和统一测试。
1.4 冒烟测试
main用合成数据做冒烟测试,检查:
1)因子数量与注册表一致;
2)形状为 len(dates) × len(codes);
3)B/M为正;
4)log_mcap与np.log(mcap) 一致。
2 示例分析
2.1 横截面因子投资
横截面因子是在某一时点,对所有股票按某指标排序,构建多空组合或进行回归,预测未来收益。
常见流程:
-
计算原始因子值;
-
去极值、标准化、中性化;
-
计算 IC(因子值与未来收益的横截面相关系数);
-
计算 ICIR = mean(IC) / std(IC);
-
根据ICIR 符号和显著性决定因子方向与是否采纳。
在上述代码中,只负责第一步,符号不硬编码,是为了把方向判断留给验证阶段。
2.2 PIT数据与避免前视偏差
PIT是量化回测的生命线。财报数据必须按公告日对齐,而不是报告期。
merge_asof(direction="backward")是典型的as-of join,保证在t时刻只使用已公告的信息。
2.3 TTM与YoY
TTM是滚动 4 季度,平滑季节性,反映最近一年经营状况。
YoY是同比,消除季节性,衡量增长率。
代码要求TTM至少 4 份报告,避免缺失季度导致误判。
2.4 财务基本面因子
代码覆盖了Fama-French五因子中的规模、价值、盈利、投资,以及应计、毛利率等异象因子。
这些因子在横截面上通常需要正交化、中性化后使用。
1)价值因子
B/M、E/P、CFP、S/P。Fama-French 三因子中的价值溢价。
2)盈利因子
ROE、毛利率。Fama-French 五因子中的盈利能力因子;Novy-Marx 强调毛利率。
3)投资因子
资产增长,Fama-French 五因子中的投资因子,资产扩张快可能负向预测收益。
4)应计异象
Sloan(1996) 发现应计项目高的公司未来收益低,因为盈利质量差。
5)规模因子
对数市值。Banz (1981) 小市值效应。
6)现金流与销售
CFP、S/P 是价值因子的变体,提供不同角度的估值衡量。
refernce
A five-factor asset pricing model
https://r.jordan.im/download/investing/fama2015.pdf
The other side of value: The gross profitability premium
https://users.nber.org/~confer/2010/APf10/Novy-Marx.pdf
Do stock prices fully reflect information in accruals and cash flows about future earnings?
https://www.cuhk.edu.hk/acy2/workshop/June2009Wasley/1996TAR).pdf
The relationship between return and market value of common stocks. Journal of Financial Economics
https://search.dailystocks.com/Banz_sizeeffect_1980.pdf
Point-in-Time Data: Essential for Backtesting
https://developer.stockfit.io/blog/point-in-time-data-backtesting
RecTurn(offset, type, NAHandling)
https://www.portfolio123.com/doc/doc_detail.jsp?factor=RecTurn&showCont=1#more