English
preview
Фундаментальные модели для торговли (Часть III): Запуск генеративной ИИ-модели прогнозирования в MetaTrader 5

Фундаментальные модели для торговли (Часть III): Запуск генеративной ИИ-модели прогнозирования в MetaTrader 5

MetaTrader 5 — Трейдинг |
48 0
Muhammad Minhas Qamar
Muhammad Minhas Qamar

Введение

В части 1 и части 2 мы создали полную нативную реализацию Kronos на MQL5 и поэтапно подтвердили ее соответствие модели PyTorch: токены кодировщика совпадают в точности, логиты декодировщика и предиктора — в пределах погрешности вычислений с плавающей запятой, а авторегрессионная генерация проверена в том числе при переходе к сдвигу контекстного окна. Тем самым мы ответили лишь на простой вопрос. Точность воспроизведения модели PyTorch — не то же самое, что прогностическая способность. Точная реализация модели, неспособной прогнозировать рынок, все равно остается моделью, неспособной прогнозировать рынок.

Это различие имеет большее значение, чем может показаться. Проверка дает однозначный ответ: либо наши матрицы совпадают с эталонными, либо нет; допуск 1e-06 разрешает спор. Прогностическая способность — вопрос открытый: эталона для проверки нет, есть только рынок, который охотно выдаст вам убедительный на вид результат, совершенно ничего не значащий. Инструменты, позволяющие решить первый вопрос, бесполезны для решения второго. Привычные приемы, которые обеспечили надежность реализации, — строгие допуски и точные сравнения — могут также породить излишнюю уверенность, когда вы переходите к оценке ее практической ценности.

В этой части мы честно ставим сложный вопрос. Сначала мы применяем модель самым приятным способом — с помощью советника, который отрисовывает прогнозные свечи Kronos прямо на графике. Это позволяет наблюдать, как модель строит прогнозы в реальном времени. Затем мы перестаем любоваться картинкой и переходим к количественной оценке. Мы выгружаем прогнозы за исторический период, строго соблюдая запрет на заглядывание в будущее, сравниваем их с наивным базовым прогнозом и проверяем намеренно простую стратегию в Тестере стратегий. Результат отрицательный, и мы сообщаем об этом прямо: честно полученный отрицательный результат ценнее положительного, полученного некорректно.

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

Мы рассмотрим следующие темы:

  1. Прогнозирование на графике в реальном времени
  2. Честная оценка и проблема заглядывания в будущее
  3. Прогностическая способность в сравнении с базовым прогнозом случайного блуждания
  4. Тестирование на исторических данных — прибыль без торгового преимущества
  5. Вердикт и его обоснование
  6. Заключение


Прогнозирование на графике в реальном времени

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

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

//+------------------------------------------------------------------+
//| Tick handler: re-forecast only when a new bar has completed.     |
//+------------------------------------------------------------------+
void OnTick()
  {
   if(!g_ready)
      return;
   datetime t = (datetime)SeriesInfoInteger(_Symbol, _Period, SERIES_LASTBAR_DATE);
   if(t == g_last_bar)
      return;        // only on a new completed bar
   g_last_bar = t;
   RunForecast();
  }

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

//--- assemble raw window oldest->newest into (L,6): open,high,low,close,vol,amount
   matrix raw = matrix::Zeros((ulong)L, KR_NFEAT);
   datetime ctx_time[];
   ArrayResize(ctx_time, L);
   for(int i = 0; i < L; i++)
     {
      int src = L - i;                // rates[L]..rates[1] -> rows 0..L-1 (oldest first)
      double o = rates[src].open, h = rates[src].high, lo = rates[src].low, c = rates[src].close;
      double vol = 0.0, amt = 0.0;
      if(InpVolMode == VOL_TICK_DERIVED)
        {
         vol = (double)rates[src].tick_volume;
         double typical = (o + h + lo + c) / 4.0;
         amt = vol * typical;
        }
      raw[i][0] = o;
      raw[i][1] = h;
      raw[i][2] = lo;
      raw[i][3] = c;
      raw[i][4] = vol;
      raw[i][5] = amt;
      ctx_time[i] = rates[src].time;
     }

Цикл считывает элементы от rates[L] до rates[1] в порядке убывания индекса, никогда не обращаясь к rates[0], поэтому окно содержит только закрытые бары. Еще формирующийся бар с индексом 0 меняется с каждым тиком, поэтому его передача модели сделала бы прогноз зависимым от момента обращения. Обратный порядок обхода индексов тоже выбран намеренно: RunForecast вызывает ArraySetAsSeries для rates до его заполнения функцией CopyRates. Поэтому индексация идет от новых баров к старым, а чтение rates[L - i] помещает самый старый бар в строку 0, как ожидает модель.

Второй момент — это объем. Два из шести признаков Kronos — объем (volume) и денежный оборот (amount) — отсутствуют в потоке данных розничного рынка Forex. Для первого мы используем тиковый объем, для второго — произведение тикового объема на типичную цену. Эта формула расчета денежного оборота — резервный вариант, предусмотренный в самом эталонном предикторе на случай, когда данные об объеме есть, а о денежном обороте — нет. Проблема заключается в самом объеме: число тиков отличается от биржевого объема и по масштабу, и по распределению, а нормирование в пределах каждого окна устраняет лишь различие в масштабе. Таким образом, треть входных данных модели поступает из распределения, с которым она никогда не сталкивалась на этапе обучения, и это будет иметь значение при оценке прогноза.

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

