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

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

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

Введение

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

Эта идея продолжает авторский фреймворк D2Skill, где политика дополняется динамическими банками двух уровней. Task Skills относятся к задаче в целом, Step Skills — к отдельным состояниям внутри траектории. Навыки извлекаются по контексту, а их ценность уточняется ретроспективно по результатам парных проходов одной политики без навыков и с их применением. Такое разделение важно не только в названии банков. Общий навык сопровождает более широкий контекст, тогда как локальный должен срабатывать лишь там, где повторяется соответствующее состояние. В нашей реализации меняется форма памяти. Вместо текстовой рекомендации банк хранит векторную коррекцию скрытого состояния. Но логика остаётся прежней — опыт должен быть привязан к контексту, извлекаться только там, где он уместен, и со временем подтверждать свою практическую ценность.

В первой части мы сознательно оставили весь банк за рамками эксперимента и работали с одним Skill. Это позволило проверить основу без влияния retrieval и правил отбора. Мы сравнили два представления коррекции, остановились на раздельном накоплении направления и масштаба и показали, что устойчивое направление сохраняется даже при отдельных выбросах. Одновременно был построен GPU-конвейер обновления и применения латентной поправки. Тем самым вопрос "может ли отдельный Skill вообще сформироваться и работать внутри нашей модели?" был закрыт.

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

Именно этот переход от одиночной коррекции к управляемой памяти составляет предмет текущего этапа нашей работы. Сначала разберём решения, необходимые банку в торговой задаче. Затем проследим их в реализации: от поиска слота и формирования Candidate до жизненного цикла, Influence и Utility. В результате проследим путь данных: от текущего скрытого состояния до обновления памяти после завершения торговой последовательности.

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


Когда одного навыка становится недостаточно

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

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

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

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

После подтверждения возникает задача сохранения. Ёмкость банка фиксирована, а финансовые режимы способны возвращаться после долгих пауз, поэтому правило "удалить самый старый" здесь слишком грубое. Новый Skill получает защитный период, затем становится активным и только после длительного отсутствия обращений переходит в Inactive. Лишь на этом этапе он может уступить место новому опыту, причём при выборе учитываются и возраст, и накопленная Utility. В результате редкий, но полезный режим не исчезает только потому, что давно не встречался.

Сам факт выбора Skill ещё ничего не говорит о его пользе. Similarity отвечает за применимость опыта, Influence — за совпадение коррекции с псевдоостатком, Utility — за связь воздействия с результатом. Именно эта последовательность замыкает цикл: перед решением банк обращается к прошлому, после обратного прохода уточняет память, а после завершения эпизода переоценивает ценность использованных навыков.

Теперь можно перейти от требований к реализации. Важно проследить не отдельные служебные вызовы MQL5, а сам поток решений: как текущее состояние превращается в score слотов, почему некоторые элементы отбрасываются ещё до Top-1, как новый псевдоостаток попадает в существующий Skill или Candidate и каким образом итог сделки возвращается в банк через Utility.


Объект банка навыков

В библиотеке эта логика собрана в классе CD2SkillBank, который наследует CD2SkillItem. Наследование здесь задаёт понятную границу ответственности. Базовый объект уже умеет добавить готовую коррекцию к скрытому состоянию; банк не дублирует этот механизм, а решает всё, что происходит до применения и после него: хранит несколько вариантов опыта, выбирает нужный слот, обновляет его и следит за жизненным циклом.

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

   uint                         m_slots;
   uint                         m_dimension;
   bool                         m_enabled;
   float                        m_similarity_threshold;
   float                        m_direction_threshold;
   float                        m_utility_weight;
   float                        m_alpha;
   float                        m_min_utility;
   bool                         m_utility_aware;
   float                        m_beta_key;
   float                        m_beta_utility;
   float                        m_max_correction;
   uint                         m_min_confirmations;
   uint                         m_protection_age;
   uint                         m_inactivity_age;
   uint                         m_active_slot;
   CBufferFloat                 m_keys;
   CBufferFloat                 m_corrections;
   CBufferFloat                 m_directions;
   CBufferFloat                 m_scales;
   CBufferFloat                 m_utility;
   CBufferFloat                 m_observations;
   CBufferFloat                 m_uses;
   CBufferFloat                 m_mass;
   CBufferFloat                 m_used;
   CBufferFloat                 m_state;
   CBufferFloat                 m_age;
   CBufferFloat                 m_protection;
   CBufferFloat                 m_influence;
   CBufferFloat                 m_influence_total;
   CBufferFloat                 m_influence_partials;
   CBufferFloat                 m_gradient_influence_total;
   CBufferFloat                 m_gradient_influence_partials;
   CBufferFloat                 m_retrieved;

Векторные данные разложены по плоским массивам. При N слотах и размерности D буферы m_keys, m_corrections и m_directions содержат по N×D значений, тогда как Utility, возраст, состояние и остальные характеристики требуют по одному числу на слот. Такое размещение позволяет обрабатывать весь банк одной параллельной задачей и не заставляет CPU последовательно обходить отдельные объекты. Каждый слот похож на запись о торговом сетапе: ключ описывает условия, коррекция — реакцию модели. Статистика показывает, насколько опыт подтверждён и как он проявлялся на практике. При этом сама запись остаётся числовой. Нам не нужно заранее решать, что перед нами "пробой", "флэт" или "новостной импульс": границы режима формируются в том же скрытом пространстве, в котором Actor принимает решение.

Отсюда естественно начинается retrieval. Текущее скрытое состояние выступает запросом, а все ключи банка — набором кандидатов. Кернел D2SkillScoreSlots не пытается сразу выбрать победителя. Он раскладывает сравнение на простые покомпонентные операции, которые затем можно независимо свести по каждому слоту.

