English Deutsch 日本語
preview
Статистический арбитраж на основе коинтегрированных акций (Часть 3): Настройка базы данных

Статистический арбитраж на основе коинтегрированных акций (Часть 3): Настройка базы данных

MetaTrader 5Торговые системы |
71 0
Jocimar Lopes
Jocimar Lopes

Введение

В предыдущей статье этой серии (часть 2) мы провели бэктестинг стратегии статистического арбитража, состоящей из корзины коинтегрированных акций из сектора микропроцессоров (акции Nasdaq). Мы начали отбирать из сотен биржевых кодов те, которые наиболее тесно коррелируют с акциями Nvidia. Затем мы проверили группу с фильтрацией на коинтеграцию с помощью теста Йохансена, стационарность спреда с помощью тестов ADF и KPSS, и, наконец, получили относительные веса портфеля, выделив собственный вектор Йохансена первого ранга. Результаты бэктеста оказались обнадеживающими.

Однако два или более актива могли демонстрировать коинтеграцию в течение последних двух лет и уже завтра начать терять эту коинтеграцию. То есть нет гарантии, что коинтегрированная пара или группа активов останется коинтегрированной. Изменения в руководстве компании, макроэкономическая ситуация или отраслевые изменения могут повлиять на фундаментальные факторы, которые изначально определяли коинтеграцию данных активов. И наоборот. Активы, которые ранее не были коинтегрированы, могут в следующую минуту начать двигаться по коинтегрированной траектории по тем же причинам. Рынок — это «загадка, постоянно пребывающая в состоянии изменения». Нам нужно справиться с этими изменениями. [ПАЛОМАР, 2025]

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

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

Мы используем интеграцию Python в MetaTrader 5 и профессионально разработанные статистические функции из библиотеки statsmodels, но до сих пор работали исключительно с данными в режиме реального времени, загружая котировки (данные о ценах) по мере необходимости. Этот подход полезен на этапе предварительного изучения благодаря своей простоте. Но если мы собираемся обновлять наш портфель, модели или веса в портфеле, нам, возможно, стоит задуматься о сохранности данных. То есть нам нужно начать задумываться о хранении данных в базе данных, поскольку загружать данные каждый раз, когда они нужны, нецелесообразно. Более того, нам, возможно, придётся искать взаимосвязи между различными классами активов, между показателями, которые не учитывались в наших первых тестах на коинтеграцию.

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



На какие вопросы должна отвечать наша база данных?

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

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

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

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

СимволыВесаСроки
 

«MU», «NVDA», «MPWR», «MCHP»

 2,699439, 1,000000, -1,877447, -2,505294 D1

Таблица 1 — Примерный вариант ожидаемого ответа на запрос для обновления модели в режиме реального времени

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



Обновление баз данных как услуга

До сих пор мы проводили анализ данных, используя котировки в реальном времени из терминала MetaTrader 5 с помощью кода на Python (технически, в большинстве случаев мы использовали котировки, сохраненные в движке самого терминала). После определения тикеров и весов портфеля мы вручную обновляли наш советник, добавляя в него новые тикеры и/или новые веса портфеля.

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

Для обновления базы данных мы реализуем сервис Metatrader 5.

Из документации по MetaTrader 5 мы узнаем, что 

  • Услуги не привязаны к конкретному графику.

  • Сервисы загружаются сразу после запуска терминала, если они были запущены на момент его завершения работы.

  • Сервисы работают в собственном потоке.

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

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

  1. Весь анализ данных будет проводиться на языке Python вне среды MetaTrader 5. Чтобы провести анализ, мы загрузим исторические данные и внесем их в базу данных.
  2. Каждый раз, когда мы вносим изменения в наш активный портфель, добавляя или удаляя инструмент, мы обновляем входные параметры Сервиса, передавая массив инструментов и массив таймфреймов.
  3. Каждый раз, когда мы вносим изменения в наш активный портфель, добавляя или удаляя инструмент, мы обновляем входные параметры советника.

Пока что мы будем выполнять обновления в шагах 2 и 3 вручную. Позже мы автоматизируем этот процесс.



Настройка базы данных

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

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

Начнём со встроенной в MetaTrader 5 базы данных SQLite. Существует огромное количество информации о том, как создать и использовать встроенную базу данных SQLite в среде MetaTrader 5. Вы можете найти это:

  • В документации по Metaeditor описано, как использовать графический интерфейс Metaeditor для создания, использования и управления базами данных

  • Кроме того, в документации Metaeditor по функциям API для работы с базами данных

  • В книге «MQL5» — подробное руководство по использованию встроенной базы данных SQLite

