← 返回模块
3.2.4.2beta 可读 · 未来付费校验通过内容版本 2026-05-24

合成截面数据与微观结构工厂

3.2.4 · 合成数据与 API · 编程

某家私募的因子研究员要演示一个多因子打分模型,需要 200 家"虚拟公司"的横截面:每家要有行业、市值、贝塔、价值/动量/质量三个因子分,且这些字段之间的相关结构得接近真实 A 股名单。另一边,执行成本组要演示成本拆解,需要一段带买卖价差与成交大小的合成 tick 流。两段需求都不能动行情数据牌照——上一课只能产价格路径,这一课要把它扩成横截面与微观结构。本课分两半:先把 make_cohort 写好,作为 200 家公司的因子分发生器;再把 make_ticks 写好,作为单标的 tick 工厂。两块工具直接喂回 3.2.3 L1 的回归脚本与 3.2.2 L4 的成交成本归因脚本。

横截面 cohort 工厂

合成公司的核心字段:asset_id(虚拟代码)、sector(申万一级行业)、market_cap(对数正态,刻意右偏)、beta(以 1.0 为中心、被 clip 到合理区间)以及三只因子分 value_score / momentum_score / quality_score(相关结构由一个 (3, 3) 相关矩阵给定,用 Cholesky 一次构造完成)。行业列表用申万一级行业:银行、食品饮料、医药生物、电子、电力设备、机械设备、有色金属、计算机、通信、家用电器,十大类涵盖主流上市分布。rng.choice 抽行业不指定概率,等价于均匀;实务里会用上市公司数量加权,这里为了教学简洁不引这层。市值用 rng.lognormal(mean=11.0, sigma=1.5),等于均值约 exp(11) ≈ 60000 单位、对数尺度上散布 1.5,正好覆盖大市值万亿到中盘百亿那一段。贝塔从 N(1.0,0.3)N(1.0, 0.3) 抽出来后再 np.clip(beta, 0.3, 2.5),把极端尾巴掐掉。

def make_cohort(n_assets: int, seed: int, sectors: list[str] = DEFAULT_HANGYE) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    asset_id = [f"SYN{i:04d}.SH" for i in range(n_assets)]
    sector = rng.choice(sectors, size=n_assets)
    market_cap = rng.lognormal(mean=11.0, sigma=1.5, size=n_assets)
    beta = rng.normal(loc=1.0, scale=0.3, size=n_assets)
    beta = np.clip(beta, 0.3, 2.5)
    # 因子分相关化(见下面 Cholesky 步骤)
    R_factors = np.array([[1.0, -0.2, 0.1], [-0.2, 1.0, 0.3], [0.1, 0.3, 1.0]])
    L_f = scipy.linalg.cholesky(R_factors, lower=True)
    Z_f = rng.standard_normal((n_assets, 3))
    F = Z_f @ L_f.T
    value_score, momentum_score, quality_score = F.T
    return pd.DataFrame({
        "asset_id": asset_id, "sector": sector, "market_cap": market_cap, "beta": beta,
        "value_score": value_score, "momentum_score": momentum_score, "quality_score": quality_score,
    })

流程梳理一下,make_cohort 内部按以下步骤走:

  1. 构造 rng = np.random.default_rng(seed),把所有随机抽样集中到一只 Generator
  2. asset_id(合成代码)、sector(申万一级行业)、market_cap(对数正态)。
  3. betanp.clip(beta, 0.3, 2.5) 把极端尾巴掐掉。
  4. 构造 (3, 3) 因子相关矩阵 R_factors,Cholesky 分解,生成相关化的因子分。
  5. 把全部七列打包成 pd.DataFrame 返回。

价值与动量在 A 股口径上长期是弱负相关 -0.2(价值买便宜、动量买涨,买点天然错开),动量与质量弱正相关 0.3(高 ROE 名单在牛市里也常跑得动)。Cholesky 之前先做一次 PSD 健全性检查:

R_factors = np.array([[1.0, -0.2, 0.1], [-0.2, 1.0, 0.3], [0.1, 0.3, 1.0]])
assert np.all(np.linalg.eigvalsh(R_factors) > 0), 'R_factors must be PSD'
L_f = scipy.linalg.cholesky(R_factors, lower=True)
Z_f = rng.standard_normal((n_assets, 3))
F = Z_f @ L_f.T
value_score, momentum_score, quality_score = F.T

