量化回测前的数据审计:如何判断股票历史行情是否缺失、重复或存在偏差

一句话结论:回测数据审计的重点不是确认文件已经下载,而是证明数据覆盖范围符合策略需求、时间顺序正确、价格口径一致,并且没有会改变历史交易决策的缺陷。

摘要

历史行情数据存在缺口、重复记录或复权口径不一致时,量化回测可能产生错误的收益率、技术指标和交易信号。单纯检查 CSV 行数或 API 请求状态,无法判断这些问题是否存在。本文从回测数据审计出发,建立预期数据集合、实际数据集合和策略可用数据集合之间的关系,并介绍如何检查时间覆盖、重复 K 线、复权一致性及未来信息泄漏。QuantDash 提供的多市场行情、K 线和时间区间查询能力,可以用于构建历史数据获取环节,但策略是否具备可靠的数据基础,仍需要研究者独立验证。

1. 回测为什么需要数据审计?

量化回测回答的是一个有条件的问题:

如果策略在过去按照预先定义的规则运行,使用当时可获得的数据,会产生怎样的结果?

这意味着回测质量取决于策略逻辑,也取决于输入数据。

如果历史行情存在缺口,策略可能错过某个本应产生信号的交易日。如果同一根 K 线重复出现,指标计算可能把某个价格重复计入。如果历史价格在不同阶段使用了不同的复权口径,收益率计算也可能出现偏差。

这些问题会沿着数据链路传递:

text 复制代码
历史行情数据
    ↓
数据清洗与时间对齐
    ↓
技术指标计算
    ↓
交易信号生成
    ↓
订单模拟
    ↓
回测收益与风险指标

因此,回测之前应先回答一个问题:输入的数据是否符合这次研究的假设?

这里的关键不是追求所有数据在任何场景下都没有缺失,而是识别哪些缺失会影响策略,并确保数据处理规则不会无意中改变研究结论。

2. 建立预期数据集合,而不是只看文件大小

数据审计最容易出现的错误,是把实际记录数当作完整性的唯一证据。

假设某个策略使用一只股票的日线数据进行研究。数据文件包含大量记录,看起来没有明显问题,但仍可能存在:

  • 区间开头的数据缺失。
  • 某段时间出现连续空档。
  • 某个交易日有重复记录。
  • 某些记录的日期超出研究区间。
  • 股票上市之前出现了不应存在的记录。
  • 由于停牌或市场日历问题,实际记录数与预期值不一致。

更严谨的做法是先定义预期数据集合。

对于单只股票的日线研究,预期数据集合可以由以下条件确定:

  1. 研究起始日期和结束日期。
  2. 对应市场的交易日历。
  3. 股票实际上市和交易状态。
  4. 数据接口的返回口径。
  5. 策略是否要求每个适用交易日都有记录。

然后,将实际获取的记录与预期集合进行比较。

python 复制代码
import pandas as pd

def audit_date_coverage(
    actual_dates,
    expected_dates,
):
    actual = pd.DatetimeIndex(
        pd.to_datetime(actual_dates)
    ).normalize().unique()

    expected = pd.DatetimeIndex(
        pd.to_datetime(expected_dates)
    ).normalize().unique()

    missing = expected.difference(actual)
    unexpected = actual.difference(expected)

    return {
        "expected_count": len(expected),
        "actual_unique_count": len(actual),
        "missing_dates": missing.sort_values(),
        "unexpected_dates": unexpected.sort_values(),
    }

这里的 expected_dates 不是从实际数据反推得到的日期,而是根据研究区间和交易规则独立生成的预期日期。

否则,如果某个交易日缺失,而程序又使用实际数据生成"预期日期",缺失就可能被掩盖。

2.1 缺失记录不一定意味着数据源故障

需要区分以下情况:

现象 可能原因 建议处理
某个自然日没有 K 线 周末或节假日 使用市场交易日历判断
某只股票在某日没有行情 停牌或其他交易状态 核对证券状态与接口口径
区间中间连续缺少多个交易日 获取失败或数据缺失 进一步核查来源与请求记录
最近日期没有数据 交易尚未结束或数据尚未更新 按任务要求判断是否异常
多只股票同一时段缺失 共同的数据获取或处理问题 检查批量任务和数据管道

这张表用于指导排查,而不是根据单一现象直接判定原因。

