Нейросети в трейдинге: Двухуровневая адаптация торговой политики (Банк навыков)
Введение
В первой статье мы начали адаптацию D2Skill к алгоритмической торговле с самого малого элемента — повторяющегося псевдоостатка. При обычном обучении такой сигнал участвует в очередном изменении общих весов и затем растворяется в параметрах модели. Мы вынесли его устойчивую часть в отдельную латентную коррекцию, чтобы при сходном состоянии Actor мог использовать уже найденное направление, а не получать его заново через очередную серию обновлений.
Эта идея продолжает авторский фреймворк D2Skill, где политика дополняется динамическими банками двух уровней. Task Skills относятся к задаче в целом, Step Skills — к отдельным состояниям внутри траектории. Навыки извлекаются по контексту, а их ценность уточняется ретроспективно по результатам парных проходов одной политики без навыков и с их применением. Такое разделение важно не только в названии банков. Общий навык сопровождает более широкий контекст, тогда как локальный должен срабатывать лишь там, где повторяется соответствующее состояние. В нашей реализации меняется форма памяти. Вместо текстовой рекомендации банк хранит векторную коррекцию скрытого состояния. Но логика остаётся прежней — опыт должен быть привязан к контексту, извлекаться только там, где он уместен, и со временем подтверждать свою практическую ценность.
В первой части мы сознательно оставили весь банк за рамками эксперимента и работали с одним Skill. Это позволило проверить основу без влияния retrieval и правил отбора. Мы сравнили два представления коррекции, остановились на раздельном накоплении направления и масштаба и показали, что устойчивое направление сохраняется даже при отдельных выбросах. Одновременно был построен GPU-конвейер обновления и применения латентной поправки. Тем самым вопрос "может ли отдельный Skill вообще сформироваться и работать внутри нашей модели?" был закрыт.
Следующий шаг уже нельзя решить простым увеличением числа таких векторов. Разные состояния рынка способны давать разные, а иногда и противоположные псевдоостатки. Если направлять их в один накопитель, полезная структура снова исчезнет в усреднении. Если, наоборот, создавать новый элемент при каждом небольшом отклонении, память быстро заполнится почти одинаковыми навыками. Поэтому банк должен одновременно различать контексты, удерживать близкий опыт вместе, отделять конфликтующие направления и уметь отказаться от коррекции, когда подходящего Skill нет.
Именно этот переход от одиночной коррекции к управляемой памяти составляет предмет текущего этапа нашей работы. Сначала разберём решения, необходимые банку в торговой задаче. Затем проследим их в реализации: от поиска слота и формирования Candidate до жизненного цикла, Influence и Utility. В результате проследим путь данных: от текущего скрытого состояния до обновления памяти после завершения торговой последовательности.

Когда одного навыка становится недостаточно
Одиночный 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, квадраты 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 и конечный торговый результат.
Ссылки
Программы, используемые в статье
| # | Имя | Тип | Описание |
|---|---|---|---|
| 1 | NeuroNet_D2Skill.mqh | Библиотека классов | Реализация CD2SkillItem, CD2SkillBank |
| 2 | NeuroNet.cl | OpenCL-программа | GPU-кернелы поиска, обновления, жизненного цикла и Utility |
| 3 | NeuroNet_Declarations.mqh | Объявления классов | Интерфейсы объектов D2Skill |
| 4 | NeuroNet_Core.mqh | Общий API | Создание объектов D2Skill и регистрация OpenCL-кернелов |
| 5 | NeuroNet_Definitions.mqh | Определения | Состояния, режимы и идентификаторы OpenCL-кернелов |
Проект представлен на forge.mql5.io/dng.
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Изучение стандартной библиотеки MQL5 (Часть 12): Мультитаймфреймный дашборд на основе составного показателя
Создание торговых систем с искусственным интеллектом на MQL5 (Часть 2): Разработка программы с интеграцией ChatGPT и пользовательским интерфейсом
Создание HTML-дашборда для Тестера стратегий и анализа проп-фирменных испытаний в MQL5
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования