preview
Как построить мультивалютную netting-сетку с точным CatBoost и swap/carry-слоем в MQL5

Как построить мультивалютную netting-сетку с точным CatBoost и swap/carry-слоем в MQL5

MetaTrader 5Примеры |
59 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Мультивалютная сетка часто «выглядит нормально» на каждом отдельном графике и одновременно накапливает одну большую скрытую ставку на валюту в netting‑счёте. Проблема не только в прогнозе: модель обучена в Python, но в терминале живёт её пересказ — и даже мелкие изменения порядка баров, rolling‑окна или обработки NaN превращают инференс в другую модель, которая затем управляет тысячами лимитных заявок. К этому добавляются брокерские реалии: суффиксы символов, шаги объёма, ограничения по expiration, разные режимы swap/rollover.

Поэтому задача статьи шире прогноза: нужен единый воспроизводимый контур — общий валютный контекст (28 пар → силы 8 валют), точный байт‑в‑байт инференс в MQL5, защита netting‑инвентаря, адаптивные правила выхода и исполнение, устойчивое к брокер‑специфичным ограничениям. Среда жёстко задана: MetaTrader 5, netting, закрытые M1‑бары и отсутствующий Python‑процесс во время торговли; self‑test и parity‑проверки обязательны до включения торговли.


Почему локальная сетка теряет портфель

Сетка начинает с простой сделки с рынком: она предлагает ликвидность ниже и выше текущей цены, получает исполнения и рассчитывает на возврат котировки к центру. На одном символе риск читается прямо с графика. На семи валютных кроссах терминал уже может обманывать взгляд. Позиции по EURGBP, EURNZD и GBPNZD выглядят разными, но вместе способны превратиться в одну крупную ставку на EUR, GBP или NZD.

Вторая проблема возникает между исследованием и эксплуатацией. Модель обучают в Python, а затем пересказывают терминалу в виде нескольких коэффициентов или упрощённого правила. Достаточно изменить порядок баров, rolling-окно, обработку NaN или знак сравнения в дереве — и в MetaTrader будет работать другая модель. У сетки это особенно болезненно: малое расхождение повторяется на каждом новом баре и влияет на тысячи заявок.

Третья проблема прозаична. Сервер может не поддерживать выбранный тип истечения, символы получают суффиксы, объём округляется по нестандартному шагу, а swap задаётся в пунктах, валюте депозита или как годовой процент. Поэтому задача статьи шире, чем прогноз цены. Нам нужен единый контур: общий рыночный контекст, точный инференс, ограниченный netting-инвентарь, адаптивный выход и исполнение, которое учитывает правила конкретного брокера.

Среда заранее ограничена: MetaTrader 5, netting-счёт, закрытые M1-бары, встроенный MQL5-инференс без Python-процесса во время торговли. Активная треугольная swap-корзина в исходнике существует, но по умолчанию отключена. Безопасный стартовый режим — InpPaperMode=true.


Из чего состоит система

Исходный советник торгует семь кроссов: EURNZD, GBPNZD, NZDCAD, AUDNZD, GBPAUD, EURAUD, EURGBP. Однако контекст строится не по семи, а по всем 28 комбинациям восьми основных валют. Получается важное разделение: исполнять можно узкий портфель, а наблюдать — весь валютный рынок.

Блок Размер Назначение
Рыночные ряды 28 пар закрытые M1-бары, spread и tick volume
Валютный граф 8 валют силы валют, остатки пар и общий разброс
Признаки 19 единый контракт Python и MQL5
CatBoost drift (сдвиг) 260 деревьев ожидаемое краткосрочное направление
CatBoost toxicity (риск неблагоприятного хода) 220 деревьев ожидаемая амплитуда неблагоприятного движения
Исполнение 5x5 Buy Limit и Sell Limit с лимитом инвентаря

Граф здесь не является графовой нейронной сетью. Это линейное валютное разложение, которое переводит 28 наблюдаемых доходностей в состояние восьми валют. Его результаты входят в CatBoost как обычные числовые признаки. Такое уточнение важно: сложность системы возникает не из модного названия, а из строгого контракта между данными, моделью и торговым контуром.

Критерии успешной сборки задаются заранее:

  • на одинаковом векторе признаков Python и MQL5 дают одинаковый прогноз;
  • при неподготовленной истории цикл пропускается;
  • при широком spread или направленном режиме ордера снимаются;
  • при лимите инвентаря новая заявка не увеличивает риск;
  • любой серверный отказ логируется с retcode и причиной.