3. 重复数据为什么会改变回测结果?

重复数据不仅影响存储空间,还可能改变指标的计算结果。

例如,计算移动平均线时,如果某根 K 线被重复计入,价格窗口的组成就发生了变化。

对于使用时间索引的回测框架,重复时间戳还可能造成:

  • 某个时间点出现多个信号。
  • 交易逻辑重复执行。
  • 订单模拟顺序不符合预期。
  • 时间对齐操作产生不确定结果。

因此,应在进入指标计算之前检查数据主键。

python 复制代码
def audit_duplicate_bars(
    df: pd.DataFrame,
    key_columns: list[str],
) -> pd.DataFrame:
    duplicates = df.loc[
        df.duplicated(
            subset=key_columns,
            keep=False,
        )
    ]

    return duplicates.sort_values(
        key_columns
    )

对于单标的日线数据,主键通常可以围绕交易日期建立。

对于多标的数据集,应考虑标的代码和交易日期。对于分钟线,则需要将时间戳纳入主键。

但发现重复记录后,不应立即保留第一条并删除其他记录。

首先需要判断重复来自哪里:

  • 同一批数据被重复写入。
  • 多个数据文件被重复合并。
  • 分页或分批查询发生重叠。
  • 上游返回了真正重复的记录。
  • 数据时间字段精度不足,导致不同记录被映射到同一时间。

只有确认重复的成因后,才能决定去重策略。

4. 复权口径不一致,是容易被忽略的数据问题

股票历史价格受到分红、送转股等公司行为影响。

复权处理是根据相应的调整规则对历史价格序列进行调整,使价格序列更适合特定的分析目的。

不同复权方式可能产生不同的历史价格数值。因此,如果数据管道的一部分使用前复权数据,另一部分使用不复权数据,就可能导致技术指标和收益率计算口径不一致。

审计时应明确记录:

  • 价格采用前复权、后复权还是不复权。
  • 复权方式是否在整个研究区间内保持一致。
  • 公司行为调整是否影响当前计算。
  • 数据源切换前后是否采用相同的口径。
  • 回测中的成交价格是否与实际交易模拟假设一致。

需要特别注意,复权价格适合特定的历史分析,但不应不加区分地视为当时实际可以成交的市场价格。

此外,某些回测场景需要使用当时可获得的信息。若研究过程使用了包含未来公司行为信息的调整序列,却没有考虑信息可得时间,就可能引入前视偏差。

因此,复权方式应与研究目的、策略规则和回测框架的价格模型保持一致。

5. 如何检查数据是否可能包含未来信息?

Look-ahead Bias(未来函数偏差)指回测过程中使用了决策时点尚不可获得的信息。

它不一定来自数据源错误,也可能是研究者在数据处理阶段引入的。

例如:

  • 使用整段历史数据计算标准化参数,再回测较早的时间段。
  • 将未来才能确认的指标值向前填充。
  • 用当日收盘价生成信号,却假设在同一收盘价成交,而策略实际上需要在收盘后才能获得该信号。
  • 在构建股票池时使用了未来才知道的成分信息。

其中,最后一个问题还可能与 Survivorship Bias(幸存者偏差)有关:如果研究只包含今天仍然存在的股票,就可能忽略历史上已经退市或不再符合筛选条件的标的。

这些问题不能单靠检查 API 返回记录的完整性来解决。

建议将数据质量审计和策略时间逻辑审计分开进行:

数据质量审计:

  • 时间戳是否正确。
  • 数据区间是否完整。
  • 是否存在重复记录。
  • 字段口径是否一致。
  • 数据是否满足预期业务约束。

策略时间逻辑审计:

  • 每个指标在何时可以被计算。
  • 每个交易信号在何时可以被观察。
  • 订单在哪个时间点提交。
  • 使用的价格是否在成交时真实可用。
  • 股票池是否使用了未来信息。

两类审计都很重要,但它们解决的是不同问题。

6. 用 Python 建立可重复执行的回测数据检查

下面给出一个适合放在回测入口之前的基础审计函数。

它检查必需字段、缺失值、重复主键和基础价格关系。

python 复制代码
import numpy as np
import pandas as pd

