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

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

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

Введение

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

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

Практическую часть мы начали с правил построения выражения. Функция AGApplyToken проверяет аргументы операции и определяет, сколько прошлых баров потребуется. Вторая функция, AGMaskToken, отвечает на более узкий вопрос — разрешён ли один предложенный токен после уже записанных. Проверив так весь словарь, получаем маску допустимых продолжений. В примере из первой статьи после Close Open маска разрешала вычитание. После Sub оставалось одно выражение — тело свечи со знаком, и формулу уже можно было завершить.

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


Собираем формулу

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

В пакетном режиме кернел AGSearchGenerate из файла AlphaGen.cl получает конфигурацию, веса модели и начальное значение генератора случайных чисел. Он выбирает обозначения. Например, Close указывает, какой ряд затем прочитает вычислитель. Сами цены при сборке формулы не нужны.

Каждая рабочая группа OpenCL строит одну формулу. Номер группы определяет номер кандидата. В первой ячейке его записи стоит −1, пока формула не завершена. Первым на вход модели поступает AG_BOS — признак начала. Каждая группа сочетает общий seed со своим номером, поэтому при одинаковых весах модели кандидаты могут различаться.

uint random=seed^((uint)(candidate+1)*0x9e3779b9U);
if(!random)
   random=1;
if(lane==0)
  {
   result[0]=-1.0f;
   choice=AG_BOS;
  }

Для контрольного запуска обнулим массив cfg длиной AG_CFG_SIZE и заполним поля по таблице. Номера кандидатов начинаются с нуля.

Параметр Значение
Пакет и начальное состояние 128 кандидатов, по одному рабочему элементу в группе. seed = 2866299785 (беззнаковое 32-битное число).
Данные и длина формулы AG_CFG_W = 8 баров, AG_CFG_LOOKBACK = 4 предыдущих бара, AG_CFG_MAX_TOKENS = 6, включая STOP.
Размеры модели AG_CFG_EMBEDDING = 1, AG_CFG_SEARCH_HIDDEN = 1, AG_CFG_POSITION_SCALE = 10000.
Словарь Пять интервалов по порядку — 1, 5, 10, 20, 60. Пять констант в записи программы — [-1, -0.5, 0.5, 1, 2].
Веса и смещения Заполняем нулями четыре массива типа float — embedding[33], first_lstm[12], second_lstm[12] и mean[68].

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

Записанную часть формулы будем называть префиксом. В начале он пуст, после выбора Close состоит из одного токена. Переменная t задаёт длину префикса, а row указывает, куда записать следующий шаг. Модель получает предыдущий токен, маска учитывает весь префикс.

for(int t=0;t<maximum;t++)
  {
   __global float *row=result+1+t*stride;
   // Snapshot the shared token before any lane can publish a new one.
   const int selected_token=choice;
   const bool active=selected_token!=AG_STOP && selected_token>=0;
   const int input_token=active?selected_token:AG_BOS;
   if(lanes>1)
      barrier(CLK_LOCAL_MEM_FENCE);
   for(int j=lane;j<dimension;j+=lanes)
     {
      float angle=(float)t/pow(cfg[AG_CFG_POSITION_SCALE],(float)(2*(j/2))/(float)dimension);
      embedded[j]=embedding[input_token*dimension+j]+(j%2==0?sin(angle):cos(angle));
     }
   if(active)
      for(int token=lane;token<AG_VOCABULARY;token+=lanes)
         row[4+token]=AGMaskToken(result+1,t,stride,cfg,mask_work,token);
   if(lanes>1)
      barrier(CLK_GLOBAL_MEM_FENCE);

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

Для чтения готовой последовательности в проекте есть отдельный кернел AGEmbedding. Он получает номера токенов в массиве float и перед обращением к весам проверяет, что номер конечен, является целым и входит в словарь. Если вместо номера записано, например, 6,5, все компоненты представления на этой позиции получат NaN.

float raw_token=tokens[t];
if(!isfinite(raw_token) || raw_token<0.0f || raw_token>=(float)AG_VOCABULARY ||
   raw_token!=floor(raw_token))
  {
   output[t*dimension+j]=NAN;
   return;
  }
int token=(int)raw_token;

Кернел вызывает AGMaskToken для каждого токена и записывает её ответ в row[4+token]. В начале записи вычитание закрыто маской, поскольку в стеке ещё нет двух аргументов.

Кандидат № 121 начинает с Close. На этом шаге уже разрешён STOP. Если выбрать его сейчас, значением фактора будет само закрытие. Для средней нужен ещё период.

Следующий токен кандидата № 121 — DeltaT(5). Он доступен после Close при трёх условиях: период предусмотрен конфигурацией, глубина истории охватывает четыре предыдущих бара, а длины записи хватает на оконную операцию и завершение. Маска теперь разрешает четыре операции — Mean, Std, Min и Max. При нулевых весах вероятность каждой равна 1/4.

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

Сколько элементов обрабатывают одну строку весов, определяет parts = min(lanes, 4). При одном элементе он считает всю строку, при двух они берут слагаемые через одно, а начиная с четырёх над строкой работают четверо. Например, группа из восьми элементов одновременно обрабатывает две строки. Перед следующей порцией строк барьер защищает ещё не прочитанные суммы от перезаписи.

const int lane=get_local_id(0),lanes=get_local_size(0);
const int parts=min(lanes,4),part=lane%parts,rows=lanes/parts,width=inputs+hidden+1;
for(int start=0;start<4*hidden;start+=rows)
  {
   int row=start+lane/parts;
   float sum=0.0f;
   if(row<4*hidden)
      for(int k=part;k<width-1;k+=parts)
         sum+=weights[row*width+k]*(k<inputs?input[k]:state[k-inputs]);
   partial[lane]=sum;
   if(lanes>1)
      barrier(CLK_LOCAL_MEM_FENCE);
   if(part==0 && row<4*hidden)
     {
      float dot=partial[lane];
      if(parts==4)
         dot=(dot+partial[lane+1])+(partial[lane+2]+partial[lane+3]);
      else
         if(parts==2)
            dot+=partial[lane+1];
      float a=weights[row*width+width-1]+dot;
      gates[row]=row/hidden==3?tanh(a):1.0f/(1.0f+exp(-a));
     }
   if(lanes>1)
      barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE);
  }

