preview
Нейросети в трейдинге: Долговременная память торговых сценариев (Окончание)

Нейросети в трейдинге: Долговременная память торговых сценариев (Окончание)

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

Введение

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

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

Поэтому здесь рассматривается не добавление новых нейронных блоков, а согласование уже построенных компонентов. Сначала соберём полную модель VLADriver-RAG и проследим фактические потоки данных. Затем разберём порядок обучения: сначала формируется устойчивое представление рынка, потом в этом пространстве обучается торговая политика и строится Base Memory, и только после этого система переходит к последовательной адаптации.

Для практикующего трейдера главный вопрос простой: что в итоге получилось из всей этой конструкции? Обучение проводилось на EURUSD H1 за 2024–2025 годы, а итоговая проверка — на первом полугодии 2026 года. Здесь латентные пространства и similarity уже не имеют самостоятельной ценности. Остаются поведение модели, риск и финансовый результат.

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


Построение модели

Начнём с общей картины. В рабочей версии VLADriver-RAG нет одной огромной сети, через которую последовательно проходят все данные. Система собрана из нескольких самостоятельных частей. StateEncoder описывает рынок, RAG-память хранит завершённый опыт, Актёр выбирает действие, а Критик оценивает это действие в текущем рыночном контексте. Такое разделение нужно не ради архитектурной красоты. Каждый объект получает ровно те данные, которые нужны для его задачи.

Основные размеры задаются в файле Trajectory.mqh. Мы используем пять последних баров по девять признаков, 13 значений для состояния счёта и позиции, 16 элементов в сценарном представлении и шесть компонентов торгового действия. Там же задаются ёмкость двух уровней памяти и число кандидатов MPI-политики.

#define HistoryBars             5
#define BarDescr                9
#define AccountDescr           13
#define EmbeddingSize          16
#define NForecast              12
#define RAGScenarioCentroids  128
#define RAGActionCentroids     16
#define ActorMPITopK            3
#define NScenarios              3
#define NActions                6

Эти числа определяют интерфейсы между частями модели. На вход StateEncoder приходит короткий рыночный фрагмент, но на выходе нужны два представления одного состояния. Подробный набор токенов остаётся для Критика. Компактный ScenarioEmbedding используется Актёром и как запрос для поиска в RAG-памяти.

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

Поток данных VLADriver-RAG

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

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

cStateEncoder.GetLayerOutput(0, Result);
if(Result.Total() != (HistoryBars * BarDescr))
   return INIT_FAILED;

cStateEncoder.GetLayerOutput(StateTokenLayer, Result);
if(Result.Total() != (BarDescr * EmbeddingSize))
   return INIT_FAILED;

cStateEncoder.GetLayerOutput(StateScenarioLayer, Result);
if(Result.Total() != EmbeddingSize)
   return INIT_FAILED;

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

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

Дальше подключаем долговременную память. Она не входит в обычную последовательность слоёв Актёра. В советнике создаётся отдельный объект CNeuronRAGMemory, а в CNeuronVLADriverRAGMPI передаётся уже опубликованный снимок. Поэтому Актёр читает готовый опыт, но не управляет содержимым памяти напрямую. В коде привязка занимает один короткий метод.

bool RebindOfflineRAGMemory(void)
  {
   if(cActor.BindRAGMemory(2, GetPointer(RAGMemory)))
      return true;

   ExpertRemove();
   return false;
  }

Число 2 здесь указывает слой Актёра, в котором находится CNeuronVLADriverRAGMPI. Сам BindRAGMemory() проверяет форму подключаемого снимка. Для нас важнее практический смысл: память можно обновлять и публиковать отдельно, а после публикации просто переподключить к той же политике.

В результате память остаётся внешним объектом и не "растворяется" в весах Актёра. Это удобно и для эксперимента, и для дальнейшего анализа. Можно заменить снимок памяти, оставить прежние веса политики и сразу проверить, как изменится поведение retrieval. Или, наоборот, сохранить память и продолжить обучение Актёра. Такое разделение даёт гораздо больше контроля, чем монолитная сеть, где исторический опыт невозможно отделить от параметров модели.

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

Action[6] + weighted relevance + mean reward

Этого достаточно, чтобы MPI-политика сопоставила найденные действия со своими текущими кандидатами. Память здесь выступает как дополнительный аргумент, а не как замена текущему рынку. Это особенно хорошо видно в реальном прямом проходе.

