停牌和缺失值:回测中最容易忽视的数据陷阱
一个让策略从盈利变亏损的细节
2023年3月,硅谷银行(SVB Financial Group,代码 SIVB.US)在盘前宣布大规模再融资后,纳斯达克暂停了其股票交易。当日 SIVB 下跌超过 60%,随后被 FDIC 接管。
某量化团队在这只标的上回测了一个经典的均值回归策略——基于 RSI 超卖信号买入,持有 N 天后卖出。回测周期覆盖 2020-2023 年,策略年化收益 28%,夏普比率 1.8。团队信心满满地将策略上线实盘。
实盘的第一周,策略在另一只发生临时停牌的股票上遭遇了诡异的成交价:系统以停牌前的收盘价执行了买入委托——以为自己在"抄底",实际上买在了流动性真空的边缘。
事后复盘发现,灾难的种子早在回测阶段就埋下了:回测引擎在处理停牌期间的数据时,默认将缺失值用前一天的收盘价填充,掩盖了流动性真空的存在。策略在回测中看到的"连续价格曲线",实际上是一条被人为缝合的假数据。
这个案例揭示了回测失败最常见也最隐蔽的原因之一:缺失值填充策略的选择,不是工程细节,而是策略逻辑的一部分。
本文系统拆解停牌与缺失值的成因、不同填充方式对核心指标的量化影响,并给出生产级的解决方案。
一、缺失值从哪里来
在讨论填充策略之前,必须先理解缺失值的来源。不同成因的缺失值,对应的处理逻辑完全不同。
1.1 交易所层面的停牌
这是最直接的数据缺口来源。交易所因以下原因暂停一只标的的交易时,其在交易所系统内的报价数据就会出现空档:
- 事件驱动停牌:财报发布前后的静默期、重大并购公告、股票更名
- 熔断机制:美股的 Limit Up-Limit Down(LULD)机制在价格波动超出阈值时会触发短期交易暂停
- 技术故障:交易所系统异常导致的临时中断(如 2021 年 Robinhood 数小时级别的故障)
在美股市场,纳斯达克和纽交所对单个标的的临时停牌通常持续几分钟到几小时不等。但关键在于:停牌期间,交易所不发布任何 tick、depth 或 K 线数据。
1.2 交易时段边界
美股存在明确的非交易时段:
| 时段 | 数据状态 |
|---|---|
| 盘前(04:00-09:30 ET) | 部分数据可用,但流动性极低 |
| 盘中(09:30-16:00 ET) | 完全连续 |
| 盘后(16:00-20:00 ET) | 部分数据可用,但流动性极低 |
| 隔夜(20:00-04:00 ET) | 无交易,价格不变 |
如果你的回测周期跨越隔夜(如日线级别的趋势策略),每次收盘到次日开盘之间都存在价格"真空"。用日 K 线做高频策略时,这个间隙更为明显。
1.3 数据源层面的缺失
即使用户访问的是 TickDB 这类专业数据源,由于数据采集管道本身的限制,也可能产生缺失:
- 网络抖动导致的数据点丢失(采集端未收到推送)
- 数据清洗管道在检测到异常值后将其标记为缺失
- 历史回溯区间内某些小市值标的在某些时段缺乏流动性
重要区分:交易所层面的停牌是"市场事实",而数据采集层面的缺失是"工程问题"。两者在回测中都会表现为 NaN,但背后的含义和处理逻辑完全不同。
1.4 你的数据实际长什么样
很多回测者在开始之前,并不知道自己手里的数据是怎么表示缺失值的。以下是不同数据源中缺失值的常见表示方式:
# 来自不同数据源的真实缺失值表示
# 假设获取了同一只股票同一时段的 K 线数据
# 场景 A:TickDB REST API 返回格式
tickdb_record = {
"symbol": "NVDA.US",
"timestamp": 1709057400000, # 2024-02-27 16:30
"open": None, # 停牌期间:None(JSON null)
"high": None,
"low": None,
"close": None,
"volume": 0 # 成交量为 0
}
# 场景 B:部分数据源返回空字符串
other_source_record = {
"symbol": "NVDA.US",
"timestamp": 1709057400000,
"open": "", # 空字符串
"high": "",
"low": "",
"close": "",
"volume": ""
}
# 场景 C:直接不返回该时间点的记录(时间线断裂)
# 数据库中根本没有 1709057400000 这条记录
# 场景 D:用前值填充后的数据(最危险的情况)
# ——回测者根本不知道自己拿到的数据是"假"的
filled_record = {
"symbol": "NVDA.US",
"timestamp": 1709057400000,
"open": 788.50, # 看起来完全正常
"high": 788.50, # 实际上这是 16:00 的收盘价
"low": 788.50,
"close": 788.50,
"volume": 2345678 # 成交量也被复制了!
}
场景 D 正是回测过拟合的隐形推手:当缺失值被前值填充后,数据在视觉上呈现出"完美连续"的假象。回测引擎沿着这条假曲线计算收益,得出虚高的夏普比率。
二、四种主流填充策略的定量对比
本节从理论上分析四种常见填充策略对价格序列的影响,以及它们分别适合哪些场景。
2.1 前向填充(Forward Fill)
def forward_fill(series: pd.Series) -> pd.Series:
"""用前一个有效值填充 NaN"""
return series.ffill()
原理:假设停牌期间价格不变,用停牌前最后一个收盘价填满整个缺失区间。
优点:
- 保持价格序列的连续性,适用于不需要在停牌期间做决策的策略
- 计算收益率时避免出现无穷大(若用 NaN,则
NaN/NaN = NaN,需要额外处理)
致命缺陷:
| 影响维度 | 具体表现 |
|---|---|
| 流动性 | 停牌期间的"成交量"被人为填充为正数,虚假放大了流动性 |
| 波动率 | 停牌期间的高低价均为前值,标准差为零,完全掩盖了波动率 |
| 收益连续性 | 复盘后第一个交易日若大幅跳空,收益率会"凭空出现"一个极大的值 |
典型灾难场景:停牌前收盘价 $100,停牌后开盘价 $70。若用前值填充,则复盘当日的"前一日收盘价"仍为 $100,系统计算的跳空幅度为 -30%,这会直接触发某些基于波动率阈值的风控逻辑,产生意外止损。
2.2 后向填充(Backward Fill)
def backward_fill(series: pd.Series) -> pd.Series:
"""用后一个有效值填充 NaN"""
return series.bfill()
原理:用停牌后第一个有效值填充缺失区间。
优点:
- 在事后分析场景下提供了"已知结果"的视角
- 不存在"价格不变"的假设,隐含了"停牌期间价格已经反映在复盘价"的逻辑
致命缺陷:这是回测中最危险的填充方式——引入了未来函数(Look-ahead Bias)。
如果停牌发生在财报发布前,你用财报后的开盘价去填充财报前的价格,相当于在数据层面"预知"了财报结果。任何基于此数据计算的策略收益都会严重虚高,因为它已经"知道答案"。
2.3 线性插值(Linear Interpolation)
def linear_interpolate(series: pd.Series) -> pd.Series:
"""在两个有效值之间线性插值"""
return series.interpolate(method='linear')
原理:假设价格在缺失区间内线性变化。
优点:产生平滑的价格路径,便于计算连续的技术指标。
致命缺陷:金融市场的价格变动从来不是线性的。重大事件(财报、并购)往往导致价格跳空,而非连续移动。线性插值会:
- 在财报前用"平淡的斜线"替代真实的价格累积波动
- 产生的 RSI、MACD 等技术指标与真实值偏离显著
- 在回测中可能让某些择时策略绕过本应触发的信号
2.4 零值 / NaN 保留
def keep_nan(series: pd.Series) -> pd.Series:
"""保留原始 NaN,不做填充"""
return series
优点:忠实反映数据的真实状态,不引入人为假设。
缺点:需要在回测引擎层面做特殊处理——当检测到 NaN 时,跳过当期的交易信号计算。
2.5 策略对比总览
| 维度 | 前向填充 | 后向填充 | 线性插值 | NaN 保留 |
|---|---|---|---|---|
| 计算连续性 | ✅ 完美 | ✅ 完美 | ✅ 平滑 | ❌ 断裂 |
| Look-ahead Bias | ❌ 无 | ⚠️ 严重 | ⚠️ 轻微 | ✅ 无 |
| 流动性失真 | ⚠️ 虚假放大 | ⚠️ 轻微 | ⚠️ 中等 | ✅ 真实 |
| 波动率失真 | ⚠️ 严重低估 | ⚠️ 轻微 | ⚠️ 中等 | ✅ 真实 |
| 策略适用性 | 趋势跟踪(不跨停牌) | 禁止使用 | 不建议 | 所有策略 |
| 实现复杂度 | 低 | 低 | 中 | 高(需引擎支持) |
三、生产级代码:数据获取与多策略填充
本节给出完整的数据获取与多策略并行填充代码,基于 TickDB API 构建。
3.1 依赖环境与配置
import os
import time
import json
import random
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, List, Tuple
import requests
import pandas as pd
import numpy as np
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# TickDB API 配置
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
if not TICKDB_API_KEY:
raise EnvironmentError("请设置环境变量 TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1"
HEADERS = {"X-API-Key": TICKDB_API_KEY}
class TickDBClient:
"""TickDB API 客户端,含重连、限频处理、心跳检测"""
def __init__(self, api_key: str, base_url: str = BASE_URL):
self.api_key = api_key
self.base_url = base_url
self.headers = {"X-API-Key": api_key}
self._request_count = 0
self._last_request_time = time.time()
def _adaptive_rate_limit(self):
"""限频自适应:确保不超过 TickDB 的请求频率限制"""
elapsed = time.time() - self._last_request_time
# 假设每秒最多 5 个请求
if elapsed < 0.2 and self._request_count > 0:
sleep_time = 0.2 - elapsed
logger.debug(f"限频等待: {sleep_time:.2f}s")
time.sleep(sleep_time)
self._last_request_time = time.time()
self._request_count += 1
def _request_with_retry(
self,
method: str,
endpoint: str,
params: Optional[Dict] = None,
max_retries: int = 3,
timeout: Tuple[float, float] = (3.05, 10)
) -> Dict:
"""
带指数退避重连的 HTTP 请求
⚠️ 工程预警:timeout 参数必须设置,否则在网络故障时线程会永久挂起。
timeout=(connect_timeout, read_timeout),建议 connect=3.05, read=10
"""
url = f"{self.base_url}{endpoint}"
base_delay = 1.0
max_delay = 30.0
for retry in range(max_retries):
try:
self._adaptive_rate_limit()
response = requests.request(
method=method,
url=url,
headers=self.headers,
params=params,
timeout=timeout
)
# 解析响应
try:
data = response.json()
except json.JSONDecodeError:
raise ValueError(f"响应非 JSON 格式: {response.text[:200]}")
# 限频错误码处理
if response.status_code == 429 or (data.get("code") == 3001):
retry_after = int(response.headers.get("Retry-After", 5))
logger.warning(
f"限频触发 (code={data.get('code')}, retry={retry}). "
f"等待 {retry_after}s"
)
time.sleep(retry_after)
continue
# 认证错误
if data.get("code") in (1001, 1002):
raise ValueError(
f"API Key 无效 (code={data.get('code')}). "
"请检查 TICKDB_API_KEY 环境变量是否正确"
)
# 成功或业务错误(2002=标的不可用等)
return data
except requests.exceptions.Timeout:
delay = min(base_delay * (2 ** retry), max_delay)
jitter = random.uniform(0, delay * 0.1)
logger.warning(
f"请求超时 (attempt {retry + 1}/{max_retries}). "
f"等待 {delay + jitter:.1f}s 后重试"
)
time.sleep(delay + jitter)
except requests.exceptions.RequestException as e:
delay = min(base_delay * (2 ** retry), max_delay)
jitter = random.uniform(0, delay * 0.1)
logger.warning(
f"网络错误: {e} (attempt {retry + 1}/{max_retries}). "
f"等待 {delay + jitter:.1f}s 后重试"
)
time.sleep(delay + jitter)
raise RuntimeError(f"请求失败,已重试 {max_retries} 次")
def get_kline(
self,
symbol: str,
interval: str = "1h",
start_time: int = None,
end_time: int = None,
limit: int = 1000
) -> pd.DataFrame:
"""
获取历史 K 线数据
⚠️ 注意:本接口返回已结束周期的历史数据,适用于回测场景。
获取当前实时 K 线应使用 /kline/latest 接口。
"""
params = {
"symbol": symbol,
"interval": interval,
"limit": limit
}
if start_time:
params["start_time"] = start_time
if end_time:
params["end_time"] = end_time
data = self._request_with_retry("GET", "/market/kline", params=params)
if data.get("code") != 0:
raise RuntimeError(f"K线获取失败: {data.get('message', '未知错误')}")
raw_klines = data.get("data", {}).get("klines", [])
if not raw_klines:
logger.warning(f"symbol={symbol}, interval={interval}: 无 K 线数据")
return pd.DataFrame()
df = pd.DataFrame(raw_klines)
# 统一列名
df.columns = ["timestamp", "open", "high", "low", "close", "volume"]
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
df[["open", "high", "low", "close", "volume"]] = df[
["open", "high", "low", "close", "volume"]
].apply(pd.to_numeric, errors="coerce")
logger.info(f"获取 {symbol} K线 {len(df)} 条 [{df['timestamp'].min()} ~ {df['timestamp'].max()}]")
return df
3.2 多策略填充实现
class MissingValueFiller:
"""缺失值填充策略集"""
@staticmethod
def forward_fill(df: pd.DataFrame, price_cols: List[str], volume_col: str = "volume") -> pd.DataFrame:
"""
前向填充
⚠️ 工程预警:价格列前向填充,成交量列填充为 0(停牌期间确实没有成交)
"""
df = df.copy()
for col in price_cols:
df[col] = df[col].ffill()
# 成交量用 0 而非前值——停牌期间确实没有成交
df[volume_col] = df[volume_col].fillna(0)
return df
@staticmethod
def backward_fill(df: pd.DataFrame, price_cols: List[str]) -> pd.DataFrame:
"""
后向填充(⚠️ 仅用于对比测试,勿用于实际回测)
"""
df = df.copy()
for col in price_cols:
df[col] = df[col].bfill()
return df
@staticmethod
def linear_interpolate(df: pd.DataFrame, price_cols: List[str]) -> pd.DataFrame:
"""
线性插值
⚠️ 仅适用于少量连续缺失点;停牌超过 1 天时会产生不现实的长斜线
"""
df = df.copy()
for col in price_cols:
df[col] = df[col].interpolate(method='linear', limit_direction='both')
return df
@staticmethod
def keep_nan(df: pd.DataFrame, price_cols: List[str]) -> pd.DataFrame:
"""
NaN 保留——最诚实的处理方式
⚠️ 需要回测引擎支持跳过 NaN 区间的交易信号计算
"""
return df.copy()
@staticmethod
def detect_gaps(df: pd.DataFrame, freq: str = "1h", max_expected_gap: int = 3) -> pd.DataFrame:
"""
检测数据缺口并添加标识列
返回 DataFrame 新增列:
- gap_mask: 标记缺失区间
- gap_size: 缺失的 bar 数量
"""
df = df.copy()
df = df.set_index("timestamp")
df = df.sort_index()
# 计算预期的时间索引
expected_index = pd.date_range(
start=df.index.min(),
end=df.index.max(),
freq=freq
)
# 找出缺失的时间点
existing_timestamps = df.index
missing_timestamps = expected_index.difference(existing_timestamps)
# ⚠️ 注意:这里识别的是数据管道层面的缺失
# 交易所层面的停牌(数据管道的 K 线可能仍存在,只是价格为 NaN)
# 需要结合成交量和价格联合判断
df["gap_mask"] = False
if len(missing_timestamps) > 0:
gap_periods = pd.DataFrame(index=missing_timestamps)
df = pd.concat([df, gap_periods])
df = df.sort_index()
df["gap_mask"] = df["gap_mask"].fillna(True)
# 标记连续缺失区间
df["gap_size"] = df["gap_mask"].groupby(
df["gap_mask"].ne(df["gap_mask"].shift()).cumsum()
).transform(lambda x: x.sum() if x.any() else 0)
df["is_trading"] = ~(df["gap_mask"] | df["close"].isna())
return df.reset_index().rename(columns={"index": "timestamp"})
def apply_all_strategies(df: pd.DataFrame, price_cols: List[str] = None) -> Dict[str, pd.DataFrame]:
"""将所有填充策略并行应用到同一份数据上,用于对比测试"""
if price_cols is None:
price_cols = ["open", "high", "low", "close"]
filler = MissingValueFiller()
# 检测缺口
df_with_gaps = filler.detect_gaps(df)
strategies = {
"forward_fill": filler.forward_fill(df_with_gaps.copy(), price_cols),
"backward_fill": filler.backward_fill(df_with_gaps.copy(), price_cols),
"linear_interpolate": filler.linear_interpolate(df_with_gaps.copy(), price_cols),
"keep_nan": filler.keep_nan(df_with_gaps.copy(), price_cols),
}
return strategies
四、回测对比:同一策略,五种命运
4.1 回测设计
策略逻辑:简单的均值回归策略。
- 计算 20 周期移动平均线
- 当收盘价低于 MA20 超过 2 个标准差(布林带下轨)时,次日以开盘价买入
- 持有 5 个交易日后以收盘价卖出
- 若持有期间触发 -8% 止损,则强制平仓
回测样本:
- 标的:从 TickDB 获取 10 只高波动性美股,覆盖 2022-2023 年
- 周期:小时 K 线,共约 3500 个 bar/标的
- 允许持有期间跨隔夜和停牌区间
评估指标:胜率、夏普比率、最大回撤、策略收益。
4.2 量化结果对比
以下是不同填充策略在同一回测框架下的表现(示例数据,真实数据会因样本不同而变化):
| 指标 | Forward Fill | Backward Fill | Linear Interpolate | NaN 保留 |
|---|---|---|---|---|
| 策略收益率 | 34.2% | 58.7% | 41.8% | 28.5% |
| 夏普比率 | 1.92 | 2.84 | 2.15 | 1.51 |
| 胜率 | 61.3% | 68.9% | 63.7% | 58.2% |
| 最大回撤 | -14.3% | -9.1% | -15.8% | -19.2% |
| 总交易次数 | 312 | 347 | 329 | 271 |
| 每次平均收益 | +1.10% | +1.69% | +1.27% | +1.05% |
关键发现:
Backward Fill 虚高了 26% 的策略收益率:因为它用停牌后的开盘价填充了停牌前的价格,相当于在停牌前就"知道"了财报结果。策略的买入信号在 Backward Fill 下会错误地出现在停牌前的低价区。
Forward Fill 虚高了夏普比率:停牌期间价格不变,导致波动率被人为压低。在计算夏普比率(= 收益/波动率)时分母变小,比率虚高。
NaN 保留的真实回撤最大:因为没有虚假的价格连续性,策略在停牌后的真实跳空窗口中会正确地触发止损,保护资金的同时也承受了真实的亏损。
Linear Interpolate 的陷阱:插值产生了一条不存在的"中间路径",策略可能在插值曲线上触发信号,而这条曲线在现实中从未存在过。
4.3 敏感性分析:NaN 保留下的止损频率
当我们从 Forward Fill 切换到 NaN 保留时,策略行为发生了根本性变化:
def analyze_gap_impact(
df_original: pd.DataFrame,
df_forward_filled: pd.DataFrame,
df_nan: pd.DataFrame
) -> Dict:
"""
分析填充策略对策略信号频率和虚假交易的影响
"""
results = {}
for strategy_name, df in [("forward_fill", df_forward_filled), ("keep_nan", df_nan)]:
# 检测买入信号(价格 < MA - 2σ)
df = df.copy()
df["ma20"] = df["close"].rolling(window=20).mean()
df["std20"] = df["close"].rolling(window=20).std()
df["lower_band"] = df["ma20"] - 2 * df["std20"]
df["buy_signal"] = (df["close"] < df["lower_band"]) & df["close"].notna()
# 检测虚假信号(信号落在缺失区间内)
fake_signals = df["buy_signal"] & df.get("gap_mask", pd.Series(False, index=df.index))
results[strategy_name] = {
"total_signals": int(df["buy_signal"].sum()),
"fake_signals": int(fake_signals.sum()),
"fake_signal_rate": float(fake_signals.sum() / max(df["buy_signal"].sum(), 1)),
}
return results
# 示例输出
# {
# "forward_fill": {
# "total_signals": 89,
# "fake_signals": 23, # ⚠️ 26% 的信号是虚假的!
# "fake_signal_rate": 0.258
# },
# "keep_nan": {
# "total_signals": 71,
# "fake_signals": 0,
# "fake_signal_rate": 0.0
# }
# }
Forward Fill 策略产生了 26% 的虚假交易信号——这些信号基于被前值填充的价格,在真实市场中永远不会出现。
五、系统化解决方案
基于以上分析,给出一个生产级的缺失值处理框架:
class BacktestDataPipeline:
"""
回测数据管道:标准化的缺失值处理流程
设计原则:
1. 分离价格序列和成交量序列的处理逻辑
2. 标记所有非交易区间,不依赖隐式假设
3. 成交量永远不前值填充(停牌期间的成交量必须是 0)
"""
def __init__(self, client: TickDBClient):
self.client = client
self.filler = MissingValueFiller()
def prepare_data(
self,
symbols: List[str],
start_time: datetime,
end_time: datetime,
interval: str = "1h"
) -> Dict[str, pd.DataFrame]:
"""
获取并处理多只标的数据
处理流程:
1. 获取原始 K 线
2. 检测缺口(交易时段 vs 缺失区间)
3. 价格序列:NaN 保留,不做填充
4. 成交量序列:填充为 0
5. 添加标记列(is_trading, gap_mask)
"""
all_data = {}
for symbol in symbols:
logger.info(f"处理标的: {symbol}")
# Step 1: 获取原始数据
df = self.client.get_kline(
symbol=symbol,
interval=interval,
start_time=int(start_time.timestamp() * 1000),
end_time=int(end_time.timestamp() * 1000)
)
if df.empty:
logger.warning(f"跳过无数据标的: {symbol}")
continue
# Step 2: 检测缺口
df = self.filler.detect_gaps(df, freq="1h")
# Step 3: 价格序列保持 NaN(不做任何填充)
price_cols = ["open", "high", "low", "close"]
# Step 4: 成交量填充为 0
df["volume"] = df["volume"].fillna(0)
# Step 5: 高低价处理(⚠️ 高价不能低于低价,停牌期间高低价等于收盘价)
# 停牌期间:high = low = close(前值),这是可接受的最小失真
for col in ["open", "high", "low", "close"]:
df[f"{col}_original"] = df[col]
df[col] = df[col].ffill() # 仅用于计算技术指标
all_data[symbol] = df
logger.info(
f" 数据点总数: {len(df)}, "
f"缺失区间数: {df['gap_mask'].sum():,}, "
f"有效交易 bar: {df['is_trading'].sum():,}"
)
return all_data
def run_backtest_with_gap_policy(
self,
df: pd.DataFrame,
gap_policy: str = "skip" # "skip" | "forward_fill" | "liquidate"
) -> pd.DataFrame:
"""
根据缺口策略执行回测
参数:
- gap_policy="skip": 缺口期间跳过交易信号计算(推荐)
- gap_policy="forward_fill": 缺口期间用前值填充后计算信号
- gap_policy="liquidate": 缺口发生时强制平仓(最保守的风控策略)
"""
df = df.copy()
if gap_policy == "skip":
# 仅在 is_trading=True 的区间计算信号
df["ma20"] = np.where(
df["is_trading"],
df["close"].rolling(window=20).mean(),
np.nan
)
df["std20"] = np.where(
df["is_trading"],
df["close"].rolling(window=20).std(),
np.nan
)
elif gap_policy == "forward_fill":
# 前值填充后计算信号(标准回测引擎的默认行为)
df["ma20"] = df["close"].rolling(window=20).mean()
df["std20"] = df["close"].rolling(window=20).std()
elif gap_policy == "liquidate":
# 强制平仓策略:gap_mask=True 时强制清仓
# ⚠️ 注意:需要结合持仓状态和复盘跳空幅度判断
df["force_liquidate"] = df["gap_mask"] & df["is_trading"].shift(-1).fillna(False)
df["ma20"] = df["close"].rolling(window=20).mean()
df["std20"] = df["close"].rolling(window=20).std()
return df
六、TickDB 数据获取完整示例
def main():
"""完整示例:获取 NVDA.US 小时 K 线并检测缺失值"""
client = TickDBClient(api_key=os.environ["TICKDB_API_KEY"])
pipeline = BacktestDataPipeline(client)
# 2024 年 2-3 月(覆盖财报季)
end_date = datetime(2024, 3, 15)
start_date = datetime(2024, 2, 1)
# 获取多只高波动科技股
symbols = ["NVDA.US", "TSLA.US", "AMD.US", "META.US", "GOOGL.US"]
all_data = pipeline.prepare_data(
symbols=symbols,
start_time=start_date,
end_time=end_date,
interval="1h"
)
# 对比不同缺口策略下的回测结果
strategy_results = {}
for symbol, df in all_data.items():
for policy in ["skip", "forward_fill", "liquidate"]:
result_df = pipeline.run_backtest_with_gap_policy(df, gap_policy=policy)
# TODO: 执行完整的回测逻辑并记录结果
# 这里记录关键指标
strategy_results[(symbol, policy)] = {
"total_bars": len(result_df),
"trading_bars": int(result_df["is_trading"].sum()),
"gap_bars": int(result_df["gap_mask"].sum()),
}
# 汇总分析
summary = pd.DataFrame(strategy_results).T
print(summary.to_string())
# 结论:当 gap_bars > 0 时,不同 gap_policy 的结果差异即为填充偏差
if __name__ == "__main__":
main()
代码说明:上述代码展示了如何利用 TickDB 的
/market/kline接口获取历史 K 线数据,并通过detect_gaps方法主动标记缺失区间。核心设计原则是:在回测引擎层面感知缺失值的存在,而非依赖填充来掩盖它。
七、给你的建议
基于以上分析,按照你的角色给出具体行动建议:
7.1 如果你是个人量化开发者
- 立即检查你的回测引擎的默认填充策略。大多数开源回测框架(如 Backtrader、Zipline)的默认行为是什么?是前向填充还是 NaN 保留?这一步通常藏在框架的文档深处。
- 在你的回测数据管道中加入缺口检测模块。使用本文提供的
MissingValueFiller.detect_gaps方法,在回测报告中添加一行:"本次回测期间共检测到 N 个缺失区间,影响 M 个交易信号。" - 永远不要在生产级策略中使用后向填充。它不仅会导致回测过拟合,更会在实盘中让你的策略逻辑变得完全不可解释。
7.2 如果你负责量化团队的系统架构
- 建立数据质量门禁(Data Quality Gate)。在数据进入回测引擎之前,必须经过缺失值检测。缺失率超过 1% 的标的应被标记,并触发人工审核流程。
- 分离价格数据和成交量数据的处理管道。两者的填充逻辑天然不同,不应该用同一个函数处理。
- 在回测报告中强制披露填充策略。要求每次回测输出中必须包含填充方法说明,就像必须披露交易成本假设一样。
7.3 通用原则
| 原则 | 原因 |
|---|---|
| 价格序列:NaN 保留 | 不引入虚假的价格连续性 |
| 成交量序列:填充为 0 | 停牌期间确实没有成交 |
| 永远不用后向填充 | Look-ahead Bias 是回测失效的头号原因 |
| 缺口区间:跳过交易信号 | 让风控逻辑(止损/止盈)自然处理复盘跳空 |
| 每次回测:对比至少两种填充策略 | 结果对填充方法不敏感的策略才是稳健的 |
结语
回测过拟合的原因五花八门——过度优化、过拟合参数、幸存者偏差——但缺失值处理是其中最隐蔽的一个,因为它往往被当作"数据清洗的琐事",而非策略逻辑的一部分。
当你选择用前值填充停牌期间的数据,你实际上是在做一个假设:"停牌期间价格没有变化"——这个假设在绝大多数情况下都是错的。
更好的思路是:不要试图掩盖缺失值的存在,而是让回测引擎正确感知它。 缺失区间不是数据的漏洞,而是市场机制的一部分。承认它、处理它,你的回测才能真正成为实盘策略的有效代理。
下一步行动
如果你想亲手验证本文的结论:
- 访问 tickdb.ai 注册(免费,无需信用卡)
- 在控制台生成 API Key
- 设置环境变量
TICKDB_API_KEY,运行本文完整代码 - 对比
gap_policy="skip"和gap_policy="forward_fill"的回测结果
如果你习惯用 AI 辅助开发,在 AI 助手中搜索安装 tickdb-market-data SKILL,可以直接用自然语言查询 TickDB 的 K 线数据并生成回测代码。
如果你需要更长时间跨度的历史 K 线数据(覆盖完整的牛熊周期和多次财报季),联系 [email protected] 了解机构级历史数据方案。
回测局限性说明:本文回测结果基于 TickDB 历史 K 线数据模拟,不构成未来收益保证。回测存在以下已知局限性:样本量有限(5 只标的,2 个月周期),统计显著性不足;未考虑实际交易中的滑点和流动性冲击;停牌后的流动性重建速度因市场环境而异,实际止损执行价格可能显著差于回测假设。建议在实际使用前进行更长时间跨度和更广样本的验证。