__kernel void D2SkillScoreSlots(__global const float *query,
                                __global const float *keys,
                                __global float *partials,
                                const int slots,
                                const int dimension)
  {
   const int index = get_global_id(0);
   const int total = slots * dimension;
   if(index < 0 || index >= total || dimension <= 0)
      return;
   const int component = index % dimension;
   const float q = IsNaNOrInf(query[component], 0.0f);
   const float k = IsNaNOrInf(keys[index], 0.0f);
   const float dot = q * k;
   const float query2 = q * q;
   const float key2 = k * k;
   const int base = index * 4;
   partials[base] = IsNaNOrInf(dot, 0.0f);
   partials[base + 1] = IsNaNOrInf(query2, 0.0f);
   partials[base + 2] = IsNaNOrInf(key2, 0.0f);
   partials[base + 3] = (!isfinite(dot) || !isfinite(query2) || !isfinite(key2) ? 1.0f : 0.0f);
  }

Для каждой пары компонентов сохраняются произведение q·k, квадраты и , а также признак числовой ошибки. После многоступенчатой редукции эти значения превращаются в hTkj, ‖h‖² и ‖kj‖², то есть дают всё необходимое для косинусного сходства. Важен и сам способ расчёта: плоский индекс охватывает сразу N×D координат, поэтому GPU сравнивает запрос со всем банком параллельно, а повреждённые NaN/Inf помечаются ещё до этапа выбора. Для торговой модели это означает, что рост банка увеличивает объём параллельной работы, но не превращает каждое решение в длинный цикл чтения памяти на CPU. Такой контракт особенно важен в онлайне, где retrieval потенциально выполняется на каждом новом состоянии.

Однако косинус отвечает только на вопрос "кто ближе", а банку нужен более строгий ответ — "есть ли среди сохранённых навыков действительно подходящий". Поэтому сведённые метрики передаются в D2SkillScoreFinalize, где каждый слот сначала считается непригодным и лишь затем получает право участвовать в Top-1.

__kernel void D2SkillScoreFinalize(__global const float *metrics,
                                   __global const float *utility,
                                   __global const float *used,
                                   __global const float *state,
                                   __global float *scores,
                                   const int slots,
                                   const float similarity_threshold,
                                   const float utility_weight,
                                   const float min_utility,
                                   const int utility_policy)
  {
   const int slot = get_global_id(0);
   if(slot < 0 || slot >= slots)
      return;
   const int base = slot * 4;
   const float dot = IsNaNOrInf(metrics[base], 0.0f);
   const float query2 = IsNaNOrInf(metrics[base + 1], 0.0f);
   const float key2 = IsNaNOrInf(metrics[base + 2], 0.0f);
   const float invalid_reduction = IsNaNOrInf(metrics[base + 3], 1.0f);
   float score = RAG_INVALID_SCORE;
   const int slot_state = (int)IsNaNOrInf(state[slot], (float)D2SKILL_INACTIVE);

Проверка начинается с самого статуса элемента. Рабочий retrieval использует только занятые слоты в состояниях Protected и Active: Candidate ещё не подтвердил закономерность, а Inactive уже выведен из текущего применения. Одновременно отбрасываются некорректные редукции и нулевые нормы, после чего для оставшихся элементов вычисляется косинус.

   if(invalid_reduction <= 0.0f && IsNaNOrInf(used[slot], 0.0f) > 0.5f &&
      slot_state != D2SKILL_INACTIVE && slot_state != D2SKILL_CANDIDATE &&
      query2 > 1.0e-12f && key2 > 1.0e-12f)
     {
      const float cosine = dot / (sqrt(query2) * sqrt(key2));
      const float slot_utility = clamp(IsNaNOrInf(utility[slot], 0.0f), -1.0f, 1.0f);

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

      if(isfinite(cosine) && cosine >= similarity_threshold &&
         (utility_policy == 0 || slot_utility >= min_utility))
        {
         const float candidate_score = cosine + utility_weight * slot_utility;
         if(isfinite(candidate_score))
            score = candidate_score;
        }
     }
   scores[slot] = score;
  }

Итоговый score имеет вид "сходство плюс небольшой вес Utility". Такой порядок сохраняет приоритет контекста: прошлый успешный Skill не переносится в чужой рынок только из-за высокой статистики. Например, навык, хорошо работавший в затяжном тренде, может иметь высокую Utility, но при переходе рынка в узкий диапазон он сначала должен пройти тот же порог сходства, что и любой другой элемент. Только после этого накопленная полезность помогает выбрать между несколькими действительно похожими сценариями. Из допустимых вариантов Top-1 определяется готовыми кернелами RAGPartial и RAGMerge; здесь переиспользуется лишь параллельный алгоритм максимума, а не семантика RAG-памяти. После редукции остаётся индекс одного слота, который и нужно превратить в реальную коррекцию.

