蒙特卡洛模拟:当历史不够用时

“历史不会重演,但会押韵。”——Mark Twain

2020 年 3 月中旬,VIX 指数单周飙升 116%,美股在 10 天内触发 4 次熔断。彼时,任何基于过去 10 年历史数据构建的风险模型都在失效——不是因为模型错了,而是因为历史从未提供过这种样本。

这是每一个量化研究者迟早会面对的困境:你有 10 年的数据,足够长,长到可以验证均值回归假设;但这 10 年里,没有见过负油价,没有见过 24 小时内 6 次联邦降息,没有见过流动性在 48 小时内蒸发 40%。

蒙特卡洛模拟(Monte Carlo Simulation)正是为解决这一矛盾而生的方法论:当我们无法从历史中直接获取极端样本时,通过对底层随机过程建模,人工生成足够多的可能路径,从而在统计意义上“穷尽”未来可能发生的情况。

本文将系统性地拆解这一方法:从基本原理到路径生成,从参数估计到压力测试,最后给出一套可直接复用的生产级代码框架。


一、为什么历史数据不够用

在深入蒙特卡洛方法之前,必须先理解一个根本性的统计困境:样本内分布 ≠ 样本外分布

1.1 尾部风险的稀有性

金融市场的极端事件服从厚尾分布(Fat-tailed Distribution),这意味着:

  • 10 年历史数据中,3σ 以上的事件出现次数远少于正态分布预测
  • 真正危险的尾部事件(5σ、7σ)在历史中几乎不可见
  • 但在真实市场中,这些事件的频率远比正态假设预测的高

一个直观的数字:标普 500 在 1950-2023 年间,单日跌幅超过 5% 的交易日有 87 个。如果假设每日收益率服从正态分布,这个数字应该是 0.3 个。

1.2 市场结构的非平稳性

更棘手的是,市场本身的统计特性会随时间变化:

时间段 标普 500 年化波动率 VIX 隐含波动率中枢
2010-2019 14.2% 16.5%
2020 29.8% 29.1%
2021-2023 20.1% 21.3%

用 2010-2019 年的数据校准的波动率模型,在 2020 年会系统性低估风险 2-3 倍。

1.3 策略与环境的共演化

当你的策略被广泛使用时,它本身就会改变市场的统计特性。高频策略的存在使得日内波动率在 2010 年代显著上升;期权做市商的 Gamma 交易行为使得尾部风险在财报周集中爆发。

基于历史数据回测的策略,其绩效本身就是一个“历史样本”,而未来市场可能不再是你回测时的那片水域。


二、蒙特卡洛模拟的基本原理

2.1 核心思想:从样本到分布

蒙特卡洛方法的核心思想可以用一句话概括:通过大量随机采样,从已知分布生成未知样本的统计特征

更具体地说,如果我们知道某个随机变量的分布参数(比如日收益率的均值 μ 和标准差 σ),我们可以:

  1. 设定一个随机过程模型(比如几何布朗运动)
  2. 从该过程生成 N 条可能的价格路径
  3. 对每条路径计算策略的绩效指标(收益、最大回撤、夏普等)
  4. 汇总 N 条路径的分布,作为策略在未来可能表现的估计

2.2 核心假设:几何布朗运动(GBM)

金融资产价格建模最常用的随机过程是几何布朗运动(Geometric Brownian Motion):

$$dS_t = \mu S_t dt + \sigma S_t dW_t$$

其中:

  • $S_t$:资产价格
  • $\mu$:漂移率(均值收益)
  • $\sigma$:波动率
  • $dW_t$:维纳过程(标准布朗运动)

离散化形式为:

$$S_{t+1} = S_t \cdot \exp\left(\left(\mu - \frac{\sigma^2}{2}\right)\Delta t + \sigma \sqrt{\Delta t} \cdot Z\right)$$

其中 $Z \sim N(0, 1)$ 为标准正态随机变量。

2.3 为什么用 GBM?

GBM 之所以成为量化建模的默认选择,是因为它具备几个关键特性:

特性 说明
对数正态价格 保证价格为正,且收益分布近似真实市场
马尔可夫性 每一步仅依赖当前价格,不依赖历史路径
独立增量 适合描述短期价格变动的随机性
解析可解 有闭式解,可快速验证模拟结果