if(!cStateEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL))
   return;

if(!RAGMemory.Retrieve(GetPointer(cStateEncoder), StateScenarioLayer))
   return;

if(!cActor.feedForward((CBufferFloat*)GetPointer(bContext), 1, false, GetPointer(cStateEncoder), StateScenarioLayer))
   return;

Порядок вызовов читается буквально. Сначала StateEncoder кодирует текущий рынок. Затем Retrieve() ищет похожие завершённые сценарии. Только после этого запускается Актёр, который уже располагает и текущим состоянием счёта, и рыночным представлением, и RAG-контекстом.

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

Есть ещё одна важная деталь. На этом этапе энкодер рынка уже не обучается вместе с торговой политикой. Режимы задаются явно.

cActor.TrainMode(true);
cCritic.TrainMode(true);
cStateEncoder.TrainMode(false);

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

Критик подключается после выбора действия. Он не получает RAG-токены напрямую. Его входы — действие Актёра и подробные токены StateTokenLayer.

if(!cCritic.feedForward(GetPointer(cActor), -1, GetPointer(cStateEncoder), StateTokenLayer))
   return;

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

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

В результате поток остаётся прозрачным даже для довольно сложной модели. StateEncoder отвечает на вопрос "что сейчас происходит на рынке", RAG-память — "что похожее уже происходило", Актёр — "что делать с учётом текущего состояния и прошлого опыта", а Критик — "насколько это действие оправдано сейчас". Дальше всё упирается уже не в топологию, а в правильный порядок обучения этих частей.


Организация процесса обучения

Именно порядок обучения здесь определяет, будет ли память вообще полезна. Если одновременно менять энкодер, Актёра, Критика и центроиды, компоненты будут менять общую базу координат друг для друга. Поэтому используем три последовательных этапа: StudyStateEncoder, затем Study и после него StudyOnline.

Процесс обучения VLADriver-RAG

Первый этап не занимается торговлей. В StudyStateEncoder нет ни Актёра, ни Критика, ни RAG-памяти. Здесь мы учим модель описывать окружающую среду. Для этого StateEncoder работает вместе со вспомогательным ForecastDecoder: по текущей истории энкодер строит внутреннее представление, а декодер пытается восстановить по нему ближайшее продолжение рынка.

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