__kernel void D2SkillGatherCorrection(__global const float2 *top,
                                      __global const float *corrections,
                                      __global const float *directions,
                                      __global const float *scales,
                                      __global float *selected_correction,
                                      __global float *selected_slot,
                                      __global float *selected_score,
                                      __global float *uses,
                                      __global float *diagnostics,
                                      const int slots,
                                      const int dimension,
                                      const int representation,
                                      const float alpha,
                                      __global float *retrieved,
                                      const int mark_retrieved)
  {
   const int component = get_global_id(0);
   const float2 top_slot = top[0];
   const int winner = (int)top_slot.x;
   const float score = IsNaNOrInf(top_slot.y, RAG_INVALID_SCORE);
   const int valid = (winner >= 0 && winner < slots && score > RAG_INVALID_SCORE);

Кернел D2SkillGatherCorrection знает представление Skill и собирает одну координату на поток. В полном режиме он читает сохранённый вектор, в Direction–Magnitude умножает нормализованное направление на масштаб. Коэффициент alpha задаёт силу вмешательства, поэтому одно и то же содержимое банка можно применять мягче или жёстче без изменения самих навыков.

   if(component < dimension)
     {
      float correction = 0.0f;
      if(valid)
        {
         const int shift = winner * dimension + component;
         correction = (representation == 1 ?
                       IsNaNOrInf(directions[shift], 0.0f) *
                       IsNaNOrInf(scales[winner], 0.0f) :
                       IsNaNOrInf(corrections[shift], 0.0f));
         correction = IsNaNOrInf(correction * IsNaNOrInf(alpha, 0.0f), 0.0f);
        }
      selected_correction[component] = correction;
     }

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

   if(component == 0)
     {
      selected_slot[0] = (valid ? (float)winner : -1.0f);
      selected_score[0] = (valid ? score : RAG_INVALID_SCORE);
      if(valid && mark_retrieved != 0)
       {
          uses[winner] = IsNaNOrInf(uses[winner], 0.0f) + 1.0f;
          retrieved[winner] = 1.0f;
         diagnostics[0] = IsNaNOrInf(diagnostics[0], 0.0f) + 1.0f;
        }
      else
         if(!valid && mark_retrieved != 0)
           {
            diagnostics[1] = IsNaNOrInf(diagnostics[1], 0.0f) + 1.0f;
           }
     }
  }

Эту разницу задаёт mark_retrieved. В рабочем forward выбранный слот увеличивает uses, получает маркер retrieved и тем самым сообщает жизненному циклу, что навык действительно понадобился. Метод Observe() выполняет тот же поиск без этих побочных эффектов: слот известен последующему обновлению, но коррекция не вмешивается в Actor и обращение не считается эксплуатацией. Так один и тот же механизм retrieval используется и для применения опыта, и для его сбора, не смешивая две статистики.

После прямого прохода логика разворачивается в другую сторону. Теперь банку нужно не выбрать прошлый опыт, а решить судьбу нового псевдоостатка g=−∂L/∂h. Прежде чем сравнивать его с существующими направлениями, система обновляет возраст и статус всех слотов. Пять состояний задают жизненный цикл памяти.

#define D2SKILL_FREE       0
#define D2SKILL_CANDIDATE  1
#define D2SKILL_PROTECTED  2
#define D2SKILL_ACTIVE     3
#define D2SKILL_INACTIVE   4

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

__kernel void D2SkillAgeLifecycle(__global const float *used,
                                  __global float *state,
                                  __global float *age,
                                  __global float *protection,
                                  __global const float *utility,
                                  __global float *free_scores,
                                  __global float *eviction_scores,
                                   const int slots,
                                   const int inactivity_age,
                                   __global float *retrieved,
                                   __global float *candidate_scores)
  {
   const int slot = get_global_id(0);
   if(slot >= slots)
      return;
   const float is_used = IsNaNOrInf(used[slot], 0.0f);
   int slot_state = (int)IsNaNOrInf(state[slot], (float)D2SKILL_FREE);
   float slot_age = max(IsNaNOrInf(age[slot], 0.0f), 0.0f);
   float slot_protection = max(IsNaNOrInf(protection[slot], 0.0f), 0.0f);
    if(is_used > 0.5f && slot_state != D2SKILL_CANDIDATE)
      {
       const bool was_retrieved = (IsNaNOrInf(retrieved[slot], 0.0f) > 0.5f);
       slot_age = (was_retrieved ? 0.0f : min(slot_age + 1.0f, (float)MAX_VALUE));
       retrieved[slot] = 0.0f;
      slot_protection = max(slot_protection - 1.0f, 0.0f);
      if(slot_state == D2SKILL_PROTECTED && slot_protection <= 0.0f)
         slot_state = D2SKILL_ACTIVE;
      if(slot_state == D2SKILL_ACTIVE && slot_age >= (float)max(1, inactivity_age))
         slot_state = D2SKILL_INACTIVE;
      state[slot] = (float)slot_state;
      age[slot] = slot_age;
      protection[slot] = slot_protection;
     }

Когда защита заканчивается, Protected становится Active; если затем активный Skill слишком долго не нужен, он переходит в Inactive. На том же проходе формируются три массива оценок: свободные места, существующий Candidate и элементы, которые разрешено вытеснить. Для вытеснения допускается только Inactive без защиты, а его score растёт при низкой Utility и большом возрасте. Здесь возраст следует читать как время без реального использования, а не как число прошедших баров или календарных дней само по себе. Если редкий рыночный режим вернулся и Skill снова был применён, счётчик обнуляется. Поэтому сезонный или кризисный паттерн может долго лежать в памяти и не считаться "старым" в том же смысле, что регулярно игнорируемый элемент.

   free_scores[slot] = (is_used <= 0.5f && slot_state == D2SKILL_FREE ?
                        -(float)slot : RAG_INVALID_SCORE);
   eviction_scores[slot] = (is_used > 0.5f && slot_state == D2SKILL_INACTIVE &&
                            slot_protection <= 0.0f ?
                            -clamp(IsNaNOrInf(utility[slot], 0.0f), -1.0f, 1.0f) +
                            1.0e-4f * slot_age : RAG_INVALID_SCORE);
   candidate_scores[slot] = (is_used > 0.5f && slot_state == D2SKILL_CANDIDATE ?
                             -(float)slot : RAG_INVALID_SCORE);
  }

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