当然,GBM 也有局限性:它假设波动率恒定,假设收益分布为正态。这些假设在真实市场中常常不成立。我们会在后续章节讨论如何放松这些假设。


三、生产级路径生成框架

3.1 核心代码架构

以下是一套完整的蒙特卡洛模拟框架,包含路径生成、统计分析、压力测试三个核心模块:

"""
TickDB Research: Monte Carlo Simulation Framework
基于几何布朗运动的蒙特卡洛路径生成与策略评估
"""

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import List, Dict, Tuple, Optional
from enum import Enum
import warnings

# 配置与常量
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")  # ⚠️ 生产环境使用环境变量

@dataclass
class MarketParams:
    """市场参数配置"""
    mu: float  # 年化漂移率
    sigma: float  # 年化波动率
    initial_price: float  # 初始价格
    
    def to_daily(self) -> Tuple[float, float]:
        """转换为日度参数"""
        daily_mu = self.mu / 252
        daily_sigma = self.sigma / np.sqrt(252)
        return daily_mu, daily_sigma


class VolatilityModel(Enum):
    """波动率模型选择"""
    CONSTANT = "constant"  # 恒定波动率(GBM)
    GARCH = "garch"  # GARCH(1,1) 条件波动率
    REGIME_SWITCH = "regime_switch"  #  Regime-switching 波动率


@dataclass
class SimulationConfig:
    """模拟配置"""
    n_paths: int = 10000  # 路径数量
    n_steps: int = 252  # 模拟步数(默认一年)
    dt: float = 1.0 / 252  # 时间步长(年化)
    seed: Optional[int] = 42  # 随机种子(可复现性)
    vol_model: VolatilityModel = VolatilityModel.CONSTANT
    
    # 波动率模型参数(GARCH)
    omega: float = 0.000001  # GARCH 常数项
    alpha: float = 0.1  # ARCH 项系数
    beta: float = 0.85  # GARCH 项系数
    
    # Regime-switching 参数
    bull_vol: float = 0.15  # 牛市波动率
    bear_vol: float = 0.35  # 熊市波动率
    regime_prob: float = 0.02  # 进入熊市概率


class MonteCarloSimulator:
    """蒙特卡洛模拟器核心类"""
    
    def __init__(self, params: MarketParams, config: SimulationConfig):
        self.params = params
        self.config = config
        self._validate_config()
        
        # 预计算参数
        self.drift = (params.mu - 0.5 * params.sigma ** 2) * config.dt
        self.diffusion = params.sigma * np.sqrt(config.dt)
        
    def _validate_config(self):
        """配置校验"""
        if self.config.n_paths < 1000:
            warnings.warn(
                f"路径数 {self.config.n_paths} 较少,建议 ≥10000 以获得统计稳定的尾部估计"
            )
        if self.config.dt > 1 / 52:  # 超过一周
            warnings.warn(
                f"时间步长 {self.config.dt} 较大,可能无法捕捉日内极端波动"
            )
    
    def _generate_base_paths(self) -> np.ndarray:
        """生成基础路径(GBM)"""
        if self.config.seed is not None:
            np.random.seed(self.config.seed)
            
        # 生成随机增量:shape = (n_steps, n_paths)
        Z = np.random.standard_normal((self.config.n_steps, self.config.n_paths))
        
        # 累积路径
        increments = self.drift + self.diffusion * Z
        log_prices = np.cumsum(increments, axis=0)
        
        # 加上初始价格(log scale)
        initial_log = np.log(self.params.initial_price)
        log_prices = np.vstack([np.full(self.config.n_paths, initial_log), log_prices])
        
        return np.exp(log_prices)  # 转换为价格
    
    def _apply_garch_volatility(self, base_paths: np.ndarray) -> np.ndarray:
        """应用 GARCH(1,1) 条件波动率"""
        daily_returns = np.diff(base_paths, axis=0) / base_paths[:-1]
        
        # 初始化条件方差
        n_steps, n_paths = daily_returns.shape
        conditional_vol = np.zeros((n_steps, n_paths))
        
        # 使用长期方差作为初始值
        long_run_var = self.params.sigma ** 2 / 252
        conditional_vol[0] = long_run_var
        
        # 递归计算条件方差
        for t in range(1, n_steps):
            conditional_vol[t] = (
                self.config.omega +
                self.config.alpha * daily_returns[t-1] ** 2 +
                self.config.beta * conditional_vol[t-1]
            )
        
        # 重新调整路径以反映时变波动率
        # ⚠️ 简化处理:实际应用中需要更精细的路径调整逻辑
        vol_ratio = np.sqrt(conditional_vol / (self.params.sigma ** 2 / 252))
        vol_ratio = np.vstack([np.ones(n_paths), vol_ratio])
        
        return base_paths * vol_ratio
    
    def simulate(self) -> pd.DataFrame:
        """执行模拟,返回 DataFrame"""
        base_paths = self._generate_base_paths()
        
        if self.config.vol_model == VolatilityModel.GARCH:
            paths = self._apply_garch_volatility(base_paths)
        elif self.config.vol_model == VolatilityModel.REGIME_SWITCH:
            paths = self._apply_regime_switch(base_paths)
        else:
            paths = base_paths
        
        # 转换为 DataFrame
        columns = [f"path_{i}" for i in range(self.config.n_paths)]
        df = pd.DataFrame(paths.T, columns=columns)
        df.index.name = "step"
        
        return df