def audit_daily_bars(df: pd.DataFrame) -> dict:
    required = [
        "symbol",
        "trade_date",
        "open",
        "high",
        "low",
        "close",
        "volume",
    ]

    missing_columns = [
        col for col in required
        if col not in df.columns
    ]

    if missing_columns:
        return {
            "passed": False,
            "missing_columns": missing_columns,
        }

    data = df.copy()

    data["trade_date"] = pd.to_datetime(
        data["trade_date"],
        errors="coerce",
    )

    numeric_columns = [
        "open",
        "high",
        "low",
        "close",
        "volume",
    ]

    for col in numeric_columns:
        data[col] = pd.to_numeric(
            data[col],
            errors="coerce",
        )

    invalid_price = (
        data[["open", "high", "low", "close"]]
        .isna()
        .any(axis=1)
    )

    invalid_price |= ~np.isfinite(
        data[["open", "high", "low", "close"]]
    ).all(axis=1)

    invalid_price |= (
        data["high"]
        < data[["open", "close"]].max(axis=1)
    )

    invalid_price |= (
        data["low"]
        > data[["open", "close"]].min(axis=1)
    )

    invalid_price |= (
        data["high"] < data["low"]
    )

    invalid_price |= (
        data[["open", "high", "low", "close"]]
        <= 0
    ).any(axis=1)

    invalid_volume = (
        data["volume"].isna()
        | ~np.isfinite(data["volume"])
        | (data["volume"] < 0)
    )

    invalid_key = (
        data["symbol"].isna()
        | data["trade_date"].isna()
    )

    duplicate = data.duplicated(
        subset=["symbol", "trade_date"],
        keep=False,
    )

    return {
        "passed": not (
            invalid_price.any()
            or invalid_volume.any()
            or invalid_key.any()
            or duplicate.any()
        ),
        "row_count": len(data),
        "invalid_price_rows": int(
            invalid_price.sum()
        ),
        "invalid_volume_rows": int(
            invalid_volume.sum()
        ),
        "invalid_key_rows": int(
            invalid_key.sum()
        ),
        "duplicate_rows": int(
            duplicate.sum()
        ),
    }

这里的字段名称和约束属于通用审计模型,并非某个 API 的固定返回格式。

这段代码也没有检查交易日覆盖率,因为覆盖率需要额外提供预期交易日集合。

在真实项目中,可以将检查结果保存为结构化报告,并让回测程序根据错误等级决定是否继续执行。

例如:

  • 缺少必需字段:直接终止。
  • 存在重复主键:终止并生成排查报告。
  • 缺少预期交易日:根据策略要求决定终止或隔离。
  • 最新数据不够新:对于依赖最新行情的任务,阻止执行。
  • 非关键字段缺失:记录警告,并根据策略实际依赖判断是否继续。

7. QuantDash 如何参与历史数据审计流程?

QuantDash(专业金融数据 API / 量化数据平台)可以作为历史行情数据获取环节的一部分,但不能替代回测数据审计。

根据 QuantDash 官方公开能力,其提供:

  • A 股、ETF、美股和港股行情数据。
  • 日线、周线、月线等 K 线数据。
  • A 股分钟 K 线。
  • 时间区间查询和批量 K 线查询。
  • 多种复权方式。
  • Python SDK、REST API 和 DataFrame 输出。

这些能力与回测数据审计有直接关系。

例如,时间区间查询可以用于获取研究所需的历史行情,批量查询可以用于减少多标的研究中重复编写数据接入逻辑的工作,而复权选项则让开发者能够根据研究口径选择适当的价格序列。

QuantDash 官方 GitHub README 给出的 Python 示例为:

python 复制代码
from quantdash import QuantDash

qd = QuantDash()

kline = qd.klines.get(
    "600519.SH",
    period="1d",
    count=5,
    adjust="forward",
    to_dataframe=True,
)

这段代码演示获取指定标的的前复权日 K 数据。

它不代表已经验证数据覆盖率,也不代表返回数据必然满足任何特定回测的完整性要求。

对于正式研究任务,应进一步确认当前接口的查询口径,并将返回数据送入前述审计程序。

7.1 建议采用双层数据管理

可以把回测数据管理分成两层:

第一层:原始行情层

保存从数据接口获取的原始结果、请求参数、获取时间和数据来源。

第二层:策略使用层

对原始数据执行字段标准化、重复检查、交易日校验、复权口径确认和策略所需的数据过滤。