__kernel void D2SkillMetricPartial(__global const float *query,
                                   __global const float *gradient,
                                   __global const float *selected_slot,
                                   __global const float *keys,
                                   __global const float *directions,
                                   __global const float *used,
                                   __global const float2 *candidate_top,
                                   __global const float *candidate_key,
                                   __global const float *candidate_direction,
                                   __global float *partials,
                                   const int slots,
                                   const int dimension)
  {
   const int component = get_global_id(0);
   if(component < 0 || component >= dimension)
      return;
   const int selected = (int)round(selected_slot[0]);
   const int selected_valid = (selected >= 0 && selected < slots &&
                               IsNaNOrInf(used[selected], 0.0f) > 0.5f);
   const int candidate_index = (int)round(candidate_top[0].x);
   const int candidate = (candidate_index >= 0 && candidate_index < slots &&
                          candidate_top[0].y > RAG_INVALID_SCORE ? candidate_index : -1);
   const float q = IsNaNOrInf(query[component], 0.0f);
   const float g = IsNaNOrInf(gradient[component], 0.0f);
   float values[D2SKILL_METRIC_COUNT];
   for(int metric = 0; metric < D2SKILL_METRIC_COUNT; metric++)
      values[metric] = 0.0f;

Кернел D2SkillMetricPartial собирает десять полей. Первые два нужны для нормы псевдоостатка и запроса независимо от наличия совпадений.

   values[0] = IsNaNOrInf(g * g, 0.0f);
   values[1] = IsNaNOrInf(q * q, 0.0f);

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

   if(selected_valid)
     {
      const int shift = selected * dimension + component;
      const float key = IsNaNOrInf(keys[shift], 0.0f);
      const float direction = IsNaNOrInf(directions[shift], 0.0f);
      values[2] = IsNaNOrInf(q * key, 0.0f);
      values[3] = IsNaNOrInf(key * key, 0.0f);
      values[4] = IsNaNOrInf(g * direction, 0.0f);
      values[5] = IsNaNOrInf(direction * direction, 0.0f);
     }

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

   if(candidate >= 0)
     {
      const int shift = candidate * dimension + component;
      const float key = IsNaNOrInf(candidate_key[shift], 0.0f);
      const float direction = IsNaNOrInf(candidate_direction[shift], 0.0f);
      values[6] = IsNaNOrInf(q * key, 0.0f);
      values[7] = IsNaNOrInf(key * key, 0.0f);
      values[8] = IsNaNOrInf(g * direction, 0.0f);
      values[9] = IsNaNOrInf(direction * direction, 0.0f);
     }
   const int base = component * D2SKILL_METRIC_COUNT;
   for(int metric = 0; metric < D2SKILL_METRIC_COUNT; metric++)
      partials[base + metric] = values[metric];
  }

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

Все подготовленные варианты сходятся в D2SkillCandidateLeader. Этот кернел работает одним потоком не из-за нехватки параллелизма, а потому что на данном этапе уже сведены все многомерные показатели и требуется принять одно дискретное решение для банка.