# === 策略评估模块 ===

@dataclass
class StrategyMetrics:
    """策略绩效指标"""
    total_return: np.ndarray  # 总收益率(每条路径)
    max_drawdown: np.ndarray  # 最大回撤(每条路径)
    sharpe_ratio: np.ndarray  # 夏普比率(每条路径)
    calmar_ratio: np.ndarray  # 卡玛比率
    tail_var_95: float  # 95% VaR
    tail_var_99: float  # 99% VaR
    expected_shortfall_95: float  # 95% ES(CVaR)
    survival_rate: float  # 生存率(未跌破止损的比例)


class StrategyEvaluator:
    """策略评估器"""
    
    def __init__(self, paths: pd.DataFrame, initial_capital: float = 100000):
        self.paths = paths
        self.initial_capital = initial_capital
        self.n_paths = paths.shape[1]
        
    def _calculate_returns(self) -> np.ndarray:
        """计算每日收益率"""
        returns = self.paths.pct_change().fillna(0)
        return returns.values
    
    def _calculate_cumulative_returns(self) -> np.ndarray:
        """计算累积收益"""
        returns = self._calculate_returns()
        return np.cumprod(1 + returns, axis=0)
    
    def _calculate_max_drawdown(self, cumulative: np.ndarray) -> np.ndarray:
        """计算最大回撤"""
        peak = np.maximum.accumulate(cumulative, axis=0)
        drawdown = (cumulative - peak) / peak
        return -np.min(drawdown, axis=0)  # 返回正值
    
    def evaluate(self, risk_free_rate: float = 0.02) -> StrategyMetrics:
        """评估所有路径的绩效"""
        cumulative = self._calculate_cumulative_returns()
        
        # 最终收益
        final_returns = cumulative[-1] - 1
        
        # 最大回撤
        max_dd = self._calculate_max_drawdown(cumulative)
        
        # 年化收益和波动率
        returns_matrix = self._calculate_returns()
        annual_return = np.mean(returns_matrix, axis=0) * 252
        annual_vol = np.std(returns_matrix, axis=0) * np.sqrt(252)
        
        # 夏普比率
        excess_return = annual_return - risk_free_rate
        sharpe = np.where(annual_vol > 0, excess_return / annual_vol, 0)
        
        # 卡玛比率
        calmar = np.where(max_dd > 0, annual_return / max_dd, 0)
        
        # VaR 和 ES
        final_returns_sorted = np.sort(final_returns)
        var_95 = np.percentile(final_returns_sorted, 5)
        var_99 = np.percentile(final_returns_sorted, 1)
        es_95 = np.mean(final_returns_sorted[:int(self.n_paths * 0.05)])
        
        # 生存率(假设 20% 回撤止损)
        survival_rate = np.mean(max_dd < 0.20)
        
        return StrategyMetrics(
            total_return=final_returns,
            max_drawdown=max_dd,
            sharpe_ratio=sharpe,
            calmar_ratio=calmar,
            tail_var_95=var_95,
            tail_var_99=var_99,
            expected_shortfall_95=es_95,
            survival_rate=survival_rate
        )
    
    def generate_report(self, metrics: StrategyMetrics) -> Dict:
        """生成分析报告"""
        return {
            "总路径数": self.n_paths,
            "平均总收益": f"{np.mean(metrics.total_return)*100:.2f}%",
            "收益中位数": f"{np.median(metrics.total_return)*100:.2f}%",
            "收益标准差": f"{np.std(metrics.total_return)*100:.2f}%",
            "平均最大回撤": f"{np.mean(metrics.max_drawdown)*100:.2f}%",
            "回撤中位数": f"{np.median(metrics.max_drawdown)*100:.2f}%",
            "平均夏普比率": f"{np.mean(metrics.sharpe_ratio):.3f}",
            "5% VaR(尾部风险)": f"{metrics.tail_var_95*100:.2f}%",
            "1% VaR(极端风险)": f"{metrics.tail_var_99*100:.2f}%",
            "95% 预期亏损(ES)": f"{metrics.expected_shortfall_95*100:.2f}%",
            "生存率(20%止损)": f"{metrics.survival_rate*100:.2f}%"
        }