if(!CreateBuffers(posit, GetPointer(bStateE), GetPointer(bTime), GetPointer(bTarget)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   Stop=true;
   break;
  }

В bStateE находится то, что модель могла видеть в текущий момент. А bTarget хранит фактическое продолжение. После подготовки буферов через StateEncoder проходит только доступная история.

if(!StateEncoder.feedForward((CBufferFloat*)GetPointer(bStateE),
                             1, false, (CBufferFloat*)NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__,__LINE__);
   Stop=true;
   break;
  }

Теперь берём обучающий выход StateRawForecastLayer. Его получает ForecastDecoder и пытается развернуть внутреннее представление обратно в будущие рыночные признаки.

if(!ForecastDecoder.feedForward((CNet*)GetPointer(StateEncoder),
                                StateRawForecastLayer,
                                (CBufferFloat*)NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__,__LINE__);
   Stop=true;
   break;
  }

Декодер сравнивает восстановление с фактическим продолжением. Ошибка рассчитывается обычным обратным проходом по целевому буферу.

if(!ForecastDecoder.backProp(GetPointer(bTarget),
                             (CBufferFloat*)NULL,
                             (CBufferFloat*)NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__,__LINE__);
   Stop=true;
   break;
  }

Но обучить один декодер недостаточно. Наша цель — изменить само рыночное представление. Поэтому рассчитанный градиент возвращается в StateEncoder через тот же прогнозный слой.

if(!StateEncoder.backPropGradient((CBufferFloat*)NULL,
                                  (CBufferFloat*)NULL,
                                  StateRawForecastLayer,
                                  true))
  {
   PrintFormat("%s -> %d", __FUNCTION__,__LINE__);
   Stop=true;
   break;
  }

Так прогнозная задача служит тренажёром для энкодера. ForecastDecoder после этого этапа в торговле не нужен. Нам нужен обученный StateEncoder, который устойчиво кодирует текущее состояние рынка и формирует основу для будущего retrieval.

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

Следующий этап — Study. Здесь уже обучаются Актёр и Критик, а энкодер переводится в режим эксплуатации. Одновременно все три сети получают общий OpenCL-контекст.

cActor.TrainMode(true);
cCritic.TrainMode(true);
cStateEncoder.TrainMode(false);

COpenCL *opencl=cActor.GetOpenCL();
cCritic.SetOpenCL(opencl);
cStateEncoder.SetOpenCL(opencl);

Это важная граница между двумя этапами. С этого момента торговая политика может меняться, но пространство, в котором хранятся и сравниваются сценарии, остаётся фиксированным.

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

После сетей создаётся сама RAG-память. В описание передаются её размеры и параметры, а объект получает тот же OpenCL-контекст.

CLayerDescription memory_description;
if(!CreateRAGMemoryDescription(memory_description) ||
   !RAGMemory.Init(0, 0, opencl, memory_description) ||
   !RAGMemory.SetOnlineMemorySize(OnlineMemorySize))
   return INIT_FAILED;

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

const bool snapshot_loaded = (LoadBaseMemorySnapshot &&
                              RAGMemory.LoadPublishedSnapshot(BaseMemorySnapshotPath));

if(!snapshot_loaded && !RAGMemory.BindNullInference())
   return INIT_FAILED;

if(!RebindOfflineRAGMemory())
   return INIT_FAILED;

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

cActor.GetLayerOutput(0, Result);
if(Result.Total() != AccountDescr)
   return INIT_FAILED;

cStateEncoder.GetLayerOutput(0, Result);
if(Result.Total() != HistoryBars * BarDescr)
   return INIT_FAILED;

cStateEncoder.GetLayerOutput(StateTokenLayer,Result);
if(Result.Total() != BarDescr * EmbeddingSize)
   return INIT_FAILED;

cStateEncoder.GetLayerOutput(StateScenarioLayer,Result);
if(Result.Total() != EmbeddingSize)
   return INIT_FAILED;

После этих проверок начинается собственно обучение политики. Для выбранной позиции в истории CreateBuffers() формирует текущее рыночное состояние. SampleAccount() создаёт описание счёта, а OraculAction() — контрольное действие по известному продолжению истории.

if(!CreateBuffers(posit, GetPointer(bState), GetPointer(bTime), Result))
  {
   ExpertRemove();
   return;
  }
const vector<float> account = SampleAccount(GetPointer(bState), datetime(bTime[0]), MaxBalance, MinBalance);
const vector<float> target_action = OraculAction(account,Result);
if(!bContext.AssignArray(account))
  {
   ExpertRemove();
   return;
  }

Здесь важно не перепутать порядок. Контрольное действие уже рассчитано, но в Актёр оно ещё не передано. Сначала модель должна принять собственное решение. Поэтому прямой проход выполняется обычным рабочим маршрутом: энкодер, retrieval, Актёр и затем Критик.

if(!cStateEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false,(CBufferFloat*)NULL))
   return;
if(!RAGMemory.Retrieve(GetPointer(cStateEncoder), StateScenarioLayer))
   return;
if(!cActor.feedForward((CBufferFloat*)GetPointer(bContext), 1, false, GetPointer(cStateEncoder), StateScenarioLayer))
   return;
if(!cCritic.feedForward(GetPointer(cActor), -1, GetPointer(cStateEncoder), StateTokenLayer))
   return;

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

cActor.getResults(Action);
double balance = account[0] * EtalonBalance;
const uint terminal_position = uint(posit - NForecast + 1);

SRAGTerminalOutcome terminal;
bool terminal_evaluated = EvaluateTerminalOutcome(RAGMemory, Action, balance, terminal_position, terminal);
double reward = terminal.reward * balance / EtalonBalance;

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

Это отличие офлайн-этапа от обычного TD-обучения стоит держать в голове. Здесь мы не пытаемся угадать ценность следующего шага, потому что весь интересующий нас отрезок уже известен. Советник может проследить действие до завершения и сразу получить итог. Для Критика это более прямой обучающий сигнал. В online-режиме такой роскоши уже не будет, и там логика целевого значения меняется.

Result.Clear();
if(!Result.Add(float(reward)))
   return;
if(!cCritic.backProp(Result, GetPointer(cStateEncoder), StateTokenLayer))
   return;

До подстановки контрольного действия можно сохранить опыт текущей политики. Сначала советник получает ScenarioEmbedding из фиксированного StateEncoder и проверяет его размер.