__kernel void D2SkillCandidateLeader(__global const float *selected_slot,
                                     __global float *used,
                                     __global float *scales,
                                     __global float *observations,
                                     __global float *mass,
                                     __global float *age,
                                     __global float *candidate_streak,
                                     __global float *candidate_valid,
                                     __global float *candidate_scale,
                                     __global const float *metrics,
                                     __global const float2 *free_top,
                                     __global const float2 *eviction_top,
                                     __global float *decision,
                                     __global float *diagnostics,
                                     const int slots,
                                     const int representation,
                                     const float beta_magnitude,
                                     const float max_correction,
                                     const int min_confirmations,
                                     const float similarity_threshold,
                                     const float direction_threshold,
                                     __global const float2 *candidate_top,
                                     __global float *candidate_protection)
  {
   if(get_global_id(0) != 0)
      return;
   decision[0] = -1.0f;
   decision[1] = D2SKILL_DECISION_NONE;
   const float gradient_norm = sqrt(fmax(IsNaNOrInf(metrics[0], 0.0f), 0.0f));
   decision[2] = gradient_norm;
   decision[3] = 0.0f;
//--- Collection must preserve a non-zero pseudo-residual even before the
//--- untrained Actor has amplified it.  Only an exact zero has no direction.
   if(slots <= 0 || gradient_norm <= 0.0f)
      return;
   const float query_norm = sqrt(fmax(IsNaNOrInf(metrics[1], 0.0f), 0.0f));
   const int selected = (int)round(selected_slot[0]);
   const int selected_valid = (selected >= 0 && selected < slots &&
                               IsNaNOrInf(used[selected], 0.0f) > 0.5f);
   const float selected_key_norm = sqrt(fmax(IsNaNOrInf(metrics[3], 0.0f), 0.0f));
   const float selected_direction_norm = sqrt(fmax(IsNaNOrInf(metrics[5], 0.0f), 0.0f));
   const float key_cosine = (query_norm > 1.0e-8f && selected_key_norm > 1.0e-8f ?
                             metrics[2] / (query_norm * selected_key_norm) : -1.0f);
   const float direction_cosine = (selected_direction_norm > 1.0e-8f ?
                                   metrics[4] / (gradient_norm * selected_direction_norm) : 0.0f);

Сначала проверяется Skill, который был выбран для текущего состояния. Для него вычисляются key_cosine и direction_cosine. Если оба порога пройдены, новый псевдоостаток признаётся продолжением уже известной закономерности: увеличивается число наблюдений, сбрасывается возраст, а для Direction–Magnitude обновляется масштаб.

   if(selected_valid)
     {
      diagnostics[2] = IsNaNOrInf(diagnostics[2], 0.0f) + 1.0f;
      if(IsNaNOrInf(metrics[5], 0.0f) > 1.0e-8f)
        {
         diagnostics[19] = IsNaNOrInf(diagnostics[19], 0.0f) + 1.0f;
         diagnostics[20] = IsNaNOrInf(diagnostics[20], 0.0f) + direction_cosine;
        }
     }
   if(selected_valid && isfinite(key_cosine) && isfinite(direction_cosine) &&
      key_cosine >= similarity_threshold && direction_cosine >= direction_threshold)
     {
      if(representation == 1)
         scales[selected] = clamp((1.0f - beta_magnitude) * IsNaNOrInf(scales[selected], 0.0f) +
                                  beta_magnitude * gradient_norm, 0.0f, max_correction);
      observations[selected] = IsNaNOrInf(observations[selected], 0.0f) + 1.0f;
      mass[selected] = 0.95f * IsNaNOrInf(mass[selected], 0.0f) + 0.05f;
      age[selected] = 0.0f;
      decision[0] = (float)selected;
      decision[1] = D2SKILL_DECISION_MATCH;
      return;
     }

На этом путь заканчивается командой MATCH. Если же контекст или направление расходятся, существующий Skill не портится компромиссным обновлением. Алгоритм обращается к Candidate; при его отсутствии сначала берёт свободный слот, а если банк заполнен — заранее выбранный Inactive для возможного вытеснения.

    const int candidate_index = (int)round(candidate_top[0].x);
    int candidate = (candidate_index >= 0 && candidate_index < slots &&
                     candidate_top[0].y > RAG_INVALID_SCORE ? candidate_index : -1);
    if(candidate < 0)
      {
       const int free_slot = (int)round(free_top[0].x);
       const int evict_slot = (int)round(eviction_top[0].x);
       const bool free_available = (free_slot >= 0 && free_slot < slots &&
                                    free_top[0].y > RAG_INVALID_SCORE);
       const bool evict_available = (evict_slot >= 0 && evict_slot < slots &&
                                     eviction_top[0].y > RAG_INVALID_SCORE);
       candidate = (free_available ? free_slot : (evict_available ? evict_slot : -1));
       if(candidate < 0)
         {
          diagnostics[5] = IsNaNOrInf(diagnostics[5], 0.0f) + 1.0f;
          return;
         }

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

       candidate_valid[candidate] = D2SKILL_CANDIDATE;
       used[candidate] = 1.0f;
       candidate_streak[candidate] = 1.0f;
       candidate_scale[candidate] = gradient_norm;
       mass[candidate] = gradient_norm;
       age[candidate] = 0.0f;
       candidate_protection[candidate] = (free_available ? 0.0f : -1.0f);
       decision[0] = (float)candidate;
       decision[1] = D2SKILL_DECISION_SEED;
       diagnostics[3] = IsNaNOrInf(diagnostics[3], 0.0f) + 1.0f;
       return;
      }

Дальше важна последовательность наблюдений. При следующем обновлении Candidate сравнивается по тем же двум критериям — контексту и направлению.

   const float candidate_key_norm = sqrt(fmax(IsNaNOrInf(metrics[7], 0.0f), 0.0f));
   const float candidate_direction_norm = sqrt(fmax(IsNaNOrInf(metrics[9], 0.0f), 0.0f));
   const float candidate_key_cosine = (query_norm > 1.0e-8f && candidate_key_norm > 1.0e-8f ?
                                       metrics[6] / (query_norm * candidate_key_norm) : -1.0f);
   const float candidate_direction_cosine =
      (candidate_direction_norm > 1.0e-8f ?
       metrics[8] / (gradient_norm * candidate_direction_norm) : 0.0f);
   if(candidate_direction_norm > 1.0e-8f && isfinite(candidate_direction_cosine))
     {
      diagnostics[19] = IsNaNOrInf(diagnostics[19], 0.0f) + 1.0f;
      diagnostics[20] = IsNaNOrInf(diagnostics[20], 0.0f) + candidate_direction_cosine;
     }
   const bool direction_rejected = (IsNaNOrInf(metrics[9], 0.0f) > 1.0e-8f &&
                                    (!isfinite(candidate_direction_cosine) ||
                                     candidate_direction_cosine < direction_threshold));

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

   if(!isfinite(candidate_key_cosine) || candidate_key_cosine < similarity_threshold ||
      direction_rejected)
     {
       candidate_streak[candidate] = 1.0f;
       candidate_scale[candidate] = gradient_norm;
       age[candidate] = 0.0f;
       decision[0] = (float)candidate;
      decision[1] = D2SKILL_DECISION_RESET;
      return;
     }

Согласованный пример, напротив, увеличивает candidate_streak и уточняет масштаб. Пока серия короче min_confirmations, элемент остаётся Candidate и не участвует в рабочем retrieval. После достижения порога решение меняется на FREE или EVICT в зависимости от того, где формировалась гипотеза. Важен именно непрерывный характер серии. Если подтверждения приходят через десятки несогласованных состояний, они не складываются в статистическое большинство: алгоритм требует локальной устойчивости во времени. Для рыночных данных это разумный компромисс между запоминанием единичной свечи и ожиданием слишком длинной истории, за которую сам режим уже может измениться.

    candidate_streak[candidate] = IsNaNOrInf(candidate_streak[candidate], 0.0f) + 1.0f;
    candidate_scale[candidate] = clamp((1.0f - beta_magnitude) * IsNaNOrInf(candidate_scale[candidate], 0.0f) +
                                beta_magnitude * gradient_norm, 0.0f, max_correction);
    decision[0] = (float)candidate;
    if(candidate_streak[candidate] < (float)max(1, min_confirmations))
     {
      decision[1] = D2SKILL_DECISION_CANDIDATE;
      return;
     }
    decision[1] = (candidate_protection[candidate] < 0.0f ?
                   D2SKILL_DECISION_EVICT : D2SKILL_DECISION_FREE);
  }

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

