使用凯利准则与蒙特卡洛模拟的投资组合风险模型·进阶篇
📘

使用凯利准则与蒙特卡洛模拟的投资组合风险模型·进阶篇

第 2/2 篇

◍ 用固定风险百分比倒推开仓手数

在 MT5 里做杠杆交易,先得把每笔愿意亏的比例钉死。上面这段逻辑默认单笔风险 2%,也就是账户余额乘 0.02 作为绝对可接受亏损额,后面所有手数都围着这个数转。 手数不是拍脑袋来的。代码先抓三个品种属性:最小跳价 ticksize、每跳价值 tickvalue、手数最小步进 lotstep;再用止损距离(slpoints)除以 ticksize 算出总共多少跳,乘 tickvalue 和 lotstep,就得到「每最小手数单位在本次交易里会亏多少货币」。风险额除以它,向下取整到 lotstep,就是候选手数。 别以为算完就能直接发单。MT5 每个品种都有 SYMBOL_VOLUME_MIN 和 SYMBOL_VOLUME_MAX,代码里用 MathMax/MathMin 夹了一遍,避免券商拒绝非标手数。外汇和贵金属自带高杠杆,2% 风险在 1:500 账户下可能对应十几手,爆仓概率随跳空放大,实盘前务必在策略测试器跑一遍。 回测曲线会骗人。稳定盈利的 EA 用百分比风险模型,权益曲线倾向走成指数而非直线——近期的盈利权重天然高于早期。线性回归、夏普比率在这类曲线上会失真,因为它们默认风险收益线性。真想看这些指标,把手数写死重测一次即可。 下面这段是核心函数的原样,逐行拆给你看: input double risk = 2.0; —— 全局输入,单笔风险百分比,默认 2 // 注释块:计算给定风险对应的手数 double calclots(double slpoints) —— 传入止损点数(价格单位),返回手数 double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100; —— 账户余额乘风险百分比得风险金额 double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); —— 当前品种最小价格变动 double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); —— 每最小变动的账户货币价值 double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); —— 手数最小变动单位 double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep; —— 每 lotstep 手数在止损距离下的货币风险 double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep; —— 风险额能覆盖几份 lotstep,向下取整 lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX)); —— 封顶到最大允许手数 lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN)); —— 兜底到最小允许手数 return lots; —— 返回合规手数 executeBuy 里先按 _Point 算 sl,NormalizeDouble 对齐小数位,risk>0 才调 calclots,否则用手动 lotpoint;最后用 BuyStop 挂单,把订单号存 buypos。

MQL5 / C++
input class="type">class="kw">double risk = class="num">2.0;
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Calculate the corresponding lot size given the risk                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double calclots(class="type">class="kw">double slpoints)
{
   class="type">class="kw">double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / class="num">100;
   class="type">class="kw">double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   class="type">class="kw">double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
   class="type">class="kw">double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
   class="type">class="kw">double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep;
   class="type">class="kw">double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep;
   lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX));
   lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN));
   class="kw">return lots;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Execute buy trade function                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void executeBuy(class="type">class="kw">double price) {
    class="type">class="kw">double sl = price- slp*_Point;
    sl = NormalizeDouble(sl, _Digits);
    class="type">class="kw">double lots = lotpoint;
    if (risk > class="num">0) lots = calclots(slp*_Point);
    trade.BuyStop(lots,price,_Symbol,sl,class="num">0,ORDER_TIME_DAY,class="num">1);
    buypos = trade.ResultOrder();
    }

用洗牌法给回撤定一条容忍线