CBufferFloat *scenario_embedding = NULL;
if(!cStateEncoder.GetLayerOutputDevice(StateScenarioLayer, scenario_embedding))
  {
   OfflineScenarioAcquireFailures++;
   CaptureOfflineRAGRejection("scenario_acquire", terminal_position);
  }
else
   if(scenario_embedding.Total() != EmbeddingSize)
     {
      OfflineScenarioShapeFailures++;
      CaptureOfflineRAGRejection("scenario_shape", terminal_position);
     }

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

float normalized_action[NActions];
ENUM_MEMORY_ACTION_NORMALIZATION_STAGE normalization_stage;
if(!NormalizeMemoryAction(RAGMemory, Action, terminal_position, balance, normalized_action, normalization_stage))
  {
   OfflineNormalizationFailures++;
   CaptureOfflineRAGRejection("normalize_" + MemoryActionNormalizationStageName(normalization_stage),
                                                                                              terminal_position);
  }

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

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

bool materially_changed = !OfflineHasLastStoredAction;
for(int i=0; i < NActions && !materially_changed; i++)
   materially_changed = (MathAbs(normalized_action[i] - OfflineLastStoredAction[i]) >= OfflineActionChangeThreshold);

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

float scenario_values[];
if(ArrayResize(scenario_values, EmbeddingSize) == EmbeddingSize &&
   scenario_embedding.GetData(scenario_values) == EmbeddingSize)
  {
   if(!RAGMemory.AddCompletedRecord(scenario_values, normalized_action, terminal))
     {
      OfflineRecordFailures++;
      PrintFormat("%s -> %d offline RAG record rejected", __FUNCTION__, __LINE__);
      Stop=true;
     }
   else
     {
      OfflineAcceptedRecords++;
      ArrayCopy(OfflineLastStoredAction, normalized_action);
      OfflineHasLastStoredAction=true;
     }
  }

Порядок здесь принципиален: в Base Memory попадает решение текущего Актёра. Контрольное target_action ещё не присвоено буферу Action. Поэтому память накапливает опыт политики, а не превращается в каталог заранее подготовленных ответов.

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

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

if(!Action.AssignArray(target_action))
   return;
reward = CheckAction(RAGMemory, Action, balance, posit - NForecast + 1) * balance / EtalonBalance;

Если контрольное действие оказалось полезным, Актёр получает прямой обучающий сигнал в его сторону. Для этого используется рыночный градиент из StateScenarioLayer.

CBufferFloat *actor_market_gradient = NULL;
if(!cStateEncoder.GetLayerOutputDevice(StateScenarioLayer, actor_market_gradient))
   return;
if(reward>0)
   if(!cActor.backProp(Action, actor_market_gradient, GetPointer(bGradient)))
      return;

Далее Критик должен научиться оценивать уже контрольное действие. Поэтому Критик снова выполняет прямой проход, целевой reward обновляется, после чего выполняется обратный проход Критика.

if(!cCritic.feedForward(Action, 1, false, GetPointer(cStateEncoder), StateTokenLayer))
   return;
if(!Result.Update(0, float(reward)))
   return;
if(!cCritic.backProp(Result, GetPointer(cStateEncoder), StateTokenLayer))
   return;

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

cCritic.TrainMode(false);
if(!cActor.feedForward((CBufferFloat*)GetPointer(bContext), 1, false, GetPointer(cStateEncoder), StateScenarioLayer) ||
   !cCritic.feedForward(GetPointer(cActor), -1, GetPointer(cStateEncoder), StateTokenLayer) ||
   !cCritic.backProp(Result, GetPointer(cStateEncoder), StateTokenLayer) ||
   !cActor.backPropGradient(actor_market_gradient, GetPointer(bGradient), -1, true))
   return;
cCritic.TrainMode(true);

Так в одной исторической итерации сходятся три источника информации: результат собственного действия Актёра, контрольное действие на известном будущем и оценка Критика. При этом StateEncoder остаётся неизменным, а RAG-память обновляется отдельно от градиентного обучения.

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

Новая запись памяти не становится доступной Актёру немедленно. AddCompletedRecord() кладёт её в pending-набор. Когда таких записей становится достаточно, советник запускает публикацию.

if(RAGMemory.PendingRecordCount() >= OnlineMemorySize &&
   !PublishOfflineMemory(false))
  {
   PrintFormat("%s -> %d offline memory publication failed", __FUNCTION__, __LINE__);
   Stop = true;
   break;
  }

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

if(!DetachOfflineRAGMemory())
   return false;