__kernel void D2SkillVectorUpdate(__global const float *query,
                                  __global const float *gradient,
                                  __global float *keys,
                                  __global float *corrections,
                                  __global float *directions,
                                  __global const float *scales,
                                  __global float *candidate_key,
                                  __global float *candidate_direction,
                                  __global const float *candidate_scale,
                                  __global const float *decision,
                                  __global const float *reduced_norm,
                                  const int dimension,
                                  const int representation,
                                  const float beta_correction,
                                  const float beta_direction,
                                  const float beta_key,
                                  const float max_correction,
                                  const int phase)
  {
   const int component = get_global_id(0);
   if(component >= dimension || dimension <= 0)
      return;
   const int action = (int)round(decision[1]);
   const int target = (int)round(decision[0]);
   const float gradient_norm = decision[2];
   const float query_value = IsNaNOrInf(query[component], 0.0f);
   const float gradient_value = IsNaNOrInf(gradient[component], 0.0f);
   const float unit = (gradient_norm > 0.0f ? gradient_value / gradient_norm : 0.0f);

Кернел получает текущий контекст, псевдоостаток, выбранный слот и тип решения. Для любого ненулевого псевдоостатка сначала вычисляется единичное направление. Это позволяет учитывать даже слабые сигналы раннего обучения: малый модуль не уничтожает информацию о направлении, а его величина учитывается отдельно через масштаб. Если подтверждён уже существующий Skill либо Candidate прошёл порог, ключ движется к текущему состоянию по EMA; одновременно обновляется полная коррекция или её направление. За счёт этого один сильный пример не переписывает накопленный опыт, но последовательная серия новых наблюдений способна постепенно сместить и область применимости, и саму реакцию модели.

    if((action == D2SKILL_DECISION_MATCH || action == D2SKILL_DECISION_FREE ||
        action == D2SKILL_DECISION_EVICT) && target >= 0)
     {
      const int shift = target * dimension + component;
      keys[shift] = IsNaNOrInf((1.0f - beta_key) * keys[shift] + beta_key * query_value, 0.0f);
      if(representation == 0)
         corrections[shift] = IsNaNOrInf((1.0f - beta_correction) *
                                         IsNaNOrInf(corrections[shift], 0.0f) +
                                         beta_correction * gradient_value, 0.0f);
      else
         directions[shift] = IsNaNOrInf((1.0f - beta_direction) *
                                        IsNaNOrInf(directions[shift], 0.0f) +
                                        beta_direction * unit, 0.0f);
      return;
     }

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

    if((action == D2SKILL_DECISION_SEED || action == D2SKILL_DECISION_RESET) && target >= 0)
      {
       const int shift = target * dimension + component;
       keys[shift] = query_value;
       directions[shift] = unit;
       return;
      }

Если Candidate продолжает подтверждаться, те же ключ и направление уже сглаживаются, а не переписываются целиком.

    if((action == D2SKILL_DECISION_CANDIDATE || action == D2SKILL_DECISION_FREE ||
        action == D2SKILL_DECISION_EVICT) && target >= 0)
      {
       const int shift = target * dimension + component;
       keys[shift] =
          IsNaNOrInf((1.0f - beta_key) * keys[shift] +
                     beta_key * query_value, 0.0f);
       directions[shift] =
          IsNaNOrInf((1.0f - beta_direction) * directions[shift] +
                     beta_direction * unit, 0.0f);
       return;
      }

В Direction–Magnitude этого ещё недостаточно. EMA единичных векторов постепенно меняет их норму, поэтому после первой фазы банк выполняет межгрупповую редукцию, получает норму всего направления и запускает кернел второй раз.

    if(phase == 1)
      {
       if((action != D2SKILL_DECISION_MATCH && action != D2SKILL_DECISION_FREE &&
           action != D2SKILL_DECISION_EVICT) || target < 0)
          return;
       const int shift = target * dimension + component;
       const float vector_norm = sqrt(fmax(IsNaNOrInf(reduced_norm[0], 0.0f), 0.0f));
       if(representation == 0)
         {
          const float factor = (vector_norm > max_correction && vector_norm > 1.0e-12f ?
                                max_correction / vector_norm : 1.0f);
          corrections[shift] = IsNaNOrInf(corrections[shift] * factor, 0.0f);
         }
       else
          {
           const float factor = (vector_norm > 1.0e-12f ? 1.0f / vector_norm : 0.0f);
           directions[shift] = IsNaNOrInf(directions[shift] * factor, 0.0f);
           corrections[shift] = IsNaNOrInf(directions[shift] *
                                           fmin(scales[target], max_correction), 0.0f);
          }
       return;
      }

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

Полный цикл обновления нужен при формировании банка, но в онлайне бывает полезнее более узкая адаптация. Для этого UpdateDirectionFromGradient() использует отдельный кернел D2SkillDirectionEMA: он не создаёт Candidate, не меняет ключ и не запускает lifecycle, а лишь слегка поворачивает направление уже выбранного рабочего Skill. Такой режим нужен потому, что онлайн-поток и накопление новой памяти решают разные задачи. В первом случае мы хотим осторожно подстроить уже знакомую реакцию к текущим данным; во втором — разрешаем структуре банка меняться, занимать новые слоты и вытеснять старый опыт. Разделение снижает риск того, что несколько последних наблюдений полностью перестроят память во время торговли.

__kernel void D2SkillDirectionEMA(__global const float *gradient,
                                  __global float *directions,
                                  __global const float *selected_slot,
                                  __global const float *used,
                                  __global const float *state,
                                  __global const float *reduced_norm,
                                  const int slots,
                                  const int dimension,
                                  const float beta_direction)
  {
   const int component = get_global_id(0);
   if(component < 0 || component >= dimension || slots <= 0)
      return;
   const int slot = (int)round(IsNaNOrInf(selected_slot[0], -1.0f));
   if(slot < 0 || slot >= slots || IsNaNOrInf(used[slot], 0.0f) <= 0.5f)
      return;
   const int slot_state = (int)IsNaNOrInf(state[slot], (float)D2SKILL_FREE);
   if(slot_state == D2SKILL_FREE || slot_state == D2SKILL_CANDIDATE ||
      slot_state == D2SKILL_INACTIVE)
      return;
   const float norm = sqrt(fmax(IsNaNOrInf(reduced_norm[0], 0.0f), 0.0f));
   if(norm <= 1.0e-12f)
      return;
   const int index = slot * dimension + component;
   const float unit = IsNaNOrInf(gradient[component], 0.0f) / norm;
   directions[index] = IsNaNOrInf((1.0f - beta_direction) *
                                   IsNaNOrInf(directions[index], 0.0f) +
                                   beta_direction * unit, 0.0f);
  }

Обновление разрешено только для занятых Protected и Active. Градиент нормализуется той же GPU-редукцией, после чего направление сдвигается по EMA. Такой режим позволяет подстроить существующий опыт на новых данных, не открывая полный механизм формирования и вытеснения памяти.

Когда Candidate всё же набрал нужную серию подтверждений, его необходимо перевести из гипотезы в рабочий элемент. Эту границу проводит D2SkillPromoteSlot.

__kernel void D2SkillPromoteSlot(__global float *scales,
                                 __global float *utility,
                                 __global float *observations,
                                 __global float *uses,
                                 __global float *mass,
                                 __global float *used,
                                 __global float *state,
                                 __global float *age,
                                 __global float *protection,
                                 __global float *candidate_streak,
                                 __global float *candidate_valid,
                                 __global const float *candidate_scale,
                                 __global const float *decision,
                                 __global float *diagnostics,
                                 const int slots,
                                 const int protection_age)
  {
   if(get_global_id(0) != 0)
      return;
   const int action = (int)round(decision[1]);
   const int target = (int)round(decision[0]);
    if((action != D2SKILL_DECISION_FREE && action != D2SKILL_DECISION_EVICT) ||
       target < 0 || target >= slots ||
       (int)IsNaNOrInf(candidate_valid[target], (float)D2SKILL_FREE) != D2SKILL_CANDIDATE)
       return;

Команды FREE и EVICT означают, что структура уже подтверждена и место под неё определено. Новый Skill получает накопленный масштаб и число наблюдений, но эксплуатационная статистика начинается заново.

   scales[target] = IsNaNOrInf(candidate_scale[target], 0.0f);
   utility[target] = 0.0f;
   observations[target] = IsNaNOrInf(candidate_streak[target], 1.0f);
   uses[target] = 0.0f;
   mass[target] = IsNaNOrInf(candidate_streak[target], 1.0f);
   used[target] = 1.0f;
   state[target] = D2SKILL_PROTECTED;
   age[target] = 0.0f;
   protection[target] = (float)protection_age;

Utility обнуляется, uses начинается с нуля, возраст сбрасывается, а состояние становится Protected. Это разделяет две разные истории: Candidate доказывал повторяемость, а сформированному Skill ещё только предстоит показать практическую ценность при реальном применении. Диагностика отдельно отмечает создание в свободном месте и вытеснение Inactive.

   if(action == D2SKILL_DECISION_EVICT)
      diagnostics[7] = IsNaNOrInf(diagnostics[7], 0.0f) + 1.0f;
   else
      diagnostics[4] = IsNaNOrInf(diagnostics[4], 0.0f) + 1.0f;
  }

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

__kernel void D2SkillInfluencePartial(__global const float *gradient,
                                      __global const float *correction,
                                      __global float *partials,
                                      const int dimension)
  {
   const int component = get_global_id(0);
   if(component < 0 || component >= dimension)
      return;
   partials[component] = IsNaNOrInf(IsNaNOrInf(gradient[component], 0.0f) *
                                    IsNaNOrInf(correction[component], 0.0f), 0.0f);
  }

Покомпонентные произведения псевдоостатка и выбранной коррекции сводятся в скаляр It = gtT dt. Положительный знак означает согласованное воздействие, значение около нуля — слабое участие, отрицательный — конфликт. Величина учитывает не только угол, но и длину коррекции: два одинаково направленных Skill дадут разный Influence, если один почти не изменил представление, а другой внёс заметный сдвиг. Это отличает Influence от простого cosine. Для последующей атрибуции важно понимать не только, "смотрел" ли Skill в правильную сторону, но и насколько сильно он реально вмешался в состояние Actor. Поэтому слабая коррекция не получает такую же долю результата, как сильное воздействие при том же направлении.

__kernel void D2SkillInfluenceAccumulate(__global const float *selected_slot,
                                         __global const float *reduced_influence,
                                         __global float *influence,
                                         const int slots)
  {
   if(get_global_id(0) != 0 || slots <= 0)
      return;
   const int slot = (int)round(IsNaNOrInf(selected_slot[0], -1.0f));
   if(slot < 0 || slot >= slots)
      return;
   influence[slot] = IsNaNOrInf(influence[slot], 0.0f) +
                     IsNaNOrInf(reduced_influence[0], 0.0f);
  }

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

Только после завершения последовательности появляется ретроспективный Δj. В авторском D2Skill он выводится из сравнения парных траекторий; сам банк не вычисляет baseline и получает уже готовый скаляр. Такое разделение сохраняет причинный порядок: при retrieval итог ещё неизвестен, во время обратного прохода доступна лишь локальная ошибка, и только завершённая последовательность даёт основание судить о результате. Сначала из накопленного Influence формируется общая положительная масса подтверждённых навыков, после чего терминальная оценка распределяется между ними пропорционально фактическому участию.

__kernel void D2SkillInfluenceTotal(__global const float *influence,
                                     __global const float *used,
                                     __global const float *state,
                                     __global float *partials,
                                     const int slots)
  {
   const int slot = get_global_id(0);
   if(slot < 0 || slot >= slots)
      return;
   const bool eligible = (IsNaNOrInf(used[slot], 0.0f) > 0.5f &&
                          (int)IsNaNOrInf(state[slot], (float)D2SKILL_FREE) != D2SKILL_CANDIDATE);
   partials[slot] = (eligible ? fmax(IsNaNOrInf(influence[slot], 0.0f), 0.0f) : 0.0f);
  }

Candidate в распределение не входит, а отрицательное локальное влияние не превращается в отрицательный вес. Знак конечной оценки задаёт Δj; Influence лишь определяет, какая доля результата относится к каждому реально воздействовавшему Skill. После редукции эту долю использует D2SkillUtility.

__kernel void D2SkillUtility(__global float *influence,
                              __global const float *influence_total,
                              __global float *utility,
                              __global const float *used,
                              __global const float *state,
                              const float delta_j,
                              const float beta,
                              const int slots,
                              __global float *diagnostics)
  {
   const int slot = get_global_id(0);
   if(slot < 0 || slot >= slots || slots <= 0)
      return;
   const float mass = fmax(IsNaNOrInf(influence[slot], 0.0f), 0.0f);
   const float total = fmax(IsNaNOrInf(influence_total[0], 0.0f), 0.0f);
   const bool eligible = (IsNaNOrInf(used[slot], 0.0f) > 0.5f &&
                          (int)IsNaNOrInf(state[slot], (float)D2SKILL_FREE) != D2SKILL_CANDIDATE);

Для каждого допустимого слота берётся его положительная масса и общая сумма по эпизоду. Если ретроспективный сигнал содержателен, Utility обновляется по EMA пропорционально доле Influence.

   if(eligible && total > 1.0e-12f && mass > 0.0f && fabs(delta_j) > 1.0e-12f)
      utility[slot] = (1.0f - beta) * IsNaNOrInf(utility[slot], 0.0f) +
                      beta * clamp(IsNaNOrInf(delta_j, 0.0f), -1.0f, 1.0f) * mass / total;

В математической форме: Uj ← (1−β)Uj + β·clip(Δj,−1,1)·max(Ij,0)/Σk max(Ik,0). Положительный результат повышает Utility тех навыков, которые действительно влияли на последовательность; отрицательный снижает её. Частый retrieval без заметного воздействия почти не меняет оценку, а ограничение Δj диапазоном [−1;1] защищает EMA от единичного экстремального результата. Представим сделку, в которой один Skill активно корректировал несколько состояний, а второй был выбран только один раз и дал почти нулевой сдвиг. При одинаковом знаке локального Influence первый получит большую долю итоговой оценки, что ближе к реальному участию обоих навыков в сформированной траектории.

   influence[slot] = 0.0f;
   if(slot == 0 && total > 1.0e-12f && fabs(delta_j) > 1.0e-12f)
      diagnostics[6] = IsNaNOrInf(diagnostics[6], 0.0f) + 1.0f;
  }

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

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


Заключение

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

Главное изменение связано не с количеством векторов, а с порядком их использования. Банк сначала проверяет применимость Skill, затем отдельно измеряет его фактическое влияние и лишь после завершения последовательности обновляет Utility. Candidate и защитный период не дают единичному выбросу сразу стать рабочим навыком, а переход в Inactive и вытеснение с учётом Utility освобождают память без простого удаления по возрасту.

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


Ссылки


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

#ИмяТипОписание
1NeuroNet_D2Skill.mqhБиблиотека классовРеализация CD2SkillItem, CD2SkillBank
2NeuroNet.clOpenCL-программаGPU-кернелы поиска, обновления, жизненного цикла и Utility
3NeuroNet_Declarations.mqhОбъявления классовИнтерфейсы объектов D2Skill
4NeuroNet_Core.mqhОбщий APIСоздание объектов D2Skill и регистрация OpenCL-кернелов
5NeuroNet_Definitions.mqhОпределенияСостояния, режимы и идентификаторы OpenCL-кернелов

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

Прикрепленные файлы |
MQL5.zip (4045.95 KB)
Изучение стандартной библиотеки MQL5 (Часть 12): Мультитаймфреймный дашборд на основе составного показателя Изучение стандартной библиотеки MQL5 (Часть 12): Мультитаймфреймный дашборд на основе составного показателя
В статье реализован CMultiTimeframeMatrix, переиспользуемый дашборд, который сопоставляет символы с таймфреймами и отображает числовой показатель с цветовой кодировкой. Показатель объединяет тренд и импульс с вычетом волатильности, обновляется по таймеру и учитывает ограничения производительности. Вы узнаете, как создавать пользовательский интерфейс с помощью CAppDialog/CLabel, вычислять метрики с помощью CMatrixDouble и встраивать компонент в простой советник для получения целостной картины рынка в реальном времени.
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 2): Разработка программы с интеграцией ChatGPT и пользовательским интерфейсом Создание торговых систем с искусственным интеллектом на MQL5 (Часть 2): Разработка программы с интеграцией ChatGPT и пользовательским интерфейсом
В этой статье разрабатывается программа на языке MQL5 с интеграцией ChatGPT и пользовательским интерфейсом. Она задействует модуль разбора JSON из части 1 для отправки промптов в API OpenAI и отображения ответов на графике MetaTrader 5. Реализуется панель управления с полем ввода, кнопкой отправки и областью отображения ответа. Для пользовательского взаимодействия программа обрабатывает обмен данными с API и перенос текста по строкам.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Создание HTML-дашборда для Тестера стратегий и анализа проп-фирменных испытаний в MQL5 Создание HTML-дашборда для Тестера стратегий и анализа проп-фирменных испытаний в MQL5
В этой статье показано, как создать многоразовый модуль оценки проп-фирм для советников MQL5 и экспортировать результаты на HTML-дашборд. Модуль отслеживает баланс и капитал во время тестирования стратегий на исторических данных, имитирует одиночные или скользящие серии испытаний, проверяет целевую прибыль, дневную и общую просадку, а также минимальное количество торговых дней, после чего выводит как итоговый отчет, так и отчет в HTML-формате, который можно открыть в браузере.