历史数据迁移实战:从旧平台到新平台的完整方案
量化交易系统跑了一两年,积累了大量交易记录、缓存数据和配置文件。当需要从旧平台迁移到新平台时,数据迁移就成了一个绕不开的工程问题。搞好了,平滑过渡;搞砸了,数据丢失、策略异常,甚至影响实盘。
这篇文章以一个真实场景为例:把旧平台的交易记录、缓存数据、配置文件迁移到新平台。方案覆盖备份、格式转换、完整性校验、迁移后验证和回滚,代码可以直接参考使用。
一、迁移前的准备工作
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% 在转换。别把顺序搞反了,先备份、再校验、最后才动手改数据。回滚方案不是可选项,是必选项------真出事的时候,它是你唯一的救命稻草。
更多内容请关注本站。