过拟合:当策略背下了答案而不是学会了规律

一位曾在华尔街工作过的量化研究员告诉我他最难忘的经历:团队花了三个月开发了一套"完美"的选股模型,在回测中夏普比率达到 4.2,收益曲线像一条笔直上升的斜线。他们信心满满地开了模拟盘,六个月后,模型亏掉了 30% 的模拟资金。

这不是能力问题,是统计学的基本陷阱:他们优化了一个不存在的规律

这个故事几乎在每个量化团队都发生过类似版本。它指向一个核心问题:当我们说一个策略"有效"时,我们到底在说什么?回测中的高收益是能力的证明,还是仅仅是统计噪声的馈赠?

理解过拟合,是所有量化交易者的成年礼。


一、过拟合的直观本质:背答案与学规律

1.1 一个思想实验

想象你在准备一场数学考试。有两种复习方式:

方式 A:把过去十年的真题全部背下来,包括每道题的答案。

方式 B:理解每种题型的解题思路,掌握背后的数学原理。

考卷出现往年原题,方式 A 会得到满分,方式 B 也会得到高分。但如果出题人换了一种问法——表面上不同,但考察的数学原理相同——方式 A 会当场崩溃,方式 B 依然能稳定发挥。

过拟合,就是策略选择了方式 A。

1.2 曲线拟合的经典演示

最清晰的过拟合可视化是多项式拟合。假设真实数据由以下函数生成:

$$y = \sin(x) + \epsilon, \quad \epsilon \sim N(0, 0.3)$$

我们用不同阶数的多项式去拟合:

真实函数: y = sin(x)
噪声: 添加随机扰动

拟合模型:
- 阶数 = 3: 欠拟合,只抓住了大趋势
- 阶数 = 10: 刚好,捕捉到规律
- 阶数 = 50: 过拟合,连噪声都拟合进去了

当多项式阶数过高时,模型不仅拟合了真实的 $\sin(x)$ 曲线,还拟合了随机噪声的波动。在训练数据上,误差趋近于零;在任何新的测试点上,误差会显著增大。

这就是过拟合的本质:策略将训练数据中的随机波动(噪声)误认为是可以重复的规律

1.3 过拟合的数学定义

从统计学习理论的角度,过拟合发生在模型的复杂度超过了数据所蕴含的信息量时。VC 维度和方差-偏差权衡给出了更精确的描述:

偏差-方差分解

$$MSE = \text{Bias}^2 + \text{Variance} + \text{Irreducible Error}$$

模型状态 偏差 (Bias) 方差 (Variance) 本质问题
欠拟合 模型太简单,捕捉不到真实规律
合适 捕捉到规律,忽略噪声
过拟合 极高 捕捉了噪声,方差失控

过拟合的标志是高方差——模型对训练数据的微小变化极度敏感。在量化策略中,这表现为:参数调整 1%,回测收益可能波动 20%。


二、过拟合的典型特征与诊断信号

2.1 量化策略中的过拟合症状

在实际量化场景中,以下六个信号出现两个以上,高度提示过拟合:

信号一:夏普比率"过于完美"

样本内夏普超过 3.0,且没有任何明显的回撤期。在真实的、不受约束的市场中,这种"完美"几乎不存在。

信号二:参数尖峰

将某一参数从最优值移动 5%,收益下降超过 30%。真实的市场规律不会对参数如此敏感。

信号三:因子数量与收益不成比例

策略使用了 50+ 个因子,但贡献主要来自少数几个;其余因子只是在"填充自由度"。

信号四:交易频率异常

日内交易次数远超同类策略。交易越频繁,样本内碰巧捕捉到的随机模式越多。

信号五:样本外性能断崖式下跌

样本内年化收益 35%,样本外变成 -8%。差距越大,过拟合程度越深。

信号六:策略只在特定品种/时段有效

