Ⅴ · 数据与交付 — 5.1

数据的一生

从券商导出到进入回测引擎,中间有五段。MT5 把它们全部隐藏,自研平台里每一段都要有人负责。

1 · 获取券商 / 数据商 / 终端2 · 落地转 Parquet · 不可变3 · 校验七项质量检查4 · 编目manifest + 目录结构5 · 取用DuckDB 或 引擎目录校验不通过 → 退回重取,不进目录MT5 的对应形态:这五段全部由终端隐式完成,且不可干预 ——经纪商给什么就是什么,格式、长度、质量均不可选、不可替换、不可校验。自研的价值:数据层成为可独立演进、独立替换、独立验证的组件 —— 也意味着这五段是新增的工作量。
第 3 段是交付业务的核心能力。客户的数据源千差万别,能不能快速判断"这份数据能不能用",决定了项目前期是顺利还是反复返工。

1 · 获取:三类来源

来源 成本 历史长度 质量 备注
券商 / 交易所 API 随账户免费 通常较短 随经纪商差异大 与实盘执行同源,一致性最好
专业数据商 付费 高,含元数据 Nautilus 原生支持 databento tardis
MT5 终端导出 免费 随经纪商 需完整校验 受 Windows 平台限制,见 5.2

交付要点:客户的历史数据源与实盘执行数据源若不同源,回测与实盘之间会引入系统性偏差。此项应在交付前明确,而不是上线后归因。

2 · 落地:三层不是三选一

是什么 职责 引入时机
Parquet 列存文件格式 冷存、归档、交付、跨引擎交换 第一天
DuckDB 单机查询引擎,可直查 Parquet 研究阶段的查询与聚合 第一天
ClickHouse 服务端数据库 多人并发查询、高频实时写入 出现并发需求时

**关键性质:Parquet 是格式而非引擎。**同一份文件可被 DuckDB、ClickHouse、pandas、Polars 与框架自身同时读取——格式选择不锁定引擎选择。

# 落地:一次写入,此后视为不可变
df.to_parquet(f"data/EURUSD/M15/{year}.parquet",
              compression="zstd", index=False)

3 · 校验:七项必查

数据接入的核心交付能力。前四项在跨源比对时是最常见的差异来源:

检查项 典型问题
时间连续性 缺失区间、数据中断
时区一致性 服务器时间与 UTC 混用、夏令时切换
时间戳含义 标记的是 bar 开盘时刻还是收盘时刻
重复时间戳 同一时刻多条记录
价格合理性 尖刺、零值、买价高于卖价
非交易时段 周末与假日出现的 bar
合约与复权 期货主力合约切换、股票除权除息
def validate(df, expected_freq="15min"):
    issues = {}
    # 时间连续性:按预期频率重建索引,找缺口
    full = pd.date_range(df.ts.min(), df.ts.max(), freq=expected_freq, tz="UTC")
    issues["missing"] = len(full.difference(df.ts))
    # 重复时间戳
    issues["dupes"] = int(df.ts.duplicated().sum())
    # 价格合理性
    issues["bad_ohlc"]   = int(((df.high  df.ask).sum())    # 买价高于卖价
    issues["zero_price"] = int((df[["open","high","low","close"]] <= 0).any(axis=1).sum())
    # 非交易时段(FX:周六休市;注意周日晚间开市)
    issues["saturday"] = int((df.ts.dt.dayofweek == 5).sum())
    return issues

实测佐证:Nautilus 的 ParquetDataCatalog 提供 get_missing_intervals_for_request() ,但源码明确说明它依据文件名模式判断哪些区间没有落盘,不检查文件的实际内容
它能发现“这段时间没有数据文件”,不能发现“文件里的 bar 有孔洞或异常值”——上面这段校验仍需自建。

4 · 编目:三个坐标

一次回测结果的可复现需要三样东西同时被记录:

可复现的回测 = 代码版本  +  数据版本   +  参数版本
                 git        manifest      配置文件
坐标 方案
代码 git;notebook 提交前剥离输出,避免 JSON 输出污染 diff
数据 Parquet 视为不可变;同目录 manifest.json 记录来源、时间范围、行数、校验和
参数 配置文件随代码进版本库;回测记录中保存 commit hash
// data/EURUSD/M15/manifest.json
{
  "source":   "MT5 export / BrokerX / server GMT+2",
  "tz_note":  "已转 UTC;原始为服务器时间,含夏令时切换",
  "range":    ["2015-01-01", "2024-12-31"],
  "rows":     249600,
  "sha256":   "3f1c…",
  "validated": {"missing": 412, "dupes": 0, "crossed": 0}
}

参数版本化对应 MT5 策略测试器的 .set 文件概念,区别在于与代码一同纳入版本控制。不引入 DVC / MLflow / W&B——在实验规模超出人工可追溯范围之前,上述三项已足够。

5 · 取用:研究与回测走不同的门

# 研究轨:DuckDB 直查 Parquet,不需要导入任何数据库
df = duckdb.sql("""
    SELECT date_trunc('hour', ts) AS h,
           first(open) o, max(high) h_, min(low) l, last(close) c
    FROM 'data/EURUSD/M15/*.parquet'
    WHERE ts >= '2024-01-01'
    GROUP BY 1 ORDER BY 1
""").df()

# 生产轨:写进引擎自己的目录格式
#   Nautilus → ParquetDataCatalog(原生 Arrow / Parquet,与上面同格式)
#   LEAN     → data/forex/<market>/<res>/<symbol>/YYYYMMDD_quote.zip(见 5.2)

**两个引擎在这一步的成本不同。**Nautilus 的目录原生基于 Arrow / Parquet,与研究轨同格式,几乎零转换;LEAN 需要转成它自己的分日 zip 结构——实测转换脚本 45 行,但有四个隐性约定要处理,见 5.2。

tick 还是 bar

存储成本 可重放的市场状态 适用
tick / quote 高,单品种年度 GB 级 真实买卖价与点差 点差敏感、盘口敏感策略
bar 需通过模型假设点差与滑点 中低频、方向性策略

分层方案:tick 级以 Parquet 冷存,常用周期的 bar 预聚合供研究使用。这一选择决定回测保真度的上限——撮合引擎需要双边市场,单边序列无法成交(5.4 有实测佐证)。