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

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

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

Введение

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

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

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

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

Сходный принцип предложен авторами работы "VLADriver-RAG: Retrieval-Augmented Vision-Language-Action Models for Autonomous Driving". Фреймворк предназначен для автономного вождения и помогает модели работать с редкими дорожными ситуациями. Авторы выносят исторический опыт во внешнюю базу и ищут примеры не по поверхностному сходству кадров, а по структуре происходящего.

Текущая дорожная сцена преобразуется в последовательность пространственно-временных семантических графов. Затем Scenario-Aligned Embedding Model формирует компактный вектор, в котором расстояние отражает сходство логики сценариев. Найденные исторические примеры поступают в VLA‑модель вместе с текущим наблюдением и участвуют в построении новой траектории.

На уровне архитектуры это три связанных шага. Visual-to-Scenario отделяет значимые связи от визуального шума. Scenario-Aligned Embedding Model превращает структуру сцены в поисковый ключ. VLA Backbone объединяет найденные примеры с текущими данными и формирует новое решение. Для переноса в трейдинг важна именно эта последовательность, а не дорожная форма исходных данных.

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

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

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

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

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

Теперь нужно перейти от одной ячейки к полноценному хранилищу. Оно должно:

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

Этой задаче посвящена текущая статья.

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


Основные проблемы построения RAG‑памяти

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

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

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

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

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

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

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

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

Локальные пространства действий независимы. Первый центроид действия одного сценария не обязан соответствовать первому центроиду другого. Совпадение индексов означает только одинаковое место в массиве. Сравнивать и объединять нужно сами векторы действий.

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

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

Новый сценарий временно добавляется к сохранённым, и среди N+1 кандидатов ищется ближайшая пара. Если пара включает новый кандидат, он уточняет ближайшую старую ячейку. Если ближайшими оказываются два старых сценария, их объединяют, а освободившийся слот занимает новый опыт.

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

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

Поэтому память возвращает несколько кандидатов. Сначала вычисляется близость ко всем заполненным сценариям, затем формируется глобальный Top-K. Поиск разбивается на этапы, чтобы размер хранилища не зависел от возможностей одной рабочей группы OpenCL.

Найденный опыт передаётся модели в виде компактных RAG‑токенов. Токен включает:

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

Пустые позиции дают нули, поэтому форма выхода остаётся постоянной.

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


Объект RAG‑памяти

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

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

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

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

class CNeuronRAGMemory : public CNeuronBaseOCL
    {
  protected:
     uint              iScenarioEmbedding;
     uint              iActionDimension;
     uint              iTokenWidth;
     uint              iScenarioCapacity;
     uint              iActionCapacity;
     uint              iTopK;
     uint              iScenarioCount;
  
     SRAGScenarioCentroid scenarios[];
     CRAGInferenceSet  *m_inference;
  
     CBufferFloat      cNearestCentroids;
     CBufferFloat      cNearestUsed;
     CBufferFloat      cNearestResult;
  
     virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL) override;
  
     bool              Configure(CLayerDescription &description);
     bool              InitializeAction(SRAGActionCentroid &action,
                                        const uint scenario_id) const;
     bool              InitializeScenario(SRAGScenarioCentroid &scenario,
                                          const uint scenario_id) const;
  
     bool              ApplyCompletedRecordToSnapshot(
                                        SRAGScenarioCentroid &candidate_snapshot[],
                                        uint &candidate_count,
                                        const uint source_scenario_id,
                                        const float &scenario_embedding[],
                                        const float &action[],
                                        const SRAGTerminalOutcome &terminal);
  
     bool              ConsolidateActionCandidates(
                                        SRAGScenarioCentroid &candidate_snapshot[]);
  
     bool              BuildInferenceSet(
                                        const SRAGScenarioCentroid &snapshot[],
                                        const ulong fingerprint,
                                        CRAGInferenceSet *&result) const;
  
  public:
                       CNeuronRAGMemory(void);
                      ~CNeuronRAGMemory(void);
  
     bool              Init(uint numOutputs,
                            uint myIndex,
                            COpenCLMy *open_cl,
                            CLayerDescription &description);
  
     bool              AddCompletedScenario(
                                        const uint source_scenario_id,
                                        const float &scenario_embedding[],
                                        const float &action[],
                                        const SRAGTerminalOutcome &terminal);
  
     bool              Retrieve(CNet *scenario_model,
                                     const uint layer);
     bool              Retrieve(CBufferFloat *scenario_embedding);
  
     CBufferFloat     *Tokens(void) const;
    };