当扩展到更多品种或更长时间段,性能显著衰减——说明捕捉到的是局部噪声而非普遍规律。

2.2 诊断矩阵

症状 诊断指向 缓解方法
夏普 > 3.0 且无回撤 极度疑似过拟合 增加约束,强制留出验证集
参数最优值附近曲线陡峭 模型自由度太高 降低参数维度或简化因子
样本内外差距 > 50% 泛化能力差 增加样本量或使用交叉验证
交易频率异常高 可能拟合了短期噪声 设置最小持仓期约束

三、样本外验证:从方法论到工程实现

3.1 为什么样本外验证是必须的

传统回测的致命缺陷在于:它用同一份数据同时训练和测试模型。这就像用往年的高考真题训练学生,然后用同一份试卷评估学习效果——高分只能说明"背答案能力强"。

样本外验证的核心思想是:用模型从未见过的数据来测试它的真实能力

3.2 三种基础验证方法

留出法(Hold-out)

最简单直接:将数据分为训练集和测试集,通常是 7:3 或 8:2。

优点:实现简单,计算成本低
缺点:数据利用效率低,若数据量不足,划分随机性可能导致测试集不具代表性

时序留出法(Walk-Forward)

针对时间序列数据(如金融数据)专门设计:使用前 N 年训练,在第 N+1 年测试,然后滚动窗口。

优点:尊重时间顺序,避免未来信息泄露
缺点:训练数据总是比可用数据少

K 折交叉验证(K-Fold Cross-Validation)

将数据分为 K 份,轮流使用 K-1 份训练、1 份测试,最终取 K 次结果的平均。

优点:数据利用效率高,估计方差更稳定
缺点:时间序列数据存在时间依赖,直接使用会低估真实误差

3.3 金融数据的特殊处理:滚动窗口 + 扩展窗口

对于时序数据,标准 K 折存在信息泄露风险。正确的做法是使用滚动窗口或扩展窗口:

滚动窗口(Rolling Window):
┌──────────────┐ ┌────┐
│    训练集1     │ │测试│
└──────────────┘ └────┘
     ┌──────────────┐ ┌────┐
     │    训练集2     │ │测试│
     └──────────────┘ └────┘
          ┌──────────────┐ ┌────┐
          │    训练集3     │ │测试│
          └──────────────┘ └────┘

扩展窗口(Expanding Window):
┌────────────┐ ┌────┐
│   训练集1   │ │测试│
└────────────┘ └────┘
┌──────────────┐ ┌────┐
│   训练集1+2   │ │测试│
└──────────────┘ └────┘
┌──────────────────┐ ┌────┐
│   训练集1+2+3     │ │测试│
└──────────────────┘ └────┘

滚动窗口适合数据量充足、担心市场 Regime 变化的情况;扩展窗口适合数据稀缺、希望充分利用所有历史信息的场景。


四、AIC/BIC:模型选择的概率准则

4.1 从拟合优度到泛化能力

$R^2$(决定系数)衡量的是模型对训练数据的拟合程度:

$$R^2 = 1 - \frac{SS_{res}}{SS_{tot}} = 1 - \frac{\sum(y_i - \hat{y}_i)^2}{\sum(y_i - \bar{y})^2}$$

但 $R^2$ 有一个根本问题:它只会随着参数增加而增加,永不衰减。即使模型只是在拟合噪声,$R^2$ 也会上升。

AIC(赤池信息准则)和 BIC(贝叶斯信息准则)引入了对模型复杂度的惩罚:

$$AIC = -2\ln(\hat{L}) + 2k$$

$$BIC = -2\ln(\hat{L}) + k\ln(n)$$

其中:

  • $\hat{L}$ 是模型的最大似然估计值
  • $k$ 是参数数量
  • $n$ 是样本量

核心逻辑:当模型增加一个参数时,拟合优度的提升必须足够大,才能抵消复杂度增加的惩罚。AIC/BIC 值越小,模型越好。

