Summary: 面向进阶用户的EA压力测试高级方法,使用自助法重采样对历史交易序列进行蒙特卡洛模拟,揭示标准回测无法发现的路径依赖风险,并提供完整的MQL5实现代码。




一个显示42%净利润和1.8盈利因子的回测结果在纸面上看起来很棒。但令人不安的真相是:这个结果是路径依赖的。历史数据中特定的盈亏序列产生了那条特定的权益曲线——如果相同的交易以不同的顺序到达,结果可能会截然不同。

1. 路径依赖问题

我们在一款EURUSD M30趋势跟踪EA上亲眼目睹了这个问题。该策略具有正期望值、不错的胜率和略高于1.6的盈利因子。然而,当我们对其进行序列压力测试时,在大约12%的替代交易排序中,该策略在200笔交易历史中到达第80笔交易之前就会触发保证金追缴。相同的交易,相同的优势,只是运气顺序不同。这就是在回测阶段没人谈论的问题。

2. 自助法重采样方法

解决方案是采用自助法重采样的蒙特卡洛模拟——这是可用蒙特卡洛方法中最简单、最不依赖假设的形式。

给定一组历史交易结果(盈亏值),将每笔交易视为从某个未知分布中抽取的独立观测值。对于每次模拟运行,从历史池中随机放回地抽取N笔交易,然后从初始余额开始将它们累积成一条合成权益曲线。重复1000次,获得1000条可能的权益路径。

为什么使用自助法而不是参数模型(如拟合正态分布)?因为真实的盈亏分布不是正态的——它们是厚尾的、往往偏斜的、有时是双峰的。自助法不对分布形状做任何假设,它从实际发生的数据中采样。

3. 完整蒙特卡洛风险评估器实现

