程序员视角看量化交易:多因子选股模型背后的数据工程与回测系统
引言:量化交易的本质是数据工程
很多程序员对量化交易感兴趣,但一上来就看各种策略论文,忽略了最核心的问题:数据质量和数据工程才是量化系统的命脉。一个策略回测收益再好,如果底层数据有问题,实盘大概率翻车。
本文将从工程师视角,完整拆解一套多因子选股系统的数据工程架构,从数据采集到回测上线,每一步都给出可运行的代码示例。
系统总体架构
┌─────────────────────────────────────────────────────────┐
│ 量化交易系统架构 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 数据采集 │──▶│ 数据清洗 │──▶│ 因子计算 │ │
│ │ Pipeline │ │ Pipeline │ │ Engine │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ 数据存储层 │ │
│ │ Arrow/Parquet │ DuckDB │ TimescaleDB │ │
│ └──────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 回测引擎 │ │ 风险分析 │ │ 实盘交易 │ │
│ │Backtrader│ │ Engine │ │ Gateway │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ 调度与监控 (Airflow/Prefect) │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
数据采集:多源异构数据接入
行情数据采集
A股行情数据的主流免费数据源对比:
| 数据源 | 覆盖范围 | 更新频率 | 稳定性 | 限制 |
|--------|----------|----------|--------|------|
| Tushare Pro | 全市场 | T+1 | 较好 | 积分制,高频接口需高积分 |
| AKShare | 全市场 | T+0/T+1 | 一般 | 爬虫方式,反爬风险 |
| BaoStock | 全市场 | T+1 | 较好 | 数据延迟较大 |
| Wind/Choice | 全市场 | 实时 | 优秀 | 年费数万 |
# 基于 AKShare 的行情数据采集 Pipeline
import akshare as ak
import polars as pl
from pathlib import Path
from datetime import date, timedelta
import logging
logger = logging.getLogger(__name__)
class MarketDataCollector:
"""A股日频行情数据采集器"""
def __init__(self, storage_path: str = "./data/market"):
self.storage_path = Path(storage_path)
self.storage_path.mkdir(parents=True, exist_ok=True)
def fetch_daily_bars(self, trade_date: str) -> pl.DataFrame:
"""获取指定日期全市场日K线数据"""
try:
df = ak.stock_zh_a_spot_em()
# 转换为 Polars DataFrame 以获得更好的性能
result = pl.DataFrame({
"trade_date": pl.Series([trade_date] * len(df)),
"symbol": df["代码"].tolist(),
"name": df["名称"].tolist(),
"open": df["今开"].tolist(),
"high": df["最高"].tolist(),
"low": df["最低"].tolist(),
"close": df["最新价"].tolist(),
"volume": df["成交量"].tolist(),
"amount": df["成交额"].tolist(),
"turnover_rate": df["换手率"].tolist(),
"pe_ratio": df["市盈率-动态"].tolist(),
"pb_ratio": df["市净率"].tolist(),
"total_mv": df["总市值"].tolist(),
"circ_mv": df["流通市值"].tolist(),
})
return result
except Exception as e:
logger.error(f"Failed to fetch daily bars for {trade_date}: {e}")
raise
def save_parquet(self, df: pl.DataFrame, trade_date: str):
"""以Parquet格式分区存储"""
path = self.storage_path / f"date={trade_date}" / "daily_bars.parquet"
path.parent.mkdir(parents=True, exist_ok=True)
df.write_parquet(path, compression="zstd")
def run_daily_pipeline(self, start_date: str, end_date: str):
"""批量采集历史数据"""
dates = pl.date_range(
date.fromisoformat(start_date),
date.fromisoformat(end_date),
interval="1d",
eager=True
)
for d in dates:
date_str = d.strftime("%Y-%m-%d")
logger.info(f"Collecting data for {date_str}")
df = self.fetch_daily_bars(date_str)
self.save_parquet(df, date_str)
财务数据采集与对齐
财务数据的难点在于时间对齐——年报是次年4月30日前披露,不能简单用报告日期。
def align_financial_data(
market_df: pl.DataFrame,
financial_df: pl.DataFrame
) -> pl.DataFrame:
"""
财务数据时间对齐:
- Q1报告: 使用截至当年4月30日
- 半年报: 使用截至当年8月31日
- Q3报告: 使用截至当年10月31日
- 年报: 使用截至次年4月30日
"""
# 计算财报可用日期
available_date_map = {
1: lambda y: f"{y}-04-30", # Q1
2: lambda y: f"{y}-08-31", # H1
3: lambda y: f"{y}-10-31", # Q3
4: lambda y: f"{y+1}-04-30", # Annual
}
financial_df = financial_df.with_columns([
pl.when(pl.col("report_type") == 1)
.then(pl.lit(pl.col("report_year").cast(pl.Utf8) + "-04-30"))
.when(pl.col("report_type") == 2)
.then(pl.lit(pl.col("report_year").cast(pl.Utf8) + "-08-31"))
.when(pl.col("report_type") == 3)
.then(pl.lit(pl.col("report_year").cast(pl.Utf8) + "-10-31"))
.otherwise(pl.lit(
(pl.col("report_year") + 1).cast(pl.Utf8) + "-04-30"
))
.alias("available_date")
])
# As-of join: 取最近可用的财务数据
result = market_df.join_asof(
financial_df,
left_on="trade_date",
right_on="available_date",
by="symbol",
strategy="backward"
)
return result
数据清洗管道
常见问题与处理策略
class DataCleaningPipeline:
"""行情数据清洗管道"""
@staticmethod
def handle_suspended(df: pl.DataFrame) -> pl.DataFrame:
"""处理停牌股票:成交量为0的交易日标记"""
return df.with_columns(
pl.when(pl.col("volume") == 0)
.then(pl.lit(True))
.otherwise(pl.lit(False))
.alias("is_suspended")
)
@staticmethod
def handle_st_stocks(df: pl.DataFrame) -> pl.DataFrame:
"""标记ST/*ST股票"""
return df.with_columns(
pl.col("name")
.str.contains(r"ST|退市")
.alias("is_st")
)
@staticmethod
def handle_new_stocks(df: pl.DataFrame, list_days: int = 60) -> pl.DataFrame:
"""标记次新股(上市不足60个交易日)"""
# 计算每只股票的上市天数
days_listed = (
df.group_by("symbol")
.agg(pl.col("trade_date").min().alias("list_date"))
)
return df.join(days_listed, on="symbol").with_columns(
(pl.col("trade_date") - pl.col("list_date"))
.dt.days()
.lt(list_days)
.alias("is_new_stock")
)
@staticmethod
def winsorize(df: pl.DataFrame, cols: list, lower=0.01, upper=0.99) -> pl.DataFrame:
"""缩尾处理:去除极端值"""
for col in cols:
q_low = df.quantile(lower, on=col)[col][0]
q_high = df.quantile(upper, on=col)[col][0]
df = df.with_columns(
pl.col(col).clip(q_low, q_high).alias(col)
)
return df
@staticmethod
def neutralize(
df: pl.DataFrame,
factor_col: str,
industry_col: str = "industry",
size_col: str = "ln_market_cap"
) -> pl.DataFrame:
"""因子中性化:回归去除行业和市值影响"""
import statsmodels.api as sm
results = []
for date, group in df.group_by("trade_date"):
X = sm.add_constant(group[[industry_col, size_col]].to_pandas())
y = group[factor_col].to_pandas()
model = sm.OLS(y, X).fit()
residuals = model.resid
group_df = group.with_columns(
pl.Series(f"{factor_col}_neutral", residuals.values)
)
results.append(group_df)
return pl.concat(results)
def run(self, df: pl.DataFrame) -> pl.DataFrame:
"""执行完整清洗管道"""
df = self.handle_suspended(df)
df = self.handle_st_stocks(df)
df = self.handle_new_stocks(df)
# 过滤不可交易的股票
df = df.filter(
~pl.col("is_suspended") &
~pl.col("is_st") &
~pl.col("is_new_stock")
)
return df
因子计算引擎
经典因子分类
多因子模型的理论基础来自 Fama-French 三因子模型及其后续扩展。核心因子分类如下:
| 因子类别 | 代表因子 | 计算逻辑 | 学术依据 |
|----------|----------|----------|----------|
| 价值因子 | EP, BP, SP | 估值类指标的倒数 | Fama-French 1992 |
| 动量因子 | MOM_20D, MOM_60D | 过去N日收益率 | Jegadeesh & Titman 1993 |
| 规模因子 | LN_MV, LN_CIRCMV | 市值对数 | Fama-French 1992 |
| 质量因子 | ROE, ROA, GPM | 盈利能力指标 | Novy-Marx 2013 |
| 波动因子 | VOL_20D, BETA | 历史波动率/Beta | Ang et al. 2006 |
| 流动性因子 | TURNOVER, AMIHUD | 换手率/非流动性 | Amihud 2002 |
因子计算实现
class FactorEngine:
"""多因子计算引擎"""
def __init__(self, market_data: pl.DataFrame):
self.df = market_data.sort(["symbol", "trade_date"])
def calc_momentum(self, window: int = 20) -> pl.DataFrame:
"""动量因子:过去N日收益率(跳过最近5日避免反转)"""
return self.df.with_columns([
pl.col("close")
.shift(5)
.over("symbol")
.alias("close_5d_ago"),
pl.col("close")
.shift(window + 5)
.over("symbol")
.alias("close_n5d_ago"),
]).with_columns(
(pl.col("close_5d_ago") / pl.col("close_n5d_ago") - 1)
.alias(f"MOM_{window}D")
)
def calc_volatility(self, window: int = 20) -> pl.DataFrame:
"""波动率因子:过去N日收益率标准差"""
daily_returns = self.df.with_columns(
pl.col("close")
.pct_change()
.over("symbol")
.alias("daily_return")
)
return daily_returns.with_columns(
pl.col("daily_return")
.rolling_std(window_size=window)
.over("symbol")
.alias(f"VOL_{window}D")
)
def calc_reversal(self, window: int = 5) -> pl.DataFrame:
"""反转因子:短期反转效应"""
return self.df.with_columns(
pl.col("close")
.pct_change(n=window)
.over("symbol")
.alias(f"REV_{window}D")
)
def calc_turnover_avg(self, window: int = 20) -> pl.DataFrame:
"""流动性因子:平均换手率"""
return self.df.with_columns(
pl.col("turnover_rate")
.rolling_mean(window_size=window)
.over("symbol")
.alias(f"TURNOVER_AVG_{window}D")
)
def calc_value_factors(self) -> pl.DataFrame:
"""价值因子族"""
return self.df.with_columns([
(1.0 / pl.col("pe_ratio")).alias("EP"), # 盈利收益率
(1.0 / pl.col("pb_ratio")).alias("BP"), # 账面市值比
(pl.col("circ_mv") / pl.col("amount")).alias("ILLIQ"), # 非流动性
pl.col("total_mv").log().alias("LN_MV"), # 对数市值
])
def calc_all_factors(self) -> pl.DataFrame:
"""计算全部因子"""
df = self.calc_momentum(20)
df = FactorEngine(df).calc_momentum(60)
df = FactorEngine(df).calc_volatility(20)
df = FactorEngine(df).calc_reversal(5)
df = FactorEngine(df).calc_turnover_avg(20)
df = FactorEngine(df).calc_value_factors()
return df
因子评价指标
def factor_ic_analysis(factor_df: pl.DataFrame, factor_col: str,
forward_return_col: str = "fwd_ret_20d") -> dict:
"""因子IC分析"""
from scipy import stats
ic_series = []
for date, group in factor_df.group_by("trade_date"):
valid = group.drop_nulls([factor_col, forward_return_col])
if len(valid) < 30:
continue
ic, p_value = stats.spearmanr(
valid[factor_col].to_numpy(),
valid[forward_return_col].to_numpy()
)
ic_series.append({"date": date, "ic": ic, "p_value": p_value})
ic_df = pl.DataFrame(ic_series)
return {
"ic_mean": ic_df["ic"].mean(),
"ic_std": ic_df["ic"].std(),
"icir": ic_df["ic"].mean() / ic_df["ic"].std(), # IC的信息比率
"ic_positive_ratio": (ic_df["ic"] > 0).mean(),
}
回测系统设计
Backtrader 事件驱动回测
import backtrader as bt
import backtrader.indicators as btind
class MultiFactorStrategy(bt.Strategy):
"""多因子选股策略 - Backtrader实现"""
params = (
("rebalance_days", 20), # 调仓周期
("top_n", 30), # 持仓数量
("momentum_weight", 0.3), # 动量因子权重
("value_weight", 0.4), # 价值因子权重
("quality_weight", 0.3), # 质量因子权重
)
def __init__(self):
self.day_count = 0
self.stock_factors = {} # 预计算的因子数据
def next(self):
self.day_count += 1
if self.day_count % self.params.rebalance_days != 0:
return
# 获取当前可交易股票池
available_stocks = self.get_available_stocks()
# 计算综合因子得分
scores = {}
for stock in available_stocks:
score = (
self.params.momentum_weight * self.get_factor(stock, "momentum") +
self.params.value_weight * self.get_factor(stock, "value") +
self.params.quality_weight * self.get_factor(stock, "quality")
)
scores[stock] = score
# 选出Top N
selected = sorted(scores.items(), key=lambda x: x[1], reverse=True)
selected = [s[0] for s in selected[:self.params.top_n]]
# 调仓执行
self.rebalance(selected)
def rebalance(self, target_stocks):
"""等权调仓"""
# 卖出不在目标列表中的持仓
current_positions = {d for d in self.datas if self.getposition(d).size > 0}
for data in current_positions:
if data._name not in target_stocks:
self.close(data)
# 等权买入目标股票
n_to_buy = len(target_stocks)
if n_to_buy == 0:
return
cash_per_stock = self.broker.getcash() / n_to_buy * 0.95 # 留5%现金
for stock_name in target_stocks:
data = self.getdatabyname(stock_name)
if self.getposition(data).size == 0:
size = int(cash_per_stock / data.close[0] / 100) * 100
if size >= 100:
self.buy(data, size=size)
VectorBT 向量化回测(性能对比)
import vectorbt as vbt
import polars as pl
import numpy as np
class VectorizedBacktest:
"""向量化回测:适合大规模因子测试"""
def __init__(self, price_data: pl.DataFrame, factor_data: pl.DataFrame):
self.prices = price_data.pivot(
on="symbol", index="trade_date", values="close"
)
self.factors = factor_data
def run_factor_portfolio(self, factor_col: str, n_groups: int = 5):
"""
分组回测:按因子值分N组,计算各组收益
"""
returns = []
for date in self.prices["trade_date"]:
day_factors = self.factors.filter(pl.col("trade_date") == date)
day_factors = day_factors.sort(factor_col)
group_size = len(day_factors) // n_groups
for g in range(n_groups):
group = day_factors.slice(g * group_size, group_size)
symbols = group["symbol"].to_list()
# 计算等权组合收益
group_return = self.calc_group_return(symbols, date)
returns.append({
"date": date,
"group": g + 1,
"return": group_return
})
return pl.DataFrame(returns)
def performance_metrics(self, returns_series: np.ndarray) -> dict:
"""策略绩效指标"""
annual_factor = 252
total_return = (1 + returns_series).prod() - 1
annual_return = (1 + total_return) ** (annual_factor / len(returns_series)) - 1
annual_vol = returns_series.std() * np.sqrt(annual_factor)
sharpe = annual_return / annual_vol if annual_vol > 0 else 0
# 最大回撤
cum_returns = (1 + returns_series).cumprod()
peak = np.maximum.accumulate(cum_returns)
drawdown = (cum_returns - peak) / peak
max_drawdown = drawdown.min()
# Calmar比率
calmar = annual_return / abs(max_drawdown) if max_drawdown != 0 else 0
return {
"total_return": f"{total_return:.2%}",
"annual_return": f"{annual_return:.2%}",
"annual_volatility": f"{annual_vol:.2%}",
"sharpe_ratio": f"{sharpe:.3f}",
"max_drawdown": f"{max_drawdown:.2%}",
"calmar_ratio": f"{calmar:.3f}",
}
回测框架对比
| 维度 | Backtrader | VectorBT | Zipline | 自研框架 |
|------|-----------|----------|---------|----------|
| 回测模式 | 事件驱动 | 向量化 | 事件驱动 | 可定制 |
| 性能 | 中等 | 极快(100x) | 中等 | 可优化 |
| 灵活性 | 高 | 中 | 中 | 极高 |
| 学习曲线 | 中 | 低 | 高 | 高 |
| 实盘对接 | 支持 | 不支持 | 有限 | 完全可控 |
| 适用场景 | 策略验证 | 因子研究 | 完整系统 | 生产级系统 |
性能优化:Polars + Arrow
为什么选择 Polars
在量化数据工程中,Pandas 的性能瓶颈非常明显。Polars 基于 Apache Arrow 列式存储,多线程并行计算,性能提升通常在 10-100 倍。
# 性能对比:计算全市场20日动量因子
# 数据规模:5000只股票 × 3000个交易日 = 1500万行
# Pandas 方式 (~45秒)
import pandas as pd
df_pd = df.to_pandas()
df_pd["momentum_20d"] = df_pd.groupby("symbol")["close"].pct_change(20)
# Polars 方式 (~2秒)
df_pl = df.with_columns(
pl.col("close")
.pct_change(n=20)
.over("symbol")
.alias("momentum_20d")
)
存储优化
# 分区存储策略
data/
├── market/
│ ├── date=2025-01-02/
│ │ └── daily_bars.parquet (zstd压缩)
│ ├── date=2025-01-03/
│ │ └── daily_bars.parquet
│ └── ...
├── factors/
│ ├── date=2025-01-02/
│ │ └── factors.parquet
│ └── ...
└── financial/
├── report_year=2024/
│ └── annual_report.parquet
└── ...
分区存储的好处:
DuckDB 分析查询
import duckdb
# 直接使用 DuckDB 查询 Parquet 文件
conn = duckdb.connect()
# 因子IC时序分析
result = conn.execute("""
SELECT
trade_date,
CORR(momentum_20d, fwd_return_20d) as ic_momentum,
CORR(EP, fwd_return_20d) as ic_value,
CORR(VOL_20D, fwd_return_20d) as ic_volatility
FROM read_parquet('./data/factors/**/*.parquet', hive_partitioning=true)
WHERE trade_date >= '2024-01-01'
GROUP BY trade_date
ORDER BY trade_date
""").pl() # 直接输出为 Polars DataFrame
系统调度与监控
# Prefect 工作流编排
from prefect import flow, task
from prefect.schedule import CronSchedule
@task(retries=3, retry_delay_seconds=300)
def collect_market_data(date_str: str):
collector = MarketDataCollector()
df = collector.fetch_daily_bars(date_str)
collector.save_parquet(df, date_str)
return len(df)
@task
def run_cleaning_pipeline(date_str: str):
pipeline = DataCleaningPipeline()
df = load_raw_data(date_str)
cleaned = pipeline.run(df)
save_cleaned_data(cleaned, date_str)
@task
def calculate_factors(date_str: str):
df = load_cleaned_data(date_str)
engine = FactorEngine(df)
factors = engine.calc_all_factors()
save_factor_data(factors, date_str)
@flow(schedule=CronSchedule(cron="0 18 * * 1-5")) # 每个交易日18:00
def daily_quant_pipeline():
today = date.today().isoformat()
collect_market_data(today)
run_cleaning_pipeline(today)
calculate_factors(today)
实盘注意事项
从回测到实盘,需要注意的关键差异:
1. 滑点模型:回测假设的成交价与实际成交价存在偏差,小盘股尤其严重
2. 涨跌停限制:回测中买入涨停股在实盘中无法执行
3. 集合竞价:开盘前集合竞价的成交规则需要特殊处理
4. T+1限制:A股当日买入次日才能卖出
5. 最小交易单位:A股最少100股,小资金无法精确等权配置
建议在回测中加入保守的交易成本假设:双边千分之三(包含佣金、印花税、滑点),如果策略依然有效,再考虑实盘。
原文链接:https://wenyiblog.top/2026/06/quant-trading-data-engineering/
首发于文艺技术笔记(wenyiblog.top),转载请注明出处。

浙公网安备 33010602011771号