历史数据迁移实战:从旧平台到新平台的完整方案

历史数据迁移实战:从旧平台到新平台的完整方案

量化交易系统跑了一两年,积累了大量交易记录、缓存数据和配置文件。当需要从旧平台迁移到新平台时,数据迁移就成了一个绕不开的工程问题。搞好了,平滑过渡;搞砸了,数据丢失、策略异常,甚至影响实盘。

这篇文章以一个真实场景为例:把旧平台的交易记录、缓存数据、配置文件迁移到新平台。方案覆盖备份、格式转换、完整性校验、迁移后验证和回滚,代码可以直接参考使用。

一、迁移前的准备工作

1.1 梳理数据资产

先别急着写代码,先搞清楚要迁什么。量化系统里通常有三类数据:

类别 典型内容 特点
交易记录 订单、成交、持仓、资金曲线 强一致性要求,不可丢失
缓存数据 行情快照、指标缓存、特征向量 可重建,但重建耗时长
配置文件 策略参数、账户配置、风控阈值 体积小,但错一个字段就出事

1.2 冻结写入

迁移期间旧平台必须停止写入,否则会出现数据漂移。最稳妥的做法是停掉策略进程,或者把数据库切到只读模式。

python 复制代码
import sqlite3

def set_readonly(db_path: str):
    """将 SQLite 数据库设为只读,防止迁移期间写入"""
    conn = sqlite3.connect(db_path)
    conn.execute("PRAGMA query_only = ON;")
    return conn

如果是 MySQL/PostgreSQL,直接 REVOKE INSERT, UPDATE, DELETE 即可。

二、数据备份

2.1 全量备份

备份要保证原子性------要么全成功,要么全失败。用时间戳目录隔离每次备份:

python 复制代码
import os
import shutil
import hashlib
from datetime import datetime
from pathlib import Path

def backup_all(source_dirs: dict, backup_root: str) -> str:
    """
    source_dirs: {'trades': '/old/trades', 'cache': '/old/cache', 'config': '/old/config'}
    返回本次备份目录
    """
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_dir = Path(backup_root) / f"backup_{ts}"
    backup_dir.mkdir(parents=True, exist_ok=False)

    for name, src in source_dirs.items():
        dst = backup_dir / name
        if not os.path.exists(src):
            raise FileNotFoundError(f"源目录不存在: {src}")
        shutil.copytree(src, dst)
        print(f"[OK] {name} -> {dst}")

    return str(backup_dir)

2.2 生成校验清单

备份完立刻算哈希,后面校验用得上:

python 复制代码
def build_manifest(root_dir: str) -> dict:
    """遍历目录,生成 {相对路径: sha256} 清单"""
    manifest = {}
    for path in Path(root_dir).rglob("*"):
        if path.is_file():
            h = hashlib.sha256()
            with open(path, "rb") as f:
                for chunk in iter(lambda: f.read(8192), b""):
                    h.update(chunk)
            manifest[str(path.relative_to(root_dir))] = h.hexdigest()
    return manifest

三、格式转换

旧平台和新平台的数据结构往往不一样。这里最容易踩的坑是字段名、时间格式、数值精度。

3.1 交易记录转换

假设旧平台用 CSV,字段是 time, symbol, side, price, qty,新平台要求 Parquet,字段改成 ts, code, direction, fill_price, volume,且时间统一为 UTC 毫秒时间戳。

python 复制代码
import pandas as pd

FIELD_MAP = {
    "time": "ts",
    "symbol": "code",
    "side": "direction",
    "price": "fill_price",
    "qty": "volume",
}

def convert_trades(src_csv: str, dst_parquet: str):
    df = pd.read_csv(src_csv)

    # 字段重命名
    df = df.rename(columns=FIELD_MAP)

    # 时间统一为 UTC 毫秒
    df["ts"] = pd.to_datetime(df["ts"], utc=True).astype("int64") // 10**6

    # 方向统一为 BUY/SELL
    df["direction"] = df["direction"].str.upper().map(
        {"B": "BUY", "S": "SELL", "BUY": "BUY", "SELL": "SELL"}
    )

    # 数值精度对齐
    df["fill_price"] = df["fill_price"].astype("float64").round(8)
    df["volume"] = df["volume"].astype("float64").round(8)

    # 按时间排序
    df = df.sort_values("ts").reset_index(drop=True)

    df.to_parquet(dst_parquet, index=False)
    print(f"[OK] {len(df)} 条交易记录 -> {dst_parquet}")
    return df

