如何基于HTML和CSV报表可视化多币种交易历史·进阶篇
(2/3)·测试器只给单一品种成交图,跨币种复盘靠肉眼比对太低效,本篇教你并行挂多实例
◍ 面板加载与信号落地的接口骨架
做信号回测面板,第一步是把外部数据文件塞进 MT5 并筛出涉及哪些品种。attach() 负责调 load(file) 拿到数据,再遍历 data.getSize() 行,用 getSymbolColumn() 定位品种列,StringTrimLeft 清掉左空格后塞进 symbols 集合,非空才调 controlPanel(symbols) 画按钮。 apply() 是真正把信号画到图表上的入口。它先 ObjectsDeleteAll(0,"SCR",ChartWindowFind(),OBJ_TREND) 清掉旧趋势线,再根据传入 symbol 或已存 symbol 找真实品种 findrealsymbol(),SymbolSelect(real,true) 拉进市场报价窗。遍历时只处理 s==symbol 的行,selected 计数后调 makeTrade(row) 逐条下单或标注。 跑完会在日志打出 data.getSize() 总记录数与 selected 命中数,并把按钮染成 clrGreen、把品种名和记录数写进指标短名。外汇与贵金属波动剧烈,这类脚本只做历史信号可视化,实盘触发前务必在策略测试器核对点差与滑点。 onChartEvent 里只截了 CHARTEVENT_OBJECT_CLICK,说明按钮交互依赖图表对象点击消息,没挂定时器或后台线程——想加自动刷新的话得自己补个 EventSetTimer。
class="type">void createButton(class="kw">const class="type">int x, class="kw">const class="type">int y, class="kw">const class="type">int dx, class="kw">const class="type">int dy, class="kw">const class="type">class="kw">string text, class="kw">const class="type">bool selected) class="type">void controlPanel(class="kw">const IndexMap &symbols) class="kw">virtual IndexMap *load(class="kw">const class="type">class="kw">string file) = class="num">0; class="kw">virtual class="type">int getColumnCount() = class="num">0; class="kw">virtual class="type">int getSymbolColumn() = class="num">0; class="kw">virtual class="type">class="kw">datetime getStart() = class="num">0; class="kw">virtual class="type">bool applyInit() { class="kw">return true; } class="kw">virtual class="type">void makeTrade(IndexMap *row) = class="num">0; class="kw">virtual class="type">int render() = class="num">0; class="type">bool attach(class="kw">const class="type">class="kw">string file) class="type">bool apply(class="kw">const class="type">class="kw">string _s = NULL) class="type">bool isEmpty() class="kw">const class="type">class="kw">string findrealsymbol() class="type">class="kw">string _symbol() class="kw">const class="type">class="kw">string _realsymbol() class="kw">const class="type">void onChartEvent(class="kw">const class="type">int id, class="kw">const class="type">long& lparam, class="kw">const class="type">class="kw">double& dparam, class="kw">const class="type">class="kw">string& sparam) class="type">bool attach(class="kw">const class="type">class="kw">string file) { data = load(file); IndexMap symbols; for(class="type">int i = class="num">0; i < data.getSize(); ++i) { IndexMap *row = data[i]; class=class="str">"cmt">// 收集唯一的交易品种 class="type">class="kw">string s = row[getSymbolColumn()].get<class="type">class="kw">string>(); StringTrimLeft(s); if(StringLen(s) > class="num">0) symbols.set(s); } if(symbols.getSize() > class="num">0) { controlPanel(symbols); } class="kw">return true; } class="type">bool apply(class="kw">const class="type">class="kw">string _s = NULL) { ObjectsDeleteAll(class="num">0, "SCR", ChartWindowFind(), OBJ_TREND); if(_s != NULL && _s != "") symbol = _s; if(symbol == NULL) { Print("没有选择交易品种"); class="kw">return class="kw">false; } class="type">class="kw">string real = findrealsymbol(); if(real == NULL) { Print("没有找到合适的交易品种"); class="kw">return class="kw">false; } SymbolSelect(real, true); if(!applyInit()) class="kw">return class="kw">false; class="type">int selected = class="num">0; for(class="type">int i = class="num">0; i < data.getSize(); ++i) { IndexMap *row = data[i]; class="type">class="kw">string s = row[getSymbolColumn()].get<class="type">class="kw">string>(); StringTrimLeft(s); if(s == symbol) { selected++; makeTrade(row); } } pressed = prefix + "#" + symbol; ObjectSetInteger(class="num">0, pressed, OBJPROP_BGCOLOR, clrGreen); class="type">int trends = render(); Print(data.getSize(), " records in total"); Print(selected, " trades for ", symbol); class="type">class="kw">string title = CHART_REPORTER_TITLE + " (" + symbol + ", " + (class="type">class="kw">string)selected + " records, " + (class="type">class="kw">string)trends + " trades)"; IndicatorSetString(INDICATOR_SHORTNAME, title); ChartRedraw(); class="kw">return true; } class="type">void onChartEvent(class="kw">const class="type">int id, class="kw">const class="type">long& lparam, class="kw">const class="type">class="kw">double& dparam, class="kw">const class="type">class="kw">string& sparam) { if(id == CHARTEVENT_OBJECT_CLICK) {
从成交报告里拆出多空配对逻辑
MT5 的成交历史导出后通常是 HTML 报表,想做自定义复盘就得先把每一行成交解析成结构对象。下面这段把报表行映射成了 Deal 类,type 用 +1/-1 标记 buy/sell,direction 用 +1/-1/0 标记 in/out/in-out,字段直接公有化是为了让外部 Processor 能零成本读取,绕开 MQL5 对私有成员跨类访问的限制。 Deal 的构造函数里所有数值都靠 StringToDouble / StringToInteger / StringToTime 从字符串列转换,其中 profit 字段先 StringReplace 掉空格再转双精度,避免「1 234.50」这种带千分位空格的字符串直接转换得到 1.0。TimeShift 是外部定义的时区偏移常量,加载时直接叠加进 time。 配对渲染阶段用的是队列法:makeTrade 把所有成交塞进 array,render 里遍历 array,遇到 isOut() 的平仓单就去 queue 里找 isIn() 且 isOpposite 且 volume 完全相等的开仓单。这种严格等量配对在真实账户里可能漏掉部分平仓(比如部分平仓只平 0.3 手),概率上更适用于闭环测试报表。 外汇与贵金属杠杆交易高风险,下面代码仅供在 MT5 策略测试器或本地脚本环境验证类结构用,不构成任何交易建议。
class="type">int x = StringFind(sparam, "_#"); if(x != -class="num">1) { class="type">class="kw">string s = StringSubstr(sparam, x + class="num">2); Print(s, " ", sparam, " ", pressed); ObjectSetInteger(class="num">0, sparam, OBJPROP_STATE, class="kw">false); ObjectSetInteger(class="num">0, pressed, OBJPROP_STATE, class="kw">false); ObjectSetInteger(class="num">0, pressed, OBJPROP_BGCOLOR, clrGray); pressed = ""; if(apply(s)) class=class="str">"cmt">// 将设置按下状态和其它属性 { ChartSetSymbolPeriod(class="num">0, _Symbol, _Period); } } } } class ReportProcessor: class="kw">public Processor { class="kw">private: class Deal class=class="str">"cmt">// 如果 MQL5 可以支持类的私有访问设置, { class=class="str">"cmt">// 交易将无法从外部世界获得,因此 class="kw">public: class=class="str">"cmt">// 把栏位变为共有,就可以从 Processor 中直接访问了。 class="type">class="kw">datetime time; class="type">class="kw">double price; class="type">int type; class=class="str">"cmt">// +class="num">1 - buy, -class="num">1 - sell class="type">int direction; class=class="str">"cmt">// +class="num">1 - in, -class="num">1 - out, class="num">0 - in/out class="type">class="kw">double volume; class="type">class="kw">double profit; class="type">long deal; class="type">long order; class="type">class="kw">string comment; class="kw">public: Deal(class="kw">const IndexMap *row) class=class="str">"cmt">// 这是 MT5 交易 { time = StringToTime(row[COLUMN_TIME].get<class="type">class="kw">string>()) + TimeShift; price = StringToDouble(row[COLUMN_PRICE].get<class="type">class="kw">string>()); class="type">class="kw">string t = row[COLUMN_TYPE].get<class="type">class="kw">string>(); type = t == "buy" ? +class="num">1 : (t == "sell" ? -class="num">1 : class="num">0); t = row[COLUMN_DIRECTION].get<class="type">class="kw">string>(); direction = class="num">0; if(StringFind(t, "in") > -class="num">1) ++direction; if(StringFind(t, "out") > -class="num">1) --direction; volume = StringToDouble(row[COLUMN_VOLUME].get<class="type">class="kw">string>()); t = row[COLUMN_PROFIT].get<class="type">class="kw">string>(); StringReplace(t, " ", ""); profit = StringToDouble(t); deal = StringToInteger(row[COLUMN_DEAL].get<class="type">class="kw">string>()); order = StringToInteger(row[COLUMN_ORDER].get<class="type">class="kw">string>()); comment = row[COLUMN_COMMENT].get<class="type">class="kw">string>(); } class="type">bool isIn() class="kw">const { class="kw">return direction >= class="num">0; } class="type">bool isOut() class="kw">const { class="kw">return direction <= class="num">0; } class="type">bool isOpposite(class="kw">const Deal *t) class="kw">const { class="kw">return type * t.type < class="num">0; } class="type">bool isActive() class="kw">const { class="kw">return volume > class="num">0; } }; RubbArray<Deal *> array; RubbArray<Deal *> queue; class="kw">virtual IndexMap *load(class="kw">const class="type">class="kw">string file) class="kw">override { class="kw">return HTMLConverter::convertReport2Map(file, true); } class="kw">virtual class="type">int getColumnCount() class="kw">override { class="kw">return COLUMNS_COUNT; } class="kw">virtual class="type">int getSymbolColumn() class="kw">override { class="kw">return COLUMN_SYMBOL; } class="macro">#define COLUMNS_COUNT class="num">13 class="macro">#define COLUMN_TIME class="num">0 class="macro">#define COLUMN_DEAL class="num">1 class="macro">#define COLUMN_SYMBOL class="num">2 ... class="kw">virtual class="type">bool applyInit() class="kw">override { ((BaseArray<Deal *>&)queue).clear(); array.clear(); class="kw">return true; } class="kw">virtual class="type">void makeTrade(IndexMap *row) class="kw">override { array << new Deal(row); } class="kw">virtual class="type">int render() class="kw">override { class="type">int count = class="num">0; for(class="type">int i = class="num">0; i < array.size(); ++i) { Deal *current = array[i]; if(!current.isActive()) class="kw">continue; if(current.isOut()) { class=class="str">"cmt">// 首先尝试完全匹配 for(class="type">int j = class="num">0; j < queue.size(); ++j) { if(queue[j].isIn() && queue[j].isOpposite(current) && queue[j].volume == current.volume) {
「成对成交与部分平仓的队列清理」
这段逻辑处理的是成交队列里「反向单互吃」的两种情况:整笔平掉和仅平部分。第一次循环里若当前持仓 volume 直接归零,就从队列里用 queue >> j 删掉那条记录,并 ++count 记一次配对成功。
第二次尝试专门处理部分平仓:当 current.volume 大于等于队列项 volume 时,减掉对方 volume 并把对方置 0;反之则减自己,对方留余量,随后 break 跳出内层循环。两种分支都调用 createTrend 写趋势线描述,描述串里带了 deal/order 编号与盈亏正负号。
循环结束后有一段倒序清扫:从 queue.size()-1 到 0 检查 isActive(),非活动项直接 queue >> j 移除,避免残留空单污染后续匹配。若 current 仍 active 且是入场单,就重新 queue << current 压回队列等下次事件。
OnInit 里按扩展名分流处理器:.htm 建 ReportProcessor,.csv 建 HistoryProcessor;Symbol 为空则退回 _Symbol,否则 SymbolSelect 激活。OnChartEvent 只在 CHARTEVENT_CHART_CHANGE 时跑本地刷新,其余事件丢给 processor。
最后用 iBars(Symbol, _Period) 比对 lastAvailable,若相等且未初始化则 Print 出「Updated 品种 N bars」并置 initialized,同时若 ReportFile 非空进入文件重载分支——外汇与贵金属品种点值跳变频繁,这类计数错位可能引发Indicator重绘异常,建议开 MT5 用真实回放文件断点验证。
class="type">class="kw">string description; StringConcatenate(description, (class="type">class="kw">float)queue[j].volume, "[", queue[j].deal, "/", queue[j].order, "-", current.deal, "/", current.order, "] ", (current.profit < class="num">0 ? "-" : ""), current.profit, " ", current.comment); createTrend(queue[j].deal, current.deal, queue[j].type, queue[j].time, queue[j].price, current.time, current.price, description); current.volume = class="num">0; queue >> j; class=class="str">"cmt">// 从队列中删除 ++count; class="kw">break; } } if(!current.isActive()) class="kw">continue; class=class="str">"cmt">// second try to perform partial close for(class="type">int j = class="num">0; j < queue.size(); ++j) { if(queue[j].isIn() && queue[j].isOpposite(current)) { class="type">class="kw">string description; if(current.volume >= queue[j].volume) { StringConcatenate(description, (class="type">class="kw">float)queue[j].volume, "[", queue[j].deal, "/", queue[j].order, "-", current.deal, "/", current.order, "] ", (current.profit < class="num">0 ? "-" : ""), current.profit, " ", current.comment); createTrend(queue[j].deal, current.deal, queue[j].type, queue[j].time, queue[j].price, current.time, current.price, description); current.volume -= queue[j].volume; queue[j].volume = class="num">0; ++count; } else { StringConcatenate(description, (class="type">class="kw">float)current.volume, "[", queue[j].deal, "/", queue[j].order, "-", current.deal, "/", current.order, "] ", (current.profit < class="num">0 ? "-" : ""), current.profit, " ", current.comment); createTrend(queue[j].deal, current.deal, queue[j].type, queue[j].time, queue[j].price, current.time, current.price, description); queue[j].volume -= current.volume; current.volume = class="num">0; ++count; class="kw">break; } } } class=class="str">"cmt">// 从队列中清除非活动的部分 for(class="type">int j = queue.size() - class="num">1; j >= class="num">0; --j) { if(!queue[j].isActive()) { queue >> j; } } } if(current.isActive()) class=class="str">"cmt">// is _still_ active { if(current.isIn()) { queue << current; } } } if(!isQueueEmpty()) { Print("Warning: not all deals are processed(probably, open positions left)."); } class="kw">return count; } Processor *processor = NULL; class="type">int OnInit() { if(StringFind(ReportFile, ".htm") > class="num">0) { processor = new ReportProcessor(); } else if(StringFind(ReportFile, ".csv") > class="num">0) { processor = new HistoryProcessor(); } class="type">class="kw">string Symbol = SubSymbol; if(Symbol == "") Symbol = _Symbol; else SymbolSelect(Symbol, true); processor.apply(Symbol); ... } class="type">void OnDeinit(class="kw">const class="type">int reason) { if(processor != NULL) class="kw">delete processor; } class="type">void OnChartEvent(class="kw">const class="type">int id, class="kw">const class="type">long& lparam, class="kw">const class="type">class="kw">double& dparam, class="kw">const class="type">class="kw">string& sparam) { if(id == CHARTEVENT_CHART_CHANGE) { ... class=class="str">"cmt">// 相同的代码 } else { processor.onChartEvent(id, lparam, dparam, sparam); } } class="type">class="kw">string Symbol = processor._realsymbol(); if(Symbol == NULL) Symbol = _Symbol; if(lastSymbol != Symbol) { _prev_calculated = class="num">0; lastAvailable = class="num">0; initialized = class="kw">false; IndicatorSetInteger(INDICATOR_DIGITS, (class="type">int)SymbolInfoInteger(Symbol, SYMBOL_DIGITS)); } if(lastAvailable == iBars(Symbol, _Period) && lastAvailable != class="num">0) { if(!initialized) { Print("Updated ", Symbol, " ", iBars(Symbol, _Period), " bars"); initialized = true; if(ReportFile != "") class=class="str">"cmt">// { class=class="str">"cmt">//
◍ 用 OnTimer 给报表只加载一次
这段 MQL5 片段解决了一个常见坑:指标或 EA 在每次 tick 重算时反复读外部报表文件,既拖慢 MT5 又容易把内存搞乱。 它借助 EventSetTimer(1) 启动 1 秒定时器,在 OnTimer 里先用 EventKillTimer 停掉定时,再用 processor.isEmpty() 判断——空才 attach(ReportFile) 并 apply,否则跳过,保证文件只载入一次。 若 attach 返回失败,Print 会抛出 "File loading failed: " 加文件名,开 MT5 跑这段代码时,能在专家日志直接看到路径是不是写错。 外汇与贵金属行情高波动、滑点频发,这类一次性加载逻辑能降低回测与实盘切换时的资源抖动,但任何自动化加载都不消除品种本身的爆仓风险。
EventSetTimer(class="num">1); class=class="str">"cmt">// } class=class="str">"cmt">// } class="kw">return rates_total; } class="type">void OnTimer() { EventKillTimer(); if(processor.isEmpty()) class=class="str">"cmt">// 只载入一次文件 { if(processor.attach(ReportFile)) { processor.apply(class=class="str">"cmt">/*keep already selected symbol*/); } else { Print("File loading failed: ", ReportFile); } } }
用脚本把多品种报表铺进子窗口
SubChartsBuilder 脚本和 SubChartReporter 指标共用一套参数,区别在启动方式:它把参数塞进 MqlParam 数组,再调 IndicatorCreate 从资源里拉指标,因此不依赖用户本地是否装了那个 ex5。资源路径里带「:Indicators\」时,脚本就判定这是被整体程序拉起的实例,顺手把控制按钮隐藏掉,避免多品种生成时界面被按钮刷屏。 MQL 本身没法区分指标是用户手动挂的还是代码里 Create 出来的,塞进资源算是个绕开限制的办法。脚本跑空报告名会触发 cleanUpChart,把之前建的子窗口全清掉,适合反复验证报表后重置图表。 实测我下了带信号历史的 CSV 跑脚本,主图最小化后能看到多个 SubChartReporter 实例并列子窗,对象描述里含交易编号、交易量、利润和评论——记得在图表设置开「显示对象描述」。品种一多子窗就变窄,细看单笔吃力;真要抠每笔,就把指标改成往主图画的 MainChartReporter,别死磕子窗。 下面这段是脚本核心:createIndicator 拼 18 个参数调 IndicatorCreate,OnStart 解析 htm/csv 报告提取品种集合后逐个建实例,空报告名走清理分支。
class="type">bool createIndicator(class="kw">const class="type">class="kw">string symbol) { class="type">MqlParam params[class="num">18] = { {TYPE_STRING, class="num">0, class="num">0.0, "::Indicators\\SubChartReporter.ex5"}, class=class="str">"cmt">// 资源内指标路径,决定按钮隐显 {TYPE_INT, class="num">0, class="num">0.0, NULL}, class=class="str">"cmt">// 图表设置 {TYPE_STRING, class="num">0, class="num">0.0, "XYZ"}, {TYPE_BOOL, class="num">1, class="num">0.0, NULL}, {TYPE_INT, class="num">0, class="num">0.0, NULL}, class=class="str">"cmt">// 通用设置 {TYPE_STRING, class="num">0, class="num">0.0, "HTMLCSV"}, {TYPE_STRING, class="num">0, class="num">0.0, "PREFIX"}, {TYPE_STRING, class="num">0, class="num">0.0, "SUFFIX"}, {TYPE_INT, class="num">0, class="num">0.0, NULL}, class=class="str">"cmt">// 时间偏移 {TYPE_INT, class="num">0, class="num">0.0, NULL}, class=class="str">"cmt">// html 设置 {TYPE_STRING, class="num">0, class="num">0.0, "ROW"}, {TYPE_STRING, class="num">0, class="num">0.0, "COLUMNS"}, {TYPE_STRING, class="num">0, class="num">0.0, "SUBST"}, {TYPE_BOOL, class="num">0, class="num">0.0, NULL}, {TYPE_BOOL, class="num">0, class="num">0.0, NULL}, {TYPE_BOOL, class="num">0, class="num">0.0, NULL}, {TYPE_INT, class="num">0, class="num">0.0, NULL}, class=class="str">"cmt">// csv 设置 {TYPE_STRING, class="num">0, class="num">0.0, ";"} }; params[class="num">2].string_value = symbol; class=class="str">"cmt">// 注入当前品种 params[class="num">5].string_value = ReportFile; class=class="str">"cmt">// 报告文件 params[class="num">6].string_value = Prefix; params[class="num">7].string_value = Suffix; params[class="num">8].integer_value = TimeShift; class=class="str">"cmt">// 时间偏移量 params[class="num">10].string_value = RowSelector; params[class="num">11].string_value = ColumnSettingsFile; params[class="num">12].string_value = SubstitutionSettingsFile; params[class="num">17].string_value = CSVDelimiter; class=class="str">"cmt">// CSV 分隔符 class="type">int handle = IndicatorCreate(_Symbol, _Period, IND_CUSTOM, class="num">18, params); class=class="str">"cmt">// 建指标句柄 if(handle == INVALID_HANDLE) { Print("无法创建 SubChartReporter 为 ", symbol, ": ", GetLastError()); class="kw">return class="kw">false; } else { if(!ChartIndicatorAdd(class="num">0, (class="type">int)ChartGetInteger(class="num">0, CHART_WINDOWS_TOTAL), handle)) class=class="str">"cmt">// 挂到新子窗 { Print("无法附加 SubChartReporter 为", symbol, ": ", GetLastError()); class="kw">return class="kw">false; } } class="kw">return true; } class="macro">#resource "\Indicators\SubChartReporter.ex5" class="type">int OnStart() { IndexMap *data = NULL; class="type">int columnsCount = class="num">0, symbolColumn = class="num">0; if(ReportFile == "") { Print("cleanUpChart"); class="kw">return cleanUpChart(); class=class="str">"cmt">// 空报告名清场 } else if(StringFind(ReportFile, ".htm") > class="num">0) { data = HTMLConverter::convertReport2Map(ReportFile, true); columnsCount = COLUMNS_COUNT; symbolColumn = COLUMN_SYMBOL; } else if(StringFind(ReportFile, ".csv") > class="num">0) { data = CSVConverter::ReadCSV(ReportFile); if(data != NULL && data.getSize() > class="num">0) { IndexMap *row = data[class="num">0]; columnsCount = row.getSize(); symbolColumn = CSV_COLUMN_SYMBOL; } } if(data != NULL) { IndexMap symbols; for(class="type">int i = class="num">0; i < data.getSize(); ++i) { IndexMap *row = data[i]; if(CheckPointer(row) == POINTER_INVALID || row.getSize() != columnsCount) class="kw">break; class="type">class="kw">string s = row[symbolColumn].get<class="type">class="kw">string>(); StringTrimLeft(s); if(StringLen(s) > class="num">0) symbols.set(s); class=class="str">"cmt">// 收集去重品种 } for(class="type">int i = class="num">0; i < symbols.getSize(); ++i) { createIndicator(symbols.getKey(i)); class=class="str">"cmt">// 逐品种建子窗 } class="kw">delete data; } class="kw">return class="num">0; } class="type">bool cleanUpChart() { class="type">bool result = true; class="type">int n = (class="type">int)ChartGetInteger(class="num">0, CHART_WINDOWS_TOTAL); for(class="type">int i = n - class="num">1; i > class="num">0; --i) {