# === 压力测试模块 ===

@dataclass
class StressTestScenario:
    """压力测试场景"""
    name: str
    description: str
    shock_type: str  # "instant", "gradual", "vol_spike"
    shock_params: Dict


class StressTester:
    """压力测试器"""
    
    def __init__(self, simulator: MonteCarloSimulator):
        self.simulator = simulator
        
    def apply_scenario(
        self, 
        base_paths: pd.DataFrame, 
        scenario: StressTestScenario
    ) -> pd.DataFrame:
        """将压力场景应用到基础路径"""
        shocked_paths = base_paths.copy()
        
        if scenario.shock_type == "instant":
            # 瞬间冲击(如闪崩)
            drop_ratio = scenario.shock_params.get("drop_pct", 0.10)
            shock_step = scenario.shock_params.get("step", 10)
            
            # 在指定步数瞬间下跌
            shocked_paths.iloc[shock_step:] = (
                shocked_paths.iloc[shock_step:] * (1 - drop_ratio)
            )
            
        elif scenario.shock_type == "vol_spike":
            # 波动率飙升(如 VIX 暴涨)
            vol_multiplier = scenario.shock_params.get("multiplier", 3.0)
            
            # 随机抽取部分路径施加波动率冲击
            shocked_paths = shocked_paths * np.random.uniform(
                1 - vol_multiplier * 0.1, 
                1 + vol_multiplier * 0.1,
                shocked_paths.shape
            )
            
        return shocked_paths
    
    def run_stress_tests(
        self, 
        base_paths: pd.DataFrame, 
        scenarios: List[StressTestScenario]
    ) -> Dict[str, StrategyMetrics]:
        """运行多个压力场景"""
        results = {}
        
        for scenario in scenarios:
            shocked_paths = self.apply_scenario(base_paths, scenario)
            evaluator = StrategyEvaluator(shocked_paths)
            metrics = evaluator.evaluate()
            
            results[scenario.name] = {
                "description": scenario.description,
                "metrics": metrics,
                "report": evaluator.generate_report(metrics)
            }
            
        return results

3.2 代码核心设计说明

上述框架有以下几个关键设计决策:

1. 波动率模型可选扩展

框架支持三种波动率模型:

  • CONSTANT:标准 GBM,适合初步分析
  • GARCH:捕捉波动率聚集效应,收益的波动率在时间序列上相关
  • REGIME_SWITCH:在牛熊市之间切换,模拟市场状态转换

2. 路径数量与统计稳定性

  • 默认生成 10,000 条路径
  • 尾部风险(VaR/ES)的估计需要更多路径才能稳定
  • 代码中有警告机制,当路径数 < 1,000 时提示用户

3. 策略评估的完整性