//--- full stamp (L+P, 5): context times + projected future bar times
   int secs = PeriodSeconds(_Period);
   matrix full_stamp = matrix::Zeros((ulong)(L + P), 5);
   for(int i = 0; i < L; i++)
     {
      int st[];
      KronosStamp(ctx_time[i], st);     // verified: weekday remap Sun->Mon
      for(int j = 0; j < 5; j++)
         full_stamp[i][j] = st[j];
     }
   datetime last_time = ctx_time[L - 1];
   for(int i = 0; i < P; i++)
     {
      datetime ft = last_time + (datetime)((i + 1) * secs);
      int st[];
      KronosStamp(ft, st);
      for(int j = 0; j < 5; j++)
         full_stamp[L + i][j] = st[j];
     }

Расчет по формуле last_time + (i + 1) * secs предполагает равномерную временную сетку баров: это допущение верно внутри торговой сессии, но нарушается при переходе через выходные или праздники. Временная метка, приходящаяся на субботу, меняет временной эмбеддинг соответствующих позиций, но не искажает историю цен и не означает заглядывания в будущее: будущие временные метки известны заранее и ничего не говорят о будущих ценах. Этот эффект также можно измерить. В приведенной ниже оценке 115 из 947 прогнозов пересекают выходные, а остальные 832, даже при отдельной оценке, оказываются менее точными, чем базовый прогноз неизменности цены, на всех горизонтах прогнозирования. Способ расчета будущих временных меток не определяет итоговый результат.

В MQL5 нет графического объекта "свеча", поэтому процедура отрисовки строит каждый прогнозируемый бар из двух трендовых линий: тонкой — от максимума до минимума и толстой — от цены открытия до цены закрытия; последовательные прогнозируемые цены закрытия соединяются пунктирным отрезком:

