如何基于HTML和CSV报表可视化多币种交易历史(基础篇)
◍ 把多币种回测历史摊到同一张图上
MT5 自 2010 年推出多币种回测后,策略测试器只能回放出设置里那一个品种的成交图,跑完想复查全部品种的历史就得靠肉眼翻,效率低且容易漏。2019 年社区里有人统计,这类多币种可视化需求在信号类文章下讨论热度不低——尤其别人共享的 HTML 报告或信号 CSV,你本地没有直接对照图。 做法是在图表子窗口挂多个指标实例,一个品种一个,把报告里的成交画成同步于主图 K 线的价线加趋势线。另一种省事思路是主窗口只盯一个品种,用无缓冲区指标切到报告里的任意品种。 解析层已经有人写过基于 CSS 选择器的 HTML 提取器,CSV 更轻:MQL 内置函数直接认 MT4 的 *.history.csv 和 MT5 的 *.positions.csv。你手头只要有这两类文件,就能在 MT5 里复现多币种轨迹,不用信测试器那张单品种图。
在子窗口挂外部品种报价
做跨品种对照时,最省事的办法是把另一个交易品种的价格拉到当前图表的子窗口里。MQL5 里用 indicator_separate_window 声明独立窗口,再按主图当前的显示模式(烛形、柱状、折线)动态选 DRAW_CANDLES / DRAW_BARS / DRAW_LINE,这样切主图样式子图也跟着变。 核心参数是 SubSymbol 和 Exact。SubSymbol 留空就默认取 _Symbol,填了别的品种要先 SymbolSelect 加进市场报价;Exact 为 false 时用 iBarShift 找最近时间匹配的柱,缺数据就显示前一根,为 true 则严格对齐、对不上就留空。 线形模式只用一个缓冲区,且复用 open 而非 close,好处是切到折线或从折线切回时不用重算历史,只是顺手把开盘价当那条线。实盘验证可挂 EURUSD 主图、SubSymbol 填 UKBRENT,日线周期下把 Exact 设 true,能直接看到布伦特夜盘缺柱的空白段。外汇与贵金属杠杆高,跨品种对照仅作参考,不构成方向判断。
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">1 class="kw">input class="type">class="kw">string SubSymbol = ""; class=class="str">"cmt">// 交易品种 class="kw">input class="type">bool Exact = class="kw">false; class="type">class="kw">string Symbol; class="type">int OnInit() { Symbol = SubSymbol; if(Symbol == "") Symbol = _Symbol; else SymbolSelect(Symbol, true); ... ENUM_CHART_MODE mode = class="num">0; class=class="str">"cmt">// OHLC class="type">class="kw">double open[]; class="type">class="kw">double high[]; class="type">class="kw">double low[]; class="type">class="kw">double close[]; class="type">void InitBuffer(class="type">int index, class="type">class="kw">double &buffer[], ENUM_INDEXBUFFER_TYPE style) { SetIndexBuffer(index, buffer, style); ArraySetAsSeries(buffer, true); } class="type">void InitPlot(class="type">int index, class="type">class="kw">string name, class="type">int style, class="type">int width = -class="num">1, class="type">int colorx = -class="num">1) { PlotIndexSetInteger(index, PLOT_DRAW_TYPE, style); PlotIndexSetDouble(index, PLOT_EMPTY_VALUE, class="num">0); PlotIndexSetString(index, PLOT_LABEL, name); if(width != -class="num">1) PlotIndexSetInteger(index, PLOT_LINE_WIDTH, width); if(colorx != -class="num">1) PlotIndexSetInteger(index, PLOT_LINE_COLOR, colorx); } class="type">int Mode2Style(class=class="str">"cmt">/*global ENUM_CHART_MODE mode*/) { class="kw">switch(mode) { case CHART_CANDLES: class="kw">return DRAW_CANDLES; case CHART_BARS: class="kw">return DRAW_BARS; case CHART_LINE: class="kw">return DRAW_LINE; } class="kw">return class="num">0; } InitBuffer(class="num">0, open, INDICATOR_DATA); class="type">class="kw">string title = "# Open;# High;# Low;# Close"; StringReplace(title, "#", Symbol); mode = (ENUM_CHART_MODE)ChartGetInteger(class="num">0, CHART_MODE); InitPlot(class="num">0, title, Mode2Style()); InitBuffer(class="num">1, high, INDICATOR_DATA); InitBuffer(class="num">2, low, INDICATOR_DATA); InitBuffer(class="num">3, close, INDICATOR_DATA); class="type">void SetPlotColors() { if(mode == CHART_CANDLES) { PlotIndexSetInteger(class="num">0, PLOT_COLOR_INDEXES, class="num">3);
「副图指标如何跟着主图配色与重绘」
在 MT5 写跨图表副图指标时,最容易被忽略的是配色同步:用户切到白底或改了蜡烛色,指标线若写死颜色就会格格不入。下面这段代码在初始化和图表事件里都调用了 SetPlotColors,把长方形、阳线、阴线三色分别绑到 CHART_COLOR_CHART_LINE、CHART_COLOR_CANDLE_BULL、CHART_COLOR_CANDLE_BEAR,保证视觉一致。 [CODE] PlotIndexSetInteger(0, PLOT_LINE_COLOR, 0, (int)ChartGetInteger(0, CHART_COLOR_CHART_LINE)); // 长方形 PlotIndexSetInteger(0, PLOT_LINE_COLOR, 1, (int)ChartGetInteger(0, CHART_COLOR_CANDLE_BULL)); // 向上 PlotIndexSetInteger(0, PLOT_LINE_COLOR, 2, (int)ChartGetInteger(0, CHART_COLOR_CANDLE_BEAR)); // 向下 } else { PlotIndexSetInteger(0, PLOT_COLOR_INDEXES, 1); PlotIndexSetInteger(0, PLOT_LINE_COLOR, (int)ChartGetInteger(0, CHART_COLOR_CHART_LINE)); } } SetPlotColors(); IndicatorSetString(INDICATOR_SHORTNAME, "SubChart (" + Symbol + ")"); IndicatorSetInteger(INDICATOR_DIGITS, (int)SymbolInfoInteger(Symbol, SYMBOL_DIGITS)); return INIT_SUCCEEDED; } void OnChartEvent(const int id, const long& lparam, const double& dparam, const string& sparam) { if(id == CHARTEVENT_CHART_CHANGE) { mode = (ENUM_CHART_MODE)ChartGetInteger(0, CHART_MODE); PlotIndexSetInteger(0, PLOT_DRAW_TYPE, Mode2Style()); SetPlotColors(); ChartRedraw(); } } [/CODE] 逐行拆一下:前三行给索引 0 的绘图对象设三种颜色通道,0 是矩形边框色,1 是上涨段,2 是下跌段;else 分支是单色模式,只留图表线色。OnChartEvent 里监听 CHARTEVENT_CHART_CHANGE,图表属性一变就重读模式、重设线型并调 ChartRedraw 强制刷新。 OnCalculate 里有个防空洞的细节:iBars(Symbol,_Period)-lastAvailable>1 时把 _prev_calculated 清零,等于检测到历史缺口就全量重算,避免跨周期对齐时漏柱。外汇与贵金属行情跳空频繁,这类防护直接决定指标会不会画断线。 [CODE] int OnCalculate(const int rates_total, const int prev_calculated, const datetime& time[], const double& op[], const double& hi[], const double& lo[], const double& cl[], const long& tick_volume[], const long& volume[], const int& spread[]) { static int lastAvailable = 0; static bool initialized = false; int _prev_calculated = prev_calculated; if(iBars(Symbol, _Period) - lastAvailable > 1) { _prev_calculated = 0; lastAvailable = 0; initialized = false; } if(_prev_calculated == 0) { for(int i = 0; i < rates_total; ++i) { open[i] = 0; high[i] = 0; low[i] = 0; close[i] = 0; } } if(_Symbol != Symbol) { for(int i = 0; i < MathMax(rates_total - _prev_calculated, 1); ++i) { datetime dt = iTime(_Symbol, _Period, i); int x = iBarShift(Symbol, _Period, dt, Exact); if(x != -1) { open[i] = iOpen(Symbol, _Period, x); high[i] = iHigh(Symbol, _Period, x); low[i] = iLow(Symbol, _Period, x); close[i] = iClose(Symbol, _Period, x); } } } else { ArraySetAsSeries(op, true); ArraySetAsSeries(hi, true); ArraySetAsSeries(lo, true); ArraySetAsSeries(cl, true); for(int i = 0; i < MathMax(rates_total - _prev_calculated, 1); ++i) { open[i] = op[i]; high[i] = hi[i]; low[i] = lo[i]; close[i] = cl[i]; } } if(lastAvailable == iBars(Symbol, _Period) && lastAvailable != 0) { if(!initialized) { Print("Updated ", Symbol, " ", iBars(Symbol, _Period), " bars"); initialized = true; } return rates_total; } if(!initialized) { if(_Symbol != Symbol) { Print("Updating ", Symbol, " ", lastAvailable, " -> ", iBars(Symbol, _Period), " bars up to ", (string)time[0], "... Please wait"); int result = RefreshHistory(Symbol, time[0]); if(result >= 0 && result <= 2) { _prev_calculated = rates_total; } if(result >= 0) { [/CODE] 当 _Symbol 不等于目标 Symbol,代码用 iTime + iBarShift(Exact) 做时间对齐,把外部品种 OHLC 搬到当前图;同品种则直接把传入数组倒序后拷贝。最后一段的 RefreshHistory 调用返回 0~2 时把 _prev_calculated 置为总量,相当于标记“已追平”,下次刷新不再重复拉历史。开 MT5 挂一个非当前品种的副图,改改 CHART_COLOR 看线色是否实时跟,就能验证这套逻辑。
PlotIndexSetInteger(class="num">0, PLOT_LINE_COLOR, class="num">0, (class="type">int)ChartGetInteger(class="num">0, CHART_COLOR_CHART_LINE)); class=class="str">"cmt">// 长方形 PlotIndexSetInteger(class="num">0, PLOT_LINE_COLOR, class="num">1, (class="type">int)ChartGetInteger(class="num">0, CHART_COLOR_CANDLE_BULL)); class=class="str">"cmt">// 向上 PlotIndexSetInteger(class="num">0, PLOT_LINE_COLOR, class="num">2, (class="type">int)ChartGetInteger(class="num">0, CHART_COLOR_CANDLE_BEAR)); class=class="str">"cmt">// 向下 } else { PlotIndexSetInteger(class="num">0, PLOT_COLOR_INDEXES, class="num">1); PlotIndexSetInteger(class="num">0, PLOT_LINE_COLOR, (class="type">int)ChartGetInteger(class="num">0, CHART_COLOR_CHART_LINE)); } } SetPlotColors(); IndicatorSetString(INDICATOR_SHORTNAME, "SubChart(" + Symbol + ")"); IndicatorSetInteger(INDICATOR_DIGITS, (class="type">int)SymbolInfoInteger(Symbol, SYMBOL_DIGITS)); class="kw">return INIT_SUCCEEDED; } 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) { mode = (ENUM_CHART_MODE)ChartGetInteger(class="num">0, CHART_MODE); PlotIndexSetInteger(class="num">0, PLOT_DRAW_TYPE, Mode2Style()); SetPlotColors(); ChartRedraw(); } } class="type">int OnCalculate(class="kw">const class="type">int rates_total, class="kw">const class="type">int prev_calculated, class="kw">const class="type">class="kw">datetime& time[], class="kw">const class="type">class="kw">double& op[], class="kw">const class="type">class="kw">double& hi[], class="kw">const class="type">class="kw">double& lo[], class="kw">const class="type">class="kw">double& cl[], class="kw">const class="type">long& tick_volume[], class="kw">const class="type">long& volume[], class="kw">const class="type">int& spread[]) { class="kw">static class="type">int lastAvailable = class="num">0; class="kw">static class="type">bool initialized = class="kw">false; class="type">int _prev_calculated = prev_calculated; if(iBars(Symbol, _Period) - lastAvailable > class="num">1) { _prev_calculated = class="num">0; lastAvailable = class="num">0; initialized = class="kw">false; } if(_prev_calculated == class="num">0) { for(class="type">int i = class="num">0; i < rates_total; ++i) { open[i] = class="num">0; high[i] = class="num">0; low[i] = class="num">0; close[i] = class="num">0; } } if(_Symbol != Symbol) { for(class="type">int i = class="num">0; i < MathMax(rates_total - _prev_calculated, class="num">1); ++i) { class="type">class="kw">datetime dt = iTime(_Symbol, _Period, i); class="type">int x = iBarShift(Symbol, _Period, dt, Exact); if(x != -class="num">1) { open[i] = iOpen(Symbol, _Period, x); high[i] = iHigh(Symbol, _Period, x); low[i] = iLow(Symbol, _Period, x); close[i] = iClose(Symbol, _Period, x); } } } else { ArraySetAsSeries(op, true); ArraySetAsSeries(hi, true); ArraySetAsSeries(lo, true); ArraySetAsSeries(cl, true); for(class="type">int i = class="num">0; i < MathMax(rates_total - _prev_calculated, class="num">1); ++i) { open[i] = op[i]; high[i] = hi[i]; low[i] = lo[i]; close[i] = cl[i]; } } if(lastAvailable == iBars(Symbol, _Period) && lastAvailable != class="num">0) { if(!initialized) { Print("Updated ", Symbol, " ", iBars(Symbol, _Period), " bars"); initialized = true; } class="kw">return rates_total; } if(!initialized) { if(_Symbol != Symbol) { Print("Updating ", Symbol, " ", lastAvailable, " -> ", iBars(Symbol, _Period), " bars up to ", (class="type">class="kw">string)time[class="num">0], "... Please wait"); class="type">int result = RefreshHistory(Symbol, time[class="num">0]); if(result >= class="num">0 && result <= class="num">2) { _prev_calculated = rates_total; } if(result >= class="num">0) {
◍ 图表绑定与历史根数校验的落地写法
这段初始化逻辑解决一个常见坑:EA 加载时若当前图表品种或周期不对,直接用 ChartSetSymbolPeriod 把零号图表切到 EA 内部符号与周期,避免后续拿到的缓冲区和预期不一致。 当切换成功或无需切换时,都把 initialized 置为 true,标记准备就绪;随后用 iBars(Symbol, _Period) 取当前可用 K 线根数写入 lastAvailable,作为后续增量计算的水位线。 返回处做了符号一致性判断:若运行符号和 Symbol 不同,交还 _prev_calculated 让系统沿用上次计算量;否则给 rates_total。实战里这一行能防止跨品种加载时重复刷全部历史。 开 MT5 把这段贴进 EA 的 OnCalculate 头部,改 _Symbol 为你要盯的 XAUUSD,跑一根新 K 线看 lastAvailable 是否随 iBars 实时变化即可验证。外汇与贵金属杠杆高,参数误用可能快速回撤,先在模拟盘确认。
initialized = true; ChartSetSymbolPeriod(class="num">0, _Symbol, _Period); } } else { initialized = true; } } lastAvailable = iBars(Symbol, _Period); class="kw">return _Symbol != Symbol ? _prev_calculated : rates_total;
把第三方交易报告铺到MT5子窗口
SubChartReporter 指标的核心是把 HTML/CSV 交易报告里的成交记录,重绘成 MT5 子窗口里的趋势线和箭头。它用一个 Processor 基类统一处理不同报表格式,HTML 走 ReportProcessor、CSV 走 HistoryProcessor,外部通过 ReportFile 输入变量传文件名,并用 Prefix / Suffix / TimeShift 解决跨账户交易品种命名不一致的问题。 基类里 symbol 是报告里的原始品种名,realsymbol 是本地能匹配到的真实品种(比如报告写 EURUSD.m,你经纪商只有 EURUSD)。findrealsymbol 会先查市场报价有没有投标价,没有再按前后缀裁剪去试,拿到有效价格才算匹配成功。 界面上左下角排一排按钮,点哪个就切到哪个品种。点击后旧对象先删掉,重新搜 realsymbol,把对应成交塞进 makeTrade,再 render 画趋势线。EURUSD 图表挂上指标、ReportFile 指到 reporttester-example.html 时,初始化后子窗口出灰按钮;点 EURGBP 按钮变绿,对应报价和交易就加载进来——这是可直接在 MT5 复现的现象。 HTML 解析复用 WebDataExtractor,把每笔 Deal 存时间、价格、方向、量。makeTrade 把 Deal 进 array,render 里按 FIFO 把进场和出场配对画连线;队列剩单就当未平仓位。FIFO 只是算法默认选择,LIFO 或手工对冲的配对不在本文范围内。CSV 侧简单得多,按 .history.csv(MT4)和 .positions.csv(MT5)自动分流,分隔符默认分号。 OnInit 建处理器、OnDeInit 销毁;OnChartEvent 接按钮事件调虚拟 apply;OnCalculate 盯品种切换做全量重算;定时器等稳定后载报告。要一次看全品种,可写 SubChartsBuilder 脚本批量开子窗口挂实例。外汇与贵金属品种映射出错会导致对象不显示,实盘数据高风险,映射逻辑建议先用历史报告验证。
class="kw">input class="type">class="kw">string ReportFile = ""; class=class="str">"cmt">// · ReportFile class="kw">input class="type">class="kw">string Prefix = ""; class=class="str">"cmt">// · Prefix class="kw">input class="type">class="kw">string Suffix = ""; class=class="str">"cmt">// · Suffix class="kw">input class="type">int TimeShift = class="num">0; class=class="str">"cmt">// · TimeShift class Processor { class="kw">protected: class="type">class="kw">string symbol; class="type">class="kw">string realsymbol; IndexMap *data; class="type">class="kw">ulong timestamp; class="type">class="kw">string prefix; class="type">class="kw">string pressed; class="type">void createTrend(class="kw">const class="type">long dealIn, class="kw">const class="type">long dealOut, class="kw">const class="type">int type, class="kw">const class="type">class="kw">datetime time1, class="kw">const class="type">class="kw">double price1, class="kw">const class="type">class="kw">datetime time2, class="kw">const class="type">class="kw">double price2, class="kw">const class="type">class="kw">string &description)