preview
Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (Окончание)

Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (Окончание)

MetaTrader 5Торговые системы |
41 0
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

Торговое решение редко сводится к прогнозу направления цены на следующем баре. Даже если движение определено верно, этого недостаточно, чтобы открыть позицию. Нужно выбрать объём, задать уровни фиксации прибыли и ограничения убытка, а иногда — остаться вне рынка.

Один и тот же прогноз может привести к разным действиям. Решение зависит от состояния счёта, открытой позиции, текущего риска и уже выбранного торгового плана. Сигнал на покупку, уместный при отсутствии позиции, может оказаться лишним после набора большого длинного объёма.

В этом состоит важное отличие торговли от прогнозирования временного ряда. Прогноз описывает возможное развитие рынка. Торговая политика выбирает действие и принимает его финансовые последствия. Эти задачи связаны, но не совпадают. Модель может неплохо воспроизводить будущую траекторию и при этом плохо управлять позицией. Возможна и обратная ситуация: полезная политика не обязана рисовать фактическое движение с визуальной точностью.

Для практикующего трейдера важен не красивый прогноз сам по себе, а результат решений, принятых на его основе. Значение имеют прибыль и убыток, просадка, нагрузка на депозит, продолжительность удержания позиции и устойчивость поведения на новых данных.

Рынок редко предлагает единственное очевидное продолжение. После одного состояния может начаться импульс, коррекция или боковое движение. В момент принятия решения каждый вариант способен выглядеть правдоподобно, хотя позднее реализуется только один.

Если заставить модель всегда строить одну траекторию, она начинает усреднять возможные исходы. Между ростом и разворотом может появиться плавное боковое движение. Формальная ошибка такого прогноза может быть умеренной. Однако компромисс часто не соответствует ни одному реальному режиму и не даёт надёжной основы для управления позицией.

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

Такой перенос сохраняет основную идею авторского ORION: анализ ситуации, планирование и действие не следует смешивать раньше времени. В исходной работе этот принцип связывает семантическое рассуждение с числовой траекторией автомобиля. В нашей адаптации сценарное пространство описывает возможное развитие рынка, а торговая политика решает, как использовать эту информацию.

После построения компонентов остался практический вопрос: как обучить их работать совместно, не разрушая смысл сценариев? И как проверить результат на новых данных? Общий сквозной запуск здесь нежелателен. Сигнал вознаграждения не должен произвольно перестраивать рыночное представление под текущие действия Актёра.

В этой статье мы объединим ранее созданные блоки в завершённую систему. Сначала рассмотрим архитектуру Market Encoder и торгового контура. Затем разберём офлайн-обучение Market Encoder, офлайн-обучение Актёра и Критиков и их последующую онлайн-настройку. Итоговую оценку проведём в тестере стратегий по финансовому результату на данных, которые не участвовали в обучении.

Авторская визуализация фреймворка ORION

Архитектурное решение

Архитектура решает две связанные задачи. Market Encoder анализирует текущий рынок и формирует несколько возможных продолжений. Затем торговый контур сопоставляет эти сценарии с состоянием счёта и выбирает действие. Прогноз не открывает сделку и не заменяет торговую политику.

Актёр формирует параметры операции. Два Критика независимо оценивают предложенное действие и создают обучающий сигнал. Такое разделение необходимо: даже наиболее вероятный сценарий роста ещё не является безусловной командой на покупку. При уже открытой длинной позиции важнее может оказаться контроль объёма и допустимой просадки.

Market Encoder строится на нижних слоях ранее рассмотренной модели CogDriver. Мы сохраняем часть, которая обрабатывает рыночную историю, и завершаем её слоем RankTCM. Функция ORIONCreateDescriptions создаёт базовое описание и удаляет слои, не используемые в новой архитектуре.

bool ORIONCreateDescriptions(CArrayObj *&market, CArrayObj *&target)
  {
   CArrayObj *legacy_decoder = NULL;
   if(!CreateStateDescriptions(market, legacy_decoder))
     {
      delete legacy_decoder;
      ReturnFalse;
     }
   delete legacy_decoder;
//--- Keep exactly CogDriver layers (RankTCM is the boundary).
   int layer = market.Total() - 1;
   while(layer >= 0)
     {
      CLayerDescription* descr = market.At(layer);
      if(!descr || descr.type != defNeuronCogDriverRankTCM)
        {
         if(!market.Delete(layer))
            ReturnFalse;
         layer--;
        }
      else
         break;
     }
   if(market.Total() <= 0)
      ReturnFalse;
   if(!ORIONNormalizeDescriptionBatch(market))
      ReturnFalse;

На вход поступают признаки за HistoryBars баров в формате BarDescr × HistoryBars. Каждая последовательность сохраняет собственную структуру. Это позволяет отдельно обрабатывать цену, волатильность и технические признаки, не смешивая их до извлечения устойчивого представления.

Слой CNeuronCogDriverData подготавливает квантильное описание исходных данных.

//--- CreateBuffers already stores state feature-major as
//--- [BarDescr,HistoryBars], which is CogDriverData's required input layout.
   if(!market.Delete(layer) || !market.Delete(layer - 1))
      ReturnFalse;
   CLayerDescription *descr = new CLayerDescription();
   if(!descr)
      ReturnFalse;
   descr.type = defNeuronCogDriverData;
   descr.window = HistoryBars;
   descr.count = BarDescr;
     {
      uint units[] = {StackSize, StackSize, Quantiles};
      if(ArrayCopy(descr.units, units, 0, 0, units.Size()) < int(units.Size()))
         DeleteObjAndFalse(descr);
     }
   descr.probability = 1.0f;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!market.Add(descr))
      DeleteObjAndFalse(descr);