Эти проверки связывают математическую часть с тем, что разработчик реально увидит в терминале.

На рисунке 1 показана реальная архитектура архива. Модель не отправляет рыночный ордер по одному числу. Drift и toxicity проходят через фильтры режима, сдвигают пассивные котировки и участвуют в оценке закрытия. Параллельно брокерские свойства и состояние счёта ограничивают то, что вообще разрешено исполнить.


Как 28 пар превращаются в валютный граф

Представим минутную доходность пары как разность движений двух валют. Для EURUSD это движение EUR минус движение USD. Для GBPJPY — движение GBP минус движение JPY. Все 28 равенств собираются в матрицу B размером 28 x 8:

r = B s,

где r — доходности пар в базисных пунктах, а s — скрытые силы восьми валют. Абсолютный уровень сил не определён: если прибавить одну константу всем валютам, разности не изменятся. Поэтому фактическая размерность равна семи. В пакет заранее экспортирована псевдообратная матрица B⁺, и терминал рассчитывает:

s = B⁺ r.

После этого для каждой пары восстанавливается объяснимое графом движение. Разность между наблюдаемой доходностью и восстановленной величиной сохраняется как graph_resid_bps. По-трейдерски это ответ на простой вопрос: пара движется вместе со своими валютами или временно выбилась из общего строя?

Функция UpdateGraphCache() использует только два закрытых M1-бара по каждой паре. Если хотя бы один ряд не готов, расчёт прерывается. Такой отказ лучше красивого, но несинхронного графа, собранного из разных минут.

//+------------------------------------------------------------------+
//| Обновляет валютный граф по 28 закрытым M1-рядам                  |
//| Результат: силы валют, остатки пар и общий разброс               |
//+------------------------------------------------------------------+
bool UpdateGraphCache()
  {
   datetime latest=0;
   double r[SHTENCO_GRAPH_PAIR_COUNT];

   //--- читаем одинаковые закрытые M1-бары для всех рёбер графа
   for(int p=0;p<SHTENCO_GRAPH_PAIR_COUNT;p++)
     {
      string s=GraphSymbols[p];
      if(s=="")
         return(false);

      MqlRates bars[];
      ArraySetAsSeries(bars,true);
      if(CopyRates(s,PERIOD_M1,1,2,bars)!=2)
         return(false);

      double pt=SymbolInfoDouble(s,SYMBOL_POINT);
      double m0=MidClose(bars[0],pt);
      double m1=MidClose(bars[1],pt);
      if(m0<=0.0 || m1<=0.0)
         return(false);

      r[p]=MathLog(m0/m1)*10000.0;
      if(p==0)
         latest=bars[0].time;
     }

   //--- повторно используем кэш, если закрытый бар не изменился
   if(latest==GraphBarTime)
      return(true);

   //--- проецируем 28 доходностей в пространство восьми валют
   for(int c=0;c<SHTENCO_CCY_COUNT;c++)
     {
      double v=0.0;
      for(int p=0;p<SHTENCO_GRAPH_PAIR_COUNT;p++)
         v+=r[p]*SHTENCO_GRAPH_PINV[c][p];
      GraphStrength[c]=v;
     }

   //--- измеряем поперечный разброс сил валют
   double avg=0.0;
   for(int c=0;c<SHTENCO_CCY_COUNT;c++)
      avg+=GraphStrength[c];
   avg/=SHTENCO_CCY_COUNT;

   double ss=0.0;
   for(int c=0;c<SHTENCO_CCY_COUNT;c++)
     {
      double z=GraphStrength[c]-avg;
      ss+=z*z;
     }
   GraphDispersion=MathSqrt(ss/SHTENCO_CCY_COUNT);

   //--- сохраняем наблюдаемую доходность и необъяснённый остаток пары
   for(int p=0;p<SHTENCO_GRAPH_PAIR_COUNT;p++)
     {
      GraphReturns[p]=r[p];
      double fair=GraphStrength[SHTENCO_PAIR_BASE[p]]
                 -GraphStrength[SHTENCO_PAIR_QUOTE[p]];
      GraphResidual[p]=r[p]-fair;
     }

   GraphBarTime=latest;
   return(true);
  }

