程序员视角看量化交易:多因子选股模型背后的数据工程与回测系统

引言:量化交易的本质是数据工程

很多程序员对量化交易感兴趣,但一上来就看各种策略论文,忽略了最核心的问题:数据质量和数据工程才是量化系统的命脉。一个策略回测收益再好,如果底层数据有问题,实盘大概率翻车。

本文将从工程师视角,完整拆解一套多因子选股系统的数据工程架构,从数据采集到回测上线,每一步都给出可运行的代码示例。

系统总体架构


┌─────────────────────────────────────────────────────────┐
│                    量化交易系统架构                       │
│                                                         │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐            │
│  │ 数据采集  │──▶│ 数据清洗  │──▶│ 因子计算  │            │
│  │ 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
    └── ...

分区存储的好处:

  • **查询优化**:按日期查询时只扫描相关分区
  • **增量更新**:每天只需写入新分区,不影响历史数据
  • **压缩效率**:同类型数据聚集,zstd压缩比更高
  • 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),转载请注明出处。

    posted @ 2026-06-22 19:32  软件工程师文艺  阅读(3)  评论(0)    收藏  举报