A 股全市场日频选股回测记录:从数据接入到复权对齐的工程细节

摘要:本文记录了用全量A股做日频选股回测的完整过程,重点分析复权因子方向选择对回测绩效的影响,以及在 vnpy 中封装统一 DataFeed 的工程实践。

一、策略写了三天,数据接了五天

全量 A 股做日频选股回测,策略代码写了 3 天,数据接入修了 5 天。

关键问题不是代码量,是复权因子方向用反了------前复权价格算信号,后复权净值算绩效,敞口漂了 0.3%,半年累积偏离 17%。回测年化 22%,实盘只剩 6%,不是过拟合,是两把不同的尺子量了同一段行情。

全市场回测最被低估的工程债:数据中间件的维护成本,远高于策略开发本身。下面按数据接入、复权对齐、vnpy 对接三个环节逐一记录。

二、复权因子对齐:为什么后复权是唯一解

这是全市场回测里最容易踩碎的坑。

股票在分红、送股、拆股后,价格会产生断崖式跳变。复权因子通过乘法链将这些断点衔接起来,让价格序列连续。

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

A 股 T+1 制度下,选股信号在今天收盘后生成,明天开盘才能交易。前复权会不断改变历史价格------11 月 10 日生成的信号,到了 11 月 20 日除权后,前复权价格一变动,历史信号对应的价格就不是当时看到的那一个了。后复权是固定的:上市第一天的价格钉死不动,后续只加复权因子。

使用时,把 K 线数据灌进回测引擎前,对每一根 bar 做复权计算。kline 接口返回原始价格(close),复权因子需用户自行维护后与 kline 数据对齐。注意别用 last_price 做计算------那是 ticker 快照字段,不带复权因子。

实际应用中常见的两个坑:一是不同数据源拼接时基准日不统一,导致复权因子链在拼接点断裂;二是 ticker 和 kline 字段混淆,ticker 用 high_24h/volume_24h,kline 用 high/volume,日频 K 线会全部偏差。优化方式是单独拉取复权因子序列缓存,向量化计算------用 kline 的 close 做基础价,配合本地缓存的因子列一次性做矩阵乘法,全量品种的复权对齐可以快速跑完。

三、vnpy DataFeed 对接

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

步骤 操作 关键细节
第一步 继承 BaseDataFeed,重写 query_bar 按时间范围返回 BarData 列表
第二步 确保品种代码后缀正确 上海 .SH(如 600519.SH 贵州茅台),深圳 .SZ(如 300750.SZ 宁德时代)

日频回测中,今日生成的信号只能在下一交易日执行(A 股 T+1),引擎里的信号日期和交易日期必须偏移一天。

四、代码实录

三段代码可以直接跑,依赖 requestssqlite3numpyvnpy

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

python 复制代码
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/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 返回该周期收盘价
python 复制代码
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
                # kline 返回原始价格(close),复权因子需用户自行维护后对齐
                # 接入前先 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,服务端给什么等什么,不给才退避指数自算。

Step 3:封装 vnpy DataFeed

python 复制代码
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;不同接口取到的列名可能带空格 每个源写一个 parser
品种代码规范混乱 不同板块的前缀要求各不相同 手工维护映射表
时区不统一 UTC / 北京时间 / 交易所时间混用 排错耗时
复权因子缺失或基准不一致 拼接收据复权链断裂 回测绩效系统性偏移

TickDB 在这个场景下解决的是工程层面的收敛问题:一个 REST + WebSocket 长连接覆盖 A 股,字段命名、鉴权方式、UTC 毫秒时间戳保持一致。

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

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

一个朋友问:"回测年化 22%,实盘只剩 6%,是不是过拟合?"

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

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

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

📡 数据由 TickDB.ai 提供

本文不构成任何投资建议。文中 A 股品种代码 600519.SH300750.SZ688981.SH 仅为代码示例。API 端点与字段定义以 TickDB 官方文档为准,可搜索查阅。

相关推荐
Walter先生5 天前
MCP行情数据接入配置踩坑全记录:从Claude Code到Zed八大客户端适配实战
后端·websocket·架构·实时行情数据源
Walter先生10 天前
Python 行情数据清洗实战:Z-Score、MAD 与分位数过滤的异常值检测
后端·websocket·架构·实时行情数据源·美股行情api
Walter先生17 天前
Python 获取美股盘前盘后数据:yfinance 的坑与解法
websocket·实时行情数据源
Walter先生1 个月前
实时行情系统设计:从协议选择到高可用架构,再到数据源选型
后端·架构·实时行情数据源