Остаток графа нельзя принимать за готовый арбитражный сигнал. Он может появиться из-за реального дисбаланса, задержки тика, разного spread или тонкой ликвидности кросса. Поэтому исходник использует его как часть набора признаков и как необязательную поправку к центру сетки. По умолчанию InpGraphWeight=0: граф уже присутствует в CatBoost, а дополнительное линейное вмешательство отключено.


Какие данные получает CatBoost

Модель видит 19 признаков. Все они рассчитываются на shift=1, то есть по уже закрытым барам. Это защищает обучение и инференс от скрытого использования текущей незавершённой свечи.

Группа Признаки Что описывают
Доходности ret1, ret3, ret5, ret15 направление на нескольких минутных горизонтах
Риск и положение цены vol10, vol60, z20, range, spread амплитуду, отклонение от среднего и стоимость входа
Поток volume_z, bar_imbalance аномалию тикового объёма и структуру свечи
Режим trend10, trend60, entropy32, flip_rate32 насколько рынок направлен или колебателен
Валютный граф residual, base strength, quote strength, dispersion общий валютный контекст и локальное отклонение пары

trend10 и trend60 измеряют не наклон средней, а эффективность направления: сумму доходностей, делённую на сумму их модулей. Когда рынок движется почти без откатов, показатель приближается к +1 или -1. Для сетки это предупреждение: пассивный инвентарь опасно набирать против движения, которое пока не собирается возвращаться.

entropy32 и flip_rate32 описывают чередование знаков доходности. Высокая энтропия ближе к привычному для сетки двустороннему шуму. Низкая энтропия вместе с большим модулем trend60 означает однонаправленный режим. В исходнике такие состояния блокируют новые котировки ещё до того, как модель успеет убедить советник в наличии эдж (торговое преимущество над случайным рынком).

Функция BuildFeatureVector() читает 66 закрытых баров, строит 65 доходностей, рассчитывает rolling-статистики и добавляет четыре графовых поля. Любое NaN или бесконечность заменяются нулём, потому что экспортированный контракт деревьев должен получать конечный массив фиксированной длины.


Как перенести деревья без упрощения

Две модели обучены на 336 000 строках реальных M1-данных по 28 парам. Первые 60% общей временной оси использованы для обучения, следующие 20% образуют validation-отрезок, последние 20% — OOS. Drift-модель содержит 260 симметричных деревьев глубины 7. Toxicity-модель — 220 деревьев той же глубины.

Цели у моделей разные, и смешивать их нельзя. target_ret_bps отвечает за ожидаемое знаковое движение и используется как drift. target_abs_bps описывает ожидаемый модуль движения и после инференса ограничивается снизу нулём. Первая модель помогает выбрать асимметрию котировок, вторая отвечает на вопрос, насколько опасно вообще оставлять пассивные заявки в рынке. Поэтому отрицательная toxicity не имеет экономического смысла, а большой положительный drift ещё не отменяет фильтр стоимости исполнения.

Главное инженерное решение — не пересказывать CatBoost приблизительно. JSON каждой модели разбирается на массивы глубин, индексов признаков, порогов и значений листьев. Они записываются в SHTENCO_V13_EXACT_MODELS.mqh. В терминале дерево проходит те же условия feature > border и выбирает тот же лист, что исходная библиотека.

//+------------------------------------------------------------------+
//| Выполняет точный инференс экспортированной drift-модели          |
//| Вход: 19 признаков в том же порядке, что при обучении            |
//+------------------------------------------------------------------+
double ShtencoEvalDrift(const double &features[])
  {
   //--- модель не запускается при нарушении контракта признаков
   if(ArraySize(features)<SHTENCO_FEATURE_COUNT)
      return(0.0);

   double result=DRIFT_BIAS;

   //--- последовательно обходим все симметричные деревья CatBoost
   for(int tree=0;tree<DRIFT_TREE_COUNT;tree++)
     {
      int leaf_index=0;
      int split_start=DRIFT_SPLIT_OFFSETS[tree];
      int depth=DRIFT_TREE_DEPTHS[tree];

      //--- собираем индекс листа из решений feature > border
      for(int d=0;d<depth;d++)
        {
         int p=split_start+d;
         int fi=DRIFT_SPLIT_FEATURES[p];
         if(features[fi]>DRIFT_SPLIT_BORDERS[p])
            leaf_index|=(1<<d);
        }

      //--- добавляем значение выбранного листа к общей сумме
      result+=DRIFT_LEAF_VALUES[DRIFT_LEAF_OFFSETS[tree]+leaf_index];
     }

   return(DRIFT_SCALE*result);
  }