Такое разбиение опирается на проверку в AGSearchGenerate. Кернел допускает группы из 1, 2, 4, 8, 16, 32 или 64 элементов. Для размера 3 не выполнено условие степени двойки, а 128 превышает вместимость partial[64]. В обоих случаях элемент 0 записывает −1 в заголовок кандидата и вся группа выходит до первого барьера. В контрольном запуске у каждой группы один элемент.

Генератор вызывает AGSearchRecur дважды. Первый слой получает представление токена, второй — выход первого слоя. У каждого свои веса и своё состояние. Выходы второго слоя кернел накапливает в sum. Для оценки следующего токена он делит накопленную сумму на число обработанных входов t+1.

AGSearchRecur(embedded,dimension,first_lstm,hidden,first,gates,partial);
AGSearchRecur(first,hidden,second_lstm,hidden,second,gates,partial);
for(int j=lane;j<hidden;j+=lanes)
   sum[j]+=second[j];
if(lanes>1)
   barrier(CLK_GLOBAL_MEM_FENCE);
for(int unit=lane;unit<=AG_VOCABULARY;unit+=lanes)
  {
   const int offset=unit*(hidden+1);
   float score=0.0f;
   for(int j=0;j<hidden;j++)
      score+=mean[offset+j]*(sum[j]/(float)(t+1));
   logits[unit]=score+mean[offset+hidden];
  }

У каждого токена своя строка весов выходного слоя. Скалярное произведение этой строки и усреднённого представления вместе со смещением даёт оценку токена в logits. Чем выше оценка разрешённого токена, тем вероятнее его выбор при прочих равных. Отдельный выход logits[AG_VOCABULARY] оценивает текущее состояние поиска для обучения. Вероятность выбора токена из словаря рассчитывается по остальным выходам.

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

float top=-INFINITY,total=0.0f;
bool valid=true;
for(int j=0;j<=AG_VOCABULARY;j++)
   valid=valid && isfinite(logits[j]);
for(int j=0;j<AG_VOCABULARY;j++)
   if(row[4+j]>0.0f)
      top=fmax(top,logits[j]);
for(int j=0;j<AG_VOCABULARY;j++)
   if(row[4+j]>0.0f)
      total+=exp(logits[j]-top);

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

