Статистический арбитраж на основе коинтегрированных акций (Часть 9): Бэктестирование обновлений весов портфеля
Введение
Рынок находится в постоянном движении. Это девиз, который ведёт нас на этом пути к созданию системы статистического арбитража для частного трейдера. Мы придерживаемся этого подхода, отказываясь от привычных представлений о медвежьих и бычьих рынках, направленных трендах или коррелирующих активах. Вместо этого мы используем статистические методы для оценки вероятности того, что пары или группы активов сохранят устойчивую связь в течение предсказуемого периода времени. На данный момент мы рассматриваем коинтеграционную связь ввиду их гибкости и практически повсеместной применимости на финансовых рынках. Мы можем искать коинтеграцию между любыми активами, включая активы из разных классов, и даже между финансовыми активами и нефинансовыми данными, например между биржевым символом и динамикой транспортных расходов. Как только обнаруживается коинтеграция, на ней почти наверняка можно торговать.
Недостаток заключается в том, что со статистической точки зрения нет гарантии, что коинтеграция сохранится в течение следующего часа, дня или недели. Всегда будет существовать некоторая остаточная вероятность того, что связь нарушится, начиная со следующего тика. Вероятность того, что цены акций изменятся, составляет почти сто процентов.
Мы вычисляем вектор коинтеграции на основе текущих цен акций. Из вектора коинтеграции мы получаем относительные веса портфеля, то есть объемы активов, которые необходимо купить или продать в каждом ордере. Поскольку цены акций постоянно меняются, меняются и веса в портфеле. Можно с почти стопроцентной уверенностью сказать, что оптимальный объем заказа вчера не является оптимальным объемом заказа сегодня. Изменились относительные цены, поэтому необходимо обновить веса в портфеле.
В предыдущей статье мы рассмотрели, как можно осуществлять постоянный мониторинг стабильности весов портфеля, используя проверку ADF внутри выборки/вне выборки (IS/OOS ADF) в сочетании с методом сравнения собственных векторов скользящих окон (RWEC). Этот хорошо известный метод эффективен для выявления прошлых разрывов коинтеграции и оценки вероятности нарушения взаимосвязи между активами в будущем. Эти функции делают его полезным как для анализа данных при формировании портфеля, так и в качестве инструмента управления рисками при мониторинге реальной торговли. В процессе формирования портфеля мы можем оценивать эффективность модели при различных значениях основных параметров каждого метода, а в ходе мониторинга — точно настраивать эти параметры с учетом результатов анализа последних данных. Поскольку в нашей реализации RWEC используется тот же метод, что и в нашей системе оценки для определения степени коинтеграции — тест коинтеграции Йохансена, — полученные в результате векторы коинтеграции можно использовать для обновления весов нашего портфеля.
В режиме реальной торговли наш советник будет считывать веса портфеля из таблицы «strategy», как мы уже обсуждалипри настройке базы данных в качестве единого источника достоверной информации. Однако при проведении бэктестов у нас нет доступа к базе данных. Бэктестинг — это единственный практичный способ смоделировать процесс тонкой настройки этих параметров для десятков, а возможно, и сотен пар активов и корзин за разумный промежуток времени. С помощью бэктестинга мы можем оценить методы проверки стабильности сигналов, а также эффективность наших алгоритмов ребалансировки. Нам необходимо проверить, работает ли логика ребалансировки советника так, как ожидалось, и насколько выбранные параметры ребалансировки могут улучшить наши результаты. Самый простой способ получить доступ к данным базы данных в тестере MetaTrader 5 — экспортировать нужные данные в файл и считывать их непосредственно из советника. Здесь мы экспортируем всю таблицу «strategy» в файл CSV (Comma Separated Values) и загрузим «новые» веса портфеля с помощью специальной вспомогательной функции для тестирования.
Заполнение базы данных примерными данными
Прежде чем экспортировать таблицу «strategy» из нашей базы данных, нам необходимо получить данные RWEC по ней. Для этого мы воспользуемся простым скриптом на Python, который запускает анализ RWEC и сохраняет его результаты в нашей базе данных. Еще раз обратите внимание, что это моделирование для бэктеста. В обычных условиях анализ RWEC будет являться частью нашего ежедневного мониторинга торговли, а его результаты будут использоваться советником непосредственно из базы данных.
Скрипт будет извлекать необходимые данные из терминала MetaTrader 5. Как обычно, в наших примерах мы используем только те инструменты, которые доступны на демо-счете MetaQuotes, поэтому вам не составит труда самостоятельно провести эти эксперименты.
Этот скрипт прилагается к данному документу под названием rwec2db.py.

Рисунок 1. Снимок экрана, на котором представлен сводный обзор методов и функций rwec2db.py
После запуска этого скрипта вы должны увидеть следующий вывод:

Рисунок 2. Снимок экрана с ожидаемым результатом работы скрипта rwec2db.py
Если вектор коинтеграции не найден, об этом будет сообщено.