Поля iScenarioEmbedding и iActionDimension задают размер представления рыночного сценария и исполненного действия. iScenarioCapacity ограничивает число сценарных ячеек, а iActionCapacity — количество локальных вариантов поведения внутри каждой из них.

iTopK определяет глубину поиска, iTokenWidth — ширину выходного токена. iScenarioCount хранит фактическое число занятых сценарных позиций. Поэтому выделенная ёмкость памяти не смешивается с её текущим заполнением.

Массив scenarios содержит иерархическое представление на стороне MQL5. Указатель m_inference ведёт к плоским OpenCL-буферам, используемым при поиске. Служебные буферы позволяют повторно искать ближайшие пары без постоянного выделения памяти.

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

Конкретные размеры объект не задаёт. Все они являются гиперпараметрами модели и поступают через CLayerDescription. Метод Configure проверяет этот контракт и переносит значения во внутренние поля.

bool CNeuronRAGMemory::Configure(CLayerDescription &description)
    {
     if(description.type!=defNeuronRAGMemory ||
        description.window==0 ||
        description.window_out==0 ||
        description.count==0 ||
        description.layers==0 ||
        ArraySize(description.units)<1 ||
        ArraySize(description.heads)<1 ||
        description.units[0]==0 ||
        description.heads[0]==0 ||
        description.count!=description.window_out+2 ||
        description.layers>RAG_TOPK_MAX ||
        description.units[0]>RAG_FLOAT_INDEX_LIMIT ||
        description.units[0]>=(uint)INT_MAX ||
        description.heads[0]>=(uint)INT_MAX)
        ReturnFalse;
  
     iScenarioEmbedding=description.window;
     iActionDimension=description.window_out;
     iTokenWidth=description.count;
     iTopK=description.layers;
     iScenarioCapacity=description.units[0];
     iActionCapacity=description.heads[0];
  
     return true;
    }

Стандартные поля описания слоя получают здесь специальный смысл: window содержит размер ScenarioEmbedding, window_out — размер действия, layers — число возвращаемых сценариев. Ёмкости верхнего и нижнего уровней передаются через первые элементы массивов units и heads.

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

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

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

bool CNeuronRAGMemory::Init(uint numOutputs,
                              uint myIndex,
                              COpenCLMy *open_cl,
                              CLayerDescription &description)
    {
     if(!Configure(description))
        ReturnFalse;

     if(iTopK>UINT_MAX/iActionCapacity)
        ReturnFalse;
  
     const uint token_count=iTopK*iActionCapacity;
     if(token_count==0 ||
        iTokenWidth>UINT_MAX/token_count)
        ReturnFalse;

После проверки параметров вызывается метод инициализации родительского класса.

     if(!CNeuronBaseOCL::Init(numOutputs,
                              myIndex,
                              open_cl,
                              token_count*iTokenWidth,
                              description.optimization,
                              description.batch))
        ReturnFalse;
  
     if(ArrayResize(scenarios,
                    (int)iScenarioCapacity)!=(int)iScenarioCapacity)
        ReturnFalse;

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

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

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

      for(uint scenario=0;
         scenario<iScenarioCapacity;
         scenario++)
        if(!InitializeScenario(scenarios[scenario],scenario))
           ReturnFalse;
  
     iScenarioCount=0;
  
     return Clear();
    }

После инициализации базового OpenCL-слоя создаётся массив сценариев полной заданной ёмкости. Выделенная позиция ещё не содержит опыта: все ячейки сначала пусты.