choice=-1;
if(valid && total>0.0f && isfinite(total))
  {
   random^=random<<13;
   random^=random>>17;
   random^=random<<5;
   float uniform=convert_float(random)*0x1p-32f;
   float normalizer=top+log(total),cumulative=0.0f,entropy=0.0f;
   int last=-1;
   for(int j=0;j<AG_VOCABULARY;j++)
      if(row[4+j]>0.0f)
        {
         float lp=logits[j]-normalizer,probability=exp(lp);
         entropy-=probability*lp;
         cumulative+=probability;
         last=j;
         if(choice<0 && uniform<cumulative)
            choice=j;
        }
   if(choice<0)
      choice=last;

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

row[0]=(float)choice;
row[1]=logits[choice]-normalizer;
row[2]=entropy;
row[3]=logits[AG_VOCABULARY];
if(choice==AG_STOP)
   result[0]=(float)(t+1);

При выборе AG_STOP кернел записывает в заголовок длину t+1. Она учитывает завершающий токен. Начальный AG_BOS в формулу не входит. Группа выполняет оставшиеся итерации общего цикла и проходит его барьеры, но запись токенов уже закончена.

У кандидата № 121 за Close DeltaT(5) следует Mean. Это обратная польская запись — сначала указывают аргументы, затем операцию. Mean берёт пять закрытий и вычисляет их среднее. С токеном завершения запись имеет вид Close DeltaT(5) Mean STOP, а в заголовке стоит 4.

Кандидат № 58 начинает с двух Close. Первый задаёт цену, из которой позже вычтем среднюю. Второй снабжает закрытиями саму среднюю.

После DeltaT(5) Mean в стеке лежат два выражения — цена закрытия и средняя. Завершение пока запрещено. Sub вычитает среднюю из цены. Теперь осталось одно выражение и маска разрешает STOP.

Полная запись второго фактора — Close Close DeltaT(5) Mean Sub STOP. Положительное значение показывает, на сколько единиц цены свеча закрылась выше средней. Отрицательное означает закрытие ниже неё.

Two ways to build a formula from Close


В пакетной записи для каждого выбора уже сохранены токен и маска. Для записи одного шага поиска в проекте предусмотрен и отдельный кернел AGSearchRecord. Он получает готовый выбор в sample[0]. Скажем, после двух Close завершать формулу рано. Если передать STOP и построенную для этого префикса маску, кернел обнаружит, что токен запрещён, и вернёт статус AG_INVALID.

result[0]=AG_INVALID;
result[1]=sample[0];
if(maximum<1 || prefix_length<0 || prefix_length>=maximum || row<0 ||
   row>=capacity || episode_start<0 || episode_start>row)
   return;
float raw=sample[0];
if(!isfinite(raw) || raw<0.0f || raw>=(float)AG_VOCABULARY || raw!=floor(raw))
   return;
int token=(int)raw;
if(mask[token]!=1.0f || (token==AG_STOP && prefix_length<1))
   return;
for(int j=0;j<7;j++)
   if(!isfinite(metadata[j]))
      return;

Если проверки пройдены, AGSearchRecord сохраняет прежний префикс, маску и выбранный токен для обучения. Затем он дописывает токен в формулу и в префикс следующего шага. В result[0] появляется статус AG_OK.

formula[prefix_length]=raw;
prefix[prefix_length+1]=raw;
result[0]=AG_OK;

Рассчитываем фактор

Для каждого закрытого бара i найдём среднюю M[i] из пяти последних закрытий C, включая текущее. Фактор отклонения равен C[i] − M[i].

M[i] = (C[i−4] + C[i−3] + C[i−2] + C[i−1] + C[i]) / 5

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

Индекс бара Цена закрытия Индексы для среднего Средняя за 5 баров (result) Статус (status) Close − средняя, ед. цены
0 100 — 0 AG_SKIP —
1 102 — 0 AG_SKIP —
2 101 — 0 AG_SKIP —
3 105 — 0 AG_SKIP —
4 107 0…4 103 AG_OK +4
5 105 1…5 104 AG_OK +1
6 106 2…6 104,8 AG_OK +1,2
7 106 3…7 105,8 AG_OK +0,2

Первое полное окно заканчивается на пятой свече. Сумма пяти закрытий равна 515, средняя — 103. Цена закрытия 107 выше средней на четыре единицы. Обе формулы вернули AG_OK.

На шестой свече из окна вышло закрытие 100 и вошло 105. Сумма выросла до 520, средняя поднялась до 104. Свеча закрылась на единицу выше средней и на две единицы ниже предыдущего закрытия.

Свечи в буфере расположены по времени. Индекс i = 0 соответствует самой старой из восьми, i = 7 — самой свежей. Для пятибарного окна левая граница равна i−4. Впервые она попадает в массив при i = 4.

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

Чтобы рассчитать среднюю и отклонение из таблицы, вычислитель читает формулы из пакета, а цены закрытия — из массива market. В первой ячейке кандидата хранится её длина. За ней для каждого токена отведено 37 ячеек — номер, три служебных поля и маска из 33 элементов. Формула средней занимает четыре шага, включая STOP.

В пакетном кернеле AGPoolEvaluateBatch указатель packed ведёт к началу записи кандидата. Функция AGFormulaEvaluate получает число токенов из packed[0], адрес первого — из packed+1. Шаг stride позволяет ей перейти к следующему токену прямо в этом буфере.

Проверим, что случится при ошибке в длине. Если указать 2 вместо 4, вычислитель прочитает только Close DeltaT(5). Такую запись можно продолжить при генерации, но для расчёта средней в ней недостаёт операции и STOP.

Вся рабочая группа обрабатывает одну и ту же формулу. Её структуру достаточно проверить один раз. Это делает рабочий элемент с локальным номером 0. Функция AGApplyToken ведёт для каждого операнда три записи — тип, число требуемых прошлых баров и признак зависимости от рынка. На время проверки эти массивы занимают начало буфера validity. В общий флаг syntax_state рабочий элемент записывает итог.

int lane=get_local_id(0),lanes=get_local_size(0);
int w=(int)cfg[AG_CFG_W],depth=0;
int capacity=(int)cfg[AG_CFG_MAX_TOKENS]+1;
if(lane==0)
  {
   __global int *types=(__global int *)validity;
   __global int *lookback=types+capacity;
   __global int *dependent=lookback+capacity;
   (*syntax_state)=length>=2 && length<capacity;

В стеке новый операнд кладётся сверху, а операция снимает нужные ей аргументы. Переменная depth показывает, сколько позиций занято. Поскольку capacity = max_tokens + 1, проверка length < capacity допускает запись из max_tokens токенов. Для самой короткой формулы хватит двух, например Close STOP.

Номера токенов хранятся как float. Вычислитель читает число в raw_token и проверяет, что оно конечно, удовлетворяет условию 0 ≤ raw_token < AG_VOCABULARY и совпадает с floor(raw_token). Лишь затем он приводит номер к int. Для словаря из 33 токенов допустимы целые числа от 0 до 32. Значения 6,5, 33 и NaN проверку не пройдут.

Шаг token_stride задаёт расстояние между номерами. Для ряда токенов, записанных подряд, он равен 1, для пакетной записи — 37. На последней позиции вычислитель ожидает AG_STOP. К этому моменту в стеке должно остаться одно выражение, зависящее от рыночных данных.

for(int k=0;k<length && (*syntax_state);k++)
  {
   float raw_token=tokens[k*token_stride];
   if(!isfinite(raw_token) || raw_token<0.0f || raw_token>=(float)AG_VOCABULARY ||
      raw_token!=floor(raw_token))
     {
      (*syntax_state)=0;
      break;
     }
   int token=(int)raw_token;
   if(k==length-1)
      (*syntax_state)=token==AG_STOP && depth==1 && types[0]==0 && dependent[0];
   else
     {
      depth=AGApplyToken(token,cfg,types,lookback,dependent,depth);
      if(depth<0)
         (*syntax_state)=0;
     }
  }

У Close глубина истории равна нулю. Для цены закрытия нужен только сам бар. Токен DeltaT(5) добавляет интервал. После Mean в стеке остаётся одно выражение, которому нужны четыре предыдущих бара. Если конфигурация допускает такую глубину, формула проходит проверку. А в записи Close DeltaT(5) STOP перед завершением остались два аргумента. В записи Close STOP Mean завершение встретилось до оператора. Обе ошибки обнаружатся на этом шаге.

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

barrier(CLK_LOCAL_MEM_FENCE | CLK_GLOBAL_MEM_FENCE);
if(!(*syntax_state))
  {
   for(int i=lane;i<w;i+=lanes)
     {
      result[i]=0.0f;
      status[i]=AG_TECHNICAL;
     }
   return;
  }

Кернел может распределить бары между несколькими рабочими элементами. В цикле lane — номер элемента внутри группы, а lanes — размер группы. Если восемь баров обрабатывают четыре элемента, элемент 0 берёт бары 0 и 4, элемент 1 — бары 1 и 5. По этому же правилу группа распределяет чтение цен и запись результатов.

Для каждого операнда стек хранит ряд из w значений, по одному на бар. В нашем примере w = 8. В ячейке d*w+i лежит значение операнда d на баре i. По тому же индексу в validity хранится статус. При численном расчёте группа снова читает буфер токенов. Все номера уже прошли проверку, поэтому их можно сразу привести к int.

Сравним этот порядок с привычными трейдеру сдвигами на графике. В таймсерии MetaTrader 5 сдвиг 0 относится к формирующемуся бару, сдвиг 1 — к последнему закрытому. Если впоследствии запросить через CopyRates восемь закрытых баров со сдвига 1, терминал поместит самый старый из них в начало физической памяти массива. При обычной индексации сдвигу 8 на графике будет соответствовать позиция 0 в нашем буфере, а сдвигу 1 — позиция 7.

В буфере market друг за другом идут пять рядов по w элементов — открытия, максимумы, минимумы, закрытия и тиковые объёмы. Обе формулы читают четвёртый ряд. При w = 8 первые три ряда занимают 24 ячейки. Закрытие пятой свечи лежит в ячейке 3*w+4, то есть по индексу 28. Например, там записано 107. Когда вычислитель встретит Close, он скопирует 107 в столбец 4 текущей строки стека. Остальные семь столбцов той же строки получат закрытия своих свечей.

Одна ветвь кода загружает в стек цены, числовые константы и интервалы. Для рыночного источника рабочий элемент читает значение канала на своём баре. Для константы или интервала он берёт число из конфигурации и повторяет его на каждом баре. Так вся вторая строка в нашем примере заполняется пятёрками. Конечное число получает AG_OK, бесконечность или NaN — AG_INVALID. После загрузки каждый рабочий элемент увеличивает свою копию depth и ждёт остальных у барьера.

depth=0;
for(int k=0;k<length;k++)
  {
   int token=(int)tokens[k*token_stride];
   if(token==AG_STOP)
      break;
   if(token>=AG_OPEN && token<AG_ABS)
     {
      for(int i=lane;i<w;i+=lanes)
        {
         float x=token<=AG_VOLUME ? market[(token-AG_OPEN)*w+i] :
                 token<AG_INTERVAL_FIRST ? cfg[AG_CFG_CONSTANTS+token-AG_CONSTANT_FIRST] :
                 cfg[AG_CFG_INTERVALS+token-AG_INTERVAL_FIRST];
         stack[depth*w+i]=x;
         validity[depth*w+i]=isfinite(x)?AG_OK:AG_INVALID;
        }
      depth++;
      barrier(CLK_GLOBAL_MEM_FENCE);
      continue;
     }

Для пятибарной средней стеку нужны две строки. Пока вычислитель читает Close и DeltaT(5), одну строку занимают цены, другую — период. После усреднения остаётся один ряд.

Прочитанный токен Занято строк Содержимое стека после шага
Close 1 Цены закрытия по всем барам
DeltaT(5) 2 Нижняя строка — цены закрытия, верхняя — интервал 5
Mean 1 Одна строка со значениями пятибарной средней
STOP 1 Готовый ряд для записи в выходные массивы

В первой статье мы также разбирали Close Open Sub STOP — тело свечи со знаком. Возьмём для этой формулы отдельную свечу с открытием по 105 и закрытием по 107. На дне стека лежит Close, над ним — Open. Оператор Sub вычитает верхнее значение из нижнего. Получаем +2 в единицах цены. Если поменять токены местами, знак изменится на противоположный. В коде l и r обозначают позиции левого и правого операндов. Результат занимает место левого, а глубина стека уменьшается на единицу.

if(token>=AG_ADD && token<=AG_DIV)
  {
   for(int i=lane;i<w;i+=lanes)
     {
      int l=(depth-2)*w+i,r=(depth-1)*w+i;
      int s=max((int)validity[l],(int)validity[r]);
      float a=stack[l],b=stack[r],x=0.0f;
      if(s==AG_OK)
        {
         if(token==AG_DIV && b==0.0f)
            s=AG_INVALID;
         else
            x=token==AG_ADD?a+b:token==AG_SUB?a-b:token==AG_MUL?a*b:a/b;
         if(!isfinite(x))
            s=AG_INVALID;
        }
      stack[l]=x;
      validity[l]=(float)s;
     }
   depth--;
   barrier(CLK_GLOBAL_MEM_FENCE);
   continue;
  }

Перед вычитанием рабочий элемент объединяет статусы аргументов с помощью max. При сочетании AG_OK и AG_SKIP результат унаследует пропуск. Для деления в этой же ветви дополнительно проверяется знаменатель, после вычисления — конечность числа. Если же обе цены известны и совпадают, тело свечи равно нулю со статусом AG_OK. Такое наблюдение можно учитывать.

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

if(token>=AG_ABS && token<=AG_LOG)
  {
   for(int i=lane;i<w;i+=lanes)
     {
      int p=(depth-1)*w+i;
      float x=stack[p];
      if(validity[p]!=AG_OK)
         continue;
      if(token==AG_LOG && !(x>0.0f))
         validity[p]=AG_INVALID;
      else
         stack[p]=token==AG_ABS?fabs(x):token==AG_SIGN?sign(x):log(x);
      if(!isfinite(stack[p]))
         validity[p]=AG_INVALID;
     }
   barrier(CLK_GLOBAL_MEM_FENCE);
   continue;
  }

Применим Log к телу свечи, закрывшейся по цене открытия. До операции в ячейке стоял корректный ноль. Взять его натуральный логарифм нельзя. Число в стеке по-прежнему равно нулю, но статус теперь AG_INVALID. На свече с положительным телом та же операция даст значение. Одной проверки isfinite(result[i]) недостаточно, чтобы понять, какой результат пригоден.

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

Представим, что первый рабочий элемент записал среднее 103 в позицию i = 4 поверх цены 107. Соседний элемент ещё читает окно 1…5. Если он успеет прочитать 103 вместо 107, сумма окажется равной 516, и мы получим 103,2 вместо 104. Чтобы оба окна видели исходные цены, вычислитель временно записывает результаты в освободившуюся строку интервала.

Для Mean в стеке заняты две строки — цены и интервал. Для Corr и Cov нужны два числовых ряда и общий интервал. Переменная slot указывает на нижний ряд, куда в конце операции нужно поместить результат. Сначала каждый рабочий элемент читает длину окна n из первой ячейки строки интервала. Начало этой строки обозначено как staged. Барьер гарантирует, что вся группа успеет прочитать n, прежде чем в ту же память начнут записываться новые значения.

int pair=(token==AG_CORR || token==AG_COV);
int slot=depth-(pair?3:2),n=(int)stack[(depth-1)*w];
// Reuse the interval row after every lane has read its value.
int staged=(depth-1)*w;
barrier(CLK_GLOBAL_MEM_FENCE);
for(int i=lane;i<w;i+=lanes)
  {
   int begin=i-n+((token==AG_REF || token==AG_DELTA)?0:1);
   int p=slot*w+i,s=AG_OK;
   float x=0.0f;
   if(begin<0)
     {
      validity[staged+i]=AG_SKIP;
      stack[staged+i]=x;
      continue;
     }

Для Mean начало окна begin = i−n+1. У Ref и Delta период обозначает сдвиг, поэтому begin = i−n. При lookback = 4 разрешена пятибарная средняя, а разность со значением пять баров назад маска отклонит. Если увеличить lookback до 5, то на баре 5 Delta вернёт 105 − 100 = +5.

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

Когда нужный прошлый бар уже есть, статус зависит от того, какие ячейки читает оператор. Для Ref достаточно значения по индексу begin. Для Delta нужны обе цены — при begin и при i. Поэтому вычислитель учитывает статусы на обоих концах интервала. Промежуточные цены в эту разность не входят.

if(token==AG_REF || token==AG_DELTA)
  {
   s=(int)validity[slot*w+begin];
   if(token==AG_DELTA)
      s=max(s,(int)validity[p]);
   x=token==AG_REF?stack[slot*w+begin]:stack[p]-stack[slot*w+begin];
  }

Для Mean нужны все цены от begin до i включительно. Рабочий элемент объединяет их статусы и находит наибольший модуль значения в окне — масштаб a. В том же цикле он находит минимум и максимум для операций Min и Max. Если у операции два ряда, для второго вычисляется свой масштаб b и учитываются статусы его ячеек.

float a=0.0f, b=0.0f, mx=0.0f, my=0.0f, xx=0.0f, yy=0.0f, xy=0.0f;
float lo=stack[slot*w+begin],hi=lo;
for(int j=begin;j<=i;j++)
  {
   s=max(s,(int)validity[slot*w+j]);
   a=fmax(a,fabs(stack[slot*w+j]));
   if(pair)
     {
      s=max(s,(int)validity[(slot+1)*w+j]);
      b=fmax(b,fabs(stack[(slot+1)*w+j]));
     }
   lo=fmin(lo,stack[slot*w+j]);
   hi=fmax(hi,stack[slot*w+j]);
  }

Даже если все цены конечны, их сумма может выйти за пределы типа float. Чтобы получить среднюю, код сначала делит каждую цену на максимальный модуль a, затем на число наблюдений n и только потом складывает результаты. В первом окне a = 107, а накопленное среднее mx приблизительно равно 103/107. Для полностью нулевого ряда код подставляет масштаб 1, и среднее остаётся нулевым.

if(a==0.0f)
   a=1.0f;
if(b==0.0f)
   b=1.0f;
for(int j=begin;j<=i;j++)
  {
   mx+=stack[slot*w+j]/a/(float)n;
   if(pair)
      my+=stack[(slot+1)*w+j]/b/(float)n;
  }

Для Std вычислитель накапливает в xx средний квадрат отклонений от mx в масштабе a. Для двух рядов нужны ещё yy для второго ряда и xy для произведений отклонений на одном баре. Делитель во всех трёх случаях равен длине окна.

for(int j=begin;j<=i;j++)
  {
   float dx=stack[slot*w+j]/a-mx;
   float dy=pair?stack[(slot+1)*w+j]/b-my:0.0f;
   xx+=dx*dx/(float)n;
   yy+=dy*dy/(float)n;
   xy+=dx*dy/(float)n;
  }

Умножение mx на a возвращает среднюю в единицах цены. Для Std ту же роль играет множитель a в выражении sqrt(xx)*a. Оператор Corr сопоставляет два ряда одного инструмента по барам окна, например диапазон свечи и тиковый объём. У авторов AlphaGen была ещё одна задача для корреляции. В эксперименте с акциями из индексов CSI 300 и CSI 500 для каждой даты авторы сравнивали значение комбинации по каждой акции с её доходностью за следующие 20 торговых дней. Двадцать дней здесь задают горизонт оценки прогноза. Пять баров в нашей формуле задают длину окна, которое заканчивается на текущей свече.

Для корреляции нужен разброс обоих рядов. Если на полном окне все входные значения пригодны, но хотя бы один из вычисленных моментов xx и yy равен нулю, кернел присваивает AG_SKIP. Сравнение выполняется в одинарной точности (FP32). Даже на постоянном ряду округление иногда оставляет малый положительный остаток.

 if(token==AG_MEAN)
    x=mx*a;
 else
    if(token==AG_STD)
       x=sqrt(xx)*a;
    else
       if(token==AG_COV)
          x=(xy*a)*b;
       else
          if(xx>0.0f && yy>0.0f)
             x=xy/sqrt(xx)/sqrt(yy);
          else
             s=max(s,AG_SKIP);
}

На тех же восьми свечах можно проследить, как входная ошибка распространяется по скользящему окну. Заменим вторую условную цену закрытия 102 на NaN прямо в ячейке market[3*w+1]. Первые четыре позиции по-прежнему получат AG_SKIP, поскольку проверка длины окна завершается до чтения цен. Полные окна 0…4 и 1…5 содержат испорченную цену и получат AG_INVALID. При i = 6 эта цена выйдет из окна, и средняя снова будет равна 104,8 со статусом AG_OK.

Рассчитанное значение пока остаётся в строке интервала по адресу staged+i. По такому же смещению в validity лежит его статус. Только когда вся группа дошла до барьера и закончила читать цены, каждый элемент копирует свою часть результатов в строку slot. Второй барьер завершает копирование до следующего токена. Оба барьера действуют в пределах рабочей группы. Флаг CLK_GLOBAL_MEM_FENCE нужен потому, что строки стека находятся в глобальной памяти.

    if(s==AG_OK && !isfinite(x))
       s=AG_INVALID;
    stack[staged+i]=x;
    validity[staged+i]=(float)s;
   }
 barrier(CLK_GLOBAL_MEM_FENCE);
 for(int i=lane;i<w;i+=lanes)
   {
    stack[slot*w+i]=stack[staged+i];
    validity[slot*w+i]=validity[staged+i];
   }
 barrier(CLK_GLOBAL_MEM_FENCE);
 depth-=pair?2:1;
}

В формуле отклонения первый Close хранится отдельной строкой. Над ним вычислитель строит среднюю по второму Close. Затем Sub вычитает её из цены той же свечи. На первых четырёх барах отклонение наследует AG_SKIP от средней.

Две последние свечи закрылись по 106. Когда из окна выпало закрытие 101 и добавилось 106, средняя поднялась с 104,8 до 105,8. Поэтому отклонение сократилось с +1,2 до +0,2 даже при неизменном закрытии.

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

 for(int i=lane;i<w;i+=lanes)
   {
    result[i]=stack[i];
    status[i]=validity[i];
   }
}

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

