Трейдинг с экономическим календарем MQL5 (часть 12): Хранение данных в SQLite и предотвращение повторных сделок
Введение
В алгоритмической торговле данные, на которых основана наша автоматизация, должны сохраняться при перезапуске программ, перезагрузке терминала, а также при работе на границе между графиками реального времени и тестером стратегий. В части 11 этой серии мы создали модульную новостную панель на основе холста, которая загружала события либо из встроенного ресурса значений, разделённых запятыми (CSV) в тестере стратегий, либо из API календаря MetaQuotes Language 5 (MQL5) в режиме реального времени. Этот подход работал, но две проблемы остались нерешенными. Файл CSV представлял собой статический снимок, зафиксированный на этапе компиляции, поэтому обновление тестовых данных требовало перекомпиляции программы. При этом список уже отработанных событий хранился только в памяти, поэтому перезапуск стирал все записи о том, какие новостные события уже вызвали открытие сделок, что создавало риск появления дублирующих ордеров. Эта статья предназначена для разработчиков на языке MetaQuotes Language 5 (MQL5) и алгоритмических трейдеров. Здесь показано, как сохранять состояние календаря и сделок между перезапусками, использовать одно хранилище данных для режима реального времени и тестера, а также загружать свежую историю по запросу без перекомпиляции.
Мы заменяем ресурс CSV слоем SQLite. Файл базы данных находится в общей папке терминала, поэтому графики в реальном времени и агенты тестера стратегий совместно используют его. В режиме реального времени каждое загруженное событие записывается на диск по мере загрузки, а тестер стратегий считывает данные из того же хранилища без изменений кода между этими двумя режимами. Кроме того, мы сохраняем идентификаторы событий, по которым уже были открыты сделки, и отображаем ход загрузки с помощью индикатора выполнения на холсте, чтобы пользователь получал наглядную обратную связь во время заполнения базы данных. При каждом перезапуске программы список сработавших сделок восстанавливается по данным за последние 24 часа. Мы рассмотрим следующие темы:
К концу этой статьи у нас будет панель новостей, которая сохраняет данные между перезапусками, загружает историю календаря по запросу с видимым индикатором выполнения и использует одну базу данных как для работы в режиме реального времени, так и для автономного тестирования.
От CSV-ресурса к уровню базы данных SQLite
В предыдущей части тестер стратегий опирался на встроенный в программу на этапе компиляции CSV-файл. Файл подключался как строковый ресурс, при запуске разбирался построчно и использовался для заполнения массива событий в памяти. Это подходило для автономного тестирования, но означало, что тестовые данные были навсегда привязаны к содержимому файла на момент компиляции. Для обновления до свежего диапазона новостных событий требовалось заново сгенерировать CSV-файл, скопировать его в папку ресурсов и перекомпилировать программу. Режим реального времени загружал данные непосредственно из API календаря MetaTrader 5 и никогда ничего не записывал на диск, поэтому эти два режима использовали совершенно разные источники данных с совершенно разными жизненными циклами.
В новой архитектуре обе части этой разделённой системы заменены единым файлом базы данных SQLite, расположенным в общей папке терминала. SQLite — это автономный движок базы данных, встроенный в среду выполнения MetaTrader 5 через Database API, который даёт нам привычные инструменты: создание таблиц, подготовку операторов, привязку параметров, выполнение транзакций и выборку строк — и всё это прямо из MQL5. Размещение в общей папке означает, что агент графика в реальном времени и каждый агент тестера стратегий обращаются к одному и тому же физическому файлу. Всё, что режим реального времени узнаёт о календаре, тестер может прочитать. Всё, что предоставляют тестеру для обработки, видит и график в реальном времени. Внутри этого файла находятся две таблицы: одна содержит сами события со всеми полями, необходимыми для отображения с учётом единиц измерения и принятия торговых решений, а другая — идентификаторы новостных событий, которые уже вызвали открытие сделок, чтобы набор для предотвращения повторных сделок сохранялся после перезапусков.
Поток данных становится асимметричным, но при этом простым и ясным. В режиме реального времени при каждом обновлении через API календаря каждое событие теперь записывается в базу данных через upsert (вставка или обновление) в рамках одной транзакции, поэтому состояние на диске остаётся синхронизированным с тем, что программа видит в памяти. Новый входной параметр позволяет пользователю запросить однократную загрузку данных за любой исторический диапазон дат для начального наполнения базы данных; при этом поверх отображается индикатор выполнения, показывающий процент и количество записей по мере накопления строк. В режиме тестера программа вообще не выполняет вызовы API — она выбирает данные из таблицы событий за настроенное окно бэктеста и передает результат напрямую в тот же конвейер фильтрации и визуализации, который используется в режиме реального времени. Список идентификаторов сработавших сделок загружается один раз при запуске из истории базы данных за последние двадцать четыре часа, восстанавливая контекст предотвращения повторных сделок, оставленный предыдущим сеансом. Ниже представлена визуализация того, чего мы намерены достичь.