Затем CNeuronCogDriverRankTCM анализирует временную структуру и формирует компактное состояние рынка.

   descr = new CLayerDescription();
   if(!descr)
      return false;
   descr.type = defNeuronCogDriverRankTCM;
   descr.window = HistoryBars * (2 * Quantiles + 1);
   descr.count = EmbeddingSize;
   descr.variables = BarDescr;
     {
      uint units[] = {StackSize, NHeads};
      if(ArrayCopy(descr.units, units, 0, 0, units.Size()) < int(units.Size()))
         DeleteObjAndFalse(descr);
     }
   descr.activation = None;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!market.Add(descr))
      DeleteObjAndFalse(descr);

На выходе получаем тензор BarDescr × EmbeddingSize. Это не один неразделимый вектор, а набор связанных латентных представлений. Каждая рыночная последовательность сохраняет собственные признаки, но все они описывают одно состояние рынка.

Следующим слоем того же Market Encoder является CNeuronScenarioForecast. Он раскрывает текущее состояние в пространство возможных продолжений.

   descr = new CLayerDescription();
   if(!descr)
      ReturnFalse;
   descr.type = defNeuronScenarioForecast;
   descr.count = NScenarios;
   descr.variables = TopK;
   descr.window_out = NForecast;
   descr.window = EmbeddingSize;
   descr.layers = BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!market.Add(descr))
      DeleteObjAndFalse(descr);

Полный тензор прогнозных состояний имеет размерность NScenarios × BarDescr × NForecast × EmbeddingSize. Каждый сценарий охватывает все анализируемые последовательности и весь горизонт. Поэтому он представляет целостную рыночную гипотезу, а не случайное сочетание независимых прогнозов.

Дополнительно Market Encoder формирует неопределённость размерности NScenarios × BarDescr × NForecast и вероятности размерности NScenarios. Вероятности не используются для раннего усреднения. Полный набор сценариев передаётся дальше, чтобы Актёр мог учитывать не только основной вариант, но и менее вероятные риски.

Кодовая книга хранит прототипы характерных продолжений. Во время обучения фактическая будущая траектория сопоставляется с ними, а ответственность получают только TopK наиболее подходящих сценариев. Разреженное обучение помогает ветвям сохранять специализацию.

Для построения цели используется отдельный Target Encoder. Он работает только во время обучения и получает будущий участок длиной NForecast. Эта ветвь не участвует в тестировании и не передаёт будущие данные в рабочий вход Market Encoder.

   if(!target)
      target = new CArrayObj();
   else
      target.Clear();
   if(!target)
      ReturnFalse;
   target.FreeMode(true);
//--- Detached Target Encoder: one normalized future trajectory per BarDescr
//--- variable.  ConvOCL zero-fills the unavailable edge of each window, so
//--- window=3 preserves NForecast positions without an auxiliary pad buffer.
   if(!ORIONAddBase(target, (BarDescr * NForecast)))
      ReturnFalse;
   descr = new CLayerDescription();
   if(!descr)
      ReturnFalse;
   descr.type = defNeuronPeriodNorm;
   descr.count = 1;
   descr.window = NForecast;
   descr.variables = BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!target.Add(descr))
      DeleteObjAndFalse(descr);

Будущие последовательности нормализуются отдельно, после чего свёрточные слои переводят их в латентное пространство.

   descr = new CLayerDescription();
   if(!descr)
      ReturnFalse;
   descr.type = defNeuronConvOCL;
   descr.count = NForecast;
   descr.window = 3;
   descr.step = 1;
   descr.window_out = EmbeddingSize;
   descr.layers = BarDescr;
   descr.activation = GELU;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!target.Add(descr))
      DeleteObjAndFalse(descr);
//---
   descr = new CLayerDescription();
   if(!descr)
      ReturnFalse;
   descr.type = defNeuronConvOCL;
   descr.count = NForecast;
   descr.window = EmbeddingSize;
   descr.step = EmbeddingSize;
   descr.window_out = 2 * EmbeddingSize;
   descr.layers = BarDescr;
   descr.activation = GELU;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!target.Add(descr))
      DeleteObjAndFalse(descr);
//---
   descr = new CLayerDescription();
   if(!descr)
      ReturnFalse;
   descr.type = defNeuronConvOCL;
   descr.count = NForecast;
   descr.window = 2 * EmbeddingSize;
   descr.step = 2 * EmbeddingSize;
   descr.window_out = EmbeddingSize;
   descr.layers = BarDescr;
   descr.activation = None;
   descr.optimization = ADAM;
   descr.batch = BatchSize;
   if(!target.Add(descr))
      DeleteObjAndFalse(descr);
//---
   return true;
  }

В результате формируется цель размерности BarDescr × NForecast × EmbeddingSize. Модель сравнивает сценарии не с одной будущей ценой, а с последующим состоянием всех наблюдаемых признаков. Это позволяет различать движения, которые приходят к похожей конечной точке, но несут разный промежуточный риск.

После обучения Market Encoder получает только доступную рыночную историю и выдаёт сценарии, их неопределённость и вероятности. Target Encoder больше не используется.

Торговый контур включает Актёра и двух Критиков. Актёр формирует торговую политику и выбирает действие. Критики оценивают его последствия. Описания этих моделей создаются функцией CreateORIONActorCriticDescriptions.