4.2 AIC 与 BIC 的关键差异

维度 AIC BIC
复杂度惩罚 $2k$ $k\ln(n)$
样本量影响 不考虑 考虑(随 $n$ 增大而加重)
倾向 倾向于更复杂的模型 倾向于更简单的模型
适用场景 预测导向的任务 模型解释性重要的任务
准则 渐近等价于留一交叉验证 渐近等价于贝叶斯模型选择

实战建议:对于量化策略的回测,由于样本量通常有限(金融数据的高信噪比限制了有效样本量),BIC 通常更保守、更可靠。如果 BIC 选择了 10 参数模型而 AIC 选择了 20 参数模型,建议信任 BIC 的结果。

4.3 实际阈值参考

在量化策略评估中,以下阈值可作为过拟合的粗略判断标准:

指标 健康范围 警告区间 危险区间
样本内/样本外夏普比 0.8 - 1.2 1.3 - 2.0 > 2.0
参数敏感性 (1%→收益变化) < 5% 5% - 15% > 15%
自由参数数 / 年交易日 < 0.1 0.1 - 0.3 > 0.3

第三条最为关键:每 10 个交易日应该对应至少 1 个有效数据点来支撑一个自由参数。如果你的策略有 50 个自由参数,但只有 1000 个交易日的数据,参数数量远超数据支撑能力。


五、生产级验证框架实现

以下代码实现了一套完整的滚动窗口交叉验证框架,包含 AIC/BIC 计算和过拟合诊断指标:

"""
量化策略过拟合诊断框架
滚动窗口交叉验证 + AIC/BIC + 诊断指标
"""

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import List, Tuple, Optional, Callable
from scipy import stats
from itertools import product
import warnings
import logging

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)


@dataclass
class FoldResult:
    """单次折叠验证结果"""
    fold_index: int
    train_start: int
    train_end: int
    test_start: int
    test_end: int
    train_return: float
    test_return: float
    train_sharpe: float
    test_sharpe: float
    train_max_drawdown: float
    test_max_drawdown: float
    n_params: int
    aic: float
    bic: float


@dataclass
class OverfittingDiagnostics:
    """过拟合诊断报告"""
    sharpe_ratio_in_sample: float
    sharpe_ratio_out_sample: float
    sharpe_ratio_degradation: float  # 样本外下降比例
    avg_param_sensitivity: float  # 参数敏感性
    aic: float
    bic: float
    best_window_size: int
    is_overfitting: bool
    confidence_level: str  # 高/中/低
    recommendations: List[str] = field(default_factory=list)


