【Python量化多因子实战 06】从因子到持仓:多因子选股实盘工程化落地

六篇走到最后一篇。前面我们解决了因子研究的所有"实验室问题":怎么构造因子(#01)、怎么检验有效性(#02)、怎么合成(#03)、怎么剥离风格暴露(#04)、怎么应对因子轮动(#05)。

但研究和实盘之间还隔着一条鸿沟:每天早上,你的程序要自动完成"拉数据 → 算因子 → 检验 → 合成 → 输出持仓"这五步,并且每一步都不能想当然------数据可能滞后、因子可能失效、单票可能超限。

今天把这条流水线一次搭完。它不追求复杂,但每一环都是实盘必需的。


本文你将得到什么

  1. 一条四步流水线的完整代码:数据接入 → 因子构造 → 滚动 IC 检验与加权 → 持仓输出。
  2. 实盘必需的四个工程细节:数据新鲜度检查、滞后标的剔除、负 IC 因子自动降权、单票权重上限。
  3. 一份真实运行输出的持仓清单,看清 IC 加权如何让组合"自动切换风格"。
  4. 本系列六篇的知识总装:每一步都能对应到前五篇的结论。

流水线设计

text 复制代码
S1 数据接入    拉行情 + 股本 + 每股指标,检查新鲜度与滞后
S2 因子构造    小市值 / 动量 / 低波动 / 价值BP,截面 z-score 标准化
S3 历史检验    过去半年滚动 IC;IC 加权,负 IC 因子权重归零
S4 持仓输出    综合打分 Top-10,单票上限 15%,输出权重清单 + 运行报告

设计要点只有三条:

  1. 因子方向统一为"值大则好":小市值取负、低波动取负,这样 Top 组永远是"因子认为最好"的组。
  2. 权重跟着 IC 走 :weight = max(mean_IC, 0),失效因子自动出局------这正是 #05 的结论:让数据决定信谁,而不是死磕某个因子。
  3. 每一步留痕:接入多少、剔了谁、IC 多少、为什么配这个权重,全部写进运行报告,事后可审计。

可运行代码

bash 复制代码
pip install mairui pandas numpy
python 复制代码
from mairui import Client
import pandas as pd
import numpy as np
from pathlib import Path

LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"  # 演示证书,请换成你自己的正式证书
client = Client(LICENCE)

POOL = [
    "600519", "000001", "000002", "000333", "000568", "000651", "000725", "000858",
    "002001", "002007", "002142", "002230", "002304", "002415", "002594", "002714",
    "300003", "300014", "300015", "300033", "300059", "300122", "300124", "300274",
    "300750", "601318", "601398", "601888", "603288", "603501",
]
ST, ET = "20260401", "20261009"
TOP_N, MAX_WEIGHT, FWD = 10, 0.15, 5

report = []
def log(msg=""):
    print(msg)
    report.append(str(msg))

# ---------- S1 数据接入 ----------
def fetch_history(code):
    try:
        bars = client.stock_history(code, "d", "n", st=ST, et=ET)
        if bars:
            df = pd.DataFrame(bars).rename(columns={"t": "date", "c": "close"})
            df["date"] = pd.to_datetime(df["date"])
            return df.sort_values("date").reset_index(drop=True)
    except Exception as e:
        log(f"  [警告] {code} 行情受限: {e}")
    return None

def fetch_capital(code):
    try:
        cap = client.stock_capital(code=code)
        if cap:
            return sorted(cap, key=lambda x: x.get("bdrq", ""), reverse=True)[0]
    except Exception:
        pass
    return None

def fetch_pershare(code):
    try:
        ps = client.stock_financial(code=code, table="pershareindex")
        if ps:
            return sorted(ps, key=lambda x: x.get("jzrq", ""), reverse=True)[0]
    except Exception:
        pass
    return None

hist_map, cap_map, ps_map = {}, {}, {}
for code in POOL:
    h = fetch_history(code)
    if h is not None:
        hist_map[code] = h
    c = fetch_capital(code)
    if c:
        cap_map[code] = c.get("zgb")
    p = fetch_pershare(code)
    if p:
        ps_map[code] = p.get("mgjzc")

snap_date = max(df["date"].max() for df in hist_map.values())
stale = [c for c, df in hist_map.items() if df["date"].max() < snap_date]
log(f"S1 接入: 行情 {len(hist_map)}/30,截面 {snap_date.date()},滞后剔除: {stale}")

# ---------- S2 因子构造(截面快照) ----------
rows = []
for code, df in hist_map.items():
    if df["date"].max() != snap_date or len(df) < 21:
        continue
    latest, past = df.iloc[-1], df.iloc[-21]
    shares, bvps = cap_map.get(code), ps_map.get(code)
    if not shares or latest["close"] <= 0:
        continue
    rows.append({
        "code": code, "close": latest["close"],
        "log_mcap": float(np.log(latest["close"] * shares)),
        "momentum": (latest["close"] / past["close"] - 1) * 100,
        "vol20": df["close"].pct_change().tail(20).std() * 100,
        "bp": (bvps / latest["close"]) * 100 if bvps else np.nan,
    })
snap = pd.DataFrame(rows).set_index("code")
log(f"S2 截面样本: {len(snap)} 只")

def zscore(s):
    s = pd.to_numeric(s, errors="coerce")
    return (s - s.mean()) / s.std()

snap["z_size"] = -zscore(snap["log_mcap"])      # 小市值更好
snap["z_mom"] = zscore(snap["momentum"])
snap["z_lowvol"] = -zscore(snap["vol20"])        # 低波动更好
snap["z_bp"] = zscore(snap["bp"])

# ---------- S3 滚动 IC 检验与加权 ----------
panel = []
for code, df in hist_map.items():
    shares = cap_map.get(code)
    if not shares:
        continue
    for i in range(20, len(df) - FWD):
        row, past, fut = df.iloc[i], df.iloc[i - 20], df.iloc[i + FWD]
        panel.append({
            "code": code, "date": row["date"].strftime("%Y-%m-%d"),
            "f_size": -np.log(row["close"] * shares),
            "momentum": (row["close"] / past["close"] - 1) * 100,
            "f_lowvol": -df["close"].pct_change().iloc[i - 19:i + 1].std() * 100,
            "bp": (ps_map.get(code, np.nan) / row["close"]) * 100,
            "fwd_return": (fut["close"] / row["close"] - 1) * 100,
        })
panel = pd.DataFrame(panel)

def day_ic(g, col):
    return g[col].corr(g["fwd_return"], method="spearman") if len(g) >= 5 else np.nan

ic_mean = {}
for name, col in [("小市值", "f_size"), ("动量", "momentum"),
                  ("低波动", "f_lowvol"), ("价值BP", "bp")]:
    ic = panel.groupby("date").apply(day_ic, col, include_groups=False).dropna()
    ic_mean[name] = ic.mean()
    log(f"S3 {name}: mean_IC={ic.mean():+.4f}")

weights = {k: max(v, 0.0) for k, v in ic_mean.items()}
total = sum(weights.values())
weights = {k: v / total for k, v in weights.items()} if total > 0 else {k: 0.25 for k in weights}
log(f"S3 IC加权: { {k: round(v, 3) for k, v in weights.items()} }")

# ---------- S4 合成与持仓输出 ----------
zcols = {"小市值": "z_size", "动量": "z_mom", "低波动": "z_lowvol", "价值BP": "z_bp"}
snap["composite"] = sum(weights[k] * snap[c] for k, c in zcols.items())
snap = snap.dropna(subset=["composite"]).sort_values("composite", ascending=False)

top = snap.head(TOP_N).copy()
top["weight"] = (1.0 / TOP_N).clip(upper=MAX_WEIGHT)
top["weight"] = (top["weight"] / top["weight"].sum() * 100).round(2)
log("S4 持仓 Top-10:")
log(top[["close", "composite", "weight"]].round(3).to_string())

top.to_csv("holdings.csv", encoding="utf-8-sig")
Path("pipeline_report.txt").write_text("\n".join(report), encoding="utf-8")
log("产物已保存: holdings.csv + pipeline_report.txt(仅为因子打分结果,不构成投资建议)")

运行后你会看到类似结果:

text 复制代码
S1 接入: 行情 30/30,截面 2026-10-09,滞后剔除: ['002230', '300124']
S2 截面样本: 28 只
S3 小市值: mean_IC=-0.0263
S3 动量:   mean_IC=-0.0750
S3 低波动: mean_IC=+0.1054
S3 价值BP: mean_IC=+0.1135
S3 IC加权: {'小市值': 0.0, '动量': 0.0, '低波动': 0.482, '价值BP': 0.518}
S4 持仓 Top-10:
        close  composite  weight
000001  11.59      1.772    10.0
601398   8.22      1.231    10.0
601318  52.83      0.762    10.0
002304  37.28      0.659    10.0
002142  35.24      0.618    10.0
000651  38.83      0.547    10.0
300003  11.68      0.349    10.0
000858  70.56      0.291    10.0
601888  50.88      0.260    10.0
300015   7.90      0.096    10.0

(示例基于 2026 年真实行情与财务指标、30 只样本股测算;演示证书返回就近样本,正式证书可拉取全市场。)


怎么读这份输出

  1. 流水线自己完成了"风格切换":过去半年小市值与动量的 IC 为负,IC 加权自动把它们的权重归零,组合自适应变成"低波动 + 价值"双因子------这正是 #05 说的"让数据决定信谁",不是嘴上说说,而是写进了权重公式。
  2. 滞后剔除是实盘刚需:002230、300124 两只当日行情滞后,直接剔除而不是硬算------脏数据进截面,比少一只样本严重得多。
  3. 持仓清单只是起点:Top-10 权重目前是等权 10%(上限 15% 未触发),后续可以接入 #02 的分层检验做定期复核、#05 的因子动量做空仓开关,这套框架是开放的。

三个容易踩的坑

  1. 快照对齐:只允许"截面日期当天有行情"的股票进入快照,滞后标的一律剔除,不要用旧价格冒充新快照。
  2. IC 加权用滚动窗口:实盘应每天用"过去 N 日"的 IC 重算权重(本文用过去半年全样本,教学从简),绝不能用未来数据。
  3. 权重归一再设上限:单票上限要在归一之后检查,否则一次 clip 可能让总权重不足 100%。

小结与系列回顾

本系列六篇走完了一条完整的多因子研究路径:

篇 主题 关键产出
01 三因子构造 市值 / 动量 / BP 的截面打分框架
02 有效性检验 IC / IR / 五分组分层回测
03 因子合成 等权 vs IC 加权 vs 回归加权实测
04 中性化 剥离市值与行业暴露,看清真 alpha
05 因子轮动 因子动量/反转择时,空仓即风控
06 工程化落地 四步流水线,每日可跑,全程留痕

把 #06 的流水线配上正式证书的定时任务,就是一个每日自动运行的多因子选股系统雏形。数据接入这件事,稳定的行情 API 比自建数据库省心得多------字段统一、历史完整、当日可取,把精力留给真正的研究。


免责声明

本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益,持仓清单为因子打分的排序结果而非荐股。投资有风险,决策需谨慎。

代码与文档可参考 GitHub:github.com/MaiRuiApi

相关推荐
贾公子33 分钟前
检测链路的容量规划——用 Little's Law 算清 QPS、并发与积压
后端·python
kyson_38 分钟前
Java 集合框架:从继承体系到数据结构,再到开发选型
后端
想不到一个好的ID1 小时前
什么是RAG
后端
金銀銅鐵1 小时前
JDK 21 中 List.of 的实现细节:从 List12 到 ListN
java·后端·ai编程
苏三说技术2 小时前
Codex中必装的11个Skills
后端
付威20233 小时前
target 目录越来越大,Rust 项目该怎么清理
后端
技录局3 小时前
Kafka 深度原理分析:从消息流到分布式日志
后端
大数据技术派3 小时前
我用AI写代码,做了3个小玩意儿
后端·微信小程序
爱上咖啡香4 小时前
我给 Laravel AI 写了个千问驱动,文本、语音、图像、视频全打通
后端