bool CreateORIONActorCriticDescriptions(CArrayObj *&actor, CArrayObj *&critic)
  {
   actor = new CArrayObj();
   critic = new CArrayObj();
   if(!actor || !critic)
      ReturnFalse;
   actor.FreeMode(true);
   critic.FreeMode(true);
//--- Full K-major scenario tensors enter one learned trunk. There is no Pi sum.
   if(!ORIONAddBase(actor, AccountDescr) || !ORIONAddCross(actor, false) ||
      !ORIONAddConv(actor, 1, (3 * EmbeddingSize), EmbeddingSize, 1, GELU) ||
      !ORIONAddConv(actor, 1, EmbeddingSize, 3, 2, SIGMOID))
      ReturnFalse;

Актёр получает состояние счёта размерности AccountDescr. В нём представлены баланс, средства, открытая позиция и временной контекст. Сценарии поступают отдельным потоком и связываются со счётом через сценарное кросс-внимание.

Для Актёра формируются три группы запросов: счёт, позиция и время. После кросс-внимания получается представление 3 × EmbeddingSize. Затем общий обучаемый ствол формирует действие размерности NActions. Оно задаёт объёмы Buy и Sell, а также TP и SL для каждого направления.

Актёр не выбирает только класс Buy, Sell или Hold. Он описывает желаемую структуру позиции. Новое действие может открыть сделку, изменить объём, обновить защитные уровни, закрыть позицию или выполнить реверс. История решений помогает не менять план без необходимости на каждом новом баре.

Критик получает состояние счёта вместе с оцениваемым действием. Для него кросс-внимание формирует пять запросов: три по торговому контексту и два по направлениям Buy и Sell.

   if(!ORIONAddBase(critic, AccountDescr + NActions) || !ORIONAddCross(critic, true) ||
      !ORIONAddConv(critic, 1, (5 * EmbeddingSize), EmbeddingSize, 1, GELU) ||
      !ORIONAddConv(critic, 1, EmbeddingSize, 1, 1, None))
      ReturnFalse;
   return true;
  }

После кросс-внимания Критик получает представление 5 × EmbeddingSize и выдаёт одно значение — оценку действия. Два Критика имеют одинаковую структуру, но независимые параметры. При формировании целевой оценки используется более осторожный результат, что снижает риск систематического оптимизма.

Такое построение особенно важно для управления риском. Актёр видит не только наиболее вероятный сценарий, но и альтернативы, которые могут привести к неблагоприятному движению. Поэтому параметры сделки формируются не из одной ожидаемой траектории, а из всего распределения возможных состояний.

При этом вероятность сценария не подменяет содержательный анализ. Сначала кросс-внимание сопоставляет торговый контекст с каждой траекторией. Только затем вероятность регулирует её итоговый вклад. Менее вероятный, но опасный вариант не исчезает до того, как Актёр оценит его влияние на открытую позицию.

Итак, Market Encoder отвечает на вопрос, как может развиваться рынок. Актёр решает, какую позицию выбрать. Критики оценивают последствия этого решения. Такая схема сохраняет прогноз в роли основы торговой политики, а не самостоятельного конечного результата.

Визуализация адаптированной модели

Подходы к обучению

Разные части ORION получают различные обучающие сигналы, поэтому их подготовка разделена на три этапа. Сначала обучается Market Encoder. Затем его параметры фиксируются, и на той же исторической выборке настраиваются Актёр и Критики. После этого торговый контур продолжает обучение в последовательном онлайн-режиме.

Если обучать всё одновременно, сигнал вознаграждения начнёт перестраивать сценарное пространство под текущую политику. Один и тот же прототип будет менять смысл вместе с Актёром. Это может улучшить результат на обучающем участке, но ослабить перенос на новые данные.

Первый этап выполняется программой StudyForecast.mq5. Текущая ветвь получает только информацию, доступную в рассматриваемый момент. Будущее не подаётся в Market Encoder до завершения прямого прохода.

//--- Build exactly the same X<=t state as CreateBuffers(position,...,future),
//--- but do not touch any future sample until Market and Scenario are complete.
   if(!CreateBuffers(position + NForecast, GetPointer(ORIONState), GetPointer(ORIONTime), NULL))
      ReturnFalse;
   if(!ORIONMarket.feedForward(GetPointer(ORIONState), 1, false, (CBufferFloat*)NULL))
      ReturnFalse;
//--- -1 is ScenarioForecast; the detached target is measured from the
//--- preceding RankTCM latent z_t.
   CNeuronBaseOCL *market_layer = ORIONMarket.Layer(-2);
   CBufferFloat *market_latent = (market_layer ? market_layer.getOutput() : NULL);
   if(!market_latent ||
      market_latent.Total() != (BarDescr * EmbeddingSize) || market_latent.GetIndex() < 0)
      ReturnFalse;

Только после этого Target Encoder получает фактическое продолжение и формирует целевую латентную траекторию.

//--- Only now is any future window presented to the detached Target Encoder.
   if(!CreateBuffers(position, GetPointer(ORIONState), GetPointer(ORIONTime), GetPointer(ORIONFuture)))
      ReturnFalse;
   if(!ORIONPrepareLatentTarget(market_latent))
      ReturnFalse;

Такое разделение исключает утечку будущего. Market Encoder строит сценарии по известной истории, а будущий участок служит лишь ответом для обучения.

На каждом примере реализуется только одно продолжение рынка, но все сценарии не должны сближаться с одной целью. Фактическая траектория сопоставляется с кодовой книгой, и обучающий сигнал получают только TopK наиболее подходящих вариантов.

   if(!ORIONForecast.BuildResponsibilities(GetPointer(ORIONLatentTarget), GetPointer(ORIONLatentDelta)))
     {
      //--- Commit only a completed device validation.  Infrastructure failures leave
      //--- the transient control-valid flag false, so stale batch_control is ignored.
      if(ORIONForecast.LastBatchControlValid() && !ORIONForecast.CommitBatchDiagnostics())
         PrintFormat("%s -> %d invalid diagnostic commit failed", __FUNCTION__, __LINE__);
      ReturnFalse;
     }
   if(!ORIONForecast.CommitBatchDiagnostics())
      ReturnFalse;
