连续前行优化 (第八部分): 程序改进和修复(基础篇)
「给连续前行优化程序打补丁」
连续前行优化(walk-forward)在 MT5 里跑久了容易暴露一些工程层面的坑,这一节集中处理程序改进与已知缺陷修复。原始讨论帖发布于 2020 年 12 月 31 日,截至统计时有 2109 次浏览、29 条跟帖,说明这类底层打磨在实盘量化群里需求很实。 最常见的问题是:多轮样本外推演时,缓存的句柄没及时释放,导致第 8 轮以后内存占用陡增并偶发崩溃。修复思路是在每轮优化收尾强制调用资源回收,而不是依赖终端自动清理。 另一个改动是参数网格的边界判定。旧逻辑在步长非整数因子的参数上会漏掉端点,新逻辑改为按实际浮点容差闭合区间,回测覆盖度更完整。外汇与贵金属品种波动率高、滑点敏感,这类边界遗漏可能让过拟合被掩盖,实盘风险偏大。
◍ 自动优化器这次重写了什么
这一版自动优化器是按读者在前面几篇里的反馈改的,不是凭空加功能。最直观的变化是速度:相比旧版,这一轮优化执行快了数倍,原因是去掉了互斥体(mutex),并砍掉了一些拖慢报告生成的操作。 另一个关键点是资产范围。旧逻辑一次只能盯一个标的,现在优化可以直接跑在一组资产上,适合做跨品种的前行测试。 内存占用也处理了。之前大批量跑优化时内存会一直涨,这一版在优化过程中的内存问题已经解决,长时间任务不容易被拖垮。 前面几篇打的底子还在:从操控优化报告、机器人机理、适配自动优化器,到优化管理器、GUI 和逻辑结构,这一版是在那个骨架上做的性能与范围重构。外汇与贵金属品种波动大、杠杆高,用这组优化跑历史窗口时仍要警惕过拟合风险。
把优化日期拆成自动前行与历史窗
老版本要手动一段段填日期做前行和历史优化,麻烦且易错。现在把时间范围自动切分:历史窗固定步长、前行窗连续不重叠,且每个新前行窗从上一窗的次日开启,历史窗偏移量等于前行步长。 界面上甩出一个独立图形子窗(AutoFillInDateBorders),里面俩日历选起止日,一张表填历史/前行的步数(天),点 Set 就自动算。比如历史填 360、前行填 90,则总区间被切成若干 360 天历史窗与 90 天前行窗,下一历史窗起点按前行步数平移。 数据模型用单例(AutoFillInDateBordersM)隔着子窗与主窗通信,只暴露 Calculate 和 DateBorders 事件。Calculate 从 History 窗起算,嵌套函数算边界,越界即停,最后抛事件把键值对(类型+起止 DateTime)给主窗。 主窗订阅事件后先清空旧边界,再逐条写新边界;DateBorder 构造包了 try-catch 防异常,ClearDateBorders 一键清,不必像以前挨个删。子窗由 SubFormKeeper 包装,主窗只调 Open,不直接碰辅助窗。 下面这段是模型核心,注意单例私有构造、事件声明和 Calculate 里的平移逻辑:
class AutoFillInDateBordersM : IAutoFillInDateBordersM { class="kw">private AutoFillInDateBordersM() { } class="kw">private class="kw">static AutoFillInDateBordersM instance; class="kw">public class="kw">static AutoFillInDateBordersM Instance() { if (instance == null) instance = new AutoFillInDateBordersM(); class="kw">return instance; } class="kw">public event Action<List<KeyValuePair<OptimisationType, DateTime[]>>> DateBorders; class="kw">public class="type">void Calculate(DateTime From, DateTime Till, class="type">uint history, class="type">uint forward) { if (From >= Till) class="kw">throw new ArgumentException("Date From must be less then date Till"); List<KeyValuePair<OptimisationType, DateTime[]>> data = new List<KeyValuePair<OptimisationType, DateTime[]>>(); OptimisationType type = OptimisationType.History; DateTime _history = From; DateTime _forward = From.AddDays(history + class="num">1); DateTime CalcEndDate() { class="kw">return type == OptimisationType.History ? _history.AddDays(history) : _forward.AddDays(forward); } class="kw">while (CalcEndDate() <= Till) { DateTime from = type == OptimisationType.History ? _history : _forward; data.Add(new KeyValuePair<OptimisationType, DateTime[]>(type, new DateTime[class="num">2] { from, CalcEndDate() })); if (type == OptimisationType.History) _history = _history.AddDays(forward + class="num">1); else
「样本外切分与窗体生命周期的衔接细节」
这段逻辑在做 Walk Forward 的边界推进:当内插样本(History)跑完,就把前向窗口向后推一天并切换为 Forward 模式,循环直到覆盖完整周期。若一步都切不出有效数据,直接抛 ArgumentException,提示 In sample step 设置过宽。 事件订阅写在 ViewModel 构造与析构里:AutoFillInDateBordersCreator.Model.DateBorders 在初始化时 +=、释放时 -=,避免 MT5 插件关闭后还回调已销毁对象。 _AddDateBorder 用 try-catch 包住,重复边界不会重复加入,异常只弹 MessageBox 不中断优化线程。ClearDateBorders 绑定 RelayCommand 一键清空,方便手动重设。 外汇与贵金属品种做此类自动切分前,建议先以历史数据验证 step 参数:step 过大会让 data.Count 为 0,优化器直接报错退出,属高风险配置失误。
_forward = _forward.AddDays(forward + class="num">1); type = type == OptimisationType.History ? OptimisationType.Forward : OptimisationType.History; } if (data.Count == class="num">0) class="kw">throw new ArgumentException("Can`t create any date borders with set In sample(History) step"); DateBorders?.Invoke(data); } } class AutoFillInDateBordersCreator { class="kw">public class="kw">static IAutoFillInDateBordersM Model => AutoFillInDateBordersM.Instance(); } class="kw">public AutoOptimiserVM() { ... AutoFillInDateBordersCreator.Model.DateBorders += Model_DateBorders; .... } ~AutoOptimiserVM() { ... AutoFillInDateBordersCreator.Model.DateBorders -= Model_DateBorders; .... } class="type">void _AddDateBorder(DateTime From, DateTime Till, OptimisationType DateBorderType) { try { DateBorders border = new DateBorders(From, Till); if (!DateBorders.Where(x => x.BorderType == DateBorderType).Any(y => y.DateBorders == border)) { DateBorders.Add(new DateBordersItem(border, _DeleteDateBorder, DateBorderType)); } } catch (Exception e) { System.Windows.MessageBox.Show(e.Message); } } ClearDateBorders = new RelayCommand((object o) => { DateBorders.Clear(); }); class SubFormKeeper { class="kw">public SubFormKeeper(Func<Window> createWindow, Action<Window> subscribe_events = null, Action<Window> unSubscribe_events = null); class="kw">public class="type">void Open(); class="kw">public class="type">void Close(); }
◍ 给优化报告挂上自定义系数并提速
ReportManager.dll 这次加了自定义优化系数字段,SortBy 枚举多了 Custom 参数,Coefficients 结构也补了对应成员。旧报表格式不变,读取类既能吃新带系数的报告,也能兼容早期版本生成的文件,所以你手里的历史优化结果不会因为升级直接废掉。 AppendMainCoef 方法现在多收一个 customCoef 参数,并塞进 ReportWriter.ReportItem 结构。如果你拿新版 dll 去编译老项目,会直接抛异常——因为方法签名变了。解决办法是替换 Include 目录里的 History Manager 代码,让卸载数据的对象兼容新旧两种写法。Write 方法也不再抛异常而是返回错误串,原因是去掉了命名互斥体,旧导出格式仍保留兼容方法。 自定义系数要落盘,得在报告里新写一条 <Item/> 标记,Name 属性填 "Custom"。MQL5 侧通过 CAutoUploader 新增的重载构造函数传入系数计算函数指针;不传时默认 EmptyCustomCoefCallback 返回 0。实际用法就是在 EA 里写一个算系数的函数,OnTester 里不硬算,改为调这个函数,再把函数指针交给 CAutoUploader 即可。 提速这块更实在:老逻辑每来一条优化推算就要读文件、存内存、追加、删旧文件、建新文件、重写全量,文件越大越慢。现在改按帧累积,用 OptimisationResultsExtentions.ReportWriter 一次性写盘。实测原来 ReportWriter.Write 要花几分钟的数组,新方式几秒内就能写完。
| 排序方向之前有个坑:亏损交易数这类指标是负数,却按降序排,导致多因子权重反转逻辑错乱。修复后负数组先做 [0 ; (Max+ | Min | )] 平移,再归一化到 [0;1],降序才用 1 减。单排序基准时也补了 GetSortingDirection 的判别,比如 SortBy.MaxDDTotalTrades 现在真按升序把最小值顶到头部,不会像旧版那样吐出最差推算。外汇与贵金属优化参数过拟合风险高,任何系数改动都建议先在小样本回测验证。 |
|---|
class="kw">public class="kw">static class="type">void AppendMainCoef(class="type">class="kw">double customCoef, class="type">class="kw">double payoff, class="type">class="kw">double profitFactor, class="type">class="kw">double averageProfitFactor, class="type">class="kw">double recoveryFactor, class="type">class="kw">double averageRecoveryFactor, class="type">int totalTrades, class="type">class="kw">double pl, class="type">class="kw">double dd,
把自定义系数塞进回测报告的结构
在 MT5 策略回测里,如果你不想只用平台默认的统计指标,而是要把自己算的 altmanZScore 或者 customCoef 这类字段写进优化报告,就得接管报告对象的系数赋值。下面这段代码做的就是:把外部传入的 customCoef 挂到 ReportItem 的 OptimisationCoefficients.Custom 上,之后用 WriteItem 以 XML 节点形式落盘,节点名硬编码为 "Custom"。 ReportItem.OptimisationCoefficients.Custom = customCoef; WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.Custom.ToString(), new Dictionary<string, string> { { "Name", "Custom" } }); 真正灵活的地方在类型定义:typedef double(*TCustomFilter)(); 这是一个无参、返回 double 的函数指针。CXmlHistoryWriter 和 CAutoUploader 的构造函数都接收 TCustomFilter filter 作为参数,意味着你能在不改动写入类的前提下,热插拔不同的自定义评分函数——比如这趟算 Z 值,下趟算别的破产距离。 类里 append_main_coef(PL_detales &pl_detales, TotalResult &totalResult) 负责把权益曲线明细和总结果转成主系数写入;insert_day 按 ENUM_DAY_OF_WEEK 把逐日回撤归档。构造函数重载区分了「指定文件名」和「仅用互斥量名+佣金管理器」两种初始化路径,析构里调 _report_manager.Clear() 释放报告缓存。 开 MT5 自己验证时,先 typedef 一个返回 double 的滤波函数,再传给 CXmlHistoryWriter 的带 filter 重载,跑完优化去 XML 里搜 Name="Custom" 就能看到你那一列系数。外汇与贵金属回测含高杠杆风险,自定义系数仅反映历史样本特征,样本外可能失效。
class="type">class="kw">double altmanZScore) { ReportItem.OptimisationCoefficients.Custom = customCoef; ... } WriteItem(xmlDoc, xpath, "Item", ReportItem.OptimisationCoefficients.Custom.ToString(), new Dictionary<class="type">class="kw">string, class="type">class="kw">string> { { "Name", "Custom" } }); typedef class="type">class="kw">double(*TCustomFilter)(); class CXmlHistoryWriter { class="kw">private: class="kw">const class="type">class="kw">string _path_to_file,_mutex_name; CReportCreator _report_manager; TCustomFilter custom_filter; class="type">void append_bot_params(class="kw">const BotParams ¶ms[]);class=class="str">"cmt">// class="type">void append_main_coef(PL_detales &pl_detales, TotalResult &totalResult);class=class="str">"cmt">// class=class="str">"cmt">//class="type">class="kw">double get_average_coef(CoefChartType type); class="type">void insert_day(PLDrawdown &day,ENUM_DAY_OF_WEEK day);class=class="str">"cmt">// class="type">void append_days_pl();class=class="str">"cmt">// class="kw">public: CXmlHistoryWriter(class="type">class="kw">string file_name,class="type">class="kw">string mutex_name, CCCM *_comission_manager, TCustomFilter filter);class=class="str">"cmt">// CXmlHistoryWriter(class="type">class="kw">string mutex_name,CCCM *_comission_manager, TCustomFilter filter); ~CXmlHistoryWriter(class="type">void) {_report_manager.Clear();} class=class="str">"cmt">// class="type">void Write(class="kw">const BotParams ¶ms[],class="type">class="kw">datetime start_test,class="type">class="kw">datetime end_test);class=class="str">"cmt">// }; class CAutoUploader { class="kw">private: class="type">class="kw">datetime From,Till; class=class="str">"cmt">// Testing start and end dates CCCM *comission_manager; class=class="str">"cmt">// Commission manager BotParams params[]; class=class="str">"cmt">// List of parameters class="type">class="kw">string mutexName; class=class="str">"cmt">// Mutex name TCustomFilter custom_filter; class="kw">public: CAutoUploader(CCCM *comission_manager, class="type">class="kw">string mutexName, BotParams ¶ms[], TCustomFilter filter); CAutoUploader(CCCM *comission_manager, class="type">class="kw">string mutexName, BotParams ¶ms[]); class="kw">virtual ~CAutoUploader(class="type">void);