3.2 缓存数据转换

缓存通常可以重建,但如果重建要跑几个小时,还是迁过去划算。缓存的关键是保留原始精度,别在转换过程中丢信息。

python 复制代码
import pickle
import numpy as np

def convert_cache(src_pkl: str, dst_npz: str):
    with open(src_pkl, "rb") as f:
        cache = pickle.load(f)

    arrays = {}
    for key, val in cache.items():
        if isinstance(val, (list, np.ndarray)):
            arrays[key] = np.asarray(val)
        else:
            # 非数组类型序列化进 meta
            arrays[f"meta__{key}"] = np.array([val], dtype=object)

    np.savez_compressed(dst_npz, **arrays)
    print(f"[OK] 缓存 {len(arrays)} 个键 -> {dst_npz}")

3.3 配置文件转换

配置文件最怕字段遗漏。用一个 schema 做白名单校验,多余的字段报警但不阻断,缺失的字段直接报错。

python 复制代码
import json

CONFIG_SCHEMA = {
    "strategy": ["name", "params", "enabled"],
    "risk": ["max_position", "max_drawdown", "stop_loss"],
    "account": ["broker", "account_id"],
}

def convert_config(src_json: str, dst_json: str):
    with open(src_json) as f:
        cfg = json.load(f)

    new_cfg = {}
    for section, keys in CONFIG_SCHEMA.items():
        if section not in cfg:
            raise KeyError(f"缺少配置段: {section}")
        new_cfg[section] = {}
        for k in keys:
            if k not in cfg[section]:
                raise KeyError(f"缺少字段: {section}.{k}")
            new_cfg[section][k] = cfg[section][k]

        extra = set(cfg[section]) - set(keys)
        if extra:
            print(f"[WARN] {section} 存在未知字段: {extra}")

    with open(dst_json, "w") as f:
        json.dump(new_cfg, f, indent=2, ensure_ascii=False)
    print(f"[OK] 配置 -> {dst_json}")

四、完整性校验

迁移完必须校验,不能凭感觉。校验分三层:

4.1 文件级校验

对比源和目标的哈希清单,确保文件没坏。

python 复制代码
def diff_manifest(src_manifest: dict, dst_manifest: dict) -> dict:
    result = {"missing": [], "extra": [], "changed": []}
    src_keys, dst_keys = set(src_manifest), set(dst_manifest)

    result["missing"] = list(src_keys - dst_keys)
    result["extra"] = list(dst_keys - src_keys)
    result["changed"] = [
        k for k in src_keys & dst_keys
        if src_manifest[k] != dst_manifest[k]
    ]
    return result

4.2 记录级校验

文件哈希只能证明字节一致,格式转换后哈希必然不同。这时候要校验业务字段:

python 复制代码
def verify_trades(src_df: pd.DataFrame, dst_df: pd.DataFrame):
    assert len(src_df) == len(dst_df), \
        f"记录数不一致: {len(src_df)} vs {len(dst_df)}"

    # 时间戳对齐(允许 ±1ms 误差)
    ts_diff = (src_df["ts"].sort_values().values -
               dst_df["ts"].sort_values().values)
    assert abs(ts_diff).max() <= 1, "时间戳偏差过大"

    # 价格和数量对齐
    for col, tol in [("fill_price", 1e-8), ("volume", 1e-8)]:
        diff = (src_df[col].values - dst_df[col].values)
        assert abs(diff).max() < tol, f"{col} 精度超限"

    print("[OK] 交易记录校验通过")

4.3 聚合校验

记录级过了还不够,做一次聚合对账,防止排序错位之类的隐蔽问题:

python 复制代码
def aggregate_check(df: pd.DataFrame) -> dict:
    return {
        "count": len(df),
        "total_volume": float(df["volume"].sum()),
        "avg_price": float(df["fill_price"].mean()),
        "first_ts": int(df["ts"].min()),
        "last_ts": int(df["ts"].max()),
    }

def compare_aggregates(src: dict, dst: dict, tol=1e-6):
    for key in src:
        a, b = src[key], dst[key]
        if isinstance(a, float):
            assert abs(a - b) < tol, f"{key} 不一致: {a} vs {b}"
        else:
            assert a == b, f"{key} 不一致: {a} vs {b}"
    print("[OK] 聚合校验通过")

五、迁移后验证

数据迁完了不代表系统能用。要跑一遍端到端验证:

5.1 回放验证