Реализация на языке MQL5
Открытие базы данных SQLite и создание схемы
Уровень базы данных состоит из одного файла, используемого как в режиме реального времени, так и в режиме тестера, а также из двух таблиц: одной для событий и одной для идентификаторов сработавших сделок. Мы используем встроенный в MetaTrader 5 движок SQLite, который предоставляет доступ к своему API непосредственно из MQL5.
//+------------------------------------------------------------------+ //| News Database.mqh | //| Copyright 2026, Allan Munene Mutiiria. | //| https://t.me/Forex_Algo_Trader | //+------------------------------------------------------------------+ #property copyright "Copyright 2026, Allan Munene Mutiiria." #property link "https://t.me/Forex_Algo_Trader" //--- Include guard #ifndef NEWS_DATABASE_MQH #define NEWS_DATABASE_MQH //--- Include core data definitions and state #include "News Core.mqh" //--- Declare debugLogging extern when not compiled from main (Logic.mqh uses same guard) #ifndef NEWS_COMPILED_FROM_MAIN extern bool debugLogging; // Print debug info to journal #endif //+------------------------------------------------------------------+ //| Database State | //+------------------------------------------------------------------+ int g_news_db = INVALID_HANDLE; // SQLite database handle (-1 when not open) //--- Database stored in the COMMON terminal folder so both live chart //--- and strategy tester agents can access the same file #define NEWS_DB_PATH "Database\\NewsCalendarEA.db" // Path relative to Common/Files/ //+------------------------------------------------------------------+ //| Open or create database and ensure required tables exist | //+------------------------------------------------------------------+ bool News_DbOpen() { //--- Return early if database is already open if(g_news_db != INVALID_HANDLE) return true; //--- Open from Common/Files/ folder for shared live and tester access g_news_db = DatabaseOpen(NEWS_DB_PATH, DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE | DATABASE_OPEN_COMMON); if(g_news_db == INVALID_HANDLE) { Print("News_DbOpen: failed to open '", NEWS_DB_PATH, "' error=", GetLastError()); return false; } //--- Create events table with all calendar value columns if(!DatabaseExecute(g_news_db, "CREATE TABLE IF NOT EXISTS events (" " value_id INTEGER PRIMARY KEY," " event_id INTEGER NOT NULL," " event_time INTEGER NOT NULL," " currency TEXT NOT NULL," " event_name TEXT NOT NULL," " importance TEXT NOT NULL," " has_actual INTEGER NOT NULL DEFAULT 0," " actual REAL NOT NULL DEFAULT 0," " has_forecast INTEGER NOT NULL DEFAULT 0," " forecast REAL NOT NULL DEFAULT 0," " has_previous INTEGER NOT NULL DEFAULT 0," " previous REAL NOT NULL DEFAULT 0," " has_revised INTEGER NOT NULL DEFAULT 0," " revised_prev REAL NOT NULL DEFAULT 0," " unit INTEGER NOT NULL DEFAULT 0," " multiplier INTEGER NOT NULL DEFAULT 0," " digits INTEGER NOT NULL DEFAULT 0," " updated_at INTEGER NOT NULL DEFAULT 0" ");")) { Print("News_DbOpen: create events table failed: ", GetLastError()); DatabaseClose(g_news_db); g_news_db = INVALID_HANDLE; return false; } //--- Create index on event_time for fast time-window queries DatabaseExecute(g_news_db, "CREATE INDEX IF NOT EXISTS idx_events_time ON events(event_time);"); //--- Create triggered table for trade deduplication across restarts if(!DatabaseExecute(g_news_db, "CREATE TABLE IF NOT EXISTS triggered (" " event_id INTEGER PRIMARY KEY," " triggered_at INTEGER NOT NULL" ");")) { Print("News_DbOpen: create triggered table failed: ", GetLastError()); DatabaseClose(g_news_db); g_news_db = INVALID_HANDLE; return false; } if(debugLogging) Print("News_DbOpen: ready at '", NEWS_DB_PATH, "'"); return true; }
Мы начинаем реализацию с объявления глобального дескриптора g_news_db для отслеживания открытого соединения с базой данных на протяжении всего времени работы программы; он инициализируется значением INVALID_HANDLE, чтобы мы могли определять состояние, когда соединение не открыто. Константа NEWS_DB_PATH»задает путь относительно общей папки с файлами: двойная обратная косая черта экранирует одиночную обратную косую черту в строковом литерале, а сам файл NewsCalendarEA.db находится в подпапке Database.
Мы определяем функцию News_DbOpen в качестве единственной точки входа для настройки базы данных. Проверка с ранним возвратом предотвращает повторное открытие уже открытого дескриптора, поскольку двукратный вызов функции open приведёт к утечке предыдущего соединения. Затем мы вызываем функцию DatabaseOpen с комбинацией трёх флагов: доступ для чтения и записи, «создать, если отсутствует» и флаг общей папки. Именно этот третий флаг играет ключевую роль в нашем разделении — без него база данных хранилась бы в папке данных терминала каждого экземпляра, где экземпляр, работающий с графиком в реальном времени, и каждый агент тестера стратегий получали бы изолированные копии. Общая папка — это единственное общее место, к которому каждый экземпляр MetaTrader 5 на компьютере имеет доступ для чтения и записи, поэтому загрузка в режиме реального времени заполняет тот же файл, который открывает тестер.
Если вызов открытия завершается сбоем, мы выводим последний код ошибки и возвращаем значение false, чтобы вызывающая сторона знала, что инициализация не может быть продолжена. В случае успеха мы переходим к созданию схемы. Таблица событий создается с помощью функции DatabaseExecute, которой передается оператор CREATE TABLE IF NOT EXISTS: при чистой установке программы схема создается, а при последующем запуске используется уже существующая. Первичным ключом таблицы является поле value_id, представляющее собой уникальный идентификатор значения календаря MetaTrader 5 — это позволяет обновлять одно и то же событие, а не создавать его дубликат, когда после первоначальной загрузки публикуются его фактические или пересмотренные значения. Столбец `event_id` содержит идентификатор родительского события, используемый для предотвращения повторных сделок, столбец `event_time` — временную метку Unix для запросов по временным окнам, а остальные столбцы соответствуют полям структуры NewsEvent, включая четыре флага наличия значения, четыре значения типа double, метаданные форматирования (единица измерения, множитель, количество разрядов) и метку времени `updated_at`.
После создания таблицы мы выполняем второй оператор для создания индекса по столбцу event_time. В противном случае SQLite при каждом запросе с временным окном просматривал бы всю таблицу, но благодаря наличию этого индекса движок может сразу перейти к нужному диапазону. Это важно, поскольку тестер загружает события по диапазону дат при каждой инициализации, а режим реального времени удаляет старые события по временной метке при каждом запуске.
Затем создается таблица сработавших сделок с помощью отдельного вызова CREATE TABLE IF NOT EXISTS. Её схема намеренно минимальна: только идентификатор события в качестве первичного ключа и временная метка момента, когда была открыта сделка. Ограничение первичного ключа обеспечивает автоматическое предотвращение повторных сделок: когда торговая логика пытается дважды отметить одно и то же событие, оператор INSERT OR IGNORE, который мы увидим позже, просто пропускает дубликат без ошибки. При любой ошибке создания мы закрываем дескриптор и возвращаем значение false, чтобы программа не работала с частично инициализированной базой данных. Последняя запись в журнале отладки подтверждает, что база данных готова и находится по ожидаемому пути. После открытия базы данных нам нужно её закрыть. Мы используем следующий подход.
Правильное закрытие базы данных
Наряду с открытием базы данных необходимо обеспечить её корректное закрытие при завершении работы программы, чтобы были сняты блокировки файлов и выполнены все ожидающие операции записи.
//+------------------------------------------------------------------+ //| Close the database cleanly | //+------------------------------------------------------------------+ void News_DbClose() { //--- Skip if database is not open if(g_news_db == INVALID_HANDLE) return; DatabaseClose(g_news_db); g_news_db = INVALID_HANDLE; }
Мы определяем функцию News_DbClose как небольшую, но важную вспомогательную функцию для очистки. Проверка с ранним возвратом предотвращает вызов close для дескриптора, который никогда не открывался или уже был закрыт, поскольку иначе в среду выполнения было бы передано недопустимое значение и в журнале возникла бы ошибка. Если дескриптор действителен, мы вызываем функцию DatabaseClose для освобождения соединения, а затем сбрасываем глобальный дескриптор обратно в недействительное сторожевое значение, чтобы любой последующий код, проверяющий состояние открытия, видел базу данных как закрытую. Эта пара операций выполняется в ветке деинициализации программы, гарантируя, что движок SQLite сбросит свой кэш записи и снимет блокировку файла до того, как терминал выгрузит программу. Далее мы реализуем вставку по одной строке за раз.
Вставка или замена одной строки события
Таблица событий записывается по одной строке за раз с помощью подготовленного оператора, который в рамках одной операции обрабатывает как новые вставки, так и обновления существующих строк. Именно это обеспечивает идемпотентные загрузки, то есть загрузки, которые можно повторять без изменения результата: обработка одних и тех же данных дважды дает тот же результат, что и их однократная обработка.
//+------------------------------------------------------------------+ //| Upsert a single event row into the events table | //+------------------------------------------------------------------+ bool News_DbUpsertEvent(long valueId, const NewsEvent &ev, datetime updatedAt) { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) return false; //--- Prepare INSERT OR REPLACE statement with all event columns int stmt = DatabasePrepare(g_news_db, "INSERT OR REPLACE INTO events (" " value_id,event_id,event_time,currency,event_name,importance," " has_actual,actual,has_forecast,forecast," " has_previous,previous,has_revised,revised_prev," " unit,multiplier,digits,updated_at" ") VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); if(stmt == INVALID_HANDLE) return false; //--- Bind all parameter values to statement slots DatabaseBind(stmt, 0, valueId); DatabaseBind(stmt, 1, ev.eventId); DatabaseBind(stmt, 2, (long)ev.eventDateTime); DatabaseBind(stmt, 3, ev.currency); DatabaseBind(stmt, 4, ev.event); DatabaseBind(stmt, 5, ev.importance); DatabaseBind(stmt, 6, (int)ev.hasActual); DatabaseBind(stmt, 7, ev.actual); DatabaseBind(stmt, 8, (int)ev.hasForecast); DatabaseBind(stmt, 9, ev.forecast); DatabaseBind(stmt, 10, (int)ev.hasPrevious); DatabaseBind(stmt, 11, ev.previous); DatabaseBind(stmt, 12, (int)ev.hasRevised); DatabaseBind(stmt, 13, ev.revisedPrevious); DatabaseBind(stmt, 14, ev.unit); DatabaseBind(stmt, 15, ev.multiplier); DatabaseBind(stmt, 16, ev.digits); DatabaseBind(stmt, 17, (long)updatedAt); //--- Execute and finalize the prepared statement const bool ok = DatabaseRead(stmt); DatabaseFinalize(stmt); return ok; }
Здесь мы определяем функцию News_DbUpsertEvent для записи одной строки события с использованием шаблона upsert (вставка или обновление). Функция принимает идентификатор значения календаря отдельно от структуры события, поскольку идентификатор значения является уникальным ключом для каждого опубликованного значения, тогда как идентификатор события — это идентификатор родительского события, который мы используем для предотвращения повторных сделок. Третий параметр представляет собой единую метку времени, общую для всех строк в одном пакете; она полезна для отметки того, когда к этой версии данных последний раз обращались.
Проверка с ранним возвратом предотвращает запись, если база данных не открыта, и возвращает false, чтобы вызывающая сторона могла решить, записывать ли сбой в журнал или молча продолжить работу. Затем мы вызываем функцию DatabasePrepare с оператором upsert (вставка или обновление). Форма INSERT OR REPLACE представляет собой сокращённую запись в SQLite, означающую: «если строка с этим первичным ключом уже существует, удалить её и вставить новую». Поскольку первичным ключом является value_id, запись с одним и тем же идентификатором значения календаря всегда перезаписывается, а значит повторная загрузка диапазона дат или обновление в режиме реального времени никогда не создаёт дублирующихся строк. Каждый столбец в таблице имеет один заполнитель для параметра — восемнадцать знаков вопроса соответствуют восемнадцати столбцам, перечисленным в скобках над ними.
При привязке параметров используется функция DatabaseBind, которая заполняет каждый плейсхолдер по индексу, начиная с нуля. Мы передаем идентификатор значения, идентификатор родительского события и временную метку события, приведенную к типу long, поскольку SQLite хранит даты и время в виде целочисленных временных меток Unix. Строковые поля — «валюта», «название события» и «метка важности» — привязываются напрямую. Четыре флага наличия значения преобразуются из bool в int, поскольку SQLite использует значения 0 и 1 для хранения булевых значений. Четыре значения типа double привязываются как числа с плавающей запятой. Три поля метаданных форматирования и метка времени updated-at завершают привязку параметров. Подготовленные операторы с привязанными параметрами позволяют избежать построения SQL путем конкатенации строк, из-за которого некорректные входные символы в именах событий могли бы создать проблемы.
Затем мы вызываем функцию DatabaseRead для выполнения подготовленного оператора и получения его возвращаемого значения. В случае операторов, не возвращающих строк, таких как операторы вставки и обновления, этот вызов просто выполняет оператор и сообщает об успешном выполнении или сбое. Мы всегда сочетаем подготовку с вызовом функции DatabaseFinalize в том же вызове, чтобы освободить дескриптор оператора, независимо от того, завершилось ли выполнение успешно: утечка дескрипторов операторов в конечном итоге привела бы к исчерпанию пула ресурсов движка SQLite. Функция возвращает результат выполнения, чтобы вызывающая сторона знала, была ли строка занесена в базу данных. Следующая задача — извлечь события из таблицы.
Считывание событий из базы данных для временного окна
Дополнением к записи событий является их последующее считывание, что и выполняет тестер стратегий при запуске для заполнения массива в памяти данными из файла базы данных.
//+------------------------------------------------------------------+ //| Load events for a time window from DB into g_news_allEvents[] | //+------------------------------------------------------------------+ int News_DbLoadEventsForWindow(datetime startDt, datetime endDt) { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) return 0; //--- Prepare time-window query ordered ascending by event time int stmt = DatabasePrepare(g_news_db, "SELECT event_id,event_time,currency,event_name,importance," " has_actual,actual,has_forecast,forecast," " has_previous,previous,has_revised,revised_prev," " unit,multiplier,digits" " FROM events" " WHERE event_time >= ? AND event_time <= ?" " ORDER BY event_time ASC;"); if(stmt == INVALID_HANDLE) return 0; //--- Bind time window bounds DatabaseBind(stmt, 0, (long)startDt); DatabaseBind(stmt, 1, (long)endDt); //--- Reset event array and read each row into it ArrayResize(g_news_allEvents, 0); int idx = 0; while(DatabaseRead(stmt)) { //--- Declare temporary locals; MQL5 forbids struct refs from arrays NewsEvent e; long evId = 0, evTime = 0; int hasA = 0, hasF = 0, hasP = 0, hasR = 0; int unitV = 0, multV = 0, digV = 0; string cur = "", evName = "", imp = ""; double act = 0, fcast = 0, prev = 0, revPrev = 0; //--- Read each column from the current row DatabaseColumnLong (stmt, 0, evId); DatabaseColumnLong (stmt, 1, evTime); DatabaseColumnText (stmt, 2, cur); DatabaseColumnText (stmt, 3, evName); DatabaseColumnText (stmt, 4, imp); DatabaseColumnInteger(stmt, 5, hasA); DatabaseColumnDouble (stmt, 6, act); DatabaseColumnInteger(stmt, 7, hasF); DatabaseColumnDouble (stmt, 8, fcast); DatabaseColumnInteger(stmt, 9, hasP); DatabaseColumnDouble (stmt, 10, prev); DatabaseColumnInteger(stmt, 11, hasR); DatabaseColumnDouble (stmt, 12, revPrev); DatabaseColumnInteger(stmt, 13, unitV); DatabaseColumnInteger(stmt, 14, multV); DatabaseColumnInteger(stmt, 15, digV); //--- Populate event struct from column values e.eventId = evId; e.eventDateTime = (datetime)evTime; e.eventDate = TimeToString((datetime)evTime, TIME_DATE); e.eventTime = TimeToString((datetime)evTime, TIME_MINUTES); e.currency = cur; e.event = evName; e.importance = imp; e.hasActual = (hasA != 0); e.actual = act; e.hasForecast = (hasF != 0); e.forecast = fcast; e.hasPrevious = (hasP != 0); e.previous = prev; e.hasRevised = (hasR != 0); e.revisedPrevious = revPrev; e.unit = unitV; e.multiplier = multV; e.digits = digV; //--- Append populated struct to the event array ArrayResize(g_news_allEvents, idx + 1); g_news_allEvents[idx] = e; idx++; } DatabaseFinalize(stmt); if(debugLogging) Print("News_DbLoadEventsForWindow: loaded ", idx, " events"); return idx; }
Мы определяем функцию News_DbLoadEventsForWindow для извлечения всех событий, временные метки которых находятся в пределах заданных начального и конечного значений. Возврат нуля при закрытом дескрипторе позволяет вызывающей стороне перейти к загрузке в режиме реального времени без генерации критической ошибки. Затем мы подготавливаем оператор SELECT, который выбирает шестнадцать столбцов из таблицы событий, применяет фильтр по предикату временного окна и упорядочивает результаты по возрастанию времени события. Порядок имеет значение, поскольку остальная часть программы ожидает, что события будут поступать от самых старых к самым новым — без этого план строк создавал бы разделители дней в неправильной последовательности.
Обе временные границы привязываются к заполнителям как целые числа типа long, что соответствует тому, как SQLite сохранял временные метки при их записи. Затем мы вызываем функцию ArrayResize, чтобы очистить глобальный массив событий перед чтением, тем самым гарантируя, что новые данные не будут добавлены к устаревшим данным, оставшимся от предыдущего запуска тестера.
Цикл чтения использует функцию DatabaseRead в конструкции while — для операторов SELECT каждый вызов перемещает курсор на следующую строку и возвращает true, пока строки остаются. Внутри цикла мы объявляем временную структуру NewsEvent, а также по одной локальной переменной для каждого столбца. Мы извлекаем каждое значение с помощью функций доступа, соответствующих конкретным столбцам: функцию DatabaseColumnLong — для двух целочисленных столбцов, которым требуется 64-битный диапазон, функцию DatabaseColumnText — для трёх строковых столбцов, функцию DatabaseColumnInteger — для семи полей целочисленного типа, включая четыре флага наличия значения, и функцию DatabaseColumnDouble — для четырёх столбцов со значениями с плавающей запятой. Параметр индекса столбца при каждом вызове соответствует позиции в списке SELECT, а не схеме таблицы.
После того как все столбцы прочитаны, мы заполняем структуру поле за полем. Временная метка события возвращается как целое число типа long, которое мы приводим к типу datetime для хранения, а затем дважды форматируем с помощью функции TimeToString, чтобы заполнить удобочитаемые строковые поля даты и времени. Флаги наличия значения преобразуются из целочисленного формата хранения 0/1 обратно в представление типа bool, которое используется в остальной части программы, а все остальные поля копируются напрямую. Наконец, мы расширяем массив событий и добавляем структуру в новый слот.
После выхода из цикла функция DatabaseFinalize освобождает дескриптор оператора, а журнал отладки сообщает, сколько строк было загружено. Возвращаемое значение содержит тот же счётчик, поэтому вызывающий код может выбирать ветку выполнения на его основе — в частности, при запуске тестера нулевое возвращаемое значение служит сигналом для вывода пользователю инструкции «Данные не найдены, пожалуйста, сначала загрузите их в режиме реального времени». Далее мы переходим к очистке данных — удалению старых строк событий, а также к записи и запросу набора сработавших сделок.
Обрезка, предотвращение повторных сделок и проверка наличия данных во временном окне
Пять небольших вспомогательных функций выполняют служебные операции с двумя таблицами: удаляют старые строки событий, записывают и запрашивают набор сработавших сделок, а также проверяют, есть ли данные во временном окне.
//+------------------------------------------------------------------+ //| Prune events older than keepDays to prevent database bloat | //+------------------------------------------------------------------+ void News_DbPruneOldEvents(int keepDays = 7) { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) return; //--- Compute cutoff timestamp and delete rows older than it const long cutoff = (long)TimeCurrent() - (long)keepDays * 86400; int stmt = DatabasePrepare(g_news_db, "DELETE FROM events WHERE event_time < ?;"); if(stmt == INVALID_HANDLE) return; DatabaseBind(stmt, 0, cutoff); DatabaseRead(stmt); DatabaseFinalize(stmt); } //+------------------------------------------------------------------+ //| Persist a triggered event ID to DB for restart safety | //+------------------------------------------------------------------+ bool News_DbMarkTriggered(long evId) { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) return false; //--- Insert event ID with current timestamp; ignore duplicate int stmt = DatabasePrepare(g_news_db, "INSERT OR IGNORE INTO triggered (event_id,triggered_at) VALUES (?,?);"); if(stmt == INVALID_HANDLE) return false; DatabaseBind(stmt, 0, evId); DatabaseBind(stmt, 1, (long)TimeCurrent()); const bool ok = DatabaseRead(stmt); DatabaseFinalize(stmt); return ok; } //+------------------------------------------------------------------+ //| Load triggered event IDs from last 24h into g_news_triggeredIds[]| //+------------------------------------------------------------------+ void News_DbLoadTriggered() { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) return; //--- Query triggered IDs from the past 24 hours const long cutoff = (long)TimeCurrent() - 86400; int stmt = DatabasePrepare(g_news_db, "SELECT event_id FROM triggered WHERE triggered_at >= ?;"); if(stmt == INVALID_HANDLE) return; DatabaseBind(stmt, 0, cutoff); //--- Populate g_news_triggeredIds array from query results ArrayResize(g_news_triggeredIds, 0); int n = 0; while(DatabaseRead(stmt)) { long id = 0; DatabaseColumnLong(stmt, 0, id); ArrayResize(g_news_triggeredIds, n + 1); g_news_triggeredIds[n++] = id; } DatabaseFinalize(stmt); if(n > 0 && debugLogging) Print("News_DbLoadTriggered: restored ", n, " triggered IDs"); } //+------------------------------------------------------------------+ //| Check if event ID was already triggered (memory array fast path) | //+------------------------------------------------------------------+ bool News_DbAlreadyTriggered(long evId) { //--- Linear scan of the in-memory triggered IDs array const int n = ArraySize(g_news_triggeredIds); for(int i = 0; i < n; i++) if(g_news_triggeredIds[i] == evId) return true; return false; } //+------------------------------------------------------------------+ //| Check if database already has events for a given time window | //+------------------------------------------------------------------+ bool News_DbHasDataForWindow(datetime startDt, datetime endDt) { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) return false; //--- Count rows in the requested window int stmt = DatabasePrepare(g_news_db, "SELECT COUNT(*) FROM events WHERE event_time >= ? AND event_time <= ?;"); if(stmt == INVALID_HANDLE) return false; DatabaseBind(stmt, 0, (long)startDt); DatabaseBind(stmt, 1, (long)endDt); int cnt = 0; if(DatabaseRead(stmt)) DatabaseColumnInteger(stmt, 0, cnt); DatabaseFinalize(stmt); return (cnt > 0); }
Здесь мы определяем функцию News_DbPruneOldEvents для удаления строк, возраст которых превышает заданное количество дней. По умолчанию сохраняется скользящее семидневное окно, что позволяет найти баланс между полезным историческим контекстом и раздуванием базы данных из-за накопленных за месяцы данных. Временная метка отсечения получается путем вычитания из текущего времени сервера значения keep-days, умноженного на 86 400 — количество секунд в сутках. Мы подготавливаем оператор DELETE с фильтром по столбцу event_time, привязываем значение отсечения как целое число типа long, выполняем его через функцию DatabaseRead и финализируем дескриптор. Эта функция вызывается в ветке запуска режима реального времени, поэтому каждая новая сессия начинается с аккуратно очищенной базы данных.
Функция News_DbMarkTriggered фиксирует, что событие с определенным идентификатором вызвало открытие сделки. Мы используем форму INSERT OR IGNORE оператора INSERT в SQLite — если строка с таким же первичным ключом уже существует, оператор без предупреждения ничего не делает, вместо того чтобы генерировать ошибку нарушения ограничения. Поскольку первичным ключом таблицы сработавших сделок является сам идентификатор события, это дает нам автоматическое предотвращение повторов на уровне базы данных. Мы привязываем идентификатор события и текущую временную метку как значения, выполняем оператор, финализируем его и возвращаем флаг успешного выполнения.
Мы определяем функцию News_DbLoadTriggered для восстановления набора идентификаторов сработавших сделок в памяти при запуске. Достаточно вернуться на 24 часа назад, чтобы покрыть ночные и выходные пробелы, не подтягивая устаревшие записи из давно прошедших сессий. Функция подготавливает SELECT по таблице сработавших сделок с фильтрацией по значению отсечения, очищает глобальный массив идентификаторов сработавших сделок, затем обходит каждую возвращенную строку с помощью функции DatabaseRead и использует функцию DatabaseColumnLong для считывания идентификатора события перед добавлением его в массив. В журнале отладки указывается, сколько идентификаторов было восстановлено, если найден хотя бы один.
Функция News_DbAlreadyTriggered представляет собой проверку на критическом пути, которая выполняется при каждом сканировании кандидатов на сделку. Вместо того чтобы каждый раз обращаться к базе данных, функция напрямую просматривает массив, хранящийся в памяти. Массив загружается один раз при запуске предыдущей функцией и пополняется при каждом открытии новой сделки, поэтому он всегда отражает актуальное состояние без обращения к диску на каждом тике.
Наконец, функция News_DbHasDataForWindow определяет, содержатся ли в таблице событий записи для заданного временного окна. Она подготавливает запрос SELECT COUNT(*) с фильтрацией по временным границам, привязывает начало и конец, а затем считывает единственное значение счетчика с помощью функции DatabaseColumnInteger. Возвращаемое выражение проверяет, является ли значение count положительным. Именно такая проверка может прервать загрузку на раннем этапе: если запрошенный диапазон уже существует, вызывающий код может полностью пропустить получение данных через API. Далее мы визуализируем ход загрузки.
Отрисовка одного кадра индикатора выполнения загрузки
Процедура загрузки может извлекать тысячи записей календаря, и пользователю нужна обратная связь, что что-то происходит. Мы рисуем один кадр индикатора выполнения на специальном наложенном холсте: с центрированным заголовком, содержащим текущий процент и счетчики, и с дорожкой, заливка которой расходится от центра к краям.
//+------------------------------------------------------------------+ //| Draw one frame of the download progress bar onto canvas | //+------------------------------------------------------------------+ void News_DbDrawProgressBar(CCanvas &canv, int pbW, int pbH, int pct, int saved, int total) { //--- Clear canvas to transparent canv.Erase(0x00000000); //--- Draw outer rounded pill background using theme colors News_FillRoundRect(canv, 0, 0, pbW, pbH, 10, ColorToARGB(g_news_bg, 255)); News_DrawRoundRectBorder(canv, 0, 0, pbW, pbH, 10, 1, ColorToARGB(g_news_borderAccent, 255)); //--- Stamp centered title text with current progress counts canv.FontSet("Arial Bold", -100); canv.TextOut(pbW / 2, 7, "Downloading News Events: " + IntegerToString(pct) + "% (" + IntegerToString(saved) + " / " + IntegerToString(total) + ")", ColorToARGB(g_news_titleText, 255), TA_CENTER | TA_TOP); //--- Draw the track capsule background below the title const int padX = 14; const int trackL = padX; const int trackR = pbW - padX; const int trackW = trackR - trackL; const int trackY = pbH - 18; const int barH = 10; News_FillRoundRect(canv, trackL, trackY, trackW, barH, barH / 2, ColorToARGB(g_news_panelAlt, 255)); News_DrawRoundRectBorder(canv, trackL, trackY, trackW, barH, barH / 2, 1, ColorToARGB(g_news_border, 255)); //--- Fill the track from center outward proportional to percent complete const int fillW = (int)(trackW * pct / 100.0); const int fillL = trackL + (trackW - fillW) / 2; if(fillW > 0) News_FillRoundRect(canv, fillL, trackY, fillW, barH, barH / 2, ColorToARGB(g_news_accent, 255)); //--- Push canvas frame to chart canv.Update(); ChartRedraw(0); }
Мы определяем функцию News_DbDrawProgressBar, которая при каждом вызове рисует полный кадр индикатора выполнения с нуля. Ссылка на холст передается параметром, поэтому эта процедура работает с любым холстом, который вызывающий код решил настроить. Сначала мы очищаем холст до полной прозрачности с помощью метода Erase, а затем рисуем внешний фон в виде капсулы с помощью вспомогательной функции News_FillRoundRect с радиусом скругления углов 10 пикселей. Вспомогательная функция News_DrawRoundRectBorder рисует сверху рамку шириной 1 пиксель, что придаёт капсуле чёткие очертания на фоне графика.
Далее мы выводим центрированный заголовок: сначала с помощью метода FontSet переключаемся на полужирный шрифт Arial кеглем 10 пунктов, затем используем метод TextOut с комбинацией флагов выравнивания TA_CENTER | TA_TOP. Строка заголовка формируется прямо в коде из процентного значения и чисел сохраненных записей и общего количества; она форматируется с помощью функции IntegerToString, чтобы пользователь видел и процент, и исходные числа записей — «45 % (450 / 1000)» читается понятнее, чем любая из этих частей по отдельности.
Под заголовком мы размещаем дорожку в форме капсулы. Дорожка имеет боковые отступы слева и справа, оставляет сверху достаточно места для заголовка и располагается на восемнадцать пикселей выше нижнего края. Саму дорожку мы рисуем как полностью скругленный прямоугольник высотой 10 пикселей с заливкой альтернативным цветом панели, а затем обводим его рамкой. Если установить радиус скругления равным половине высоты полосы, дорожка получит настоящую форму капсулы независимо от своей ширины.
Затем мы вычисляем ширину заливки как ширину дорожки, умноженную на процентное значение и деленную на 100. Интересный момент — расположение заливки: вместо того чтобы наращивать полосу от левого края, как это делается в большинстве индикаторов выполнения, мы центрируем её внутри дорожки, смещая левый край заливки на половину оставшейся незаполненной части. По мере того как значение процента увеличивается от 0 до 100, заливка симметрично расширяется от центра наружу, в конечном итоге достигая обоих краев дорожки. Это небольшой визуальный прием, который придает полосе приятное ощущение «роста к завершению», а не простого однонаправленного заполнения.
Наконец, мы передаём отрисованный холст на график с помощью метода Update и вызываем функцию ChartRedraw, чтобы заставить терминал немедленно перерисовать график. Без этой явной перерисовки обновления индикатора выполнения помещались бы в очередь и отображались бы только с обычной периодичностью обновления графика, из-за чего счётчик процентов выглядел бы «зависшим» во время загрузки. Наконец, мы можем связать все эти функции, чтобы связать все эти функции в единый процесс загрузки.
Загрузка данных календаря и их сохранение в базу данных
Это функция-оркестратор, которая объединяет API календаря, холст индикатора выполнения и вспомогательную функцию upsert (вставка или обновление) в единую операцию загрузки. Она выполняется только в режиме реального времени и только в том случае, если пользователь явно включил эту возможность.
//+------------------------------------------------------------------+ //| Download calendar data for date range and save to database | //+------------------------------------------------------------------+ void News_DbDownloadAndSave(datetime startDt, datetime endDt) { //--- Abort if database is not open if(g_news_db == INVALID_HANDLE) { Print("News_DbDownloadAndSave: DB not open - aborting"); return; } Print(">>> News DB: Fetching calendar events from ", TimeToString(startDt), " to ", TimeToString(endDt), " ..."); //--- Query all raw calendar values in the requested time window MqlCalendarValue values[]; const int total = CalendarValueHistory(values, startDt, endDt, NULL, NULL); if(total <= 0) { Print(">>> News DB: No events found for specified range"); return; } Print(">>> News DB: ", total, " raw calendar records found - saving to DB..."); //--- Create progress bar canvas centered at the top of the chart const int chartW = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS); const int PB_W = 420; const int PB_H = 46; const int PB_X = (chartW - PB_W) / 2; const int PB_Y = 20; const string PB_NAME = "NewsDB_ProgressCanvas"; CCanvas pbCanv; if(pbCanv.CreateBitmapLabel(PB_NAME, PB_X, PB_Y, PB_W, PB_H, COLOR_FORMAT_ARGB_NORMALIZE)) { //--- Configure progress bar canvas object properties ObjectSetInteger(0, PB_NAME, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, PB_NAME, OBJPROP_HIDDEN, true); ObjectSetInteger(0, PB_NAME, OBJPROP_ZORDER, 2000); } //--- Draw initial 0% progress frame News_DbDrawProgressBar(pbCanv, PB_W, PB_H, 0, 0, total); //--- Begin transaction, iterate all values, upsert each event record DatabaseTransactionBegin(g_news_db); const datetime updatedAt = TimeCurrent(); int saved = 0; for(int i = 0; i < total; i++) { //--- Fetch associated event metadata and country info MqlCalendarEvent ev; if(!CalendarEventById(values[i].event_id, ev)) continue; MqlCalendarCountry ctry; CalendarCountryById(ev.country_id, ctry); MqlCalendarValue val; CalendarValueById(values[i].id, val); //--- Populate NewsEvent struct from calendar API data NewsEvent ne; ne.eventDate = TimeToString(values[i].time, TIME_DATE); ne.eventTime = TimeToString(values[i].time, TIME_MINUTES); ne.currency = ctry.currency; ne.event = ev.name; ne.importance = News_GetImpactLabel(ev.importance); ne.hasActual = val.HasActualValue(); ne.hasForecast = val.HasForecastValue(); ne.hasPrevious = val.HasPreviousValue(); ne.hasRevised = val.HasRevisedValue(); ne.actual = val.HasActualValue() ? val.GetActualValue() : 0.0; ne.forecast = val.HasForecastValue() ? val.GetForecastValue() : 0.0; ne.previous = val.HasPreviousValue() ? val.GetPreviousValue() : 0.0; ne.revisedPrevious = val.HasRevisedValue() ? val.GetRevisedValue() : 0.0; ne.unit = (int)ev.unit; ne.multiplier = (int)ev.multiplier; ne.digits = (int)ev.digits; ne.eventDateTime = values[i].time; ne.eventId = (long)values[i].event_id; //--- Upsert the event into the database News_DbUpsertEvent((long)values[i].id, ne, updatedAt); saved++; //--- Refresh progress bar every 5 records to reduce redraw overhead if(i % 5 == 0 || i == total - 1) { const int pct = (int)((i + 1) * 100.0 / total); News_DbDrawProgressBar(pbCanv, PB_W, PB_H, pct, saved, total); } } //--- Commit all upserts as a single transaction DatabaseTransactionCommit(g_news_db); //--- Hold at 100% briefly so the user sees completion before canvas disappears News_DbDrawProgressBar(pbCanv, PB_W, PB_H, 100, saved, total); Sleep(800); //--- Destroy progress bar canvas and clean up the chart object pbCanv.Destroy(); ObjectDelete(0, PB_NAME); ChartRedraw(0); Print(">>> News DB: Download complete. ", saved, " events saved to ", NEWS_DB_PATH, " for range ", TimeToString(startDt), " to ", TimeToString(endDt), ". Use it for offline testing..."); //--- Open the DB file to log its current size on disk ResetLastError(); int fh = FileOpen(NEWS_DB_PATH, FILE_READ | FILE_BIN | FILE_COMMON | FILE_SHARE_READ | FILE_SHARE_WRITE); if(fh == INVALID_HANDLE) { Print(">>> News DB: Could not open DB file to read size (error ", GetLastError(), ")"); } else { //--- Format byte count into KB or MB for readability const ulong dbBytes = FileSize(fh); FileClose(fh); string sizeStr; if(dbBytes >= 1048576) sizeStr = DoubleToString((double)dbBytes / 1048576.0, 2) + " MB"; else if(dbBytes >= 1024) sizeStr = DoubleToString((double)dbBytes / 1024.0, 1) + " KB"; else sizeStr = IntegerToString((long)dbBytes) + " bytes"; Print(">>> News DB: Database file size: ", dbBytes, " bytes (", sizeStr, ")..."); } }
Здесь мы определяем функцию News_DbDownloadAndSave в качестве основной процедуры загрузки. После проверки наличия открытого дескриптора и записи запрошенного диапазона в журнал мы вызываем функцию CalendarValueHistory, чтобы извлечь все записи значений календаря в этом временном окне. Ранний возврат при нулевом или отрицательном количестве обрабатывает случай, когда в запрошенном диапазоне вообще нет событий — например, это будущая дата, данные для которой ещё не опубликованы, или окно выходных, когда не выходит никаких новостей.
Как только мы узнаём, что есть данные для сохранения, мы размещаем наложение индикатора выполнения. Холст имеет ширину 420 пикселей и высоту 46 пикселей; он выровнен по центру по горизонтали в верхней части графика путем вычисления смещения относительно ширины графика. Мы создаём растровую метку с помощью метода CreateBitmapLabel, а затем настраиваем объект графика: он недоступен для выделения, скрыт из списка объектов и имеет Z-порядок 2000, чтобы располагаться над основным холстом новостной панели с Z-порядком 1000. В начальном кадре отображается состояние «0%», поэтому пользователь видит появление индикатора ещё до начала выполнения какой-либо работы.
Основную часть функции составляет цикл upsert (вставка или обновление), заключённый в транзакцию базы данных. Мы вызываем функцию DatabaseTransactionBegin перед циклом и функцию DatabaseTransactionCommit после него — без этого SQLite автоматически фиксировал бы каждую вставку как отдельную транзакцию, что приводило бы к сбросу данных на диск для каждой записи и замедляло бы операцию примерно на порядок. Когда транзакция охватывает каждую операцию upsert (вставка или обновление), SQLite удерживает записи в памяти и сбрасывает их все на диск при фиксации.
Внутри цикла мы обрабатываем каждое значение календаря в три этапа. Сначала мы извлекаем метаданные родительского события с помощью функции CalendarEventById. Далее мы получаем информацию о стране и валюте с помощью функции CalendarCountryById. Наконец, мы извлекаем саму запись значения с помощью функции CalendarValueById. Эти три результата напрямую сопоставляются со структурой NewsEvent: методы HasActualValue, HasForecastValue, HasPreviousValue и HasRevisedValue формируют четыре булевых флага, а соответствующие им методы доступа GetActualValue, GetForecastValue, GetPreviousValue и GetRevisedValue возвращают значения типа double. Затем мы передаём эту структуру в вспомогательную функцию upsert (вставка или обновление).
Индикатор выполнения обновляется каждые пять записей, а также на последней итерации. Перерисовка после каждой отдельной записи привела бы к перегрузке графика событиями отрисовки и заметно замедлила бы загрузку, но слишком редкое обновление оставило бы счётчик процентов на вид «зависшим». Шаг в пять записей обеспечивает баланс: индикатор движется плавно, не отнимая ресурсы процессора у самой операции сохранения. Вы можете задать любой шаг по записям; это был просто произвольный шаг, который мы выбрали.
После фиксации транзакции мы с помощью функции Sleep удерживаем индикатор на отметке 100 процентов в течение 800 миллисекунд, чтобы пользователь получил визуальное подтверждение завершения операции до того, как оверлей исчезнет. Затем холст индикатора выполнения уничтожается с помощью метода Destroy и удаляется с графика через функцию ObjectDelete, после чего выполняется финальная перерисовка, чтобы очистить его с экрана. В сообщении журнала о завершении указываются количество сохранённых записей, путь и диапазон.
В последнем блоке с диска считывается размер файла базы данных для вывода в журнал. Мы открываем файл с флагами совместного чтения через функцию FileOpen, чтобы операция выполнялась даже при активном подключении к базе данных, получаем количество байтов с помощью функции FileSize и сразу же закрываем дескриптор. Затем количество байтов форматируется в удобочитаемую строку: байты для очень маленьких файлов, килобайты с одним знаком после запятой для файлов среднего размера и мегабайты с двумя знаками после запятой для больших файлов, чтобы пользователь мог понять, получился ли в результате загрузки файл приемлемого размера или его размер приближается к нескольким мегабайтам. После загрузки мы видим следующий результат.