Если вы планируете серьезно работать со встроенной базой данных SQLite, настоятельно рекомендуем вам внимательно изучить все эти ссылки. Ниже приводится краткое изложение шагов для нашего конкретного примера использования, описанного в данной статье, с некоторыми пояснениями относительно обоснования сделанного выбора.



Схема

В приложении к этой статье вы найдете файл db-setup.mq5 — это скрипт на языке MQL5. В качестве входных параметров она принимает имя файла базы данных SQLite и имя файла схемы базы данных. По умолчанию для этих параметров используются файлы statarb-0.1.db и schema-0.1.sql соответственно. 

ВНИМАНИЕ: Настоятельно рекомендуется хранить файл схемы базы данных в версионном хранилище и не создавать его дубликаты в файловой системе. Metatrader 5 предлагает надежную систему управления версиями, уже встроенную в платформу.

Запустив этот скрипт с параметрами по умолчанию, вы создадите базу данных SQLite в папке MQL5/Files/StatArb вашего терминала (НЕ в общей папке всех клиентских терминалов), содержащую все таблицы и поля, описанные ниже. Скрипт также создаст файл output.sql в папке MQL/Files исключительно для целей отладки. Если у вас возникнут какие-либо проблемы, вы можете проверить этот файл, чтобы увидеть, как ваша система считывает файл схемы. Если всё прошло успешно, этот файл можно смело удалить.

В качестве альтернативы вы можете создать базу данных другим способом и загрузить файл схемы из базы данных в любом клиенте SQLite3, в интерфейсе Metaeditor, в Windows PowerShell или в командной строке SQLite3. Я рекомендую, по крайней мере в первый раз, создать базу данных, запустив прилагаемый скрипт. Вы всегда сможете настроить этот процесс позже.

Схема базы данных

Наша исходная схема базы данных состоит всего из четырёх таблиц, две из которых являются заполнителями для последующих этапов. То есть на данном этапе мы будем использовать только таблицы «symbol» и «market_data».

На рисунке 1 представлена ER-диаграмма «сущность-связь» (Entity-Relationship Diagram, ERD) этой начальной схемы.

Рис. 1 — Диаграмма «сущность-связь» (ERD) для исходной схемы базы данных

Рис. 1 — Исходная схема базы данных (диаграмма «сущность-связь»)

Таблица «corporate_event», как и следовало ожидать, предназначена для хранения данных о событиях, связанных с компаниями нашего портфеля, таких как суммы дивидендов, сплиты, выкупы, слияния и т. д. Пока что мы не будем этим заниматься.

В таблице «trade» будут храниться наши трейды (сделки). Собрав эти данные, мы получим уникальный массив данных для обобщения и анализа. Мы будем использовать его только тогда, когда начнём торговать.

В таблице «market_data» будут храниться наши данные OHLC со всеми полями MqlRates. Он имеет составной первичный ключ (symbol_id, timeframe и timestamp), чтобы гарантировать уникальность каждой записи. Таблица «market_data» связана с таблицей «symbol» посредством внешнего ключа.

Как видите, это единственная таблица, в которой используется составной первичный ключ. Во всех остальных таблицах в качестве первичного ключа используется временная метка, хранящаяся в виде целого числа (INTEGER). У этого выбора есть своя причина. Согласно документации SQLite3,

«Данные для таблиц rowid хранятся в виде структуры B-дерева, содержащей по одной записи на каждую строку таблицы, причем в качестве ключа используется значение rowid. Это означает, что поиск или сортировка записей по rowid происходит быстро. Поиск записи по конкретному rowid или всех записей с rowid в заданном диапазоне выполняется примерно в два раза быстрее, чем аналогичный поиск по любому другому PRIMARY KEY или индексированному значению.

(...) если таблица rowid имеет первичный ключ, состоящий из одного столбца, и объявленный тип этого столбца — «INTEGER» (в любом сочетании заглавных и строчных букв), то этот столбец становится псевдонимом для rowid. Такую колонку обычно называют «целочисленным первичным ключом».

(...) «Выполнение запросов может ускориться примерно вдвое, если использовать INTEGER в качестве первичного ключа». «Временные метки эпохи Unix можно вставлять в базы данных SQLite3 в виде целых чисел (INTEGER)».