用新平台加载迁移后的数据,跑一遍历史回放,看指标是否和旧平台一致。

python 复制代码
def replay_check(new_platform, old_metrics: dict, start_ts: int, end_ts: int):
    new_platform.load_data(start_ts, end_ts)
    new_metrics = new_platform.run_backtest()

    for key in old_metrics:
        old_v, new_v = old_metrics[key], new_metrics[key]
        rel_diff = abs(old_v - new_v) / (abs(old_v) + 1e-9)
        status = "OK" if rel_diff < 1e-4 else "FAIL"
        print(f"[{status}] {key}: old={old_v:.6f} new={new_v:.6f} diff={rel_diff:.2e}")

5.2 抽样人工核对

程序校验再严,也建议随机抽 10~20 条记录人工看一眼。特别是边界情况:跨日订单、部分成交、撤单记录。

python 复制代码
import random

def sample_check(df: pd.DataFrame, n=10):
    samples = df.sample(n=min(n, len(df)), random_state=42)
    for _, row in samples.iterrows():
        print(row.to_dict())

六、回滚方案

迁移不可能 100% 一次成功,必须准备好回滚。核心思路:新平台独立部署,旧平台保持可启动状态。

6.1 回滚触发条件

  • 记录级校验失败
  • 回放指标偏差超过阈值
  • 新平台启动后 30 分钟内出现异常

6.2 回滚脚本

python 复制代码
import shutil
from pathlib import Path

def rollback(backup_dir: str, target_dirs: dict):
    """
    target_dirs: {'trades': '/new/trades', ...}
    从备份恢复
    """
    backup = Path(backup_dir)
    for name, target in target_dirs.items():
        src = backup / name
        if not src.exists():
            raise FileNotFoundError(f"备份缺失: {src}")

        target_path = Path(target)
        if target_path.exists():
            shutil.rmtree(target_path)
        shutil.copytree(src, target_path)
        print(f"[ROLLBACK] {name} <- {src}")

    print("[OK] 回滚完成,请重启旧平台服务")

6.3 双写过渡期

如果业务允许,迁移后可以保持 1~3 天的双写:新平台为主,旧平台只记录。对账无误后再彻底下线旧平台。这是最稳妥的做法,代价是短期维护成本翻倍。

七、一些踩过的坑

时间戳精度:旧平台用秒,新平台用毫秒,直接转换会丢精度。转换前先确认单位。

浮点数比较 :不要用 == 比较价格,用 abs(a-b) < 1e-8。

Parquet 的 schema 推断:pandas 写 Parquet 时如果某列全为整数,读回来可能是 int,也可能是 float。显式指定 dtype 更安全。

配置文件的默认值:新平台可能有旧平台没有的字段,转换时要主动补默认值,不要指望新平台容错。

缓存顺序 :如果缓存里有依赖顺序的结构(比如时间序列),序列化时要保持顺序,pickle 默认保序,但 JSON 转 dict 后要注意 Python 3.7+ 才保序。

八、完整流程串联

把上面的模块串起来,一个完整的迁移脚本大概长这样:

python 复制代码
def migrate(source_dirs, backup_root, work_dir):
    # 1. 备份
    backup_dir = backup_all(source_dirs, backup_root)
    src_manifest = build_manifest(backup_dir)

    # 2. 转换
    convert_trades(f"{backup_dir}/trades/trades.csv",
                   f"{work_dir}/trades.parquet")
    convert_cache(f"{backup_dir}/cache/cache.pkl",
                  f"{work_dir}/cache.npz")
    convert_config(f"{backup_dir}/config/config.json",
                   f"{work_dir}/config.json")

    # 3. 校验
    src_df = pd.read_csv(f"{backup_dir}/trades/trades.csv")
    dst_df = pd.read_parquet(f"{work_dir}/trades.parquet")
    verify_trades(src_df.rename(columns=FIELD_MAP), dst_df)
    compare_aggregates(aggregate_check(src_df.rename(columns=FIELD_MAP)),
                       aggregate_check(dst_df))

    # 4. 部署到新平台后跑回放验证
    # replay_check(new_platform, old_metrics, start_ts, end_ts)

    print("[DONE] 迁移完成,请执行回放验证")
    return backup_dir

迁移这件事,80% 的工作量在准备和校验,20% 在转换。别把顺序搞反了,先备份、再校验、最后才动手改数据。回滚方案不是可选项,是必选项------真出事的时候,它是你唯一的救命稻草。

更多内容请关注本站。