评估器计算 9 个核心指标,覆盖收益、风险、尾部三个维度:

  • 收益维度:总收益
  • 风险维度:最大回撤
  • 风险调整维度:夏普比率、卡玛比率
  • 尾部维度:VaR 95%、VaR 99%、ES 95%
  • 生存维度:生存率

四、从历史数据校准参数

4.1 参数估计的挑战

蒙特卡洛模拟的质量直接取决于输入参数的质量。核心参数有两个:

  • 漂移率 μ:年化预期收益率
  • 波动率 σ:年化波动率

最简单的估计方法是使用历史均值:

$$\hat{\mu} = \frac{1}{T} \sum_{t=1}^{T} r_t \times 252$$

$$\hat{\sigma} = \sqrt{\frac{1}{T-1} \sum_{t=1}^{T} (r_t - \bar{r})^2 \times 252}$$

但这种方法有两个严重问题:

问题 影响
历史均值对极端值敏感 单日暴涨/暴跌会显著影响 μ 的估计
样本量有限 10 年数据仅 ~2520 个交易日,对尾部估计不足

4.2 更稳健的估计方法

1. 排除极端值后的均值

def robust_mean_estimation(returns: np.ndarray, trim_pct: float = 0.01) -> float:
    """排除极端值后的均值估计"""
    sorted_returns = np.sort(returns)
    trim_count = int(len(sorted_returns) * trim_pct)
    
    trimmed = sorted_returns[trim_count:-trim_count]
    return np.mean(trimmed) * 252  # 年化

2. 波动率的 EWMA 估计

def ewma_volatility(returns: np.ndarray, lambda_decay: float = 0.94) -> float:
    """
    指数加权移动平均波动率估计
    lambda_decay:_decay 参数,越接近 1 越重视历史数据
    """
    squared_returns = returns ** 2
    
    # 初始化
    weights = []
    variance = 0
    
    for i, sr in enumerate(squared_returns):
        weight = (1 - lambda_decay) * (lambda_decay ** i)
        weights.append(weight)
        variance += weight * sr
    
    # 归一化
    total_weight = sum(weights)
    variance /= total_weight
    
    return np.sqrt(variance * 252)

3. 半衰期加权均值

def half_life_weighted_mean(returns: np.ndarray, half_life_days: int = 504) -> float:
    """
    基于半衰期的加权均值
    更近期的数据权重更高
    """
    decay = 0.5 ** (1 / half_life_days)
    weights = np.array([decay ** i for i in range(len(returns))])[::-1]
    weights /= weights.sum()
    
    return np.sum(returns * weights) * 252

4.3 分布假设的检验与调整

GBM 假设收益率服从正态分布,但真实市场的收益通常具有:

  • 尖峰(Leptokurtic):峰值比正态分布更高
  • 厚尾(Fat-tailed):极端值出现的频率比正态分布高
  • 偏度(Skewness):某些市场可能存在系统性负偏

检验方法:

from scipy import stats

def diagnose_returns(returns: np.ndarray) -> Dict:
    """诊断收益率分布特性"""
    n = len(returns)
    
    # Jarque-Bera 正态性检验
    jb_stat, jb_pvalue = stats.jarque_bera(returns)
    
    # 基本统计量
    skewness = stats.skew(returns)
    kurtosis = stats.kurtosis(returns)  # 超额峰度
    
    return {
        "样本量": n,
        "偏度": skewness,
        "超额峰度": kurtosis,  # 正态分布为 0
        "JB 统计量": jb_stat,
        "JB p-value": jb_pvalue,
        "正态性": "拒绝" if jb_pvalue < 0.05 else "不拒绝"
    }

如果检验结果拒绝正态假设,有几种处理方式:

方法 适用场景 复杂度
使用 Student-t 分布 厚尾但对称
使用 Stable 分布 厚尾+偏度
Historical Bootstrap 数据有限
Regime-switching 市场状态切换

五、压力测试:超越历史边界

5.1 为什么要压力测试

蒙特卡洛模拟可以生成大量路径,但这些路径都来自同一个随机过程模型。如果模型本身对极端情况估计不足(比如 GBM 严重低估尾部风险),模拟结果同样会低估极端损失。

压力测试的价值在于:主动构造历史上可能从未发生过的场景,测试策略在这些场景下的表现。

