基于MQL5中表模型的表类和表头类:应用MVC概念·综合运用
(3/3)·从二维数组到链表存储,补齐列控制与表管理,MVC界面骨架才算真正立住
- 表格控件的类型映射与行列接口
- 表格控件的单元格与行列操作接口
- CTableModel 的接口与多路构造
- 用矩阵和参数列表灌入表格模型
- 按数据类型把参数灌进表格单元
- 按交易字段类型填单元格的散尾逻辑
- 给表格列统一改类型与小数位
- 表头对象与列头列表的落地结构
- 表头对象的比较与持久化接口
- 表格列标题对象的存档与回读
- 表头类的列管理与内存清理
- 表头对象的列标题挂载逻辑
- 表头类的列操作与索引维护
- 表头列序重排与数据清理的实现细节
- 表头对象的日志打印与文件存取实现
- 反序列化时的类型与表头校验
- Excel 风列名与表类的指针结构
- 表格模型与表头的存取和清理接口
- 表格模型的清理与访问接口
- 表格单元的读写与行管理接口
- 表格对象的行列增删与序列化接口
- 把列序号翻成 Excel 列名
- CTable 三种构造路径与资源回收
- 表格对象的比较与列名拷贝逻辑
- 表格单元的属性与对象接管
- 表格控件的单元格与行操作接口
- 表格行与列的增删搬移接口
- 表格列操作的底层方法拆解
- 表格对象的列类型与日志打印实现
- 把表格对象落盘前先写数据头标记
- 从文件句柄复原表格对象的校验链
- 把行数据与表头喂给表格模型
- 空表与数据表的日志打印差异
- 用 CTable 把矩阵丢进日志
- 用矩阵直接喂出控制台表格
- 用双层链表把成交记录铺成表格
- 把成交记录拆成可显示的属性格
- 补全成交类型与方向字段的映射
- 把成交单的进出场属性逐列填进表格行
- 成交归因与建表落盘
- 逐行读持仓明细表
- 从成交明细看 EA 的进出场痕迹
- 成交明细里藏着的风控线索
- 下一步把视图和控制器一起做掉
表格控件的类型映射与行列接口
这段代码片段展示了一个 CTable 类内部对基础类型的判别逻辑:当字段类型名为 "datetime" 时映射到 TYPE_DATETIME,为 "color" 时映射到 TYPE_COLOR,其余字符串类统一落到 TYPE_STRING。这种三元判别写在返回语句里,属于典型的 MQL5 枚举桥接手法,方便后续把脚本变量安全地塞进 GUI 表格单元。 公开接口层面,GetRow 直接借 m_list_rows.GetNodeAtIndex(index) 取行节点,RowsTotal 用 m_list_rows.Total() 返回当前行数——这两个都是 O(1) 调用,回测里挂万行以内的面板不会卡。 写值走模板函数 CellSetValue< typename T >,配合 CellSetDigits 控精度、CellSetTimeFlags 控时间格式、CellSetColorNamesFlag 控颜色名显示。想在 MT5 自建报价面板,直接抄这套接口就能把 tick 数据按行列落桌。外汇与贵金属波动剧烈,自建表格渲染若堵在主线程可能漏 tick,实盘前务必在策略测试器跑压力验证。
type_name=="class="type">class="kw">datetime" ? TYPE_DATETIME : class=class="str">"cmt">//--- Color value type_name=="class="type">class="kw">color" ? TYPE_COLOR : class=class="str">"cmt">/*--- String value */ TYPE_STRING); class=class="str">"cmt">//--- Create and add a new empty class="type">class="kw">string to the end of the list CTableRow *CreateNewEmptyRow(class="type">void); class=class="str">"cmt">//--- Add a class="type">class="kw">string to the end of the list class="type">bool AddNewRow(CTableRow *row); class=class="str">"cmt">//--- Set the row and column positions to all table cells class="type">void CellsPositionUpdate(class="type">void); class="kw">public: class=class="str">"cmt">//--- Return(class="num">1) cell, (class="num">2) row by index, number(class="num">3) of rows, cells(class="num">4) in the specified row and(class="num">5) in the table CTableCell *GetCell(const class="type">uint row, const class="type">uint col); CTableRow *GetRow(const class="type">uint index) { class="kw">return this.m_list_rows.GetNodeAtIndex(index); } class="type">uint RowsTotal(class="type">void) const { class="kw">return this.m_list_rows.Total(); } class="type">uint CellsInRow(const class="type">uint index); class="type">uint CellsTotal(class="type">void); class=class="str">"cmt">//--- Set(class="num">1) value, (class="num">2) precision, (class="num">3) time display flags and(class="num">4) class="type">class="kw">color name display flag to the specified cell class="kw">template<class="kw">typename T> class="type">void CellSetValue(const class="type">uint row, const class="type">uint col, const T value); class="type">void CellSetDigits(const class="type">uint row, const class="type">uint col, const class="type">int digits); class="type">void CellSetTimeFlags(const class="type">uint row, const class="type">uint col, const class="type">uint flags); class="type">void CellSetColorNamesFlag(const class="type">uint row, const class="type">uint col, const class="type">bool flag);
◍ 表格控件的单元格与行列操作接口
在 MT5 自定义表格类里,单元格级别先开放了对象绑定与解绑:CellAssignObject 把任意 CObject 派生实例挂到指定 row/col,CellUnassignObject 只做解绑不销毁对象,方便复用图形或指标锚点。 单元格自身的删、移由 CellDelete 与 CellMoveTo 负责,后者接收 cell_index 与目标 index_to,返回 bool 可供调用方判断越界。 取数类接口里 CellDescription 回字符串、CellPrint 直接打日志、CellGetObject 回指针;这三者组合能在 EA 面板悬停时实时回看某格绑了啥对象。 行操作上 RowAddNew 追加空行、RowInsertNewTo 按索引插队;RowDelete / RowMoveTo / RowClearData 分别管删、移、清数据,行描述用 RowDescription 取、RowPrint 带 detail 开关打细节。 列接口最灵活:ColumnAddNew 默认塞到末尾(index=-1),也可指定插入位;ColumnSetDatatype 接 ENUM_DATATYPE 决定该列存整型、双精或字符串,精度由另外接口控。外汇与贵金属行情高频跳动,表格若绑实时报价,清数据与改类型要在 tick 外做,避免重绘卡顿。
class=class="str">"cmt">//--- (class="num">1) Assign and(class="num">2) cancel the object in the cell class="type">void CellAssignObject(const class="type">uint row, const class="type">uint col,CObject *object); class="type">void CellUnassignObject(const class="type">uint row, const class="type">uint col); class=class="str">"cmt">//--- (class="num">1) Delete and(class="num">2) move the cell class="type">bool CellDelete(const class="type">uint row, const class="type">uint col); class="type">bool CellMoveTo(const class="type">uint row, const class="type">uint cell_index, const class="type">uint index_to); class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the cell description and(class="num">3) the object assigned to the cell class="type">class="kw">string CellDescription(const class="type">uint row, const class="type">uint col); class="type">void CellPrint(const class="type">uint row, const class="type">uint col); CObject *CellGetObject(const class="type">uint row, const class="type">uint col); class="kw">public: class=class="str">"cmt">//--- Create a new class="type">class="kw">string and(class="num">1) add it to the end of the list, (class="num">2) insert to the specified list position CTableRow *RowAddNew(class="type">void); CTableRow *RowInsertNewTo(const class="type">uint index_to); class=class="str">"cmt">//--- (class="num">1) Remove or(class="num">2) relocate the row, (class="num">3) clear the row data class="type">bool RowDelete(const class="type">uint index); class="type">bool RowMoveTo(const class="type">uint row_index, const class="type">uint index_to); class="type">void RowClearData(const class="type">uint index); class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the row description in the journal class="type">class="kw">string RowDescription(const class="type">uint index); class="type">void RowPrint(const class="type">uint index,const class="type">bool detail); class=class="str">"cmt">//--- (class="num">1) Add, (class="num">2) remove, (class="num">3) relocate a column, (class="num">4) clear data, set the column data(class="num">5) type and(class="num">6) accuracy class="type">bool ColumnAddNew(const class="type">int index=-class="num">1); class="type">bool ColumnDelete(const class="type">uint index); class="type">bool ColumnMoveTo(const class="type">uint col_index, const class="type">uint index_to); class="type">void ColumnClearData(const class="type">uint index); class="type">void ColumnSetDatatype(const class="type">uint index,const ENUM_DATATYPE type);
「CTableModel 的接口与多路构造」
CTableModel 是 MT5 标准库里管二维数据表的类,这一节把它的成员函数和构造函数摊开看。先说几个容易被忽略的方法:ColumnSetDigits(uint index, int digits) 能按列序号单独设小数位,做多品种报价表时 XAUUSD 给 2 位、USDJPY 给 3 位就不会串格式。 PrintTable(int cell_width=CELL_WIDTH_IN_CHARS) 默认用宏定义的单元格字符宽把表打到日志,调参时能直接肉眼核对行列对齐;Destroy() 和 ClearData() 要分清,前者连模型对象一起拆,后者只清数据保留结构。 构造侧给了四条路:模板数组、指定行列数、matrix 引用、CList 引用。用 matrix 构造最省事,比如把 100 根 K 线的 OHLC 塞进 matrix 再丢给 CTableModel,一行就出表模型。外汇和贵金属波动大,这类表只作本地分析辅助,实盘信号仍要以经纪商报价为准,杠杆品种风险高。 Compare / Save / Load 都是虚函数,意味着你继承后能把表模型存文件再恢复,回测样本复用就靠它。Type() 写死返回 OBJECT_TYPE_TABLE_MODEL,只是给对象树做类型识别用。
class="type">void ColumnSetDigits(const class="type">uint index,const class="type">int digits); class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the table description in the journal class="kw">virtual class="type">class="kw">string Description(class="type">void); class="type">void Print(const class="type">bool detail); class="type">void PrintTable(const class="type">int cell_width=CELL_WIDTH_IN_CHARS); class=class="str">"cmt">//--- (class="num">1) Clear the data, (class="num">2) destroy the model class="type">void ClearData(class="type">void); class="type">void Destroy(class="type">void); class=class="str">"cmt">//--- Virtual methods of(class="num">1) comparing, (class="num">2) saving to file, (class="num">3) loading from file, (class="num">4) object type class="kw">virtual class="type">int Compare(const CObject *node,const class="type">int mode=class="num">0) const; class="kw">virtual class="type">bool Save(const class="type">int file_handle); class="kw">virtual class="type">bool Load(const class="type">int file_handle); class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return(OBJECT_TYPE_TABLE_MODEL); } class=class="str">"cmt">//--- Constructors/destructor class="kw">template<class="kw">typename T> CTableModel(T &array[][]) { this.CreateTableModel(array); } CTableModel(const class="type">uint num_rows,const class="type">uint num_columns) { this.CreateTableModel(num_rows,num_columns); } CTableModel(const matrix &row_data) { this.CreateTableModel(row_data); } CTableModel(CList &row_data) { this.CreateTableModel(row_data); } CTableModel(class="type">void){} ~CTableModel(class="type">void){} };
用矩阵和参数列表灌入表格模型
在 MT5 自定义表格控件里,表格模型(CTableModel)的填充通常走两条路:空壳逐格建,或直接拿矩阵一次性铺满。前一种适合先占坑再异步写值,后一种适合回测结果、品种快照这类规整二维数据。 下面这段先按行数、列数双重循环,每行用 CreateNewEmptyRow() 挂到行链表尾,再在行内用 CellAddNew(0.0) 加单元格并 ClearData() 清空。注意 num_rows、num_columns 若为 0,外层循环直接不进,表格保持空模型,不会崩。
for(class="type">uint r=class="num">0; r<num_rows; r++) { CTableRow *row=this.CreateNewEmptyRow(); if(row!=NULL) { for(class="type">uint c=class="num">0; c<num_columns; c++) { CTableCell *cell=row.CellAddNew(class="num">0.0); if(cell!=NULL) cell.ClearData(); } } }
class="type">void CTableModel::CreateTableModel(const matrix &row_data) { class="type">ulong num_rows=row_data.Rows(); class="type">ulong num_columns=row_data.Cols(); for(class="type">uint r=class="num">0; r<num_rows; r++) { CTableRow *row=this.CreateNewEmptyRow(); if(row!=NULL) { for(class="type">uint c=class="num">0; c<num_columns; c++) row.CellAddNew(row_data[r][c]); } } }
class="type">void CTableModel::CreateTableModel(CList &list_param) { if(list_param.Total()==class="num">0) { ::PrintFormat("%s: Error. Empty list passed",__FUNCTION__); class="kw">return; } CList *first_row=list_param.GetFirstNode(); if(first_row==NULL || first_row.Total()==class="num">0) { if(first_row==NULL) ::PrintFormat("%s: Error. Failed to get first row of list",__FUNCTION__); else ::PrintFormat("%s: Error. First row does not contain data",__FUNCTION__); } }
for(class="type">uint r=class="num">0; r<num_rows; r++) { CTableRow *row=this.CreateNewEmptyRow(); if(row!=NULL) { for(class="type">uint c=class="num">0; c<num_columns; c++) { CTableCell *cell=row.CellAddNew(class="num">0.0); if(cell!=NULL) cell.ClearData(); } } } } class="type">void CTableModel::CreateTableModel(const matrix &row_data) { class="type">ulong num_rows=row_data.Rows(); class="type">ulong num_columns=row_data.Cols(); for(class="type">uint r=class="num">0; r<num_rows; r++) { CTableRow *row=this.CreateNewEmptyRow(); if(row!=NULL) { for(class="type">uint c=class="num">0; c<num_columns; c++) row.CellAddNew(row_data[r][c]); } } } } class="type">void CTableModel::CreateTableModel(CList &list_param) { if(list_param.Total()==class="num">0) { ::PrintFormat("%s: Error. Empty list passed",__FUNCTION__); class="kw">return; } CList *first_row=list_param.GetFirstNode(); if(first_row==NULL || first_row.Total()==class="num">0) { if(first_row==NULL) ::PrintFormat("%s: Error. Failed to get first row of list",__FUNCTION__); else ::PrintFormat("%s: Error. First row does not contain data",__FUNCTION__); } }
◍ 按数据类型把参数灌进表格单元
这段逻辑干的事很直接:先拿到参数列表的行数 num_rows 和列数 num_columns,外层按行循环,内层按列循环,把 CList 里的 CMqlParamObj 逐个转成表格的 CTableCell。 内层循环里先用 col_list.GetNodeAtIndex(c) 取单元格参数对象,空指针就 continue 跳过,避免脏数据把整行构建打断。 真正分叉点在 switch(datatype):TYPE_FLOAT 和 TYPE_DOUBLE 走同一分支,用 row.CellAddNew((double)param.ValueD()) 建双精度单元格,紧接着若 cell 非空就调 cell.SetDigits((int)param.ValueL()) 设定显示小数位——这一步决定你在面板里看到的是 1.05000 还是 1.05。 TYPE_DATETIME 分支则是 row.CellAddNew((datetime)param.ValueL()) 建时间单元格,再用 cell.SetDatetimeFlags((int)param.ValueD()) 控制日期时间的呈现格式。外汇与贵金属行情里时间轴对齐很关键,这类标志位设错可能导致回测和实盘时间戳错位,属高风险操作环节,建议开 MT5 把这段挂到自定义面板里逐项验证显示效果。
class="type">ulong num_rows=list_param.Total(); class="type">ulong num_columns=first_row.Total(); for(class="type">uint r=class="num">0; r<num_rows; r++) { CList *col_list=list_param.GetNodeAtIndex(r); if(col_list==NULL) class="kw">continue; CTableRow *row=this.CreateNewEmptyRow(); if(row!=NULL) { for(class="type">uint c=class="num">0; c<num_columns; c++) { CMqlParamObj *param=col_list.GetNodeAtIndex(c); if(param==NULL) class="kw">continue; CTableCell *cell=NULL; ENUM_DATATYPE datatype=param.Datatype(); class="kw">switch(datatype) { case TYPE_FLOAT : case TYPE_DOUBLE : cell=row.CellAddNew((class="type">class="kw">double)param.ValueD()); if(cell!=NULL) cell.SetDigits((class="type">int)param.ValueL()); class="kw">break; case TYPE_DATETIME: cell=row.CellAddNew((class="type">class="kw">datetime)param.ValueL()); if(cell!=NULL) cell.SetDatetimeFlags((class="type">int)param.ValueD()); } } } }
「按交易字段类型填单元格的散尾逻辑」
上面那段 switch 散代码处理的是把不同 MQL5 数据类型写进表格行单元格:颜色型用 CellAddNew((color)param.ValueL()) 建格后,若返回非空指针就顺手 SetColorNameFlag((bool)param.ValueD()) 控制是否显示已知色名;字符串与长整型则分别走 TYPE_STRING 和 default 分支直接建格。 尾部脱离 switch 后,单独抓了每笔成交的盈亏:用 HistoryDealGetDouble(ticket,DEAL_PROFIT) 取双精度值,再按非零与否把 integer_value 置为 2 或 1,最后交给 DataListCreator::AddNewCellParamToRow 落表。这里 integer_value 的 1/2 仅是标记盈亏状态,不预示任何方向。 ColumnAddNew 则是给现有所有行批量补一列双精度空格:循环 this.RowsTotal() 次,逐行 CellAddNew(0.0),任一返回 NULL 就把 res 按位与 false 记失败。外汇与贵金属表格化回测时,这种空列常用来后续填指标,注意 MT5 高危杠杆风险。
class="kw">break; class=class="str">"cmt">//--- class="type">class="kw">color data type case TYPE_COLOR : cell=row.CellAddNew((class="type">class="kw">color)param.ValueL()); class=class="str">"cmt">// Create a new cell with class="type">class="kw">color data and if(cell!=NULL) cell.SetColorNameFlag((class="type">bool)param.ValueD()); class=class="str">"cmt">// set the flag for displaying the names of known colors class="kw">break; class=class="str">"cmt">//--- class="type">class="kw">string data type case TYPE_STRING : cell=row.CellAddNew((class="type">class="kw">string)param.ValueS()); class=class="str">"cmt">// Create a new cell with class="type">class="kw">string data class="kw">break; class=class="str">"cmt">//--- integer data type class="kw">default : cell=row.CellAddNew((class="type">long)param.ValueL()); class=class="str">"cmt">// Create a new cell with class="type">long data class="kw">break; } } } } class=class="str">"cmt">//--- Financial result of a trade param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_PROFIT); param.integer_value=(param.double_value!=class="num">0 ? class="num">2 : class="num">1); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Add a column | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTableModel::ColumnAddNew(const class="type">int index=-class="num">1) { class=class="str">"cmt">//--- Declare the variables CTableCell *cell=NULL; class="type">bool res=true; class=class="str">"cmt">//--- In the loop based on the number of rows for(class="type">uint i=class="num">0;i<this.RowsTotal();i++) { class=class="str">"cmt">//--- get the next row CTableRow *row=this.GetRow(i); if(row!=NULL) { class=class="str">"cmt">//--- add a cell of class="type">class="kw">double type to the end of the row cell=row.CellAddNew(class="num">0.0); if(cell==NULL) res &=false; class=class="str">"cmt">//--- clear the cell else
给表格列统一改类型与小数位
CTableModel 里这两个方法专门处理整列的属性批量写入,而不是逐个单元格去改。ColumnSetDatatype 接收列序号 index 和 ENUM_DATATYPE 枚举,对从 0 到 RowsTotal()-1 的每一行取出该列单元格,非空就调用 SetDatatype 覆盖类型。 ColumnSetDigits 逻辑完全一致,只是把类型换成精度位数 digits,用来控制浮点报价在面板里显示几位小数。写 EA 自定义报价表时,若某列要切从整数点差改成带 2 位小数的 spread 显示,直接传 digits=2 即可,不必重建表结构。 两个函数都靠 this.GetCell(i, index) 定位,循环上限用 RowsTotal() 而非写死数值,因此后续增删行不会漏改。打开 MT5 新建脚本继承该类,挂上这两段就能在运行时动态规整列格式。
class="type">void CTableModel::ColumnSetDatatype(const class="type">uint index,const ENUM_DATATYPE type) { class=class="str">"cmt">//--- In a loop through all table rows for(class="type">uint i=class="num">0;i<this.RowsTotal();i++) { class=class="str">"cmt">//--- get the cell with the column index from each row and set the data type CTableCell *cell=this.GetCell(i, index); if(cell!=NULL) cell.SetDatatype(type); } } class="type">void CTableModel::ColumnSetDigits(const class="type">uint index,const class="type">int digits) { class=class="str">"cmt">//--- In a loop through all table rows for(class="type">uint i=class="num">0;i<this.RowsTotal();i++) { class=class="str">"cmt">//--- get the cell with the column index from each row and set the data accuracy CTableCell *cell=this.GetCell(i, index); if(cell!=NULL) cell.SetDigits(digits); } }
◍ 表头对象与列头列表的落地结构
表头在 MT5 里不是界面控件,而是一串带字符串值的列头对象,全部挂在 CListObj 动态列表里。要把这套机制跑起来,得拆成两个类:单个列头对象类(管文本、列号、数据类型和单元格控制),以及表头类(管列头对象列表和列的访问方法)。两者都写在 Scripts\Tables\Tables.mqh 同一个文件里,方便后续表类直接调用。 列头对象类 CColumnCaption 本质是单元格类的精简版。比较虚方法只按列索引排序;存盘时先写数据开始标记和对象类型,再逐属性落盘,读回顺序一致——这套逻辑和前一篇的表单元格完全相同。描述方法会拼出「对象类型:列 XX,值 “Value”」供日志排查。 表头类则负责把列头塞进列表。新增列头时,文本传入后新建对象,其索引直接取当前列表长度(即末尾位置),再 push 进列表并返回指针;从字符串数组批量建头则按数组 size 循环生成。删头或移动位置后必须调「重排索引」方法,否则列头索引和实际列表位置错位,后续按索引读数据类型就会偏。 为列头设 ENUM_DATATYPE 有个实战价值:列的单元格类型可以继承列头的设定,读数据时只需从列头取一次类型。无表头的表只能按模型静态显示,接 Controller 做交互必须带表头——外汇和贵金属面板的高频刷新场景下,这种结构能降低误读风险,但杠杆品种本身波动剧烈,仍属高风险操作。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Table column header class | class=class="str">"cmt">//+------------------------------------------------------------------+ class CColumnCaption : class="kw">public CObject { class="kw">protected: class=class="str">"cmt">//--- Variables class="type">class="kw">ushort m_ushort_array[MAX_STRING_LENGTH]; class=class="str">"cmt">// Array of header symbols class="type">uint m_column; class=class="str">"cmt">// Column index ENUM_DATATYPE m_datatype; class=class="str">"cmt">// Data type class="kw">public: class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the column index class="type">void SetColumn(const class="type">uint column) { this.m_column=column; } class="type">uint Column(class="type">void) const { class="kw">return this.m_column; } class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the column data type ENUM_DATATYPE Datatype(class="type">void) const { class="kw">return this.m_datatype; } class="type">void SetDatatype(const ENUM_DATATYPE datatype) { this.m_datatype=datatype;} class=class="str">"cmt">//--- Clear data class="type">void ClearData(class="type">void) { this.SetValue(""); } class=class="str">"cmt">//--- Set the header class="type">void SetValue(const class="type">class="kw">string value) { ::StringToShortArray(value,this.m_ushort_array);
「表头对象的比较与持久化接口」
在自定义表格控件的底层类里,CColumnCaption 承担了列标题的存储与排序基准。它的 Value() 方法把内部 ushort 数组转成字符串后做了左右去空格,意味着你往标题里塞的换行或制表符会被静默吃掉,MT5 里看到的就是纯文本。 比较逻辑只认列序号:Compare() 拿 this.Column() 和传入节点的列号硬比,大于返回 1、小于返回 -1、相等返回 0。这决定了后续用 CArrayObj 排序时,标题行会严格按 m_column 升序排,跟文字内容无关。 Type() 被直接内联返回 OBJECT_TYPE_COLUMN_CAPTION,相当于给对象打了一个编译期常量标签,Save/Load 虚函数则留了文件句柄接口,方便把表头配置落盘。开 MT5 建个继承该类的 EA,故意给两个标题设相同 column 值,能验证排序塌缩成 0 分支。
class="type">class="kw">string Value(class="type">void) const { class="type">class="kw">string res=::ShortArrayToString(this.m_ushort_array); res.TrimLeft(); res.TrimRight(); class="kw">return res; } class="kw">virtual class="type">int Compare(const CObject *node,const class="type">int mode=class="num">0) const; class="kw">virtual class="type">bool Save(const class="type">int file_handle); class="kw">virtual class="type">bool Load(const class="type">int file_handle); class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return(OBJECT_TYPE_COLUMN_CAPTION); } CColumnCaption(class="type">void) : m_column(class="num">0) { this.SetValue(""); } CColumnCaption(const class="type">uint column,const class="type">class="kw">string value) : m_column(column) { this.SetValue(value); } ~CColumnCaption(class="type">void) {} class="type">int CColumnCaption::Compare(const CObject *node,const class="type">int mode=class="num">0) const { const CColumnCaption *obj=node; class="kw">return(this.Column()>obj.Column() ? class="num">1 : this.Column()<obj.Column() ? -class="num">1 : class="num">0); }
表格列标题对象的存档与回读
在 MT5 自定义表格控件里,CColumnCaption 这个类负责管一列的标题。要把它落盘,得先写 0xFFFFFFFFFFFFFFFF 这个 8 字节起始标记,再依次存对象类型、列索引、ushort 数组内容;任何一步 FileWrite 返回的字节数对不上,函数直接返 false,避免半截文件。 读回时顺序必须严格对称:先 FileReadLong 比对 MARKER_START_DATA,再读类型做一致性校验,类型不符也返 false。列索引和值数组读进来后,若数组长度与 sizeof 不符同样判失败。这套双向校验能在 EA 重启后还原界面状态,外汇或贵金属图表的高频刷新场景下,断点续存倾向更稳。 Description() 用 StringFormat 拼出「类型: Column 序号, Value: 文本」的日志串,Print() 直接丢进专家日志。调面板时开着 MT5 终端的 EA 日志窗,就能看到每一列标题的实时反序列化结果。
if(file_handle==INVALID_HANDLE) class="kw">return(false); class=class="str">"cmt">//--- Save data start marker - 0xFFFFFFFFFFFFFFFF if(::FileWriteLong(file_handle,MARKER_START_DATA)!=class="kw">sizeof(class="type">long)) class="kw">return(false); class=class="str">"cmt">//--- Save the object type if(::FileWriteInteger(file_handle,this.Type(),INT_VALUE)!=INT_VALUE) class="kw">return(false); class=class="str">"cmt">//--- Save the column index if(::FileWriteInteger(file_handle,this.m_column,INT_VALUE)!=INT_VALUE) class="kw">return(false); class=class="str">"cmt">//--- Save the value if(::FileWriteArray(file_handle,this.m_ushort_array)!=class="kw">sizeof(this.m_ushort_array)) class="kw">return(false); class=class="str">"cmt">//--- All is successful class="kw">return true; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Load from file | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CColumnCaption::Load(const class="type">int file_handle) { class=class="str">"cmt">//--- Check the handle if(file_handle==INVALID_HANDLE) class="kw">return(false); class=class="str">"cmt">//--- Load and check the data start marker - 0xFFFFFFFFFFFFFFFF if(::FileReadLong(file_handle)!=MARKER_START_DATA) class="kw">return(false); class=class="str">"cmt">//--- Load the object type if(::FileReadInteger(file_handle,INT_VALUE)!=this.Type()) class="kw">return(false); class=class="str">"cmt">//--- Load the column index this.m_column=::FileReadInteger(file_handle,INT_VALUE); class=class="str">"cmt">//--- Load the value if(::FileReadArray(file_handle,this.m_ushort_array)!=class="kw">sizeof(this.m_ushort_array)) class="kw">return(false); class=class="str">"cmt">//--- All is successful class="kw">return true; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the object description | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string CColumnCaption::Description(class="type">void) { class="kw">return(::StringFormat("%s: Column %u, Value: \"%s\"", TypeDescription((ENUM_OBJECT_TYPE)this.Type()),this.Column(),this.Value())); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Display the object description in the journal | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CColumnCaption::Print(class="type">void) { ::Print(this.Description());
◍ 表头类的列管理与内存清理
在 MT5 自定义表格控件里,CTableHeader 继承自 CObject,用 m_list_captions 这个 CListObj 容器hold住所有列标题对象,另有一个 m_caption_tmp 作为列表查找时的临时锚点。 对外接口里,GetColumnCaption(index) 直接返回第 index 个节点,ColumnsTotal() 返回 m_list_captions.Total()——这两个都是 inline 写法,调用零额外开销。 CreateNewColumnCaption 负责 new 一个 CColumnCaption 并挂到链表尾;ColumnCaptionMoveTo 能把某列从 caption_index 挪到 index_to,重排时不用重建整个表头。 Destroy() 只做一件事:m_list_captions.Clear() 清空链表,但不释放单个节点内存,若你的 caption 是 new 出来的,得在 ClearData 里自己处理,否则可能漏内存。 外汇和贵金属图表上挂这类 GUI 属于高风险操作环境,建议先在策略测试器里用模拟品种验证链表行为,再上真实 tick。
class CTableHeader : class="kw">public CObject { class="kw">protected: CColumnCaption m_caption_tmp; class=class="str">"cmt">// Column header object to search in the list CListObj m_list_captions; class=class="str">"cmt">// List of column headers class=class="str">"cmt">//--- Add the specified header to the end of the list class="type">bool AddNewColumnCaption(CColumnCaption *caption); class=class="str">"cmt">//--- Create a table header from a class="type">class="kw">string array class="type">void CreateHeader(class="type">class="kw">string &array[]); class=class="str">"cmt">//--- Set the column position of all column headers class="type">void ColumnPositionUpdate(class="type">void); class="kw">public: class=class="str">"cmt">//--- Create a new header and add it to the end of the list CColumnCaption *CreateNewColumnCaption(const class="type">class="kw">string caption); class=class="str">"cmt">//--- Return(class="num">1) the header by index and(class="num">2) the number of column headers CColumnCaption *GetColumnCaption(const class="type">uint index) { class="kw">return this.m_list_captions.GetNodeAtIndex(index); } class="type">uint ColumnsTotal(class="type">void) const { class="kw">return this.m_list_captions.Total(); } class=class="str">"cmt">//--- Set the value of the specified column header class="type">void ColumnCaptionSetValue(const class="type">uint index,const class="type">class="kw">string value); class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the data type for the specified column header class="type">void ColumnCaptionSetDatatype(const class="type">uint index,const ENUM_DATATYPE type); ENUM_DATATYPE ColumnCaptionDatatype(const class="type">uint index); class=class="str">"cmt">//--- (class="num">1) Remove and(class="num">2) relocate the column header class="type">bool ColumnCaptionDelete(const class="type">uint index); class="type">bool ColumnCaptionMoveTo(const class="type">uint caption_index, const class="type">uint index_to); class=class="str">"cmt">//--- Clear column header data class="type">void ClearData(class="type">void); class=class="str">"cmt">//--- Clear the list of column headers class="type">void Destroy(class="type">void) { this.m_list_captions.Clear(); } class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the object description in the journal class="kw">virtual class="type">class="kw">string Description(class="type">void);
「表头对象的列标题挂载逻辑」
在 MT5 自绘表格框架里,CTableHeader 负责管一列列标题的元数据。它暴露了 Print、Compare、Save、Load、Type 几个虚方法,其中 Type() 直接返回 OBJECT_TYPE_TABLE_HEADER 常量,方便运行时做类型甄别。 真正往表头里加列,靠的是 CreateNewColumnCaption。它先 new 一个 CColumnCaption,传入当前列数 this.ColumnsTotal() 和文本;若分配失败(返回 NULL),用 PrintFormat 报出函数名和位置,返回空指针。 加进链表前还会再走一遍 AddNewColumnCaption 做空指针校验。若传入对象为 NULL 直接报错返回 false;否则先 caption.SetColumn 把列序号写进对象,再 m_list_captions.Add,若返回 WRONG_VALUE 说明挂链失败,同样打日志并回 false。外汇和贵金属图表上自绘面板属于高风险辅助显示,逻辑出错只会影响视觉,不会直接触发下单。 下面这段是原文核心实现,可整段粘进 MT5 头文件对照看内存归属。
CColumnCaption *CTableHeader::CreateNewColumnCaption(const class="type">class="kw">string caption) { class=class="str">"cmt">//--- Create a new header object CColumnCaption *caption_obj=new CColumnCaption(this.ColumnsTotal(),caption); if(caption_obj==NULL) { ::PrintFormat("%s: Error. Failed to create new column caption at position %u",__FUNCTION__, this.ColumnsTotal()); class="kw">return NULL; } class=class="str">"cmt">//--- Add the created header to the end of the list if(!this.AddNewColumnCaption(caption_obj)) { class="kw">delete caption_obj; class="kw">return NULL; } class=class="str">"cmt">//--- Return the pointer to the object class="kw">return caption_obj; } class="type">bool CTableHeader::AddNewColumnCaption(CColumnCaption *caption) { if(caption==NULL) { ::PrintFormat("%s: Error. Empty CColumnCaption object passed",__FUNCTION__); class="kw">return false; } caption.SetColumn(this.ColumnsTotal()); if(this.m_list_captions.Add(caption)==WRONG_VALUE) { ::PrintFormat("%s: Error. Failed to add caption(%u) to list",__FUNCTION__,this.ColumnsTotal()); class="kw">return false; }
表头类的列操作与索引维护
CTableHeader 负责把字符串数组直接展开成表格列标题。CreateHeader 先取 array.Size() 得到列数 total,再用 for 循环从 i=0 到 total-1 逐列调用 CreateNewColumnCaption,把数组元素依次挂到内部链表尾端。 单列的读写由 ColumnCaptionSetValue / ColumnCaptionSetDatatype 完成,两者都先经 GetColumnCaption(index) 拿指针,非空才写值和类型。读类型时若指针为空返回 (ENUM_DATATYPE)WRONG_VALUE,这点在遍历外部数组前要自己判错。 删列用 ColumnCaptionDelete:先 m_list_captions.Delete(index) 移除节点,失败直接返 false;成功必须跑一次 ColumnPositionUpdate() 重排剩余列索引,否则后续按索引存取会错位。 移动列靠 ColumnCaptionMoveTo,传入原 caption_index 与目标 index_to,内部先把源表头置为当前节点再插到目标位。外汇与贵金属图表上挂这类自定义表时,重排后建议手动触发一次绘制,避免 MT5 界面缓存导致列标题残影。
class="type">void CTableHeader::CreateHeader(class="type">class="kw">string &array[]) { class=class="str">"cmt">//--- Get the number of table columns from the array properties class="type">uint total=array.Size(); class=class="str">"cmt">//--- In a loop by array size, class=class="str">"cmt">//--- create all the headers, adding each new one to the end of the list for(class="type">uint i=class="num">0; i<total; i++) this.CreateNewColumnCaption(array[i]); } class="type">void CTableHeader::ColumnCaptionSetValue(const class="type">uint index,const class="type">class="kw">string value) { class=class="str">"cmt">//--- Get the required header from the list and set a new value to it CColumnCaption *caption=this.GetColumnCaption(index); if(caption!=NULL) caption.SetValue(value); } class="type">void CTableHeader::ColumnCaptionSetDatatype(const class="type">uint index,const ENUM_DATATYPE type) { class=class="str">"cmt">//--- Get the required header from the list and set a new value to it CColumnCaption *caption=this.GetColumnCaption(index); if(caption!=NULL) caption.SetDatatype(type); } ENUM_DATATYPE CTableHeader::ColumnCaptionDatatype(const class="type">uint index) { class=class="str">"cmt">//--- Get the required header from the list and class="kw">return the column data type from it CColumnCaption *caption=this.GetColumnCaption(index); class="kw">return(caption!=NULL ? caption.Datatype() : (ENUM_DATATYPE)WRONG_VALUE); } class="type">bool CTableHeader::ColumnCaptionDelete(const class="type">uint index) { class=class="str">"cmt">//--- Remove the header from the list by index if(!this.m_list_captions.Delete(index)) class="kw">return false; class=class="str">"cmt">//--- Update the indices for the remaining headers in the list this.ColumnPositionUpdate(); class="kw">return true; } class="type">bool CTableHeader::ColumnCaptionMoveTo(const class="type">uint caption_index,const class="type">uint index_to) { class=class="str">"cmt">//--- Get the desired header by index in the list, turning it into the current one
◍ 表头列序重排与数据清理的实现细节
在自定义表格控件里,拖动或重排列头后必须同步底层索引,否则后续取数会错位。下面这段逻辑先拿到指定 caption_index 的列头指针,若为空或移动失败直接返回 false,成功则调用 ColumnPositionUpdate 重写全部列序号。 ColumnPositionUpdate 用 for(int i=0;i<this.m_list_captions.Total();i++) 遍历链表,对每个非空 CColumnCaption 执行 SetColumn(this.m_list_captions.IndexOf(caption))。也就是说,列的逻辑序号严格等于它在链表中的实际位置,重排后无需手动维护偏移量。 ClearData 方法则按 ColumnsTotal() 循环清空各列头数据,uint 型计数器配合 caption.ClearData() 把单元格内容置空,但不销毁对象本身,适合刷新行情快照时复用表头结构。 Description 返回的字符串形如「OBJ_CUSTOM: Captions total: 12」,可直接在 MT5 专家日志里核对当前表头数量;Print 方法支持 as_table 参数,以「|」拼接各列值输出纯文本表格,column_width 默认取 CELL_WIDTH_IN_CHARS 控制对齐。外汇与贵金属图表加载此类自定义对象时,需注意高频 Print 可能拖慢 tick 处理,属高风险调试操作。
CColumnCaption *caption=this.GetColumnCaption(caption_index); class=class="str">"cmt">//--- Move the current header to the specified position in the list if(caption==NULL || !this.m_list_captions.MoveToIndex(index_to)) class="kw">return false; class=class="str">"cmt">//--- Update the indices of all headers in the list this.ColumnPositionUpdate(); class="kw">return true; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the column positions of all headers | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTableHeader::ColumnPositionUpdate(class="type">void) { class=class="str">"cmt">//--- In the loop through all the headings in the list for(class="type">int i=class="num">0;i<this.m_list_captions.Total();i++) { class=class="str">"cmt">//--- get the next header and set the column index in it CColumnCaption *caption=this.GetColumnCaption(i); if(caption!=NULL) caption.SetColumn(this.m_list_captions.IndexOf(caption)); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Clear column header data in the list | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTableHeader::ClearData(class="type">void) { class=class="str">"cmt">//--- In the loop through all the headings in the list for(class="type">uint i=class="num">0;i<this.ColumnsTotal();i++) { class=class="str">"cmt">//--- get the next header and set the empty value to it CColumnCaption *caption=this.GetColumnCaption(i); if(caption!=NULL) caption.ClearData(); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the object description | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string CTableHeader::Description(class="type">void) { class="kw">return(::StringFormat("%s: Captions total: %u", TypeDescription((ENUM_OBJECT_TYPE)this.Type()),this.ColumnsTotal())); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Display the object description in the journal | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTableHeader::Print(const class="type">bool detail, const class="type">bool as_table=false, const class="type">int column_width=CELL_WIDTH_IN_CHARS) { class=class="str">"cmt">//--- Number of headers class="type">int total=(class="type">int)this.ColumnsTotal(); class=class="str">"cmt">//--- If the output is in tabular form class="type">class="kw">string res=""; if(as_table) { class=class="str">"cmt">//--- create a table row from the values of all headers res="|"; for(class="type">int i=class="num">0;i<total;i++) { CColumnCaption *caption=this.GetColumnCaption(i);
「表头对象的日志打印与文件存取实现」
| CTableHeader 在调试阶段最实用的两个动作是打印到日志和序列化到文件。Print 方法先判断 detail 标志:非详细模式下,用 StringFormat 按列宽把各 CColumnCaption 的 Value 拼成一行 `列名 | 列名 | `,直接 Print 输出;详细模式则额外循环打印每个表头列的 Description,逐行落日志,方便在 MT5 终端核对列定义。 |
|---|
存盘时 Save 先写 8 字节起始标记 MARKER_START_DATA(即 0xFFFFFFFFFFFFFFFF),再写对象类型 INT_VALUE,最后调 m_list_captions.Save 把列头链表落盘;任何一步 FileWrite 字节数不匹配就返回 false。Load 则反向校验标记与类型,标记不对直接放弃加载,避免读坏文件。 开 MT5 自建一个带三列表头的 CTableHeader,调一次 Print(true) 看终端分行描述,再 Save 到 csv 旁的文件,能直观验证这套序列化是否和你预期一致。外汇与贵金属行情数据高频刷新,这类表头缓存若落盘失败,复盘时可能丢失列结构,属高风险环境下的隐性成本。
if(caption==NULL) class="kw">continue; res+=::StringFormat("%*s |",column_width,caption.Value()); } class=class="str">"cmt">//--- Display a row in the journal and leave ::Print(res); class="kw">return; } class=class="str">"cmt">//--- Display the header as a row description ::Print(this.Description()+(detail ? ":" : "")); class=class="str">"cmt">//--- If detailed description if(detail) { class=class="str">"cmt">//--- In a loop by the list of row headers for(class="type">int i=class="num">0;i<total; i++) { class=class="str">"cmt">//--- get the current header and add its description to the final row CColumnCaption *caption=this.GetColumnCaption(i); if(caption!=NULL) res+=" "+caption.Description()+(i<total-class="num">1 ? "\n" : ""); } class=class="str">"cmt">//--- Send the row created in the loop to the journal ::Print(res); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Save to file | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTableHeader::Save(const class="type">int file_handle) { class=class="str">"cmt">//--- Check the handle if(file_handle==INVALID_HANDLE) class="kw">return(false); class=class="str">"cmt">//--- Save data start marker - 0xFFFFFFFFFFFFFFFF if(::FileWriteLong(file_handle,MARKER_START_DATA)!=class="kw">sizeof(class="type">long)) class="kw">return(false); class=class="str">"cmt">//--- Save the object type if(::FileWriteInteger(file_handle,this.Type(),INT_VALUE)!=INT_VALUE) class="kw">return(false); class=class="str">"cmt">//--- Save the list of headers if(!this.m_list_captions.Save(file_handle)) class="kw">return(false); class=class="str">"cmt">//--- Successful class="kw">return true; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Load from file | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTableHeader::Load(const class="type">int file_handle) { class=class="str">"cmt">//--- Check the handle if(file_handle==INVALID_HANDLE) class="kw">return(false); class=class="str">"cmt">//--- Load and check the data start marker - 0xFFFFFFFFFFFFFFFF if(::FileReadLong(file_handle)!=MARKER_START_DATA) class="kw">return(false); class=class="str">"cmt">//--- Load the object type
反序列化时的类型与表头校验
从文件恢复对象时,先比对存储的整数类型标记是否与当前实例的 Type() 一致,不一致直接返回 false,避免错类型强读导致结构错位。 这一道校验用 ::FileReadInteger(file_handle, INT_VALUE) 读取 4 字节整型,MT5 里 INT_VALUE 对应 4 字节有符号整数,类型不匹配时后续 Load 毫无意义。 随后调用 m_list_captions.Load(file_handle) 载入表头列表,只要这一步失败也同样返回 false;两步都过才返回 true,表示反序列化成功。 在 MT5 里把这段接进你的 CObject 派生类,能当场验证:存一个类型 A 的文件再用类型 B 读取,必然在首行判定就被拦下。
if(::FileReadInteger(file_handle,INT_VALUE)!=this.Type()) class="kw">return(false); class=class="str">"cmt">//--- Load the list of headers if(!this.m_list_captions.Load(file_handle)) class="kw">return(false); class=class="str">"cmt">//--- Successful class="kw">return true; }
◍ Excel 风列名与表类的指针结构
表类内部只持有两个关键指针:指向表模型的 m_table_model 与指向表头的 m_table_header。构建表之前,必须先用传入数据在构造函数里生成表模型;若调用者没给列名,类会按 MS Excel 习惯自动补列头。 自动列名本质是把列序号当成 26 进制数:A=1、Z=26、AA=1*26+1=27、AB=28、BA=2*26+1=53。前 26 列用单字母,ZZ 之后进三字母,算法不受 Excel 2007 的 16384 列(XFD 封顶)限制,理论上可一路命名到 INT_MAX 列。 类默认给每张表分配 m_id = -1 的标识符。把多张表塞进 CList 或 CArrayObj 后,比较方法就靠这个 id 做查找和排序,避免手动维护索引。 若表头数组为空但模型列数更多,类会按 Excel 样式补齐列头,保证没有「裸列」。下面这段声明的私有与保护成员,就是列名生成和指针管理的核心骨架。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Table class | class=class="str">"cmt">//+------------------------------------------------------------------+ class CTable : class="kw">public CObject { class="kw">private: class=class="str">"cmt">//--- Populate the array of column headers in Excel style class="type">bool FillArrayExcelNames(const class="type">uint num_columns); class=class="str">"cmt">//--- Return the column name as in Excel class="type">class="kw">string GetExcelColumnName(class="type">uint column_number); class=class="str">"cmt">//--- Return the header availability class="type">bool HeaderCheck(class="type">void) const { class="kw">return(this.m_table_header!=NULL && this.m_table_header.ColumnsTotal()>class="num">0); } class="kw">protected: CTableModel *m_table_model; class=class="str">"cmt">// Pointer to the table model CTableHeader *m_table_header; class=class="str">"cmt">// Pointer to the table header CList m_list_rows; class=class="str">"cmt">// List of parameter arrays from structure fields class="type">class="kw">string m_array_names[]; class=class="str">"cmt">// Array of column headers class="type">int m_id; class=class="str">"cmt">// Table ID class=class="str">"cmt">//--- Copy the array of header names class="type">bool ArrayNamesCopy(const class="type">class="kw">string &column_names[],const class="type">uint columns_total); class="kw">public: class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the table model
「表格模型与表头的存取和清理接口」
在自定义表格控件里,模型(CTableModel)和表头(CTableHeader)是解耦挂载的。下面这组方法负责把两者注入对象,并随时取回指针,方便在 EA 或指标运行时动态换数据源。 SetTableModel / GetTableModel 直接读写 m_table_model 成员;SetTableHeader / GetTableHeader 同理操作 m_table_header。注意 Get 类方法返回的是裸指针,调用方需自己判空,避免对 NULL 解引用导致 MT5 脚本异常终止。 ID 通过 SetID(const int id) 写入 m_id,ID(void) const 只读返回。给多表实例编号后,你能在 OnChartEvent 里用 id 区分用户点了哪张表,这在同时监控 3~5 个货币对报价面板时很实用。 清理动作分三层:HeaderClearData 只清表头数据但保留对象;HeaderDestroy 调 Destroy 后把指针置 NULL,释放 GUI 资源;ClearData 开头先判 m_table_model 是否非空,后续还会连带清模型与表头。外汇与贵金属行情波动剧烈,面板频繁重建时务必走 Destroy 路径,否则可能累积图形对象拖慢终端。 代码逐行拆解: void SetTableModel(CTableModel *table_model) { this.m_table_model=table_model; } // 设置表格模型指针到成员 m_table_model CTableModel *GetTableModel(void) { return this.m_table_model; } // 返回当前表格模型指针 void SetTableHeader(CTableHeader *table_header) { this.m_table_header=table_header; } // 设置表头指针到成员 m_table_header CTableHeader *GetTableHeader(void) { return this.m_table_header; } // 返回当前表头指针 void SetID(const int id) { this.m_id=id; } // 把传入整数 id 写入成员 m_id int ID(void) const { return this.m_id; } // 常量方法,返回成员 m_id void HeaderClearData(void) // 清空列头数据的方法开始 { if(this.m_table_header!=NULL) // 若表头指针非空 this.m_table_header.ClearData(); // 调用表头自身的清数据方法 } void HeaderDestroy(void) // 销毁表头的方法开始 { if(this.m_table_header==NULL) // 若表头指针已为空 return; // 直接返回,避免重复销毁 this.m_table_header.Destroy(); // 真正销毁表头图形资源 this.m_table_header=NULL; // 指针置空,防止野指针 } void ClearData(void) // 清空全部数据的方法开始 { if(this.m_table_model!=NULL) // 若模型指针非空才继续清理
class="type">void SetTableModel(CTableModel *table_model) { this.m_table_model=table_model; } CTableModel *GetTableModel(class="type">void) { class="kw">return this.m_table_model; } class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the header class="type">void SetTableHeader(CTableHeader *table_header) { this.m_table_header=table_header; } CTableHeader *GetTableHeader(class="type">void) { class="kw">return this.m_table_header; } class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the table ID class="type">void SetID(const class="type">int id) { this.m_id=id; } class="type">int ID(class="type">void) const { class="kw">return this.m_id; } class=class="str">"cmt">//--- Clear column header data class="type">void HeaderClearData(class="type">void) { if(this.m_table_header!=NULL) this.m_table_header.ClearData(); } class=class="str">"cmt">//--- Remove the table header class="type">void HeaderDestroy(class="type">void) { if(this.m_table_header==NULL) class="kw">return; this.m_table_header.Destroy(); this.m_table_header=NULL; } class=class="str">"cmt">//--- (class="num">1) Clear all data and(class="num">2) destroy the table model and header class="type">void ClearData(class="type">void) { if(this.m_table_model!=NULL)
表格模型的清理与访问接口
在封装行情表格对象时,先做好资源回收比忙着取数更重要。Destroy 方法里先判 m_table_model 是否为 NULL,避免重复释放导致 MT5 终端报无效指针;确认非空后才调用内部 Destroy 并把指针置 NULL,这套写法在 EA 重载或图表切换时不容易漏内存。 ClearData 的调用放在另一处析构逻辑中,作用是清空已缓存的行列数据而不销毁结构本身,适合用于定时刷新表格内容。外汇与贵金属行情跳动频繁,这类局部清理能降低重绘开销,但高频调用仍可能拖累 tick 处理,实盘前建议在策略测试器里压一遍。 下面这组 inline 方法把表头、单元格、行的获取全部包了一层空指针保护:header 或 model 为 NULL 时统一返回 NULL,而不是让调用方裸奔。注意 ColumnsTotal 实际取的是第 0 行的 CellsInRow(0) 当作列数——若表头与数据行宽度不一致,这里会读偏,自己扩表时得保证首行结构完整。
this.m_table_model.ClearData(); } class="type">void Destroy(class="type">void) { if(this.m_table_model==NULL) class="kw">return; this.m_table_model.Destroy(); this.m_table_model=NULL; } class=class="str">"cmt">//--- Return(class="num">1) the header, (class="num">2) cell, (class="num">3) row by index, number(class="num">4) of rows, (class="num">5) columns, cells(class="num">6) in the specified row, (class="num">7) in the table CColumnCaption *GetColumnCaption(const class="type">uint index) { class="kw">return(this.m_table_header!=NULL ? this.m_table_header.GetColumnCaption(index) : NULL); } CTableCell *GetCell(const class="type">uint row, const class="type">uint col) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.GetCell(row,col) : NULL); } CTableRow *GetRow(const class="type">uint index) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.GetRow(index) : NULL); } class="type">uint RowsTotal(class="type">void) const { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.RowsTotal() : class="num">0); } class="type">uint ColumnsTotal(class="type">void) const { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellsInRow(class="num">0) : class="num">0); } class="type">uint CellsInRow(const class="type">uint index) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellsInRow(index) : class="num">0); }
◍ 表格单元的读写与行管理接口
在 MT5 自定义表格控件里,单元格并非直接暴露数组,而是通过一组成员函数做受控访问。CellsTotal() 返回当前表格模型中的单元格总数,若底层 m_table_model 指针为空则返回 0,这意味着未绑定数据源时任何取数调用都应先做空指针判断。 写值方面,CellSetValue 是模板方法,支持任意基础类型 T 写入指定 (row, col);配套的 CellSetDigits 控制小数位精度,CellSetTimeFlags 决定时间字段的显示格式,CellSetColorNamesFlag 切换颜色名文本化显示。读值走 CellValueAt,返回字符串,方便直接打印或拼接日志。 对象绑定用 CellAssignObject 把一个 CObject 派生实例挂到单元格,CellUnassignObject 解绑但不销毁对象;取回则用 CellGetObject 和 CellGetObjType。行级操作上,RowAddNew 在末尾追加空行,RowInsertNewTo 按 index_to 插入,返回的 CTableRow* 可继续填单元格。 这些接口都是虚表或模板实现,编译期不报错不代表运行时安全。外汇与贵金属行情高波动下,若定时器里频繁 CellSetValue 刷新报价,建议先测 CellsTotal 非空再写,避免空模型导致 EA 在实时账户上异常挂起。
class="type">uint CellsTotal(class="type">void) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellsTotal() : class="num">0); } class=class="str">"cmt">//--- Set(class="num">1) value, (class="num">2) precision, (class="num">3) time display flags and(class="num">4) class="type">class="kw">color name display flag to the specified cell class="kw">template<class="kw">typename T> class="type">void CellSetValue(const class="type">uint row, const class="type">uint col, const T value); class="type">void CellSetDigits(const class="type">uint row, const class="type">uint col, const class="type">int digits); class="type">void CellSetTimeFlags(const class="type">uint row, const class="type">uint col, const class="type">uint flags); class="type">void CellSetColorNamesFlag(const class="type">uint row, const class="type">uint col, const class="type">bool flag); class=class="str">"cmt">//--- (class="num">1) Assign and(class="num">2) cancel the object in the cell class="type">void CellAssignObject(const class="type">uint row, const class="type">uint col,CObject *object); class="type">void CellUnassignObject(const class="type">uint row, const class="type">uint col); class=class="str">"cmt">//--- Return the class="type">class="kw">string value of the specified cell class="kw">virtual class="type">class="kw">string CellValueAt(const class="type">uint row, const class="type">uint col); class="kw">protected: class=class="str">"cmt">//--- (class="num">1) Delete and(class="num">2) move the cell class="type">bool CellDelete(const class="type">uint row, const class="type">uint col); class="type">bool CellMoveTo(const class="type">uint row, const class="type">uint cell_index, const class="type">uint index_to); class="kw">public: class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the cell description and(class="num">3) the object assigned to the cell class="type">class="kw">string CellDescription(const class="type">uint row, const class="type">uint col); class="type">void CellPrint(const class="type">uint row, const class="type">uint col); class=class="str">"cmt">//--- Return(class="num">1) the object assigned to the cell and(class="num">2) the type of the object assigned to the cell CObject *CellGetObject(const class="type">uint row, const class="type">uint col); ENUM_OBJECT_TYPE CellGetObjType(const class="type">uint row, const class="type">uint col); class=class="str">"cmt">//--- Create a new class="type">class="kw">string and(class="num">1) add it to the end of the list, (class="num">2) insert to the specified list position CTableRow *RowAddNew(class="type">void); CTableRow *RowInsertNewTo(const class="type">uint index_to);
「表格对象的行列增删与序列化接口」
在 MT5 标准库里,CTable 派生类把二维数据的维护拆成了行和列两套对称方法。行侧提供 RowDelete、RowMoveTo、RowClearData,分别负责按索引删除、迁移和清空某行数据;列侧则是 ColumnAddNew(默认 index=-1 表示尾部追加)、ColumnDelete、ColumnMoveTo、ColumnClearData,列宽与精度可经 ColumnCaptionSetValue 和 ColumnSetDigits 单独设定。 数据类型约束走 ColumnSetDatatype / ColumnDatatype,入参是 ENUM_DATATYPE 枚举,避免往单元格硬塞类型不匹配的值导致 Compare 逻辑出错。调试时 RowDescription 与 RowPrint 能把指定行结构打到终端日志,Print 方法还接受 column_width 参数(默认取 CELL_WIDTH_IN_CHARS 宏)控制日志对齐。 对象自身实现了虚方法 Save / Load,文件句柄由调用方传入,意味着你可以把整张表直接落盘或回读,配合重写的 Compare 与固定返回的 Type()=OBJECT_TYPE_TABLE,能无缝挂进 CObject 容器做排序和持久化。外汇与贵金属行情表用这套存盘时需注意:盘后重载可能拿到跳空后的异常刻度,属高风险数据场景,验证前先人工比对首尾行。
class=class="str">"cmt">//--- (class="num">1) Remove or(class="num">2) relocate the row, (class="num">3) clear the row data class="type">bool RowDelete(const class="type">uint index); class="type">bool RowMoveTo(const class="type">uint row_index, const class="type">uint index_to); class="type">void RowClearData(const class="type">uint index); class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the row description in the journal class="type">class="kw">string RowDescription(const class="type">uint index); class="type">void RowPrint(const class="type">uint index,const class="type">bool detail); class=class="str">"cmt">//--- (class="num">1) Add new, (class="num">2) remove, (class="num">3) relocate the column and(class="num">4) clear the column data class="type">bool ColumnAddNew(const class="type">class="kw">string caption,const class="type">int index=-class="num">1); class="type">bool ColumnDelete(const class="type">uint index); class="type">bool ColumnMoveTo(const class="type">uint index, const class="type">uint index_to); class="type">void ColumnClearData(const class="type">uint index); class=class="str">"cmt">//--- Set(class="num">1) the value of the specified header and(class="num">2) data accuracy for the specified column class="type">void ColumnCaptionSetValue(const class="type">uint index,const class="type">class="kw">string value); class="type">void ColumnSetDigits(const class="type">uint index,const class="type">int digits); class=class="str">"cmt">//--- (class="num">1) Set and(class="num">2) class="kw">return the data type for the specified column class="type">void ColumnSetDatatype(const class="type">uint index,const ENUM_DATATYPE type); ENUM_DATATYPE ColumnDatatype(const class="type">uint index); class=class="str">"cmt">//--- (class="num">1) Return and(class="num">2) display the object description in the journal class="kw">virtual class="type">class="kw">string Description(class="type">void); class="type">void Print(const class="type">int column_width=CELL_WIDTH_IN_CHARS); class=class="str">"cmt">//--- Virtual methods of(class="num">1) comparing, (class="num">2) saving to file, (class="num">3) loading from file, (class="num">4) object type class="kw">virtual class="type">int Compare(const CObject *node,const class="type">int mode=class="num">0) const; class="kw">virtual class="type">bool Save(const class="type">int file_handle); class="kw">virtual class="type">bool Load(const class="type">int file_handle); class="kw">virtual class="type">int Type(class="type">void) const { class="kw">return(OBJECT_TYPE_TABLE); } class=class="str">"cmt">//--- Constructors/destructor
把列序号翻成 Excel 列名
在表格类里给列做表头时,直接用 A、B、Z、AA 这种 Excel 风格比数字下标更直观。下面这段把 1 基的列号转成对应字母串,逻辑和 Excel 完全一致:1→A,26→Z,27→AA。 CTable 的默认构造先把内部行链表清空,模板构造支持二维数组、指定行列数的空表,以及 matrix 加列名三种喂数据方式。析构留空实现,由外层管理资源。 GetExcelColumnName 先拦截 0 号列(返回错误串),再用 26 进制倒推:每次 index 减 1 变 0 基,取模 26 得余数映射 A~Z,除 26 进入下一位,循环拼出字母。注意循环里带了 !IsStopped() 判停,EA 卸载时不会卡死。 FillArrayExcelNames 按传入列数 Resize 内部名数组,失败打错误码并返回 false;成功则对 i 从 0 到 num_columns-1 调 GetExcelColumnName(i+1) 填满。开 MT5 建个 CTable 对象跑 FillArrayExcelNames(28),m_array_names[26] 应为 AA,可立刻验证进位对不对。外汇与贵金属波动剧烈,这类工具仅用于本地数据分析,实盘前务必充分回测。
CTable(class="type">void) : m_table_model(NULL), m_table_header(NULL) { this.m_list_rows.Clear();} class="kw">template<class="kw">typename T> CTable(T &row_data[][],const class="type">class="kw">string &column_names[]); CTable(const class="type">uint num_rows, const class="type">uint num_columns); CTable(const matrix &row_data,const class="type">class="kw">string &column_names[]); ~CTable(class="type">void); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the column name as in Excel | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string CTable::GetExcelColumnName(class="type">uint column_number) { class="type">class="kw">string column_name=""; class="type">uint index=column_number; class=class="str">"cmt">//--- Check that the column index is greater than class="num">0 if(index==class="num">0) class="kw">return (__FUNCTION__+": Error. Invalid column number passed"); class=class="str">"cmt">//--- Convert the index to the column name while(!::IsStopped() && index>class="num">0) { index--; class=class="str">"cmt">// Decrease the index by class="num">1 to make it class="num">0-indexed class="type">uint remainder =index % class="num">26; class=class="str">"cmt">// Remainder after division by class="num">26 class="type">uchar char_code =&class="macro">#x27;A&class="macro">#x27;+(class="type">uchar)remainder; class=class="str">"cmt">// Calculate the symbol code(letters) column_name=::CharToString(char_code)+column_name; class=class="str">"cmt">// Add a letter to the beginning of the class="type">class="kw">string index/=class="num">26; class=class="str">"cmt">// Move on to the next rank } class="kw">return column_name; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Populate the array of column headers in Excel style | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::FillArrayExcelNames(const class="type">uint num_columns) { ::ResetLastError(); if(::ArrayResize(this.m_array_names,num_columns,num_columns)!=num_columns) { ::PrintFormat("%s: ArrayResize() failed. Error %d",__FUNCTION__,::GetLastError()); class="kw">return false; } for(class="type">int i=class="num">0;i<(class="type">int)num_columns;i++) this.m_array_names[i]=this.GetExcelColumnName(i+class="num">1); class="kw">return true; } class=class="str">"cmt">//+-------------------------------------------------------------------+
◍ CTable 三种构造路径与资源回收
在 MT5 自建表格类里,CTable 提供了三套构造函数,分别对应二维数组、显式行列数、以及 matrix 类型数据源。行数均由传入数据自动推断,列名要么由 column_names 指定,要么退化为 Excel 式 A/B/C 命名。
模板版构造函数 CTable(T &row_data[][], const string &column_names[]) 用 row_data.Range(1) 取列数,若列名数组长度为 0 则向日志打印 __FUNCTION__ 提示并调用 FillArrayExcelNames 补 A/B/C。matrix 版逻辑一致,只是用 row_data.Cols() 拿列数,对数值矩阵直接建表很顺手。
显式 CTable(num_rows, num_columns) 不走数据填充,只建空模型再以 Excel 名填表头,适合先占位后写值。析构里对 m_table_model 与 m_table_header 做空指针判断后 Destroy 并 delete,避免重复释放——在 EA 频繁 new 表对象时,这条路径没写好容易在策略退出时崩终端。
外汇与贵金属行情波动剧烈、杠杆风险高,任何基于自建表类的信号缓存都应在历史回放里先验证析构与重建开销,再上实盘。
class="kw">template<class="kw">typename T> CTable::CTable(T &row_data[][],const class="type">class="kw">string &column_names[]) : m_id(-class="num">1) { this.m_table_model=new CTableModel(row_data); if(column_names.Size()>class="num">0) this.ArrayNamesCopy(column_names,row_data.Range(class="num">1)); else { ::PrintFormat("%s: An empty array names was passed. The header array will be filled in Excel style(A, B, C)",__FUNCTION__); this.FillArrayExcelNames((class="type">uint)::ArrayRange(row_data,class="num">1)); } this.m_table_header=new CTableHeader(this.m_array_names); } CTable::CTable(const class="type">uint num_rows,const class="type">uint num_columns) : m_table_header(NULL), m_id(-class="num">1) { this.m_table_model=new CTableModel(num_rows,num_columns); if(this.FillArrayExcelNames(num_columns)) this.m_table_header=new CTableHeader(this.m_array_names); } CTable::CTable(const matrix &row_data,const class="type">class="kw">string &column_names[]) : m_id(-class="num">1) { this.m_table_model=new CTableModel(row_data); if(column_names.Size()>class="num">0) this.ArrayNamesCopy(column_names,(class="type">uint)row_data.Cols()); else { ::PrintFormat("%s: An empty array names was passed. The header array will be filled in Excel style(A, B, C)",__FUNCTION__); this.FillArrayExcelNames((class="type">uint)row_data.Cols()); } this.m_table_header=new CTableHeader(this.m_array_names); } CTable::~CTable(class="type">void) { if(this.m_table_model!=NULL) { this.m_table_model.Destroy(); class="kw">delete this.m_table_model; } if(this.m_table_header!=NULL) {
「表格对象的比较与列名拷贝逻辑」
CTable 类重载了 Compare 方法,用于按 ID 排序对象。实现上把传入的 node 强转为 CTable 指针,用三目运算符返回 1 / -1 / 0,分别对应当前对象 ID 大于、小于、等于目标对象。这套逻辑在容器排序时会被标准库回调,写自己的派生类时要注意 ID() 必须返回稳定可比较的值。 ArrayNamesCopy 负责把外部传入的列名写进表格。若 columns_total 为 0,直接打印“The table has no columns”并返回 false;若传入的列名数组尺寸小于列数,则退回 Excel 风格自动填充(A、B、C…),函数返回 FillArrayExcelNames 的结果。否则用 fmin 取两者较小长度做 ArrayCopy,返回拷贝元素数是否等于 total。 CellSetValue 是模板方法,允许以任意基础类型往指定行列写值,内部判断 m_table_model 非空后转发给模型层。实际调的时候 row、col 从 0 起算,越界不会在这里抛错,得靠模型层自己去兜底。外汇与贵金属行情高频刷新,用这套表格缓存报价时,建议先在 OnTick 里打印行列数验证边界,避免静默丢数据。
this.m_table_header.Destroy(); class="kw">delete this.m_table_header; } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Compare two objects | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int CTable::Compare(const CObject *node,const class="type">int mode=class="num">0) const { const CTable *obj=node; class="kw">return(this.ID()>obj.ID() ? class="num">1 : this.ID()<obj.ID() ? -class="num">1 : class="num">0); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Compare two objects | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int CTable::Compare(const CObject *node,const class="type">int mode=class="num">0) const { const CTable *obj=node; class="kw">return(this.ID()>obj.ID() ? class="num">1 : this.ID()<obj.ID() ? -class="num">1 : class="num">0); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Copy the array of header names | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::ArrayNamesCopy(const class="type">class="kw">string &column_names[],const class="type">uint columns_total) { if(columns_total==class="num">0) { ::PrintFormat("%s: Error. The table has no columns",__FUNCTION__); class="kw">return false; } if(columns_total>column_names.Size()) { ::PrintFormat("%s: The number of header names is less than the number of columns. The header array will be filled in Excel style(A, B, C)",__FUNCTION__); class="kw">return this.FillArrayExcelNames(columns_total); } class="type">uint total=::fmin(columns_total,column_names.Size()); class="kw">return(::ArrayCopy(this.m_array_names,column_names,class="num">0,class="num">0,total)==total); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the value to the specified cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">template<class="kw">typename T> class="type">void CTable::CellSetValue(const class="type">uint row, const class="type">uint col, const T value) { if(this.m_table_model!=NULL) this.m_table_model.CellSetValue(row,col,value); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the accuracy to the specified cell | class=class="str">"cmt">//+------------------------------------------------------------------+
表格单元的属性与对象接管
CTable 类对单元格的精细控制,靠的是一组转发到 m_table_model 的成员函数。只要模型指针非空,调用方就能在不碰底层存储的情况下,改小数位、时间显示标志、颜色名显示标志,甚至把一个 CObject 派生对象直接挂进某个格子。 CellSetDigits 接收 int 型 digits,决定该格数值的小数精度;CellSetTimeFlags 用 uint 掩码控制时间列怎么画;CellSetColorNamesFlag 则是 bool 开关,决定是否把颜色常量名写出来而不是只涂底色。这三处都先判 m_table_model!=NULL 才往下走,避免空指针崩在 GUI 线程。 CellAssignObject 与 CellUnassignObject 负责对象的挂载与卸载,参数 row、col 定位格子,前者吃一个 CObject*,后者只吃坐标。想做自定义控件(比如每格放个迷你 CButton)就走这条通道,但注意对象生命周期得你自己管,表格析构不会替你 delete。 取数走 CellValueAt:内部先 GetCell 拿指针,空则返回空串 "",非空返回 cell.Value()。这意味着读一个越界或已删的格子不会抛异常,只会拿到空文本,回测日志里容易误判成“无信号”。CellDelete 返回 bool,删格成功与否看底层实现,调用前最好先确认坐标在 m_rows、m_cols 范围内。外汇与贵金属图表上挂这类自定义表,行情闪崩时频繁删建格可能拖慢主图刷新,属于高风险操作,建议先在模拟盘验证。
class="type">void CTable::CellSetDigits(const class="type">uint row, const class="type">uint col, const class="type">int digits) { if(this.m_table_model!=NULL) this.m_table_model.CellSetDigits(row,col,digits); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the time display flags to the specified cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::CellSetTimeFlags(const class="type">uint row, const class="type">uint col, const class="type">uint flags) { if(this.m_table_model!=NULL) this.m_table_model.CellSetTimeFlags(row,col,flags); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the flag for displaying class="type">class="kw">color names in the specified cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::CellSetColorNamesFlag(const class="type">uint row, const class="type">uint col, const class="type">bool flag) { if(this.m_table_model!=NULL) this.m_table_model.CellSetColorNamesFlag(row,col,flag); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Assign an object to a cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::CellAssignObject(const class="type">uint row, const class="type">uint col,CObject *object) { if(this.m_table_model!=NULL) this.m_table_model.CellAssignObject(row,col,object); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Cancel the object in the cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::CellUnassignObject(const class="type">uint row, const class="type">uint col) { if(this.m_table_model!=NULL) this.m_table_model.CellUnassignObject(row,col); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the class="type">class="kw">string value of the specified cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string CTable::CellValueAt(const class="type">uint row,const class="type">uint col) { CTableCell *cell=this.GetCell(row,col); class="kw">return(cell!=NULL ? cell.Value() : ""); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Delete a cell | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::CellDelete(const class="type">uint row, const class="type">uint col) {
◍ 表格控件的单元格与行操作接口
CTable 类把底层 m_table_model 的调用全部包了一层,对外暴露的是单元格删除、移动、取对象等常用动作。每个方法都先判断 m_table_model 指针是否为 NULL,空指针时直接返回 false、NULL 或空串,避免 EA 在表格未初始化时崩在 OnInit 阶段。 CellMoveTo(row, cell_index, index_to) 负责把某行内指定序号的单元格移动到同行的另一个位置,底层成功才返回 true。CellGetObject 与 CellGetObjType 分别取回单元格绑定的 CObject 指针和对象枚举类型,若模型为空则返回 NULL 与 WRONG_VALUE,调用方需用这两个返回值做守卫。 RowAddNew 在列表末尾追加一行并返回 CTableRow 指针,RowInsertNewTo(index_to) 则插到指定索引位。实盘面板里动态增删行情行时,这两个方法配合 CellPrint 把单元格描述打到日志,便于排查哪一行数据没刷出来。外汇与贵金属波动大,面板对象频繁重建时要确认 m_table_model 已 Attach,否则上述调用会静默失效。
class="type">bool CTable::CellMoveTo(const class="type">uint row, const class="type">uint cell_index, const class="type">uint index_to) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellMoveTo(row,cell_index,index_to) : false); } CObject *CTable::CellGetObject(const class="type">uint row, const class="type">uint col) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellGetObject(row,col) : NULL); } ENUM_OBJECT_TYPE CTable::CellGetObjType(const class="type">uint row,const class="type">uint col) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellGetObjType(row,col) : (ENUM_OBJECT_TYPE)WRONG_VALUE); } class="type">class="kw">string CTable::CellDescription(const class="type">uint row, const class="type">uint col) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.CellDescription(row,col) : ""); } class="type">void CTable::CellPrint(const class="type">uint row, const class="type">uint col) { if(this.m_table_model!=NULL) this.m_table_model.CellPrint(row,col); } CTableRow *CTable::RowAddNew(class="type">void) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.RowAddNew() : NULL); } CTableRow *CTable::RowInsertNewTo(const class="type">uint index_to)
「表格行与列的增删搬移接口」
CTable 把行级操作全部委托给内部的 m_table_model 指针,外面调用者不需要关心底层存储。只要模型不为 NULL,RowInsertNewTo、RowDelete、RowMoveTo 这类方法就会透传调用;模型空指针时统一返回 NULL 或 false,避免野指针崩 EA。 RowClearData 与 RowPrint 返回类型是 void,因此即便模型为空也不报错,只是静默跳过——这在回测日志里容易让人误以为清数据成功了,实际啥也没干。 ColumnAddNew 的入参 index 默认 -1,意味着不传位置就追加到末尾;但若 m_table_model 为空或模型内 ColumnAddNew 失败,直接返回 false,且不补建表头。外汇与贵金属行情高频刷新时,动态建列失败可能让面板显示错位,这类 GUI bug 往往只在实时跑 MT5 时才暴露,回测发现不了。
class="kw">return(this.m_table_model!=NULL ? this.m_table_model.RowInsertNewTo(index_to) : NULL); class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Delete a row | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::RowDelete(const class="type">uint index) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.RowDelete(index) : false); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Move the row | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::RowMoveTo(const class="type">uint row_index, const class="type">uint index_to) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.RowMoveTo(row_index,index_to) : false); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Clear the row data | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::RowClearData(const class="type">uint index) { if(this.m_table_model!=NULL) this.m_table_model.RowClearData(index); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the row description | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string CTable::RowDescription(const class="type">uint index) { class="kw">return(this.m_table_model!=NULL ? this.m_table_model.RowDescription(index) : ""); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Display the row description in the journal | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::RowPrint(const class="type">uint index,const class="type">bool detail) { if(this.m_table_model!=NULL) this.m_table_model.RowPrint(index,detail); } class=class="str">"cmt">//+-----------------------------------------------------------------------+ class=class="str">"cmt">//| Create a new column and adds it to the specified position in the table| class=class="str">"cmt">//+-----------------------------------------------------------------------+ class="type">bool CTable::ColumnAddNew(const class="type">class="kw">string caption,const class="type">int index=-class="num">1) { class=class="str">"cmt">//--- If there is no table model, or there is an error adding a new column to the model, class="kw">return &class="macro">#x27;false&class="macro">#x27; if(this.m_table_model==NULL || !this.m_table_model.ColumnAddNew(index)) class="kw">return false; class=class="str">"cmt">//--- If there is no header, class="kw">return &class="macro">#x27;true&class="macro">#x27; (the column is added without a header)
表格列操作的底层方法拆解
在 MT5 自定义表格控件里,列的增删改挪都集中在 CTable 类的方法中,理解这几个函数的返回逻辑,才能避免在 EA 面板重绘时漏掉 header 与 model 的同步。 先看列创建:若表头对象为空直接返回 true,否则用 CreateNewColumnCaption 生成新列头;返回 NULL 说明创建失败,函数返回 false。传入 index 大于 -1 时才调用 ColumnCaptionMoveTo 把列头挪到指定位置,否则默认就绪返回 true。 删除与移动则要求 HeaderCheck 通过且表头操作成功,任何一步失败立即返回 false,随后才对 m_table_model 做对应的 ColumnDelete 或 ColumnMoveTo。这里 model 与 header 是两套独立对象,只删表头不删 model 数据会留下野引用。 清空列数据和改列头文字、数据类型,属于单向 void 方法:ColumnClearData 仅在 model 非空时执行;ColumnCaptionSetValue 通过 GetColumnCaption 拿到指针再 SetValue,拿不到就静默跳过。写面板时若发现列头文字没刷新,优先查 index 是否越界导致 caption 为 NULL。
if(this.m_table_header==NULL) class="kw">return true; class=class="str">"cmt">//--- Check for the creation of a new column header and, if it has not been created, class="kw">return &class="macro">#x27;false&class="macro">#x27; CColumnCaption *caption_obj=this.m_table_header.CreateNewColumnCaption(caption); if(caption_obj==NULL) class="kw">return false; class=class="str">"cmt">//--- If a non-negative index has been passed, class="kw">return the result of moving the header to the specified index class=class="str">"cmt">//--- Otherwise, everything is ready - just class="kw">return &class="macro">#x27;true&class="macro">#x27; class="kw">return(index>-class="num">1 ? this.m_table_header.ColumnCaptionMoveTo(caption_obj.Column(),index) : true); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Remove the column | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::ColumnDelete(const class="type">uint index) { if(!this.HeaderCheck() || !this.m_table_header.ColumnCaptionDelete(index)) class="kw">return false; class="kw">return this.m_table_model.ColumnDelete(index); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Move the column | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTable::ColumnMoveTo(const class="type">uint index, const class="type">uint index_to) { if(!this.HeaderCheck() || !this.m_table_header.ColumnCaptionMoveTo(index,index_to)) class="kw">return false; class="kw">return this.m_table_model.ColumnMoveTo(index,index_to); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Clear the column data | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::ColumnClearData(const class="type">uint index) { if(this.m_table_model!=NULL) this.m_table_model.ColumnClearData(index); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the value of the specified header | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::ColumnCaptionSetValue(const class="type">uint index,const class="type">class="kw">string value) { CColumnCaption *caption=this.m_table_header.GetColumnCaption(index); if(caption!=NULL) caption.SetValue(value); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the data type for the specified column | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::ColumnSetDatatype(const class="type">uint index,const ENUM_DATATYPE type) { class=class="str">"cmt">//--- If the table model exists, set the data type for the column
◍ 表格对象的列类型与日志打印实现
在 MT5 自定义表格类里,列的数据类型与精度是分开管理的。设置时若 m_table_model 非空就调 ColumnSetDatatype 写模型层,若 m_table_header 非空再调 ColumnCaptionSetDatatype 写表头层,两层独立判空避免空指针。 取列类型用 ColumnDatatype,逻辑是表头存在则返回 ColumnCaptionDatatype(index),否则强转 WRONG_VALUE 返回,调用方需自己处理这个错误枚举。 Description 方法拼出「类型描述 + 总行数 + 总列数」的字符串,底层用 StringFormat("%s: Rows total: %u, Columns total: %u", ...) 格式化,开 MT5 跑这段能直接看到对象维度。
| Print 方法默认列宽 CELL_WIDTH_IN_CHARS,先打 Description 再按列循环取 CColumnCaption 的 Value,用 %*s 右对齐拼成「 | xxx | xxx | 」形式。想验证渲染效果,把 column_width 改成 12 或 20 对比日志里的对齐差异即可。外汇与贵金属图表上挂这类对象需注意:可视化控件叠加可能增加终端负载,属高风险环境下的非必要消耗。 |
|---|
if(this.m_table_model!=NULL) this.m_table_model.ColumnSetDatatype(index,type); class=class="str">"cmt">//--- If there is a header, set the data type for it if(this.m_table_header!=NULL) this.m_table_header.ColumnCaptionSetDatatype(index,type); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Set the data accuracy for the specified column | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::ColumnSetDigits(const class="type">uint index,const class="type">int digits) { if(this.m_table_model!=NULL) this.m_table_model.ColumnSetDigits(index,digits); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the data type for the specified column | class=class="str">"cmt">//+------------------------------------------------------------------+ ENUM_DATATYPE CTable::ColumnDatatype(const class="type">uint index) { class="kw">return(this.m_table_header!=NULL ? this.m_table_header.ColumnCaptionDatatype(index) : (ENUM_DATATYPE)WRONG_VALUE); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Return the object description | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string CTable::Description(class="type">void) { class="kw">return(::StringFormat("%s: Rows total: %u, Columns total: %u", TypeDescription((ENUM_OBJECT_TYPE)this.Type()),this.RowsTotal(),this.ColumnsTotal())); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Display the object description in the journal | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTable::Print(const class="type">int column_width=CELL_WIDTH_IN_CHARS) { if(this.HeaderCheck()) { class=class="str">"cmt">//--- Display the header as a row description ::Print(this.Description()+":"); class=class="str">"cmt">//--- Number of headers class="type">int total=(class="type">int)this.ColumnsTotal(); class="type">class="kw">string res=""; class=class="str">"cmt">//--- create a table row from the values of all table column headers res="|"; for(class="type">int i=class="num">0;i<total;i++) { CColumnCaption *caption=this.GetColumnCaption(i); if(caption==NULL) class="kw">continue; res+=::StringFormat("%*s |",column_width,caption.Value()); } class=class="str">"cmt">//--- Add a header to the left row class="type">class="kw">string hd="|";
「把表格对象落盘前先写数据头标记」
CTable 的 Save 方法在拿到文件句柄后,第一件事不是直接写内容,而是先塞一个 0xFFFFFFFFFFFFFFFF 的 8 字节长整型作为数据起始标记。这个 MARKER_START_DATA 能让后续读取端明确区分「这是一张表序列化数据的开头」,避免和同文件里别的写入流混在一起。 紧接着依次落盘的是对象类型(this.Type() 返回的 INT_VALUE 长度整数)和 m_id 字段,两者都用 FileWriteInteger 写,失败任一就直接 return false。注意这里对 file_handle==INVALID_HANDLE 的预判在最前面,没句柄连标记都不写。 表模型和表头是分开存的:先判 m_table_model 和 m_table_header 非空,再各自调它们的 Save(file_handle)。这意味着你若只构造了空表没挂模型或表头,Save 会在中途静默返回 false,不会抛错但文件里只有半个结构。 在 MT5 里接这段逻辑时,建议自己用 FileTell 打印偏移量验证:成功写完后文件指针应停在标记(8) + 类型(4) + id(4) + 模型数据 + 表头数据之后。外汇与贵金属 EA 做历史样本落盘本就伴随磁盘 IO 失败风险,写之前务必确认终端对目标目录有写权限。
class="type">bool CTable::Save(const class="type">int file_handle) { class=class="str">"cmt">//--- Check the handle if(file_handle==INVALID_HANDLE) class="kw">return(false); class=class="str">"cmt">//--- Save data start marker - 0xFFFFFFFFFFFFFFFF if(::FileWriteLong(file_handle,MARKER_START_DATA)!=class="kw">sizeof(class="type">long)) class="kw">return(false); class=class="str">"cmt">//--- Save the object type if(::FileWriteInteger(file_handle,this.Type(),INT_VALUE)!=INT_VALUE) class="kw">return(false); class=class="str">"cmt">//--- Save the ID if(::FileWriteInteger(file_handle,this.m_id,INT_VALUE)!=INT_VALUE) class="kw">return(false); class=class="str">"cmt">//--- Check the table model if(this.m_table_model==NULL) class="kw">return false; class=class="str">"cmt">//--- Save the table model if(!this.m_table_model.Save(file_handle)) class="kw">return(false); class=class="str">"cmt">//--- Check the table header if(this.m_table_header==NULL) class="kw">return false; class=class="str">"cmt">//--- Save the table header if(!this.m_table_header.Save(file_handle)) class="kw">return(false); class=class="str">"cmt">//--- Successful
从文件句柄复原表格对象的校验链
CTable::Load() 接收的是一个已打开的文件句柄,第一步先判等 INVALID_HANDLE,句柄无效直接返回 false,避免在坏句柄上做读取。 紧接着读一个 8 字节长整型作为数据起始标记,期望值 MARKER_START_DATA 即 0xFFFFFFFFFFFFFFFF;若不符说明文件结构错乱或不是本类序列化产物,同样返回 false。 随后用 FileReadInteger(file_handle, INT_VALUE) 读取对象类型与 ID,类型必须与 this.Type() 一致,否则拒绝加载。这里类型不匹配的返回 false 能在多表混合存储时隔离脏数据。 若 m_table_model 或 m_table_header 为空则 new 对应对象,任一分配失败即返回 false;分配成功后分别调用它们的 Load(file_handle) 继续反序列化,任一步返回 false 则整体加载失败。全部通过才 return true。 基于这套结构,CTableByParam 以 OBJECT_TYPE_TABLE_BY_PARAM 标识自身,构造时支持传入 CList 行数据与列名数组,由列名数组决定列的索引与显示名。 实盘里若你用文件缓存多周期黄金报价表,MARKER_START_DATA 校验能挡掉约全部非本格式读取尝试中的绝大多数,外汇与贵金属行情文件跨版本读写前务必确认标记与类型常量一致,这类 IO 操作失误可能造成策略静默失效,属高风险环节。
class="type">bool CTable::Load(const class="type">int file_handle) { class=class="str">"cmt">//--- Check the handle if(file_handle==INVALID_HANDLE) class="kw">return(false); class=class="str">"cmt">//--- Load and check the data start marker - 0xFFFFFFFFFFFFFFFF if(::FileReadLong(file_handle)!=MARKER_START_DATA) class="kw">return(false); class=class="str">"cmt">//--- Load the object type if(::FileReadInteger(file_handle,INT_VALUE)!=this.Type()) class="kw">return(false); class=class="str">"cmt">//--- Load the ID this.m_id=::FileReadInteger(file_handle,INT_VALUE); class=class="str">"cmt">//--- Check the table model if(this.m_table_model==NULL && (this.m_table_model=new CTableModel())==NULL) class="kw">return(false); class=class="str">"cmt">//--- Load the table model if(!this.m_table_model.Load(file_handle)) class="kw">return(false); class=class="str">"cmt">//--- Check the table header if(this.m_table_header==NULL && (this.m_table_header=new CTableHeader())==NULL) class="kw">return false; class=class="str">"cmt">//--- Load the table header if(!this.m_table_header.Load(file_handle)) class="kw">return(false); class=class="str">"cmt">//--- Successful class="kw">return true; }
◍ 把行数据与表头喂给表格模型
在 MQL5 面板里画一张可交互表格,核心是先备好两份素材:行数据列表和列名数组。上面这段构造函数收尾,就是把外部传进来的 row_data 和 column_names 分别落进成员变量,再据此生成 CTableModel 与 CTableHeader。 m_list_rows 直接接管 row_data 的引用,后续表格刷新只要改这个列表就能驱动视图。ArrayNamesCopy 负责把列名深拷贝进 m_array_names,避免外部数组被回收后表头悬空——这在 EA 退出时容易踩坑。 两个 new 出来的对象(CTableModel / CTableHeader)不会被自动释放,调用方要在析构里 delete,否则 MT5 终端跑几小时就可能漏出几十 MB。开 MT5 把这段塞进你自己的 CTable 派生类,编译后拖到图表,看右上角是否按列名画出空表,就能验证接线对不对。
class=class="str">"cmt">//--- create a table model based on this list this.m_list_rows=row_data; this.m_table_model=new CTableModel(this.m_list_rows); class=class="str">"cmt">//--- Copy the passed list of headers to m_array_names and class=class="str">"cmt">//--- create a table header based on this list this.ArrayNamesCopy(column_names,column_names.Size()); this.m_table_header=new CTableHeader(this.m_array_names); }
「空表与数据表的日志打印差异」
在 MT5 的 MQL5\Scripts\Tables\ 目录下新建 TestEmptyTable.mq5,挂上 Tables.mqh 后直接 new 一个 4x4 的 CTable,日志里会看到列头按 MS Excel 风格自动标成 A/B/C/D,4 行数据区全是空串。 换 TestTArrayTable.mq5 传入自定义列头数组,打印出来的列头就是你传进去的文字,底层二维数组支持 ENUM_DATATYPE 里的任意类型,但表模型类只收 double、long、datetime、color、string 这五种,其余类型在内部自动转掉。 再跑 TestMatrixTable.mq5 用矩阵建表,出来的版式和 4x4 二维数组的没什么两样;TestDealsTable.mq5 则扫一遍历史成交,把每笔单子塞进表里,Print 方法默认单元格宽 19 字符。日志示例只露了前四后四笔,但足够判断整表形态。 所有脚本和表类文件打包解压到终端目录即可用,不用手动挪路径。外汇与贵金属交易历史统计涉及真实账户数据,回测与日志输出仅反映已发生记录,不构成任何方向暗示,实盘高风险。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| TestEmptyTable.mq5 | class=class="str">"cmt">//| Copyright class="num">2025, MetaQuotes Ltd. | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Copyright class="num">2025, MetaQuotes Ltd." class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#class="kw">property version "class="num">1.00" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Include libraries | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "Tables.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- Create an empty 4x4 table CTable *table=new CTable(class="num">4,class="num">4); if(table==NULL) class="kw">return; class=class="str">"cmt">//--- Display it in the journal and class="kw">delete the created object table.Print(class="num">10); class="kw">delete table; } Table: Rows total: class="num">4, Columns total: class="num">4: | n/n | A | B | C | D | | class="num">0 | | | | | | class="num">1 | | | | | | class="num">2 | | | | | | class="num">3 | | | | | class=class="str">"cmt">//+------------------------------------------------------------------+
用 CTable 把矩阵丢进日志
做批量回测或参数扫描时,最怕结果散在 Print 里肉眼对。MQL5 的 Tables.mqh 里 CTable 类可以把二维数组直接排版成带列名的文本表,甩进专家日志。 下面这段脚本先声明一个 4×4 的 double 数组,值从 1 排到 16,再配一个 4 元素的 string 表头数组,两者交给 new CTable(array,headers) 即可成型。 构造后务必判 NULL,脚本里 table==NULL 直接 return;随后 table.Print(10) 的参数是单元格最小宽度,日志输出会显示 'Rows total: 4, Columns total: 4' 及对齐表格。实际跑出来首行是 1.00/2.00/3.00/4.00,次行 5.00~8.00,列名 Column 1 到 Column 4。外汇与贵金属策略验证涉及杠杆与滑点,日志表格仅作本地核对,实盘前请自建样本重跑。
class=class="str">"cmt">//| TestTArrayTable.mq5 | class=class="str">"cmt">//| Copyright class="num">2025, MetaQuotes Ltd. | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Copyright class="num">2025, MetaQuotes Ltd." class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#class="kw">property version "class="num">1.00" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Include libraries | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "Tables.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- Declare and initialize a 4x4 class="type">class="kw">double array class="type">class="kw">double array[class="num">4][class="num">4]={{ class="num">1, class="num">2, class="num">3, class="num">4}, { class="num">5, class="num">6, class="num">7, class="num">8}, { class="num">9, class="num">10, class="num">11, class="num">12}, {class="num">13, class="num">14, class="num">15, class="num">16}}; class=class="str">"cmt">//--- Declare and initialize the column header array class="type">class="kw">string headers[]={"Column class="num">1","Column class="num">2","Column class="num">3","Column class="num">4"}; class=class="str">"cmt">//--- Create a table based on the data array and the header array CTable *table=new CTable(array,headers); if(table==NULL) class="kw">return; class=class="str">"cmt">//--- Display the table in the journal and class="kw">delete the created object table.Print(class="num">10); class="kw">delete table; }
◍ 用矩阵直接喂出控制台表格
在 MT5 脚本里做批量回测或参数扫描时,最烦的就是把结果一行行 Print 拼字符串。MQL5 的 matrix 类型配合自定义 CTable 封装,可以一步把二维数据丢进专家日志。 上面这段脚本声明了一个 4×4 的 matrix,元素从 1 到 16 按行填满,再配一个 4 个字符串的表头数组。CTable 接收这两者后,调用 Print(10) 就能在日志里输出带列名的对齐表格,实测输出为 Rows total: 4, Columns total: 4,列名依次为 Column 1 到 Column 4。 想验证就新建一个 Script,把 Tables.mqh 放到同级目录,把下面代码原样贴进 OnStart 编译运行。外汇与贵金属行情波动剧烈、杠杆风险高,这类表格常用于离线的参数矩阵统计,不要在实盘 tick 环境频繁 new 对象。
class="macro">#class="kw">property copyright "Copyright class="num">2025, MetaQuotes Ltd." class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#class="kw">property version "class="num">1.00" class="macro">#include "Tables.mqh" class="type">void OnStart() { class=class="str">"cmt">//--- Declare and initialize a 4x4 matrix matrix row_data = {{ class="num">1, class="num">2, class="num">3, class="num">4}, { class="num">5, class="num">6, class="num">7, class="num">8}, { class="num">9, class="num">10, class="num">11, class="num">12}, {class="num">13, class="num">14, class="num">15, class="num">16}}; class=class="str">"cmt">//--- Declare and initialize the column header array class="type">class="kw">string headers[]={"Column class="num">1","Column class="num">2","Column class="num">3","Column class="num">4"}; class=class="str">"cmt">//--- Create a table based on a matrix and an array of headers CTable *table=new CTable(row_data,headers); if(table==NULL) class="kw">return; class=class="str">"cmt">//--- Display the table in the journal and class="kw">delete the created object table.Print(class="num">10); class="kw">delete table; }
「用双层链表把成交记录铺成表格」
想把 MT5 历史成交按行按列摆出来,核心不是画界面,而是先建好数据结构。上面那段示例表格只是示意:行号 0~3 对应四笔成交,列 1.00~4.00 放成交属性,单元格值从 5.00 一路排到 16.00,实际跑起来由 HistoryDealGetTicket 取到的 ticket 决定。 脚本思路是先 HistorySelect(0, TimeCurrent()) 把全部账户历史拉进内存,再用 HistoryDealsTotal 拿到成交总数。随后 for 循环里逐笔取 ticket,ticket 为 0 就跳过,避免脏数据进表。 建表时用 CList 套 CList:外层 rows_data 装所有行,每行又是一个 CList,里面塞 CMqlParamObj 类型的单元格。DataListCreator::AddNewRowToDataList 负责给每笔成交挂一个新行,返回 NULL 同样跳过。外汇和贵金属杠杆高,历史成交可能上千笔,这种嵌套结构在回测或实盘复盘里能直接喂给小布类工具做批量统计,但务必先在策略测试器里用小资金验证内存占用。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| TestDealsTable.mq5 | class=class="str">"cmt">//| Copyright class="num">2025, MetaQuotes Ltd. | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Copyright class="num">2025, MetaQuotes Ltd." class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#class="kw">property version "class="num">1.00" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Include libraries | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "Tables.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- Declare a list of deals, the deal parameters object, and the structure of parameters CList rows_data; CMqlParamObj *cell=NULL; class="type">MqlParam param={}; class=class="str">"cmt">//--- Select the entire history if(!HistorySelect(class="num">0,TimeCurrent())) class="kw">return; class=class="str">"cmt">//--- Create a list of deals in the array of arrays(CList in CList) class=class="str">"cmt">//--- (one row is one deal, columns are deal class="kw">property objects) class="type">int total=HistoryDealsTotal(); for(class="type">int i=class="num">0;i<total;i++) { class="type">ulong ticket=HistoryDealGetTicket(i); if(ticket==class="num">0) class="kw">continue; class=class="str">"cmt">//--- Add a new row of properties for the next deal to the list of deals CList *row=DataListCreator::AddNewRowToDataList(&rows_data); if(row==NULL) class="kw">continue;
把成交记录拆成可显示的属性格
在 MT5 的历史成交遍历里,光拿到 ticket 不够,真正做面板展示要把每笔 deal 的关键字段逐个抽出来塞进表格行。下面这段逻辑就是先取品种精度和小数位,再按列顺序填充时间、品种、ticket、来源订单、持仓 ID 和成交类型。
| 时间列用 DEAL_TIME 拿整型时间戳,显示格式靠 double_value 里的 TIME_DATE | TIME_MINUTES | TIME_SECONDS 组合控制,能精确到秒。品种名直接 HistoryDealGetString 取 DEAL_SYMBOL,digits 则来自 SymbolInfoInteger 的 SYMBOL_DIGITS,黄金 XAUUSD 通常是 2 位、主流外汇对多是 3 或 5 位。 |
|---|
成交类型那列不能只存枚举,得 switch 映射成可读字符串:DEAL_TYPE_BUY 转 "Buy"、DEAL_TYPE_SELL 转 "Sell",余额和费用类分别是 Balance / Credit / Charge。这样终端历史页才不会出现交易者看不懂的数字代号。 外汇和贵金属杠杆高,历史成交里的 Charge、Credit 往往藏着隔夜息和手续费,复盘时别只盯 Buy/Sell 盈亏,把这些格子都拉出来看才可能摸清真实交易成本。
class=class="str">"cmt">//--- Create "cells" with the deal parameters and class=class="str">"cmt">//--- add them to the created deal properties row class="type">class="kw">string symbol=HistoryDealGetString(ticket,DEAL_SYMBOL); class="type">int digits=(class="type">int)SymbolInfoInteger(symbol,SYMBOL_DIGITS); class=class="str">"cmt">//--- Deal time(column class="num">0) param.type=TYPE_DATETIME; param.integer_value=HistoryDealGetInteger(ticket,DEAL_TIME); param.double_value=(TIME_DATE|TIME_MINUTES|TIME_SECONDS); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Symbol name(column class="num">1) param.type=TYPE_STRING; param.string_value=symbol; DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal ticket(column class="num">2) param.type=TYPE_LONG; param.integer_value=(class="type">long)ticket; DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- The order the performed deal is based on(column class="num">3) param.type=TYPE_LONG; param.integer_value=HistoryDealGetInteger(ticket,DEAL_ORDER); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Position ID(column class="num">4) param.type=TYPE_LONG; param.integer_value=HistoryDealGetInteger(ticket,DEAL_POSITION_ID); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal type(column class="num">5) param.type=TYPE_STRING; ENUM_DEAL_TYPE deal_type=(ENUM_DEAL_TYPE)HistoryDealGetInteger(ticket,DEAL_TYPE); param.integer_value=deal_type; class="type">class="kw">string type=""; class="kw">switch(deal_type) { case DEAL_TYPE_BUY : type="Buy"; class="kw">break; case DEAL_TYPE_SELL : type="Sell"; class="kw">break; case DEAL_TYPE_BALANCE : type="Balance"; class="kw">break; case DEAL_TYPE_CREDIT : type="Credit"; class="kw">break; case DEAL_TYPE_CHARGE : type="Charge"; class="kw">break; }
◍ 补全成交类型与方向字段的映射
在 MT5 历史成交表渲染里,除买卖开平仓外还有十余种成交类别,上面这段 switch 把 DEAL_TYPE_CORRECTION 到 DEAL_TAX 全部映射成可读字符串,default 分支留空防止未知枚举崩报表。 需要注意 DEAL_TYPE_COMMISSION 与 DEAL_TYPE_COMMISSION_DAILY / MONTHLY 是分开的, agent 后缀代表代理佣金,做返佣统计时若只抓 COMMISSION 会漏掉日结和月结两笔。 方向字段用 ENUM_DEAL_ENTRY 取 DEAL_ENTRY,直接存整数再由前端转义,比存字符串省一次转换;外汇和贵金属品种在高杠杆下这类 correction、bonus 记录可能频繁出现,解读持仓成本时建议把非交易类成交单独过滤。
case DEAL_TYPE_CORRECTION : type="Correction"; class="kw">break; case DEAL_TYPE_BONUS : type="Bonus"; class="kw">break; case DEAL_TYPE_COMMISSION : type="Commission"; class="kw">break; case DEAL_TYPE_COMMISSION_DAILY : type="Commission daily"; class="kw">break; case DEAL_TYPE_COMMISSION_MONTHLY : type="Commission monthly"; class="kw">break; case DEAL_TYPE_COMMISSION_AGENT_DAILY : type="Commission agent daily"; class="kw">break; case DEAL_TYPE_COMMISSION_AGENT_MONTHLY : type="Commission agent monthly"; class="kw">break; case DEAL_TYPE_INTEREST : type="Interest"; class="kw">break; case DEAL_TYPE_BUY_CANCELED : type="Buy canceled"; class="kw">break; case DEAL_TYPE_SELL_CANCELED : type="Sell canceled"; class="kw">break; case DEAL_DIVIDEND : type="Dividend"; class="kw">break; case DEAL_DIVIDEND_FRANKED : type="Dividend franked"; class="kw">break; case DEAL_TAX : type="Tax"; class="kw">break; class="kw">default : class="kw">break; } param.string_value=type; DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal direction(column class="num">6) param.type=TYPE_STRING; ENUM_DEAL_ENTRY deal_entry=(ENUM_DEAL_ENTRY)HistoryDealGetInteger(ticket,DEAL_ENTRY); param.integer_value=deal_entry;
「把成交单的进出场属性逐列填进表格行」
在 MT5 历史成交遍历里,单条 deal 的元数据要靠 switch 把枚举转成可读字符串再写进单元格。下面这段先把 DEAL_ENTRY 四种状态映射为 In / Out / InOut / OutBy,default 分支留空避免越界赋值。 string entry=""; switch(deal_entry) { case DEAL_ENTRY_IN : entry="In"; break; case DEAL_ENTRY_OUT : entry="Out"; break; case DEAL_ENTRY_INOUT : entry="InOut"; break; case DEAL_ENTRY_OUT_BY : entry="OutBy"; break; default : break; } param.string_value=entry; DataListCreator::AddNewCellParamToRow(row,param); 紧接着从第 7 列起连续塞双精度字段:成交量用 HistoryDealGetDouble(ticket,DEAL_VOLUME),价格、SL、TP 同理。小数位处理有个细节——double_value>0 才用 digits 精度,否则只留 1 位,能压掉 0.00000 这类噪声显示。 //--- Deal volume (column 7) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_VOLUME); param.integer_value=2; DataListCreator::AddNewCellParamToRow(row,param); //--- Deal price (column 8) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_PRICE); param.integer_value=(param.double_value>0 ? digits : 1); DataListCreator::AddNewCellParamToRow(row,param); //--- Stop Loss level (column 9) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_SL); param.integer_value=(param.double_value>0 ? digits : 1); DataListCreator::AddNewCellParamToRow(row,param); //--- Take Profit level (column 10) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_TP); param.integer_value=(param.double_value>0 ? digits : 1); DataListCreator::AddNewCellParamToRow(row,param); 第 11 列写盈亏,非零就给 2 位小数、零则 1 位;第 12 列魔法码用 HistoryDealGetInteger(ticket,DEAL_MAGIC) 走长整型。外汇与贵金属杠杆高,历史回看时盈亏数字仅反映过去,不预示后续分布。 //--- Deal financial result (column 11) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_PROFIT); param.integer_value=(param.double_value!=0 ? 2 : 1); DataListCreator::AddNewCellParamToRow(row,param); //--- Deal magic number (column 12) param.type=TYPE_LONG; param.integer_value=HistoryDealGetInteger(ticket,DEAL_MAGIC); DataListCreator::AddNewCellParamToRow(row,param); 最后第 13 列记成交原因,先把 DEAL_REASON 强转成 ENUM_DEAL_REASON 再 switch 展开,方便区分 EA 挂单、手动、止损触发等来源。 //--- Deal execution reason or source (column 13) param.type=TYPE_STRING; ENUM_DEAL_REASON deal_reason=(ENUM_DEAL_REASON)HistoryDealGetInteger(ticket,DEAL_REASON); param.integer_value=deal_reason; string reason=""; switch(deal_reason) {
class="type">class="kw">string entry=""; class="kw">switch(deal_entry) { case DEAL_ENTRY_IN : entry="In"; class="kw">break; case DEAL_ENTRY_OUT : entry="Out"; class="kw">break; case DEAL_ENTRY_INOUT : entry="InOut"; class="kw">break; case DEAL_ENTRY_OUT_BY : entry="OutBy"; class="kw">break; class="kw">default : class="kw">break; } param.string_value=entry; DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal volume(column class="num">7) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_VOLUME); param.integer_value=class="num">2; DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal price(column class="num">8) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_PRICE); param.integer_value=(param.double_value>class="num">0 ? digits : class="num">1); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Stop Loss level(column class="num">9) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_SL); param.integer_value=(param.double_value>class="num">0 ? digits : class="num">1); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Take Profit level(column class="num">10) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_TP); param.integer_value=(param.double_value>class="num">0 ? digits : class="num">1); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal financial result(column class="num">11) param.type=TYPE_DOUBLE; param.double_value=HistoryDealGetDouble(ticket,DEAL_PROFIT); param.integer_value=(param.double_value!=class="num">0 ? class="num">2 : class="num">1); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal magic number(column class="num">12) param.type=TYPE_LONG; param.integer_value=HistoryDealGetInteger(ticket,DEAL_MAGIC); DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal execution reason or source(column class="num">13) param.type=TYPE_STRING; ENUM_DEAL_REASON deal_reason=(ENUM_DEAL_REASON)HistoryDealGetInteger(ticket,DEAL_REASON); param.integer_value=deal_reason; class="type">class="kw">string reason=""; class="kw">switch(deal_reason) {
成交归因与建表落盘
上面这段 switch 把每一笔 deal 的触发来源映射成可读字符串:手动端(Client / Mobile / Web)、EA 自动单(Expert)、止损止盈(SL / TP)、强平(StopOut)、隔夜利息(Rollover)、保证金变动(VMargin)、拆单(Split)以及公司行为(Corporate action),未匹配到的走 default 留空。 拿到 reason 后写进 param.string_value,再交给 DataListCreator::AddNewCellParamToRow 塞进当前行;紧接着第 14 列取 DEAL_COMMENT 备注同样入行。这样每行就攒齐了时间、品种、Ticket、Order、Position、类型、开平、手数、价格、SL、TP、利润、Magic、Reason、Comment 共 16 个字段。 表头数组 headers[] 顺序与列一一对应,CTableByParam 用 rows_data + headers 直接构表;构表失败就 return,成功则 Print() 打到 MT5 专家日志,随后 delete 释放对象。开 MT5 跑一遍,日志里就能看到每笔贵金属或外汇成交的来源分布,外汇和贵金属杠杆高,StopOut 行出现频率本身就能反映账户风险暴露。
case DEAL_REASON_CLIENT : reason="Client"; class="kw">break; case DEAL_REASON_MOBILE : reason="Mobile"; class="kw">break; case DEAL_REASON_WEB : reason="Web"; class="kw">break; case DEAL_REASON_EXPERT : reason="Expert"; class="kw">break; case DEAL_REASON_SL : reason="SL"; class="kw">break; case DEAL_REASON_TP : reason="TP"; class="kw">break; case DEAL_REASON_SO : reason="StopOut"; class="kw">break; case DEAL_REASON_ROLLOVER : reason="Rollover"; class="kw">break; case DEAL_REASON_VMARGIN : reason="VMargin"; class="kw">break; case DEAL_REASON_SPLIT : reason="Split"; class="kw">break; case DEAL_REASON_CORPORATE_ACTION: reason="Corporate action"; class="kw">break; class="kw">default : class="kw">break; } param.string_value=reason; DataListCreator::AddNewCellParamToRow(row,param); class=class="str">"cmt">//--- Deal comment(column class="num">14) param.type=TYPE_STRING; param.string_value=HistoryDealGetString(ticket,DEAL_COMMENT); DataListCreator::AddNewCellParamToRow(row,param); } class=class="str">"cmt">//--- Declare and initialize the table header class="type">class="kw">string headers[]={"Time","Symbol","Ticket","Order","Position","Type","Entry","Volume","Price","SL","TP","Profit","Magic","Reason","Comment"}; class=class="str">"cmt">//--- Create a table based on the created list of parameters and the header array CTableByParam *table=new CTableByParam(rows_data,headers); if(table==NULL) class="kw">return; class=class="str">"cmt">//--- Display the table in the journal and class="kw">delete the created object table.Print(); class="kw">delete table; }
◍ 逐行读持仓明细表
回测或实盘后,MT5 的 OnTester 或自定义报表常吐出一张宽表。上面这段样本显示:总行数 797、列数 15,说明单次跑完的策略把每笔订单、仓位、盈亏、魔数都摊开了,不是只给一个净值曲线。 第 0 行是 Balance 类型、Entry 标 In、Profit 100000.00,就是初始入金 10 万的账户起点;第 1 行才是真交易:2025.01.02 00:01:31 在 GBPAUD 卖 0.25 手,开价 2.02111,Ticket 3152603334,Position 与 Order 同号 3191672408,Magic 112、Reason 标 Expert——典型 EA 自动下单。 SL/TP 都是 0.0,意味着这笔单子没挂硬止损止盈,盈亏栏也是 0.0,说明截图时还未平仓。外汇和贵金属杠杆高,这种裸单若遇跳空可能扩大回撤,读表时先盯 SL/TP 列是否为 0,再决定要不要手补风控。 把表导进 MT5 的 CSV 浏览或写个脚本筛 Magic=112 的行,就能单独看这套 EA 的进出场节奏,比只看 equity 图有用。
「从成交明细看 EA 的进出场痕迹」
上面这段是 MT5 账户历史里抽出的三行真实成交记录,时间跨度从 2025.01.02 到 2025.04.18,共截取第 2、3、793 笔。GBPAUD 在 02:50:31 以 2.02001 价格 0.25 手 Buy 出场,盈利 17.04,备注显示命中 tp 2.02001;同日凌晨 GBPUSD 以 1.25270 开 0.10 手 Sell,挂止盈 1.24970,魔术码 12 标注为 Expert 信号。 EURCAD 那笔在 03:22:11 以 1.57503 平掉 0.25 手 Sell,获利 12.64,同样是 TP 触发,魔术码 112。把这几行塞进 Excel 或写个脚本按魔术码分组,就能反推出不同 EA 实例分别管哪些品种、手数偏好怎样。 外汇与贵金属杠杆交易风险极高,这类明细只说明历史行为,不代表后续信号概率稳定,拿去验证前先确认点差和滑点环境是否一致。
成交明细里藏着的风控线索
上面这组 MT5 交易日志截取了三笔已平仓记录,时间集中在 2025.04.18 凌晨至早盘。GBPAUD 两笔 Sell 在 04:06:52 同时以 2.07977 触 TP 出场,手数均为 0.25,盈利分别是 3.35 和 12.93;AUDJPY 一笔 Buy 在 05:57:48 以 90.672 触 TP,盈利 19.15。 三笔单子的 magic number 都落在 3652xxxxxx 区间,且 comment 字段直接标注了 [tp 价格],说明这是同一套 EA 在跑固定止盈逻辑,并非人工干预。 注意 GBPAUD 那笔盈利仅 3.35 却也走了 TP,回看开平价差为 0——意味着挂单成交价与止盈价重合,属于跳空或滑点极小环境下的秒触。外汇与贵金属杠杆交易高风险,这类窄幅 TP 策略在流动性突变时可能连续踏空。 开 MT5 按 Ctrl+T 调出交易标签,用同样日期筛 GBPAUD+H1,能复现这两笔 Sell 的 TP 位是否正好压在前期供给区边缘。
◍ 下一步把视图和控制器一起做掉
表模型(Model)这块基础已经打完:我们用类封装了表与表头,并在二维数组、矩阵、交易历史三类数据上跑通了读写。MQL5 自带事件系统能让对象对用户操作直接响应,所以视图(View)和控制器(Controller)没必要拆太开,绑在一起写反而能省掉 View 多层嵌套的麻烦。 随文给的 6 个文件可直接下:Tables.mqh(261.24 KB)是核心类库,TestEmptyTable.mq5(3.22 KB)、TestTArrayTable.mq5(4.06 KB)、TestMatrixTable.mq5(4.03 KB)、TestDealsTable.mq5(24.63 KB)四个脚本分别验证空表、数组、矩阵、成交历史四种来源,MQL5.zip(30.86 KB)整包解压进终端 MQL5 目录就能编译。 下一篇会直接落地 View+Controller 合并实现的完整表操作工具。等这套框架成型,后续做其他 UI 控件(面板、选择器)都能复用同一套事件与渲染骨架,不用每次从零造轮子。