Теперь, когда это сделано, мы добавим в главный файл несколько дополнительных входных параметров для управления условной загрузкой новостных событий в режиме реального времени. Для этого мы использовали следующую логику.
Подключение входных параметров загрузки и подключаемого файла базы данных к главному файлу
В главном входном файле появляется новая группа входных параметров для настроек загрузки и новый подключаемый файл для модуля базы данных. Именно эти два небольших дополнения позволяют пользователю запускать загрузку со стандартной панели входных параметров и дают всем остальным модулям доступ к функциям базы данных.
//--- Main file //+------------------------------------------------------------------+ //| Inputs - Data Download Settings | //+------------------------------------------------------------------+ input group "=== DATA DOWNLOAD SETTINGS ===" input bool inp_DownloadDataInLive = false; // Download NEWS data for offline testing? input datetime inp_DownloadStartDate = D'2026.01.01'; // Download range: START date input datetime inp_DownloadEndDate = D'2026.05.12'; // Download range: END date //+------------------------------------------------------------------+ //| Module Includes | //+------------------------------------------------------------------+ //--- We include the new database file //--- Include SQLite database layer for persistent calendar storage #include "News Database.mqh"
В главном файле мы объявляем три новых входных параметра в группе «Настройки загрузки данных». По умолчанию этот логический флаг имеет значение false, поэтому программа никогда не загружает данные автоматически — пользователь должен сознательно включить эту функцию с помощью переключателя, что предотвращает случайные загрузки каждый раз, когда программа повторно присоединяется к графику. Эти два входных параметра datetime задают начало и конец диапазона для загрузки; значения по умолчанию подобраны так, чтобы соответствовать входным параметрам окна бэктеста из другой группы. Если оставить их отдельными входными параметрами, не связанными с диапазоном бэктеста, это дает пользователю гибкость: он может один раз загрузить широкий исторический диапазон, а затем многократно тестировать более узкие отрезки без повторной загрузки.
Подключаемый файл News Database.mqh в порядке подключения расположен между основным модулем и модулем логики. Это расположение важно, поскольку модуль логики обращается к функциям базы данных в путях трассировки и обновления, поэтому к моменту обработки заголовочного файла модуля логики модуль базы данных уже должен быть виден компилятору. Затем остальные модули автоматически получают символы базы данных через ту же цепочку подключений. В модуле логики мы повышаем надежность при перезапуске, направляя работу через модуль базы данных.
//--- In logic module //--- Mark triggered logic now persists to DB //+------------------------------------------------------------------+ //| Append event ID to triggered list and persist to database | //+------------------------------------------------------------------+ void News_MarkTriggered(long evId) { //--- Add ID to the in-memory triggered array const int n = ArraySize(g_news_triggeredIds); ArrayResize(g_news_triggeredIds, n + 1); g_news_triggeredIds[n] = evId; //--- Persist triggered ID to database for restart safety News_DbMarkTriggered(evId); //--- Invalidate candidate cache so next FindCandidate recomputes g_news_cachedCandStamp = (datetime)-1; } //--- Already triggered logic now delegates to DB module //+------------------------------------------------------------------+ //| Test if an event ID is already in the triggered list | //+------------------------------------------------------------------+ bool News_AlreadyTriggered(long evId) { //--- Delegate to database module's in-memory triggered list check return News_DbAlreadyTriggered(evId); }
Функция News_MarkTriggered теперь выполняет две операции подряд. Мы увеличиваем массив идентификаторов сработавших сделок в памяти и добавляем новый идентификатор в конец, сохраняя быстрой проверку предотвращения повторных сделок на критическом пути: каждый последующий вызов поиска события-кандидата просматривает этот массив и пропускает уже имеющиеся идентификаторы. Сразу после добавления идентификатора в память мы вызываем функцию News_DbMarkTriggered из модуля базы данных, чтобы сохранить этот идентификатор в таблице сработавших сделок. Именно благодаря этому второму вызову данные сохраняются после перезапуска. Затем кэш кандидатов сбрасывается, поэтому следующий поиск перестраивается на основе обновленного состояния, а не возвращает устаревшего кандидата, по которому мы только что открыли сделку.
Функция News_AlreadyTriggered становится тонким делегатом, который вызывает функцию News_DbAlreadyTriggered модуля базы данных. Мы оставляем эту функцию в модуле логики, чтобы другой код мог вызывать ее под тем же именем, что и раньше, но фактическая реализация теперь находится в модуле базы данных. Сама проверка по-прежнему выполняется сканированием в памяти — без обращения к диску при каждом вызове, — поскольку при запуске модуль базы данных загрузил идентификаторы в тот же глобальный массив. Такое разделение сохраняет стабильность интерфейса модуля логики, перенося при этом ответственность за постоянное хранение данных в отдельное место. В модуле взаимодействия мы теперь изменяем функции инициализации и деинициализации так, чтобы они работали через слой базы данных и корректно закрывали соединение.
Подключение базы данных к жизненному циклу Init и Deinit
Теперь жизненный цикл программы управляет подключением к базе данных: оно открывается один раз в init и закрывается один раз в deinit, а между этими этапами применяются совершенно разные стратегии загрузки данных для тестера и режима реального времени.
//+------------------------------------------------------------------+ //| Initialize program | //+------------------------------------------------------------------+ bool News_Init() { //--- Apply default light theme News_ApplyTheme(false); //--- Initialize currency/impact filters and scroll state News_InitDefaultFilters(); News_ScrollInit(g_news_tableScroll); //--- Set magic number on the trade helper object g_news_trade.SetExpertMagicNumber(20260507); //--- Open the SQLite database; abort if it fails if(!News_DbOpen()) { Print(">>> News DB: Failed to open database - cannot continue"); return false; } //--- Branch on tester vs live mode for data loading strategy if(MQLInfoInteger(MQL_TESTER)) { //--- Load events from database for the configured backtest window Print(">>> News DB: Running in Strategy Tester, loading from: ", NEWS_DB_PATH, "..."); const int loaded = News_DbLoadEventsForWindow( (datetime)StartDate, (datetime)EndDate); if(loaded == 0) { //--- Instruct user to download data on a live chart first Print("================================================================"); Print(">>> News DB: NO DATA FOUND FOR BACKTEST PERIOD"); Print(">>> Go to a live chart and enable 'Download NEWS data for"); Print(">>> offline testing?' with the same date range, then rerun."); Print("================================================================"); return false; } //--- Log file size; use shared-read flags so file can be read while DB is open ResetLastError(); int fh = FileOpen(NEWS_DB_PATH, FILE_READ | FILE_BIN | FILE_COMMON | FILE_SHARE_READ | FILE_SHARE_WRITE); if(fh == INVALID_HANDLE) { Print(">>> News DB: Loaded ", loaded, " events from ", NEWS_DB_PATH, " (could not read file size, error ", GetLastError(), ")..."); } else { //--- Format byte count into KB or MB for readability const ulong dbBytes = FileSize(fh); FileClose(fh); string sizeStr; if(dbBytes >= 1048576) sizeStr = DoubleToString((double)dbBytes / 1048576.0, 2) + " MB"; else if(dbBytes >= 1024) sizeStr = DoubleToString((double)dbBytes / 1024.0, 1) + " KB"; else sizeStr = IntegerToString((long)dbBytes) + " bytes"; Print(">>> News DB: Loaded ", loaded, " events from ", NEWS_DB_PATH, " (size: ", dbBytes, " bytes / ", sizeStr, ")..."); } News_ApplyFilters(); } else { //--- Prune stale events and restore triggered IDs from database News_DbPruneOldEvents(7); News_DbLoadTriggered(); //--- Download calendar data when user has enabled offline download if(inp_DownloadDataInLive) { Print(">>> News DB: inp_DownloadDataInLive=true - downloading ", TimeToString((datetime)inp_DownloadStartDate), " to ", TimeToString((datetime)inp_DownloadEndDate)); News_DbDownloadAndSave((datetime)inp_DownloadStartDate, (datetime)inp_DownloadEndDate); } //--- Load current live window from MT5 calendar API const datetime now = TimeTradeServer(); News_LoadEventsFromLive(now - PeriodSeconds(start_time), now + PeriodSeconds(end_time)); News_ApplyFilters(); } //--- Show dashboard after successful initialization News_ShowDashboard(); return true; } //+------------------------------------------------------------------+ //| Deinitialize program | //+------------------------------------------------------------------+ void News_Deinit() { //--- Hide and destroy the dashboard canvas News_HideDashboard(); //--- Close the SQLite database before EA unloads News_DbClose(); //--- Destroy persistent table scratch canvas if(g_news_tableTmpReady) { g_news_tableTmp.Destroy(); g_news_tableTmpReady = false; } //--- Destroy primitives high-resolution fill canvas if(g_news_prim.m_hrFillReady) { g_news_prim.m_hrFill.Destroy(); g_news_prim.m_hrFillReady = false; } //--- Destroy primitives high-resolution border canvas if(g_news_prim.m_hrBorderReady) { g_news_prim.m_hrBorder.Destroy(); g_news_prim.m_hrBorderReady = false; } }
Мы открываем базу данных в функции «News_Init» после инициализации темы, фильтров, состояния прокрутки и магического номера торгового помощника. Вызов осуществляется через функцию News_DbOpen из модуля базы данных. Если инициализация завершится неудачно, она будет прервана — без рабочей базы данных ни тестеру, ни режиму реального времени будет неоткуда читать данные и некуда их записывать. Мы записываем в журнал чёткое сообщение и возвращаем значение false, чтобы внешний обработчик инициализации программы мог прервать работу с кодом ошибки INIT_FAILED.
После открытия базы данных мы выбираем ветку тестера или режима реального времени с помощью функции MQLInfoInteger и флага MQL_TESTER. В режиме тестера база данных является нашим единственным достоверным источником данных, поэтому мы вызываем функцию News_DbLoadEventsForWindow, передавая даты начала и окончания бэктеста. Если события не возвращаются, мы выводим чётко оформленный блок инструкций, в котором пользователю предлагается переключиться на график в реальном времени, включить флаг загрузки и запустить тест заново — без этих указаний пустой бэктест молча не открыл бы ни одной сделки, и пользователь не понял бы почему.
Когда события всё же возвращаются, мы сообщаем, что именно было загружено. Мы открываем файл базы данных с помощью функции FileOpen с флагами совместного чтения — соединение с базой данных по-прежнему активно, поэтому нам необходимо указать, что мы выполняем параллельное чтение. Размер файла получается с помощью функции FileSize, а небольшой блок форматирования формирует удобную для восприятия строку с указанием размера в байтах, килобайтах или мегабайтах в зависимости от величины. Строка журнала объединяет количество загруженных событий и размер файла, предоставляя пользователю одну строку, которая подтверждает, что размер базы данных соответствует объёму данных, которые она должна содержать. Затем мы вызываем функцию News_ApplyFilters, чтобы сформировать набор данных для отображения на основе загруженных данных.
В режиме реального времени порядок действий другой. Сначала мы вызываем функцию News_DbPruneOldEvents с семидневным периодом хранения, чтобы удалить устаревшие строки, а затем вызываем функцию News_DbLoadTriggered для восстановления набора для предотвращения повторных сделок за последние двадцать четыре часа торговой истории. Когда пользователь включил флаг загрузки, мы вызываем функцию News_DbDownloadAndSave с входными параметрами диапазона загрузки; она извлекает и сохраняет исторические данные, одновременно показывая пользователю холст с индикатором выполнения. Наконец, независимо от того, запускалась ли загрузка, мы извлекаем текущее временное окно режима реального времени с помощью функции News_LoadEventsFromLive, используя настроенные интервалы времени в прошлом и будущем, а затем применяем фильтры.
Затем мы добавляем вызов закрытия базы данных в точку входа обработчика события «News_Deinit» сразу после скрытия панели мониторинга и перед уничтожением внеэкранных холстов. Вызов функции News_DbClose в этом месте гарантирует, что SQLite запишет все отложенные изменения и снимет блокировку файла до выгрузки программы. Остальная часть последовательности деинициализации остаётся без изменений — черновой холст, холст заливки и холст рамки поочерёдно уничтожаются, поэтому после завершения работы не остаётся утечек памяти или объектов графика. Наконец, в обработчике события тика мы упрощаем логику до единого пути обновления для обоих режимов, как показано ниже.
Единое обновление по тикам для обоих режимов
Обработчик тиков в предыдущей части поддерживал два отдельных пути обновления — один для режима реального времени с периодичностью 30 секунд и один для тестера с периодичностью 1 секунда. Благодаря новой структуре базы данных такое разделение больше не требуется, поэтому мы объединяем оба пути в один.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void News_OnTick() { //--- Skip processing when dashboard is hidden if(!g_news_dashboardVisible) return; //--- Refresh live events and DB every 30 seconds static datetime lastRefresh = 0; const datetime now = TimeCurrent(); if(now - lastRefresh >= 30) { //--- Snapshot displayable state before refresh to detect changes const int preCount = ArraySize(g_news_displayableEvents); const datetime preStamp = g_news_lastEventStamp; News_RefreshEvents(); //--- Redraw only if event list actually changed const int postCount = ArraySize(g_news_displayableEvents); const datetime postStamp = g_news_lastEventStamp; if(postCount != preCount || postStamp != preStamp) { News_RenderAll(); ChartRedraw(); } lastRefresh = now; } //--- Check whether a news trade should be placed News_CheckForNewsTrade(); }
Здесь мы переписываем точку входа обработчика события «News_OnTick», чтобы оба режима использовали одну и ту же периодичность обновления. После проверки видимости статическая метка времени последнего обновления отслеживает, когда должно произойти следующее обновление. 30-секундный интервал подходит для режима реального времени, поскольку API календаря редко изменяется в пределах одного временного окна, а также подходит для режима тестера, поскольку база данных считывается один раз при запуске, а отображаемый набор изменяется только при переключении фильтров.
Внутри блока обновления мы фиксируем текущее количество событий и сигнатуру отслеживания изменений перед вызовом функции «News_RefreshEvents». После выполнения обновления мы сравниваем количество событий и сигнатуру после обновления со снимком. Если один из этих параметров отличается, мы запускаем полную отрисовку холста с помощью функции «News_RenderAll» и выводим результат на график с помощью функции ChartRedraw. Пропуск рендеринга, когда ничего не изменилось, позволяет избежать затрат на перерисовку неизменённого контента тридцать раз в час. Затем функция «News_CheckForNewsTrade» запускается в конце каждого тика независимо от обновления: решения о времени совершения сделок должны срабатывать на каждом тике, а не с медленной периодичностью обновления. После этого база данных полностью интегрирована. Остаётся только протестировать программу, и этому посвящён следующий раздел.
Визуализация
Мы компилируем программу и подключаем её к графику в реальном времени с включённым флагом загрузки и диапазоном, охватывающим период с января по май. Холст индикатора выполнения появляется в верхней части графика, его заливка расходится от центра по мере сохранения записей, затем он ненадолго задерживается на отметке 100 процентов и исчезает.

В ходе тестирования сеанс в режиме реального времени заполнял файл базы данных в общей папке, в то время как панель продолжала обновляться в обычном режиме. При последующем запуске тестера стратегий на чистом агенте события загружались напрямую из того же файла без перекомпиляции, а идентификаторы сработавших сделок из предыдущего сеанса в режиме реального времени восстанавливались при запуске, благодаря чему события, которые уже вызвали открытие сделок, оставались защищёнными от повторного открытия сделок даже после перезапуска.
Заключение
В заключение мы заменяем встроенный ресурс CSV на базу данных SQLite. Она сохраняет события календаря и идентификаторы сработавших сделок в одном файле, расположенном в общей папке терминала. База данных используется совместно режимом реального времени и тестером стратегий с помощью флага общей папки, поэтому одна загрузка заполняет одно и то же хранилище, из которого считывают данные оба режима. Режим реального времени выполняет upsert (вставку или обновление) каждого события, полученного из API календаря, в рамках транзакции, загружает исторические диапазоны по запросу, когда пользователь включает входной флаг, и отображает холст с индикатором выполнения во время операции. Теперь предотвращение повторных сделок сохраняется даже после перезапуска программы, поскольку при запуске идентификаторы сработавших сделок загружаются из истории базы данных за последние двадцать четыре часа. Тестер считывает события из базы данных, а при отсутствии данных для запрошенного временного окна выводит в журнал понятное сообщение с подсказкой для пользователя. Прочитав эту статью, вы сможете:
- Открывать общую базу данных SQLite из общей папки терминала, создавать индексированные таблицы и использовать шаблон upsert (вставка или обновление), чтобы сохранять по одной строке для каждого уникального идентификатора значения календаря
- Загружать исторический диапазон календаря в базу данных в рамках одной транзакции, отображая на холсте индикатор выполнения с процентом и количеством записей во время операции
- Сохранять идентификаторы сработавших сделок при перезапусках программы, чтобы новостные события, вызвавшие открытие сделок, оставались защищёнными от повторного открытия сделок после перезагрузок и повторных подключений советника.
Вложения
| № | Имя | Тип | Описание |
|---|---|---|---|
| 1 | MQL5 News Calendar EA PART 12.mq5 | Советник | Главная точка входа программы: объявляет пользовательские входные параметры и группу настроек загрузки данных, подключает заголовочные файлы пяти модулей в порядке зависимостей и передаёт каждое событие соответствующей функции модуля |
| 2 | News Core.mqh | Подключаемый файл | Определяет константы компоновки, размеры шрифтов, коды глифов, палитру двух тем, структуры событий и планов строк, структуру состояния прокрутки, вспомогательный класс для графических прмитивов, а также утилиты форматирования значений и времени |
| 3 | News Database.mqh | Подключаемый файл | Управляет жизненным циклом базы данных SQLite — открывает общий файл с флагом общей папки, создаёт таблицы событий и сработавших сделок, выполняет upsert (вставку или обновление) строк событий, загружает события по временному окну, удаляет устаревшие строки, сохраняет и восстанавливает идентификаторы сработавших сделок, а также загружает историю календаря с помощью холста с индикатором выполнения |
| 4 | News Interact.mqh | Подключаемый файл | Направляет события мыши, колеса мыши и графика через преобразование координат, проверку попадания на уровне пикселей, диспетчеризацию действий, обработку перетаскивания панели и изменения размера перетаскиванием края, а также управляет жизненным циклом программы, включая открытие и закрытие базы данных |
| 5 | News Logic.mqh | Подключаемый файл | Загружает события из API календаря в режиме реального времени и сохраняет каждое из них в базе данных в рамках транзакции, применяет фильтры по валюте, важности и времени, формирует план строк со сворачиваемыми группами дней, а также выполняет поиск кандидатов на сделку и исполнение ордеров с предотвращением повторных сделок на основе базы данных |
| 6 | News Render.mqh | Подключаемый файл | Отрисовывает всю панель управления — заголовок, переключатели фильтров, чипы валюты и важности, заголовок таблицы с наложением разделителя, строки событий с маркерами пересмотра и ячейками «Осталось», полосу прокрутки, баннер обратного отсчета и toast-уведомление |
Поддержка проекта ведется в Algo Forge: https://forge.mql5.io/29210372/Article-22608-News-Calendar-SQLite-Persistence-Layer
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22608
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Вау, это же очень подробно. Отличная работа, gee! 👍