把资金曲线看成账户余额的一串百分比变动,最大回撤就是这串序列里累计最差的那段。单次回测只是历史排列的一种可能,统计稳健性有限;想知道实盘可能撞上的回撤边界,得靠蒙特卡洛把序列打乱重排。本文选了洗牌法:随机重排历史回报率顺序,用重排后的序列生成多条资金路径,最终权益不变——乘法交换律保证无论怎么排,乘积都一样。 具体落地:从 MT5 回测报告右键导出交易记录,用 Python 读 Excel 跳过表头脏行,抽出 Profit 和 Balance 两列,把每笔盈亏转成「交易前余额」的百分比变化。这样重排后曲线形态会变,终点权益却锁死,才能公平比较不同路径的回撤深度。 模拟 1000 条随机序列后,取最大回撤最大的前 10 条绘图,回撤分布接近正态,95% 分位数(约两个标准差)落在 30% 左右。原始回测最大回撤仅 17%,比分布均值还小;若拿它当预期回撤上限,相对蒙特卡洛揭示的真实风险约等于放大了一倍。外汇与贵金属 EA 实盘波动剧烈、属高风险品类,用 95% 分位数作容忍基准更接近学者认可的实盘表现。 本例 95% 分位数恰与预设 30% 容忍度接近,意味着单 EA 组合下每笔 2% 风险可能让利润最大化且卡在容忍线内;若两者偏离,应重复洗牌—重算流程调参,直到找到匹配解。