(...) «SQLite хранит целочисленные значения в 64-битном формате с дополнением до двух¹. Таким образом, диапазон значений составляет от -9223372036854775808 до +9223372036854775807 включительно. Целые числа в этом диапазоне являются точными. (Документация SQLite3

Таким образом, мы можем преобразовать наши даты и время из строковых значений в временные метки эпохи Unix и ввести их в качестве первичного ключа, чтобы превзойти скорость света. 🙂

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

Таблица: symbol

Хранит метаданные о финансовых инструментах, торгуемых или отслеживаемых.

ПолеТип данныхПустое значениеКлючОписание
symbol_idINTEGERNOPKУникальный идентификатор для каждого финансового инструмента
tickerTEXT(≤10)NO
Тикер актива (например, «AAPL», «MSFT»)
exchangeTEXT(≤50)NO
Биржа, на которой котируется данный актив (например, «NASDAQ», «NYSE»)
asset_typeTEXT(≤50)YES
Тип актива (например, «Акции», «Биржевые инвест-фонды», «Форекс», «Криптовалюта»)
sectorTEXT(≤50)YES

Классификация по отраслям экономики (например, «Технологии», «Здравоохранение»)
industryTEXT(≤50)YES

Классификация отраслей в рамках сектора
 currencyTEXT(≤50)YES Валюта номинала актива (например, «EUR», «USD»)

Таблица 2 — Описание словаря данных таблицы «symbol» (версия 0.1)

Таблица: corporate_event

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

ПолеТип данныхПустое значениеКлючОписаниеПример
tstampINTEGERNOPKВремя в формате Unix, когда событие вступает в силу1678905600
event_typeTEXT ENUM {'dividend', 'split', 'earnings'}NO
Тип корпоративного события"dividend"
event_valueREALYES

Числовое значение события: • Размер дивиденда на акцию • Коэффициент сплита • Прибыль на акцию (EPS)

0.85, 2.0, 1.35
detailsTEXT(≤255)YES
Дополнительные примечания или контекст"Q2 dividend payout"
 symbol_id INTEGERNO  FKИдентификатор синструмента (symbol_id); связывает событие с активом 1

Таблица 3 — Описание словаря данных таблицы «corporate_event» (версия 0.1)

Таблица: market_data

Хранит данные OHLCV (открытие, максимум, минимум, закрытие, объем) и связанные временные ряды по активам.

ПолеТип данныхПустое значениеКлючОписаниеПример
tstampINTEGERNOPK*
Время в формате Unix для бара/свечи1678905600
timeframeTEXT ENUM {M1,M2,M3,M4,M5,M6, M10,M12,M15,M20,M30,H1,H2,H3, H4,H6,H8,H12,D1,W1,MN1}NO
PK*
Таймфрейм данных временных рядов"M5", "D1"
price_openREALNO

Цена открытия бара145.20
price_highREAL
NO

Максимальная цена за бар146.00
price_lowREAL
NO

Минимальная цена за бар144.80
price_closeREAL
NO

Цена закрытия бара145.75
tick_volumeINTEGER
YES
Тиковый объем200
real_volumeINTEGER
YES

Реальный объем (если доступен)15000
spreadREAL
YES

Средний спред за бар0.02
symbol_id INTEGER
 NOPK*, FKИдентификатор синструмента (symbol_id).1

Таблица 4 — Описание словаря данных таблицы «market_data» (версия 0.1)

Таблица: trade

Отслеживает реальные или моделируемые сделки по стратегиям.

ПолеТип данныхПустое значениеКлючОписаниеПример
tstampINTEGER
NOPKВремя сделки в формате Unix1678905600
ticketINTEGER
NO

Тикет сделки/идентификатор ордера20230001
sideTEXT ENUM {'buy', 'sell'}NO

Направление сделки"buy"
quantityINTEGER (>0)
NO

Торговый объем в акциях/контрактах100
price
NO

Цена исполнения145.50
strategy
YES
Идентификатор торговой стратегии, на основании которой совершена  сделка"StatArb_Pairs"
symbol_id INTEGERNOFKИдентификатор синструмента (symbol_id). 1

Таблица 5 — Описание словаря данных таблицы «trade» (версия 0.1)

STRICT

Если вы просмотрите файл схемы, то увидите, что все таблицы являются таблицами типа STRICT .

CREATE TABLE symbol(
    symbol_id INTEGER PRIMARY KEY,
    ticker TEXT CHECK(LENGTH(ticker) <= 10) NOT NULL,
    exchange TEXT CHECK(LENGTH(exchange) <= 50) NOT NULL,
    asset_type TEXT CHECK(LENGTH(asset_type) <= 50),
    sector TEXT CHECK(LENGTH(sector) <= 50),
    industry TEXT CHECK(LENGTH(industry) <= 50),
    currency TEXT CHECK(LENGTH(currency) <= 10)
) STRICT;

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

«Если в операторе CREATE TABLE в конце, после закрывающей скобки «)», добавлено ключевое слово опции таблицы «STRICT», то к этой таблице применяются строгие правила типизации.» (Документация SQLite)