На 8 400 validation/OOS-строках максимальное расхождение drift составило 8.88e-16, toxicity — 4.88e-15. Это уровень машинной погрешности double, а не практическая разница модели.

Отдельно проверены признаки. На 210 синхронизированных строках глобальный максимум ошибки равен 1.30e-6, средняя абсолютная ошибка — 1.46e-9. Самое заметное отклонение появилось в rolling z-score из-за порядка суммирования. Все четыре графовых признака совпали с ошибкой ниже 8e-16.

В архиве есть отдельный скрипт SHTENCO_V13_MODEL_SELFTEST.mq5. Он подаёт фиксированный вектор из 19 значений и сравнивает два прогноза с эталонами. Перед demo-запуском журнал должен вывести V13 MODEL SELFTEST: PASS. Такой тест отделяет ошибку переноса модели от ошибок торговой логики и брокерского исполнения.


Как прогноз управляет 5x5-сеткой

На каждом выбранном символе советник размещает до пяти Buy Limit и пяти Sell Limit. Объём уровня начинается с InpBaseLots=0.01 и растёт по множителю 1.10. Если шаг брокерского объёма делает два соседних уровня одинаковыми, функция LevelLots() пытается поднять следующий уровень ещё на один SYMBOL_VOLUME_STEP.

Netting меняет саму логику лимита. В hedging можно считать отдельные тикеты, а здесь все исполнения по символу превращаются в одну знаковую позицию. Поэтому допустимый объём новой заявки рассчитывается через будущий инвентарь:

buy lots <= maxInventory - netLots,

sell lots <= maxInventory + netLots.

Перед котированием система проверяет spread, entropy, trend и прогноз toxicity. Динамический предел toxicity растёт вместе со spread, базовым отступом и текущей волатильностью. Это разумнее одной константы: одинаковая амплитуда может быть аномальной ночью и обычной на активной сессии.

//+------------------------------------------------------------------+
//| Фрагмент PlaceGrid(): фильтр режима и смещение центра сетки      |
//+------------------------------------------------------------------+
//--- рассчитываем стоимость исполнения и динамический toxicity-лимит
double spreadBps=(tick.ask-tick.bid)/mid*10000.0;
double entropy=PredFeatures[idx][13];
double trend=PredFeatures[idx][12];
double vol10=PredFeatures[idx][4];
double toxLimit=InpToxicityFloorBps
                +InpToxicityRatio*(spreadBps
                +2.0*(InpBaseOffsetBps+InpGridVolMult*vol10));

//--- снимаем заявки, если рынок стал слишком дорогим или направленным
if(spreadBps>InpMaxSpreadBps
   || entropy<InpMinEntropy
   || MathAbs(trend)>InpMaxTrend
   || PredToxicity[idx]>toxLimit)
  {
   CancelOurOrders(s);
   return;
  }

//--- объединяем прогноз, остаток графа, инвентарь и carry-смещение
double inv=NetLots(s);
double maxInv=MaxInventoryLots(s);
double carry=InpEnableSwapBias?CarryDirectionForIndex(idx):0.0;
double shiftBps=InpAlphaWeight*PredDrift[idx]
                -InpGraphWeight*PredFeatures[idx][15]
                -InpInventorySkewBps*(inv/MathMax(InpBaseLots,1e-8))
                +InpSwapSkewBps*carry;

Drift не превращается в рыночную кнопку Buy. Он слегка смещает центр пассивной сетки. Противоположное влияние оказывает уже накопленный инвентарь: если позиция стала слишком длинной, центр сдвигается так, чтобы новые продажи были ближе, а покупки — менее агрессивны. Carry-направление добавляет небольшую асимметрию в пользу стороны с лучшим текущим swap.

Расстояние каждого уровня увеличивается с номером уровня и vol10. Перед отправкой цены дополнительно подчиняются SYMBOL_TRADE_STOPS_LEVEL и SYMBOL_TRADE_FREEZE_LEVEL. Сетка получается не статичной лестницей, а котировочным контуром, который учитывает режим, прогноз, инвентарь и ограничения сервера.


Почему закрытием занимается отдельный комитет