bool CNeuronRAGMemory::InitializeScenario(
     SRAGScenarioCentroid &scenario,
     const uint scenario_id) const
    {
     scenario.used=false;
     scenario.scenario_id=scenario_id;
     scenario.hit_count=0;
  
     if(ArrayResize(scenario.centroid,
                    (int)iScenarioEmbedding)!=(int)iScenarioEmbedding)
        return false;
  
     ArrayInitialize(scenario.centroid,0.0f);
  
     if(ArrayResize(scenario.actions,
                    (int)iActionCapacity)!=(int)iActionCapacity)
        return false;
  
     for(uint action=0;
         action<iActionCapacity;
         action++)
        if(!InitializeAction(scenario.actions[action],
                             scenario_id))
           return false;
  
     return true;
    }

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

bool CNeuronRAGMemory::InitializeAction(
     SRAGActionCentroid &action,
     const uint scenario_id) const
    {
     action.used=false;
     action.scenario_id=scenario_id;
     action.hit_count=0;
     action.mean_reward=0.0f;
  
     ClearTerminal(action.mean_terminal);
  
     if(ArrayResize(action.centroid,
                    (int)iActionDimension)!=(int)iActionDimension)
        return false;
  
     ArrayInitialize(action.centroid,0.0f);
  
     return true;
    }

После инициализации объект уже имеет полную геометрию памяти, но не содержит ни одного торгового сценария. Все позиции доступны для заполнения, а iScenarioCount равен нулю.

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

Добавление завершённого факта выполняется отдельно от прямого прохода. Метод AddCompletedScenario сначала создаёт рабочую копию памяти. Только после успешной обработки она заменяет текущее содержимое.

bool CNeuronRAGMemory::AddCompletedScenario(
     const uint source_scenario_id,
     const float &scenario_embedding[],
     const float &action[],
     const SRAGTerminalOutcome &terminal)
    {
     SRAGScenarioCentroid candidate_snapshot[];
     uint candidate_count=iScenarioCount;
  
     if(!CopySnapshot(scenarios,candidate_snapshot) ||
        !ApplyCompletedRecordToSnapshot(candidate_snapshot,
                                        candidate_count,
                                        source_scenario_id,
                                        scenario_embedding,
                                        action,
                                        terminal) ||
        !ConsolidateActionCandidates(candidate_snapshot))
        return false;
  
     ArraySwap(scenarios,candidate_snapshot);
     iScenarioCount=candidate_count;
  
     return true;
    }

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

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

Основной выбор выполняется внутри ApplyCompletedRecordToSnapshot. Эмбеддинг нового сценария нормируется, после чего объект ищет свободную позицию.

  int free_scenario=-1;
  for(uint scenario=0;
      scenario<iScenarioCapacity;
      scenario++)
     if(!candidate_snapshot[scenario].used)
       {
        free_scenario=(int)scenario;
        break;
       }

Пока свободный слот существует, консолидация не нужна. Новый сценарий получает собственную ячейку и первое действие.

   if(free_scenario>=0)
    {
     SRAGScenarioCentroid incoming;
     if(!InitializeScenario(incoming,source_scenario_id))
        return false;
  
     incoming.used=true;
     incoming.hit_count=1;
     ArrayCopy(incoming.centroid,normalized);
  
     incoming.actions[0].used=true;
     incoming.actions[0].scenario_id=source_scenario_id;
     incoming.actions[0].hit_count=1;
     incoming.actions[0].mean_reward=terminal.reward;
     incoming.actions[0].mean_terminal=terminal;
     ArrayCopy(incoming.actions[0].centroid,action);
  
     RebindScenarioActions(incoming,(uint)free_scenario);
  
     if(!CopyScenario(incoming,
                      candidate_snapshot[free_scenario]))
        return false;
  
     candidate_count++;
     return true;
    }

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

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