__kernel void AGEvaluate(__global const float *market,
                         __global const float *tokens, const int length,
                         __global const float *cfg, __global float *stack,
                         __global float *validity, __global float *result,
                         __global float *status)
  {
   __local int syntax;
   AGFormulaEvaluate(market,tokens,length,cfg,stack,validity,result,status,1,&syntax);
  }

В пакетном кернеле AGPoolEvaluateBatch из файла AlphaGen_Pool.cl все формулы читают один входной массив. У каждой свои токены, стек, значения и статусы. Номер группы выбирает кандидата, её участники распределяют между собой позиции в массиве. Группа с номером candidate>=batch завершает работу до первого барьера.

При максимальной длине формулы t под каждого кандидата зарезервировано 1+t*stride элементов. Начало записи с номером c находится по смещению c*(1+t*stride). Фактическую длину кернел читает из первой ячейки этой записи.

Кернел отводит под стек каждого кандидата w*t чисел. За ними идёт область validity. При проверке формулы в ней хранятся три массива длиной t+1, при вычислении — статусы ячеек стека. Эти операции идут одна за другой, поэтому достаточно большего из двух размеров. В выходном буфере сначала лежат w значений первого кандидата и w его статусов. Дальше в том же порядке записан второй кандидат. В приведённом коде все смещения измеряются в элементах буфера.