CHECK LENGTH

Кроме того, мы требуем проверки длины нескольких полей TEXT. Это связано с тем, что SQLite не обеспечивает фиксированную длину и не обрезает строки в соответствии со значением (n), указанным в типах CHAR(n) или VARCHAR(n), как это обычно делается в других СУБД.

«Обратите внимание, что числовые аргументы в скобках, следующие за именем типа (например: «VARCHAR(255)») игнорируются SQLite — SQLite не налагает никаких ограничений на длину строк, BLOB-объектов или числовых значений (за исключением общего ограничения SQLITE_MAX_LENGTH)» (документация SQLite)
Мы вводим ограничение, чтобы избежать проблем, связанных с чрезмерно длинными строками из неизвестных источников.

А как насчет индексов?

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



Ввод исходных данных

Предполагается, что база данных будет заполняться автоматически и незаметно в процессе анализа данных через интеграцию MQL5 и Python. Тем не менее, для удобства прилагается скрипт на Python (db_store_quotes.ipynb), который поможет вам сохранить котировки по списку символов, с определенного таймфрейма и за выбранный период времени. С этого момента мы будем проводить анализ данных (проверку на корреляцию, коинтеграцию и стационарность) на основе этих сохраненных данных.

Рисунок 2 — Таблица «Symbol» после первоначальной вставки данных с помощью скрипта на Python

Рисунок 2 — Таблица «Symbol» после первоначальной вставки данных с помощью скрипта на Python

Как видно, большинство метаданных «символа» имеют значение «UNKNOWN». Это связано с тем, что функции Python для SymbolInfo не охватывают все метаданные символов, доступные в MQL5 API. Мы восполним эти пробелы позже.

   # Insert new symbol
    cursor.execute("""
    INSERT INTO Symbol (ticker, exchange, asset_type, sector, industry, currency)
    VALUES (?, ?, ?, ?, ?, ?)
    """, (
        mt5_symbol,
        # some of this data will be filled by the MQL5 DB Update Service
        # because some of them are not provided by the Python MT5 API 
        symbol_info.exchange or 'UNKNOWN',
        symbol_info.asset_class or 'UNKNOWN',
        symbol_info.sector or 'UNKNOWN',
        symbol_info.industry or 'UNKNOWN',
        symbol_info.currency_profit or 'UNKNOWN'
    ))

Путь к базе данных должен передаваться в виде переменной среды, и мы используем модуль python-dotenv для загрузки этой переменной. Это должно помочь избежать проблем, связанных с тем, что редактор не распознает переменные среды терминала и/или PowerShell.

В самом начале скрипта вы найдёте Jupyter-ноутбук, который загружает расширение python-dotenv и соответствующий файл *.env.

%load_ext dotenv
%dotenv .env

К этой статье также прилагается пример файла *.env.

# Сохраните этот файл в корневом каталоге вашего проекта 
# или в той же папке, что и Python-скрипт, который его использует
STATARB_DB_PATH="путь/к/вашей/базе/данных"

Основной вызов в файле db_store_quotes.ipynb находится в конце скрипта.

symbols = ['MPWR', 'AMAT', 'MU']  # Символ из Market Watch
timeframe = mt5.TIMEFRAME_M5    # пятиминутный таймфрейм
start_date = '2024-02-01'
end_date = '2024-03-31'
db_path = os.getenv('STATARB_DB_PATH')  # Путь в Вашей БД SQLite

if db_path is None:
        print("Error: STATARB_DB_PATH environment variable is not set.")
else:
        print("db_path: " + db_path)
# Загрузка исторических данных и сохранение в БД
        download_mt5_historical_quotes(symbols, timeframe, start_date, end_date, db_path)