Фиксированный Take Profit плохо описывает задачу сетки. Одна позиция может быть уже прибыльной, но всё ещё поддерживаться положительным drift и carry. Другая почти не потеряла деньги, однако рынок стал однонаправленным, toxicity выросла, а до отрицательного rollover осталось несколько минут. Исходник сводит эти причины в общий score.

В оценку входят текущий PnL в базисных пунктах, неблагоприятный drift, знак графового остатка, отношение toxicity к торговому диапазону, entropy, trend, возраст позиции и доля выбранного лимита инвентаря. Затем swap-агент добавляет ещё два решения: отрицательный прогнозируемый swap перед rollover ускоряет выход, положительный carry уменьшает желание закрываться при допустимом убытке.

//+------------------------------------------------------------------+
//| Фрагмент AdaptiveClose(): расчёт итоговой оценки выхода          |
//+------------------------------------------------------------------+
double score=0.0;

//--- прибыль и убыток задают базовый приоритет закрытия
if(pnlBps>=InpTakeProfitBps)
   score+=1.25+MathMin(1.0,(pnlBps-InpTakeProfitBps)
                       /MathMax(InpTakeProfitBps,0.25));
if(pnlBps<=-InpStopLossBps)
   score+=3.0+MathMin(2.0,(-pnlBps-InpStopLossBps)
                      /MathMax(InpStopLossBps,0.25));

//--- неблагоприятные drift, графовый остаток и toxicity усиливают выход
score+=MathMin(1.5,alphaAdv/MathMax(InpAlphaCloseBps,0.05));
score+=MathMin(1.25,graphAdv/MathMax(InpGraphCloseBps,0.05));
if(toxRatio>=InpCloseToxicityRatio)
   score+=MathMin(1.5,toxRatio/MathMax(InpCloseToxicityRatio,0.1));

//--- направленный режим, возраст и заполненный инвентарь повышают риск
if(PredFeatures[idx][13]<InpMinEntropy
   || MathAbs(PredFeatures[idx][12])>InpMaxTrend)
   score+=0.75;
score+=MathMin(1.25,age/MathMax((double)InpSoftAgeMinutes,1.0))
       +0.75*invRatio;

//--- отрицательный swap перед rollover ускоряет закрытие,
//--- положительный carry позволяет удерживать позицию дольше
if(InpEnableSwapBias)
  {
   if(projected<0.0 && toRoll<=InpNegativeSwapExitMinutesBefore)
      score+=InpNegativeSwapCloseScore;
   if(projected>0.0 && pnlBps>-InpStopLossBps)
      score-=InpPositiveSwapHoldBonus;
  }

При score >= 2.50 советник закрывает около трети позиции. При score >= 4.50 или достижении жёсткого возраста 1440 минут позиция закрывается полностью. Частичное закрытие здесь не декоративная функция: в price-only OOS-симуляции было 24 288 адаптивных частичных закрытий и 174 полных.

Есть важная практическая деталь. PosSince хранится только в оперативной памяти. После перезапуска терминала возраст существующей позиции начинается заново с текущего серверного времени. Для production-версии этот момент стоит исправить: брать POSITION_TIME либо сохранять состояние через Global Variables или файл.

Дневной лимит также нужно читать буквально. При потере InpDailyLossLimitPct советник снимает отложенные ордера, но не ликвидирует открытые позиции. Это ограничитель дальнейшего набора риска, а не полноценный equity kill-switch.


Как устроен swap/carry-слой

Swap в MetaTrader 5 нельзя считать одной формулой. Брокер может хранить его в пунктах, валюте депозита, валюте базового актива, валюте маржи или как годовую процентную ставку. Функция EstimateSwapMoneyPerLotDay() читает SYMBOL_SWAP_MODE и переводит результат в валюту счёта.

Для режима POINTS используется OrderCalcProfit(): код строит виртуальное изменение цены на величину swap и получает денежный результат одного лота. Для CURRENCY_DEPOSIT значение уже готово. Для CURRENCY_SYMBOL и CURRENCY_MARGIN сумма конвертируется через прямую или обратную валютную пару. Процентные режимы рассчитываются от контракта и цены по банковской базе 360 дней.

Отдельно учитывается SYMBOL_SWAP_ROLLOVER3DAYS. В тройной день ожидаемая стоимость умножается на три. Советник сравнивает long и short swap, формирует carryDirection от -1 до +1 и использует его в трёх местах: сдвигает центр сетки, немного меняет объёмы buy/sell уровней и корректирует score закрытия.