接回 L1:每股的期望收益

cohort 不是空中楼阁——每只虚拟股票要有自己的漂移 mu_i,才能把 L1 的 simulate_basket 跑成 200 名的回测样本。最小可行的口径是 CAPM 加一个 3 因子 premia 叠加:

r_f, mu_m = 0.02, 0.08
factor_premia = np.array([0.03, 0.04, 0.02])
loadings = scipy.stats.zscore(cohort[['value_score', 'momentum_score', 'quality_score']].to_numpy(), axis=0)
mu_i = r_f + cohort['beta'].to_numpy() * (mu_m - r_f) + loadings @ factor_premia

mu_i 当作上一课 simulate_basket(mu=mu_i, ...) 的入参,再配一份每股波动率(可以从行业均值 + 噪声构造),200 家的合成回测就出来了。这是合成数据,用来教因子模型的代码,不是对真实 A 股横截面收益动态的校准。

第二半:tick 工厂

切到微观结构。tick 工厂的输入是上一课的日度 GBM mid 路径;输出是一段在工作日交易时段内、按合成时间戳排列的 (timestamp, bid, ask, last, last_size)pd.date_range 在中国大陆时区下构造 tick 时间网格,日内 mid 价用 np.interp 线性插值。买卖价差按 basis point 给:half_spread = mid * half_spread_bp / 1e4,因此 bid = mid - half_spread,ask = mid + half_spread。每笔成交方向 sign 是 ±1 等概率(p=[0.5, 0.5])——50% 概率买盘抬升 ask,50% 概率卖盘打压 bid;成交量用 rng.lognormal(mean=np.log(mean_size), sigma=1.0) 抽,确保右偏分布。最后一根扣子是市场冲击:每笔交易按 delta_bp = impact_bp * sign * np.sqrt(last_size / np.median(last_size)) bp 永久推动后续 mid,符合实证上的平方根冲击规律。

def make_ticks(n_ticks: int, mid_price_path: np.ndarray, half_spread_bp: float, seed: int, mean_size: float = 100.0, impact_bp: float = 2.0) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    timestamps = pd.date_range(start="2024-01-02 09:30", periods=n_ticks, freq="30s", tz="Asia/Shanghai")
    t_grid = np.linspace(0, 1, n_ticks)
    day_grid = np.linspace(0, 1, len(mid_price_path))
    mid = np.interp(t_grid, day_grid, mid_price_path)
    sign = rng.choice([+1, -1], size=n_ticks, p=[0.5, 0.5])
    last_size = rng.lognormal(mean=np.log(mean_size), sigma=1.0, size=n_ticks)
    delta_bp = impact_bp * sign * np.sqrt(last_size / np.median(last_size))
    mid = mid * np.exp(np.cumsum(delta_bp) / 1e4)
    half_spread = mid * half_spread_bp / 1e4
    bid, ask = mid - half_spread, mid + half_spread
    last = np.where(sign == +1, ask, bid)
    return pd.DataFrame({"timestamp": timestamps, "bid": bid, "ask": ask, "last": last, "last_size": last_size})

买卖价差与冲击的核心公式可以并排写成 KaTeX 形式,方便课后复习:

half_spread=midhalf_spread_bp104,Δbp=impactbpsignsizemedian(size)\text{half\_spread} = \text{mid} \cdot \frac{\text{half\_spread\_bp}}{10^4}, \qquad \Delta_{bp} = \text{impact}_{bp} \cdot \text{sign} \cdot \sqrt{\frac{\text{size}}{\text{median(size)}}}

买卖价差换算:half_spread = mid * half_spread_bp / 1e4;bid = mid - half_spread,ask = mid + half_spread。冲击模型:delta_bp = impact_bp * sign * np.sqrt(last_size / np.median(last_size))(平方根冲击,永久;Almgren-Chriss 的严格推导与平方根经验规律的实证证据归 Subject 1.1)。完整的撮合引擎、订单簿队列位置动力学、Glosten-Milgrom / Kyle 的逆向选择模型、Almgren-Chriss 的最优执行——这些都不在本课范围;订单簿买卖价差市场冲击最小变动单位 是被引用的概念,但只有 bid-ask-spreadmarket-impact 在本课有真正的代码实现,其余在脚注里提一句,A 股最小变动单位 tick-size 是 RMB 0.01,小数位差一位都不合规。