//+------------------------------------------------------------------+
//| Draw predicted candles as chart objects to the right.            |
//+------------------------------------------------------------------+
void DrawForecast(const matrix &fc, datetime last_time, int secs, double last_close)
  {
   ObjectsDeleteAll(0, KR_OBJ_PREFIX);
   int P = (int)fc.Rows();
   double prev_close = last_close;

   for(int i = 0; i < P; i++)
     {
      datetime t  = last_time + (datetime)((i + 1) * secs);
      double o = fc[i][0], h = fc[i][1], lo = fc[i][2], c = fc[i][3];
      color col = (c >= o) ? InpUpColor : InpDownColor;

      //--- wick: high-low vertical line
      string wick = StringFormat("%swick_%d", KR_OBJ_PREFIX, i);
      ObjectCreate(0, wick, OBJ_TREND, 0, t, h, t, lo);
      ObjectSetInteger(0, wick, OBJPROP_COLOR, col);
      ObjectSetInteger(0, wick, OBJPROP_WIDTH, 1);
      ObjectSetInteger(0, wick, OBJPROP_RAY, false);

      //--- body: thick open-close line
      string body = StringFormat("%sbody_%d", KR_OBJ_PREFIX, i);
      ObjectCreate(0, body, OBJ_TREND, 0, t, o, t, c);
      ObjectSetInteger(0, body, OBJPROP_COLOR, col);
      ObjectSetInteger(0, body, OBJPROP_WIDTH, 4);
      ObjectSetInteger(0, body, OBJPROP_RAY, false);

      //--- connector close[i-1] -> close[i] (dotted path)
      string seg = StringFormat("%sseg_%d", KR_OBJ_PREFIX, i);
      datetime t0 = last_time + (datetime)(i * secs);
      ObjectCreate(0, seg, OBJ_TREND, 0, t0, prev_close, t, c);
      ObjectSetInteger(0, seg, OBJPROP_COLOR, clrSilver);
      ObjectSetInteger(0, seg, OBJPROP_STYLE, STYLE_DOT);
      ObjectSetInteger(0, seg, OBJPROP_RAY, false);
      prev_close = c;
     }

Каждый объект имеет префикс KronosFC_, поэтому первая строчка процедуры безопасна: вызов ObjectsDeleteAll с этим префиксом удаляет объекты предыдущего прогноза, не затрагивая остальные объекты на графике, а OnDeinit удаляет их тем же способом. Если свойству OBJPROP_RAY присвоить значение false, каждая трендовая линия останется конечным отрезком, а не бесконечным лучом по умолчанию.

Kronos forecast candles drawn to the right of live price on the chart

Рис. 1. Советник KronosForecast отображает прогнозируемые свечи справа от текущей цены и обновляет прогноз при появлении каждого нового бара

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


Честная оценка и проблема заглядывания в будущее

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

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

Такое разделение отчасти обусловлено затратами, хотя и в меньшей степени, чем можно было бы ожидать. Ключевым показателем во второй части было время полного выполнения Predict — около шестнадцати секунд при генерации пяти сэмплированных траекторий. При формировании выгрузки вместо этого генерируется одна траектория с помощью жадного декодирования. При том же контексте из 256 баров один авторегрессионный шаг занимает около 200 миллисекунд. Для прогноза на 16 баров нужно 16 таких шагов, а также кодирование перед ними и декодирование после. По замерам за весь прогон на один прогноз уходило чуть менее пяти секунд, поэтому обработка 947 баров для оценки заняла около часа с четвертью непрерывных вычислений. Этого достаточно, чтобы не выполнять инференс внутри Тестера стратегий: при каждом изменении порога все вычисления пришлось бы повторять. Но более важная причина — это дисциплина. Поскольку прогнозы модели рассчитываются один раз и записываются в файл, все последующие этапы читают одни и те же фиксированные значения, и никакой анализ не может случайно изменить предсказания модели. Формирование выгрузки с жадным декодированием вместо сэмплирования дополняет эту схему: прогнозы становятся детерминированными, так что все исследование воспроизводится в точности, а не приблизительно.

Этот выбор требует одной оговорки, которую стоит сделать сразу, а не прятать в тексте. Ни советник, работающий на графике, ни эталонная реализация не используют жадное декодирование: оба генерируют сэмплированные траектории и усредняют их; в приведенном выше примере таких траекторий пять. При жадном декодировании на каждом шаге выбирается токен с наибольшей вероятностью, поэтому получается одна наиболее вероятная траектория, а не центр распределения. Усреднение множества сэмплированных траекторий приближенно оценивает тот же центр распределения, поэтому мы не ожидаем изменения итогового вывода. Однако отдельно мы это не проверяли и предпочитаем прямо сказать об этом, а не позволять читателю предполагать, что оба режима были протестированы. Далее речь идет о работе Kronos с жадным декодированием на этих данных.

Входные параметры скрипта выгрузки полностью задают эксперимент, и их стоит рассмотреть как его спецификацию:

input datetime      InpStartDate   = D'2025.04.01 00:00'; // eval window start (first eval bar)
input datetime      InpEndDate     = D'2025.06.01 00:00'; // eval window end   (last eval bar)
input int           InpStep        = 1;                   // dump density (evaluate every Nth bar)
input int           InpLookback    = 256;                 // context bars (<=512)
input int           InpPredLen     = 16;                  // forecast horizon (bars)
input ENUM_VOL_MODE InpVolMode     = VOL_TICK_DERIVED;    // volume/amount handling
input double        InpTemperature = 1.0;                 // sampling temperature (greedy ignores)
input int           InpTopK        = 0;                   // top-k (greedy ignores)
input double        InpTopP        = 0.9;                 // nucleus top-p (greedy ignores)
input int           InpSampleCount = 1;                   // averaged sample paths (1 for greedy)
input bool          InpGreedy      = true;                // greedy => deterministic / reproducible
input string        InpOutFile     = "kronos_eval\\eurusd_h1_forecasts.csv"; // output (Common\Files)

Два месяца данных EURUSD H1, прогноз на каждом баре, контекстное окно в 256 баров и горизонт прогнозирования в 16 баров. Период исследования невелик, поскольку прогнозирование — затратный этап; при необходимости его легко расширить. В результате получается 947 прогнозов — чуть более 15 000 отдельных прогнозных значений для оценки, хотя независимых среди них, как мы увидим, гораздо меньше.

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

//--- Pull enough history: L bars before the start, P bars after the end, so
//--- every eval bar has a full context AND P realized bars. Oldest-first.
   datetime from = InpStartDate - (datetime)((L + 2) * secs);
   datetime to   = InpEndDate   + (datetime)((P + 2) * secs);
   MqlRates rates[];
   int got = CopyRates(_Symbol, _Period, from, to, rates);

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

Расширение диапазона измеряется в секундах календарного времени, а цикл отсчитывает бары. Если отсчитать 258 часов назад от 1 апреля, получится 21 марта, однако из-за выходных в ряду H1 отсутствует примерно треть этих часов, поэтому 256-й бар полученного массива оказывается уже через несколько дней после начала запрошенного интервала. Поэтому первый прогноз привязан к дате 2025.04.04, а не 2025.04.01; это приводит лишь к потере нескольких баров для оценки и ни на что другое не влияет. Ошибка в сторону недостаточного объема истории безопасна: бар без полного контекста пропускается, и прогноз для него никогда не строится по укороченному контексту.

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

//--- Eval bar t: context is rates[t-L+1 .. t], ending at t's close (the anchor),
//--- and forecasts cover t+1..t+P. Nothing at or after t+1 feeds the forecast.
   int rows_written = 0;
   ulong t0 = GetTickCount64();

   for(int t = L - 1; t + P < got; t++)
     {
      if(rates[t].time < InpStartDate || rates[t].time > InpEndDate)
         continue;
      if(((t - L + 1) % InpStep) != 0)
         continue;

      matrix raw;
      datetime ctx_time[];
      BuildWindow(rates, t - L + 1, L, raw, ctx_time);   // closed bars t-L+1..t

Условие t + P < got означает, что прогноз для бара оценивается только при наличии всех P последующих баров, поэтому ни один прогноз не сравнивается с отсутствующими данными. В BuildWindow передается контекст rates[t-L+1 .. t] — L закрытых баров вплоть до оцениваемого включительно. Последняя свеча, которую видит модель, — та, чья цена закрытия служит опорной ценой. Данные бара t+1 и последующих баров не участвуют в формировании прогноза.

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

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

//+------------------------------------------------------------------+
//| One row: eval-bar time, its close, forecast closes 1..P, then the|
//| REALIZED future closes 1..P (rates[t+1..t+P]). Stored for scoring|
//| only, never an input to any forecast/trade decision.             |
//+------------------------------------------------------------------+
void WriteRow(int fh, const MqlRates &rates[], int t, int P, const matrix &forecast)
  {
   string row = TimeToString(rates[t].time, TIME_DATE | TIME_MINUTES);
   row += StringFormat(",%.6f", rates[t].close);
   for(int h = 0; h < P; h++)
      row += StringFormat(",%.6f", forecast[h][3]);     // col 3 = close
   for(int h = 1; h <= P; h++)
      row += StringFormat(",%.6f", rates[t + h].close);
   FileWrite(fh, row);
  }

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

Timeline of one evaluation bar showing context, forecast and realized bars

Рис. 2. Один оцениваемый бар. Контекст — L закрытых баров до оцениваемого включительно; его цена закрытия служит опорной ценой, прогноз охватывает P следующих баров, а фактические будущие бары считываются только при оценке качества, но не при прогнозировании или торговле

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


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

Наиболее чистая оценка прогноза вообще не предполагает торговой стратегии: нет ни порога, ни расчета размера позиции, ни стоп-лосса, а значит, нет и комбинации параметров, на которую можно случайно удачно наткнуться. Скрипт KronosEvalQuality считывает выгрузку и задает два вопроса для каждого горизонта прогнозирования от 1 до 16 баров. Совпадает ли прогнозируемое направление с фактическим чаще, чем в случае правила, игнорирующего модель? И ближе ли прогнозируемая доходность к фактической, чем при базовом прогнозе случайного блуждания — наивном прогнозе, согласно которому цена не изменится?

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

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

//+------------------------------------------------------------------+
//| Per-horizon running accumulators.                                |
//+------------------------------------------------------------------+
struct HorizonStat
  {
   int    n;            // directional samples (actual and forecast both non-flat)
   int    dir_hit;      // Kronos directional hits
   int    up;           // realized up-moves among those samples
   double k_abs;        // sum |kronos_ret - actual_ret|
   double k_sq;         // sum (kronos_ret - actual_ret)^2
   double rw_abs;       // sum |0 - actual_ret| == sum |actual_ret|
   double rw_sq;        // sum actual_ret^2
  };

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

      for(int h = 0; h < P; h++)
        {
         g_k[g_rows * P + h] = (fc[h] - last_close) / last_close;   // predicted cumulative return to h
         g_a[g_rows * P + h] = (ac[h] - last_close) / last_close;   // realized   cumulative return to h
        }

Процедура оценки проходит по этим доходностям и накапливает ошибки модели и базового прогноза. Она принимает список индексов строк, а не считывает строки по порядку; причина этого станет ясна при оценке неопределенности:

         double k_ret = g_k[base + h];
         double a_ret = g_a[base + h];

         //--- directional accuracy: skip flat actuals (no direction to call)
         //--- and flat forecasts (no call made)
         if(a_ret != 0.0 && k_ret != 0.0)
           {
            if((k_ret > 0.0) == (a_ret > 0.0)) st[h].dir_hit++;
            if(a_ret > 0.0) st[h].up++;
            st[h].n++;
           }

         double k_err = k_ret - a_ret;
         st[h].k_abs += MathAbs(k_err);
         st[h].k_sq  += k_err * k_err;
         st[h].rw_abs += MathAbs(a_ret);    // baseline error == |actual return|
         st[h].rw_sq  += a_ret * a_ret;

Прогноз "без изменений" означает прогноз нулевой доходности, то есть ошибка равна абсолютному значению фактической доходности для MAE и её квадрату для MSE, поэтому rw_abs суммирует модули фактических доходностей. При оценке направления пропускаются фактические цены закрытия, равные опорной, поскольку у них нет направления изменения, и прогнозы неизменности цены, которые направления не предсказывают. Поэтому для оценки направления учитывается 15 121 прогнозное значение, а не 15 152, которые дали бы 947 прогнозов на шестнадцати горизонтах: 23 фактические и 8 прогнозных цен закрытия точно совпали с опорной ценой закрытия. Суммы ошибок рассчитываются по всем прогнозным значениям и делятся на их полное количество, а не на число значений, участвующих в оценке направления.

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

Затем в отчете для каждого горизонта из двух сумм ошибок вычисляется отношение ошибки модели к ошибке базового прогноза — показатель, который и дает ответ на вопрос:

      //--- skill ratio < 1 means Kronos beats random-walk on that error metric
      double rmse_skill = (rw_rmse > 0) ? k_rmse / rw_rmse : 0.0;
      double mae_skill  = (rw_mae  > 0) ? k_mae  / rw_mae  : 0.0;

      PrintFormat("%2d| %6.2f%%  %5.1f%% | %.6f %.6f %5.3f | %.6f %.6f %5.3f",
                  h + 1, diracc * 100.0, upshr * 100.0, k_rmse, rw_rmse, rmse_skill,
                  k_mae, rw_mae, mae_skill);

Отношение ошибки модели к ошибке базового прогноза ниже 1,0 означало бы, что Kronos превосходит базовый прогноз случайного блуждания на этом горизонте; значение выше 1,0 означает, что результат хуже. Результаты однозначны:

Горизонт прогнозирования (бары)
Точность определения направления
Доля фактических движений вверх
Отношение RMSE модели к RMSE базового прогноза
Отношение MAE модели к MAE базового прогноза
1
51,86%
53,0%
1,124
1,166
2
50,64%
53,0%
1,094
1,131
3
51,80%
54,0%
1,095
1,137
4
51,53%
53,2%
1,098
1,119
5
49,63%
53,9%
1,093
1,115
6
50,58%
53,7%
1,081
1,106
7
51,90%
53,7%
1,071
1,092
8
50,32%
52,9%
1,077
1,097
9
51,00%
53,1%
1,073
1,088
10
51,59%
51,9%
1,093
1,094
11
51,90%
50,3%
1,120
1,109
12
54,03%
50,7%
1,188
1,144
13
52,91%
49,8%
1,252
1,171
14
53,49%
49,4%
1,300
1,198
15
52,48%
49,0%
1,371
1,231
16
53,59%
48,8%
1,403
1,250

Оба отношения ошибок модели к ошибкам базового прогноза превышают 1,0 на всех горизонтах, поэтому точечные прогнозы менее точны, чем прогноз неизменности цены. Обе меры ошибки дают согласованные результаты на всех горизонтах, так что вывод не является артефактом возведения ошибок в квадрат. В целом точность определения направления по 15 121 оцененному прогнозному значению составляет 51,83%, что выглядит как намек на прогностическую способность, пока этот показатель не сравнить со столбцом "Доля фактических движений вверх". На первых девяти горизонтах прогнозирования цена росла в 53-54% случаев, поэтому постоянный прогноз "рост" превзошел бы Kronos на каждом из них. Лишь начиная с горизонта 11 баров, где доля движений вверх снижается примерно до 50%, модель опережает прогноз по наиболее частому направлению на 1,5-3,3 процентного пункта.

Отношение ошибок остается в узком диапазоне 1,07-1,13 на горизонтах 1-10 баров; наименее плохое значение — 1,071 — достигается на горизонте 7 баров, а затем показатель растет до 1,403 на горизонте 16 баров. На горизонтах, близких к опорному бару, модель добавляет стабильную величину ошибки к базовому прогнозу, который и без того практически оптимален для почти случайных часовых движений цены; на более дальних горизонтах авторегрессионный дрейф, накопленный за шестнадцать баров, уводит прогноз от фактической цены.

Насколько убедительны результаты на этой выборке

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

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

//--- moving-block bootstrap: neighbouring forecasts share most of their path,
//--- so whole runs of rows are resampled to keep that dependence intact
   int B    = MathMax(1, MathMin(InpBlockLen, g_rows));
   int reps = MathMax(2, InpBootReps);
   MathSrand(InpSeed);

   double boot_dir[];                       // overall directional accuracy per resample
   double boot_hdir[];                      // [rep*P + h] directional accuracy
   double boot_rmse[];                      // [rep*P + h] RMSE skill ratio
   ArrayResize(boot_dir, reps);
   ArrayResize(boot_hdir, reps * P);
   ArrayResize(boot_rmse, reps * P);
   int pick[];
   ArrayResize(pick, g_rows);
   HorizonStat bs[];

   for(int r = 0; r < reps; r++)
     {
      for(int filled = 0; filled < g_rows; )
        {
         int start = RandIndex(g_rows - B + 1);
         for(int j = 0; j < B && filled < g_rows; j++)
            pick[filled++] = start + j;
        }
      Score(pick, P, bs);

При фиксированном начальном значении генератора случайных чисел интервалы воспроизводятся в точности. Ниже приведены интервалы для нескольких характерных горизонтов, полученные по 2 000 повторным выборкам из блоков по 24 последовательных прогноза:

Горизонт прогнозирования (бары)
Точность определения направления, 95-процентный доверительный интервал
Отношение RMSE модели к RMSE базового прогноза, 95-процентный доверительный интервал
Повторные выборки с отношением ошибки модели к ошибке базового прогноза ниже 1,0
1
48,88% - 55,32%
1,075 - 1,181
0,0%
4
48,10% - 55,56%
1,055 - 1,151
0,0%
7
47,67% - 57,34%
1,016 - 1,130
0,3%
12
48,09% - 59,62%
1,029 - 1,409
0,6%
16
46,78% - 60,28%
1,038 - 1,874
0,4%

Две части итогового вывода резко различаются. Вывод по ошибкам устойчив: на каждом горизонте интервал отношения ошибки модели к ошибке базового прогноза целиком лежит выше 1,0; доля повторных выборок со значением ниже 1,0 нигде не превышает 0,8%. Результат оценки направления не дает оснований для вывода о прогностической способности. Для общей точности 51,83% 95-процентный доверительный интервал составляет 47,47-56,38% и включает как 50%, так и точность правила "всегда прогнозировать рост"; его ширина соответствует примерно 503 независимым прогнозным значениям, а не 15 121. Данные не подтверждают прогностическую способность в определении направления. Это более слабое утверждение, чем доказательство равносильности прогноза подбрасыванию монеты; рост точности к горизонту шестнадцать баров вполне укладывается в шум.

Per-horizon directional accuracy and skill ratio versus the random-walk baseline

Рис. 3. Качество прогнозов по горизонтам: точность определения направления остается около 50% и на коротких горизонтах уступает правилу "всегда прогнозировать рост", тогда как отношение ошибки модели к ошибке базового прогноза и его 95-процентный доверительный интервал везде лежат выше 1,0

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


Тестирование на исторических данных — прибыль без торгового преимущества

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

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

      double last_close = (double)FileReadString(fh);
      double fc[]; ArrayResize(fc, P);
      for(int h = 0; h < P; h++) fc[h] = (double)FileReadString(fh);
      for(int h = 0; h < P; h++) FileReadString(fh);   // skip realized actuals (scoring only)

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

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

//+------------------------------------------------------------------+
//| Current position direction held by THIS EA on this symbol: +1    |
//| long, -1 short, 0 flat. Reading the terminal each bar instead    |
//| of a shadow flag keeps the EA in step with the real position     |
//| and prevents duplicate same-bar orders.                          |
//+------------------------------------------------------------------+
int CurrentDir()
  {
   if(!PositionSelect(_Symbol))
      return 0;
   if(PositionGetInteger(POSITION_MAGIC) != (long)InpMagic)
      return 0;
   long ptype = PositionGetInteger(POSITION_TYPE);
   return (ptype == POSITION_TYPE_BUY) ? +1 : -1;
  }

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

Логика обработки баров связывает компоненты, а первые две строчки обеспечивают защиту от заглядывания в будущее и в торговой части исследования:

//+------------------------------------------------------------------+
//| Bar logic. The forecast keyed at the PREVIOUS bar (the just-     |
//| closed bar) is the one we may act on now, at this bar's open,    |
//| so the decision never uses information from the bar it trades on.|
//+------------------------------------------------------------------+
void OnBar()
  {
//--- the just-closed bar is index 1; its time keys the forecast we trade now
   datetime closed_time = (datetime)iTime(_Symbol, _Period, 1);
   int r = FindRow(closed_time);

Прогноз привязан к бару с индексом 1 — только что закрывшемуся — и используется при открытии текущего бара. Решение не использует данных бара, на котором совершается сделка: это аналог правила контекста выгрузки на стороне тестера. Сам сигнал не может быть проще:

   double pred_ret = (fc_h - last_close) / last_close;
   int want = 0;
   if(pred_ret >  InpThreshold) want = +1;
   else if(pred_ret < -InpThreshold) want = -1;

   if(want == 0 || want == dir)
      return;   // no signal, or already in the wanted direction (no dup order)

//--- flip or fresh entry: close any opposite position first, then open once
   if(dir != 0)
      CloseAll();
   if(want > 0)  g_trade.Buy(InpLots);
   else          g_trade.Sell(InpLots);
   g_hold = InpHorizon;      // reset hold for the new position

Если прогнозируемая цена закрытия на выбранном горизонте прогнозирования превышает опорную цену закрытия более чем на пороговую величину, открывайте длинную позицию; если она ниже опорной цены закрытия более чем на пороговую величину, открывайте короткую позицию. Удерживайте позицию в течение горизонта прогнозирования или до смены направления сигнала. В обоих прогонах используется InpHorizon = 16, поэтому торговое правило опирается на прогноз модели о цене через шестнадцать часов. Стоп-лоссы, тейк-профиты и фильтры отсутствуют, чтобы тестировалась только модель, а единственный регулируемый параметр — InpThreshold.

Эти два пороговых значения устанавливаются на основе собственных выходных данных модели. На горизонте 16 баров в 69,6% прогнозов ожидаемое движение по модулю превышает 0,1%, поэтому торговое условие выполняется на большинстве баров. Лишь в 5,2% прогнозов ожидаемое движение по модулю превышает 0,6%, поэтому порог оставляет только самые выраженные сигналы, при которых даже плохо откалиброванная в среднем модель может давать полезную информацию. Эти пороги соответствуют двум описанным режимам, и мы приводим результаты обоих, а не только лучшего.

Оба прогона в тестере охватывают период выгрузки для EURUSD H1; фактический торговый интервал внутри него показывает, как действуют правила защиты от заглядывания в будущее. Первый прогноз привязан к 2025.04.04 21:00 по описанной выше причине — из-за различия между календарным временем и числом баров. Последний прогноз привязан к 2025.05.30 07:00, пятнице: ни для одного более позднего бара до выходных не набралось бы шестнадцати фактически сформировавшихся последующих баров. За пределами этого интервала поиск не находит соответствующей строки, и советник не торгует, поэтому границы эксперимента определяются исключительно выгрузкой.

Прогоны выполняются в режиме "Только цены открытия": этого достаточно, поскольку советник действует только при открытии бара. Начальный депозит — 10 000 долларов, кредитное плечо — 1:500, фиксированный объем — 0,1 лота, поэтому один доллар прибыли соответствует движению EURUSD на один пипс. Тестер учитывает реальные издержки этого счета: спред из истории цен — около двух пунктов по данным о ценах исполнения, комиссию 0,70 доллара за полный цикл сделки и своп за перенос позиции на следующий день. Одна деталь вновь отсылает к дублирующему флагу состояния. В этом режиме тик, открывающий бар в 00:00, может предшествовать началу торговой сессии; отправленный на этом тике ордер отклоняется с сообщением [Market closed]. В прогоне с умеренным порогом это произошло одиннадцать раз; поскольку советник получает сведения о позиции из терминала, после каждого отказа его состояние оставалось прежним.

При высоком пороге в 0,6% стратегия срабатывает всего восемь раз за два месяца. Четыре сделки прибыльны, профит-фактор равен 0,66, а прогон завершился убытком 73,60 доллара до учета издержек. Восемь сделок не позволяют сделать вывод в ту или иную сторону. Это первый урок, который дает тестер, и он действует в обе стороны: за другой двухмесячный период столь же небольшое число сделок с тем же успехом могло бы сформировать плавно растущую кривую.

Снизьте порог до 0,1%, чтобы стратегия действительно торговала, и в прогоне получится 133 сделки:

Показатель
Высокий порог, 0,6%
Умеренный порог, 0,1%
Общее количество сделок
8
133
Чистая прибыль до учета издержек (долл. США)
-73,60
220,20
Комиссия и своп (долл. США)
-12,32
-130,61
Чистая прибыль после учета издержек (долл. США)
-85,92
89,59
Профит-фактор
0,61
1,05
Коэффициент Шарпа
-0,22
0,34
Доля прибыльных сделок
50,00%
45,86%
Ожидаемая прибыль на сделку (долл. США)
-10,74
0,67
Фактор восстановления
-0,25
0,16
Относительная просадка баланса
2,10%
4,28%
Максимальная прибыль по сделке (долл. США)
71,10
105,75
Максимальный убыток по сделке (долл. США)
-157,99
-272,89
Корреляция LR
-0,15
0,50
Z-оценка
-0,38
-0,80

Начиная со строки "Профит-фактор" приведены показатели самого тестера после учета издержек. На первый взгляд прогон с умеренным порогом можно считать умеренным успехом: профит-фактор — 1,05, коэффициент Шарпа — 0,34, а кривая эквити заканчивается выше начального уровня.

Fair-threshold backtest equity curve: 133 trades and a small profit after costs

Рис. 4. Тестирование на исторических данных с умеренным порогом — 133 сделки и небольшая прибыль после вычета издержек, которую приведенные ниже проверки объясняют дрейфом рынка

Две проверки ставят этот успех под сомнение. Первая проверка касается разброса результатов. До учета издержек стратегия заработала 220,20 доллара на 133 сделках — 1,66 доллара на сделку при стандартном отклонении результатов сделок 40,29 доллара. t-статистика равна 0,47, что значительно ниже любого общепринятого порогового значения. При повторном отборе сделок с возвращением результат оказывается нулевым или отрицательным примерно в 31% повторных выборок. Z-оценка -0,80 указывает, что порядок прибыльных и убыточных сделок неотличим от случайного.

Вторая проверка выясняет, откуда взялась прибыль. 68 сделок на покупку принесли 328,70 доллара прибыли, а 65 сделок на продажу — 108,50 доллара убытка. За рассматриваемый период курс EURUSD вырос примерно с 1,094 до 1,135, то есть примерно на 3,7%, поэтому правило, по которому около половины сделок приходится на покупки, позволяет получить часть этого роста независимо от подаваемого сигнала. Это тот же дрейф рынка, который позволил правилу "всегда прогнозировать рост" превзойти модель при оценке качества прогнозов, но теперь он выражается в денежном результате.

Издержки забирают большую часть того, что осталось. Комиссия и своп в сумме составляют 130,61 доллара — около 59% прибыли до вычета издержек; комиссия фиксирована и равна 0,70 доллара за полный цикл сделки независимо от сигнала, а более широкий спред при реальной торговле привел бы к дополнительным издержкам. При прибыли в 1,66 доллара на сделку запас для покрытия всех этих издержек очень мал. Крайние результаты подтверждают вывод: максимальный убыток 272,89 доллара более чем в 2,5 раза превышает максимальную прибыль 105,75 доллара, поскольку без стоп-лоссов сигнал, не позволяющий надежно определить направление, не защищает от крупных неблагоприятных движений цены.

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


Вердикт и его обоснование

Три подхода к оценке одних и тех же 947 прогнозов дают согласующиеся результаты. На всех горизонтах точечные прогнозы менее точны, чем прогноз "без изменений"; интервалы отношения ошибок находятся выше 1. Точность определения направления статистически неотличима от 50%, а на коротких горизонтах она ниже, чем у правила "всегда прогнозировать рост". Прибыль по результатам тестирования на исторических данных статистически неотличима от нуля и получена за счет длинных позиций на растущем рынке. Эти подходы к оценке не независимы, поскольку все три используют одни и те же прогнозы за одни и те же два месяца, но отвечают на разные вопросы.

Из этого следуют два отдельных вывода. На этом временном отрезке EURUSD H1 модель Kronos с жадным декодированием не демонстрирует прогностической способности по сравнению с базовым прогнозом "без изменений", а простое правило на основе ее прогнозов не дает торгового преимущества. Причины конкретны и поучительны; дело не в ошибке переноса:

  • Несоответствие предметных областей. Модель Kronos обучалась на данных по акциям и криптовалютам, преимущественно на дневных барах и барах более крупных таймфреймов. Мы подавали модели часовые данные валютного рынка — другой класс инструментов и другой временной масштаб по сравнению с данными, на которых она обучалась. Фундаментальные модели гораздо лучше обобщают закономерности в пределах своей предметной области, чем за ее пределами, а часовые данные валютного рынка сильно отличаются от дневных данных рынка акций как по статистическим характеристикам, так и по структуре рынка.
  • Синтетический объем. Потоки данных рынка Forex не содержат реального биржевого объема, поэтому мы искусственно сформировали признаки volume и amount из тикового объема и расчетного денежного оборота. Модель никогда не видела таких заменителей исходных показателей; два из шести ее входных признаков находятся вне обучающего распределения, и она никак не может сообщить, что из-за этого ее уверенность ниже.
  • Крайне сильный базовый прогноз. Внутридневная динамика EURUSD близка к мартингалу: следующее движение практически непредсказуемо, поэтому прогноз "без изменений" служит чрезвычайно сильным базовым прогнозом. Превзойти этот базовый прогноз на этом инструменте и временном масштабе трудно для любой прогнозной модели, а отношение ошибки модели к ошибке базового прогноза 1,07 даже на лучшем горизонте — цена попыток непременно указать направление там, где его невозможно надежно определить.

Мы сознательно не стали искать более благоприятные условия тестирования ради более привлекательных показателей. Очевидный следующий шаг — повторное тестирование на дневных барах с реальным биржевым объемом, ближе к данным обучения Kronos; оно вполне может дать другую картину. Однако проведение теста только после неутешительного результата и публикация лишь лучшего из двух результатов — именно та нечестность, которую призвана предотвратить эта оценка. Этот вердикт остается наиболее неблагоприятным, но справедливым, а результаты повторного тестирования, если кто-то его проведет, следует рассматривать наряду с этим результатом, а не вместо него.

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

  1. Запустите скрипт KronosForecastDump на графике EURUSD с периодом H1, используя указанные ранее параметры. Скрипт записывает файл kronos_eval\eurusd_h1_forecasts.csv в общую папку Common\Files — место, доступное также агенту Тестера стратегий. Отведите на это примерно час с четвертью.
  2. Запустите KronosEvalQuality на том же графике. За несколько секунд он выводит в лог на вкладке "Эксперты" таблицу прогностической способности по горизонтам и бутстреп-интервалы, не выполняя торговых операций.
  3. Запустите KronosSignalEA в тестере стратегий на EURUSD H1 в режиме «Только цены открытия»: один раз с InpThreshold = 0.006, другой — со значением 0.001, в обоих случаях установив InpHorizon = 16 и выбрав любой интервал, охватывающий период выгрузки. Сделки будут совпадать с нашими; итоговые показатели с учетом издержек будут отличаться, если комиссия и своп у вас не совпадают с нашими.
Главный урок: проверка доказала точность воспроизведения эталонной модели PyTorch в нативной реализации на MQL5, но ничего не сказала о наличии торгового преимущества. Описания торговых моделей чаще всего вводят в заблуждение тем, что выдают первое за второе, а тестирование на исторических данных, превращающее дрейф рынка в растущую кривую эквити, — самый обычный пример этой ловушки.


Заключение

Цель этой серии статей заключалась в том, чтобы запустить настоящую фундаментальную модель непосредственно в MetaTrader 5 и честно выяснить, насколько она полезна. Нативная реализация на MQL5 точно воспроизводит эталонную модель PyTorch, что подтверждено сравнением на каждом этапе. Прогнозы, полученные жадным декодированием на данных EURUSD H1 за два месяца с синтетическим объемом, не дают практически полезного торгового преимущества. Три подхода к оценке, отвечающие на разные вопросы, приводят к одному выводу.

  • Здесь прогноз одновременно поддается проверке и бесполезен. Он точно воспроизводит результат эталонной модели, но не дает торгового преимущества на этом инструменте. Главное — честно признавать оба факта, и ни один из них не умаляет другого.
  • Прибыль — это не торговое преимущество. Тестирование на исторических данных с умеренным порогом заканчивается прибылью, но эта прибыль — шум плюс дрейф рынка: она статистически неотличима от нуля и получена за счет длинных позиций на растущем рынке. Отличить прибыль от торгового преимущества позволяют подсчет сделок, сравнение среднего результата с разбросом результатов и раздельный анализ по направлениям сделок.
  • Временное выравнивание — часть честного анализа. Заглядывание в будущее — широко известная ошибка, но контекстное окно, заканчивающееся на один бар раньше нужного, не приводит к утечке информации из будущего и все же заставляет оценивать каждый прогноз на неверном горизонте прогнозирования. Это видно только по индексам.
  • Архитектура может обеспечивать честность. Выгрузка прогнозов на подготовительном этапе, их оценка отдельным скриптом и отбрасывание советником столбцов с фактическими будущими данными при чтении файла затрудняют заглядывание в будущее самой структурой системы, а не сводят его предотвращение к благим намерениям. Считывание состояния позиции из терминала вместо использования дублирующего флага состояния — тот же подход, примененный к задаче меньшего масштаба.
  • Честные отрицательные результаты укрепляют доверие. Причины неудачи модели здесь конкретны: несоответствие предметных областей и временных масштабов, а также синтетический объем. Они ясно указывают, в каких условиях стоит провести следующий тест для справедливой оценки модели.

На этом серия завершена. Запуск фундаментальной модели в MQL5 оказался решаемой частью задачи: после трех статей реализация работает, проверена и достаточно быстра для использования. Главное — понять, можно ли доверять прогнозам модели. При работе со следующей моделью стоит сохранить подход, который позволил ответить на этот вопрос: отдельно проверять точность воспроизведения и практическую ценность для торговли, не допускать утечки информации из будущего в прошлое и, прежде чем доверять результатам тестирования на исторических данных, выяснять, каким был бы результат без модели. Этот конкретный отрицательный результат не обесценивает ни реализацию, ни метод оценки следующего результата.

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


Получение исходного кода через MQL5 Algo Forge

Все исходные файлы прикреплены ниже к этой статье, но полный репозиторий также доступен на MQL5 Algo Forge — платформе сообщества на базе Git для публикации торговых проектов и совместной работы над ними.

Четыре приведенные ниже программы добавлены в этой части серии. Все они используют реализацию из частей 1 и 2, поэтому для компиляции и запуска требуются заголовочные файлы из MQL5\Include\Kronos и экспортированные файлы весов из тех статей.

Имя файла
Описание
MQL5\Experts\Kronos\KronosForecast.mq5
Советник только для визуализации: запускает полный пайплайн и отрисовывает прогнозные свечи на графике
MQL5\Scripts\Kronos\KronosForecastDump.mq5
За один проход по историческим данным выгружает в CSV прогнозы, полученные жадным декодированием, и фактические будущие цены закрытия, соблюдая меры защиты от заглядывания в будущее
MQL5\Scripts\Kronos\KronosEvalQuality.mq5
Оценивает прогнозы из выгрузки для каждого горизонта: точность определения направления, базовую точность правила "всегда прогнозировать рост", отношение ошибки модели к ошибке базового прогноза случайного блуждания и интервалы блочного бутстрепа
MQL5\Experts\Kronos\KronosSignalEA.mq5
Советник для Тестера стратегий: торгует по намеренно простому пороговому правилу на основе заранее рассчитанных прогнозов

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/24221

Прикрепленные файлы |
MQL5.zip (65.63 KB)
Пользовательские инструменты отладки и профилирования для разработки на MQL5 (Часть II): Профилирование советников и тестирование торговой логики Пользовательские инструменты отладки и профилирования для разработки на MQL5 (Часть II): Профилирование советников и тестирование торговой логики
Мы создаём компактный профилировщик, который записывает в CSV количество вызовов, минимальное, максимальное и среднее время выполнения, а также количество медленных вызовов, и простую среду запуска тестов, которая формирует детерминированные отчёты об успешном или неуспешном прохождении тестов. В статье объясняется, где размещать замеры в советнике, как выполнять выборку тиков и как сохранять возможность тестирования чистых вычислений. Выполнение сначала скрипта, а затем профилирующего советника позволяет получить воспроизводимые данные для регрессионного анализа.
Конструирование признаков для машинного обучения (Часть 5): Микроструктурные признаки на Python Конструирование признаков для машинного обучения (Часть 5): Микроструктурные признаки на Python
В статье представлен набор микроструктурных признаков из главы 19, реализованный в модуле afml.features.microstructure, и описана двухуровневая архитектура для работы как только с данными OHLCV, так и с добавлением тиковых данных. Мы рассматриваем оценки спреда и волатильности по Роллу и Корвину — Шульцу, лямбду Кайла, лямбду Амихуда и лямбду Хасбрука, VPIN, а также признаки дисбаланса на уровне баров — всё это реализовано в вычислительных ядрах, ускоренных с помощью Numba. Один проход с использованием np.searchsorted определяет границы баров, что позволяет выполнять параллельные вычисления с помощью prange и получать матрицу признаков с индексом по барам, готовую для дальнейшего использования в моделях машинного обучения.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Конструирование признаков для машинного обучения (Часть 7): Энтропийные признаки в Python Конструирование признаков для машинного обучения (Часть 7): Энтропийные признаки в Python
В статье представлены готовые к промышленной эксплуатации оцениватели энтропии (Шеннона, подстановочный, Лемпеля — Зива, Контоянниса), работающие с последовательностями, закодированными по тиковому правилу. В статье устранены три проблемы корректности и производительности исходного кода, результаты сверены с данными из главы, а возможности кодирования расширены за счёт квантильного и сигма-кодирования. Пользователи получают воспроизводимые результаты и заметно более высокую скорость вычислений при работе с большими наборами баров.