if(!RAGMemory.Publish(episode_closed))
  {
   RebindOfflineRAGMemory();
   return false;
  }
if(!RebindOfflineRAGMemory())
   return false;
if(!SaveOfflineBaseMemory())
   return false;

Практический смысл этого порядка простой: решение, которое сейчас читает память, не должно одновременно менять ту же версию памяти под собой. Новый опыт становится доступен только после согласованной публикации.

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

Если к концу эпохи pending-набор не достиг порога, оставшиеся записи всё равно нужно опубликовать. Для этого используется принудительный вызов с признаком закрытого эпизода.

if(!Stop && !PublishOfflineMemory(true))
   PrintFormat("%s -> %d offline memory publication failed", __FUNCTION__, __LINE__);

После Study у нас уже есть фиксированный StateEncoder, обученные Актёр и Критик, опубликованная Base Memory. Но здесь у советника была серьёзная привилегия: дальнейшее движение исторической последовательности известно заранее. StudyOnline эту привилегию убирает.

В последовательном режиме выход Актёра — лишь намерение. Результат должен пройти через торговое исполнение. Только подтверждённое изменение позиции может стать частью долгого RAG-сценария. Открытие начинает новый pending-сценарий, дальнейшие изменения дополняют его, а полное закрытие даёт терминальный результат.

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

Следующий блок в исходном тексте показывает именно укрупнённую логику завершения такого сценария. Это схема последовательности, а не дословный фрагмент StudyOnline.mq5.

if(position_completed && terminal_ready)
  {
   if(!RAGMemory.AddCompletedRecord(scenario_embedding, executed_action, terminal))
      return;
   ClearPendingFact();
  }

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

Актёр и Критик в StudyOnline могут обновляться гораздо чаще — по последовательным переходам между состояниями. RAG-память живёт на другом временном масштабе и ждёт завершения всей позиции. Такое разделение не даёт превратить её в ещё один replay buffer.

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

В итоге обучение идёт в трёх темпах. StudyStateEncoder сначала формирует и фиксирует пространство рынка. Study обучает торговую политику и собирает базовый исторический опыт. StudyOnline продолжает адаптацию уже в последовательной среде и связывает память с фактически исполненными сценариями. Именно после этого эту систему имеет смысл оценивать обычными торговыми метриками.


Тестирование модели

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

Для обучения использовались исторические данные EURUSD H1 за 2024–2025 годы. Итоговую проверку проводили на первом полугодии 2026 года. Этот участок не участвовал в обучении моделей и не использовался для построения Base Memory. Во время теста веса сетей и содержимое памяти не обновлялись: система могла читать накопленный опыт, но не могла подстроиться под уже увиденный результат.

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

Начальный депозит составлял 10 000 USD. За тестовый период модель заработала 2 974,77 USD и довела баланс до 12 974,77 USD. Формально это почти 29,75% за полгода. Но для торговой системы одной конечной прибыли недостаточно, поэтому сразу посмотрим, какой ценой она получена.

Profit Factor составил 1,30. Это положительное значение, но запас не выглядит большим: валовая прибыль 12 815,47 USD против валового убытка 9 840,70 USD. Ожидаемый результат одной сделки равен 5,12 USD. То есть преимущество есть, но оно формируется на длинной серии операций, а не на нескольких очевидно выигрышных входах.

Всего тестер зафиксировал 581 сделку и 937 исполнений. Прибыльными стали 295 сделок, убыточными — 286. Соотношение почти равное, поэтому основной вклад даёт не высокий процент попаданий, а размер результата. Средняя прибыльная сделка принесла 43,44 USD, средняя убыточная потеряла 34,41 USD.

Просадка при этом существенная. Максимальное снижение баланса достигло 31,63%, а по средствам — 36,60%. Разница между двумя цифрами хорошо видна и на графике: в ряде эпизодов открытые позиции уходили глубже в минус, чем успевал показать закрытый баланс. Recovery Factor составил 0,58, Sharpe Ratio — 0,93. Поэтому говорить о готовой торговой системе было бы преждевременно.

Загрузка депозита оставалась умеренной и не приближалась к верхней границе шкалы 25%. Минимальный Margin Level составил 757,22%. Значит, глубокая просадка возникла не из-за экстремального использования плеча. Для дальнейшей настройки стоит смотреть прежде всего на удержание позиции, момент выхода и реакцию на неблагоприятное развитие сценария.