//--- The unified CNet updates ScenarioForecast and all preceding Market layers.
   if(!ORIONMarket.backPropGradient((CBufferFloat*)NULL, (CBufferFloat*)NULL, -1, true))
      ReturnFalse;

Кодовая книга обновляется плавно через экспоненциальное скользящее среднее. Одновременно контролируется использование прототипов. Сценарий, который долго не получает ответственности, может быть перемещён ближе к актуальной области целей и получить новую специализацию.

Router учится оценивать вероятности сценариев только по текущему рынку. Ветка неопределённости показывает, насколько надёжно модель оценивает отдельные части траектории. При этом весь набор сценариев сохраняется до торгового решения.

После завершения первого этапа Market Encoder сохраняется как единая модель. Его ранние слои, сценарный слой и кодовая книга образуют согласованное пространство и не должны смешиваться с результатами другого запуска.

Офлайн-обучение торгового контура выполняется программой Study.mq5. Market Encoder загружается в режиме вывода и остаётся неизменным. Актёр получает сценарии и текущее состояние счёта, после чего формирует действие.

   terminal = false;
   const bool trace_td = (iteration < 8 || iteration % 10000 == 0);
   if(!ORIONForwardForecast(position, GetPointer(State), GetPointer(TimeState)))
     { Print("TrainTransition stage=current_forecast"); ReturnFalse; }
   if(!Actor.feedForward(GetPointer(Account), 1, false, GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=actor_forward"); ReturnFalse; }
   if(!ReadAction(Actor, GetPointer(CurrentAction)))
     { Print("TrainTransition stage=read_action"); ReturnFalse; }
   const double balance = MathMax(0.0, double(Account[0]) * EtalonBalance);
   const double buy_lot = MathMax(0.0, double(CurrentAction[0] - CurrentAction[3]));
   const double sell_lot = MathMax(0.0, double(CurrentAction[3] - CurrentAction[0]));
   if(trace_td)
      PrintFormat("ORION action iteration=%d balance=%.2f buy_lot=%.8f buy_tp=%.8f 
                  buy_sl=%.8f sell_lot=%.8f sell_tp=%.8f sell_sl=%.8f",
                  iteration, balance, buy_lot, double(CurrentAction[1]), double(CurrentAction[2]),
                  sell_lot, double(CurrentAction[4]), double(CurrentAction[5]));

Обучение организовано последовательными эпизодами длиной до EpisodeBars. Это необходимо, потому что состояние счёта зависит от предыдущих действий. Открытая позиция переносится между барами, плавающий результат влияет на средства, а закрытие или реверс меняют баланс.

Исторический симулятор переводит счёт на следующий бар. Он проверяет исполнение действия, изменение позиции и возможное срабатывание TP или SL внутри бара. Баланс и средства учитываются отдельно.

   double reward = 0;
   if(!AdvanceAccount(GetPointer(Account), GetPointer(CurrentAction), position, MinBalance,
                      GetPointer(NextAccount), reward, terminal))
     { Print("TrainTransition stage=advance_account"); ReturnFalse; }
   const double target_value = reward;
   if(trace_td)
      PrintFormat("ORION return iteration=%d reward=%.8f target=%.8f balance=%.2f equity=%.2f "
                  "floating_buy=%.8f floating_sell=%.8f next_buy=%.8f next_sell=%.8f terminal=%s",
                  iteration, reward, target_value, double(NextAccount[0])*EtalonBalance,
                  double(NextAccount[2])*balance, double(NextAccount[6])*MathMax(double(NextAccount[0])*EtalonBalance, 1.0),
                  double(NextAccount[7])*MathMax(double(NextAccount[0])*EtalonBalance, 1.0),
                  double(NextAccount[4]), double(NextAccount[5]), (terminal ? "true" : "false"));
   if(!MathIsValidNumber(target_value) || !ScalarTarget.BufferInit(1, 0) ||
      !ScalarTarget.Update(0, float(target_value)))
     { Print("TrainTransition stage=scalar_target"); ReturnFalse; }
   CNeuronBaseOCL *context_layer = Actor.Layer(0);
   CNeuronBaseOCL *actor_layer = Actor.Layer(3);
   if(!context_layer || !actor_layer)
     { Print("TrainTransition stage=actor_layer"); ReturnFalse; }

В показанном коде целевая величина Критика получает значение reward. Сам reward рассчитывается функцией EvaluateAction, которая вызывает существующий контракт CheckAction. Этот контракт просматривает доступное продолжение сделки, учитывает TP, SL и временное дисконтирование. Поэтому оценка Критика не сводится к изменению счёта на одном следующем баре.

Практический смысл офлайн-оценки состоит в том, что Критик сравнивает действия в одной финансовой шкале. Короткая сделка, быстро достигшая TP, и длительная позиция с тем же номинальным итогом получают разные цели. Это позволяет учитывать не только сумму результата, но и время, в течение которого капитал оставался под риском.

Если TP и SL не срабатывают на доступном участке, расчёт завершается по последнему доступному состоянию. Такая цель не обещает точного исхода за пределами истории, но позволяет использовать весь известный фрагмент без добавления искусственной оценки следующего состояния.

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

Оба Критика получают одинаковое состояние, действие и сценарное пространство, но обучаются независимо.

//--- Use the live device activation, not the CPU account object.
   if(!BuildCriticInput(context_layer.getOutput(), actor_layer.getOutput(), GetPointer(CriticInput)))
     { Print("TrainTransition stage=critic_input"); ReturnFalse; }
   if(!Q1.feedForward(GetPointer(CriticInput), 1, false, GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=q1_forward"); ReturnFalse; }
   if(!Q2.feedForward(GetPointer(CriticInput), 1, false, GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=q2_forward"); ReturnFalse; }
   if(trace_td)
     {
      CNeuronBaseOCL *q1_layer = Q1.Layer(3);
      CNeuronBaseOCL *q2_layer = Q2.Layer(3);
      CBufferFloat *q1_output = (q1_layer ? q1_layer.getOutput() : NULL);
      CBufferFloat *q2_output = (q2_layer ? q2_layer.getOutput() : NULL);
      if(!q1_output || !q2_output || q1_output.GetIndex() < 0 || q2_output.GetIndex() < 0 ||
         q1_output.Total() != 1 || q2_output.Total() != 1 ||
         !q1_output.BufferRead() || !q2_output.BufferRead())
        { Print("TrainTransition stage=current_q_read"); ReturnFalse; }
      PrintFormat("ORION Q iteration=%d q1=%.8f q2=%.8f td_q1=%.8f td_q2=%.8f",
                  iteration, double(q1_output[0]), double(q2_output[0]),
                  target_value - double(q1_output[0]), target_value - double(q2_output[0]));
     }

Актёр обновляется реже Критиков. Градиент передаётся ему только для исполнимых операций. Если объём ниже минимального или TP и SL нарушают ограничения символа, небольшое изменение выхода может ничего не изменить: сделка всё равно не откроется. Критики изучают такие примеры, но Актёр не получает от них ненадёжного направления.

//--- A critic gradient has no valid direction on the discontinuous no-trade
//--- plateau.  Keep learning Q there, but let only the realized-future teacher
//--- move Actor back into the executable action manifold.
   const bool executable_action = (IsExecutableOrder(buy_lot, CurrentAction[1], CurrentAction[2]) ||
                                   IsExecutableOrder(sell_lot, CurrentAction[4], CurrentAction[5]));
   if(iteration > 0 && UpdatePolicy > 0 && iteration % UpdatePolicy == 0)
     {
      if(executable_action)
        {
         if(!PolicyBackward())
           { Print("TrainTransition stage=policy_backward"); ReturnFalse; }
         PolicyTransitions++;
        }
      else
         PolicySkippedInvalid++;
     }
   if(!Q1.backProp(GetPointer(ScalarTarget), GetPointer(ORIONMarket), -1) ||
      !Q2.backProp(GetPointer(ScalarTarget), GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=critic_backward"); ReturnFalse; }

На старте политика может застрять около нулевого объёма. Чтобы вернуть её в область реальных операций, используются учительские и случайные действия.

Учительское действие строится по фактически реализованному будущему. Оно не выбирает сценарий Market Encoder и не участвует в прямом проходе Актёра. Будущие бары используются только для создания дополнительного обучающего примера.

//--- The teacher uses realized future bars, not one selected Forecast scenario.
//--- It is deliberately a second Critic-only sample: Q1/Q2 are stateless,
//--- whereas Actor has already performed its only forward for this state.
   double teacher_reward = 0;
   if(!BuildTeacherAction(position, GetPointer(Account), GetPointer(TeacherAction), teacher_reward))
     { Print("TrainTransition stage=teacher_action"); ReturnFalse; }
   if(teacher_reward > 0)
     {
      if(trace_td)
         PrintFormat("ORION teacher iteration=%d reward=%.8f buy_lot=%.8f sell_lot=%.8f",
                     iteration, teacher_reward,
                     MathMax(0.0, double(TeacherAction[0] - TeacherAction[3])),
                     MathMax(0.0, double(TeacherAction[3] - TeacherAction[0])));
      //--- Supervised Actor update reuses the current activation; it never advances
      //--- the history stack or invokes a second Actor forward.
      if(!Actor.backProp(GetPointer(TeacherAction), GetPointer(ORIONMarket), -1))
        { Print("TrainTransition stage=teacher_actor_backward"); ReturnFalse; }
      TeacherTransitions++;
      TeacherRewardSum += teacher_reward;
     }

Если учительское действие даёт положительный результат, оно становится дополнительной целью для Актёра. Критики получают все учительские действия, включая убыточные, чтобы различать полезные и неудачные операции.

//--- Critic receives every oracle action, including a losing one.  Only Actor
//--- is restricted to profitable oracle supervision above.
   if(!ScalarTarget.BufferInit(1, 0) || !ScalarTarget.Update(0, float(teacher_reward)) ||
      !BuildCriticInput(context_layer.getOutput(), GetPointer(TeacherAction), GetPointer(CriticInput)))
     { Print("TrainTransition stage=teacher_critic_input"); ReturnFalse; }
   if(!Q1.feedForward(GetPointer(CriticInput), 1, false, GetPointer(ORIONMarket), -1) ||
      !Q2.feedForward(GetPointer(CriticInput), 1, false, GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=teacher_critic_forward"); ReturnFalse; }
   if(!Q1.backProp(GetPointer(ScalarTarget), GetPointer(ORIONMarket), -1) ||
      !Q2.backProp(GetPointer(ScalarTarget), GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=teacher_critic_backward"); ReturnFalse; }
   TeacherCriticTransitions++;

Случайные исполнимые действия расширяют область, в которой оценки Критиков опираются на фактические расчёты. Прибыльные примеры могут дополнительно обучать Актёра, убыточные используются только Критиками.

//--- A second critic-only sample is uniformly random over executable lots and
//--- stop distances.  It never runs Actor backward or advances policy history.
   double random_reward = 0;
   if(!BuildRandomAction(position, GetPointer(Account), GetPointer(RandomAction), random_reward))
     { Print("TrainTransition stage=random_action"); ReturnFalse; }
   if(random_reward > 0)
     {
      if(!Actor.backProp(GetPointer(RandomAction), GetPointer(ORIONMarket), -1))
        { Print("TrainTransition stage=random_actor_backward"); ReturnFalse; }
     }
   if(!ScalarTarget.BufferInit(1, 0) || !ScalarTarget.Update(0, float(random_reward)) ||
      !BuildCriticInput(context_layer.getOutput(), GetPointer(RandomAction), GetPointer(CriticInput)))
     { Print("TrainTransition stage=random_critic_input"); ReturnFalse; }
   if(!Q1.feedForward(GetPointer(CriticInput), 1, false, GetPointer(ORIONMarket), -1) ||
      !Q2.feedForward(GetPointer(CriticInput), 1, false, GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=random_critic_forward"); ReturnFalse; }
   if(!Q1.backProp(GetPointer(ScalarTarget), GetPointer(ORIONMarket), -1) ||
      !Q2.backProp(GetPointer(ScalarTarget), GetPointer(ORIONMarket), -1))
     { Print("TrainTransition stage=random_critic_backward"); ReturnFalse; }
   RandomCriticTransitions++;
//--- Only the three declared Forecast trainable buffers are read here. The
//--- complete persistent Codebook is checked at checkpoint/lifecycle bounds.
   if(!ORIONVerifyFrozenWeightsExact())
     { Print("TrainTransition stage=frozen_forecast_check"); ReturnFalse; }

Дополнительные варианты не продвигают память Актёра. Для текущего состояния выполняется один прямой проход, а учительские и случайные действия служат только дополнительными целями. Так сохраняется реальная последовательность решений.

После офлайн-обучения используется StudyOnline.mq5. Market Encoder остаётся замороженным, а Актёр и Критики продолжают обучение на последовательных закрытых барах с учётом фактического состояния счёта.

В онлайн-режиме заранее неизвестно, чем завершится сделка. Поэтому система сначала формирует текущее состояние и сценарии.

   if(!ORIONRefreshLiveMarket(GetPointer(State), GetPointer(TimeState)))
     {
      PrintFormat("%s -> %d market", __FUNCTION__, __LINE__);
      if(!ClearPreviousTransition())
         ExpertRemove();
      return;
     }
//--- Sequential current inference when possible. Training restoration below
//--- clears/reruns explicit states, so sequence boundaries remain deliberate.
   if(!ORIONForwardForecastState(GetPointer(State)))
     {
      PrintFormat("%s -> %d forecast", __FUNCTION__, __LINE__);
      if(!ClearPreviousTransition())
         ExpertRemove();
      return;
     }
   double buy_value = 0, sell_value = 0;
   if(!ORIONBuildLiveAccount(PreviousBalance, PreviousEquity, Rates[0].time, GetPointer(Account),
                                                                          buy_value, sell_value))
     {
      PrintFormat("%s -> %d account", __FUNCTION__, __LINE__);
      if(!ClearPreviousTransition())
         ExpertRemove();
      return;
     }

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

Поскольку онлайн-вознаграждение охватывает один завершённый переход, к нему добавляется дисконтированная оценка следующего состояния. Для этого используются целевые копии Актёра и Критиков. Из двух оценок выбирается меньшая. Терминальным состоянием считается снижение баланса до MinBalance.

//--- A rejected order is recoverable.  Loss of the configured minimum
//--- deposit is the only financial terminal state for online training.
   const bool terminal = (AccountInfoDouble(ACCOUNT_BALANCE) <= MinBalance);
   if(HavePrevious)
     {
      PolicyCheckpointSafe = false;
      if(!TrainPreviousTransition(terminal))
        {
         PrintFormat("%s -> %d train: terminal recurrent-state failure", __FUNCTION__, __LINE__);
         ClearPreviousTransition();
         ExpertRemove();
         return;
        }
      PolicyCheckpointSafe = true;
      PolicyCheckpointPending = true;
      Transition++;
      if(CheckpointTransitions > 0 && Transition % (ulong)CheckpointTransitions == 0)
        {
         if(!ORIONSavePolicySet(Actor, Q1, Q2))
           {
            PrintFormat("%s -> %d checkpoint failed", __FUNCTION__, __LINE__);
            ClearPreviousTransition();
            ExpertRemove();
            return;
           }
         PolicyCheckpointPending = false;
        }
      //--- Training ended at previous state. A terminal transition has no next
      //--- action, hence it must not rerun the current policy state.
      if(!terminal && (!CreateBuffers(0, GetPointer(State), GetPointer(TimeState), (CBufferFloat*)NULL) ||
                       !ORIONForwardForecastState(GetPointer(State))))
        {
         PrintFormat("%s -> %d restore: terminal recurrent-state failure", __FUNCTION__, __LINE__);
         ClearPreviousTransition();
         ExpertRemove();
         return;
        }
     }
   if(terminal)
     {
      PrintFormat("ORION online terminal: balance=%.2f min_balance=%.2f", AccountInfoDouble(ACCOUNT_BALANCE),
                                                                                                 MinBalance);
      ClearPreviousTransition();
      ExpertRemove();
      return;
     }

В онлайн-режиме такой полный просмотр будущего невозможен. Поэтому здесь используется классическая связь между уже наблюдаемым результатом и оценкой следующего состояния. Различие режимов не является противоречием. Офлайн-этап использует известную историю целиком, а онлайн-этап — только данные, доступные на текущий момент.

Для практикующего трейдера это означает, что модель сначала получает широкий исторический опыт, а затем учится уточнять решения в последовательной торговой среде. Market Encoder при этом остаётся неизменным, поэтому адаптация касается политики, а не заднего пересмотра рыночных сценариев.

Целевые модели обновляются плавно через каждые UpdateTargets переходов с коэффициентом Tau. Это делает обучающую цель устойчивее.

После обучения предыдущего перехода Актёр формирует действие для текущего состояния. Оно проходит проверку допустимости.

   if(!Actor.feedForward(GetPointer(Account), 1, false, GetPointer(ORIONMarket), -1) ||
      !ReadAction(Actor, GetPointer(Action)) || !ORIONValidateAction(GetPointer(Action)))
     {
      PrintFormat("%s -> %d action", __FUNCTION__, __LINE__);
      if(!ClearPreviousTransition())
         ExpertRemove();
      return;
     }

Затем действие передаётся торговому модулю. Закрытый рынок рассматривается как временная причина отложить исполнение, а нехватка маржи создаёт отрицательный сигнал, но не завершает весь процесс.

   double margin_penalty = 0;
   bool market_closed = false;
   if(!ORIONExecuteAction(GetPointer(Action), buy_value, sell_value, margin_penalty, market_closed))
     {
      PrintFormat("%s -> %d execute: terminal partial-trade risk", __FUNCTION__, __LINE__);
      ClearPreviousTransition();
      ExpertRemove();
      return;
     }
   if(market_closed)
     {
      Print("ORION online execution deferred: market closed");
      //--- The preceding transition was already trained above.  Do not replay it
      //--- on the first tradable bar, but preserve Actor history for this state.
      HavePrevious = false;
      PreviousAccount.Clear();
      PreviousMarginPenalty = 0;
      return;
     }
   if(margin_penalty < 0)
      PrintFormat("ORION online insufficient margin: penalty=%.2f", margin_penalty);

После исполнения сохраняются данные, необходимые для обучения на следующем баре: состояние счёта, действие, баланс, средства и возможный штраф.

   if(!StorePrevious())
     {
      PrintFormat("%s -> %d metadata: terminal post-trade failure", __FUNCTION__, __LINE__);
      ClearPreviousTransition();
      ExpertRemove();
      return;
     }
   PreviousBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   PreviousEquity = AccountInfoDouble(ACCOUNT_EQUITY);
   PreviousMarginPenalty = margin_penalty;
   HavePrevious = true;

Точки сохранения создаются только после полностью завершённых переходов. На всём онлайн-этапе дополнительно проверяется неизменность Market Encoder.

В итоге три этапа дополняют друг друга. Market Encoder учится различать возможные режимы. Офлайн-этап связывает действия с расчётным дисконтированным результатом на доступной истории. Онлайн-этап уточняет политику по фактическим последовательным переходам и ограничениям исполнения.

Тестирование

Качество обучения на известной истории ещё не показывает, как модель поведёт себя на новых данных. Поэтому обучающая и тестовая выборки разделены по времени.

Все этапы подготовки выполнялись на EURUSD H1 за 2024–2025 годы. Сначала на этом периоде обучался Market Encoder. Затем на той же истории проводились офлайн- и онлайн-этапы обучения Актёра и Критиков. Первые пять месяцев 2026 года не использовались для настройки весов и впервые подавались модели только в тестере стратегий.

Во время тестирования все модели работали в режиме вывода. Market Encoder формировал сценарии по доступной истории, а Актёр управлял позицией. Основным критерием служил финансовый результат, а не визуальная близость одной прогнозной траектории к фактической цене.

Результаты тестирования Результаты тестирования

На участке с января по май 2026 года начальный депозит 1 000 USD вырос до 9 795,96 USD. Чистая прибыль составила 8 795,96 USD, Profit Factor — 1,57, ожидаемый результат сделки — 8,17 USD.

За период модель совершила 1 077 сделок. Прибыльными оказались 619 операций, или 57,47%. Короткие позиции использовались чаще: 756 сделок против 321 длинной. Доля прибыльных продаж составила 57,94%, покупок — 56,39%. Следовательно, результат не зависел исключительно от одного направления, хотя политика заметно чаще выбирала Sell.

Средняя прибыльная сделка принесла 38,93 USD, средняя убыточная — 33,41 USD. Максимальная прибыль по одной операции достигла 841,50 USD, максимальный убыток — 691,01 USD. Recovery Factor составил 2,74, коэффициент Шарпа — 4,97.

Кривая баланса имела общий восходящий характер, но рост сопровождался заметными коррекциями. Максимальная просадка по балансу составила 26,83%, по средствам — 34,09%. Разница показывает, что часть риска накапливалась в открытых позициях до фиксации результата.

В начале теста депозитная нагрузка была повышенной. По мере роста счёта она снизилась и большую часть периода оставалась умеренной. Тем не менее просадка по средствам остаётся существенной и не позволяет оценивать модель только по высокой итоговой прибыли.

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

Особое внимание следует уделить расхождению кривых баланса и средств. На отдельных участках зелёная линия заметно отклоняется от синей. Это отражает плавающий результат по открытым позициям. Именно поэтому просадка по средствам выше просадки по балансу и лучше отражает фактическую нагрузку на депозит.

Отдельные показатели также нужно рассматривать вместе. Profit Factor выше единицы подтверждает положительное соотношение прибыли и убытков, но не отменяет просадку. Доля прибыльных сделок показывает устойчивое преимущество, однако сама по себе не объясняет размер риска в каждой операции.

Полученный результат показывает, что Актёр смог перенести торговую политику на участок вне обучающей выборки. Однако тест охватывает один инструмент, один таймфрейм и ограниченный период. Поэтому он подтверждает работоспособность текущей конфигурации, но не доказывает её универсальность.

Заключение

Во введении был поставлен практический вопрос: как перейти от пространства возможных рыночных сценариев к торговому решению и при этом не свести прогноз к одной усреднённой траектории. В этой работе мы завершили соответствующий контур. Market Encoder сохраняет несколько вариантов будущего, Актёр формирует на их основе торговую политику, а два Критика оценивают последствия действий.

Разделение обучения позволило сохранить назначение каждого блока. Market Encoder сначала получил устойчивое сценарное пространство. Затем Актёр и Критики обучались на исторических операциях с учётом TP, SL, дисконтированного результата и последовательного состояния счёта. Онлайн-этап продолжил настройку торгового контура без изменения рыночного представления.

Проверка на EURUSD H1 за первые пять месяцев 2026 года дала положительный результат вне обучающей выборки. При начальном депозите 1 000 USD чистая прибыль составила 8 795,96 USD, Profit Factor — 1,57, а доля прибыльных сделок — 57,47%. Тем самым поставленная задача получила практическое решение: сценарный прогноз стал не самостоятельной картинкой будущего, а рабочей основой торговой политики.

Одновременно тест выявил ограничение текущей реализации. Максимальная просадка по средствам достигла 34,09%. Поэтому дальнейшая работа должна быть направлена не на визуальное улучшение прогнозных кривых, а на контроль объёма, депозитной нагрузки и риска открытых позиций.


Ссылки


Программы, используемые в статье

# Имя Тип Описание
1 Study.mq5 Советник Советник офлайн-обучения моделей
2 StudyOnline.mq5 Советник Советник онлайн-обучения моделей
3 StudyForecast.mq5 Советник Советник офлайн-обучения Market Encoder
4 Test.mq5 Советник Советник для тестирования модели
5 Trajectory.mqh Библиотека класса Структура описания состояния системы и архитектуры моделей
6 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
7 NeuroNet.cl Библиотека Библиотека кода OpenCL‑программы

Проект представлен на forge.mql5.io/dng.

Прикрепленные файлы |
MQL5.zip (3899.74 KB)
Анализ CSV-данных (Часть 3): Построение аналитического пайплайна на Python для CSV-экспорта из MetaTrader 5 Анализ CSV-данных (Часть 3): Построение аналитического пайплайна на Python для CSV-экспорта из MetaTrader 5
MetaTrader 5 предоставляет обширные данные о результатах, но ограниченные возможности структурного анализа. В статье показано, как экспортировать результаты из MQL5 в CSV и построить пять визуализаций на Python, раскрывающих согласованность параметров между активами, компромисс между задержкой и шумом, деградацию результатов при walk-forward-валидации, распределение глубины и длительности просадок, а также внутридневные кластеры по часам и дням недели. Единый модуль автоматизации запускает полный пайплайн для каждого нового файла экспорта и обеспечивает воспроизводимую диагностику.
Автоматизация торговых стратегий в MQL5 (Часть 33): Создание системы распознавания гармонического паттерна "Shark" с использованием Price Action Автоматизация торговых стратегий в MQL5 (Часть 33): Создание системы распознавания гармонического паттерна "Shark" с использованием Price Action
В этой статье мы разрабатываем систему распознавания паттернов "Shark" (акула) на языке MQL5, которая определяет бычьи и медвежьи гармонические паттерны "Shark" с использованием точек разворота и коэффициентов Фибоначчи, совершая сделки с настраиваемыми уровнями входа, стоп-лосса и тейк-профита на основе выбранных пользователем параметров. Мы улучшим работу трейдеров с помощью визуальной обратной связи, используя графические объекты, такие как треугольники, линии тренда и подписи, для наглядного отображения структуры паттерна X-A-B-C-D.
Разработка самовосстанавливающегося советника в MQL5 (Часть 2): Виртуальная защита сделок, устойчивая к перезапуску Разработка самовосстанавливающегося советника в MQL5 (Часть 2): Виртуальная защита сделок, устойчивая к перезапуску
Постройте уровень виртуальной защиты, устойчивый к перезапускам, поверх механизма персистентности SQLite из Части 1. Советник восстанавливает скрытые стоп-лосс и тейк-профит после перезапуска, сверяет текущую цену с восстановленными уровнями выхода и в зависимости от результата либо закрывает позиции, либо продолжает ими управлять. В результате формируется согласованный сценарий восстановления, который обнаруживает управляемые позиции и обеспечивает безопасное управление в рабочем режиме.
Анализ CSV-данных (Часть 2): Построение конвейера экспорта и парсинга CSV промышленного уровня для количественного анализа стратегий Анализ CSV-данных (Часть 2): Построение конвейера экспорта и парсинга CSV промышленного уровня для количественного анализа стратегий
Файловая система MQL5 работает в строгой песочнице. Понимание флагов доступа и правил разрешения путей лежит в основе любого надёжного пайплайна экспорта. В статье создаётся класс CCSVExporter, который отвечает за создание файлов, безопасное дописывание и механизмы восстановления при ошибках. Также рассматриваются разбор CSV-файлов, токенизация полей, конфликты параллельного доступа и стратегии буферизации записи при интенсивных сериях оптимизации.