Затем вызывается рассмотренная в первой статье операция поиска ближайшей пары.

  int destination=-1;
  int source=-1;
  
  if(!FindNearestScenarioPair(scenario_candidates,
                              destination,
                              source) ||
     !MergeScenarioCandidates(scenario_candidates[destination],
                              scenario_candidates[source]))
     return false;

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

  if(source==(int)iScenarioCapacity)
     return true;

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

  RebindScenarioActions(
     scenario_candidates[iScenarioCapacity],
     (uint)source);
  
  if(!CopyScenarioFlexible(
        scenario_candidates[iScenarioCapacity],
        candidate_snapshot[source]))
     return false;

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

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

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

  const ulong destination_hits=destination.hit_count;
  const ulong source_hits=source.hit_count;
  
  for(uint element=0;
      element<iScenarioEmbedding;
      element++)
     weighted[element]=
        (float)(destination.centroid[element]*destination_hits+
                source.centroid[element]*source_hits);
  
  if(!NormalizeL2(weighted,destination.centroid))
     ArrayCopy(destination.centroid,previous_centroid);
  
  destination.hit_count=destination_hits+source_hits;

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

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

После пересчёта рыночного центра в итоговую ячейку переносятся действия второго сценария.

   for(int action=0;
      action<ArraySize(source.actions);
      action++)
     if(source.actions[action].used &&
        !AppendActionCandidate(destination.scenario_id,
                               destination,
                               source.actions[action]))
        return false;

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

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

  int used_actions=0;
  
  for(int action=0;
      action<candidates;
      action++)
     if(candidate_snapshot[scenario].actions[action].used)
        used_actions++;
  
  if(used_actions>(int)iActionCapacity)
    {
     const int active = ArraySize(activeScenarios);
     if(ArrayResize(activeScenarios, active + 1) != active + 1)
        return false;
     activeScenarios[active] = (int)scenario;
     if(candidates > maxCandidates)
        maxCandidates = candidates;
    }

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

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

После выбора пары MergeActionCandidates пересчитывает действие, среднюю награду и терминальную статистику с весами по числу наблюдений.

  const ulong destination_hits=destination.hit_count;
  const ulong source_hits=source.hit_count;
  const double total=(double)destination_hits+
                     (double)source_hits;
  
  for(uint element=0;
      element<iActionDimension;
      element++)
     destination.centroid[element]=
        (float)((destination.centroid[element]*destination_hits+
                 source.centroid[element]*source_hits)/total);
  
  destination.mean_reward=
     (float)((destination.mean_reward*destination_hits+
              source.mean_reward*source_hits)/total);
  
  MergeTerminal(destination.mean_terminal,
                destination_hits,
                source.mean_terminal,
                source_hits);
  
  destination.hit_count=destination_hits+source_hits;  

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

Процесс повторяется, пока число активных действий не вернётся к заданному пределу. Затем массив уплотняется, а всем элементам назначается идентификатор итогового сценария.

  for(uint scenario=0;
      scenario<iScenarioCapacity;
      scenario++)
     if(!CompactActionCandidates(candidate_snapshot[scenario]) ||
        !RebindScenarioActions(candidate_snapshot[scenario],scenario))
        return false;

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

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

Для обновления вложенная структура удобна, но OpenCL‑ядрам нужны непрерывные массивы. Метод BuildInferenceSet разворачивает иерархию в плоские буферы.

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

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

  float flat_centroids[];
  float flat_used[];
  float flat_actions[];
  float flat_action_used[];
  float flat_action_mean_reward[];
  float flat_action_hit_count[];

