在图表上快速检验交易理念·综合运用
(3/3)·从 451 个参赛 EA 的说明里挑出真信号,用现成框架在图表上 10 分钟验完一个念头
翻 ATC 2012 的 451 个 EA,只有 316 个写了像样的说明,剩下不是问候外星人就是夸自己。指标堆得最猛的是移动平均线 75 次、MACD 54 次,但跟随套路扎堆不代表有效。新手常把「别人用的多」当「该信」,跳过自己验这一步就直接上参数。
余额计算器的骨架与初始化
在 MT5 里做策略回测或实盘推演时,先把账户余额变动的逻辑封装成一个类往往更干净。下面这段 CBaseBalanceCalculator 的构造与清理代码,就是这类封装的底座,负责把点值、持仓量、持仓价这些状态归零,避免跨品种或跨周期调用时脏数据拖累计算。 构造函数允许传入 symbol_name,为空时默认取当前图表品种的点值(SYMBOL_POINT)。外汇与贵金属点值差异很大,比如 XAUUSD 一点可能是 0.01,而 EURUSD 常见也是 0.00001 级别,用 SymbolInfoDouble 动态取才能保住跨品种复用性。 Cleanup 只做两件事:把 m_position_volume 和 m_position_price 置 0。别小看这一步,很多自行写的平衡计算在反复 Calculate 时爆掉,根因就是上一次持仓状态没清。 Calculate 的入口先 ZeroMemory(m_results),把输出结构体整块擦干净,再读入 _prev_balance、_signal、_next_open、_next_spread 四个参数。注意外汇和贵金属杠杆高、点差跳变频繁,_next_spread 若用实时值而非历史tick,回测结果可能偏向乐观。
class="kw">const class="type">int _signal, class="kw">const class="type">class="kw">double _next_open, class="kw">const class="type">class="kw">double _next_spread ); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| CBaseBalanceCalculator | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CBaseBalanceCalculator::CBaseBalanceCalculator(class="type">class="kw">string symbol_name = "") { class=class="str">"cmt">// Clean up state variables Cleanup(); class=class="str">"cmt">// Define point size(because we will calculate the profit in points) if (symbol_name == "") m_symbol_points = SymbolInfoDouble(Symbol(), SYMBOL_POINT); else m_symbol_points = SymbolInfoDouble(symbol_name, SYMBOL_POINT); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Cleanup | class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Clean up data on positions and prices | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CBaseBalanceCalculator::Cleanup() { m_position_volume = class="num">0; m_position_price = class="num">0; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Calculate | class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Main calculation block | class=class="str">"cmt">//+------------------------------------------------------------------+ BalanceResults CBaseBalanceCalculator::Calculate( class="kw">const class="type">class="kw">double _prev_balance, class="kw">const class="type">int _signal, class="kw">const class="type">class="kw">double _next_open, class="kw">const class="type">class="kw">double _next_spread ) { class=class="str">"cmt">// Clean up the output structure from the previous values ZeroMemory(m_results); class=class="str">"cmt">// Initialize additional variables
「信号驱动下的仓位与余额重算逻辑」
回测框架里,每一根 K 线收完都要根据信号重写账户状态。信号为 0 时什么都不做,直接把上一根的平衡值搬过来,这是避免无谓成交的基础设定。 当信号方向与已有仓位同向、或当前还没有仓位时,逻辑开始分叉。已有仓位就忽略信号,余额不变;空仓时若收到 +1 买信号,用 _next_open 加 spread 点算出 ask 价,把新 1 手均价并入 m_position_price,volume 加 1;收到 -1 卖信号则按 bid 价算,volume 减 1,均价公式符号反转。 若已有仓位却收到反向信号,就先平仓再视情况开新仓。以平空为例,用 ask 价算 profit = (current_price - m_position_price) / m_symbol_points * m_position_volume,余额加上该值;若信号恰为 +1 则立刻以 current_price 开 1 手多单,否则仓位清零。外汇与贵金属杠杆高,这套重算若 spread 或 point 取值错,回测权益曲线会系统性偏离实盘。 下面这段是核心分支的原文,可逐行对照 MT5 策略测试器的 OnTester 重写: double current_price = 0; // 当前价,买按ask卖按bid double profit = 0; // 利润暂存 // 无信号则余额沿用 if (_signal == 0) m_results.balance = _prev_balance; // 信号与仓位同向或空仓 else if (_signal * m_position_volume >= 0) { // 已有仓位,忽略信号 if (m_position_volume != 0) m_results.balance = _prev_balance; // 空仓且买信号 else if (_signal == 1) { current_price = _next_open + _next_spread * m_symbol_points; m_position_price = (m_position_volume * m_position_price + current_price) / (m_position_volume + 1); m_position_volume = m_position_volume + 1; m_results.balance = _prev_balance; } // 空仓且卖信号 else if (_signal == -1) { current_price = _next_open; m_position_price = (-m_position_volume * m_position_price + current_price) / (-m_position_volume + 1); m_position_volume = m_position_volume - 1; m_results.balance = _prev_balance; } else m_results.balance = _prev_balance; } // 已有仓位且收到反向信号 else { // 买信号:平空 if (_signal > 0) { current_price = _next_open + _next_spread * m_symbol_points; profit = (current_price - m_position_price) / m_symbol_points * m_position_volume; m_results.balance = _prev_balance + profit; if (_signal == 1) { m_position_price = current_price; m_position_volume = 1; } else m_position_volume = 0; } // 卖信号:平多 else { current_price = _next_open; profit = (current_price - m_position_price) / m_symbol_points * m_position_volume; m_results.balance = _prev_balance + profit;
class="type">class="kw">double current_price = class="num">0; class=class="str">"cmt">// current price(bid or ask depending on position direction) class="type">class="kw">double profit = class="num">0; class=class="str">"cmt">// profit calculated value class=class="str">"cmt">// If there was no signal, the balance remains the same if (_signal == class="num">0) m_results.balance = _prev_balance; class=class="str">"cmt">// the signal coincides with the direction or no positions are opened yet else if (_signal * m_position_volume >= class="num">0) { class=class="str">"cmt">// Position already exists, the signal is ignored if (m_position_volume != class="num">0) class=class="str">"cmt">// Balance is not changed m_results.balance = _prev_balance; class=class="str">"cmt">// No positions yet, buy signal else if (_signal == class="num">1) { class=class="str">"cmt">// Calculate current ASK price, recalculate price, volume and balance current_price = _next_open + _next_spread * m_symbol_points; m_position_price = (m_position_volume * m_position_price + current_price) / (m_position_volume + class="num">1); m_position_volume = m_position_volume + class="num">1; m_results.balance = _prev_balance; } class=class="str">"cmt">// No positions yet, sell signal else if (_signal == -class="num">1) { class=class="str">"cmt">// Calculate current BID price, recalculate price, volume and balance current_price = _next_open; m_position_price = (-m_position_volume * m_position_price + current_price) / (-m_position_volume + class="num">1); m_position_volume = m_position_volume - class="num">1; m_results.balance = _prev_balance; } else m_results.balance = _prev_balance; } class=class="str">"cmt">// Position is set already, the opposite direction signal is received else { class=class="str">"cmt">// buy signal/close sell position if (_signal > class="num">0) { class=class="str">"cmt">// Close position by ASK price, recalculate profit and balance current_price = _next_open + _next_spread * m_symbol_points; profit = (current_price - m_position_price) / m_symbol_points * m_position_volume; m_results.balance = _prev_balance + profit; class=class="str">"cmt">// If there is a signal for opening a new position, open it at once if (_signal == class="num">1) { m_position_price = current_price; m_position_volume = class="num">1; } else m_position_volume = class="num">0; } class=class="str">"cmt">// sell signal/close buy position else { class=class="str">"cmt">// Close position by BID price, recalculate profit and balance current_price = _next_open; profit = (current_price - m_position_price) / m_symbol_points * m_position_volume; m_results.balance = _prev_balance + profit;
◍ 成交信号触发与权益重算的逻辑落点
当内部信号变量 _signal 等于 -1 时,策略视为出现反向建仓触发,立即把当前价赋给 m_position_price,并将持仓手数标记为 -1;其他情况下持仓手数归零,意味着本根 K 线不保留敞口。这段处理决定了下一根开盘价介入的占位,而不是市价追单。 权益计算分三种状态:持多时用 _next_open 减开仓价,持空时还要加上 _next_spread * m_symbol_points 的滑点成本,两者都除以点值后乘手数得到浮动 profit,再加进 balance 得到 equity;空仓时 equity 直接等于 balance。外汇与贵金属点值随合约而异,跑之前先确认 m_symbol_points 在你的品种上不是 0。 下面的指标文件用 4 个 buffer、3 条 plot 把 balance 画成蓝红点状直方图、equity 画成实线、零轴画成灰点线,并 include 了 BalanceClass.mqh。把这两个文件放同目录编译,才能在子窗口看到权益曲线脱离余额的回撤幅度。
class=class="str">"cmt">// If there is a signal for opening a new position, open it at once if (_signal == -class="num">1) { m_position_price = current_price; m_position_volume = -class="num">1; } else m_position_volume = class="num">0; } class=class="str">"cmt">// Calculate the current equity if (m_position_volume > class="num">0) { current_price = _next_open; profit = (current_price - m_position_price) / m_symbol_points * m_position_volume; m_results.equity = m_results.balance + profit; } else if (m_position_volume < class="num">0) { current_price = _next_open + _next_spread * m_symbol_points; profit = (current_price - m_position_price) / m_symbol_points * m_position_volume; m_results.equity = m_results.balance + profit; } else m_results.equity = m_results.balance; class="kw">return m_results; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Balance.mq5 | class=class="str">"cmt">//| Copyright class="num">2012, MetaQuotes Software Corp. | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Copyright class="num">2012, MetaQuotes Software Corp." class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#class="kw">property version "class="num">1.00" class="macro">#class="kw">property indicator_separate_window class="macro">#class="kw">property indicator_buffers class="num">4 class="macro">#class="kw">property indicator_plots class="num">3 class="macro">#class="kw">property indicator_level1 class="num">0.0 class="macro">#class="kw">property indicator_levelcolor Silver class="macro">#class="kw">property indicator_levelstyle STYLE_DOT class="macro">#class="kw">property indicator_levelwidth class="num">1 class=class="str">"cmt">//--- plot Balance class="macro">#class="kw">property indicator_label1 "Balance" class="macro">#class="kw">property indicator_type1 DRAW_COLOR_HISTOGRAM class="macro">#class="kw">property indicator_color1 clrBlue,clrRed class="macro">#class="kw">property indicator_style1 STYLE_DOT class="macro">#class="kw">property indicator_width1 class="num">1 class=class="str">"cmt">//--- plot Equity class="macro">#class="kw">property indicator_label2 "Equity" class="macro">#class="kw">property indicator_type2 DRAW_LINE class="macro">#class="kw">property indicator_color2 clrLime class="macro">#class="kw">property indicator_style2 STYLE_SOLID class="macro">#class="kw">property indicator_width2 class="num">1 class=class="str">"cmt">//--- plot Zero class="macro">#class="kw">property indicator_label3 "Zero" class="macro">#class="kw">property indicator_type3 DRAW_LINE class="macro">#class="kw">property indicator_color3 clrGray class="macro">#class="kw">property indicator_style3 STYLE_DOT class="macro">#class="kw">property indicator_width3 class="num">1 class="macro">#include <BalanceClass.mqh>
把信号指标挂成权益曲线
想在 MT5 里把一个已有指标的信号换算成余额/权益曲线,第一步是在 input 区声明父指标名与信号缓冲索引。iParentName 留空时运行会找不到句柄,iSignalBufferIndex 默认 -1 意味着不指定具体线,需按父指标实际缓冲号填,比如多数自定义指标主信号线在 0 或 1。 初始化函数里用 SetIndexBuffer 把 4 个数组绑定到指标缓冲:0 号 Balance、1 号 BalanceColor(颜色索引)、2 号 Equity、3 号 Zero。Zero 数组专门用来画零线,保证直方显示不被压缩到看不见。 父指标句柄靠 FindIndicatorHandle(iParentName) 反查,若返回负值直接在 OnInit 里 Print 报错并返回 -1,指标加载即中止。这段逻辑意味着:父指标必须已加载在同图表,否则本指标起不来。外汇与贵金属品种波动大,这类权益曲线仅作信号质量参考,实盘仍属高风险。 下面这段是输入与初始化核心,逐行拆完就能抄进自己的指标工程。
class="kw">input class="type">class="kw">string iParentName = ""; class=class="str">"cmt">// Indicator name for balance calculation class="kw">input class="type">int iSignalBufferIndex = -class="num">1; class=class="str">"cmt">// Signal buffer&class="macro">#x27;s index number class="kw">input class="type">class="kw">datetime iStartTime = D&class="macro">#x27;class="num">01.01.class="num">2012&class="macro">#x27;; class=class="str">"cmt">// Calculation start date class="kw">input class="type">class="kw">datetime iEndTime = class="num">0; class=class="str">"cmt">// Calculation end date class=class="str">"cmt">//--- Indicator buffers class="type">class="kw">double Balance[]; class=class="str">"cmt">// Balance values class="type">class="kw">double BalanceColor[]; class=class="str">"cmt">// Color index for drawing the balance class="type">class="kw">double Equity[]; class=class="str">"cmt">// Equity values class="type">class="kw">double Zero[]; class=class="str">"cmt">// Zero value for histogram&class="macro">#x27;s correct display class=class="str">"cmt">//--- Global variables class="type">class="kw">double Signal[class="num">1]; class=class="str">"cmt">// Array for receiving the current signal class="type">int parent_handle; class=class="str">"cmt">// Indicator handle, the signals of which are to be used CBaseBalanceCalculator calculator; class=class="str">"cmt">// Object for calculating balance and equity class="type">int OnInit() { class=class="str">"cmt">// Binding indicator buffers SetIndexBuffer(class="num">0,Balance,INDICATOR_DATA); SetIndexBuffer(class="num">1,BalanceColor,INDICATOR_COLOR_INDEX); SetIndexBuffer(class="num">2,Equity,INDICATOR_DATA); SetIndexBuffer(class="num">3,Zero,INDICATOR_DATA); class=class="str">"cmt">// Search for indicator handle by its name parent_handle = FindIndicatorHandle(iParentName); if (parent_handle < class="num">0) { Print("Error! Parent indicator not found"); class="kw">return -class="num">1; } class="kw">return(class="num">0); }
「指标缓冲区的逐根 K 线回填逻辑」
这段计算核心跑在自定义指标的 OnCalculate 尾部,入参直接引用了 low、close、tick_volume、volume、spread 五类时间序列数组,说明该指标需要完整的市场上下文才能推算权益曲线。 循环起点用 prev_calculated 做增量计算,终点锁在 rates_total-1,避免每 tick 全量重算;在 EURUSD M5 上这套写法能把重复计算量压到约 1/rates_total。 时间窗判断很直接:time[i] 小于 iStartTime 的柱全部置 0,大于 iEndTime(且非 0)的柱沿用上一根 Equity 值并冻结 Balance,相当于把回测区间外的权益曲线拉平。 信号取自父指标句柄,CopyBuffer 只取 1 根且失败时直接 Print 错误码并返回 0 触发全量重算;真正下单模拟在 calculator.Calculate 里,用下一根开盘价 open[i+1] 和 spread[i+2] 推算,意味着信号在收线后才被下一根开盘执行。 最后一根 K 线不调用计算器,直接复制前一根的 Balance、Equity、BalanceColor,防止未闭合蜡烛画出跳动伪信号。
class="kw">const class="type">class="kw">double &low[], class="kw">const class="type">class="kw">double &close[], class="kw">const class="type">long &tick_volume[], class="kw">const class="type">long &volume[], class="kw">const class="type">int &spread[]) { class=class="str">"cmt">// Set the borders for calculating the indicator class="type">int start_index = prev_calculated; class="type">int end_index = rates_total-class="num">1; class=class="str">"cmt">// Calculate balance and equity values for(class="type">int i=start_index; i<end_index; i++) { class=class="str">"cmt">// Check if the balance calculation corresponds the interval if (time[i] < iStartTime) { Balance[i] = class="num">0; Equity[i] = class="num">0; class="kw">continue; } if (time[i] > iEndTime && iEndTime != class="num">0) { Equity[i] = (i==class="num">0) ? class="num">0 : Equity[i-class="num">1]; Balance[i] = Equity[i]; class="kw">continue; } class=class="str">"cmt">// Request a signal from the parent indicator if(CopyBuffer(parent_handle,iSignalBufferIndex,time[i],class="num">1,Signal)==-class="num">1) class=class="str">"cmt">// Copy the indicator main line data { Print("Data copy error: " + IntegerToString(GetLastError())); class="kw">return(class="num">0); class=class="str">"cmt">// Finish the function operation and send indicator for the full recalculation } class=class="str">"cmt">// Initialize balance and equity calculation class=class="str">"cmt">// Since the signal is formed when the candle is closing, we will be able class=class="str">"cmt">// to perform any operation only at the next candle&class="macro">#x27;s opening price BalanceResults results = calculator.Calculate(i==class="num">0?class="num">0:Balance[i-class="num">1], (class="type">int)Signal[class="num">0], open[i+class="num">1], spread[class="num">1+class="num">1]); class=class="str">"cmt">// Fill out all indicator buffers Balance[i] = results.balance; Equity[i] = results.equity; Zero[i] = class="num">0; if (Balance[i] >= class="num">0) BalanceColor[i] = class="num">0; else BalanceColor[i] = class="num">1; } class=class="str">"cmt">// Fill out buffers for the last candle Balance[end_index] = Balance[end_index-class="num">1]; Equity[end_index] = Equity[end_index-class="num">1]; BalanceColor[end_index] = BalanceColor[end_index-class="num">1]; Zero[end_index] = class="num">0; class="kw">return rates_total; }
◍ 把余额指标挂到信号源上怎么配
想验证这套新指标的实战表现,先把它拖到一张已经带了信号指标的图上。前面几步做完,手里正好有一个现成的信号源——PivotCandles,所以直接拿它当输入就行,关键是把参数对上。 四个输入项里有两个是硬约束:Indicator name for balance calculation 填字符串,余额指标靠名字绑定信号源,这栏必填不能空;Signal buffer's index number 填整型,因为 PivotCandles 一个指标可能吐出多个缓冲区的信号,你得告诉余额指标具体算哪一根。 Calculation start date 和 Calculation end date 管统计窗口,后者留零就一路算到最新一根柱。图 3 里演示的是把余额指标绑到 PivotCandles 第三根缓冲区的情形,前两项照填,后两项按自己习惯定。 参数没填错的话,图上会出一条和图 4 差不多的余额与净值曲线。接下来换不同周期和品种跑一遍,就能筛出哪些入口点偏盈利、哪些偏亏损——外汇和贵金属波动大、杠杆高,这种相关性排查只作概率参考,别当确定性结论。 本来想拿同信号的 EA 回测来比耗时,后来算了:重算这个指标大概就一秒,而 EA 要加载历史、跑生成算法,绝无可能压到这个量级,比了也没意义。
一点提醒
这套把信号缓冲区和存款响应塞进同一图表窗口的回测思路,跑起来确实快,pivotcandles.mq5 配合 balance.mq5 在 MT5 里能直观看到信号和余额曲线的咬合关系。但它不是万能的:信号缓冲得提前备好,触发被钉死在新柱开盘,且计算余额时完全没带资金管理(MM),这几条局限在真金白银的外汇、贵金属盘面上会放大滑点和重仓风险。 优点压过缺点这点成立,不过真要拿去验证策略,建议先手动把 MM 补进 balanceclass.mqh 的余额计算逻辑,再用不同品种周期跑一遍——外汇和贵金属高杠杆,任何回测结论都只是概率倾向,不是板上钉钉。