Обновления

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

Сервис подключается к (или создает) локальному файлу базы данных SQLite. 

ВНИМАНИЕ: Если вы создаете совершенно новую базу данных с помощью Сервиса обновления баз данных, не забудьте инициализировать базу данных с помощью скрипта db_setup, как упоминалось выше.

Затем сервис устанавливает необходимые ограничения для обеспечения целостности данных, после чего работает в бесконечном цикле, в котором проверяет

новые рыночные данные. Для каждого символа и таймфрейма 

  1. извлекает данные о последнем сформированном баре (включая цены открытия и закрытия, максимальную и минимальную цены, объем и спред),
  2. проверяет, есть ли он уже в базе данных, 
  3. и вставляет новую котировку (или котировки), если их нет

Для обеспечения атомарности сервис оборачивает операцию вставки в транзакцию базы данных. Если что-то пойдет не так (например, произойдет ошибка базы данных), система будет повторять попытку до установленного предела (по умолчанию 3 раза) с паузами в одну секунду. Ведение журнала не является обязательным. Цикл приостанавливается между обновлениями и останавливается только при ручной остановке сервиса.

Итак, давайте рассмотрим некоторые из его компонентов.

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

//+------------------------------------------------------------------+
//|   Inputs                                                         |
//+------------------------------------------------------------------+
input string   InpDbPath      = "StatArb\\statarb-0.1.db";  // Имя файла базы данных
input int      InpUpdateFreq  = 1;     // Частота обновления в минутах
input int      InpMaxRetries  = 3;     // Максимальное количество попыток
input bool     InpShowLogs    = true; // Включить ведение журнала?

Рис. 3 — Диалоговое окно Metatrader 5 для ввода параметров службы обновления базы данных

Рисунок 3 — Диалоговое окно Metatrader 5 для ввода параметров службы обновления базы данных

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

//+------------------------------------------------------------------+
//|   Global vars                                                    |
//+------------------------------------------------------------------+
string symbols[] = {"EURUSD", "GBPUSD", "USDJPY"};
ENUM_TIMEFRAMES timeframes[] = {PERIOD_M5};

Здесь мы инициализируем хендл базы данных как INVALID_HANDLE. Это будет проверено при следующем открытии базы данных.

// Хендл базы данных
int dbHandle = INVALID_HANDLE;

OnStart()

В обработчике событий OnStart сервиса мы лишь запускаем бесконечный цикл и вызываем функцию UpdateMarketData, в которой собственно и начинается работа. В функцию Sleep() передается количество миллисекунд, которое необходимо выждать между каждым циклом (каждым запросом обновления котировок). Мы переводим это в минуты, чтобы было удобнее для пользователей. Кроме того, мы не ожидаем обновлений на таймфреймах короче одной минуты.

//+------------------------------------------------------------------+
//| Main Service function                                            |
//| Parameters:                                                      |
//|   symbols    - Array of symbol names to update                   |
//|   timeframes - Array of timeframes to update                     |
//|   InpMaxRetries - Maximum number of retries for failed operations|
//+------------------------------------------------------------------+
void OnStart()
  {
   do
     {
      printf("Updating db: %s", InpDbPath);
      UpdateMarketData(symbols, timeframes, InpMaxRetries);
      Sleep(1000 * 60 * InpUpdateFreq); // 60 secs
     }
   while(!IsStopped());
  }

UpdateMarketData()

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