В архиве реализован поиск валютно-нейтральной треугольной корзины вида AB + BC - AC. Для каждой ориентации код согласует номиналы через размеры контрактов, суммирует ожидаемый swap и вычитает оценку round-trip spread cost. Наиболее выгодная корзина может быть открыта перед rollover и закрыта после него.

Однако InpEnableActiveSwapBasket=false — правильный дефолт. Исторические CSV не содержат изменения брокерских swap-спецификаций во времени. Поэтому price-only OOS ничего не доказывает о прибыльности carry-арбитража. Активную корзину можно оценивать только на demo у конкретного брокера, сверяя фактический POSITION_SWAP с расчётом.


Как пережить брокерские ограничения

Самая неприятная ошибка отложенной заявки часто не имеет отношения к стратегии. Сервер отвечает invalid expiration, потому что символ не разрешает ORDER_TIME_SPECIFIED или считает указанное время слишком близким. В исходнике этот риск вынесен в отдельный контур.

//+------------------------------------------------------------------+
//| Выбирает поддерживаемый брокером тип истечения заявки            |
//| При отказе последовательно переходит к безопасным режимам        |
//+------------------------------------------------------------------+
void SelectExpiration(const string s,
                      ENUM_ORDER_TYPE_TIME &type_time,
                      datetime &expiration,
                      const int attempt=0)
  {
   expiration=0;

   //--- GTC предпочтителен: советник сам снимает и переставляет заявки
   if(attempt<=0 && ExpirationAllowed(s,SYMBOL_EXPIRATION_GTC))
     {
      type_time=ORDER_TIME_GTC;
      return;
     }

   //--- DAY используется как первый серверный резервный режим
   if(attempt<=1 && ExpirationAllowed(s,SYMBOL_EXPIRATION_DAY))
     {
      type_time=ORDER_TIME_DAY;
      return;
     }

   //--- SPECIFIED задаёт срок, превышающий возраст ручного requote
   if(ExpirationAllowed(s,SYMBOL_EXPIRATION_SPECIFIED))
     {
      type_time=ORDER_TIME_SPECIFIED;
      datetime base=MathMax(TimeTradeServer(),
                            (datetime)SymbolInfoInteger(s,SYMBOL_TIME));
      expiration=base+MathMax(InpMinSpecifiedExpirySeconds,
                              InpOrderAgeSeconds+60);
      return;
     }

   //--- SPECIFIED_DAY переносит истечение на конец серверного дня
   if(ExpirationAllowed(s,SYMBOL_EXPIRATION_SPECIFIED_DAY))
     {
      type_time=ORDER_TIME_SPECIFIED_DAY;
      MqlDateTime dt;
      TimeToStruct(TimeTradeServer(),dt);
      dt.hour=23;
      dt.min=59;
      dt.sec=59;
      expiration=StructToTime(dt);
      return;
     }

   //--- защитный режим для серверов с нестандартной спецификацией
   type_time=ORDER_TIME_GTC;
   expiration=0;
  }

Сначала выбирается GTC, потому что советник сам снимает и переставляет заявки каждые несколько секунд. Затем пробуется DAY. SPECIFIED используется только при наличии соответствующего флага и с минимальным временем жизни не меньше 300 секунд. PlaceLimitRobust() повторяет запрос только при ошибке формата expiration; ошибки цены, объёма или маржи не превращаются в бесконечный спам серверу.

Перед запуском ResolveBrokerSymbol() ищет каноническое имя и варианты с суффиксами. Это помогает на символах вроде EURUSD.a, но поиск через StringFind() остаётся эвристикой: при необычных наборах символов возможен неоднозначный матч. Для production лучше сравнивать первые шесть букв после нормализации имени.

Советник жёстко требует netting или exchange margin mode. Объём нормализуется по минимуму, максимуму и шагу символа. Для торговых запросов вызываются SetTypeFillingBySymbol() и заданный magic number. В paper-режиме решения выводятся в журнал, но реальные заявки и сделки не отправляются.

Это хороший пример того, почему статья о торговой модели не должна заканчиваться на формуле прогноза. Эдж (торговое преимущество над случайным рынком) существует только после округления объёма, проверки stops level, выбора expiration, учёта spread и получения успешного retcode.


Жизненный цикл советника