Свободные позиции остаются нулевыми. Для заполненного сценария копируется центроид и включается соответствующая маска.

  for(uint scenario=0;
      scenario<iScenarioCapacity;
      scenario++)
    {
     if(!snapshot[scenario].used)
        continue;
  
     flat_used[scenario]=1.0f;
  
     for(uint element=0;
         element<iScenarioEmbedding;
         element++)
        flat_centroids[scenario*iScenarioEmbedding+element]=snapshot[scenario].centroid[element];

Действия размещаются последовательными блоками. Линейный адрес определяется номером сценария и локальным индексом действия.

const uint slot = scenario*iActionCapacity+action;  

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

Маски flat_used и flat_action_used показывают только наличие данных. Релевантность появляется позже, когда текущий ScenarioEmbedding сравнивается с сохранёнными центрами.

После подготовки буферов объект может выполнять прямой проход. Его метод остаётся коротким:

bool CNeuronRAGMemory::feedForward(
     CNeuronBaseOCL *NeuronOCL)
    {
     return Retrieve(
        NeuronOCL==NULL ?
        NULL :
        NeuronOCL.getOutput());
    }

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

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

Метод Retrieve проверяет необходимые буферы и запускает единый поисковый конвейер.

return DispatchRAGPipeline(
     OpenCL,
     scenario_embedding,
     m_inference.centroids,
     m_inference.used,
     m_inference.scores,
     m_inference.partial,
     m_inference.merge,
     m_inference.top,
     m_inference.actions,
     m_inference.action_used,
     m_inference.action_mean_reward,
     m_inference.action_hit_count,
     m_inference.absolute_relevance,
     m_inference.tokens,
     iScenarioCapacity,
     iScenarioEmbedding,
     iActionCapacity,
     iActionDimension,
     iTokenWidth,
     iTopK);

Полный путь состоит из четырёх этапов: RAGScoreRAGPartialRAGMergeRAGGather.

RAGScore уже рассматривался в первой статье. Здесь он формирует массив оценок сходства текущего эмбеддинга со всеми заполненными сценарными центрами.

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

RAGPartial делит оценки на блоки и выбирает лучшие результаты внутри каждой рабочей группы.

__kernel void RAGPartial(__global const float *scores,
                         __global float2 *partial,
                         const int scenario_count,
                         const int block_size,
                         const int top_k)
    {
     const int lane=get_local_id(0);
     const int block=get_group_id(0);
     const int scenario=block*block_size+lane;
     __local float2 candidates[LOCAL_ARRAY_SIZE];
  
     if(block_size<=0 ||
        block_size>LOCAL_ARRAY_SIZE ||
        (block_size&(block_size-1))!=0 ||
        top_k<=0 ||
        top_k>block_size)
        return;
  
     const float score = (scenario<scenario_count ? scores[scenario] :
                                                           MIN_VALUE);
  
     candidates[lane] = (score>MIN_VALUE ? (float2)((float)scenario,score) :
                                                 (float2)(-1.0f,MIN_VALUE));
     BarrierLoc;
  
     RAGSortDescending(candidates,lane,block_size);
     if(lane<top_k)
        partial[block*top_k+lane]=candidates[lane];
    }

Каждый кандидат хранит исходный номер сценария и его оценку. После локальной bitonic‑сортировки в выходной массив попадает только частичный Top-K. Остальные элементы больше не участвуют в поиске.

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

Далее RAGMerge применяет тот же принцип к уже отобранным кандидатам.

__kernel void RAGMerge(__global const float2 *source,
                       __global float2 *destination,
                       const int candidate_count,
                       const int block_size,
                       const int top_k)
    {
     const int lane=get_local_id(0);
     const int block=get_group_id(0);
     const int candidate_id = block*block_size+lane;
     __local float2 candidates[LOCAL_ARRAY_SIZE];
  
     if(block_size<=0 ||
        block_size>LOCAL_ARRAY_SIZE ||
        (block_size&(block_size-1))!=0 ||
        top_k<=0 ||
        top_k>block_size)
        return;
  
     const float2 candidate = (candidate_id<candidate_count ? source[candidate_id] :
                                                         (float2)(-1.0f,MIN_VALUE));
  
     candidates[lane] = ((int)candidate.x>=0 && candidate.y > MIN_VALUE ? candidate :
                                                          (float2)(-1.0f,MIN_VALUE));
     BarrierLoc;
  
     RAGSortDescending(candidates,lane,block_size);
     if(lane<top_k)
        destination[block*top_k+lane]=candidates[lane];
    }

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

while(merge_count>block_size)
    {
     merge_count=next_count;
     CBufferFloat *swap=source;
     source=destination;
     destination=swap;
    }

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

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

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

После получения итоговых идентификаторов RAGGather извлекает локальные действия найденных сценариев и формирует токены.

Сначала ядро проверяет, существует ли кандидат и заполнена ли конкретная позиция действия. При отсутствии данных весь токен обнуляется.

  if(scenario<0 ||
     scenario>=scenario_count ||
     top[rank].y<=MIN_VALUE)
    {
     for(int d=0;d<width;d++)
        tokens[out+d]=0.0f;
  
     return;
    }
  
  const int slot=
     scenario*action_count+action;
  
  const int valid=
     (action_used[slot]>0.5f &&
      action_hit_count[slot]>0.0f);

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

  const float raw_relevance = exp(clamp( IsNaNOrInf(top[rank].y,MIN_VALUE), -16.0f, 16.0f)) *
                              log(1.0f + fmax(IsNaNOrInf(action_hit_count[slot],0.0f), 0.0f));

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

Полученная величина нормируется по всем допустимым действиям выбранных сценариев.

  const float weighted_relevance = (relevance_sum>1.0e-20f ? raw_relevance/relevance_sum :0.0f);

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

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

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

  for(int d=0; d<action_dimension; d++)
     tokens[out+d] = IsNaNOrInf( actions[slot*action_dimension+d], 0.0f);
  
  tokens[out+action_dimension] = weighted_relevance;
  
  tokens[out+action_dimension+1] = IsNaNOrInf(action_mean_reward[slot],0.0f);

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

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


Заключение

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

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

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

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

Механизм чтения также завершён. RAGScore оценивает близость, RAGPartial и RAGMerge формируют глобальный Top-K, а RAGGather превращает действия найденных сценариев в токены постоянной ширины.

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

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

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


Ссылки


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

# Имя Тип Описание
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 (3926.58 KB)
Автоматизация торговых стратегий в MQL5 (Часть 44): Обнаружение смены характера рынка (CHoCH) по пробоям свинговых экстремумов Автоматизация торговых стратегий в MQL5 (Часть 44): Обнаружение смены характера рынка (CHoCH) по пробоям свинговых экстремумов
В этой статье мы разрабатываем на MQL5 систему обнаружения смены характера рынка (Change of Character, CHoCH), которая определяет свинговые максимумы и минимумы на заданном пользователем количестве баров, присваивает максимумам метки HH/LH, а минимумам — LL/HL для определения направления тренда и торгует пробои этих свинговых уровней при изменении структуры рынка, что указывает на возможный разворот.
Моделирование рынка: Position View (XII) Моделирование рынка: Position View (XII)
В этой статье вы научитесь создавать визуальный сигнал на своей торговой платформе, чтобы прямо на графике определять, является ли позиция длинной или короткой, без необходимости обращаться к терминалу. Кроме того, в тексте рассматривается реализация функциональности, которая улучшает отображение при перемещении линий тейк-профита и стоп-лосса, скрывая горизонтальную линию, следующую за курсором мыши, во время перемещения этих линий во избежание путаницы. Чтение предлагает практические знания для настройки систем моделирования рынка.
ARD-регрессия на MQL5: автоматический отбор признаков и случайные признаки Фурье ARD-регрессия на MQL5: автоматический отбор признаков и случайные признаки Фурье
Статья посвящена ARD-регрессии как байесовскому методу отбора признаков и применению случайных признаков Фурье (Random Fourier Features) для учета нелинейностей в рамках линейной модели. К статье прилагаются классы ARDRegression и CRBFSampler. Проводится сравнительный анализ точности и производительности реализации на MQL5 с эталонным классом из библиотеки scikit-learn.
Автоматизация торговых стратегий в MQL5 (Часть 43): Адаптивная стратегия на основе канала линейной регрессии Автоматизация торговых стратегий в MQL5 (Часть 43): Адаптивная стратегия на основе канала линейной регрессии
В этой статье мы реализуем в MQL5 адаптивную систему на основе канала линейной регрессии. Она автоматически рассчитывает линию регрессии и канал стандартного отклонения за заданный пользователем период, активируется только тогда, когда абсолютное значение наклона превышает минимальный порог, что подтверждает наличие выраженного тренда, а также динамически перестраивает или продлевает канал, когда цена выходит за его границы на настраиваемую долю ширины.