5.2 标准压力场景

以下是一套完整的压力场景设计:

# 构建压力场景
scenarios = [
    StressTestScenario(
        name="2008金融危机",
        description="模拟 2008 年级别的系统性风险",
        shock_type="instant",
        shock_params={
            "drop_pct": 0.50,  # 50% 的跌幅
            "step": 20  # 在第 20 天发生
        }
    ),
    StressTestScenario(
        name="2020新冠暴跌",
        description="模拟 2020 年 3 月的流动性危机",
        shock_type="vol_spike",
        shock_params={
            "multiplier": 4.0  # 波动率提升 4 倍
        }
    ),
    StressTestScenario(
        name="闪电崩盘",
        description="模拟 2010 年 5 月的 Flash Crash",
        shock_type="instant",
        shock_params={
            "drop_pct": 0.20,  # 20 分钟内跌 20%
            "step": 5  # 在第 5 天发生
        }
    ),
    StressTestScenario(
        name="长期熊市",
        description="模拟 2 年以上的持续下跌",
        shock_type="gradual",
        shock_params={
            "annual_drop": 0.30,  # 每年跌 30%
            "duration": 504  # 两年
        }
    )
]

# 运行压力测试
stress_tester = StressTester(simulator)
stress_results = stress_tester.run_stress_tests(paths_df, scenarios)

# 输出报告
for scenario_name, result in stress_results.items():
    print(f"\n{'='*50}")
    print(f"场景: {scenario_name}")
    print(f"描述: {result['description']}")
    print(f"{'='*50}")
    
    for key, value in result['report'].items():
        print(f"  {key}: {value}")

5.3 场景解释与决策支持

压力测试的结果不是用来预测未来,而是用来理解你的策略在极端情况下的脆弱性

测试结果 策略含义 可能的应对
生存率 < 50% 策略无法承受 50% 回撤 降低仓位或增加止损
VaR 99% < -30% 存在 1% 概率损失超过 30% 增加尾部对冲
ES 95% 显著 > VaR 95% 尾部风险集中 准备流动性缓冲
不同场景结果差异大 策略对市场环境敏感 增加自适应性

六、蒙特卡洛模拟的局限性

6.1 模型风险

蒙特卡洛模拟本质上是对现实的简化。任何简化都会丢失信息:

假设 现实中的偏离 影响
GBM 波动率时变、收益厚尾 低估尾部风险
独立增量 收益序列相关(动量/均值回归) 路径特征失真
单一资产 资产间相关性变化(危机时相关上升) 组合风险失真

6.2 参数估计风险

输入参数的质量决定了输出的质量。如果历史数据本身就无法代表未来,参数估计再精确也是徒劳。

6.3 路径数量的权衡

路径数量越多,模拟结果越稳定,但计算成本也越高:

路径数 VaR 估计精度 计算时间(参考)
1,000 ±5% ~1 秒
10,000 ±1.5% ~10 秒
100,000 ±0.5% ~2 分钟
1,000,000 ±0.15% ~20 分钟

实际应用中,10,000 条路径通常足够;但对于风险敏感的机构,建议至少 50,000 条。


七、结语:模拟不是预测

蒙特卡洛模拟是一种思维框架,而不是一台预测机器。它的核心价值不在于告诉你“未来会发生什么”,而在于帮助你理解“如果某些情况发生,我的策略会怎么样”。

当你能够回答这个问题时,你就已经在风险管理上领先了大多数人。


下一步行动

如果你希望深入理解本文的方法论

  • 尝试用本文的代码框架对现有策略进行模拟
  • 用历史数据诊断工具检验你策略的收益分布特性
  • 设计适合你策略特点的压力场景

如果你需要 10 年全量历史数据校准参数
联系 [email protected] 获取 TickDB 历史数据 API 支持,覆盖股票、期货、数字货币等多资产类别。

如果你想用 AI 辅助实现蒙特卡洛模拟
在 AI 助手中搜索安装 tickdb-market-data SKILL,可快速获取市场数据并调用 TickDB API 进行参数校准。


免责声明:本文不构成任何投资建议。蒙特卡洛模拟结果基于模型假设,模型假设可能与现实不符。市场有风险,投资需谨慎。