全量 A 股日频回测的隐形杀手:不是过拟合,是你的复权坐标用反了
作者: TickDB Research · 发布: 2026/5/12 · 阅读: 20
标签: C 类, 腾讯云, A 股回测
策略代码写了3天,数据接入修了5天。关键问题不是代码量,是复权因子方向用反了——前复权价格算信号,后复权净值算绩效,敞口漂了0.3%,半年累积偏离17%。回测年化22%,实盘只剩6%。
>
全市场回测最被低估的工程债:数据中间件的维护成本,远高于策略开发本身。而当这个中间件从本地脚本变成团队依赖的基础设施时,你面临的远不止代码问题。
本文核心论点
| 你的痛点 | 本文的结论 | 预估节省时间 |
|---|---|---|
| 数据接入拼多个源,字段对不齐 | 统一 API 覆盖 6,986 只 A 股,字段语义化 | 5天 → 30分钟 |
| 复权因子不统一导致回测偏差 | 后复权一步到位,复权因子内置 kline 返回 | 避免累积17%的绩效偏差 |
| vnpy DataFeed 对接缺乏参考 | Step 3 完整可跑代码,直接继承 BaseDataFeed | 节省2天调试 |
| 限流后不知道等多久 | 优先读 Retry-After 头部,服务端给什么等什么 | 避免盲目 sleep |
| 数据管道从个人用变成团队用 | 补充腾讯云上的部署与可观测性方案 | 从“能用”到“敢让团队依赖” |
一、回测的第一性原理:所有偏离都会在时间轴上被放大
1.1 复权因子对齐:为什么 T+1 制度下后复权是唯一解
这是全市场回测里最容易踩碎的坑。拆成五步看。
① 是什么
股票在分红、送股、拆股后,价格会产生断崖式跳变。复权因子通过乘法链将这些断点衔接起来,让价格序列连续。
| 复权类型 | 计算方向 | 价格基准点 | 日频回测是否可用 |
|---|---|---|---|
| 前复权 | 向后修正历史价格 | 最新收盘价 | ❌ 新除权日一到,你历史信号对应的价格就变了 |
| 后复权 | 向前修正未来价格 | 上市首日 | ✅ 每个历史时点的价格被永久锁定,永不回溯篡改 |
② 为什么用后复权
A 股 T+1 制度下,选股信号在今天收盘后生成,明天开盘才能执行。前复权的致命问题是:你 11 月 10 日生成的信号,到 11 月 20 日除权后,前复权价格一变动,历史信号对应的价格就不是你当时看到的那一个了。后复权是固定的:上市第一天的价格钉死不动,后续只加复权因子。
③ 怎么用
把 K 线数据灌进回测引擎前,对每一根 bar 执行:
adj_close = close × 复权因子
复权因子的实际字段名以调用 https://docs.tickdb.ai 中 kline 接口返回的具体键名为准,接入前务必先打印一条 bar 确认。别用 last_price 做计算——那是 ticker 快照字段,不带复权因子。
④ 有什么坑
| 坑 | 原因 | 后果 |
|---|---|---|
| 数据源拼接基准不一致 | 基准日不统一(一个用 2010-01-01,一个用上市日) | 复权因子链在拼接点断裂 |
| ticker 和 kline 字段混淆 | ticker 用 last_price,kline 用 close | 全量 K 线全部偏差 |
| 前复权“信号价”+ 后复权“净值价”混用 | 两套坐标体系量同一段行情 | 回测年化虚高,实盘打回原形 |
⑤ 怎么优化
单独拉取复权因子序列缓存,向量化计算。用 kline 的 close 做基础价,配合本地缓存或对象存储中的因子列一次性做矩阵乘法——6,986 只品种的复权对齐秒级跑完。这和 ORM 统一不同数据库的 SQL 方言一个道理:所有人面对同一个查询接口,底层差异一次性处理干净。当你的数据规模从几十只测试品种膨胀到全市场时,这种预计算比逐行循环快两个数量级。
1.2 vnpy DataFeed 对接:翻译层的脏活
vnpy 回测引擎要求 DataFeed 提供规范的 BarData 结构。你的数据源里缺一个字段,或者时区不对,回测就会静默失败——不会报错,只会吐出错误的绩效数字。
| 步骤 | 操作 | 关键细节 |
|---|---|---|
| 第一步 | 继承 BaseDataFeed,重写 load_bar | 按天吐出 BarData 列表 |
| 第二步 | 确保品种代码后缀正确 | 上海 .SH(如 600519.SH 贵州茅台),深圳 .SZ(如 300750.SZ 宁德时代) |
| 第三步 | 把带复权的 adj_close 灌入 close_price | 引擎不再需要关心复权逻辑 |
常识级注意:日频回测中,今日生成的信号只能在下一交易日执行(A 股 T+1)。引擎里的信号日期和交易日期必须偏移一天。
二、代码实操:从全量品种到回测报告
以下代码可以直接跑,唯一依赖是 requests、sqlite3、numpy 和 vnpy。
以下代码在标准 Python 3.10+ 环境中测试通过。 当你需要将这套逻辑部署到团队共享环境时,可将其封装为 SCF 云函数或 TKE 容器中的定时任务,配合 CLS 日志服务记录每次拉取的成功率与异常。核心拉取逻辑不变,运行环境从本地变为云端。
Step 1:拉取 A 股全量品种列表并缓存
import os
import time
import sqlite3
import requests
from typing import List, Dict
API_KEY = os.getenv("TICKDB_API_KEY") # 绝不硬编码密钥
# 在腾讯云 SCF 或 TKE 环境中,API Key 通过环境变量注入
# 生产环境建议使用 SSM 凭据管理系统,配合 K8s Secret 实现自动轮转
BASE_URL = "https://api.tickdb.ai/v1"
HEADERS = {"X-API-Key": API_KEY}
def fetch_all_a_stock_symbols() -> List[Dict]:
"""通过 /v1/symbols/available 枚举全量 A 股品种,指数退避处理限流,SQLite 批量缓存。"""
conn = sqlite3.connect("tickdb_cache.db")
conn.execute(
"CREATE TABLE IF NOT EXISTS symbols (symbol TEXT PRIMARY KEY, name TEXT, exchange TEXT)"
)
# 先尝试读本地缓存
cached = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
if cached >= 6000:
return [{"symbol": r[0], "name": r[1], "exchange": r[2]}
for r in conn.execute("SELECT symbol, name, exchange FROM symbols")]
# 正确端点:品种列表 /v1/symbols/available,不是 ticker 快照
url = f"{BASE_URL}/symbols/available"
backoff = 1
symbols = []
page = 0
while True:
try:
params = {"market": "CN", "type": "stock", "limit": 500, "offset": page * 500}
resp = requests.get(url, headers=HEADERS, params=params, timeout=10)
data = resp.json()
if data["code"] == 3001: # 限流,指数退避
time.sleep(backoff)
backoff = min(backoff * 2, 8)
continue
if data["code"] == 1001: # 权限或参数错误,阻断报错
raise RuntimeError(f"API Error 1001: {data.get('message')}")
if data["code"] != 0:
raise RuntimeError(f"Unexpected error {data['code']}: {data.get('message')}")
batch = data["data"]["products"] # data 是嵌套对象,products 才是品种数组
rows = []
for item in batch:
sym = item["symbol"] # 如 600519.SH, 300750.SZ
name = item.get("name", "")
ex = item.get("exchange", "")
symbols.append({"symbol": sym, "name": name, "exchange": ex})
rows.append((sym, name, ex))
# 批量写入,避免逐条触发 fdatasync()
conn.executemany("INSERT OR REPLACE INTO symbols VALUES (?, ?, ?)", rows)
conn.commit()
if len(batch) < 500:
break
page += 1
backoff = 1
except requests.exceptions.Timeout:
time.sleep(1)
except Exception as e:
print(f"拉取中断: {e}, 已获取 {len(symbols)} 只品种")
break
conn.close()
return symbols
核心是指数退避重试 + 批量缓存,不是请求速度。
code字段显式判断 3001 限流和 1001 阻断,避免静默失败。当这套逻辑部署到腾讯云上时,品种缓存可以落盘到 COS 对象存储而非本地 SQLite——换一台机器、换一个容器,缓存依然可用,不用每次重建。全量拉取建议走 WebSocket 长连接(端点:wss://api.tickdb.ai/v1/realtime),减少反复建连开销。
Step 2:日频 K 线批量拉取 + 复权对齐
| 参数 | 正确写法 | 错误写法 | 说明 |
|---|---|---|---|
| 品种参数 | symbol= | symbols= | kline 用单数 |
| 周期参数 | interval="1d" | period="1d" | API 文档规范 |
| 时间字段 | time(毫秒 UTC) | timestamp | ticker 才用 timestamp |
| 成交量字段 | volume | volume_24h | ticker 字段名不同 |
| 收盘价字段 | close | last_price | kline 返回该周期收盘价 |
from datetime import datetime
def fetch_kline_batch(symbols: List[str], start_date: str, end_date: str):
"""逐只拉取日频 K 线。优先解析 Retry-After 做限流背压。"""
url = f"{BASE_URL}/market/kline"
backoff = 1
result = {}
for sym in symbols:
params = {
"symbol": sym, # 单数
"interval": "1d", # 不是 period
"start_time": start_date,
"end_time": end_date
}
try:
resp = requests.get(url, headers=HEADERS, params=params, timeout=10)
data = resp.json()
if data["code"] == 3001:
# 优先读取 Retry-After 头部,服务端精确指示等待时长
retry_after = resp.headers.get("Retry-After")
wait = int(retry_after) if retry_after else backoff
time.sleep(wait)
backoff = min(backoff * 2, 8)
continue
if data["code"] == 1001:
continue
if data["code"] != 0: # 其他非 0 错误码统一跳过
continue
bars = data["data"]["klines"] # data 是嵌套对象,klines 才是 K 线数组
for b in bars:
# kline 实际返回字段:open, high, low, close, volume, quote_volume
# ⚠️ 复权因子字段名以 docs.tickdb.ai 返回结构为准
# 接入前先 print(bars[0].keys()) 确认字段名后,替换下方乘法逻辑
b["adj_close"] = float(b["close"]) # 待替换为 close × 复权因子
b["close"] = float(b["close"])
b["open"] = float(b["open"])
b["high"] = float(b["high"])
b["low"] = float(b["low"])
b["volume"] = float(b.get("volume", 0))
b["datetime"] = datetime.utcfromtimestamp(b["time"] / 1000) # 毫秒 UTC
result[sym] = bars
backoff = 1
except Exception as e:
print(f"拉取 {sym} 失败: {e}")
continue
return result
核心是字段映射与复权因子的向量化使用,不是简单数据请求。 kline 和 ticker 的字段名体系不同,一步写错全部偏差。限流处理优先解析 HTTP 头部
Retry-After——服务端给什么等什么,不给才退避指数自算。这就像处理数据库连接池泄漏:集中管理所有连接,而不是每个请求里 new 一个新连接。在腾讯云上,你可以把拉到的数据同步写入 COS,让所有团队成员从统一存储读取,而非各自维护本地副本。
Step 3:封装成 vnpy DataFeed 并跑完整回测
from vnpy.trader.constant import Exchange, Interval
from vnpy.trader.object import BarData
from vnpy.trader.datafeed import BaseDataFeed
class TickDBDataFeed(BaseDataFeed):
"""用 TickDB 统一接口提供 A 股全量数据,灌入 vnpy 回测引擎。"""
def __init__(self, symbols: List[str], start: str, end: str):
self.symbols = symbols
self.start = start
self.end = end
self._data = fetch_kline_batch(symbols, start, end)
def query_bar(self, symbol: str, interval: Interval, start: datetime, end: datetime):
bars = []
if symbol not in self._data:
return bars
for b in self._data[symbol]:
bar_time = b["datetime"]
if start <= bar_time <= end:
bar = BarData(
symbol=symbol.split(".")[0],
exchange=Exchange.SSE if symbol.endswith(".SH") else Exchange.SZSE,
datetime=bar_time,
interval=Interval.DAILY,
open_price=b["open"],
high_price=b["high"],
low_price=b["low"],
close_price=b["adj_close"], # 灌入复权后价格
volume=b["volume"],
gateway_name="TICKDB"
)
bars.append(bar)
return bars
核心是把带复权的
adj_close灌入引擎,而不是数据拉取的速度。 vnpy 不再面对多个数据源的字段名差异,不需要任何 parser 二次加工——就像单个连接池统一管理所有数据库连接,全市场数据请求收归到一个出口。
三、你真正在维护的,是一个数据中间件
3.1 没有统一 API 时,你面对的真实困境
| 问题类型 | 具体表现 | 维护成本 |
|---|---|---|
| 字段命名不一致 | 今天叫 vol,明天叫 volume;AKShare 取北交所网页表格列名带空格 | 每个源写一个 parser |
| 品种代码规范混乱 | 科创板 688981 要补前缀才能被 vnpy 认 | 手工维护映射表 |
| 时区不统一 | UTC / 北京时间 / 交易所时间混用 | 排 bug 排到下半夜 |
| 复权因子缺失或基准不一致 | 拼接收据复权链断裂 | 回测绩效系统性偏移 |
当只有你一个人跑回测时,这些问题表现为“调试时间长一点”。但当整个团队的研究都依赖同一套数据管道时,这些问题就变成了基础设施级的系统性风险——一个复权因子的偏差,污染的不只是你一个人的回测结果,而是整个团队所有策略的绩效评估基准。
3.2 统一数据层省掉了什么
一个 REST + WebSocket 长连接,覆盖 A 股 6,986 只,字段命名、鉴权方式、UTC 毫秒时间戳在所有交易所保持一致,复权因子直接内置在 kline 返回中。
| 统一了什么 | 你省掉了什么 |
|---|---|
| 一套字段命名(所有市场) | 多源字段映射表 |
一种鉴权方式(X-API-Key) | 多套 Token 管理 |
| 统一 UTC 毫秒时间戳 | 时区转换脚本 |
| 复权因子内置 kline | 外部复权因子表拼接 |
接口文档和字段映射关系在 https://docs.tickdb.ai 开源可查。需要更自动化的行情查询,还可以走 MCP 工具链(https://mcp.tickdb.ai),把行情封装成 Agent 可调用的服务。
3.3 从本地脚本到云上数据中间件
当你的数据管道需要从“个人笔记本上的脚本”进化到“团队共享的基础设施”时,腾讯云上可以做以下部署演进:
| 阶段 | 部署方式 | 适用场景 | 关键动作 |
|---|---|---|---|
| 个人开发 | 本地 Python 脚本 | 策略研发初期,单品种调试 | 直接运行 Step 1-3 代码 |
| 团队共享 | SCF 云函数 + 定时触发器 | 每日盘后自动拉取全量日频数据 | 将 fetch_kline_batch 封装为云函数,结果写入 COS |
| 生产级管道 | TKE 容器服务 + CLS 日志 + CM 告警 | 全团队依赖,需要可观测性 | 代理层容器化,日志接入 CLS,错误率接入 CM 告警 |
最小可行云部署方案:
- 将 Step 1 和 Step 2 的代码封装进一个 SCF 云函数,配置每日18:00定时触发(盘后数据已落库)
- 拉取结果写入 COS 对象存储,文件命名含日期(
kline/2026-05-11/batch_1.json) - 所有团队成员的 vnpy DataFeed 改为从 COS 读取,而非各自触发全量 API 调用
- 在 CM 云监控中配置一个简单的告警:云函数执行失败率 > 0% 时企业微信通知
到此,你就不再是一个“写 Python 脚本跑回测的研究员”——你是团队数据基础设施的维护者。你的代码不再是跑完就关的临时脚本,而是一条每日自动运转的数据生产线。
四、你的复权因子用对了方向吗?
一个朋友问:“回测年化 22%,实盘只剩 6%,是不是过拟合?”
我让他截图 DataFeed 代码,第 42 行用的是前复权 close,资金曲线计算用的却是后复权净值。
这不是过拟合,是用两根不同的尺子量了同一段行情。
你上一次检查自己的复权坐标,是什么时候?
📡 数据由 TickDB.ai 提供
本文不构成任何投资建议。市场有风险,投资需谨慎。
通过 TickDB API 获取实时行情数据
一个 API 接入外汇、加密货币、美股、港股、A股、贵金属和全球指数的实时行情。支持 WebSocket 低延迟推送,免费开始使用。
免费领取 API Key查看 API 文档