基本面因子计算的示例分析

财报基本面数据,涵盖价值、盈利、投资、应计、规模等经典异象,是投资关注的重要指标。

这里示例从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

相关推荐
Lyyaoo.2 小时前
【动态规划】【待更新】
java·数据结构·算法
AgentMaster2 小时前
开源智能客服系统技术对比:从架构设计到生产部署的实战指南
大数据·人工智能·算法
weixin_307779133 小时前
C++代码实现MATLAB中的ode23t函数功能
开发语言·c++·算法·matlab
萧西待水3 小时前
奥赛一本通 1451 棋盘游戏
算法·宽度优先
Niuguangshuo3 小时前
论文解读:Paraformer,非自回归中文 ASR 的并行 Transformer
算法·音视频·语音识别
鹿角片ljp3 小时前
LeetCode 78:子集|回溯、选与不选、递归和path快照
java·数据结构·算法
hansang_IR3 小时前
【代数与组合数学 | 那忘算 5】生成函数 & 例题 & 卷积
c++·算法·多项式·生成函数·母函数
Zane19943 小时前
快速排序凭什么叫"快"排序?平均O(nlogn)背后,藏着一个能让它退化成O(n²)的选择
算法
6Hzlia4 小时前
【Classic 150 刷题计划】 LeetCode 242. 有效的字母异位词 | C++ 哈希计数与严密防线
c++·算法·leetcode