MQL5 / C++
class="kw">import pandas as pd
# Replace &class="macro">#x27;your_file.xlsx&class="macro">#x27; with the path to your file
input_file = &class="macro">#x27;DBG-XAU.xlsx&class="macro">#x27;
# Load the Excel file and skip the first {skiprows} rows
data = pd.read_excel(input_file, skiprows=class="num">10757)
# Select the &class="macro">#x27;profit&class="macro">#x27; column(assumed to be &class="macro">#x27;Unnamed: class="num">10&class="macro">#x27;) and filter rows as per your instructions
profit_data = data[[&class="macro">#x27;Profit&class="macro">#x27;,&class="macro">#x27;Balance&class="macro">#x27;]][class="num">1:-class="num">1] 
profit_data = profit_data[profit_data.index % class="num">2 == class="num">0]  # Filter for rows with odd indices
profit_data = profit_data.reset_index(drop=True)  # Reset index
# Convert to class="type">float, then apply the condition to set values to class="num">1 if > class="num">0, otherwise to class="num">0
profit_data = profit_data.apply(pd.to_numeric, errors=&class="macro">#x27;coerce&class="macro">#x27;).fillna(class="num">0)  # Convert to class="type">float, replacing NaN with class="num">0
# Save the processed data to a new CSV file with index
output_csv_path = &class="macro">#x27;processed-DBG-XAU.csv&class="macro">#x27;
profit_data.to_csv(output_csv_path, index=True, header=[&class="macro">#x27;profit_loss&class="macro">#x27;,&class="macro">#x27;account_balance&class="macro">#x27;])
print(f"Processed data saved to {output_csv_path}")
initial_balance = account_balance.iloc[class="num">0] - profit_loss.iloc[class="num">0]
# Calculate the account balance before each trade
account_balance_before_trade = account_balance.shift(class="num">1)
account_balance_before_trade.iloc[class="num">0] = initial_balance
# Compute the percentage change made to the account balance for each trade
percentage_change = profit_loss / account_balance_before_trade
# Fill any NaN values that might have occurred
percentage_change.fillna(class="num">0, inplace=True)

「多EA账户的凯利权重落地」

在一个 MT5 账户上跑多个 EA 时,先给每个 EA 单独算出最优风险,再按组合权重切分资金。每个 EA 的风险金额 = 原始风险 × 分配权重,权重由它和其他 EA 的收益相关性及回测表现共同决定,核心目的是让回撤互相抵消,使资金曲线更平滑。 只有新加的 EA 与原有策略不相关,增加数量才真正提升多样性;否则相当于放大单一 EA 风险,外汇与贵金属本身杠杆高、回撤可能失控,这点不能忽视。计算用到的变量:r 为某 EA 在 t 时刻回报,μ 为平均回报,f 为凯利权重向量,Σ−1 是收益协方差矩阵的逆,u 是预期回报向量。 提取这些变量必须对 each EA 做回测,把百分比收益序列塞进同一个数据框;协方差依赖同时间段相关性,所以要统一起止时间并选好聚合间隔。我们案例中取「月」为间隔、用月均收益作特征,间隔过散会导致样本不足、协方差算不准。下面这段 Python 负责读回报并拼表: # Read returns for each strategy for file in strategy_files: try: data = pd.read_csv(file, index_col='Time') # Ensure 'Time' is parsed correctly as datetime data.index = pd.to_datetime(data.index, errors='coerce') # Drop rows where 'Time' or 'return' is invalid data.dropna(subset=['return'], inplace=True) # Aggregate duplicate time indices by mean data = data.groupby(data.index).agg({'return': 'mean'}) # Append results returns_list.append(data['return']) strategy_names.append(file) except Exception as e: print(f"Error processing {file}: {e}") continue # Check if any data was successfully loaded if not returns_list: print("No valid data found in files.") return # Combine returns into a single DataFrame, aligning by date returns_df = pd.concat(returns_list, axis=1, keys=strategy_names) # returns_df.dropna(inplace=True) returns_df.fillna(0, inplace=True) # Calculate expected returns (mean returns) expected_returns = returns_df.mean() # Calculate the covariance matrix of returns cov_matrix = returns_df.cov() # Compute the inverse of the covariance matrix try: inv_cov_matrix = np.linalg.inv(cov_matrix.values) except np.linalg.LinAlgError: # Use pseudo-inverse if covariance matrix is singular 逐行拆解:循环读每个策略的 CSV,索引设为 Time 并转 datetime;清掉 return 为空的行;按时间重复索引用均值聚合(忽略交易频率差异);收进列表并记文件名。无数据就退出。concat 按日期对齐成矩阵,缺失填 0(适合回测年份错位的 EA)。之后 mean() 算预期回报,cov() 算协方差矩阵,再求逆;奇异则走伪逆。 算出 f 后直接写回 MQL5 原始代码即可,因为最初风险就是基于总余额算的,账户余额一动,分配给各 EA 的资本自动重算并用于下一单。比如改示例 EA 里那一段风险系数,活就干完了。 别把分配资本的变化当风险基准 基于分配资金变动作风险,表现好的 EA 分到的钱多、风险也跟着膨胀,会把按相关性分配风险的初衷冲掉。凯利准则是为整个组合长期增长服务的,单个 EA 盈亏会牵动其他 EA 的风险额度。 这种算法也有短板:EA 风险随整体组合波动,盯单个 EA 表现变难,得看百分比变化而非绝对利润;且不纳入交易频率,同账户 EA 频率差巨大时,分配了也可能敞口不均。对个人交易者而言,它最大化增长潜力的概率偏高,值得采用。

MQL5 / C++
# Read returns for each strategy
    for file in strategy_files:
        try:
            data = pd.read_csv(file, index_col=&class="macro">#x27;Time&class="macro">#x27;)
            # Ensure &class="macro">#x27;Time&class="macro">#x27; is parsed correctly as class="type">class="kw">datetime
            data.index = pd.to_datetime(data.index, errors=&class="macro">#x27;coerce&class="macro">#x27;)
            
            # Drop rows where &class="macro">#x27;Time&class="macro">#x27; or &class="macro">#x27;class="kw">return&class="macro">#x27; is invalid
            data.dropna(subset=[&class="macro">#x27;class="kw">return&class="macro">#x27;], inplace=True)
            
            # Aggregate duplicate time indices by mean(or could use &class="macro">#x27;sum&class="macro">#x27;, but here mean can ignore the trade frequency significance)
            data = data.groupby(data.index).agg({&class="macro">#x27;class="kw">return&class="macro">#x27;: &class="macro">#x27;mean&class="macro">#x27;})
            
            # Append results
            returns_list.append(data[&class="macro">#x27;class="kw">return&class="macro">#x27;])
            strategy_names.append(file)
        except Exception as e:
            print(f"Error processing {file}: {e}")
            class="kw">continue
    # Check if any data was successfully loaded
    if not returns_list:
        print("No valid data found in files.")
        class="kw">return
    # Combine returns into a single DataFrame, aligning by date
    returns_df = pd.concat(returns_list, axis=class="num">1, keys=strategy_names)
    # Uncomment the below line if u wanna drop rows with missing values across strategies
    class="macro">#returns_df.dropna(inplace=True)
    class="macro">#Uncomment the below line if u wanna just fill unaligned rows with class="num">0( I think this is best for backtest that may have years differences)
    returns_df.fillna(class="num">0, inplace=True)
    # Calculate expected returns(mean returns)
    expected_returns = returns_df.mean()
    
    # Calculate the covariance matrix of returns
    cov_matrix = returns_df.cov()
    
    # Compute the inverse of the covariance matrix
    try:
        inv_cov_matrix = np.linalg.inv(cov_matrix.values)
    except np.linalg.LinAlgError:
        # Use pseudo-inverse if covariance matrix is singular

◍ 协方差逆矩阵算出的仓位权重怎么落进MT5

上面那段 Python 先把收益率协方差矩阵求伪逆,再乘预期收益向量得到原始 Kelly 权重,最后归一化。出来的三组回测标的里,XAU 组合占 0.211095、SP 占 0.682924、EU 占 0.105981,黄金仓位明显低于股指。 把归一化后的 XAU 权重 0.211095 直接塞进 MT5 的风险参数,等价于总风险的约 21%。下面这行把账户余额乘 percent risk 算出每笔风险金额,外汇贵金属波动大,实盘这么配仓仍可能触发连亏,需自行压力测试。 double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100; 这行取账户余额乘以 risk 百分比,得到绝对风险美元数。input double risk = 2.0*0.211095; 则是把 2% 总风险锚定到黄金子策略的权重,开 MT5 把这句贴进 ea 输入段就能跑。

MQL5 / C++
class="type">class="kw">double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / class="num">100;
input class="type">class="kw">double risk = class="num">2.0*class="num">0.211095;

把这条线请下神坛

整套流程跑下来,核心就三件可验证的事:用 KellyMultiFactors.ipynb 算多因子凯利分数做资金分配,用 MonteCarloDrawdown.ipynb 基于单次回测跑蒙特卡洛去估最优最大回撤,再把 EA 间相关性塞进权重里控组合风险。Risk_Management.zip 里 190.96 KB 的文件直接下到 MT5 环境就能复现。 凯利准则在杠杆空间里只是概率倾向下的仓位参考,不是账户圣杯;外汇与贵金属自带高杠杆高风险,回测得出的分数若直接满仓跟,爆仓概率可能显著抬升。 开 MT5 把那两个 ipynb 的输出接进你现有 EA 回测报告,先只看相关性矩阵变没变样,比急着调仓位更有用。

常见问题

手数=账户权益×风险百分比÷(止损点数×每点价值)。上述例子:10000×1%÷(20×10)=0.5手(标准账户每点10刀),直接按此倒推即可。
把历史收益序列随机打乱重采样几千次,看最大回撤分布取95分位作容忍线。可用Python或Excel重采样,实盘前跑一遍已知策略历史数据。
小布可读取你的账户与策略配置,自动跑凯利权重与蒙特卡洛回撤模拟,直接标出超容忍线的组合并给调仓建议。
按各EA历史夏普与相关性算总凯利后,用协方差逆矩阵拆成子账户权重,低相关EA可并跑,高相关需降总仓避免共振。
正常,矩阵权重是理论比例,需再乘账户总风险预算并受平台最小手数限制,落地时四舍五入或取整到合规手数即可。