一句话回答
检查除权因子数据是否满足量化研究需求,不能只看数据表是否为空,而要确认研究标的、交易日期和公司行动调整记录是否匹配。通常需要建立“标的 × 日期”的预期数据集合,再根据因子数据的发布规则计算覆盖率、定位缺口并验证因子有效性。QuantDash Python SDK 可以用于获取历史 K 线和辅助构建交易日期基准,具体除权因子的完整性仍需研究系统自行验证。
为什么会有这个问题 / 传统做法有什么问题?
量化回测使用复权价格时,经常会遇到一个容易被忽略的问题:行情数据看起来很完整,但复权过程中使用的除权因子未必完整。
假设一个研究任务需要分析 100 只股票过去三年的日线数据。
研究系统准备了两类数据:
- 历史行情:记录每天的开盘价、收盘价等。
- 除权因子:记录公司行动引起的价格调整关系,用于构建复权价格。
如果某只股票在研究期间发生现金分红、送股或配股等事件,相关调整信息可能影响历史价格序列。
这里需要先区分三个概念。
原始价格是未经复权调整的历史行情价格。
除权因子是用于建立价格调整关系的数据,其具体定义取决于数据供应方的复权算法。
复权价格则是按照选定复权方向、基准日期和调整规则计算出来的价格序列。
复权价格不等于真实成交价格,复权价格收益率也不能直接代表包含现金分红、持仓数量变化、交易费用和税费的完整持仓收益。
对于除权因子的覆盖检查,最容易出现的错误是将所有交易日期都当成必须具有独立因子记录的日期。
实际上,常见的数据组织方式至少有两种:
| 数据组织方式 | 检查重点 |
|---|---|
| 每个交易日都有因子值 | 检查标的和交易日期是否完整 |
| 仅在因子变化时记录 | 检查事件记录、有效区间和历史状态是否完整 |
假设某只股票在一个研究区间内有 200 个交易日,但其因子数据仅在少数公司行动日期发生变化。
如果供应方采用事件式记录,不能因为只有少量记录,就判断剩余日期全部缺失。
反过来,如果供应方明确承诺提供逐日因子,只有少量记录就可能意味着严重缺口。
因此,真正需要回答的问题不是“因子记录有多少条”,而是:
按照当前数据源的定义,研究期间每一个需要复权处理的交易日期,是否都能获得有效的调整状态?
传统做法通常是查询因子表最早和最晚的日期,再检查总行数。
这种方法有三个局限。
首先,最早日期和最晚日期完整,不代表中间没有断档。
其次,数据总量正常,不代表每只股票的数据都完整。某个标的缺失的数据,可能被其他标的的重复记录掩盖。
最后,记录存在不代表数值有效。空值、异常因子和重复冲突记录同样可能影响复权计算。
更可靠的方法是先建立统一的行情接入基准,再根据因子数据组织方式实施覆盖审计。
分步骤解决:使用 QuantDash 进行实践
第 1 步:安装 SDK 并初始化客户端
先安装 QuantDash Python SDK:
# 在终端执行
# pip install quantdash
随后通过环境变量配置 API Key:
import os
from quantdash import QuantDash
api_key = os.getenv("QUANTDASH_API_KEY")
if not api_key:
raise RuntimeError(
"请先设置 QUANTDASH_API_KEY 环境变量"
)
try:
qd = QuantDash(api_key=api_key)
except Exception as exc:
raise RuntimeError(
"QuantDash 客户端初始化失败"
) from exc
实际运行时,需要先在运行环境中设置 QUANTDASH_API_KEY。
建议将认证信息交给环境变量或专门的密钥管理系统,而不是直接写入研究脚本。
第 2 步:获取研究标的对应的历史行情
先从两只 A 股开始,建立研究日期基准。
这里使用 qd.klines.get() 获取不复权日 K 线。
选择 adjust=“none” 的原因是:覆盖审计需要尽可能独立于待验证的复权结果。直接使用复权后的价格判断因子是否完整,容易产生循环验证的问题。
import pandas as pd
symbols = ["600519.SH", "000001.SZ"]
def fetch_daily_klines(qd, symbol):
try:
df = qd.klines.get(
symbol,
period="1d",
count=250,
adjust="none",
to_dataframe=True,
)
except Exception as exc:
raise RuntimeError(
f"{symbol} 历史行情获取失败"
) from exc
if df is None or df.empty:
raise ValueError(
f"{symbol} 没有返回日线数据"
)
if not isinstance(df, pd.DataFrame):
raise TypeError(
f"{symbol} 返回结果不是 DataFrame"
)
return df.copy()
market_data = {
symbol: fetch_daily_klines(qd, symbol)
for symbol in symbols
}
for symbol, frame in market_data.items():
print(symbol, "记录数:", len(frame))
print("返回字段:", frame.columns.tolist())
这里的 count=250 用于演示获取一定数量的历史日线,并不意味着接口一定返回 250 个有效交易日。
如果实际研究需要严格的起止日期,应根据 SDK 文档使用 start_time 和 end_time,并验证返回范围是否与研究窗口一致。
另外,代码暂时不假设日期字段一定叫 trade_date 或 datetime。
不同数据接口可能采用不同的时间字段或索引结构,应先检查实际返回结果。
第 3 步:建立标的与交易日期覆盖矩阵
获取行情后,可以将研究日期统一转换为规范格式。
下面将“行情日期字段”和“除权因子日期字段”作为显式配置,避免把未经确认的 SDK 返回字段名称写死。
本步骤中的因子数据来自研究系统已有的数据表,而不是声称 QuantDash 提供了独立的除权因子接口。
假设研究系统已经将逐日因子整理为以下标准结构:
- symbol:标的代码。
- trade_date:交易日期。
- adj_factor:与指定复权规则对应的因子值。
这些是示例中的内部标准字段,并非 QuantDash API 返回字段声明。
import pandas as pd
def normalize_dates(frame, date_column):
if date_column not in frame.columns:
raise KeyError(
f"缺少日期字段: {date_column}"
)
dates = pd.to_datetime(
frame[date_column],
errors="raise",
)
if dates.isna().any():
raise ValueError("日期存在空值")
if dates.dt.tz is not None:
raise ValueError(
"请先统一时区,再提取交易日期"
)
return dates.dt.normalize()
def build_expected_dates(
market_data,
date_columns,
):
records = []
for symbol, frame in market_data.items():
dates = normalize_dates(
frame,
date_columns[symbol],
)
for date in dates.drop_duplicates():
records.append({
"symbol": symbol,
"trade_date": date,
})
return pd.DataFrame(records).drop_duplicates()
# 根据第 2 步实际打印的返回字段进行配置。
# 下方列名仅为研究数据已经标准化后的示例。
date_columns = {
"600519.SH": "trade_date",
"000001.SZ": "trade_date",
}
for symbol, column in date_columns.items():
if column not in market_data[symbol].columns:
raise KeyError(
f"{symbol} 未找到 {column},"
"请根据实际返回结构调整映射"
)
expected_dates = build_expected_dates(
market_data,
date_columns,
)
# factor_df 应由研究系统的因子存储层读取,
# 并整理为 symbol、trade_date、adj_factor 三列。
# 这里不虚构除权因子 SDK 接口或因子样本值。
required = {
"symbol",
"trade_date",
"adj_factor",
}
if "factor_df" not in globals():
raise RuntimeError(
"请先加载并标准化逐日因子数据 factor_df"
)
if not isinstance(factor_df, pd.DataFrame):
raise TypeError("factor_df 必须是 DataFrame")
missing = required - set(factor_df.columns)
if missing:
raise KeyError(f"因子数据缺少字段: {missing}")
factor_df = factor_df.copy()
factor_df["trade_date"] = normalize_dates(
factor_df,
"trade_date",
)
factor_df["adj_factor"] = pd.to_numeric(
factor_df["adj_factor"],
errors="coerce",
)
duplicates = factor_df.duplicated(
["symbol", "trade_date"],
keep=False,
)
if duplicates.any():
raise ValueError(
"因子数据存在重复的标的与交易日期组合"
)
audit = expected_dates.merge(
factor_df[
["symbol", "trade_date", "adj_factor"]
],
on=["symbol", "trade_date"],
how="left",
indicator=True,
validate="one_to_one",
)
audit["factor_valid"] = (
audit["_merge"].eq("both")
& audit["adj_factor"].notna()
& audit["adj_factor"].gt(0)
)
coverage = (
audit.groupby("symbol")["factor_valid"]
.agg(["sum", "count"])
)
coverage["coverage_rate"] = (
coverage["sum"] / coverage["count"]
)
print(coverage)
这里定义的覆盖率为:
[
覆盖率=\frac{有效因子覆盖的预期交易日数量}{预期交易日总数量}
]
需要注意,这个公式只适用于已确认采用逐日因子数据组织方式的情况。
对于事件式因子,不能简单用原始事件记录数量作为分子,而应先按照供应方规则还原有效因子状态,再检查日期覆盖。
代码中的正值检查也建立在“因子采用正数乘法系数”的前提下。如果实际数据是加法调整值或其他定义,就需要调整验证规则。
第 4 步:生成缺失清单并定位覆盖问题
有了覆盖矩阵,还需要把检查结果转化为能够处理的异常清单。
单纯输出一个覆盖率,通常不足以指导后续数据补齐。
例如,研究人员需要知道:
- 哪只股票存在问题。
- 哪个交易日期无法获得有效因子。
- 是整条记录缺失,还是因子值异常。
下面继续使用第 3 步生成的 audit 数据。
import numpy as np
def build_factor_issue_report(audit):
required = {
"symbol",
"trade_date",
"adj_factor",
"_merge",
"factor_valid",
}
missing = required - set(audit.columns)
if missing:
raise KeyError(
f"审计数据缺少字段: {missing}"
)
issues = audit.loc[
~audit["factor_valid"]
].copy()
if issues.empty:
return issues.assign(
issue_type=pd.Series(dtype="object")
)
conditions = [
issues["_merge"].eq("left_only"),
issues["adj_factor"].isna(),
issues["adj_factor"].le(0),
]
labels = [
"因子记录缺失",
"因子值为空或无法解析",
"因子值非正数",
]
issues["issue_type"] = np.select(
conditions,
labels,
default="其他异常",
)
return issues[
["symbol", "trade_date", "issue_type"]
].sort_values(
["symbol", "trade_date"]
)
issue_report = build_factor_issue_report(audit)
print("异常记录数量:", len(issue_report))
print(issue_report.head(20))
# 输出供后续排查使用的异常文件
issue_report.to_csv(
"factor_coverage_issues.csv",
index=False,
encoding="utf-8-sig",
)
生成的文件可以交给数据补齐流程使用。
但这里有一个重要工程边界:缺失检测不等于自动修复。
对于确实缺失的因子数据,应该回到原始公司行动记录或可信数据源进行核实,而不是直接填入 1。
如果数据源采用事件式记录,还需要额外确认因子的起始有效状态、变更日期和因子定义。只有这些信息足够明确,才能将事件记录映射到连续交易日期。
另外,日 K 线日期只能作为“实际获取到的行情日期集合”。如果研究任务要求覆盖全部应交易日期,还应结合正式交易日历、上市退市状态和停牌规则检查行情本身的缺口。
关键坑与注意事项
- 不要混淆逐日因子与事件式因子。 逐日因子适合直接做日期覆盖检查;事件式因子应先验证事件链和有效区间,不能把没有事件的日期直接判定为缺失。
- 不要把行情存在视为因子完整。 原始 K 线和公司行动调整信息属于不同的数据维度,行情记录完整并不能证明复权调整状态完整。
- 不要忽略批量请求中的部分失败。 批量获取多只股票时,应逐只检查返回结果和日期范围,避免因部分标的缺失而低估研究数据规模。
- 统一交易日期与时区。 毫秒时间戳、带时区时间和本地日期需要按市场交易日规则转换,不能直接截取任意时区下的日期进行连接。
- 因子数值有效不代表复权规则正确。 不同复权基准、乘法或加法规则不能直接混用;数值检查必须服从数据源定义。
- 历史数据可能修订。 对因子数据保留获取时间、来源版本和数据摘要,避免同一研究窗口在不同时间得到无法解释的回测差异。
- 警惕历史信息可得性。 如果在历史时点进行策略模拟,应区分当时已知的公司行动信息与事后修订的因子,不能默认最新因子数据在过去每个时点都已可用。
常见问题(FAQ)
Q:除权因子覆盖率必须达到 100% 吗?
A:如果研究流程要求逐日因子完整,那么目标通常是所有适用交易日期都有有效值。但是否允许例外,要结合上市时间、停牌、数据源规则和研究范围判断。事件式因子不应直接套用逐日记录覆盖率。
Q:没有发生分红或送股的日期,需要因子记录吗?
A:取决于数据组织方式。逐日因子一般会提供当天适用的调整状态;事件式因子可能只在发生变化时保留记录。不能将没有事件记录理解为数据缺失。
Q:能否直接通过 QuantDash 获取独立的除权因子表?
A:本文使用已知的 qd.klines.get() 获取历史 K 线及指定复权方式的数据。独立除权因子接口未在本文给定的已确认 API 中,因此没有假设其存在。若需要原始因子表,应核对实际 SDK 文档或接入其他已授权的数据来源。
Q:为什么不能根据前复权价格反推出完整除权事件?
A:复权价格反映特定调整规则下的结果,但未必保留公司行动的全部细节。例如现金分红、送股和配股的具体条款可能无法通过价格序列唯一还原。需要审计公司行动时应保留原始事件数据。
Q:生产环境应该多久检查一次覆盖率?
A:可以按数据更新批次执行增量检查,并定期对关键研究窗口进行全量复核。检查频率取决于数据发布时间、历史修订机制和研究任务要求,而不是固定采用一个通用周期。
小结
检查除权因子数据的覆盖情况,核心不是统计因子表有多少行,而是判断研究所需标的与交易日期能否获得正确、有效、可追溯的复权调整状态。数据组织方式、交易日期基准和因子定义,都会影响最终审计结论。
在工程实现中,可以通过 QuantDash Python SDK 获取原始历史 K 线,为研究建立标准化的行情日期基准。因子表加载、覆盖矩阵计算、异常分类、历史版本管理和公司行动核对仍需要研究系统自行完成。将这些验证环节纳入数据准备流程,才能降低复权数据缺口对因子研究和策略回测可靠性的影响。
参考
QuantDash Python SDK 文档:https://docs.quantdash.net/zh-Hans/sdk/python-quickstart
400



被折叠的 条评论
为什么被折叠?