__kernel void AGPoolEvaluateBatch(__global const float *market,
                                  __global const float *candidates,const int batch,
                                  __global const float *cfg,__global float *workspace,
                                  __global float *evaluated)
  {
   int candidate=(int)get_group_id(0);
   if(candidate>=batch)
      return; // Uniform for the whole group, before barriers.
   int w=(int)cfg[AG_CFG_W],t=(int)cfg[AG_CFG_MAX_TOKENS];
   int stride=4+AG_VOCABULARY;
   size_t work_stride=(size_t)w*t+max(w*t,3*(t+1));
   __global const float *packed=candidates+(size_t)candidate*(1+(size_t)t*stride);
   __global float *stack=workspace+(size_t)candidate*work_stride;
   __global float *result=evaluated+(size_t)candidate*2*w;
   __local int syntax;
   AGFormulaEvaluate(market,packed+1,(int)packed[0],cfg,stack,stack+w*t,
                     result,result+w,stride,&syntax);
  }

В массиве evaluated каждый кандидат занимает 16 ячеек — восемь значений и восемь статусов. Для средней у кандидата № 121 результат на баре 4 лежит по индексу 16 × 121 + 4 = 1940, а статус — по индексу 1948. У кандидата № 58 отклонению на том же баре соответствуют индексы 932 и 940. С округлением как в таблице получаем 103 и +4, оба со статусом AG_OK.