Рисунок 3. Снимок экрана, демонстрирующий вывод скрипта rwec2db.py в случае, когда вектор коинтеграции не найден
Обратите внимание, что описанная выше ситуация может возникнуть при тестировании одних и тех же символов в течение относительно короткого периода, скажем, 30 баров. Это связано с тем, что исторических данных может оказаться недостаточно для сравнения собственных векторов скользящих окон. Если это произойдет, вы можете запросить дополнительные данные и/или увеличить длину скользящего окна. Если всё пройдёт успешно, ваша таблица «strategy» должна выглядеть примерно так.

Рис. 4. Снимок экрана интегрированной в MetaEditor базы данных SQLite, на котором показана таблица «strategy», заполненная примерными данными
Теперь мы готовы экспортировать таблицу.
Экспорт таблицы базы данных
В MetaEditor есть встроенная функция экспорта таблиц. Просто щелкните правой кнопкой мыши по названию таблицы.

Рис. 5. Снимок экрана контекстного меню базы данных MetaEditor
Чтобы ваш экспорт полностью соответствовал приложенному примеру кода, выберите следующие параметры в открывшемся диалоговом окне.

Рис. 6. Снимок экрана диалогового окна «Параметры экспорта базы данных» в MetaEditor с выделенными рекомендуемыми настройками
Выберите символ табуляции в качестве разделителя. Таким образом, фактически мы будем работать с файлом в формате TSV (Tab-Separated Values). Это сделано для того, чтобы упростить разбор (парсинг) весов портфеля, поскольку они хранятся в базе данных в виде массива JSON. В этих массивах уже есть запятые.
[1.0, -0.045459, 0.021855, -0.033486]
Выбрав символ табуляции в качестве разделителя, мы можем упростить этот процесс разбора. Кроме того, было решено сохранить названия столбцов и строки в двойных кавычках.
Помните, что для того, чтобы файл TSV был доступен в среде Tester, его необходимо сохранить в папке TERMINAL_DATA_PATH («Папка, в которой хранятся данные терминалов») или в папке TERMINAL_COMMONDATA_PATH («Общий путь для всех терминалов, установленных на компьютере»). В данном примере мы будем использовать первый вариант.
Если вы не сохраняете файл TSV в указанном выше общем каталоге, вам необходимо включить свойство tester_file в ваш основной файл MQL5.
//+------------------------------------------------------------------+ //| CointNasdaq.mq5 | //| Copyright 2025, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ //+------------------------------------------------------------------+ //| Expert Advisor - Cointegration Statistical Arbitrage | //| Assets: Dynamic assets allocation | //| Strategy: Mean-reversion on Johansen cointegration portfolio | //+------------------------------------------------------------------+ #property tester_file "StatArb\\strategy_202512041731.csv"
«Свойства, описанные во вложенных файлах, полностью игнорируются. Свойства должны быть указаны в основном файле mq5. (...) Имя файла для тестера с указанием расширения, заключённое в двойные кавычки (в виде константной строки). Указанный файл будет передан тестировщику. «Входные файлы для тестирования, если таковые необходимы, должны всегда указываться» (документация MQL5)
Без этого свойства вы не сможете прочитать файл из среды Tester.