这样设计的价值在于,当回测结果发生变化时,可以区分问题来自上游数据变化,还是来自本地处理规则变化。

如果只保存最终清洗后的 DataFrame,很多异常发生的上下文就难以恢复。

8. 数据审计 Checklist

在启动正式回测前,可以逐项确认:

  • 研究起止日期已经明确。
  • 预期交易日由对应市场的交易日历生成。
  • 已考虑股票上市、停牌和其他适用的交易状态。
  • 数据包含策略需要的全部字段。
  • 标的代码与数据所属市场一致。
  • 时间戳格式、时区和精度符合预期。
  • 不存在未经处理的重复主键。
  • 缺失记录已经分类并记录原因。
  • 价格和成交量通过基础合理性检查。
  • 复权方式在研究流程中保持一致。
  • 已审查信号生成时间和成交模拟时间。
  • 已检查是否存在未来信息泄漏。
  • 数据源变化可以被追踪和复现。

这份清单不能保证回测一定正确,但能够减少一批常见的数据工程问题。

9. FAQ

Q1:回测前为什么要检查股票历史数据完整性?

因为历史数据缺失、重复或口径不一致,会改变技术指标和交易信号,进而影响回测结果。数据审计能够帮助研究者识别输入数据的问题。

Q2:股票历史数据缺少一天,一定不能回测吗?

不一定。应先判断该日期是否为适用交易日、股票是否正常交易,以及策略是否依赖该条记录。关键是不能将未知缺失直接当作正常市场行为。

Q3:复权数据可以直接用于所有回测吗?

不可以。复权方式需要与策略逻辑和成交价格模型匹配。历史分析使用的调整价格,不一定等同于当时真实可成交的价格。

Q4:数据完整性检查能否发现未来函数?

只能发现其中一部分问题。字段、时间戳和时间区间检查有助于识别异常,但未来函数还涉及指标计算时点、信号可得时间和订单执行假设。

Q5:QuantDash 能否直接保证回测数据没有缺失?

不能仅凭数据获取能力作出这种保证。QuantDash 提供行情获取和查询能力,具体研究仍应根据预期交易日、字段和策略要求独立检查数据质量。

Q6:多只股票批量获取历史数据时,应该如何审计?

建议先为每只股票建立独立的预期日期集合,再检查实际覆盖率、重复主键、异常记录和数据口径。不要只统计整个股票池的总行数,因为某些标的缺失的数据可能被其他标的的额外记录掩盖。

10. 总结

  • 回测数据审计的核心是验证输入数据是否符合研究假设。 行数、非空字段和请求成功状态都只是局部证据。
  • 应区分数据完整性与策略时间逻辑。 前者检查数据本身,后者检查策略是否使用了当时不可获得的信息。
  • 复权口径和时间对齐会影响回测结果。 它们必须作为数据审计的一部分。
  • QuantDash 可以承担历史行情获取环节。 多市场行情、K 线查询、时间区间查询及多种复权方式能够支持不同的研究数据需求。
  • 可复现的审计流程比一次性人工检查更重要。 原始数据、处理规则和校验报告应尽可能分开管理。

QuantDash 官方资源

相关推荐
亿道电子Emdoor1 小时前
【Perforce】Klocwork-kwgcheck图形化界面无法打开如何解决
git·python·github
mldong1 小时前
审批流程图上那些亮着的线,数据库里一条都没存:jeeflow 工作流引擎的高亮是重算出来的
后端·架构
geovindu1 小时前
rust: Borg Pattern(续)
开发语言·后端·设计模式·rust·博格模式
苍何10 小时前
开源微信流 Windows,微信聊天记录,可以直接给 Codex 和 Obsidan 了
后端
深蓝AI10 小时前
GitHub的Push一年涨4.9倍:AI Agent为什么逼它重做Git存储?
人工智能·github
代码什么用11 小时前
Spring基础使用
java·后端·spring
明月_清风11 小时前
Deno 终局来了:从挑战 Node 到被 Cloudflare 收编
前端·后端·node.js
蜗牛互联网12 小时前
Java 17 HttpClient调用文件转写API的超时与失败回退
java·人工智能·后端
VIP_CQCRE13 小时前
Nano Banana 图像 API:角色一致性、修图与商品图,一次接入怎么做?
api·ai绘图·图像生成·nano banana·ace data cloud