В файле AlphaGen_Pool.cl предусмотрен и последовательный вычислитель AGPoolEvaluate. Для оконной операции один рабочий элемент обходит бары с конца к началу. Рассчитав среднюю на позиции 5, он может сразу записать её поверх цены. Для следующего окна ему понадобятся только закрытия 0…4, и все они ещё на месте. Такой порядок позволяет обойтись без временной строки.

int pair=(token==AG_CORR || token==AG_COV);
int slot=depth-(pair?3:2),n=(int)stack[(depth-1)*w];
// Descending evaluation preserves the original operand rows.
for(int i=w-1;i>=0;i--)
  {
   int begin=i-n+((token==AG_REF || token==AG_DELTA)?0:1);
   int p=slot*w+i,s=AG_OK;

Когда формула уже в пуле, AGPoolSignal читает её токены с шагом 1. Для каждого фактора кернел проверяет статусы от start до конца окна. Если встречается пропуск или ошибка, элемент 0 записывает в control[0] статус первого такого бара у проверяемого фактора. Кернел выходит до формирования нового сигнала. Так, для пятибарной средней проверка с бара 0 остановится на AG_SKIP.

__global float *record=bank+10+j*stride;
AGFormulaEvaluate(market,record+5,(int)record[0],cfg,stack,valid,raw,status,1,&syntax);
barrier(CLK_GLOBAL_MEM_FENCE);
float first_bad=(float)w;
for(int row=start+lane;row<w;row+=lanes)
   if(status[row]!=AG_OK)
      first_bad=fmin(first_bad,(float)row);
int invalid=(int)AGPoolReduce(first_bad,reduce,2);
if(invalid<w)
  {
   if(lane==0)
     {
      control[0]=status[invalid];
      if(mode==2)
         bank[9]=0.0f;
     }
   return;
  }

Заключение

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

Для контрольного ряда первая пятибарная средняя появляется на баре с индексом 4. Её значение равно 103, а отклонение закрытия от средней — +4. На первых четырёх барах записан ноль со статусом AG_SKIP: это означает, что данных для расчёта ещё недостаточно.

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

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


Ссылки


Файлы проекта

# Имя Тип Описание
1 AlphaGen_Contract.mqh Общий контракт Идентификаторы токенов и параметры общего контракта
2 AlphaGen.cl OpenCL-программа Вычислительные кернелы
3 Trajectory.mqh Общий модуль Общие настройки, подготовка данных, финансовая среда, исполнение и завершение
4 Study.mq5 Советник Советник предварительного офлайн-обучения
5 StudyOnline.mq5 Советник Советник обучения в тестере стратегий
6 Test.mq5 Советник Исполнение торговой политики с фиксированными весами
7 NeuroNet.mqh Библиотека классов Подключение модулей нейронной библиотеки
8 NeuroNet.cl OpenCL-программа Вычислительные кернелы нейронной библиотеки
9 AlphaGen_Pool.cl OpenCL-программа Пакетная обработка кандидатов и вычисление факторов пула

Исходные файлы доступны в репозитории проекта.

Прикрепленные файлы |
MQL5.zip (8205.13 KB)
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Обобщенный пул объектов в MQL5: Снижение накладных расходов на выделение памяти в высокочастотных индикаторах Обобщенный пул объектов в MQL5: Снижение накладных расходов на выделение памяти в высокочастотных индикаторах
В высокочастотных индикаторах MQL5, создающих объекты при каждом тике, накапливаются накладные расходы на выделение памяти и возникает джиттер времени выполнения в OnCalculate(). В этой статье создается обобщенный пул объектов на основе шаблона класса с использованием массива индексов списка свободных элементов, обеспечивающего выполнение операций Acquire() и Release() за время O(1). Архитектура предусматривает защиту от повторного возврата объекта в пул, строгое разделение состояния полезных данных и метаданных пула в методе Reset(), а также список свободных элементов фиксированной вместимости без резервного выделения памяти в куче. Пользовательский индикатор сравнивает два варианта обработки и с помощью GetMicrosecondCount() измеряет разницу накладных расходов на тик.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Создание объектно-ориентированного расчетного модуля сессионного VWAP на MQL5 Создание объектно-ориентированного расчетного модуля сессионного VWAP на MQL5
В этой статье показано, как реализовать сессионный VWAP на MQL5 в виде пригодного для повторного использования подключаемого класса, который строго сбрасывает накопленные значения в полночь по времени брокера. Расчетный модуль вычисляет VWAP и взвешенные по объему полосы отклонения только по закрытым барам и задает временную привязку накопления с помощью MqlDateTime, чтобы избежать искажений из-за отсутствующих свечей. Вспомогательный индикатор отображает базовую линию и полосы, а советник считывает сигналы один раз на бар, что обеспечивает согласованное выполнение, экономное использование ресурсов процессора и надежное тестирование.