cohort 的贝塔是 CAPM 斜率,从 N(1.0,0.3)N(1.0, 0.3) 抽出来再 clip 到 [0.3, 2.5],不允许出现负 beta(实务里逆贝塔 ETF 会有,但本课为合成数据教学不引这层例外)。这些因子加权的具体形式与正交化处理,见 3.2.3 L1 的回归章节。

Formula Explorer

\\Delta_{bp} = \\text{impact}_{bp} \\cdot \\text{sign} \\cdot \\sqrt{\\text{size} / \\text{median(size)}}

国内 A 股交易时段是 09:30-11:30 与 13:00-15:00,本课的 tick 网格用 pd.date_range 直出 30 秒间隔,午休缺口在合成数据上略去不处理;严肃的日内回测要按真实交易时段过滤,这归 3.2.2 L4 的交易日历章节。

Exercise

实现 `make_cohort(n_assets, seed, sectors=DEFAULT_HANGYE)` 与 `make_ticks(n_ticks, mid_price_path, half_spread_bp, seed, mean_size=100.0, impact_bp=2.0)`,按本课规范。然后 (1) 调用 `cohort = make_cohort(n_assets=200, seed=7)`,断言 cohort 有 200 行 7 列;(2) 用 L1 以 `seed=7` 构造 252 天的 GBM mid 路径,调用 `ticks = make_ticks(n_ticks=2520, mid_price_path=mid, half_spread_bp=5.0, seed=11)`,断言 `ticks["ask"] > ticks["bid"]` 对每一行成立;(3) 写 `pytest` 测试,断言两次 `make_cohort(n_assets=200, seed=7)` 调用产生逐字节相同的 `pd.DataFrame`(用 `pd.testing.assert_frame_equal`)。

提示
Hint 1: 因子分的相关化与 L1 完全一致——构造 (3, 3) 相关矩阵 R_factors,Cholesky 分解后用独立同分布扰动 Z_f @ L_f.T 即可。
提示
Hint 2: tick 工厂里,先用 np.interp(t_grid, day_grid, mid_path) 把日度 mid 插值到 tick 网格,再算 bid / ask / last 三列。

参考阅读:申万一级行业分类官方文件;Kenneth French 数据库(因子收益参考);《主动投资组合管理》(Grinold & Kahn 中译,第二版)第 7 章多因子模型;国内常用 pip 镜像(清华 / 阿里)安装 pyarrow 的实务提示。

一段实务校验:对照 A 股已知统计量

合成出来的 cohort 要做一次基本的健全性比对。把 make_cohort(200, seed=7) 跑出来后,看 market_cap 的对数中位数应该在 exp(11) ≈ 60000 附近,前 5% 大市值名单应该过 exp(13.5) ≈ 728000——折合到沪深300 名单,大致对应宁德时代、贵州茅台、招商银行的市值量级(单位 RMB 万元)。beta 列的均值应该贴近 1.0、标准差贴近 0.3,看 cohort['beta'].describe() 这一行能立刻验出来。sector 列做 value_counts(),十大申万一级行业的分布应该接近均匀(每类 20 家上下);若发现某行业占比远大于 1/10,要么是 rng.choice 抽样太少、要么是 sectors 参数传错。这套对账动作放在合成数据生成器之后、回归脚本之前,是把"合成数据看似可用、实际却带噪声偏差"这类隐性失败拦在 CI 入口处的标准做法。

tick 工厂的健全性比对走另一条路。ticks['ask'] - ticks['bid']half_spread_bp=5.0 下应该是 mid 的 0.1% 上下,折算到沪深300 ETF (300ETF) 一份 4.0 元的报价上,价差大约 4 厘——这正是 A 股一线 ETF 在 T+1 制度下白盘的真实价差水位。ticks['last_size'].quantile(0.99) / ticks['last_size'].median() 一般落在 8 到 25 之间;若超过 100,要么是 sigma=1.0 给得太大、要么是 mean_size 与样本配置不匹配。这两条对账规则与上证 50ETF、CFFEX 沪深300 期指的实务 tick 数据高度相似,而合成生成器在维持上述比例的同时让 seed 完全可复现,在 CI 与论文复现里都是直接收益。

下一课从合成数据切到真实公开 API:用 AKShare 抓 10 年期国债到期收益率,把超时、重试、限流、Pydantic 校验、Parquet 缓存五件套全套上,把研究 notebook 做到不会被 429 卡死。