先说个场景:你拿到一篇Energy Economics或者Journal of Energy Markets上面做电价风险管理的高水平文章,模型也读懂了,公式也能推导,自信满满打开MATLAB准备复现。结果三个小时之后,你不仅没跑出论文里的那张风险价值图,反而被报错信息淹没了:维度对不上,似然函数返回NaN,优化器说收敛但估计出来回复速度是负的。这个场景我太熟了,过去两年里我前前后后复现了十几篇能源价格风险管理方向的论文,踩过的坑比代码行数还多。这篇我就以“能源价格风险管理MATLAB源代码,按照高水平文章复现,保证正确”为线索,把一套完整的复现思路、代码骨架和验证方法摊开来讲。文章面向的是金融工程、能源经济方向的研究生,以及初入电力市场量化分析岗位的从业者——你们大概率都绕不过要读懂并重写别人的模型这件事。
真实世界的电力价格波动性远远大于原油、天然气这些传统能源品种,原因不是单点的,而是整个电力系统的物理约束和交易机制共同作用的结果。这也直接决定了,你若想用标准的几何布朗运动或者单纯的均值回复模型去给电价做风险度量,结果一定是灾难性的。所以复现高水平论文的第一件事不是打开MATLAB写代码,而是搞清楚这些论文为什么非要给模型加上跳跃项、加上季节项、加上高频波动率过程。理清这个逻辑之后,参数估计怎么设计、风险指标怎么算、正确性怎么验证,才有一条清晰的线。
1. 电力价格风险的独特之处:为什么普通金融模型直接失效
在写任何代码之前,我建议先花半天时间做一件事:拉一条电力市场的日度现货价格序列,再拉一条同期的原油期货或天然气价格序列,分别算一下对数收益率的均值和标准差,再画一画直方图和自相关图。做完这个对比,你对“电力价格的波动性远远大于其他品种”这句话的感知会从抽象变成具体。
1.1 从统计特征看,电价的三观崩塌
我拿一组典型的日前现货市场数据做过统计,结果和论文里报告的现象基本一致。为了不涉及具体市场,下表给出的是数量级含义的示意值,但足以说明问题:
| 品种 | 日收益率标准差(年化后) | 峰度 | 出现极端跳跃的频率 |
|---|---|---|---|
| 原油期货 | 20%-30% | 3-5 | 低 |
| 天然气期货 | 30%-50% | 4-6 | 中等 |
| 电力现货 | 100%-300% | 10-40 | 高 |
一年365天里,原油价格单日跌掉5%已经能上新闻,电价在某几个小时里从30元/MWh飙到500元/MWh,在电力市场里却是家常便饭。正因为这样,普通的金融资产定价模型落到电价上会犯两个方向相反的错:一方面低估了尾部风险,另一方面又因为非平稳性给出混乱的长期均值。
1.2 物理约束和市场机制:波动来自骨子里
电价波动大的底层原因,可以拆成三个层面。
第一,电不能大规模经济化储存。原油放在库罐里,今天卖不掉可以明天卖,价格波动被库存缓冲。电力基本是即产即销,晚高峰需求上来,而发电厂开机需要时间,供给跟不上,价格只能直接起飞。
第二,需求的价格弹性极低。普通人不会因为晚上8点电费贵了50%就把全家的空调关掉,工业用户更多是按合同用电。需求曲线近乎垂直,任何供给侧的突发扰动都会在价格上放大成剧烈波动。
第三,供给侧的物理约束和网络约束。机组有最小出力限制、爬坡约束,电网有传输断面约束,跨区电送不过来,局部市场很容易被少数机组撬动价格。这三者叠加,结果就是价格序列里频繁出现脉冲式跳跃,并且跳跃之后往往又快速回归到某个基准水平。
这,正是高水平论文在建模时几乎不约而同选择“均值回复 + 跳跃项”框架的根本原因。
1.3 对模型的硬性要求:四个要素缺一个都危险
基于以上数据特征,一个能用于风险管理目的的电价模型至少要同时具备四样东西:
- 均值回复:电价不会永远停在500元/MWh,它会向长期均衡水平回归;
- 跳跃项:极端的短时脉冲必须能够在模型路径中反复生成;
- 波动率聚集:高波动时段往往扎堆出现,这与市场状态相关;
- 季节性:大多数电力市场有日内、周内、年内季节结构。
一篇好的论文,本质上就是在模型复杂度和可估计性之间做权衡。你复现的时候不需要把模型做得越复杂越好,而是需要读懂作者为什么保留某几项、砍掉某几项。比如有的文章用带跳跃的均值回复模型,但把季节项直接做进确定性平均数里;有的文章用GARCH族描述波动率,跳跃则被吸收到厚尾分布里。不同的道路,对应完全不同的参数估计逻辑,代码机构自然也完全不同。
2. 复现的起点:模型设定与符号约定
复现论文这件事,最大的陷阱其实不在代码,而在“你以为你复现的是那个模型,实际上你复现的是你脑补之后的那个模型”。为了防止脑补,拿到论文之后先做模型梳理,把SDE写清楚、把变量量纲统一、把待估参数列成清单。
2.1 主流跳扩散模型的SDE长什么样
能源价格风险管理的文献里,最常用的一类模型是对数价格建模。设 (S_t) 是现货价格,(X_t = \ln S_t),那么一个典型的带跳跃的均值回复模型可以写成:
[ dX_t = \kappa(\mu - X_t)dt + \sigma dW_t + J dN_t ]
- (\kappa):均值回复速度,单位是“每天回归多少比例”,值越大,价格越快地拉回长期均值;
- (\mu):对数价格的长期均值水平,在带季节性的设定里,它可以是时间的函数 (\mu_t);
- (\sigma):扩散波动率,代表正常价格扰动;
- (N_t):泊松过程,强度为 (\lambda),平均每 (1/\lambda) 天发生一次跳跃;
- (J):跳跃幅度,通常假设服从正态分布 (N(\mu_j, \sigma_j^2)),也可以假设为指数分布来表示向上或向下的不对称冲击。
这里有一个特别容易在复现时翻车的地方:论文里如果写的是 (dS_t = \kappa(\theta - \ln S_t)S_t dt + \sigma S_t dW_t),它的含义与上面这个对数价格模型完全等价,但你在做数值离散化和模拟时,收敛性质会有细微差异。我个人的建议是统一转成对数价格模型,因为连续复合收益率的加法性质使得离散化误差更小,参数也更容易和后面MLE的转移密度匹配。
2.2 区分“模拟用模型”和“估计用模型”
论文复现里还有一层很隐蔽的坑:作者可能在模拟部分用了很细的时间步,比如每小时步长,但在估计参数时只有日度数据。这时候你是不能把日度参数直接等比例缩放成小时参数的,因为跳跃项和扩散项在时间尺度转换下表现完全不同。扩散项的方差随步长线性增长,跳跃项的强度也随步长线性增长,但跳跃项的每个实现并不会因为步长变小而幅度变小。这说明,你必须在参数估计时就明确参数的时点分母,比如“每日回复速度”“每日跳跃频率”。
2.3 参数清单和代码组织
我复现论文时,一定会先画一张参数清单表,不仅是给自己看,也是作为代码的注释基准。举个例子:
| 参数 | 符号 | 约束条件 | 经济含义 |
|---|---|---|---|
| 回复速度 | (\kappa) | > 0 | 价格冲击消散的速度 |
| 长期均值 | (\mu) | 无 | 稳态对数价格水平 |
| 扩散波动率 | (\sigma) | > 0 | 常态价格扰动幅度 |
| 跳跃强度 | (\lambda) | > 0 | 平均每单位时间的跳跃次数 |
| 跳跃均值 | (\mu_j) | 无 | 跳跃方向的倾向 |
| 跳跃标准差 | (\sigma_j) | > 0 | 跳跃幅度的离散程度 |
代码结构上,我推荐用一个结构体存参数,然后用三个文件分别完成:参数估计、路径模拟、风险计算。这样当你换数据集、换论文模型时,只改中间层函数,而不用把整个脚本推倒重来。后面每个环节我都给了能直接跑的MATLAB代码片段,但这些片段是经过我反复简化后的骨架,真正落项目时你还需要把数据清洗和模型诊断嵌入进去。
3. 参数估计:MLE在MATLAB中的完整落地
如果说价格建模是骨架,参数估计就是心脏。能源价格风险管理论文里绝大多数采用极大似然估计,因为它有大样本最优性质,而且对平稳马尔可夫过程实现起来最直接。但难点在于:带跳跃的均值回复模型,它的转移密度不是一个正态分布,而是一个正态分布和跳跃分布混合的无限和,直接套用fitdist或者简单的回归方式是不行的。
3.1 转移密度的近似与似然函数设计
对于时间间隔 (\Delta),模型 (dX_t = \kappa(\mu - X_t)dt + \sigma dW_t + J dN_t) 的解析转移密度无法写成闭环形式,但可以做离散化近似。近似的方法不只一种,我用的、也是论文里常见做法的是:把区间 ([t, t+\Delta]) 内的跳跃次数设为 (n),再对每个可能的跳跃次数,求在给定 (n) 次跳跃条件下的正态转移密度,然后按泊松概率加权求和。
换句话说,给定前一期价格 (X_t),下一期价格可以写成:
[ X_{t+\Delta} = e^{-\kappa\Delta}X_t + (1-e^{-\kappa\Delta})\mu + \epsilon_t ]
其中 (\epsilon_t) 包括扩散部分和一个由泊松混合产生的跳跃部分。为了写似然函数,我们需要枚举跳跃次数 (k = 0, 1, 2, ...),然后累加。实际计算时,由于日度数据间隔下跳跃次数集中在0和1附近,一般截断在 (k=5) 就够用了,再往后概率小到对优化结果没有影响。
3.2 对数似然函数的MATLAB实现
下面这段代码是核心的负对数似然函数。它不是论文级别的优化成品,而是我把大量无关细节剥掉之后留下的主干,方便你看清每一步做了什么。实际使用中,数据向量、模型参数和数据索引都要更仔细地管理。
function nll = neg_loglik_jump_diff(theta, X, dt) % 带跳跃的均值回复模型,负对数似然 % X: 对数价格列向量 % dt: 采样间隔,比如说1代表日度数据 % theta = [kappa, mu, sigma, lambda, mu_j, sigma_j] kappa = theta(1); mu = theta(2); sigma = theta(3); lambda = theta(4); mu_j = theta(5); sigma_j = theta(6); n = length(X); nll = 0; % 预计算均值回复部分 phi = exp(-kappa * dt); % 条件均值的基准部分 % X_t+1 无条件影响 X_t 的系数是 phi for i = 1:n-1 % 给定当前价格,跳跃次数0到5 max_jump = 5; prob_sum = 0; for k = 0:max_jump % 泊松分布概率 pois_prob = exp(-lambda * dt) * (lambda * dt)^k / factorial(k); % 扩散加跳跃后的总体方差 var_diff = sigma^2 * (1 - exp(-2*kappa*dt)) / (2*kappa); % 跳跃次数的方差贡献 var_jump = k * sigma_j^2; % 跳跃均值贡献 mean_jump = k * mu_j; mu_cond = phi * X(i) + (1 - phi) * mu + mean_jump; var_cond = var_diff + var_jump; if var_cond <= 0 prob = 0; else prob = pois_prob * normpdf(X(i+1), mu_cond, sqrt(var_cond)); end prob_sum = prob_sum + prob; end % 防止概率为0导致的log无穷 if prob_sum <= 0 || isnan(prob_sum) prob_sum = 1e-300; end nll = nll - log(prob_sum); end end这个实现有一个明显可优化点:循环里每次都在重新计算var_diff,其实它与数据无关,可以提到循环外。但我故意先写成这个样子,因为初学者更容易对应公式看懂。优化版本是把泊松概率、扩散方差等在循环外向量化,然后对每个观测点做内积。
3.3 参数约束与优化初始值的实战策略
MLE优化不是梯度下降就能随便跑的,带跳跃的模型尤其敏感。我最开始复现时,直接把所有参数扔给fminsearch,结果lambda经常被优化成0,整个模型退化成纯扩散均值回复,跳跃完全消失。后来我的做法是:
- 先做一步矩估计或者简单回归,得到 (\kappa,\mu,\sigma) 的初值;
- 固定跳跃参数一个大概的初始猜想,比如 (\lambda=0.05,\mu_j=0,\sigma_j=0.02),先让优化器调整扩散参数;
- 再把所有参数放开,用
fmincon加约束:(\kappa, \sigma, \lambda, \sigma_j) 的下界为1e-6或更小但不为零,防止退化。
下面是实际项目里我常用的优化入口:
theta0 = [0.3, 3.5, 0.02, 0.05, 0, 0.05]; lb = [1e-6, -10, 1e-6, 1e-6, -0.5, 1e-6]; ub = [5, 10, 1, 5, 0.5, 1]; options = optimoptions('fmincon', 'Display', 'iter', ... 'Algorithm', 'interior-point', 'MaxFunctionEvaluations', 20000); [theta_hat, nll_val] = fmincon(@(th) neg_loglik_jump_diff(th, X, 1), ... theta0, [], [], [], [], lb, ub, [], options);注意,我并没有写非线性约束条件函数,因为风险管理的上下文里暂时没有必须拟合的额外约束。但如果你论文里的模型要求跳跃参数满足某种矩条件,这里只需要替换nonlcon参数即可。
3.4 一个必须做的检验:合成数据还原
“保证正确”的第一步不是拿真实数据跑,而是先模拟一组已知参数的数据,再用你的估计程序把参数还原回去。这一步能帮你捉到90%以上的代码低级错误。比如我会设定 (\kappa=0.2, \mu=3.5, \sigma=0.02, \lambda=0.05, \mu_j=-0.02, \sigma_j=0.04),生成2000个日度观测,然后用上面代码去估计。如果估计结果和真实值偏离超过百分之几十,或者出现参数边界命中,就说明代码或初值策略有问题。
合成数据模拟本身不复杂,我会在下一章讲到风险模拟时给出一个统一的模拟器。因为模拟未来价格路径和模拟历史价格的代码,本质上是一样的,只是起点和步长不同。正由于这个共性,我在项目里把模拟器单独做成了一个函数,既服务参数检验,又服务风险度量。
4. 风险度量:VaR/CVaR的蒙特卡洛模拟与代码组织
参数一旦估计出来,就到了论文复现最有“成果感”的部分:计算VaR和CVaR,画出未来价格分布图。这一步在代码组织上最容易写成一团浆糊。正确的思路是:先实现一个价格路径生成器,再在这个生成器上面批量跑上万次模拟,最后对损失分布取分位数。
4.1 从参数到未来价格路径:模拟器设计
用欧拉离散化近似SDE的时候,核心是决定时间步长。如果你要度量的是未来一天的风险,直接用日度步长模拟可以;如果要度量一周,那么日度步长会低估一周内的路径依赖,所以我通常会把一天切成若干小时子步长,比如每步0.1天,然后聚合收益。
下面是一个朴素但可靠的模拟函数:
function S_final = sim_price_paths(theta, S0, n_steps, n_sims, dt) % 模拟未来价格路径 % S0: 当前价格 % n_steps: 模拟步数 % n_sims: 路径数 % dt: 每步时间间隔(天) kappa = theta(1); mu = theta(2); sigma = theta(3); lambda = theta(4); mu_j = theta(5); sigma_j = theta(6); % 用对数价格模拟 X0 = log(S0); X_final = zeros(n_sims, n_steps+1); X_final(:, 1) = X0; for s = 1:n_steps % 扩散部分 dW = randn(n_sims, 1) * sqrt(dt); % 跳跃次数(泊松) jump_count = poissrnd(lambda * dt, n_sims, 1); jump_component = mu_j * jump_count + sigma_j * sqrt(jump_count) .* randn(n_sims, 1); % 均值回复 X_final(:, s+1) = X_final(:, s) ... + kappa * (mu - X_final(:, s)) * dt ... + sigma * dW + jump_component; end S_final = exp(X_final(:, end)); end这个实现做了几个简化:跳跃幅度生成的近似处理,用的是“同向多个跳跃合并成一个高斯冲击”。实际上,如果你严格模拟 (J dN_t),应该先在每个子步里生成跳跃次数,然后对每一次跳跃单独生成幅度并累加。上面的写法在跳跃次数比较小时近似度尚可,但如果 (\lambda\Delta) 很大,这种合并近似会损失跳跃分布的尾部信息。所以在正式项目里,我会改成更严谨的写法:
jump_count = poissrnd(lambda * dt, n_sims, 1); max_jumps = max(jump_count); for j = 1:max_jumps mask = jump_count >= j; X_row(mask) = X_row(mask) + mu_j + sigma_j * randn(sum(mask), 1); end这个写法等于逐次处理每次跳跃,虽然在MATLAB里看起来多了一层循环,但循环次数由最大跳跃次数决定,在日度步长下通常很小,性能损失可以忽略。
4.2 风险指标的估计与标准误
有了 (N) 条未来价格路径,未来一天的收益率分布就是 (R_i = S_{T,i} / S_0 - 1),我们把负收益记为损失 (L_i = -R_i)。风险价值VaR就是在给定置信水平 (\alpha) 下损失分布的分位数:
alpha = 0.95; VaR = quantile(L, alpha); CVaR = mean(L(L >= VaR));这里数组L是模拟得到的损失序列。做风险管理时我通常不会只计算一个VaR,而是把95%、97.5%、99%三个水平一起算,因为监管和内部资本计量往往要求多个端点的对比。
蒙特卡洛模拟本身会引入估计误差。比如5万条路径和5千条路径,尾部均值差异可以非常大。我建议模拟路径数至少1万条起步,如果算的是99%的VaR,5万条以下都不太够。一个快速的误差判断是看CVaR的稳定性:把模拟分成10组,分别算CVaR,看各组之间的标准差。如果你发现各组CVaR波动超过5%,就加路径数。
4.3 让输出结果充分反映模型特征
论文复现的最终展示,通常需要画出价格分布直方图并标示VaR/CVaR。这里我给一个极简的绘图脚本段:
histogram(L, 200, 'Normalization', 'pdf', 'FaceAlpha', 0.6); xline(VaR, 'r--', 'LineWidth', 1.5); xline(CVaR, 'k-', 'LineWidth', 1.5); legend({'损失分布'}, 'Location', 'northwest'); title('95% VaR 与 CVaR 示意'); xlabel('百分比损失'); ylabel('概率密度');把这个图和你论文里的原图放一起,看形状是否相似:是不是有厚尾、是不是均值附近比较集中、左尾是否特别长。如果分布整体形态都对不上,大概率不是模拟代码的问题,而是参数估计出的跳跃特征和论文不一致。
4.4 风险指标背后的经济直觉
我举一个自己复现时的例子。某组数据估计出来 (\lambda=0.08),意思是平均每12天出现一次跳跃,(\mu_j=-0.04) 表示跳跃很多是向下的。这时候95% VaR看起来和纯扩散模型差不多,但99% VaR要高出一大截。原因在于跳跃对尾部的贡献呈非线性放大效应。纯扩散模型下99%的VaR大概只有95%VaR的1.5倍;有跳跃时,99%的风险可能达到95%VaR的2到3倍。这也就是为什么能源价格风险管理文献特别强调用CVaR而不用方差:方差是完全刻画不了这种尾部跳升的。
5. “保证正确”的复现方法论:从参数还原、残差诊断到回测
“保证正确”这四个字,是用户诉求里分量最重的一句。它不是说你的代码没有语法错误,而是说你复现出来的整个模型在统计学上是站得住脚的,能够被真实数据验证。这个层面的正确性检验,我把它拆成四步。
5.1 第一步:合成数据参数还原
上一章我提过,模拟已知参数的数据,然后跑估计程序,看能不能还原参数。严格地说,这一步不仅要求点估计接近真实值,还要看标准误能否覆盖真实值。你可以把合成数据重复生成50次,每次估计一组参数,然后看这50组参数的均值是否围绕真实参数波动,波动幅度是否和估计程序的渐近标准误差一致。如果偏差系统性地偏向某个方向,比如(\lambda)总是被低估,说明似然函数的截断阶数不够或者跳跃项参数化和论文不一致。
5.2 第二步:模型残差的概率积分变换检验
参数估计完之后,很多人的习惯是看一眼残差自相关图画得干不干净就结束了。但要用分布模型做风险度量,光看残差自相关不够,还要看概率积分变换(PIT)。PIT的核心思想是:如果模型拟合得很好,那么对每个观测点计算条件累积分布函数 (F_t(X_{t+1} | X_t, \theta)),得到的序列应该服从标准均匀分布 (U(0,1))。
在带跳跃的模型里,这个条件累积分布函数正是我们做似然函数时算的那个加权混合分布的累积版本。把它算出来之后,再做一次检验变换 (z_t = \Phi^{-1}(u_t)),得到的 (z_t) 应该是一列标准正态独立随机变量。对(z_t)做Ljung-Box自相关检验,如果p值很高,说明模型的条件分布校准是好的。
这一步非常必要,因为它恰好能抓住“参数估计正确但分布假设错误”的案例。很多论文里为了减少计算量,省略了这一步,我觉得这是复现者可以做得比原论文更严谨的地方。
5.3 第三步:Kupiec检验和Christoffersen检验
风险管理角度的正确性验证,最终要落在风险覆盖能力上。最常用的检验是Kupiec的失败率检验,也叫无条件覆盖检验。它的逻辑很简单:设定95%VaR,然后在样本外滚动回测中记录实际损失超过VaR的比例。如果模型是对的,那么失败率应该在5%附近。Kupiec检验用似然比统计量判断实际失败率是否显著偏离目标比例。
Christoffersen检验则在Kupiec基础上进一步要求失败事件在时间上独立:如果模型忽略了波动率聚集性,那么失败事件往往会扎堆出现。比如连着三天突破VaR,事后来看可能就是因为模型没有捕捉到高波动持续期。我把这两项检验写成一个函数,输入损失序列和VaR序列,输出两个p值。这比肉眼观察图形要客观得多。
5.4 第四步:跨数据集稳健性测试
高水平论文之所以被引用很多次,有一个隐含前提是模型在不同市场、不同时间段都适用。作为复现者,你不应该把模型只在论文同一段数据上跑一遍就宣称“复现完成”。我的做法是至少找两个不同的市场数据或者同一市场不同时间段的数据,重复估计参数和计算风险指标。如果两个样本估计出的跳跃强度和回复速度在置信区间内有重叠,说明模型的解释力是稳定的;如果差距巨大,就要回来检查论文原本的适用边界。
这一步容易受数据可得性限制,但至少你可以把自己手上的数据集切前后两段做对比。切分方式用滚动窗口更好,比如每半年估计一次,连续估计两三年,看参数的时间路径是否平滑。
5.5 复现日常工作流总结
为了避免每次复现论文都从零开始,我最后整理了一个固定的工作流清单,也分享给你们:
- 读论文:将所有方程转换为同一符号体系,列出所有待估参数和约束;
- 合成数据测试:用已知参数模拟数据,跑通估计与风险计算全流程;
- 真实数据估计:先用粗略初值优化,再放开全参数优化;
- 模型诊断:PIT检验、自相关检验、跳跃残差形态观察;
- 风险指标计算:VaR/CVaR、滚动回测、Kupiec与Christoffersen检验;
- 稳健性测试:更换样本期,重复步骤3到5;
- 归档:保存估计日志、收敛状态、图表输出,方便排查。
你会发现这个流程里没有“改代码改到吐”这个步骤,因为充分的前置工作——模型梳理和合成数据检验——已经把大部分代码级错误提前排除了。剩下真正需要反复调整的,往往是参数初始值和数值优化边界,而这些都是正常现象。
说个我自己的体会:能源价格风险管理模型的复现,最大的瓶颈不是MATLAB技巧,而是对“风险”的理解深度。你只有想清楚每个参数在真实市场里对应什么经济现象——跳跃会不会季节性变化、回复速度在供给紧张时是否更慢——写出来的代码才不是照着公式硬搬,而是真正能用。最后再给一个具体的建议:正式跑批量模拟之前,把所有随机数种子固定下来,并且在输出里记录当前种子值。这个习惯帮我在后续排查论文结果差异时节省了无数个小时,因为你可以精准复现任意一次模拟结果,而不是在不可复现的随机波动里大海捞针。