//+------------------------------------------------------------------+
//| Обновление рыночных данных для нескольких символов и временных интервалов           |
//+------------------------------------------------------------------+
bool UpdateMarketData(string &symbols_array[], ENUM_TIMEFRAMES &time_frames[], int max_retries = 3)
  {
// Инициализировать базу данных
   if(!InitializeDatabase())
     {
      LogMessage("Не удалось инициализировать базу данных");
      return false;
     }
   bool allSuccess = true;

Если база данных инициализирована (открыта), мы приступаем к обработке каждого символа и каждого таймфрейма.

// Обработать каждый символ
   for(int i = 0; i << ArraySize(symbols_array); i++)
     {
      string symbol = symbols_array[i];
      // Обработать каждый временной интервал
      for(int j = 0; j << ArraySize(time_frames); j++)
        {
         ENUM_TIMEFRAMES timeframe = time_frames[j];
         int retryCount = 0;
         bool success = false;
//+------------------------------------------------------------------+
//| Обновление данных множества символов и периодов графика          |
//+------------------------------------------------------------------+
bool UpdateMarketData(string &symbols_array[], ENUM_TIMEFRAMES &time_frames[], int max_retries = 3)
  {
// Инициализация БД
   if(!InitializeDatabase())
     {
      LogMessage("Failed to initialize database");
      return false;
     }
   bool allSuccess = true;

// Если база данных инициализирована (открыта), приступаем к обработке каждого символа и каждого таймфрейма

// Обработка каждого символа
   for(int i = 0; i < ArraySize(symbols_array); i++)
     {
      string symbol = symbols_array[i];
      // Обработка каждого таймфрейма
      for(int j = 0; j < ArraySize(time_frames); j++)
        {
         ENUM_TIMEFRAMES timeframe = time_frames[j];
         int retryCount = 0;
         bool success = false;

В этом цикле while мы контролируем максимальное количество попыток и фактически вызываем функцию для обновления базы данных.

         while(retryCount < max_retries && !success)
           {
            success = UpdateSymbolTimeframeData(symbol, timeframe);
            if(!success)
              {
               retryCount++;
               Sleep(1000); // Wait before retry
              }
           }
         if(!success)
           {
            LogMessage(StringFormat("Failed to update %s %s after %d retries",
                                    symbol, TimeframeToString(timeframe), max_retries));
            allSuccess = false;
           }
        }
     }
   DatabaseClose(dbHandle);
   return allSuccess;
  }

UpdateSymbolTimeframeData()

Поскольку таблица market_data требует идентификатор символа (symbol_id) в качестве внешнего ключа, нам необходимо сначала создать эту таблицу. Поэтому мы проверяем наличие этого идентификатора или создаём новый symbol_id в таблице «symbol», если это новый символ.

//+------------------------------------------------------------------+
//| Update market data for a single symbol and timeframe              |
//+------------------------------------------------------------------+
bool UpdateSymbolTimeframeData(string symbol, ENUM_TIMEFRAMES timeframe)
  {
   ResetLastError();
// Get symbol ID (insert if it doesn't exist)
   long symbol_id = GetOrInsertSymbol(symbol);
   if(symbol_id == -1)
     {
      LogMessage(StringFormat("Failed to get symbol ID for %s", symbol));
      return false;
     }

Преобразуем тип временного интервала из MQL5 ENUM_TIMEFRAMES в строковый (TEXT) тип, требуемый нашей таблицей.

   string tfString = TimeframeToString(timeframe);
   if(tfString == "")
     {
      LogMessage(StringFormat("Unsupported timeframe for symbol %s", symbol));
      return false;
     }

Копируем котировки последнего закрытого бара.

// Get the latest closed bar
   MqlRates rates[];
   if(CopyRates(symbol, timeframe, 1, 1, rates) != 1)
     {
      LogMessage(StringFormat("Failed to get rates for %s %s: %d", symbol, tfString, GetLastError()));
      return false;
     }

Проверяем, существует ли эта запись. Если да, регистрируем это и возвращаем true.

   if(MarketDataExists(symbol_id, rates[0].time, tfString))
     {
      LogMessage(StringFormat("Data already exists for %s %s at %s",
                              symbol, tfString, TimeToString(rates[0].time)));
      return true;
     }

Если это новая запись или новая котировка, мы запускаем транзакцию базы данных, чтобы обеспечить атомарность. Если что-то пойдет не так, мы откатываем изменения. Если всё прошло успешно, фиксируем транзакцию и возвращаем значение true.

// Start transaction
   if(!DatabaseTransactionBegin(dbHandle))
     {
      LogMessage(StringFormat("Failed to start transaction: %d", GetLastError()));
      return false;
     }
// Insert the new data
   if(!InsertMarketData(symbol_id, tfString, rates[0]))
     {
      DatabaseTransactionRollback(dbHandle);
      return false;
     }
// Commit transaction
   if(!DatabaseTransactionCommit(dbHandle))
     {
      LogMessage(StringFormat("Failed to commit transaction: %d", GetLastError()));
      return false;
     }
   LogMessage(StringFormat("Successfully updated %s %s data for %s",
                           symbol, tfString, TimeToString(rates[0].time)));
   return true;
  }

InitializeDatabase()

Здесь мы открываем базу данных и проверяем хендл.

//+------------------------------------------------------------------+
//| Initialize database connection                                   |
//+------------------------------------------------------------------+
bool InitializeDatabase()
  {
   ResetLastError();
// Open database (creates if it doesn't exist)
   dbHandle = DatabaseOpen(InpDbPath, DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE);
   if(dbHandle == INVALID_HANDLE)
     {
      LogMessage(StringFormat("Failed to open database: %d", GetLastError()));
      return false;
     }

Эта директива «PRAGMA», включающая поддержку foreign_keys в SQLite, не является строго необходимой при работе со встроенной базой данных SQLite, поскольку мы знаем, что она была скомпилирована с включенной этой функцией. Это всего лишь мера безопасности на случай, если вам придется работать с внешней базой данных.

// Enable foreign key constraints
   if(!DatabaseExecute(dbHandle, "PRAGMA foreign_keys = ON"))
     {
      LogMessage(StringFormat("Failed to enable foreign keys: %d", GetLastError()));
      return false;
     }
   LogMessage("Database initialized successfully");
   return true;
  }

TimeframeToString()

Функция преобразования MQL5 ENUM_TIMEFRAMES в строку представляет собой простой оператор switch.

//+------------------------------------------------------------------+
//| Convert MQL5 timeframe to SQLite format                          |
//+------------------------------------------------------------------+
string TimeframeToString(ENUM_TIMEFRAMES tf)
  {
   switch(tf)
     {
      case PERIOD_M1:
         return "M1";
      case PERIOD_M2:
         return "M2";
      case PERIOD_M3:
         return "M3";
(...)
      case PERIOD_MN1:
         return "MN1";
      default:
         return "";
     }
  }

MarketDataExists()

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

//+------------------------------------------------------------------+
//| Check if market data exists for given timestamp and timeframe     |
//+------------------------------------------------------------------+
bool MarketDataExists(long symbol_id, datetime tstamp, string timeframe)
  {
   ResetLastError();
   int stmt = DatabasePrepare(dbHandle, "SELECT 1 FROM market_data WHERE symbol_id = ? AND tstamp = ? AND timeframe = ? LIMIT 1");
   if(stmt == INVALID_HANDLE)
     {
      LogMessage(StringFormat("Failed to prepare market data existence check: %d", GetLastError()));
      return false;
     }
   if(!DatabaseBind(stmt, 0, symbol_id) ||
      !DatabaseBind(stmt, 1, (long)tstamp) ||
      !DatabaseBind(stmt, 2, timeframe))
     {
      LogMessage(StringFormat("Failed to bind parameters for existence check: %d", GetLastError()));
      DatabaseFinalize(stmt);
      return false;
     }
   bool exists = DatabaseRead(stmt);
   DatabaseFinalize(stmt);
   return exists;
  }

InsertMarketData()

Наконец, чтобы внести новые рыночные данные (обновление), мы выполняем запрос на вставку, используя функцию MQL5 StringFormat, как рекомендуется в документации.

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

//+------------------------------------------------------------------+
//| Insert market data into database                                 |
//+------------------------------------------------------------------+
bool InsertMarketData(long symbol_id, string timeframe, MqlRates &rates)
  {
   ResetLastError();
   string req = StringFormat(
                   "INSERT INTO market_data ("
                   "tstamp, timeframe, price_open, price_high, price_low, price_close, "
                   "tick_volume, real_volume, spread, symbol_id) "
                   "VALUES(%d, '%s', %G, %G, %G, %G, %d, %d, %d, %d)",
                   rates.time, timeframe, rates.open, rates.high, rates.low, rates.close,
                   rates.tick_volume, rates.real_volume, rates.spread, symbol_id);
   if(!DatabaseExecute(dbHandle, req))
     {
      LogMessage(StringFormat("Failed to insert market data: %d", GetLastError()));
      return false;
     }
   return true;
  }

Пока служба работает…

Рис. 5 — Навигатор Metaeditor при запущенной службе обновления базы данных

Рисунок 5 — Навигатор Metaeditor при запущенной службе обновления базы данных

… на вкладке «Журнал экспертов» должно появиться примерно следующее.

Рис. 7 — Вкладка «Журнал экспертов» в MetaTrader 5 с результатами работы службы обновления базы данных

Рисунок 7 — Вкладка «Журнал экспертов» в MetaTrader 5 с результатами работы службы обновления базы данных

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

Рис. 8 — Встроенный в Metaeditor вкладка SQLite с обновлениями базы данных

Рисунок 8 — Встроенный в Metaeditor вкладка SQLite с обновлениями базы данных


Заключение

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

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

Кроме того, мы подробно описали шаги по обновлению базы данных путем создания сервиса Metatrader 5, предоставили один вариант реализации этого сервиса, а также скрипты на языке Python для ввода исходных данных по любому доступному символу и таймфрейму.

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

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


Ссылки

Дэниел П. Паломар (2025). Оптимизация портфеля: Теория и практика. Издательство Кембриджского университета.

*Одним из существенных недостатков SQLite по сравнению с некоторыми базами данных, ориентированными на временные ряды, является отсутствие «соединений по дате». Поскольку мы будем индексировать наши рыночные данные по временным меткам, в будущем мы почти наверняка не сможем использовать «соединения» между таблицами, так как временные метки используются в качестве первичных ключей, а их расположение в двух или более таблицах редко, а возможно, и никогда не будет совпадать, а «соединения» (внутренние, левые и внешние) зависят именно от такого совпадения индексов.Поэтому при использовании «соединений» мы получим пустые (нулевые) наборы результатов. В этой записи блога подробно объясняется данная проблема.

Название файлаОписание
StatArb/db-setup.mq5

Скрипт MQL5 для создания и инициализации базы данных SQLite путем чтения файла схемы schema-0.1.sql

StatArb/db-update-statarb-0.1.mq5Сервис MQL5 для обновления базы данных SQLite самыми последними закрытыми ценовыми барами
StatArb/schema-0.1.sqlФайл схемы SQL (DDL) для инициализации базы данных (создание таблиц, полей и ограничений)
db_store_quotes.ipynbJupyter Notebook, содержащий код на Python. Вспомогательный файл для заполнения базы данных SQLite котировками символов за определенный период времени и таймфрейм
.envПример файла с переменными среды, которые должны считываться из приведенного выше вспомогательного файла Python. (необязательно)

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

Прикрепленные файлы |
Создание самооптимизирующихся советников на MQL5 (Часть 13): Введение в теорию управления с использованием факторизации матриц Создание самооптимизирующихся советников на MQL5 (Часть 13): Введение в теорию управления с использованием факторизации матриц
Финансовые рынки непредсказуемы, и торговые стратегии, которые в прошлом казались прибыльными, зачастую терпят крах в реальных рыночных условиях. Это происходит потому, что большинство стратегий после внедрения остаются неизменными и не могут адаптироваться или извлекать уроки из своих ошибок. Заимствуя идеи из теории управления, мы можем использовать регуляторы с обратной связью, чтобы наблюдать за тем, как наши стратегии взаимодействуют с рынками, и корректировать их поведение с целью обеспечения прибыльности. Наши результаты показывают, что добавление регулятора с обратной связью к простой стратегии скользящего среднего позволило увеличить прибыль, снизить риск и повысить эффективность, что свидетельствует о значительном потенциале данного подхода для применения в торговле.
Разработка инструментария для анализа Price Action (Часть 28): Инструмент для торговли пробоя диапазона открытия Разработка инструментария для анализа Price Action (Часть 28): Инструмент для торговли пробоя диапазона открытия
В начале каждой торговой сессии направление рынка часто становится понятным только после того, как цена выходит за пределы диапазона открытия. В этой статье мы разберем, как создать советник на MQL5, который автоматически обнаруживает и анализирует пробои диапазона открытия, предоставляя своевременные сигналы на основе данных для более уверенных внутридневных входов.
Python + MetaTrader 5: быстрый исследовательский контур для данных, признаков и прототипов Python + MetaTrader 5: быстрый исследовательский контур для данных, признаков и прототипов
Статья показывает, как интеграция Python и MetaTrader 5 объединяет исследовательскую гибкость и торговое исполнение в едином рабочем процессе. Python используется для анализа данных, отбора признаков и обучения модели, а MetaTrader 5 — для тестирования и автоматизации торговли. Такой подход упрощает перенос решений в практику, повышает воспроизводимость и делает разработку торговых систем более быстрой и структурированной.
От начального до среднего уровня: Объекты (II) От начального до среднего уровня: Объекты (II)
В сегодняшней статье мы рассмотрим, как простым способом управлять некоторыми свойствами объектов с помощью кода. Мы также рассмотрим, как с помощью специального приложения можно разместить более одного объекта на одном графике. Кроме того, мы начнём разбираться в важности присвоения краткого названия любому индикатору, который мы собираемся внедрить.