OnInit() проверяет тип счёта, разбирает список выбранных символов, разрешает брокерские имена и отдельно загружает все 28 пар валютного графа. Если хотя бы одна обязательная пара недоступна, инициализация завершается ошибкой. Это строгий режим: модель предпочитает не работать, чем строить неполный граф.

После подготовки массивов советник фиксирует начальную equity дня, обновляет граф, рассчитывает первые прогнозы, ищет лучшую swap-треугольную комбинацию и запускает секундный таймер. Торговая логика находится в OnTimer(), а не зависит от частоты тиков одного графика.

Каждую секунду выполняется следующая цепочка:

  1. проверяется смена календарного дня;
  2. при необходимости обновляется лучшая carry-корзина;
  3. обслуживается активная swap-корзина;
  4. считается дневная потеря equity;
  5. для каждого выбранного символа выполняется AdaptiveClose;
  6. раз в InpRequoteSeconds перестраивается сетка.

При деинициализации таймер отключается, а все отложенные ордера советника снимаются. OnTradeTransaction() записывает новые сделки в журнал. Сейчас этот обработчик не восстанавливает возраст позиции и не ведёт постоянную статистику исполнения — это естественное направление следующей доработки.

Практический порядок запуска должен быть консервативным. Сначала компилируются .mq5 и .mqh в одной папке. Затем запускается self-test. После PASS советник работает в paper-режиме на netting demo-счёте с полным набором 28 символов в Market Watch. Только после проверки журналов, expiration и фактического swap можно отключать InpPaperMode. Активная swap-корзина остаётся выключенной до отдельного forward-теста.


Что показали проверки и OOS

Архив содержит три разных уровня проверки, и их нельзя смешивать. Первый — точность модели: CatBoost и плоский MQH-инференс совпали почти до последнего бита double. Второй — точность признаков: Python и MQL5-формулы проверены на синхронизированных строках. Третий — price-only OOS-симуляция сетки на периоде с 26 июня по 24 июля 2026 года.

Метрика Значение Комментарий
Начальный баланс 10 000 USD price-only исследовательский контур
Финальный баланс 10 148.61 USD до исторического swap
Доходность 1.4861% около 29 дней OOS
Максимальная просадка 0.0230% по агрегированной кривой
Profit Factor 2.9824 по bar-PnL симулятора
Исполнения 28 926 модель исполнений по барам M1
Stress return 1.2683% при повышенных издержках

Эти графики построены по приложенному CSV и отражают только исследовательскую симуляцию. Это не скриншот Strategy Tester и не доказательство live-прибыльности. Модель исполнения использует M1 OHLC, а исторические swap-спецификации отсутствуют. Очень высокий daily Sharpe из JSON также нельзя переносить в реальную торговлю: период короткий, кривая сглажена, а тысячи модельных fills делают оценку чувствительной к устройству симулятора.

Число 28 926 означает модельные исполнения уровней, а не 28 926 независимых торговых идей. Один инвентарный цикл может включать несколько входов, частичные разгрузки и финальное закрытие. В отчёте зафиксировано 502 цикла, 7 165 buy fills и 21 761 sell fills. Такая асимметрия требует отдельного разбора по символам: агрегированная прибыль не показывает, не возникла ли зависимость результата от одной стороны рынка или нескольких конкретных кроссов. Для публикационного теста полезно дополнить архив таблицей по каждой паре: fills, чистый PnL, максимальный инвентарь и доля принудительных закрытий.

Сильный результат архива — не сама доходность, а воспроизводимость вычислений. Мы точно знаем, что терминал получает те же 19 признаков и проходит те же деревья. Теперь ошибки demo-теста можно искать в исполнении, рыночном режиме и настройках риска, а не в неизвестном расхождении Python и MQL5.


Что получает разработчик и где границы метода

После реализации разработчик получает законченный исследовательский контур: валютный граф из 28 пар, два точных оценщика CatBoost, 5x5 netting-сетку, ограничение инвентаря, адаптивное частичное закрытие, обработку всех основных swap-режимов и устойчивый выбор expiration. Система запускается одним советником и одним файлом весов, а отдельный self-test проверяет модель до подключения торговли.

