如何分析图表中所选择信号的交易·进阶篇
(2/3)·终端自带图表只能单品种翻页,跨品种重合仓位与回撤峰值根本看不穿
很多人以为在终端里点开信号历史、在各个品种图表间来回翻页就足够评估一个信号了。可一旦信号同时跑多个品种,重合的入场和平仓在时间轴上被拆进不同窗口,你既看不到资金负荷峰值,也难判断是不是在对冲。本文接着上篇,继续把这套跨品种统计与图形化工作落到可运行的工程里。
◍ 遍历图表抓取箭头信号
做信号聚合的第一步,是把终端里所有打开的图表都扫一遍,把手动或 EA 画上去的买卖箭头对象捞出来。下面这段逻辑从 ChartFirst 拿第一个图表句柄,只要句柄大于 0 就循环,用 ChartNext 顺着链往下走,直到没有更多图表。 每个图表先数 OBJ_ARROW_BUY 和 OBJ_ARROW_SELL 的数量,两个加起来为 0 就直接跳过。这里有个细节:用 fmax(total_buy,total_sell) 决定循环上限,意味着买卖箭头数量不等时,也按多的那一侧跑满次数,另一侧靠 i<total_x 判断补抓。 图表对应的交易品种会写进 ar_Symbols 数组,找不到就 ArrayResize 扩 10 个缓冲位再塞进去。品种去重靠线性查找,品种少时够用,开几十个图表时倾向改成哈希思路。外汇和贵金属波动大,这类箭头只是某时刻的主观或规则标记,不代表后续一定走出预期方向。 买箭头的名字被取出来后,会 Trim 掉首尾空格、把 # 替换掉,再按空格 Split。代码假定名字里第 1、3、6 段分别是订单号、开价、止损这类字段,直接 StringToInteger / StringToDouble 灌进 COrder 对象。你的箭头注释格式若对不上这个顺序,跑出来就是错单或空指针,开 MT5 前先确认画线工具的命名规则。
class="type">long chart=ChartFirst(); while(chart>class="num">0) { class="type">int total_buy=ObjectsTotal(chart,class="num">0,OBJ_ARROW_BUY); class="type">int total_sell=ObjectsTotal(chart,class="num">0,OBJ_ARROW_SELL); if((total_buy+total_sell)<=class="num">0) { chart=ChartNext(chart); class="kw">continue; } class="type">int symb=ArraySize(ar_Symbols); class="type">class="kw">string symbol=ChartSymbol(chart); class="type">bool found=false; for(class="type">int i=class="num">0;(i<symb && !found);i++) if(ar_Symbols[i]==symbol) { found=true; symb=i; break; } if(!found) { if(ArrayResize(ar_Symbols,symb+class="num">1,class="num">10)<=class="num">0) class="kw">return false; ar_Symbols[symb]=symbol; } class="type">int total=fmax(total_buy,total_sell); for(class="type">int i=class="num">0;i<total;i++) { if(i<total_buy) { class="type">class="kw">string name=ObjectName(chart,i,class="num">0,OBJ_ARROW_BUY); class="type">class="kw">datetime time=(class="type">class="kw">datetime)ObjectGetInteger(chart,name,OBJPROP_TIME); StringTrimLeft(name); StringTrimRight(name); StringReplace(name,"#",""); class="type">class="kw">string split[]; StringSplit(name,&class="macro">#x27; &class="macro">#x27;,split); Temp=new COrder; if(CheckPointer(Temp)!=POINTER_INVALID) { if(Temp.Create(ar_Symbols[symb],StringToInteger(split[class="num">1]),StringToDouble(split[class="num">3]),StringToDouble(split[class="num">6]),time,POSITION_TYPE_BUY)) Add(Temp); } } class=class="str">"cmt">//--- if(i<total_sell) { class="type">class="kw">string name=ObjectName(chart,i,class="num">0,OBJ_ARROW_SELL);
「从图表对象反推卖单与净仓逻辑」
这段 MQL5 片段干了两件事:把图表上带 '#' 标记的文本对象解析成一笔卖单塞进集合,再按品种归并出净流入/出方向。外汇与贵金属市场杠杆高、滑点随机,这类从图形反推仓位的做法只能当辅助,不能单独作为下单依据。 对象名先被截掉首尾空格、去掉 '#',再用空格切分。split[1] 是 ticket 整数,split[3] 是开仓价,split[6] 是成交量,time 取自对象的时间属性。若同 ticket 已存在则跳过,避免重复添加。 SetDealsEntry 里先按 0 列排序,再双层循环:同品种下若当前 volume 为 0 或方向一致,标记为 DEAL_ENTRY_IN 并累加(保留 2 位小数);若方向相反且已有量大于等于当前量,则标记为 DEAL_ENTRY_OUT 并扣减。这样能粗略看出某品种净仓是建还是平。 开 MT5 把下面代码丢进 EA 的调试分支,打印 split 数组和 volume 变化,就能验证你图上标的 '#' 文本是否被正确识别成卖单。
class="type">class="kw">datetime time=(class="type">class="kw">datetime)ObjectGetInteger(chart,name,OBJPROP_TIME); StringTrimLeft(name); StringTrimRight(name); StringReplace(name,"#",""); class="type">class="kw">string split[]; StringSplit(name,&class="macro">#x27; &class="macro">#x27;,split); Temp=new COrder; if(CheckPointer(Temp)!=POINTER_INVALID) { if(Temp.Create(ar_Symbols[symb],StringToInteger(split[class="num">1]),StringToDouble(split[class="num">3]),StringToDouble(split[class="num">6]),time,POSITION_TYPE_SELL)) Add(Temp); } } } chart=ChartNext(chart); } SetDealsEntry(); class=class="str">"cmt">//--- class="kw">return true; } class="type">bool COrdersCollection::Add(COrder *element) { for(class="type">int i=class="num">0;i<m_data_total;i++) { Temp=m_data[i]; if(Temp.Ticket()==element.Ticket()) class="kw">return true; } class=class="str">"cmt">//--- class="kw">return CArrayObj::Add(element); } COrdersCollection::SetDealsEntry(class="type">void) { Sort(class="num">0); class=class="str">"cmt">//--- class="type">int symbols=ArraySize(ar_Symbols); for(class="type">int symb=class="num">0;symb<symbols;symb++) { class="type">class="kw">double volume=class="num">0; class="type">ENUM_POSITION_TYPE type=-class="num">1; for(class="type">int ord=class="num">0;ord<m_data_total;ord++) { Temp=m_data[ord]; if(Temp.Symbol()!=ar_Symbols[symb]) class="kw">continue; class=class="str">"cmt">//--- if(volume==class="num">0 || type==Temp.Type()) { Temp.DealEntry(DEAL_ENTRY_IN); volume=NormalizeDouble(volume+Temp.Volume(),class="num">2); type=Temp.Type(); } else { if(volume>=Temp.Volume()) { Temp.DealEntry(DEAL_ENTRY_OUT); volume=NormalizeDouble(volume-Temp.Volume(),class="num">2); } else
用 M5 切片还原账户资金曲线
要把一堆历史成交按时间轴摊开成资金曲线,核心是先锁定品种与时间窗,再借 M5 周期把每根 K 线当作切片边界。下面这段逻辑里,tick_value 取 SYMBOL_TRADE_TICK_VALUE 除以 SYMBOL_POINT,若为 0 直接返 false,说明点值配置异常时没必要继续算。 时间轴用 CopyRates 拉 M5 的 rates 数组,count 就是切片数量;若 count<=0 且未触发重载,会发一个 EventChartCustom 自定义事件 1222 并退出,避免空数据污染曲线。数组 balance / equity / time 都按 count 用 ArrayResize 撑开,再用 ArrayInitialize 把 balance 清 0。 成交遍历用 do-while 跳过无效指针,直到捞到第一条有效 Temp 记录;随后 for 循环按 i 走每个 M5 切片,内部 while 把落在该切片时间窗(rates[i].time + PeriodSeconds(PERIOD_M5))之前的同品种成交累加进 volume 与浮盈浮亏统计。long_trades / short_trades 也在这过程里分别计数,外汇与贵金属品种点值波动大,实盘回测前务必确认 tick_value 非零,否则统计会直接中断。
{
Temp.DealEntry(DEAL_ENTRY_INOUT);
volume=NormalizeDouble(volume-Temp.Volume(),class="num">2);
type=Temp.Type();
}
}
}
}
class="type">bool COrdersCollection::GetTimeSeries(const class="type">class="kw">string symbol,const class="type">class="kw">datetime start_time,const class="type">class="kw">datetime end_time,const class="type">int direct,class="type">class="kw">double &balance[],class="type">class="kw">double &equity[], class="type">class="kw">double &time[], class="type">class="kw">double &profit, class="type">class="kw">double &loss,class="type">int &long_trades, class="type">int &short_trades)
{
profit=loss=class="num">0;
long_trades=short_trades=class="num">0;
class=class="str">"cmt">//---
if(symbol==NULL)
class="kw">return false;
class=class="str">"cmt">//---
class="type">class="kw">double tick_value=SymbolInfoDouble(symbol,SYMBOL_TRADE_TICK_VALUE)/SymbolInfoDouble(symbol,SYMBOL_POINT);
if(tick_value==class="num">0)
class="kw">return false;
ENUM_TIMEFRAMES timeframe=PERIOD_M5;
class=class="str">"cmt">//---
class="type">class="kw">double volume=class="num">0;
class="type">class="kw">double price=class="num">0;
class="type">ENUM_POSITION_TYPE type=-class="num">1;
class="type">int order=-class="num">1;
class=class="str">"cmt">//---
class="type">MqlRates rates[];
class="type">int count=class="num">0;
count=CopyRates(symbol,timeframe,start_time,end_time,rates);
if(count<=class="num">0 && !ReloadHistory)
{
class=class="str">"cmt">//--- 发送通知
ReloadHistory=EventChartCustom(CONTROLS_SELF_MESSAGE,class="num">1222,class="num">0,class="num">0.0,symbol);
class="kw">return false;
}
if(ArrayResize(balance,count)<count || ArrayResize(equity,count)<count || ArrayResize(time,count)<count)
class="kw">return false;
ArrayInitialize(balance,class="num">0);
do
{
order++;
if(order<m_data_total)
Temp=m_data[order];
else
Temp=NULL;
}
while(CheckPointer(Temp)==POINTER_INVALID && order<m_data_total);
class=class="str">"cmt">//---
for(class="type">int i=class="num">0;i<count;i++)
{
while(order<m_data_total && Temp.Time()<(rates[i].time+PeriodSeconds(timeframe)))
{
if(Temp.Symbol()!=symbol)
{
do
{
order++;
if(order<m_data_total)
Temp=m_data[order];◍ 逐笔撮合里的浮盈浮亏落点
上面这段逻辑干的是持仓链表遍历后的清算动作:当 Temp 指针有效且同类型时,用成交量加权把 price 重算一遍,同时给 long_trades / short_trades 计数。加权公式 price=volume*price+Temp.Volume()*Temp.Price() 再除以合计 volume,意味着同方向加仓时均价是严格线性的,不会受报价小数位干扰。
若类型不匹配(即对向单),且 i>0、方向过滤通过,就进入撮合分支:temp=(Temp.Price()-price)*tick_value*(买?1:-1)*MathMin(volume,Temp.Volume()) 直接把这一撮手数的浮动盈亏算进 balance[i]。tick_value 没乘杠杆倍数,说明这里记录的是合约层面盈亏,要在 MT5 里跑通得先确认品种 tick_value 不为 0,否则外汇贵金属账户上 balance 数组全平。
profit 与 loss 分别累加非负和负向 temp,volume 减去 Temp.Volume() 后若转负,就取绝对值翻成对手向新仓位并重置 price 与 type。这个翻转点就是净敞口方向切换的边界,实盘里黄金 XAUUSD 在 5 位数报价下,一次 0.1 手反向吃单就可能让 volume 符号翻转两次,建议把 m_data_total 控制在 200 笔内观察数组写入延迟。
else Temp=NULL; } while(CheckPointer(Temp)==POINTER_INVALID && order<m_data_total); class="kw">continue; } class=class="str">"cmt">//--- if(Temp!=NULL) { if(type==Temp.Type()) { price=volume*price+Temp.Volume()*Temp.Price(); volume+=Temp.Volume(); price=price/volume; class="kw">switch(type) { case POSITION_TYPE_BUY: long_trades++; break; case POSITION_TYPE_SELL: short_trades++; break; } } else { if(i>class="num">0 && (direct<class="num">0 || direct==type)) { class="type">class="kw">double temp=(Temp.Price()-price)*tick_value*(type==POSITION_TYPE_BUY ? class="num">1 : -class="num">1)*MathMin(volume,Temp.Volume()); balance[i]+=temp; if(temp>=class="num">0) profit+=temp; else loss+=temp; } volume-=Temp.Volume(); if(volume<class="num">0) { volume=MathAbs(volume); price=Temp.Price(); type=Temp.Type(); class="kw">switch(type) { case POSITION_TYPE_BUY:
「逐行拆开权益计算的那段循环」
下面这段是持仓遍历与权益累加的核心片段,直接贴出来逐行看逻辑。 long_trades++; 与 short_trades++; 分别在 switch 匹配到 BUY、SELL 持仓类型时计数加一,break 跳出当前 case 避免穿透。 do{ order++; if(order<m_data_total) Temp=m_data[order]; else Temp=NULL; } while(CheckPointer(Temp)==POINTER_INVALID && order<m_data_total); 这段用 do-while 向后找下一条有效数据指针,遇到空指针或越界就停。 if(i>0) balance[i]+=balance[i-1]; 把余额做前缀和,使每根 K 线的 balance 承载累计值。
| if(volume>0 && (direct<0 | direct==type)) equity[i]=(rates[i].close-price)*tick_value*(type==POSITION_TYPE_BUY ? 1 : -1)*MathMin(volume,(Temp!=NULL ? Temp.Volume(): DBL_MAX)); 否则 equity[i]=0; 这里用收盘价减开仓价乘每跳价值,再乘方向系数与可平量,得到该 bar 浮动权益;最后 equity[i]+=balance[i]; 叠上余额,time[i]=(double)rates[i].time; 记下时间戳。 |
|---|
开 MT5 把这段塞进你自己的回测类里,把 tick_value 和 price 换成实际持仓成本,跑一根 EURUSD 的 M1 就能核对 equity 曲线是否和账户历史一致。外汇与贵金属杠杆高,核对结果仅反映历史逻辑,实盘可能偏离。
long_trades++;
break;
case POSITION_TYPE_SELL:
short_trades++;
break;
}
}
}
}
do
{
order++;
if(order<m_data_total)
Temp=m_data[order];
else
Temp=NULL;
}
while(CheckPointer(Temp)==POINTER_INVALID && order<m_data_total);
}
if(i>class="num">0)
{
balance[i]+=balance[i-class="num">1];
}
if(volume>class="num">0 && (direct<class="num">0 || direct==type))
equity[i]=(rates[i].close-price)*tick_value*(type==POSITION_TYPE_BUY ? class="num">1 : -class="num">1)*MathMin(volume,(Temp!=NULL ? Temp.Volume(): DBL_MAX));
else
equity[i]=class="num">0;
equity[i]+=balance[i];
time[i]=(class="type">class="kw">double)rates[i].time;
}
class=class="str">"cmt">//---
class="kw">return true;
}给统计面板套上图形外壳
图形界面由分析起止日期、显示选项复选框、文本统计块和曲线图组成。我们在继承自 CAppDialog 的 CStatisticsPanel 类里搭这套壳:日期用 CDatePicker,三组数据显示开关用 CCheckGroup(余额资金 / 多空持仓 / 品种列表),文字指标用 CLabel,绘图核心用 CGraphic,订单统计靠 COrdersCollection 实例取数。 Create 方法先调父类初始化,再把各控件按坐标摆好、给初值并加入控件集合。注意 CGraphic 不从 CWnd 派生,不能进集合,所以要重写面板最小化/展开逻辑,初始化完直接调图表刷新。 CreateGraphic 接收对象名和坐标,内部建 CGraphic 后按客户区偏移。随后对每个品种建 CCurve 余额/资金曲线,用空数组初始化并先隐藏。横坐标关掉自动缩放、绑定日期属性,标题尺寸设好再显图。 UpdateChart 负责数据刷新:取起止日,按复选状态调 GetTimeSeries 补时序,勾了的品种才显曲线。最后写分析区间、网格、刷新图,并把胜败笔数等写进 CLabel。事件端只需监听日期改动、复选变化及自定义“历史缺失”事件,触发即调 UpdateChart;最小化方法里补一句图表显隐即可。 外汇与贵金属品种的历史数据缺口很常见,跑这套面板时若某品种报自定义缺失事件,图表可能只覆盖部分符号,属正常容错而非 bug。
class CStatisticsPanel : class="kw">public CAppDialog { class="kw">private: CDatePicker StartDate; CDatePicker EndDate; CLabel Date; CGraphic Graphic; CLabel ShowLabel; CCheckGroup Symbols; CCheckGroup BalEquit; CCheckGroup Deals; class="type">class="kw">string ar_Symbols[]; CLabel TotalProfit; CLabel TotalProfitVal; CLabel GrossProfit; CLabel GrossProfitVal; CLabel GrossLoss; CLabel GrossLossVal; CLabel TotalTrades; CLabel TotalTradesVal; CLabel LongTrades; CLabel LongTradesVal; CLabel ShortTrades; CLabel ShortTradesVal; class=class="str">"cmt">//--- COrdersCollection Orders; class="kw">public: CStatisticsPanel(); ~CStatisticsPanel(); class=class="str">"cmt">//--- 主应用程序对话框的创建和注销 class="kw">virtual class="type">bool Create(const class="type">long chart,const class="type">class="kw">string name,const class="type">int subwin,const class="type">int x1,const class="type">int y1,const class="type">int x2,const class="type">int y2); class="kw">virtual class="type">void Destroy(const class="type">int reason=REASON_PROGRAM); class=class="str">"cmt">//--- 图表事件处理器 class="kw">virtual class="type">bool OnEvent(const class="type">int id,const class="type">long &lparam,const class="type">class="kw">double &dparam,const class="type">class="kw">string &sparam); class="kw">protected: