综合

你的量化回测年化 22%,实盘只剩 6%——可能不是过拟合

作者: TickDB Research · 发布: 2026/5/12 · 阅读: 20

标签: B 类, 微信, A 股回测

!ScreenShot_2026-05-12_120824_648.png

一个朋友最近很痛苦。

回测年化 22%,实盘跑了半年,只剩 6%。第一反应是过拟合。让他截图 DataFeed 代码,第 42 行——前复权价格算信号,后复权净值算绩效。

不是策略不行。是用两把不同的尺子量了同一段行情。

更让他沉默的,是另一组数字:策略代码写了 3 天,数据接入修了 5 天。关键卡点不是代码量,是复权因子方向用反了——敞口漂了 0.3%,半年累积偏离 17%。

全市场回测最被低估的成本,从来不是策略本身。是数据中间件的维护,把该做 alpha 的时间,吃进了适配层的黑洞里。

——

回测里最容易踩碎的坑,跟市场无关

① 是什么

股票分红、送股、拆股后,价格会跳变。复权因子用乘法链把断点接上,让价格序列连续。

② 为什么后复权是唯一解

A 股 T+1:今天收盘后出信号,明天才能交易。前复权会不断改变历史价格——你 11 月 10 日看到的信号价格,到了 11 月 20 日除权后,已经不是同一个数了。

复权类型计算方向日频回测是否可用
前复权向后修正历史价格❌ 信号历史价格会不断变动
后复权向前修正未来价格✅ 钉死首日价格,后续只加因子

后复权把上市第一天价格钉死,后续只加因子。不会出现“昨天的信号今天的价格已经变了”这种静默错误。

③ 有什么坑

最致命的一个:数据源拼接收的复权基准不一致。一个用 2010-01-01 做基准日,一个用上市首日——复权因子链在拼接点直接断裂。

另一个高频错误:把 ticker 快照的字段当 K 线用。ticker 返回的是瞬时快照,K 线返回的是周期聚合,字段名不同,数据语义完全不同。

④ 怎么优化

单独拉复权因子序列缓存,向量化计算。K 线的 close 做基础价,配合本地缓存的因子列一次性矩阵乘法——6986 只品种的复权对齐瞬间跑完。这和统一数据库连接池一个道理:所有人面对同一个查询接口,翻译在底层一次处理干净。

——

你真正在维护的,是一个没人看见的数据中间件

没有统一数据接入层时,团队面对的真实困境:

问题类型具体表现实际代价
字段命名不一致今天叫 vol,明天叫 volume;某些数据源取北交所网页表格,列名带空格每个源写一个 parser
品种代码规范混乱科创板 688981 要补前缀才能被回测引擎识别手工维护映射表
时区不统一UTC / 北京时间 / 交易所时间混用排 bug 排到下半夜
复权因子缺失多源拼接的复权链断裂回测绩效系统性偏移,且你很难发现

这是全市场回测最被低估的工程债:数据中间件的维护成本,远高于策略开发本身。

TickDB 出现在这个背景下不意外:一个 REST + WebSocket 接口,覆盖全量 A 股 6,986 只。字段命名、鉴权方式、UTC 毫秒时间戳在所有交易所保持一致,复权因子直接内置在 K 线返回中。

你省掉的不是一笔数据费,是一个持续消耗工程师精力的维护黑洞。

统一了什么你省掉了什么
一套字段命名(全市场)多源字段映射表
一种鉴权方式多套 Token 管理
统一 UTC 毫秒时间戳时区转换脚本
复权因子内置 K 线外部复权因子表拼接

接口文档和字段映射在 docs.tickdb.ai 开源可查。需要更自动化的接入,技术团队可以直接走 MCP(https://mcp.tickdb.ai),把行情封装成 AI 编码助手可调用的服务。

——

给技术同事:完整可跑的三段代码

以下代码直接可跑,唯一依赖:requestssqlite3numpyvnpy。从接入到回测跑通,调试时间从 5 天压到 30 分钟。

Step 1:拉取 6986 只 A 股全量品种列表并缓存

import os
import time
import sqlite3
import requests
from typing import List, Dict

API_KEY = os.getenv("TICKDB_API_KEY")          # 绝不硬编码密钥
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 阻断,避免静默失败。生产环境全量拉取建议走 WebSocket 长连接(端点:wss://api.tickdb.ai)。


Step 2:日频 K 线批量拉取 + 复权对齐

参数正确写法错误写法说明
品种参数symbol=symbols=kline 用单数
周期参数interval="1d"period="1d"API 文档规范
时间字段time(毫秒 UTC)timestampticker 才用 timestamp
成交量字段volumevolume_24hticker 字段名不同
收盘价字段closelast_pricekline 返回该周期收盘价
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 新连接。


Step 3:封装进 vnpy 并跑完整回测

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 二次加工——全市场数据请求收归到一个出口。


完整的带错误处理代码已托管,文末有获取方式。

——

一句话的问题,值得今天检查一次

回测 22%,实盘 6%,大多数人的第一反应是过拟合。

但更常见的凶手,静默地躺在 DataFeed 第 42 行——你用前复权算信号,用后复权算净值。两把尺子量同一段行情,偏离在时间轴上被持续放大。

你上一次检查自己的复权坐标,是什么时候?

——

【价值总结】

  • 数据中间件的维护成本,是被严重低估的回测工程债——策略写 3 天,数据修 5 天
  • 复权因子方向错误不是 bug,是静默的性能泄漏——偏离 0.3%,半年累积 17%
  • 统一数据接入层把字段、时区、复权封装干净,让团队精力回到 alpha 本身

【下一步】 点击阅读原文,访问 tickdb.ai,了解统一数据接入方案。或联系你的技术团队,今天就检查一次 DataFeed 里的复权坐标。


📡 数据由 TickDB.ai 提供

▼▼▼ 给技术同事的参考

▸ 产品主页:https://tickdb.ai

▸ API 文档:https://docs.tickdb.ai

▸ MCP 端点:https://mcp.tickdb.ai

▸ WebSocket 端点:wss://api.tickdb.ai

▸ Skill 安装:npx clawhub@latest install tickdb-market-data

▸ CLI 安装:npm install -g tickdb

▸ 本文品种代码:600519.SH / 300750.SZ / 688981.SH

▸ REST 鉴权:Header X-API-Key

▸ 错误码:3001(限流,指数退避)/ 1001(权限/参数错误,阻断)

▸ 覆盖市场:4 大市场

▸ 品种总数:40,000+

▸ AI 工具数量:3 件(Skill / MCP / CLI)


本文为技术经验分享,不构成投资建议。文中品种代码仅为数据演示,不作推荐。

通过 TickDB API 获取实时行情数据

一个 API 接入外汇、加密货币、美股、港股、A股、贵金属和全球指数的实时行情。支持 WebSocket 低延迟推送,免费开始使用。

免费领取 API Key查看 API 文档

相关文章