Рис. 7. Снимок экрана, на котором видна ошибка открытия файла отчёта журнала в MetaTrader 5 в тестовом режиме
Это происходит потому, что среда Tester из соображений безопасности работает как изолированная тестовая среда. В этой статье дано краткое и понятное описание внутреннего процесса работы с файлами в программе Tester. В документации по MetaTrader 5 содержится много информации о чтении и записи файлов. В AlgoBook есть подробное руководство по работе с файлами в MQL5. Здесь мы сосредоточимся на требованиях, характерных для нашего конкретного случая использования.
Загрузка параметров стратегии из файла
После выполнения этих предварительных действий мы готовы представить функцию LoadStrategyFromFile(), которая теперь включена в наш пример советника CointNasdaq.mq5, приложенный в конце этой статьи. Прилагаемый код специально оставлен в «сыром» виде. Я не приукрашивал код, удаляя отладочные выводы. Вместо этого я оставил их вместе со всеми комментариями, которые помогли мне в написании кода, чтобы вам было проще следить за процессом и искать более простые, лаконичные или эффективные решения. Большинство выводов отладочной информации, которые я использовал, закомментированы. Итак, если у вас возникнут проблемы при тестировании собственных изменений, вы можете просто раскомментировать нужный фрагмент кода.
В зависимости от среды выполнения функция LoadStrategyFromFile() может вызываться непосредственно в обработчике события OnInit() советника.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { ResetLastError(); // Check if all symbols are available for(int i = 0; i < ArraySize(symbols); i++) { if(!SymbolSelect(symbols[i], true)) { Print("Error: Symbol ", symbols[i], " not found!"); return(INIT_FAILED); } } // Initialize spread buffer ArrayResize(spreadBuffer, InpLookbackPeriod); // Set a timer for spread, mean, stdev calculations // and strategy parameters update (check DB) EventSetTimer(InpUpdateFreq * 60); // min one minute // check if we are backtesting if(!MQLInfoInteger(MQL_TESTER)) { // Load strategy parameters from database if(!LoadStrategyFromDB(InpDbFilename, InpStrategyName, symbols, weights, timeframe, InpLookbackPeriod)) { // Handle error - maybe use default values printf("Error at " + __FUNCTION__ + " %s ", getUninitReasonText(GetLastError())); return INIT_FAILED; } } else { // Load strategy parameters from CSV file if(!LoadStrategyFromFile(InpTesterStrategyFilename, symbols, weights)) { // Handle error - maybe use default values printf("Error at " + __FUNCTION__ + " %s ", getUninitReasonText(GetLastError())); return INIT_FAILED; } } return(INIT_SUCCEEDED); }
Если советник работает в тестовой среде, мы загружаем стратегию из файла. В противном случае, при обычной торговле стратегия будет загружена из базы данных.
Чтобы не перегружать наш основной файл EA, функция реализована в сопутствующем заголовочном файле TestHelper.mqh, который также прилагается к этому документу.
//+------------------------------------------------------------------+ //| Load the strategy parameters from CSV/TSV file | //+------------------------------------------------------------------+ bool LoadStrategyFromFile(string filename, string &strat_symbols[], double &strat_weights[]) { Print("Running on tester"); // Instantiate the hash map CHashMap<ulong, CArrayDouble*> updates; // Load the weights from the CSV file LoadWeights(filename, updates); Print("Updates count ", updates.Count()); (...)
Если посмотреть на параметры функции, можно заметить, что она учитывает только веса портфеля. Это контрастирует с аналогичной функцией, которая загружает стратегию из базы данных и включает название стратегии, таймфрейм и период обратного анализа. Это связано с тем, что данная реализация находится в стадии разработки. Позже, при рассмотрении вопроса о ротации портфеля, будут учтены и все параметры стратегии.
Функция начинается с создания экземпляра динамической хэш-таблицы из стандартной библиотеки MQL5 / «Общие коллекции данных» — объекта CHashMap, в котором в качестве ключей будут храниться временные метки обновлений нашего портфеля, а в качестве значений — веса портфеля в виде массивов чисел типа double. Затем экземпляр этого объекта вместе с именем файла передается по ссылке в специальную функцию LoadWeights() для правильного чтения файла CSV.
//+------------------------------------------------------------------+ //| Load portfolio weights updates from CSV/TSV file | //+------------------------------------------------------------------+ void LoadWeights(string filename, CHashMap<ulong, CArrayDouble*> &updates) { ResetLastError(); int filehandle = FileOpen(filename, FILE_ANSI | FILE_CSV | FILE_READ, '\\t', CP_ACP); if(filehandle != INVALID_HANDLE) { printf("Data Path: %s Filename: %s", TerminalInfoString(TERMINAL_DATA_PATH), filename); // Read and discard the header line string first_line = FileReadString(filehandle); Print(first_line); (...)
Функция LoadWeights() использует стандартный подход к чтению текстовых файлов в MQL5. Еще раз обратите внимание, что при открытии файла мы НЕ используем флаг FILE_COMMON, а это означает, что мы используем терминальный путь к данным, что требует использования #property tester_file в нашем советнике, как уже упоминалось выше. Мы игнорируем первую строку — заголовок CSV — и выводим её в журнал для проверки.

Рис. 8. Снимок экрана журнала MetaTrader 5, на котором видны первые строки журнала тестирования
Затем мы начинаем проходить по строкам файла CSV, исключая заголовок, чтобы разделить их по символу табуляции. Полученные сегменты и являются теми значениями, которые нам нужны. Итак, мы сохраняем их в строковом массиве fields[].
// iterate over lines while(!FileIsEnding(filehandle)) { string line = FileReadString(filehandle); string fields[]; // fields[0] -> tstamp fields[4] -> weights int count = StringSplit(line, '\t', fields); //printf("fields => %s %s %s %s %s %s %s", // fields[0], fields[1], fields[2], // fields[3], fields[4], fields[5], fields[6]); //— (...)
Мы создаём объект CArrayDouble для хранения массива весов для каждой прочитанной строки. Это объект, необходимый для нашего класса CHashMap.
// Create the CArrayDouble object for this timestamp CArrayDouble *current_weights_arr = new CArrayDouble(); ulong tstamp = 0; (...)
Поскольку массивы весов нашего портфеля хранятся в базе данных в формате JSON, перед передачей их объекту CArrayDouble нам необходимо очистить их, удалив скобки.
// Ensure we have at least the tstamp (0) and weights (4) fields if(count > 4) { // weights string string weights_str = fields[4]; StringReplace(weights_str, "[", ""); StringReplace(weights_str, "]", ""); // weights strings array string weights_str_arr[]; int weights_count = StringSplit(weights_str, ',', weights_str_arr); //--- if(current_weights_arr == NULL) { Print("Err creating CArrayDouble for timestamp ", fields[0]); continue; // Skip to the next line } // Populate the new CArrayDouble for(int i = 0; i < weights_count; i++) { //printf("weights_str_arr %s", weights_str_arr[i]); double weight_value = StringToDouble(weights_str_arr[i]); //printf("weight_value %.6f", weight_value); tstamp = (ulong)StringToInteger(fields[0]); //printf("tstamp %I64u ", tstamp); current_weights_arr.Add(weight_value); //printf("current_weights_arr Total: %d", current_weights_arr.Total()); } (...)
Теперь, когда наш объект CArrayDouble заполнен, мы можем добавить его в нашу хеш-карту.
// 4. Add to the HashMap once per line if(updates.Add(tstamp, current_weights_arr)) { printf("Added tstamp %I64u -> %s ", tstamp, TimeToString(tstamp)); } else { Print("Failed adding record"); } } } } else { printf("Error opening file %s. Error: %i", filename, GetLastError()); } FileClose(filehandle); }

Рис. 9. Снимок экрана журнала MetaTrader 5, на котором видны временные метки обновлений, добавленные в хеш-карту
Вернёмся к нашей функции LoadStrategyFromFile(): мы проверяем количество загруженных обновлений портфеля. Это количество строк в вашем файле CSV за вычетом одной (заголовка).
// Load the weights from the CSV file LoadWeights(filename, updates); Print("Updates count ", updates.Count()); // copy the values to iterable arrays ulong tstamp_keys[]; CArrayDouble *weights_values[]; updates.CopyTo(tstamp_keys, weights_values); // check if everything was copied Print("Keys size: ", tstamp_keys.Size()); Print("Values size: ", weights_values.Size()); (...)

Рис. 10. Снимок экрана журнала MetaTrader 5, на котором отображается количество обновлений, подлежащих обработке
Затем мы проверяем наличие устаревших обновлений в файле.
// check for outdated updates on file ulong first_tstamp_on_file = tstamp_keys[0]; printf("first_tstamp_on_file %I64u", first_tstamp_on_file); ulong update_to_apply = 0; if(FileHasOutdatedUpdates(first_tstamp_on_file)) FileCleanUpdates(tstamp_keys, updates, update_to_apply); (...)
Помните, что мы используем экспортированные данные базы данных для моделирования обновлений весов портфеля, которые при реальной торговле мы будем получать из базы данных. Итак, единственное значимое обновление — это последнее, то, которое имеет более раннюю временную метку, непосредственно предшествующую текущему моменту времени. Однако при экспорте данных наша база данных может содержать — и, скорее всего, будет содержать — более старые данные RWEC, относящиеся к периоду за много дней или недель до даты начала нашего бэктеста. Эти старые данные необходимо удалить, чтобы сохранить совпадение даты и времени между нашими тестовыми данными и настройками бэктеста.
//+------------------------------------------------------------------+ //| check if the CSV file has outdated updates | //+------------------------------------------------------------------+ bool FileHasOutdatedUpdates(ulong updates_start_time) { datetime test_start_time = TimeCurrent(); if((datetime)updates_start_time < test_start_time) { Print("Warning! Updates starts before test start time."); printf("Test start time: %s", TimeToString(test_start_time)); printf("Updates start time: %s", TimeToString(updates_start_time)); Print("Will REMOVE outdated updates."); return true; } return false; }

Рис. 11. Снимок экрана журнала MetaTrader 5 с предупреждением об удалении устаревших обновлений
Функция FileCleanUpdates() проходит по всем временным меткам и удаляет все устаревшие обновления, за исключением последнего.
//+------------------------------------------------------------------+ //| iterate over keys to remove outdated updates | //+------------------------------------------------------------------+ void FileCleanUpdates(ulong &tstamp_keys[], CHashMap<ulong, CArrayDouble*> &updates, ulong &update_to_apply) { int outdated_count = 0; ulong outdated_keys[]; for(int i = 0; i < ArraySize(tstamp_keys); i++) { if((datetime)tstamp_keys[i] < TimeCurrent()) // look for outdated updates { printf("Outdated updates at: %s", TimeToString(tstamp_keys[i])); outdated_keys.Push(tstamp_keys[i]); outdated_count++; } while(outdated_count > 1) // preserve the newest one to be applied { if(updates.Remove(tstamp_keys[i - 1])) { outdated_count--; printf("Removed outdated update from %s ", TimeToString(tstamp_keys[i - 1])); } } } printf("Removed %i outdated updates:", outdated_keys.Size() - 1); update_to_apply = outdated_keys[outdated_keys.Size() - 1]; printf("Update from %s to be applied", TimeToString(update_to_apply)); }
В журнале фиксируются как удаленные устаревшие обновления, так и обновление, которое предстоит установить.

Рис. 12. Снимок экрана журнала MetaTrader 5 с уведомлением об удаленных обновлениях
Наконец, наша функция LoadStrategyFromFile() может завершить свою работу, установив веса портфеля советника.
//--- CArrayDouble *new_weights = new CArrayDouble(); if(updates.TryGetValue(update_to_apply, new_weights)) { ArrayResize(strat_weights, 4); //--- strat_weights[0] = new_weights[0]; strat_weights[1] = new_weights[1]; strat_weights[2] = new_weights[2]; strat_weights[3] = new_weights[3]; //--- ArrayResize(strat_symbols, 4); //--- strat_symbols[0] = "INTC"; strat_symbols[1] = "AMD"; strat_symbols[2] = "AVGO"; strat_symbols[3] = "MU"; //--- printf("New weights: %s %.6f | %s %.6f | %s %.6f | %s %.6f", strat_symbols[0], new_weights[0], strat_symbols[1], new_weights[1], strat_symbols[2], new_weights[2], strat_symbols[3], new_weights[3] ); return true; } return false; }
Новые веса портфеля отражены в журнале.

Рис. 13. Снимок экрана журнала MetaTrader 5 с уведомлением о новых весах в портфеле
Все вышеперечисленное вызывается один раз из обработчика события OnInit() советника. В начале бэктеста наш советник загружает параметры стратегии из файла CSV, сохраняет их в объекте хеш-карты, удаляет обновления, временные метки которых предшествуют времени начала бэктеста (так называемые «устаревшие обновления»), если таковые имеются, и применяет самое последнее из них. Теперь, по мере продвижения бэктеста во времени, нам необходимо применять следующие обновления — те, у которых временные метки превышают время начала бэктеста, — чтобы смоделировать обновления весов портфеля, которые будут происходить во время работы советника в режиме реальной торговли. Это осуществляется с помощью обработчика событий OnTimer().
//+------------------------------------------------------------------+ //| Timer function | //+------------------------------------------------------------------+ void OnTimer(void) { ResetLastError(); if(!MQLInfoInteger(MQL_TESTER)) { // Wrapper around LoadStrategyFromDB: for clarity if(!UpdateModelParams(InpDbFilename, InpStrategyName, symbols, weights, timeframe, InpLookbackPeriod)) { printf("%s failed: Error %i", __FUNCTION__, GetLastError()); } } else { if(!UpdateModelParamsFromFile(symbols, weights)) { printf("%s failed: Error %i", __FUNCTION__, GetLastError()); } } printf("Actual weights: %s %.6f | %s %.6f | %s %.6f | %s %.6f", symbols[0], weights[0], symbols[1], weights[1], symbols[2], weights[2], symbols[3], weights[3] );
Функция UpdateModelParamsFromFile() очень проста. Поскольку мы скопировали ключи HashMap в отдельный массив, нам остаётся лишь последовательно извлекать каждый из них при каждом вызове функции. Каждый ключ представляет собой временную метку обновления. Если это значение меньше текущего времени, мы соответствующим образом обновляем веса в корзине. В противном случае, если ключ/метка времени превышает текущее время, речь идет об обновлении, которое ещё не существует. В данном случае функция возвращает значение true, поскольку ошибки не произошло, но обновление не применяется. Метод TryGetValue() <>класса HashMap вернет значение false или ошибку только в том случае, если не сможет найти соответствующий ключ в объекте хеш-карты.
ulong tstamp_keys[]; CHashMap<ulong, CArrayDouble*> *updates = new CHashMap<ulong, CArrayDouble*>(); //+------------------------------------------------------------------+ //| get the earlier tstamp on the updates hash map; | //| then update the model params (symbols and weights) | //| with its values | //+------------------------------------------------------------------+ bool UpdateModelParamsFromFile(string &curr_symbols[], double &curr_weights[]) { // Print("Updating model params from file"); // get the earlier tstamp on the updates hash map int static i = 0; if((datetime)tstamp_keys[i] < TimeCurrent()) { curr_symbols[0] = "INTC"; curr_symbols[1] = "AMD"; curr_symbols[2] = "AVGO"; curr_symbols[3] = "MU"; //—-- CArrayDouble *new_weights = new CArrayDouble(); if(!updates.TryGetValue(tstamp_keys[i], new_weights)) { return false; } curr_weights[0] = new_weights[0]; curr_weights[1] = new_weights[1]; curr_weights[2] = new_weights[2]; curr_weights[3] = new_weights[3]; //—-- increment the idx i++; delete new_weights; } else { Print("No update to apply"); } return true; }
Когда в ходе бэктеста происходит обновление, оно фиксируется в журнале следующим образом.

Рис. 14. Снимок экрана журнала MetaTrader 5, демонстрирующий обновление весов портфеля в ходе бэктеста
Наш бэктест начинается здесь, после загрузки, анализа и очистки файла CSV/TSV, экспортированного из базы данных.
Как указано во введении к этой статье, нам необходимо проверить, работает ли логика советника по ребалансировке так, как ожидалось, и насколько выбранные параметры RWEC для ребалансировки могут улучшить наши результаты.
Параметры RWEC, подлежащие проверке
Цель бэктеста — приблизиться к оптимальным значениям скользящих параметров коинтеграции. Обратите внимание, что в RWEC для оценки наличия коинтеграции используется тест коинтеграции Йохансена. У этого теста есть свои собственные параметры, но мы не будем их здесь рассматривать, поскольку предполагается, что мы уже работаем с коинтегрированной корзиной. В настоящее время роль модели RWEC заключается в обновлении весов портфеля, ранее рассчитанных с помощью теста Йохансена на этапах отбора и оценки в рамках нашего алгоритма. Основные параметры модели RWEC, которые мы хотим протестировать на исторических данных, включают:
- Период, за который запрашиваются исторические данные
- Длина окна теста коинтеграции
- Длина перекрывающихся окон
Как видно ниже, где мы описываем каждый из этих параметров, при их настройке всегда приходится выбирать между оперативностью и точностью. Наша цель — найти оптимальный баланс. Для начала мы проведем бэктестинг показателя RWEC для как минимум трёх размеров окон, чтобы определить, какой из них обеспечивает наиболее эффективную перебалансировку весов портфеля на таймфрейме H4. Это тот график, над которым мы работали с самого начала. Нашим критерием оценки будет относительное падение.
ВНИМАНИЕ: На данном этапе мы проводим ретроспективную проверку с целью определения оптимальных параметров модели RWEC. Мы НЕ уделяем особого внимания прибыльности стратегии. У нас уже есть объективный критерий для оценки: относительное падение. Как только мы подберем оптимальные параметры для корзины, будем использовать их в реальной торговле, а не в тестовых прогонах. Очень важно понимать, что именно в этом заключается цель бэктеста.
Настройки бэктеста
Начнём с обычного годового периода, то есть почти 252 торговых дней.

Рис. 15. Снимок экрана с настройками для одного годового бэктеста (около 252 торговых дней)
Временной горизонт RWEC (n_bars) должен быть не меньше даты начала бэктеста плюс длину окна, чтобы мы могли более точно моделировать реальную торговлю за счет ранних обновлений в самом начале бэктеста. Поскольку мы находимся на таймфрейме H4, а для акций в день формируется 2 свечи H4, нам потребуется не менее 504 свечей. Мы добавим 90 полос на каждую длину окна.
def fetch_data(self, symbols, timeframe=mt5.TIMEFRAME_H4, n_bars=504+90): """Fetch OHLC data from MT5"""
СОВЕТ: Я рекомендую очищать таблицу «стратегии» перед каждым запуском RWEC, чтобы упростить экспорт данных. Помните, что эта таблица служит лишь связующим звеном между анализом данных и нашим советником. Его единственная цель — предоставлять актуальные параметры стратегии для реальной торговли. Это может быть любой внешний источник данных, включая текстовые файлы и веб-API. Если вы хотите сохранить эти данные, вы также можете использовать временную таблицу для бэктестов. Здесь важно то, что эти данные являются одноразовыми.
После запуска скрипта rwec2db.py с указанными выше параметрами n_bars, window=90 и step=22 наша таблица «strategy» должна содержать следующие данные.

Рис. 16. Снимок экрана с таблицей «стратегии» и векторами RWEC для годового бэктеста
После экспорта этой таблицы, как описано в предыдущем разделе, в папке TERMINAL_DATA_PATH должен появиться файл TSV следующего вида.
"tstamp" "test_id" "name" "symbols" "weights" "timeframe" "lookback" 1733155200 1 RWEC_CointNasdaq INTC,AMD,AVGO,MU [1.0, 0.19818, -0.385289, 0.29447] H4 90 1734451200 1 RWEC_CointNasdaq INTC,AMD,AVGO,MU [1.0, -1.166557, -0.762914, 3.44909] H4 90 (...) 1764360000 1 RWEC_CointNasdaq INTC,AMD,AVGO,MU [1.0, -0.072521, -0.063501, 0.023864] H4 90
Обратите внимание, что дата начала нашего теста — 13 декабря 2024 года, а первая временная метка вектора, рассчитанная с помощью RWEC, имеет значение 1733155200, что соответствует 2 декабря 2024 года. Это почти за две торговые недели до даты начала нашего бэктеста. Это означает, что мы начнём с уже сформированного вектора коинтеграции, причём без необходимости удаления устаревших записей.
Кроме того, второй вектор имеет временную метку 1734451200, что соответствует 17 декабря 2024 года — сразу после даты начала нашего бэктеста. Это означает, что мы будем корректировать веса в портфеле с частотой, которая будет немного выше, чем в нашей стратегии свинг-трейдинга. Это не идеальный вариант. Возможно, нам удастся найти более подходящий интервал обновления.
В любом случае, запишите эти частоты. Далее они немного изменятся, когда мы начнём изменять длину окна и шаг. Если рассматривать эти изменения в контексте указанных частот, то их последствия будут полезны для нашего анализа.
Временной горизонт
Временной горизонт, пожалуй, является наиболее важным параметром, который необходимо протестировать и точно настроить при ребалансировке торгового портфеля в реальных условиях. Это связано с тем, что он также является наиболее важным параметром при оценке стабильности портфеля с помощью метода RWEC. Причина вполне очевидна: чем длиннее временной горизонт, тем точнее оценка, но при этом снижается ее оперативность, то есть полученная оценка менее чувствительна к текущей рыночной ситуации. С другой стороны, хотя более короткий временной горизонт позволяет уделить больше внимания текущей рыночной структуре, он также более чувствителен к шуму.
Не существует какого-то единого «оптимального» временного горизонта. Это зависит от частоты нашей торговой стратегии и от периода возврата к среднему значению нашего спреда. Если наш спред вернётся к среднему значению в течение нескольких часов или дней, то более короткого временного горизонта, например от 20 до 60 баров, может оказаться достаточно для отражения текущей динамики взаимосвязей. Если период возврата к среднему значению спреда составляет несколько недель или месяцев, может потребоваться более длительное окно — от 120 до 250 баров или более — для обеспечения надежной оценки весов, не зависящей от шума.
Вот результаты, полученные нами при перебалансировке с использованием RWEC 504/90/22 (n_bars/window/step):

Рис. 17. Снимок экрана с отчетом о бэктесте по перебалансировке весов портфеля в соответствии с RWEC 504/90/22
Вот полученный график баланса/собственного капитала.

Рис. 18. Снимок экрана с графиком баланса/капитала в рамках бэктеста для перебалансировки весов портфеля в соответствии со стратегией RWEC 504/90/22
Длина окна теста коинтеграции
Давайте посмотрим, какие результаты мы получим при том же временном горизонте и шаге, но с 45-дневным окном.
def rolling_cointegration(self, data, window=45, step=22): """Compute rolling cointegration vectors"""
В целом, те же замечания, которые были сделаны выше в отношении компромиссов, связанных с выбором оптимального временного горизонта, применимы и к длине тестового окна. Однако этот параметр напрямую участвует в вычислении собственных векторов, поэтому он непосредственно влияет на значения весов портфеля. При оценке это напрямую влияет на расчёт стабильности весов портфеля. Однако в данном случае при обновлении данных о текущих торговых операциях это влияние отражается на коэффициенте оборачиваемости портфеля. Более короткий срок приводит к более высокой текучести кадров. Он более адаптивный, но при этом улавливает больше краткосрочных помех. Этот шум приводит к более значительным колебаниям оценок собственных векторов от одного окна к другому, что измеряется показателем RWEC (косинусное расстояние). Если пороговое значение RWEC достигается чаще, это свидетельствует о более высокой оборачиваемости портфеля вследствие частых перебалансировок.
С другой стороны, более длительный период ведения позиции приводит к снижению оборота, что увеличивает риск удержания убыточной пары. Использование длинного окна приводит к более медленно изменяющимся и более плавным собственным векторам, но это также означает, что стратегия медленно реагирует на разрывы совокупности коинтеграции.
Единственный способ определить оптимальную длину — это провести бэктестинг. Нам следует учитывать временной интервал и тенденцию корзины к возврату к среднему значению в середине периода. Объективный критерий оценки результатов бэктеста поможет нам определить оптимальную длину окна. Здесь мы используем относительное падение в качестве критерия. Вот результаты, полученные при ребалансировке с параметрами RWEC 504/45/22 (n_bars/window/step):

Рис. 19. Снимок экрана с отчетом о бэктесте по перебалансировке весов портфеля в соответствии со стратегией RWEC 504/45/22
Вот как выглядит график баланса/собственного капитала, если уменьшить значение параметра «окно» вдвое.

Рис. 20. Снимок экрана с графиком баланса/капитала в рамках бэктеста для ребалансировки весов портфеля в соответствии со стратегией RWEC 504/45/22
Длина перекрывающихся окон
Теперь мы сохраняем 90-дневный период, но сокращаем длительность перекрывающихся периодов до одной торговой недели.
def rolling_cointegration(self, data, window=90, step=5): """Compute rolling cointegration vectors"""
Этот параметр определяет количество временных интервалов (торговых дней), на которое окно сдвигается вперед между последовательными вычислениями собственных векторов. Величина шага определяет временное разрешение сигнала и частоту переоценки. Эта частота определяет, как часто вычисляется новый набор весов портфеля (собственных векторов) и сравнивается с предыдущим набором.
Когда мы выбираем небольшой шаг, например 1 день, мы работаем с сигналом высокого разрешения. Практически каждый день мы получаем новые сравнительные данные от RWEC. Это позволяет ежедневно проверять прочность долгосрочных отношений. Мы можем обнаружить разрывы коинтеграции (значительное изменение показателя RWEC) в тот же момент, когда оно происходит. Мы можем отреагировать практически мгновенно, закрыв сделку или допустив перебалансировку, которая при бэктесте происходит автоматически. Недостатком является то, что при очень маленьком шаге требуется пропорционально высокочастотный расчет собственных векторов. Это может стать проблемой для крупного портфеля, но, вероятно, не станет таковой для частного трейдера (на котором мы здесь сосредоточиваемся), который, как правило, не имеет дела с крупными портфелями.
Более крупный шаг, например один торговый месяц (~22 дня), означает сигнал с относительно низким разрешением. Мы будем пересматривать веса в портфеле и сигнал RWEC один раз в месяц. Это значительно снижает вычислительную нагрузку, но сигнал теряет своевременность. Если коинтеграция нарушится в первый день, мы обнаружим нестабильность или необходимость перебалансировки только на 20-й день. Эта задержка может привести к значительным убыткам на быстро меняющемся рынке.
Таким образом, величина шага напрямую зависит от того, как часто мы будем перебалансировать веса в портфеле. Мы можем настроить расчет весов портфеля таким образом, чтобы они всегда основывались на самых свежих рыночных данных, а хеджирование было максимально приближено к оптимальному. В этом случае нам придётся столкнуться с более высокими транзакционными издержками (комиссиями, проскальзыванием), что может подорвать ту небольшую маржу, которую мы обычно получаем при статистическом арбитраже. Или мы можем выбрать меньший оборот и более низкие транзакционные издержки. На протяжении всего этапа в нашей корзине будут оставаться устаревшие веса. В условиях волатильности рынков мы значительно увеличим наши риски.

Рис. 21. Снимок экрана с отчетом о бэктесте по перебалансировке весов портфеля в соответствии с соотношением RWEC 504/90/5

Рис. 22. Снимок экрана с графиком баланса/капитала в рамках бэктеста для перебалансировки весов портфеля в соответствии с соотношением RWEC 504/90/5
Сравнительная таблица относительных падений стоимости за один и тот же временной период при различной длине окна и шаге RWEC.
| n_bars | длина окна | шаг перекрытия | полученные данные | относительное падение |
|---|---|---|---|---|
| 504 + 90 | 90 | 22 | 23 | 18,18 % |
| 504 + 90 | 45 | 22 | 25 | 36,08 % |
| 504 + 90 | 90 | 5 | 101 | 85,11 % |
Таблица 1. Сравнение показателя относительного просадки при бэктестировании для различных значений длины окна и шага в методе RWEC
Основная цель здесь — показать, как каждый из основных параметров RWEC влияет на перераспределение весов. Начав экспериментировать с различными корзинами, вы быстро поймёте, что единственный действенный способ найти оптимальный набор параметров — это исчерпывающее тестирование.
Однако даже это простое сравнение показывает, что при сокращении длительности окна вдвое наши результаты ухудшились на сто процентов — относительное падение с 18,08 % до 36,08 %, что ясно свидетельствует о том, что первый вариант лучше подходит для нашей корзины.
Когда мы сократили период торговли с одного торгового месяца (22 дня) до одной торговой недели (5 дней), наши результаты резко ухудшились: с 18,8 % до 85,11 %. Однако благодаря увеличению количества точек данных почти в пять раз — с 23 до 101 — мы также обнаружили разрывы коинтеграции. В этом последнем прогоне полученный график демонстрирует явление, похожее на структурный разрыв, который не был обнаружен при использовании более широких скользящих окон.
Надеюсь, что эти краткие оценки с использованием трёх комбинаций параметров RWEC позволят вам получить как количественные, так и наглядные доказательства влияния, которое каждая из них может оказать на результаты нашего бэктеста, а также подтвердят важность этапа перекрывающихся окон для раннего выявления структурных разрывов. Выявление подобных разрывов коинтеграции станет темой нашего следующего доклада.
Заключение
В данной статье мы представили один из возможных методов ретроспективного тестирования процедуры корректировки весов в портфеле коинтегрированных акций. Мы показали, что, загрузив данные в формате CSV/TSV в универсальную коллекцию HashMap и последовательно считывая их с временными метками, приведенными к формату бэктеста, можно моделировать обновления данных в режиме реальной торговли.
Для расчета новых весов используется метод сравнения собственных векторов скользящих окон (RWEC). Мы представили краткое описание влияния каждого из этих параметров на результаты бэктеста: временной горизонт, или период бэктеста; временной интервал расчета вектора коинтеграции, или длину окна; а также период перекрытия окон, или шаг вперед. По каждому из этих параметров мы представили результаты бэктеста, чтобы продемонстрировать, как их анализ может помочь в выборе оптимальных параметров для реальной торговли.
Мы предоставляем скрипт на Python для запуска RWEC и сохранения его результатов в специальной таблице базы данных с возможностью экспорта в формате CSV/TSV, а также файл заголовков с функциями MQL, необходимыми для чтения данных в Tester.
| Имя файла | Описание |
|---|---|
| Experts\StatArb\CointNasdaq.mq5 | Пример основного файла MQL5 советника |
| Include\StatArb\CointNasdaq.mqh | Пример главного файла заголовка MQL5 для советника |
| Include\StatArb\TestHelper.mqh | Пример файла заголовка вспомогательной программы для тестирования советника |
| Файлы\StatArb\strategy_*.csv | Файлы TSV, экспортированные из базы данных и использованные в статье |
| rwec2db | Скрипт на Python для запуска RWEC и сохранения результатов в встроенной базе данных SQLite |
| CointNasdaq.INTC.H4.20241213_20251213.000 | Настройки бэктеста, использованные в статье |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/20657
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Разработка инструментария для анализа Price Action (Часть 36): Прямой доступ Python к потокам рыночных данных MetaTrader 5
Архитектура машинного обучения в MetaTrader 5 (Часть 6): Проектирование системы кэширования промышленного уровня
Статистический арбитраж с использованием коинтегрированных акций (Часть 8): Сравнение собственных векторов на скользящих окнах для ребалансировки портфеля
Dynamic Swing Architecture: Распознавание структуры рынка — от свингов до автоматического исполнения сделок
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования