简介:PyVaR 是一套面向金融量化分析师与数据科学学习者的 Python 计算资源,聚焦于用蒙特卡罗模拟与几何布朗运动(GBM)模型并行估算股票风险价值(VaR)。它解决的是在给定置信水平下,如何高效量化投资组合未来最大潜在损失的问题,适合具备一定 Python 与金融工程基础、希望深入理解随机过程建模与并行加速的中高级读者。压缩包共 182 个文件,约 14.62MB,以 98 个 m 文件、29 个 txt 说明、11 个 f90 源码及少量 py、mat、mex 系列文件为主,涵盖核心算法实现、参数配置、示例数据与测试脚本,并附有 readme、changelog、pdf 文档等辅助材料。目前已有 857 人学习下载。借助其中的源代码、示例脚本与文档,读者可掌握蒙特卡罗路径生成、GBM 漂移与扩散参数设置,以及基于共享内存多核并行的 VaR 计算流程,为风险管理、投资决策与合规报告提供可复用的工具与排错参考。
1. 从一次回撤复盘说起:这个并行 VaR 计算项目到底解决什么问题
去年有段时间帮一个做量化风控的朋友复盘组合回撤,他丢过来一份 Python 脚本,说单只股票跑蒙特卡罗 VaR 要等十几秒,组合里几十只标的轮一遍,一个下午就没了。我打开一看,逻辑没问题——几何布朗运动(GBM)模拟价格路径,再取分位数算风险价值,问题出在它是纯串行跑的,几万条路径一条条循环,CPU 多核全在围观。后来我把循环拆成多进程、用共享内存把价格矩阵摊开,同样的路径数从十几秒压到两秒出头。这个项目就是那套思路的整理版:用蒙特卡罗 + GBM 计算股票 VaR 的共享内存并行 Python 代码。它适合两类人——想搞懂 VaR 到底怎么算出来的量化新手,以及手里有现成串行脚本、想把它改成多核并行加速的开发者。关键词里出现了 MATLAB,其实这类蒙特卡罗模拟在 MATLAB 里也有大量实现,思路可以互相印证,但这份代码是纯 Python 的,依赖轻、改起来快,下面按「能干什么 → 怎么跑 → 怎么改」一层层拆。
2. 核心原理与代码骨架:GBM 路径模拟和 VaR 分位数怎么落地
2.1 GBM 模型:从随机游走到价格路径
几何布朗运动是这套代码的数学地基。它假设股票价格的对数收益率服从带漂移的随机游走,离散化之后每一步的递推公式是:
S(t+1) = S(t) * exp((mu - 0.5 * sigma^2) * dt + sigma * sqrt(dt) * Z)其中mu是年化漂移率,sigma是年化波动率,dt是时间步长(比如 1/252 表示一个交易日),Z是标准正态分布随机数。这个公式里有个容易被忽略的细节:漂移项要减掉0.5 * sigma^2,这是伊藤引理修正的结果,漏掉它会让模拟出来的价格系统性偏高,长期路径上偏差会越滚越大。我见过不少人直接写mu * dt,短期看不出问题,跑一年期 VaR 就翻车了。
用 NumPy 向量化实现单步演化,核心就几行:
import numpy as np def simulate_gbm_paths(s0, mu, sigma, dt, n_steps, n_paths, seed=None): """ s0: 初始价格 mu: 年化漂移率, sigma: 年化波动率 dt: 单步时间长度, n_steps: 步数, n_paths: 模拟路径数 返回 shape=(n_steps+1, n_paths) 的价格矩阵 """ rng = np.random.default_rng(seed) # 预生成所有随机冲击, 避免循环里反复调用随机数生成器 z = rng.standard_normal((n_steps, n_paths)) drift = (mu - 0.5 * sigma ** 2) * dt diffusion = sigma * np.sqrt(dt) log_returns = drift + diffusion * z # 每步对数收益 cum_log = np.cumsum(log_returns, axis=0) # 沿时间轴累加 prices = s0 * np.exp(np.vstack([np.zeros(n_paths), cum_log])) return prices这里用cumsum一次性把整条路径的对数收益累加出来,比逐步循环快一个数量级。np.vstack把初始价格那一行补回去,保证矩阵第一行全是s0。参数上,n_paths决定模拟精度,路径越多 VaR 估计越稳,但内存和算力也线性上涨;n_steps对应持有期,算 1 日 VaR 就取 1,算 10 日就取 10。
2.2 VaR 分位数:从价格矩阵到风险数字
拿到价格矩阵后,VaR 的计算就是把终点价格换算成损益,再取分位数。假设置信水平 95%,就是找损益分布的第 5 百分位:
def compute_var(prices, confidence=0.95): """ prices: simulate_gbm_paths 返回的价格矩阵 confidence: 置信水平, 0.95 表示 95% VaR """ s0 = prices[0, 0] terminal = prices[-1, :] # 每条路径的终点价格 pnl = terminal - s0 # 绝对损益 var_abs = -np.percentile(pnl, (1 - confidence) * 100) var_pct = var_abs / s0 # 相对 VaR return var_abs, var_pctnp.percentile的第二个参数传的是百分位数值,95% 置信对应 5,别传成 95,这是新手最常见的低级错误。返回的var_abs是绝对金额,var_pct是占初始价的比例,做组合汇总时用相对值更方便。到这里单只股票的 VaR 链路就通了:GBM 生成路径 → 取终点 → 算损益 → 取分位数。
2.3 共享内存并行:把路径矩阵摊到多核
串行版本慢在路径循环上。多进程并行的关键难点是数据怎么共享——如果每个进程都复制一份价格矩阵,内存直接爆掉。Python 的multiprocessing.shared_memory提供了共享内存块,多个进程可以映射同一块物理内存,避免复制:
from multiprocessing import shared_memory, Pool import numpy as np def worker(args): shm_name, shape, dtype, start, end, s0, mu, sigma, dt, n_steps = args shm = shared_memory.SharedMemory(name=shm_name) # 把共享内存映射成 ndarray, 只写自己负责的列区间 buf = np.ndarray(shape, dtype=dtype, buffer=shm.buf) z = np.random.default_rng().standard_normal((n_steps, end - start)) drift = (mu - 0.5 * sigma ** 2) * dt diffusion = sigma * np.sqrt(dt) cum = np.cumsum(drift + diffusion * z, axis=0) buf[1:, start:end] = s0 * np.exp(cum) buf[0, start:end] = s0 shm.close() return None主进程先创建共享内存块,把形状、dtype、每个进程负责的列区间打包成参数分发下去。每个 worker 只写自己那段列,互不干扰,天然避免了锁竞争。shm.close()只关闭当前进程的映射,不销毁内存块,主进程最后统一shm.unlink()释放。这套模式比Pool默认的 pickle 传参省内存得多,路径数上万时差距非常明显。
3. 跑起来:环境、参数和一次完整调用
3.1 环境依赖与最小可跑示例
这份代码依赖很轻,核心就 NumPy,并行部分用标准库multiprocessing,不需要额外装并行框架。Python 版本建议 3.8 以上,因为shared_memory是 3.8 才进标准库的。装依赖:
pip install numpy一个完整的最小调用示例,把前面几个函数串起来:
if __name__ == "__main__": s0, mu, sigma = 100.0, 0.08, 0.25 dt, n_steps, n_paths = 1/252, 1, 50000 prices = simulate_gbm_paths(s0, mu, sigma, dt, n_steps, n_paths, seed=42) var_abs, var_pct = compute_var(prices, confidence=0.95) print(f"95% 1日 VaR: {var_abs:.2f} 元 ({var_pct:.2%})")if __name__ == "__main__"这行在 Windows 上不是可选项,多进程模块在 Windows 下靠 spawn 启动子进程,没有这层保护会无限递归创建进程,直接卡死。Linux 下 fork 模式没这个问题,但为了跨平台一致,建议一律加上。
3.2 参数怎么调:路径数、步长和置信水平
参数不是随便填的,每个都影响结果和性能。下面这张表是我调参时总结的经验区间:
| 参数 | 含义 | 常用取值 | 影响 |
|---|---|---|---|
| n_paths | 模拟路径数 | 10000 ~ 100000 | 越大 VaR 越稳,内存线性增长 |
| n_steps | 时间步数 | 1(日)/ 10(旬) | 对应持有期,步数越多越慢 |
| dt | 单步时长 | 1/252 | 日频用 1/252,周频用 1/52 |
| confidence | 置信水平 | 0.95 / 0.99 | 越高 VaR 越大,尾部样本越少 |
| mu | 年化漂移 | 0.05 ~ 0.15 | 长期影响大,短期影响小 |
| sigma | 年化波动 | 0.15 ~ 0.60 | 直接决定 VaR 量级 |
路径数从 1 万加到 10 万,VaR 估计的标准误大约降为原来的三分之一(按 1/√n 收敛),但耗时涨十倍。实务里 5 万条路径配 95% 置信是个性价比不错的平衡点。sigma的估计建议用历史对数收益的标准差年化,别拍脑袋填,这个参数对结果的影响比路径数大得多。
3.3 并行版本怎么组织进程数
进程数不是越多越好。经验值是设成 CPU 物理核心数,超线程逻辑核在计算密集场景下收益有限,反而增加调度开销:
import os from multiprocessing import Pool def run_parallel(n_paths, n_workers=None): n_workers = n_workers or os.cpu_count() chunk = n_paths // n_workers # 创建共享内存, 形状 (n_steps+1, n_paths) shm = shared_memory.SharedMemory(create=True, size=...) tasks = [(shm.name, shape, np.float64, i * chunk, (i + 1) * chunk, ...) for i in range(n_workers)] with Pool(n_workers) as pool: pool.map(worker, tasks) # 主进程读取结果并计算 VaR result = np.ndarray(shape, dtype=np.float64, buffer=shm.buf) var = compute_var(result) shm.close(); shm.unlink() return varchunk是每个进程负责的路径数,最后一段可能除不尽,实际代码里要做边界处理,把余数并到最后一个进程。Pool的map会阻塞到所有任务完成,之后主进程直接从共享内存读结果算 VaR,不需要再收集各进程的返回值,这也是共享内存方案比返回大数组更省事的地方。
4. 避坑与排查:并行 VaR 计算里最容易翻车的几处
4.1 共享内存没释放,进程退出后残留
现象是反复运行脚本,系统内存占用越来越高,/dev/shm下堆了一堆无名段。原因是shm.unlink()没执行,或者 worker 里抛异常导致主进程提前退出,清理逻辑被跳过。解决办法是用try/finally把close和unlink包起来,保证异常路径也能释放:
shm = shared_memory.SharedMemory(create=True, size=nbytes) try: # 分发任务、计算 ... finally: shm.close() shm.unlink()Linux 下可以用ls /dev/shm确认有没有残留,Windows 下共享内存随进程结束自动回收,问题没这么明显,但养成显式释放的习惯没坏处。
4.2 随机种子在多进程里重复,路径高度相关
现象是并行跑出来的 VaR 和串行差很多,甚至方差偏小。原因是每个 worker 都用默认随机状态,spawn 模式下子进程继承的种子可能相同,导致各段路径其实是同一批随机数。解决是给每个 worker 传不同的种子,或者用SeedSequence派生独立子序列:
from numpy.random import SeedSequence, default_rng ss = SeedSequence(42) child_seeds = ss.spawn(n_workers) # 每个进程一个独立种子 rng = default_rng(child_seeds[worker_id])这样各段路径统计独立,合并后的分布才正确。这个坑很隐蔽,因为结果看起来「像那么回事」,不做串并行对比根本发现不了。
4.3 浮点精度在共享内存里对不上
现象是共享内存里读出来的价格和预期有微小偏差,或者不同进程写同一区域导致数据错乱。前者通常是 dtype 不一致,主进程按float64建块,worker 按float32映射,读出来就是乱的。后者是列区间划分有重叠,两个进程写同一列。排查时先打印buf.dtype和buf.shape确认一致,再检查start/end有没有算错。我一般会在 worker 里加一句断言assert end <= shape[1],把越界挡在写入之前。
4.4 路径数不能被进程数整除时的边界
现象是最后一段路径没被模拟,VaR 结果偏小,或者直接索引越界报错。原因是n_paths // n_workers丢掉了余数。解决是把余数补给最后一个进程:
chunk = n_paths // n_workers remainder = n_paths % n_workers for i in range(n_workers): start = i * chunk end = start + chunk + (remainder if i == n_workers - 1 else 0)这种边界问题在路径数取整万、进程数取 8 或 16 时经常被掩盖,一旦换成 6 进程、5 万路径就暴露。写并行代码时对整除假设要格外警惕。
4.5 Windows 下 spawn 启动慢或卡死
现象是脚本在 Windows 上跑不起来,或者启动子进程时反复执行模块顶层代码。原因是 spawn 模式会重新导入主模块,顶层如果有创建共享内存、启动进程的代码就会递归。解决是把所有执行逻辑收进if __name__ == "__main__",模块顶层只留函数和常量定义。这个坑在 Linux 上开发、Windows 上部署时特别容易踩,因为 fork 模式不会重新导入模块,本地测不出问题。
5. 二次开发:把单资产 VaR 扩成组合并接上真实数据
5.1 从单资产到多资产组合
单资产 VaR 只是起点,真实风控要的是组合 VaR。最直接的扩展是给每只标的独立模拟路径,再按权重加总损益。但这样忽略了资产间的相关性,算出来的组合 VaR 会偏小。更靠谱的做法是在随机冲击Z上引入相关结构,用 Cholesky 分解把独立正态变成相关正态:
def correlated_shocks(n_steps, n_paths, corr_matrix, seed=None): """corr_matrix: 资产间相关系数矩阵, shape=(k, k)""" rng = np.random.default_rng(seed) k = corr_matrix.shape[0] L = np.linalg.cholesky(corr_matrix) # 下三角分解 z_indep = rng.standard_normal((n_steps, n_paths, k)) # 沿资产维度做线性变换, 得到相关冲击 z_corr = z_indep @ L.T return z_corrL是相关矩阵的 Cholesky 因子,z_indep @ L.T把独立标准正态转成相关系数为corr_matrix的多元正态。之后每只资产用自己的mu、sigma和对应的z_corr[:, :, i]演化价格,最后按持仓权重加权求组合损益。相关系数矩阵建议用历史收益率滚动窗口估计,窗口太短噪声大,太长反应迟钝,60 到 120 个交易日是个常见区间。
5.2 接真实行情数据
模拟的输入参数mu和sigma得从真实数据来。用日频收盘价算对数收益,再年化:
import numpy as np def estimate_params(close_prices, trading_days=252): """close_prices: 一维收盘价序列""" log_ret = np.diff(np.log(close_prices)) mu = log_ret.mean() * trading_days sigma = log_ret.std(ddof=1) * np.sqrt(trading_days) return mu, sigmaddof=1用样本标准差,别用总体标准差,样本量小时差别不小。mu的估计噪声很大,实务里很多人干脆把漂移设成无风险利率或直接设 0,因为短期 VaR 对漂移不敏感,对sigma敏感。这个取舍看你的持有期,1 日 VaR 设mu=0完全可接受。
5.3 验证方法:串并行结果对比和收敛性检查
改完并行代码,第一件事是拿小路径数跑串行和并行两版,对比 VaR 是否在统计误差内一致。固定种子后两者应该非常接近,差太多说明并行逻辑有问题。第二件事是收敛性检查:路径数从 1 万翻到 10 万,VaR 估计应该逐渐稳定,如果还在大幅跳动,说明路径数不够或者随机种子有问题。我一般会画一条 VaR 随路径数变化的曲线,看它什么时候趋于平缓,那个拐点附近就是够用的路径数。
5.4 一个容易忽略的改造点:内存布局
共享内存里价格矩阵按(n_steps+1, n_paths)存,worker 按列切片写入。这种布局对按路径写入友好,但如果后面要按时间步做向量化运算,行优先的访存模式更高效。如果n_steps很大(比如模拟一整年 252 步),可以考虑转置成(n_paths, n_steps+1),让每条路径连续存储,缓存命中率更高。改布局时记得同步改 worker 里的切片方向和compute_var取终点的轴,这两处不对齐是改造后最常见的 bug。
从那以后我每次改并行数值代码,都强制先跑一遍小规模串并行对比,确认统计一致再放大路径数,这个习惯帮我挡掉了好几次随机种子和内存布局的暗坑。希望帮到你。
本文还有配套的精品资源,点击获取