Сам график полезнее читать не по последней точке, а по характеру движения. В начале теста модель почти не проявляла активности. Затем появилась более ровная серия сделок и рост баланса. Во второй половине февраля амплитуда резко увеличилась: появились крупные ступени по балансу и заметные расхождения между Balance и Equity. Самый тяжёлый участок пришёлся на март, после чего средства восстановились.

Есть и ещё один сигнал, который нельзя игнорировать. Из 581 сделки 531 была короткой и только 50 длинными. При этом 53,11% коротких сделок оказались прибыльными, а среди длинных — лишь 26%. Почти весь положительный результат относится к Sell-стороне. Это не обесценивает тест, но хорошо показывает, где искать следующий резерв улучшения.

Максимальная прибыль по одной сделке составила 1 440,87 USD, максимальный убыток — 813,11 USD. Крупная прибыльная операция заметно влияет на общий результат, а наиболее тяжёлая серия убытков тоже достаточно велика. Поэтому одной цифры Net Profit здесь явно недостаточно.

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

Для текущего этапа эксперимент отвечает на главный вопрос. Мы проверяли весь контур: фиксированный рыночный энкодер, поиск в RAG-памяти, Актёра и торговое исполнение. На новых данных система сохранила положительный итог и совершила достаточно большую серию сделок. Следующая задача уже не в доказательстве самой работоспособности схемы, а в снижении риска и устранении выраженного перекоса между направлениями.


Заключение

По итогам работы удалось довести VLADriver-RAG от набора отдельных компонентов до законченного торгового контура. Главное практическое решение оказалось не в добавлении ещё одного слоя, а в правильном разделении ответственности. StateEncoder сначала обучается описывать рынок и затем фиксируется. Актёр и Критик работают уже в устойчивом пространстве признаков. RAG-память не получает градиентов и накапливает завершённый опыт отдельно от параметрической модели.

Такой порядок решает проблему, поставленную во введении: память и торговая политика могут развиваться рядом, не разрушая друг другу основу. Актёр читает неизменяемый опубликованный снимок, новые записи накапливаются отдельно, а после консолидации подключается следующая версия памяти. В online-контуре дополнительно разделены намерение модели и фактическое исполнение, поэтому несостоявшаяся заявка не становится "воспоминанием" о сделке, которой на рынке не было.

Проверка на EURUSD H1 за первое полугодие 2026 года дала положительный результат: +2 974,77 USD к начальному депозиту 10 000 USD, Profit Factor 1,30 и 581 сделка. Это показывает, что собранная архитектура способна работать вне обучающего участка. Но тот же отчёт сразу показывает направление дальнейшей работы: максимальная просадка по средствам 36,60%, Recovery Factor 0,58 и сильный перекос в короткие позиции пока не позволяют считать модель готовой для практической торговли.

Для текущего исследовательского этапа основная задача решена: завершённые торговые сценарии стали рабочей долговременной памятью Актёра, а retrieval встроен в принятие решений без подмены текущего рыночного контекста. Дальнейшие эксперименты можно направить уже на качество поведения — риск, баланс Buy/Sell и устойчивость результата на других участках рынка.


Ссылки


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

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

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

Прикрепленные файлы |
MQL5.zip (3956.01 KB)
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Возможности Мастера MQL5, которые вам нужно знать (Часть 88): Использование фильтра Блума с пользовательским классом трейлинга Возможности Мастера MQL5, которые вам нужно знать (Часть 88): Использование фильтра Блума с пользовательским классом трейлинга
Следующая тема в серии идей, которые можно быстро прототипировать с помощью Мастера MQL5, – пользовательский класс трейлинга, использующий фильтр Блума. Системы трейлинг-стопов – необязательная, но полезная часть любой торговой системы. В этой серии они рассматриваются наряду с традиционными сигналами входа.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Изучение стандартной библиотеки MQL5 (Часть 7): Создание интерактивных меток позиций с помощью класса CCanvas Изучение стандартной библиотеки MQL5 (Часть 7): Создание интерактивных меток позиций с помощью класса CCanvas
В этой статье мы рассмотрим, как создать инструмент визуализации информации о позициях, используя класс CCanvas из стандартной библиотеки MQL5. Этот проект укрепит ваши навыки работы с библиотечными модулями и дает трейдерам практический инструмент для визуализации открытых позиций и взаимодействия с ними непосредственно на графике в реальном времени. Присоединяйтесь к обсуждению и узнайте больше.