连续前行优化 (第八部分): 程序改进和修复(基础篇)
📘

连续前行优化 (第八部分): 程序改进和修复(基础篇)

第 1/3 篇

「给连续前行优化程序打补丁」

连续前行优化(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 里的平移逻辑:

MQL5 / C++
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,优化器直接报错退出,属高风险配置失误。

MQL5 / C++
        _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 现在真按升序把最小值顶到头部,不会像旧版那样吐出最差推算。外汇与贵金属优化参数过拟合风险高,任何系数改动都建议先在小样本回测验证。
MQL5 / C++
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" 就能看到你那一列系数。外汇与贵金属回测含高杠杆风险,自定义系数仅反映历史样本特征,样本外可能失效。

MQL5 / C++
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  &params[]);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 &params[],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 &params[],
                                   TCustomFilter filter);
                     CAutoUploader(CCCM *comission_manager, class="type">class="kw">string mutexName, BotParams &params[]);
   class="kw">virtual           ~CAutoUploader(class="type">void);

常见问题

先确认优化日期拆分逻辑是否把自动前行窗和历史窗接错,再检查自定义系数写入回测报告的结构是否被旧字段覆盖,这两处最易引发异常退出。
文中未给固定值,建议先用样本外切分衔接生命周期,历史窗覆盖近期一个完整波动周期即可,再按品种波动微调。
可以,小布能按重写的自动优化逻辑替你调度前行窗与历史窗,并在回测报告上挂自定义系数后直接高亮偏离项,省去手动翻报告。
生效,文中是把系数挂载到已有报告结构而非重建,报告生成路径未变,自定义系数附加后仍保持原提速效果。
会出现优化窗重复计算或漏算,表现为回测结果跳变、同一日期被多次优化,需在切分处加生命周期边界判断修复。