```cpp
//+------------------------------------------------------------------+
//| MonteCarlo_RiskAssessor.mq5 |
//| 使用自助法重采样对交易序列进行压力测试 |
//+------------------------------------------------------------------+
#property script_show_inputs

//--- 核心模拟参数
input string InpCSVFile = "trades.csv"; // MQL5\Files目录下的CSV文件
input int InpSimulations = 1000; // 蒙特卡洛模拟次数
input double InpInitialBalance = 10000.0; // 初始账户余额(美元)
input double InpRuinThreshold = 0.20; // 破产阈值 = 20%回撤

//--- 手续费与滑点压力测试
input bool InpSlippageEnabled = false;
input double InpCommission = 2; // 每笔固定手续费(美元)
input double InpSlippageMax = 3; // 每笔最大随机滑点(美元)

//--- CSV导出
input bool InpExportCSV = true;
input string InpExportFile = "mc_results.csv";

//--- 全局数据数组
double g_Profits[]; // 从CSV加载的每笔交易盈亏
double g_FinalEquities[]; // 每次模拟的最终权益
double g_MaxDrawdowns[]; // 每次模拟的最大回撤(%)
double g_AllCurves[]; // 扁平化的[模拟次数 × (交易数+1)]权益矩阵
int g_TradeCount = 0;

//+------------------------------------------------------------------+
//| 从CSV文件加载交易盈亏数据到g_Profits数组 |
//+------------------------------------------------------------------+
bool LoadTradesFromCSV(const string fileName) {
int handle = FileOpen(fileName, FILE_READ | FILE_CSV | FILE_ANSI, ',');
if(handle == INVALID_HANDLE) {
PrintFormat("错误:无法打开'%s'。请确认文件在MQL5\\Files\\目录下。错误码:%d", fileName, GetLastError());
return false;
}

// 跳过表头行(如果存在)
if(!FileIsEnding(handle))
FileReadString(handle);

double buffer[];
int count = 0;

while(!FileIsEnding(handle)) {
string row = FileReadString(handle);
StringTrimRight(row);
StringTrimLeft(row);
if(StringLen(row) == 0) continue;

string cols[];
int nCols = StringSplit(row, ',', cols);
double profit = 0.0;

if(nCols >= 2)
profit = StringToDouble(cols[1]); // 假设第2列是盈亏数据
else if(nCols == 1)
profit = StringToDouble(cols[0]);

ArrayResize(buffer, count + 1, 1000);
buffer[count++] = profit;
}

FileClose(handle);

if(count == 0) {
Print("错误:未找到有效的交易数据。");
return false;
}

ArrayResize(g_Profits, count);
ArrayCopy(g_Profits, buffer, 0, 0, count);
g_TradeCount = count;

PrintFormat("从%s加载了%d笔交易", fileName, g_TradeCount);
return true;
}

//+------------------------------------------------------------------+
//| 核心自助法蒙特卡洛模拟引擎 |
//+------------------------------------------------------------------+
void RunMonteCarloSimulation() {
int curveLen = g_TradeCount + 1;
ArrayResize(g_FinalEquities, InpSimulations);
ArrayResize(g_MaxDrawdowns, InpSimulations);
ArrayResize(g_AllCurves, InpSimulations * curveLen);

double equity[];
ArrayResize(equity, curveLen);
MathSrand((int)TimeLocal());

for(int sim = 0; sim < InpSimulations; sim++) {
equity[0] = InpInitialBalance;
double maxEquity = InpInitialBalance;
double maxDrawdown = 0;

for(int t = 0; t < g_TradeCount; t++) {
int rIdx = MathRand() % g_TradeCount;
double pnl = g_Profits[rIdx];

// 如果启用,应用手续费和滑点
if(InpSlippageEnabled) {
double slip = ((double)MathRand() / 32767.0) * InpSlippageMax;
pnl -= (InpCommission + slip);
}

equity[t+1] = equity[t] + pnl;

// 跟踪回撤
if(equity[t+1] > maxEquity)
maxEquity = equity[t+1];

double currentDD = (maxEquity - equity[t+1]) / maxEquity * 100;
if(currentDD > maxDrawdown)
maxDrawdown = currentDD;

// 存储到扁平化矩阵
g_AllCurves[sim * curveLen + (t+1)] = equity[t+1];
}

g_FinalEquities[sim] = equity[curveLen - 1];
g_MaxDrawdowns[sim] = maxDrawdown;
}
}

//+------------------------------------------------------------------+
//| 从排序数组计算百分位数 |
//+------------------------------------------------------------------+
double Percentile(double &array[], double p) {
int size = ArraySize(array);
if(size == 0) return 0;

double sorted[];
ArrayCopy(sorted, array);
ArraySort(sorted);

double index = (size - 1) * p;
int lower = (int)MathFloor(index);
int upper = (int)MathCeil(index);

if(lower == upper)
return sorted[lower];

double weight = index - lower;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}

//+------------------------------------------------------------------+
//| 从模拟结果计算风险指标 |
//+------------------------------------------------------------------+
void ComputeRiskMetrics() {
// 为百分位数计算排序
double sortedFinalEq[];
ArrayCopy(sortedFinalEq, g_FinalEquities);
ArraySort(sortedFinalEq);

double sortedDD[];
ArrayCopy(sortedDD, g_MaxDrawdowns);
ArraySort(sortedDD);

// 最终权益的百分位数
double p05Eq = Percentile(sortedFinalEq, 0.05);
double p50Eq = Percentile(sortedFinalEq, 0.50);
double p95Eq = Percentile(sortedFinalEq, 0.95);

// 最大回撤的百分位数
double p50DD = Percentile(sortedDD, 0.50);
double p95DD = Percentile(sortedDD, 0.95);

// 风险价值(5%)
double varAmount = InpInitialBalance - p05Eq;

// 破产概率(回撤超过阈值)
int ruinCount = 0;
for(int i = 0; i < InpSimulations; i++) {
if(g_MaxDrawdowns[i] >= InpRuinThreshold * 100)
ruinCount++;
}
double probRuin = (double)ruinCount / InpSimulations;

// 输出结果
Print("=== 蒙特卡洛风险评估结果 ===");
PrintFormat("模拟次数: %d", InpSimulations);
PrintFormat("初始余额: $%.2f", InpInitialBalance);
PrintFormat("破产阈值: %.1f%%", InpRuinThreshold * 100);
Print("");
Print("--- 最终权益分布 ---");
PrintFormat("第5百分位数: $%.2f", p05Eq);
PrintFormat("第50百分位数(中位数): $%.2f", p50Eq);
PrintFormat("第95百分位数: $%.2f", p95Eq);
Print("");
Print("--- 回撤分布 ---");
PrintFormat("中位数最大回撤: %.1f%%", p50DD);
PrintFormat("第95百分位数最大回撤: %.1f%%", p95DD);
Print("");
Print("--- 风险指标 ---");
PrintFormat("风险价值(5%%): $%.2f", varAmount);
PrintFormat("破产概率(>%.1f%%回撤): %.1f%%", InpRuinThreshold * 100, probRuin * 100);
}

//+------------------------------------------------------------------+
//| 导出扇形图数据到CSV文件 |
//+------------------------------------------------------------------+
void ExportFanChartData() {
if(!InpExportCSV) return;

int handle = FileOpen(InpExportFile, FILE_WRITE | FILE_CSV | FILE_ANSI, ',');
if(handle == INVALID_HANDLE) {
Print("创建导出文件失败");
return;
}

int curveLen = g_TradeCount + 1;

// 写入表头
FileWrite(handle, "TradeNum", "P5", "P25", "P50", "P75", "P95");

// 计算每个交易步骤的百分位数
for(int step = 0; step < curveLen; step++) {
double values[];
ArrayResize(values, InpSimulations);

for(int sim = 0; sim < InpSimulations; sim++) {
values[sim] = g_AllCurves[sim * curveLen + step];
}

double p05 = Percentile(values, 0.05);
double p25 = Percentile(values, 0.25);
double p50 = Percentile(values, 0.50);
double p75 = Percentile(values, 0.75);
double p95 = Percentile(values, 0.95);

FileWrite(handle, step, p05, p25, p50, p75, p95);
}

FileClose(handle);
PrintFormat("扇形图数据已导出至: %s", InpExportFile);
}

//+------------------------------------------------------------------+
//| 脚本入口点 |
//+------------------------------------------------------------------+
void OnStart() {
if(!LoadTradesFromCSV(InpCSVFile)) {
Print("加载交易数据失败。退出。");
return;
}

PrintFormat("开始蒙特卡洛模拟,共%d次模拟...", InpSimulations);
uint startTime = GetTickCount();

RunMonteCarloSimulation();

uint elapsed = GetTickCount() - startTime;
PrintFormat("模拟完成,耗时%.2f秒", elapsed / 1000.0);

ComputeRiskMetrics();
ExportFanChartData();
}
```

4. 解读扇形图

五个百分位数曲线——第5、25、50、75和95百分位——构成了锚定输出的扇形图。第50百分位数是典型结果。第5百分位数是“糟糕运行”场景:95%的模拟最终权益高于这个水平。在任何给定交易步骤处扇形的宽度是结果不确定性的直接视觉度量。如果在序列早期扇形就很宽,意味着该策略对序列顺序敏感——这在上线前值得关注。

5. 四个核心风险指标

从模拟结果中可以提取四个关键指标:

| 指标 | 描述 |
|------|------|
| 中位数最大回撤 | 典型的从峰值到谷底的最差跌幅 |
| 压力回撤(第95百分位数) | 仅在5%的模拟运行中会超过的回撤水平 |
| 风险价值(5%) | 初始余额 − 第5百分位数最终权益 |
| 破产概率 | 超过破产阈值的模拟运行所占比例 |

参考来源:MQL5社区《使用蒙特卡洛进行交易序列压力测试》(mql5.com/articles/22291);罗伯特·帕尔多《交易策略评估与优化》(Wiley出版社,2008)。