综合

生产环境复盘:6986只A股日频选股回测,数据接入修了5天,代码只写了3天

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

标签: 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浪费时间

一、回测的第一性原理:所有偏离都会在时间轴上被放大

复权因子对齐:为什么T+1制度下后复权是唯一解

这是全市场回测里最容易踩碎的坑。拆成五步看清楚。

① 是什么

分红、送股、拆股之后,价格会断崖式跳变。复权因子用乘法链把断点衔接起来,让价格序列重新变得连续。

② 为什么必须用后复权

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

A股T+1制度下,选股信号今天收盘后生成,明天开盘才能交易。前复权会持续改变已经生成信号的那根历史bar的价格——11月10日的信号到了11月20日除权后,前复权价格一变动,历史信号对应的入场价就不再是决策那一刻看到的价格。后复权把上市首日作为锚点锁定,后续只乘因子,不会回改历史。

③ 怎么用

把K线灌进回测引擎之前,对每一根bar做:adj_close = close × 复权因子。复权因子的实际字段名,以调用https://docs.tickdb.ai中kline接口返回的键名为准,接入前务必先打印一条bar确认。另外注意:不要用last_price做计算——那是ticker快照字段,本身不带复权因子。

④ 容易踩的坑

原因后果
多源拼接基准不一致基准日不统一:一个用2010-01-01,一个用上市日复权因子链在拼接点断裂
ticker和kline字段混淆ticker用high_24h/volume_24h,kline用high/volume日频K线全部算偏

⑤ 优化方向

把复权因子序列单独拉出来做本地缓存,然后向量化计算。用kline的close做基础价,配合缓存的因子列一次性矩阵乘法——6986只品种的复权对齐可以瞬间完成。这跟ORM统一不同数据库SQL方言是同一个思路:让所有人面对同一个查询接口,方言的翻译动作在底层一次性处理干净。


vnpy DataFeed对接:翻译层的脏活

vnpy的回测引擎要求DataFeed提供规范的BarData结构。数据源里缺一个字段,或者时区对不上,回测不会报错——只会默默吐出错误的绩效数字。

步骤操作容易被忽略的细节
第一步继承BaseDataFeed,重写load_bar(days)按天吐出BarData列表,逐条检查字段完整性
第二步品种代码后缀一一核对上海.SH(如600519.SH贵州茅台),深圳.SZ(如300750.SZ宁德时代)

日频回测中,今天生成的信号只能在下一个交易日执行(A股T+1)。引擎里的信号日期和交易日期必须偏移一天,写反了回测结果没有任何报错提示。


二、代码实操:从全量品种到回测报告,跨三道坎

下面三段代码可以直接跑起来,唯一依赖是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阻断,避免两种不同性质的异常被混在一起静默处理。HTTP示例适合快速集成,生产环境全量拉取建议走WebSocket长连接(端点:wss://api.tickdb.ai/v1/realtime),减少反复建连的开销。


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 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不再面对多个数据源的字段名差异,所有品种的后缀、时区、字段映射在一个接口层统一收口。

![代码运行截图示意:可放一张回测结果统计摘要的截图,展示年化收益、夏普比率、最大回撤等核心指标]


三、你真正在维护的,是一个数据中间件

没有统一API的时候,工程侧的境况大致如下:

问题类型具体表现维护成本
字段命名不一致今天叫vol,明天叫volume;AKShare取北交所网页表格列名可能带空格每个数据源写一个parser
品种代码规范混乱科创板688981.SH需要确认后缀才能被vnpy识别手工维护映射表
时区不统一UTC / 北京时间 / 交易所时间混用排查时间偏差问题往往要到深夜
复权因子缺失或基准不一致拼接收据时复权链断裂回测绩效出现系统性偏移

在这种背景下,用一个REST + WebSocket长连接,覆盖A股6,986只,字段命名、鉴权方式、UTC毫秒时间戳在所有交易所保持一致,kline 接口返回原始价格(close),复权因子需用户自行维护后与 kline 数据对齐——这意味着你不用再维护三个parser、两套字段映射和一套时区转换脚本。

统一了什么省掉了什么
一套字段命名(所有市场)多源字段映射表
一种鉴权方式(X-API-Key多套Token管理
统一UTC毫秒时间戳时区转换脚本

接口文档和字段映射关系在https://docs.tickdb.ai开源可查。需要更自动化的行情查询,还可以走MCP工具链(https://mcp.tickdb.ai),把行情封装成Agent可调用的服务。


四、你的复权因子用对方向了吗?

一个同行问过我:“回测年化22%,实盘只剩6%,是不是过拟合?”

我让他把DataFeed代码截图发过来。第42行,信号计算用的是前复权close,资金曲线用的却是后复权净值。

这不是过拟合,这是用两根不同的尺子量了同一段行情。

你上一次检查自己回测里的复权坐标,是什么时候?欢迎在评论区聊聊你踩过的数据接入的坑。


📡 数据由 TickDB.ai 提供

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

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

免费领取 API Key查看 API 文档

相关文章