class RollingWindowValidator:
    """
    滚动窗口交叉验证器
    
    支持:
    - 滚动窗口和扩展窗口两种模式
    - 自动计算 AIC/BIC
    - 参数敏感性分析
    - 过拟合诊断
    """
    
    def __init__(
        self,
        returns: pd.Series,
        n_windows: int = 5,
        test_ratio: float = 0.2,
        window_type: str = "rolling",
        min_periods_per_param: int = 10
    ):
        """
        Args:
            returns: 收益率序列
            n_windows: 验证窗口数量
            test_ratio: 测试集比例
            window_type: "rolling" 或 "expanding"
            min_periods_per_param: 每个参数需要的最小数据点数
        """
        if len(returns) < 100:
            raise ValueError("样本量至少需要 100 个数据点")
        
        self.returns = returns.values
        self.n_windows = n_windows
        self.test_ratio = test_ratio
        self.window_type = window_type
        self.min_periods_per_param = min_periods_per_param
        self._results: List[FoldResult] = []
    
    def _calculate_sharpe(self, returns: np.ndarray, periods_per_year: int = 252) -> float:
        """计算年化夏普比率"""
        if len(returns) < 2 or np.std(returns) == 0:
            return 0.0
        return (np.mean(returns) / np.std(returns)) * np.sqrt(periods_per_year)
    
    def _calculate_max_drawdown(self, returns: np.ndarray) -> float:
        """计算最大回撤"""
        if len(returns) < 2:
            return 0.0
        cumulative = np.cumprod(1 + returns)
        running_max = np.maximum.accumulate(cumulative)
        drawdown = (cumulative - running_max) / running_max
        return abs(np.min(drawdown))
    
    def _calculate_ic(self, predicted: np.ndarray, actual: np.ndarray) -> Tuple[float, float]:
        """
        计算信息系数 (IC) 和 Rank IC
        IC = correlation(predicted_returns, actual_returns)
        """
        if len(predicted) != len(actual) or len(predicted) < 10:
            return 0.0, 0.0
        # Pearson IC
        ic = np.corrcoef(predicted, actual)[0, 1]
        # Rank IC (Spearman)
        rank_ic, _ = stats.spearmanr(predicted, actual)
        return ic, rank_ic
    
    def _calculate_aic_bic(
        self,
        returns: np.ndarray,
        n_params: int,
        log_likelihood: Optional[float] = None
    ) -> Tuple[float, float]:
        """
        计算 AIC 和 BIC
        
        使用 log-likelihood 近似:
        LL ≈ -n/2 * log(RSS/n) - n/2 * log(2π) - n/2
        """
        n = len(returns)
        
        if log_likelihood is None:
            # 使用收益率计算残差平方和
            rss = np.sum(returns ** 2)
            if rss <= 0:
                rss = 1e-10
            log_likelihood = -n / 2 * np.log(rss / n) - n / 2 * np.log(2 * np.pi) - n / 2
        
        # AIC
        aic = 2 * n_params - 2 * log_likelihood
        
        # BIC
        bic = n_params * np.log(n) - 2 * log_likelihood
        
        return aic, bic
    
    def validate_strategy(
        self,
        strategy_func: Callable,
        param_grid: dict,
        baseline_returns: Optional[pd.Series] = None
    ) -> Tuple[dict, List[FoldResult], pd.DataFrame]:
        """
        执行滚动窗口验证
        
        Args:
            strategy_func: 策略函数,输入(returns, **params),返回预测收益率
            param_grid: 参数网格
            baseline_returns: 基准收益率(可选)
        
        Returns:
            (最优参数, 折叠结果列表, 诊断报告)
        """
        n = len(self.returns)
        test_size = int(n * self.test_ratio)
        train_size = n - test_size
        
        logger.info(f"开始验证: 总样本 {n}, 训练集 {train_size}, 测试集 {test_size}")
        logger.info(f"参数网格大小: {len(list(product(*param_grid.values())))}")
        
        # 生成参数组合
        param_names = list(param_grid.keys())
        param_combinations = list(product(*param_grid.values()))
        
        best_params = None
        best_avg_test_sharpe = -np.inf
        all_results = []
        
        for params in param_combinations:
            params_dict = dict(zip(param_names, params))
            fold_results = []
            
            for window_idx in range(self.n_windows):
                # 计算当前窗口的训练/测试区间
                if self.window_type == "rolling":
                    train_end = train_size + window_idx * (test_size // self.n_windows)
                else:  # expanding
                    train_end = train_size
                
                test_start = train_end
                test_end = min(test_start + test_size, n)
                
                if test_end - test_start < 20:  # 测试集太小,跳过
                    continue
                
                train_returns = self.returns[:train_end]
                test_returns = self.returns[test_start:test_end]
                
                try:
                    # 训练策略
                    train_predictions = strategy_func(train_returns, **params_dict)
                    
                    # 计算训练集指标
                    train_sharpe = self._calculate_sharpe(train_returns)
                    train_mdd = self._calculate_max_drawdown(train_returns)
                    
                    # 样本外预测和测试
                    test_predictions = strategy_func(test_returns, **params_dict)
                    test_sharpe = self._calculate_sharpe(test_returns)
                    test_mdd = self._calculate_max_drawdown(test_returns)
                    
                    # 计算 AIC/BIC
                    n_params = len(params_dict)
                    log_lik = -len(train_returns) / 2 * np.log(np.var(train_returns) + 1e-10)
                    aic, bic = self._calculate_aic_bic(train_returns, n_params, log_lik)
                    
                    fold_result = FoldResult(
                        fold_index=window_idx,
                        train_start=0,
                        train_end=train_end,
                        test_start=test_start,
                        test_end=test_end,
                        train_return=np.mean(train_returns) * 252,
                        test_return=np.mean(test_returns) * 252,
                        train_sharpe=train_sharpe,
                        test_sharpe=test_sharpe,
                        train_max_drawdown=train_mdd,
                        test_max_drawdown=test_mdd,
                        n_params=n_params,
                        aic=aic,
                        bic=bic
                    )
                    fold_results.append(fold_result)
                    
                except Exception as e:
                    logger.warning(f"参数 {params_dict} 窗口 {window_idx} 执行失败: {e}")
                    continue
            
            if fold_results:
                avg_test_sharpe = np.mean([r.test_sharpe for r in fold_results])
                all_results.append((params_dict, avg_test_sharpe, fold_results))
                
                if avg_test_sharpe > best_avg_test_sharpe:
                    best_avg_test_sharpe = avg_test_sharpe
                    best_params = params_dict
        
        self._results = [r for _, _, r in all_results]
        
        # 生成诊断报告
        diagnostics = self._generate_diagnostics(best_params, param_grid)
        
        logger.info(f"验证完成: 最优参数 {best_params}, 平均样本外夏普 {best_avg_test_sharpe:.2f}")
        
        return best_params, self._results, diagnostics
    
    def _generate_diagnostics(
        self,
        best_params: dict,
        param_grid: dict
    ) -> pd.DataFrame:
        """生成过拟合诊断报告"""
        if not self._results:
            return pd.DataFrame()
        
        # 收集最优参数对应的所有折叠结果
        best_fold_results = self._results
        
        if not best_fold_results:
            return pd.DataFrame()
        
        # 计算诊断指标
        in_sharpe = np.mean([r.train_sharpe for r in best_fold_results])
        out_sharpe = np.mean([r.test_sharpe for r in best_fold_results])
        sharpe_degradation = (in_sharpe - out_sharpe) / abs(in_sharpe) if in_sharpe != 0 else 0
        
        avg_aic = np.mean([r.aic for r in best_fold_results])
        avg_bic = np.mean([r.bic for r in best_fold_results])
        
        # 参数敏感性分析
        param_sensitivity = self._analyze_param_sensitivity(param_grid)
        
        # 构建诊断报告 DataFrame
        diagnostics_data = {
            '指标': ['样本内夏普', '样本外夏普', '夏普衰减率', '平均AIC', '平均BIC', '参数敏感性'],
            '值': [f"{in_sharpe:.3f}", f"{out_sharpe:.3f}", f"{sharpe_degradation:.1%}",
                   f"{avg_aic:.2f}", f"{avg_bic:.2f}", f"{param_sensitivity:.2%}"],
            '状态': self._interpret_diagnostics(
                in_sharpe, out_sharpe, sharpe_degradation, param_sensitivity
            )
        }
        
        return pd.DataFrame(diagnostics_data)
    
    def _analyze_param_sensitivity(self, param_grid: dict) -> float:
        """分析参数敏感性"""
        # 这是简化版本,实际需要更复杂的参数空间采样
        n_params = len(param_grid)
        n = len(self.returns)
        min_data_per_param = n / n_params if n_params > 0 else n
        
        # 经验阈值:每个参数至少需要 10 个数据点
        if min_data_per_param < self.min_periods_per_param:
            return 0.5  # 高敏感性
        
        return 1.0 / min_data_per_param
    
    def _interpret_diagnostics(
        self,
        in_sharpe: float,
        out_sharpe: float,
        degradation: float,
        sensitivity: float
    ) -> List[str]:
        """解释诊断结果"""
        results = []
        
        if degradation > 0.5:
            results.append("⚠️ 警告: 夏普衰减超过50%,疑似过拟合")
        elif degradation > 0.3:
            results.append("🟡 注意: 夏普有一定衰减,建议检查")
        else:
            results.append("✅ 正常: 夏普衰减在可接受范围")
        
        if out_sharpe < 0.5:
            results.append("⚠️ 警告: 样本外夏普低于0.5,策略实用价值存疑")
        
        if sensitivity > 0.3:
            results.append("⚠️ 警告: 参数敏感性较高,建议简化模型")
        
        return results


def example_strategy(returns: np.ndarray, lookback: int = 20, threshold: float = 0.5) -> np.ndarray:
    """
    示例策略:简单移动平均交叉
    
    Args:
        returns: 收益率序列
        lookback: 移动平均窗口
        threshold: 仓位阈值
    
    Returns:
        预测收益率
    """
    if len(returns) < lookback:
        return np.zeros(len(returns))
    
    # 计算移动平均
    ma = np.convolve(returns, np.ones(lookback)/lookback, mode='valid')
    
    # 简单信号:均值上方持有,下方空仓
    signal = np.where(returns[lookback-1:] > np.mean(returns[lookback-1:]), 1, -1)
    
    # 返回预测(实际应用中应该返回下一期的预测收益)
    return np.roll(signal, 1) * returns[lookback-1:]


# 使用示例
if __name__ == "__main__":
    # 生成模拟数据
    np.random.seed(42)
    n = 1000
    dates = pd.date_range('2018-01-01', periods=n, freq='B')
    
    # 生成带有自相关性的收益率序列
    returns = np.random.randn(n) * 0.02
    for i in range(10, n):
        returns[i] += 0.3 * returns[i-10]  # 加入滞后效应
    
    returns_series = pd.Series(returns, index=dates, name='returns')
    
    # 定义参数网格
    param_grid = {
        'lookback': [10, 20, 30, 50],
        'threshold': [0.4, 0.5, 0.6]
    }
    
    # 执行验证
    validator = RollingWindowValidator(
        returns=returns_series,
        n_windows=5,
        test_ratio=0.2,
        window_type="rolling"
    )
    
    best_params, results, diagnostics = validator.validate_strategy(
        strategy_func=example_strategy,
        param_grid=param_grid
    )
    
    print("\n" + "="*60)
    print("过拟合诊断报告")
    print("="*60)
    print(f"\n最优参数: {best_params}")
    print(f"\n诊断结果:\n{diagnostics}")
    print("\n各窗口验证结果:")
    for r in results:
        print(f"  窗口 {r.fold_index}: 样本内夏普={r.train_sharpe:.2f}, "
              f"样本外夏普={r.test_sharpe:.2f}, AIC={r.aic:.2f}, BIC={r.bic:.2f}")

六、数据质量与回测可靠性的关系

过拟合问题的根源除了模型设计不当,还有相当一部分来自数据本身的质量和数量

6.1 为什么历史数据量如此重要

在统计学习中,有一个基本原则:模型复杂度的上限由数据量决定

样本量 可支撑的自由参数上限 合理策略复杂度
500 日 约 5-10 个 简单单因子策略
1,000 日 约 10-20 个 多因子策略,需严格正则化
2,500 日 (10年) 约 25-50 个 复杂多因子模型
5,000 日+ 50+ 个 可考虑机器学习模型

一个残酷的现实是:大多数量化团队可用的美股历史数据不足 10 年。这意味着复杂的深度学习模型在大多数场景下是严重过拟合的高危区。

6.2 TickDB 数据能力与回测可靠性

对于量化回测而言,数据的时间跨度和质量直接影响模型验证的可靠性

TickDB 提供 10 年级别的历史 K 线数据,覆盖美股主要标的。足够长的历史周期意味着:

  • 覆盖多个市场 Regime:牛市、熊市、震荡市、高波动期、低波动期
  • 足够的样本量:每个市场状态有足够的事件样本,避免策略只在特定市场条件下有效
  • Regime 切换验证:可以在不同市场状态下分别评估策略表现

在实际工程中,建议将数据的使用方式规划为:

回测周期规划:
├─ 训练集 (70%): 用于参数优化
│  └─ 如果有 10 年数据,约 7 年
├─ 验证集 (15%): 用于超参数调优和早停
│  └─ 约 1.5 年
└─ 测试集 (15%): 最终泛化能力评估
   └─ 约 1.5 年,模型完全未见过

关键原则:验证集和测试集必须是从未参与过任何优化的"干净"数据。任何时候看到"回测在全部历史上都表现完美"的策略,第一反应应该是质疑数据划分是否合理。


七、实战过拟合检查清单

在提交策略进入实盘模拟或实盘前,请逐项检查:

7.1 数据层面

  • 训练集和测试集是否有明确的时间划分?
  • 是否存在"未来函数"或数据泄露?
  • 历史数据是否包含足够长的周期(至少跨越一个完整牛熊)?
  • 数据的清洗和对齐是否到位(除权除息、异常值处理)?

7.2 模型层面

  • 自由参数数量是否与数据量匹配?(参考 1:10 原则)
  • 是否使用了交叉验证或滚动验证?
  • AIC/BIC 是否在合理范围?
  • 是否进行了参数敏感性分析?

7.3 性能层面

  • 样本外夏普是否至少达到样本内的 70%?
  • 样本外最大回撤是否在可接受范围内?
  • 策略在扩展品种/时间段后是否依然有效?
  • 不同验证窗口的结果是否稳定(方差是否过大)?

7.4 业务逻辑层面

  • 策略的逻辑是否可以用一句话解释清楚?
  • 是否存在交易成本对策略的显著影响?
  • 策略是否对某一类市场条件过度依赖?
  • 是否有足够的流动性支撑策略的交易规模?

结语

过拟合不是量化交易的"小问题",而是整个回测范式的根本性挑战。当我们用历史数据测试策略时,我们实际上是在问:"基于过去发生的事,这个策略在未来还有效吗?"

这个问题没有确定的答案。但我们可以问的是:"这个策略捕捉到的是真实的规律,还是仅仅拟合了历史中的随机噪声?"

AIC/BIC、交叉验证、样本外测试,这些工具的本质都是同一个问题的不同侧面:给策略一个证明自己的机会,让它面对从未见过的数据

如果你想验证一个策略的真正能力,而不是满足于一个漂亮的回测曲线,那么第一步就是:把测试集藏起来,直到你确定策略的所有参数都已经调优完毕。

这可能是量化回测中最重要的纪律。


下一步行动

如果你想亲手实现本文的验证框架

  1. 访问 tickdb.ai 获取历史 K 线数据
  2. 将数据按时间划分为训练集、验证集、测试集
  3. 使用本文的代码框架进行滚动窗口验证
  4. 对比不同参数配置下的 AIC/BIC 和样本外表现

如果你想了解更多量化回测的系统性方法

  • 关注 TickDB 技术博客,后续将推出"回测避坑指南"系列
  • 涵盖滑点模拟、未来函数检测、流动性约束等核心话题

如果你习惯用 AI 辅助开发:在 AI 助手中搜索安装 tickdb-market-data SKILL,用自然语言查询历史数据和构建回测信号。


风险提示:本文不构成任何投资建议。过拟合诊断方法论是对历史数据的分析,不代表对未来收益的预测。回测结果与实盘表现可能存在显著差异,包括但不限于流动性限制、滑点成本和市场冲击。市场有风险,投资需谨慎。