При этом несколько ограничений остаются принципиальными:

  • нативный MetaEditor compile-log в архиве отсутствует; источник прошёл статические проверки, но его нужно скомпилировать локально;
  • price-only OOS не содержит исторического swap и не подтверждает carry-доходность;
  • OHLC-модель не воспроизводит реальную очередь лимитных заявок, partial fills и latency;
  • возраст позиции сбрасывается после рестарта;
  • дневной loss limit снимает заявки, но не закрывает инвентарь;
  • активная swap-корзина требует отдельного demo-forward;
  • поиск символов с суффиксом может быть неоднозначным;
  • полный граф требует доступности и синхронизации всех 28 M1-рядов.

Следующий практический шаг — не добавлять ещё одну модель, а укрепить исполнение: сохранить runtime-состояние, реализовать equity kill-switch, записывать фактические retcode и slippage, сравнивать ожидаемый и начисленный swap, а затем провести мультивалютный forward-тест у конкретного брокера.

Главная идея системы остаётся простой. Отдельная валютная пара редко рассказывает всю историю. Граф даёт рынку общий контекст, CatBoost переводит его в два числовых прогноза, а netting-контур решает, сколько риска действительно можно поставить. Именно разделение прогноза и исполнения превращает набор моделей в инженерную торговую систему.

Заключение

Реализация даёт практический, воспроизводимый исследовательский контур: 28‑парный валютный граф для контекста, два CatBoost‑оценщика (drift и toxicity), экспортированные в MQL5 деревья с точным инференсом и self‑test, 5×5 netting‑сетка с ограничением инвентаря, комитет адаптивного закрытия и слой обработки swap/carry + выбор expiration, учитывающий брокерские ограничения. В комплекте — скрипт самопроверки, parity‑отчёты по признакам и моделям, MQH‑файл с весами и основной mq5‑советник — то, что нужно, чтобы воспроизвести прогнозы и отделить ошибки модели от ошибок исполнения.

При этом границы метода чётко очерчены: price‑only OOS не содержит исторического swap и не доказывает carry‑прибыль; OHLC‑симуляция не моделирует очередь лимиток, частичные филлы и задержки; возраст позиций сбрасывается после рестарта; дневной лимит отменяет ордера, но не является equity‑kill‑switch. Практические следующие шаги — сохранить runtime‑состояние, реализовать equity kill‑switch, логировать retcode/slippage и сверять ожидаемый и начисленный swap, затем выполнить мультивалютный forward‑demo у конкретного брокера. Ключевая идея остаётся простой: разделение точного прогноза и надёжного исполнения превращает набор моделей в инженерную торговую систему.

Приложенные файлы

Файл Назначение
SHTENCO_V13_NETTING_GRID_SWAP_AGI.mq5 основной netting-советник
SHTENCO_V13_EXACT_MODELS.mqh точные деревья CatBoost и матрица валютного графа
SHTENCO_V13_MODEL_SELFTEST.mq5 проверка эталонного вектора модели
MODEL_PARITY_REPORT.json аудит совпадения CatBoost и MQH
FEATURE_PARITY_REPORT.json аудит совпадения 19 признаков
V13_PRICE_ONLY_BACKTEST.json метрики исследовательского OOS
Прикрепленные файлы |
MQL5.zip (384.48 KB)
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Кооперация бионических алгоритмов — Co-Operation of Biology Related Algorithms (COBRA) Кооперация бионических алгоритмов — Co-Operation of Biology Related Algorithms (COBRA)
В статье реализована кооперативная метаэвристика COBRA во фреймворке C_AO: шесть популяционных методов (PSO, WPS, FFA, CSA, BA, FSS) работают в одной популяции при фиксированном бюджете обращений к целевой функции и экспериментально выясняем, на чём на самом деле держится кооперация. В комплекте — расширяемый класс для сборки собственных кооперативных портфелей из любых алгоритмов серии.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Моделирование рынка: Position View (X) Моделирование рынка: Position View (X)
Нам необходим способ обработки создаваемых графических объектов. Предложение, представленное в предыдущей статье, отлично подходит для некоторых сценариев. В данном случае нам потребуется нечто более сложное, учитывая специфику рассматриваемой проблемы. Поэтому мы не будем пытаться заменить существующие в MetaTrader 5 механизмы управления ZOrder и, конечно же, проверять, какой объект находится на переднем плане или скрыт другим объектом. Мы сделаем нечто совершенно другое. Здесь я покажу, какие изменения необходимо внести в код, чтобы задействовать часть того, что MetaTrader 5 уже делает за нас.