神经网络变得简单(第 68 部分):离线优先引导政策优化·综合运用
轨迹概率重加权与训练采样逻辑
下面这段 MQL5 片段做两件事:先把奖励向量按收益分布做非线性重标定,再在 Train 里用概率向量抽样两条轨迹做配对比较。外汇与贵金属行情高波动,这类重加权只改变样本倾向,不保证任何实盘收益。 奖励重标定里,min 取收益最小值再减 0.1 倍标准差;当最大利润大于该阈值时才进入乘子计算,否则整条 rewards 填 1。Percentile(90) 减 max_profit 得到偏移 k,用绝对值差除以 k(k 为 0 时退化用 -std)再取 exp,形成对极端盈利的放大权重。 Train 函数先拿 GetProbTrajectories 出的概率向量,循环上限由 Iterations 控制。每次抽 tr_p 与 tr_m 两条轨迹,若二者相同则重抽 tr_m,保证配对不同;利润低的换到 tr_m 位。正样本起点 i 用 MathRand 平方分布偏左采样,上限夹在 Total-2*HistoryBars-NBarInPattern 与 20 之间,i 为负就回退本次迭代。 状态回填时,History 数据来自 Buffer[tr_p].States[state].state,账户描述用上一 state 的 balance/equity 做差分比例。注意 PrevBalance 为 0 会导致除零,实盘前应在 MT5 里加保护或确认首态 account[0] 非零。
class="type">class="kw">double min = rewards.Min() - class="num">0.1 * std; if(max_profit > min) { class="type">class="kw">double k = sorted.Percentile(class="num">90) - max_profit; vector<class="type">class="kw">double> multipl = MathAbs(rewards - max_profit) / (k == class="num">0 ? -std : k); multipl = exp(multipl); rewards = (rewards - min) / (max_profit - min); rewards = rewards / (rewards + lanbda) * multipl; rewards.ReplaceNan(class="num">0); } else rewards.Fill(class="num">1); rewards = rewards / rewards.Sum(); rewards = rewards.CumSum(); class=class="str">"cmt">//--- class="kw">return rewards; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Train function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void Train(class="type">void) { vector<class="type">class="kw">double> probability = GetProbTrajectories(Buffer, class="num">0.1f); class="type">uint ticks = GetTickCount(); class="type">bool StopFlag = false; for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { class="type">int tr_p = SampleTrajectory(probability); class="type">int tr_m = SampleTrajectory(probability); while(tr_p == tr_m) tr_m = SampleTrajectory(probability); if(Buffer[tr_p].Profit < Buffer[tr_m].Profit) { class="type">int t = tr_p; tr_p = tr_m; tr_m = t; } class=class="str">"cmt">//--- Positive class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * MathMax(Buffer[tr_p].Total - class="num">2 * HistoryBars - NBarInPattern, MathMin(Buffer[tr_p].Total, class="num">20)))); if(i < class="num">0) { iter--; class="kw">continue; } Scheduler.Clear(); for(class="type">int state = i; state < MathMin(Buffer[tr_p].Total - class="num">1 - NBarInPattern, i + HistoryBars * class="num">2); state++) { class=class="str">"cmt">//--- History data State.AssignArray(Buffer[tr_p].States[state].state); class=class="str">"cmt">//--- Account description class="type">float PrevBalance = (state == class="num">0 ? Buffer[tr_p].States[state].account[class="num">0] : Buffer[tr_p].States[state - class="num">1].account[class="num">0]); class="type">float PrevEquity = (state == class="num">0 ? Buffer[tr_p].States[state].account[class="num">1] : Buffer[tr_p].States[state - class="num">1].account[class="num">1]); State.Add((Buffer[tr_p].States[state].account[class="num">0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr_p].States[state].account[class="num">1] / PrevBalance);
◍ 把账户状态塞进神经网络前的特征拼装
这段逻辑干的事,是把每个交易状态(state)里的账户维度逐个灌进特征向量 State,再丢给调度器做前向推理和反向修正。前 6 个特征分别是权益变动率(相对 PrevEquity)、三个绝对账户量,以及三个相对 PrevBalance 的占比,归一化口径不统一,实盘里要留意分母取错会导致特征尺度爆炸。 时间标签那块用了年/月/周/日四个周期做正弦余弦编码:以 2024.01.01 减 2023.01.01 的秒数当年度基准,再分别除以 MN1、W1、D1 的 PeriodSeconds 取模。x 越小说明该 state 距周期原点越近,三角函数输出越接近 0,模型对“月初效应”可能更敏感。 state>0 时把上一状态的动作向量追加进来,否则填 NActions 个零——这一步让网络能看到“上一步干了啥”。feedForward 若返回失败直接置 StopFlag 并 break,backProp 同理,说明训练循环对外层容错要求极高,任一层崩了整轮就停。 有个隐蔽点是 if(GetTickCount()-ticks>500) 的耗时判定,意味着单轮训练超过 500 毫秒就会触发打印进度,外汇与贵金属行情跳变快,这种阻塞式训练在实盘 EA 里可能拖慢 tick 响应,属于高风险设计。
State.Add((Buffer[tr_p].States[state].account[class="num">1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr_p].States[state].account[class="num">2]); State.Add(Buffer[tr_p].States[state].account[class="num">3]); State.Add(Buffer[tr_p].States[state].account[class="num">4] / PrevBalance); State.Add(Buffer[tr_p].States[state].account[class="num">5] / PrevBalance); State.Add(Buffer[tr_p].States[state].account[class="num">6] / PrevBalance); class=class="str">"cmt">//--- Time label class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr_p].States[state].account[class="num">7] / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr_p].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); State.Add((class="type">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr_p].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr_p].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); class=class="str">"cmt">//--- Prev action if(state > class="num">0) State.AddArray(Buffer[tr_p].States[state - class="num">1].action); else State.AddArray(vector<class="type">float>::Zeros(NActions)); class=class="str">"cmt">//--- Feed Forward if(!Scheduler.feedForward(GetPointer(State), class="num">1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } class=class="str">"cmt">//--- Study Result.AssignArray(Buffer[tr_p].States[state].scheduler); if(!Scheduler.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } if(GetTickCount() - ticks > class="num">500) { class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Scheeduler",
「负样本里的状态特征拼装」
在生成对抗训练用的负样本时,代码先用双重 MathRand() 平方归一化来挑起点 i,分母取 32767 的平方,把随机落点压到靠近 0 的区域,避免均匀撒网。若算出的 i 小于 0 就直接 iter-- 并 continue,相当于这次迭代作废,不计入总轮次。 随后对每个 state 做特征向量拼装:先把历史状态数组 AssignArray 进去,再算账户维度的相对变化——比如 (当前余额-前态余额)/前态余额、权益/余额、权益变化率等 7 个 float 特征,全部除以 PrevBalance 做尺度对齐。这套输入对外汇与贵金属品种同样适用,但这类杠杆交易波动剧烈,特征失真可能导致模型误判,实盘前务必小资金验证。 时间标签部分用账户时间戳分别除以 2023 全年秒数、月线/周线/日线周期秒数,再套 2π 的正弦余弦,把绝对时间折成周期相位。这样网络能隐式吃到月度、周度、日内的节律,而不需要显式日期字段。 把这段直接贴进你的 EA 训练循环,改一下 HistoryBars 与 NBarInPattern,就能在 MT5 里跑出带周期编码的负样本状态缓冲。
i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * MathMax(Buffer[tr_m].Total - class="num">2 * HistoryBars - NBarInPattern, MathMin(Buffer[tr_m].Total, class="num">20))); if(i < class="num">0) { iter--; class="kw">continue; } Scheduler.Clear(); for(class="type">int state = i; state < MathMin(Buffer[tr_m].Total - class="num">1 - NBarInPattern, i + HistoryBars * class="num">2); state++) { class=class="str">"cmt">//--- History data State.AssignArray(Buffer[tr_m].States[state].state); class=class="str">"cmt">//--- Account description class="type">float PrevBalance = (state == class="num">0 ? Buffer[tr_m].States[state].account[class="num">0] : Buffer[tr_m].States[state - class="num">1].account[class="num">0]); class="type">float PrevEquity = (state == class="num">0 ? Buffer[tr_m].States[state].account[class="num">1] : Buffer[tr_m].States[state - class="num">1].account[class="num">1]); State.Add((Buffer[tr_m].States[state].account[class="num">0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr_m].States[state].account[class="num">1] / PrevBalance); State.Add((Buffer[tr_m].States[state].account[class="num">1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr_m].States[state].account[class="num">2]); State.Add(Buffer[tr_m].States[state].account[class="num">3]); State.Add(Buffer[tr_m].States[state].account[class="num">4] / PrevBalance); State.Add(Buffer[tr_m].States[state].account[class="num">5] / PrevBalance); State.Add(Buffer[tr_m].States[state].account[class="num">6] / PrevBalance); class=class="str">"cmt">//--- Time label class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr_m].States[state].account[class="num">7] / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr_m].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); State.Add((class="type">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr_m].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr_m].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x));
强化学习里的状态回填与误差回传
这段逻辑把上一笔动作和当前状态拼成输入向量,再喂给调度器做前向推理。若 state 大于 0,就取前一个状态的 action 补进 State 数组;否则填一串长度为 NActions 的零向量,避免网络在初始步收到脏数据。 前向一旦失败立刻打印函数名加行号、置 StopFlag 并 break,说明这套训练循环对异常零容忍,实盘里若乱改缓冲区结构可能直接停训。 回传阶段先看前后两步 profit 是否相等:相等就直接把目标设成该状态已有 scheduler 权重;不等则用 forecast 与原有 target 做折半修正(target = forecast - (target - forecast)/2),相当于把误差砍掉一半再反传。 每累计 500 毫秒 tick 就在图表用 Comment 刷一次平均误差,例如跑 1000 代时进度到 50.00% 那行会显示 Error 0.0xxxxxx,方便你肉眼盯收敛。训练结束调 ExpertRemove 自卸,不占后续资源。 别把收敛曲线当圣杯 外汇与贵金属杠杆高、滑点凶,这套 scheduler 误差降到 0.001 级别也只代表回测轨迹拟合好,实盘泛化概率仍要你自己的样本外验证。
class=class="str">"cmt">//--- Prev action if(state > class="num">0) State.AddArray(Buffer[tr_m].States[state - class="num">1].action); else State.AddArray(vector<class="type">float>::Zeros(NActions)); class=class="str">"cmt">//--- Feed Forward if(!Scheduler.feedForward(GetPointer(State), class="num">1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } class=class="str">"cmt">//--- Study if(Buffer[tr_p].Profit == Buffer[tr_m].Profit) Result.AssignArray(Buffer[tr_m].States[state].scheduler); else { vector<class="type">float> target, forecast; target.Assign(Buffer[tr_m].States[state].scheduler); Scheduler.getResults(forecast); target = forecast - (target - forecast) / class="num">2; Result.AssignArray(target); } if(!Scheduler.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } if(GetTickCount() - ticks > class="num">500) { class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Scheeduler", (iter + class="num">0.5) * class="num">100.0 / (class="type">class="kw">double)(Iterations), Scheduler.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } } Comment(""); class=class="str">"cmt">//--- PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Scheduler", Scheduler.getRecentAverageError()); ExpertRemove(); class=class="str">"cmt">//--- } vector<class="type">class="kw">double> probability = GetProbTrajectories(Buffer, class="num">0.1f); class="type">uint ticks = GetTickCount(); class="type">bool StopFlag = false; for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++) { class="type">int tr = SampleTrajectory(probability);
◍ 用双重随机与周期编码喂给 Agent 的历史切片
这段逻辑在强化学习回测里负责造一个随机起点 i,再从 Buffer 里抠出一段历史状态喂给 Agent。i 由两次 MathRand() 相乘再除以 32767 的平方归一化,乘以可用长度与下限 20 的较小值,等于把取样窗口压在可控范围,避免越界读脏数据。 如果算出的 i 为负,iter 自减并 continue 跳过本轮,相当于丢弃这次无效抽样。随后 Agent.Clear() 清空上一轮记忆,进入以 state 为游标的 for 循环,上限取 Buffer 总量减模式长度与 i 加两倍 HistoryBars 的较小值。 循环体内先把当前 K 线状态数组赋给 State,再用上一 state 的余额权益算相对变化率——比如 (account[0]-PrevBalance)/PrevBalance 这种归一化差值,能削弱绝对资金量对策略的干扰。 时间特征用了年、月、周、日四个周期的正弦余弦编码:以 2024.01.01 减 2023.01.01 的秒数做年周期分母,再分别用 MN1/W1/D1 的 PeriodSeconds 做月周日周期分母,喂入 sin/cos 让模型隐式记季节律。外汇与贵金属这类高杠杆品种,历史分布漂移快,这种编码也只代表过去样本的概率倾向,实盘仍需警惕过拟合。 最后处理上一动作:state>0 就取前一状态 action 数组,否则补 NActions 个零向量,保证首帧输入维度对齐。开 MT5 把这段贴进 EA 回测框架,改一下 HistoryBars 或 NBarInPattern,能直接看到抽样密度变化。
class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * MathMax(Buffer[tr].Total - class="num">2 * HistoryBars - NBarInPattern, MathMin(Buffer[tr].Total, class="num">20))); if(i < class="num">0) { iter--; class="kw">continue; } Agent.Clear(); for(class="type">int state = i; state < MathMin(Buffer[tr].Total - class="num">1 - NBarInPattern, i + HistoryBars * class="num">2); state++) { class=class="str">"cmt">//--- History data State.AssignArray(Buffer[tr].States[state].state); class=class="str">"cmt">//--- Account description class="type">float PrevBalance = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">0] : Buffer[tr].States[state - class="num">1].account[class="num">0]); class="type">float PrevEquity = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">1] : Buffer[tr].States[state - class="num">1].account[class="num">1]); State.Add((Buffer[tr].States[state].account[class="num">0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">1] / PrevBalance); State.Add((Buffer[tr].States[state].account[class="num">1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[state].account[class="num">2]); State.Add(Buffer[tr].States[state].account[class="num">3]); State.Add(Buffer[tr].States[state].account[class="num">4] / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">5] / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">6] / PrevBalance); class=class="str">"cmt">//--- Time label class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); State.Add((class="type">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); State.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); class=class="str">"cmt">//--- Prev action if(state > class="num">0) State.AddArray(Buffer[tr].States[state - class="num">1].action); else State.AddArray(vector<class="type">float>::Zeros(NActions));
「训练循环里的调度与误差回显」
这段代码把强化学习 agent 的单轮训练拆成了三段:先灌调度状态,再前向推理,最后做策略回传。任何一步返回失败就置 StopFlag 并 break,避免脏数据继续污染权重。 前向调用 feedForward 的第三个参数写死为 false,意味着本次不触发训练模式下的随机探索,仅做确定性推断;backProp 的第二个目标缓冲传了 NULL,说明标签直接取自 Buffer[tr].States[state].action 数组,属于同态策略回放。 每累计耗时超过 500 毫秒(GetTickCount 差值判定),就把当前迭代进度与 Agent.getRecentAverageError() 通过 Comment 打到图表左上角。这个 500ms 的节流阈值很关键——设太小会拖慢回测,设太大则界面反馈滞后,建议在 MT5 策略测试器里按自己 CPU 单核性能微调。 循环结束后清空 Comment 并打印最终平均误差,随后调用 ExpertRemove() 自卸载。外汇与贵金属市场高风险,这类离线训练 EA 仅适合在历史数据上验证网络收敛性,切勿直接挂实盘。
class=class="str">"cmt">//--- Scheduler State.AddArray(Buffer[tr].States[state].scheduler); class=class="str">"cmt">//--- Feed Forward if(!Agent.feedForward(GetPointer(State), class="num">1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } class=class="str">"cmt">//--- Policy study Result.AssignArray(Buffer[tr].States[state].action); if(!Agent.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } class=class="str">"cmt">//--- if(GetTickCount() - ticks > class="num">500) { class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Agent", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Agent.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } } Comment(""); class=class="str">"cmt">//--- PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError()); ExpertRemove(); class=class="str">"cmt">//--- }
EURUSD H1 上跑通的一次离线策略实测
这套离线优先引导策略优化(OPPO)的实现,训练与测试都钉在 EURUSD 的 H1 周期:用 2023 年前 7 个月历史数据训练,拿 2023 年 8 月数据做样本外测试。因为轨迹保存结构改了,旧工作的样本全废,重新在笔记本上从随机权重跑 500 条轨迹,连续烧了 3 天。 训练拆给 2 个独立智能系统并行跑,并且照例做了训练集迭代筛选。个人观察到一个硬约束:想让 Agent 学到能盈利的行为,训练集里必须有正验算——靠环境探索补采轨迹,或像前文那样直接灌 EA 轨迹、复制信号都行;有正验算后,收敛明显更快。 测出来模型在训练集和测试集都盈利。测试段 EA 共下 180 笔单,盈利平仓占比近 49%,盈亏单数基本对等;但平均盈利比平均亏损高 30%,盈利因子 1.25,余额线虽有急拉急跌,上行趋势还在。外汇/贵金属这类品种杠杆高、回撤凶,1.25 的因子只说明该样本段概率占优,换周期或品种可能直接变脸,开 MT5 用同样数据复一遍最实在。
◍ 把轨迹当整体来评估政策
OPPO 思路里最反直觉的一点,是把整条轨迹看成由单一政策生成的整体,而不是逐笔去算每个动作的贡献。在外汇与贵金属这种奖励稀疏、反馈延迟的环境里,单独给某次开仓贴「好 / 坏」标签往往失真,模型反而容易被噪声带偏。 我们在 MT5 用 MQL5 落了这套方法,和原论文有偏差,但训出的政策在训练段和样本外测试段都出了盈利。这至少说明:不依赖显式奖励函数,也有可能构出能跑的策略原型。 要提醒的是,外汇 / 贵金属杠杆高、回撤快,这类演示程序只验证技术可行性,直接上实盘风险很大。真要用的话,先在策略测试器里把历史段和 walk-forward 段都跑一遍再谈。
「把这条线请下神坛」
整套 LSTM 优化实验落到工程层,就是七个文件在 MT5 里各司其职:Research.mq5 与 StudyAgent.mq5 两只 EA 分别跑样本收集和智能体训练,StudyScheduler.mq5 调度偏好模型,Test.mq5 做闭环验证;Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 与 NeuroNet.cl 则把网络构建和 OpenCL 并行计算下沉到类库与代码库。 随文提供的 MQL5.zip 约 672.66 KB,在 MT5 里解压后直接编译即可复现文中所说的多元时间序列预测流程;但外汇与贵金属杠杆高、滑点跳空频繁,神经网络的样本外表现可能随品种与周期漂移,实盘前务必用 Test.mq5 在至少三年历史数据上跑通回测。 所谓「AI 预测圣杯」从来只是工程链路上的一串参数,把它请下神坛,你才愿意动手改一处学习率、换一组特征,去 MT5 里看曲线自己说话。