Практическое применение баз данных для анализа рынков

Alexander | 20 апреля, 2010

Введение

Работа с данными стала главной задачей современного программного обеспечения, как автономных, так и сетевых прикладных программ. Для ее решения было создано специализированное программное обеспечение - системы управления базами данных (СУБД), которые позволяют структурировать, систематизировать и организовывать данные для их компьютерного хранения и обработки. Они составляют фундамент информационной деятельности во всех сферах - начиная с производства и заканчивая финансами и телекоммуникациями. 

Что касается трейдинга, то основная масса аналитиков не прибегает к использованию баз данных (БД) в своей работе. Но бывают задачи, где такое решение пришлось бы кстати. 

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

Алгоритм BuySellVolume 

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

     Δt = t2 – t1      (секунд)

     Δp = p2 – p1    (пунктов)

Значение объема вычисляется по формуле:

     v2 = Δp / Δt

Получается, наш объем прямо пропорционален количеству пунктов, на которое переместилась цена и обратно пропорционален времени, за которое она это сделала. Если Δt = 0, то вместо него берется значение 0.5. Таким образом, мы получаем в некотором роде значение активности продавцов и покупателей на рынке. 

1. Реализация индикатора без использования базы данных

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

Для начала создадим файл BsvEngine.mqh и подключим классы данных AdoSuite:

#include <Ado\Data.mqh>

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

Листинг 1.1

//+------------------------------------------------------------------+
// Класс движка индикатора BuySellVolume (без сохранения в базу)
//+------------------------------------------------------------------+
class CBsvEngine
  {
private:
   MqlTick           TickBuffer[];     // буфер тиков
   double            VolumeBuffer[];   // буфер объемов 
   int               TicksInBuffer;    // количество тиков в буфере

   bool              DbAvailable;      // указывает, возможно ли работать с базой данных     

   long              FindIndexByTime(const datetime &time[],datetime barTime,long left,long right);

protected:

   virtual string DbConnectionString() { return NULL; }
   virtual bool DbCheckAvailable() { return false; }
   virtual CAdoTable *DbLoadData(const datetime startTime,const datetime endTime) { return NULL; }
   virtual void DbSaveData(CAdoTable *table) { return; }

public:
                     CBsvEngine();

   void              Init();
   void              ProcessTick(double &buyBuffer[],double &sellBuffer[]);
   void              LoadData(const datetime startTime,const datetime &time[],double &buyBuffer[],double &sellBuffer[]);
   void              SaveData();
  };

Хочу обратить внимание, что для увеличения производительности решения данные помещаются в специальные буфера (TickBuffer и VolumeBuffer), а затем через определенный интервал времени выгружаются в базу.

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

Листинг 1.2

//+------------------------------------------------------------------+
// Конструктор класса
//+------------------------------------------------------------------+
CBsvEngine::CBsvEngine(void)
  {
// изначально можно поместить до 500 тиков в буффер
   ArrayResize(TickBuffer,500);
   ArrayResize(VolumeBuffer,500);
   TicksInBuffer=0;
   DbAvailable=false;
  }

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

Далее представлена реализация метода Init():

 Листинг 1.3

//+------------------------------------------------------------------+
// Функция, вызываемая в событии OnInit
//+------------------------------------------------------------------+
CBsvEngine::Init(void)
  {
   DbAvailable=DbCheckAvailable();
   if(!DbAvailable)
      Alert("Невозможно осуществить работу с базой данных. Работаем в автономном режиме");
  }

Здесь производится проверка, имеется ли возможность работать с базой данных. В базовом классе DbCheckAvailable() всегда возвращает false, так как работа с БД будет осуществляться только из производных классов. Думаю, вы уже заметили, что функции DbConnectionString(), DbCheckAvailable(), DbLoadData(), DbSaveData() не несут особой смысловой нагрузки. Это как раз те функции, которые мы переопределим в потомках для привязки к конкретной БД. 

На листинге 1.4 показана реализация функции ProcessTick(), которая вызывается по приходу нового тика, заносит тик в буфер, и рассчитывает значения для нашего индикатора. Для этого в функцию передаются 2 индикаторных буфера: один служит для хранения активности покупателей, другой – для активности продавцов. 

 Листинг 1.4

//+------------------------------------------------------------------+
// Обрабатывает пришедший тик и обновляет данные индикатора
//+------------------------------------------------------------------+
CBsvEngine::ProcessTick(double &buyBuffer[],double &sellBuffer[])
  {
// если отведенного буфера под тики недостаточно, то увеличим его
   int bufSize=ArraySize(TickBuffer);
   if(TicksInBuffer>=bufSize)
     {
      ArrayResize(TickBuffer,bufSize+500);
      ArrayResize(VolumeBuffer,bufSize+500);
     }

// получаем последний тик и записываем его в буфер
   SymbolInfoTick(Symbol(),TickBuffer[TicksInBuffer]);

   if(TicksInBuffer>0)
     {
      // вычисляем разницу во времени
      int span=(int)(TickBuffer[TicksInBuffer].time-TickBuffer[TicksInBuffer-1].time);
      // вычисляем разницу в цене
      int diff=(int)MathRound((TickBuffer[TicksInBuffer].bid-TickBuffer[TicksInBuffer-1].bid)*MathPow(10,_Digits));

      // вычисляем объем. Если тик пришел в ту же секунду, что и предыдущий, то считаем время равным 0.5 секунды
      VolumeBuffer[TicksInBuffer]=span>0 ?(double)diff/(double)span :(double)diff/0.5;

      // заполняем буфера индикатора данными
      int index=ArraySize(buyBuffer)-1;
      if(diff>0) buyBuffer[index]+=VolumeBuffer[TicksInBuffer];
      else sellBuffer[index]+=VolumeBuffer[TicksInBuffer];
     }

   TicksInBuffer++;
  }

Функция LoadData() загружает данные из базы для текущего таймфрейма за указанный период времени.

 Листинг 1.5

//+------------------------------------------------------------------+
// Загружает исторические данные из базы
//+------------------------------------------------------------------+
CBsvEngine::LoadData(const datetime startTime,const datetime &time[],double &buyBuffer[],double &sellBuffer[])
  {
// если база данных недоступна, то никак не загрузим данные
   if(!DbAvailable) return;

// получаем данные из базы
   CAdoTable *table=DbLoadData(startTime,TimeCurrent());

   if(CheckPointer(table)==POINTER_INVALID) return;

// заполняем буфера полученными данными
   for(int i=0; i<table.Records().Total(); i++)
     {
      // получаем запись с данными
      CAdoRecord *row=table.Records().GetRecord(i);

      // получаем индекс соответствующего бара
      MqlDateTime mdt;
      mdt=row.GetValue(0).ToDatetime();
      long index=FindIndexByTime(time,StructToTime(mdt));

      // заполняем буфера данными
      if(index!=-1)
        {
         buyBuffer[index]+=row.GetValue(1).ToDouble();
         sellBuffer[index]+=row.GetValue(2).ToDouble();
        }
     }
   delete table;
  }

LoadData() вызывает функцию DbLoadData(), которая должна быть переопределена в наследниках и возвратить таблицу с тремя столбцами – временем бара, значением буфера покупателей и значением буфера продавцов.

Еще одна используемая здесь функция - это FindIndexByTime(). На момент написания статьи в стандартной библиотеке не нашел функции двоичного поиска для таймсерий, поэтому написал свою. 

И, наконец, функция сохранения данных SaveData(): 

Листинг 1.6 

//+------------------------------------------------------------------+
// Сохраняет данные из буферов TickBuffer и VolumeBuffer в базу
//+------------------------------------------------------------------+
CBsvEngine::SaveData(void)
  {
   if(DbAvailable)
     {
      // формируем таблицу для передачи в SaveDataToDb
      CAdoTable *table=new CAdoTable();
      table.Columns().AddColumn("Time", ADOTYPE_DATETIME);
      table.Columns().AddColumn("Price", ADOTYPE_DOUBLE);
      table.Columns().AddColumn("Volume", ADOTYPE_DOUBLE);

      // заполняем таблицу данными из буферов
      for(int i=1; i<TicksInBuffer; i++)
        {
         CAdoRecord *row=table.CreateRecord();
         row.Values().GetValue(0).SetValue(TickBuffer[i].time);
         row.Values().GetValue(1).SetValue(TickBuffer[i].bid);
         row.Values().GetValue(2).SetValue(VolumeBuffer[i]);

         table.Records().Add(row);
        }

      // сохраняем данные в базу
      DbSaveData(table);

      if(CheckPointer(table)!=POINTER_INVALID)
         delete table;
     }

// записываем последний тик в начало, чтобы было с чем сравнивать
   TickBuffer[0] = TickBuffer[TicksInBuffer - 1];
   TicksInBuffer = 1;
  }

Как видим, в методе формируется таблица с необходимой для индикатора информацией и передается в DbSaveData(), которая и должна сохранить эти данные в базу. После записи мы просто обнуляем буфер.

Ну вот, наш каркас готов - теперь давайте посмотрим на листинге 1.7, как выглядит сам индикатор BuySellVolume.mq5:

Листинг 1.7

// подключаем файл с классом индикатора
#include "BsvEngine.mqh"

//+------------------------------------------------------------------+
//| свойства индикатора
//+------------------------------------------------------------------+
#property indicator_separate_window

#property indicator_buffers 2
#property indicator_plots   2

#property indicator_type1   DRAW_HISTOGRAM
#property indicator_color1  Red
#property indicator_width1  2

#property indicator_type2   DRAW_HISTOGRAM
#property indicator_color2  SteelBlue
#property indicator_width2  2

//+------------------------------------------------------------------+
//| буфера данных
//+------------------------------------------------------------------+
double ExtBuyBuffer[];
double ExtSellBuffer[];

//+------------------------------------------------------------------+
//| переменные
//+------------------------------------------------------------------+
// объявляем класс индикатора
CBsvEngine bsv;
//+------------------------------------------------------------------+
//| OnInit
//+------------------------------------------------------------------+
int OnInit()
  {
// устанавливаем свойства индикатора
   IndicatorSetString(INDICATOR_SHORTNAME,"BuySellVolume");
   IndicatorSetInteger(INDICATOR_DIGITS,2);
// буфер для buy
   SetIndexBuffer(0,ExtBuyBuffer,INDICATOR_DATA);
   PlotIndexSetString(0,PLOT_LABEL,"Buy");
// буфер для sell
   SetIndexBuffer(1,ExtSellBuffer,INDICATOR_DATA);
   PlotIndexSetString(1,PLOT_LABEL,"Sell");

// устанавливаем таймер для очищения буферов с тиками
   EventSetTimer(60);

   return(0);
  }
//+------------------------------------------------------------------+
//| OnDeinit
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
  }
//+------------------------------------------------------------------+
//| OnCalculate
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
// обрабатываем пришедший тик  
   bsv.ProcessTick(ExtBuyBuffer,ExtSellBuffer);

   return(rates_total);
  }
//+------------------------------------------------------------------+
//| OnTimer
//+------------------------------------------------------------------+
void OnTimer()
  {
// сохраним данные
   bsv.SaveData();
  }

Проще некуда, по-моему. В индикаторе вызываются только две функции из написанного класса ProcessTick() и SaveData(): в ProcessTick() производятся расчеты, а функция SaveData(), хоть и не производит сохранение данных, необходима для обнуления буфера с тиками.

Попробуем скомпилировать и "вуа-ля" – индикатор начал показывать значения:

BuySellVolume без сохранения в базу 

 Рис 1. Индикатор BuySellVolume без привязки к базе данных на GBPUSD M1

Отлично! Тики тикают, индикатор считает. Достоинство такого решения – это то, что для работы индикатора нужен лишь сам индикатор (ex5), и ничего более. Вместе с тем, при смене таймфрейма, или инструмента, или при закрытии терминала, данные безвозвратно теряются. Чтобы этого избежать, давайте посмотрим, как можно добавить в наш индикатор сохранение и загрузку данных.

2. Привязываемся к SQL Server 2008

В данный момент у меня на компьютере установлены две СУБД - SQL Server 2008 и Db2 9.7. Из двух я выбрал SQL Server из расчета, что больше читателей скорее знакомы с SQL Server, чем с Db2.

Для начала, создадим новую базу данных BuySellVolume для SQL Server 2008 (через SQL Server Management Studio или любым другим доступным способом) и новый файл BsvMsSql.mqh, в который подключим файл с базовым классом CBsvEngine:

#include "BsvEngine.mqh"

SQL Server снабжен OLE DB драйвером, поэтому работу с ним можно осуществить через OleDb провайдер, входящий в библиотеку AdoSuite. Для этого подключим необходимые классы:

#include <Ado\Providers\OleDb.mqh>

И, собственно, создадим производный класс:

Листинг 2.1 

//+------------------------------------------------------------------+
// Класс индикатора BuySellVolume, прикрученный к базе MsSql
//+------------------------------------------------------------------+
class CBsvSqlServer : public CBsvEngine
  {
protected:

   virtual string    DbConnectionString();
   virtual bool      DbCheckAvailable();
   virtual CAdoTable *DbLoadData(const datetime startTime,const datetime endTime);
   virtual void      DbSaveData(CAdoTable *table);

  };

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

Начнем по-порядку. Метод DbConnectionString() возвращает строку подключения к базе данных.

В моем случае он выглядит следующим образом:

Листинг 2.2  

//+------------------------------------------------------------------+
// Возвращает строку соединения с базой
//+------------------------------------------------------------------+
string CBsvSqlServer::DbConnectionString(void)
  {
   return "Provider=SQLOLEDB;Server=.\SQLEXPRESS;Database=BuySellVolume;Trusted_Connection=yes;";
  }

Из строки подключения видим, что работаем через OLE-DB драйвер MS SQL, с сервером SQLEXPRESS, находящимся на локальной машине. Подключаемся к базе BuySellVolume, используя аутентификацию Windows (другой вариант - явно вводить логин, пароль).

Следующим шагом будет реализация функции DbCheckAvailable(). Но прежде давайте разберемся, что же в действительности должна делать функция?

Было сказано, что она проверяет возможность работы с базой данных. В какой-то степени это правда. На самом деле основное её предназначение - проверить существует ли таблица для хранения данных для текущего инструмента, и если нет, то создать её. И если при выполнении этих действий возникнет ошибка, она возвратит false, что будет означать, что запись и чтение данных индикатора из таблицы будут игнорироваться, и индикатор будет работать по аналогии с тем, который мы уже реализовали (см. листинг 1.7).

Работать с данными предлагаю через хранимые процедуры (ХП) SQL Server'a. Почему именно через них? Просто захотелось. Это дело вкуса конечно, но я считаю использование ХП более элегантным решением, чем писать запросы в коде (которые к тому же потребуют времени для компиляции, хотя к данному случаю это не относится, так как будут использованы динамические запросы :)

Для  DbCheckAvailable() хранимка имеет вид:

Листинг 2.3   

CREATE PROCEDURE [dbo].[CheckAvailable]
        @symbol NVARCHAR(30)
AS
BEGIN
        SET NOCOUNT ON;
        
        -- Ecли таблицы инструмента нет, то создадим
        IF OBJECT_ID(@symbol, N'U') IS NULL
        EXEC ('
                -- Создадим таблицу для инструмента
                CREATE TABLE ' + @symbol + ' (Time DATETIME NOT NULL,
                        Price REAL NOT NULL, 
                        Volume REAL NOT NULL);
                
                -- Создадим индекс для времени тика
                CREATE INDEX Ind' + @symbol + '
                ON  ' + @symbol + '(Time);
        ');
END

Мы видим, что если нужная таблица отсутствует в базе, то формируется и выполняется динамический запрос (в виде строки), который и создает таблицу. Когда хранимая процедура создана – самое время заняться функцией DbCheckAvailable():

 Листинг 2.4

//+------------------------------------------------------------------+
// Выполняет проверку имеется ли возможность подключиться к бд
//+------------------------------------------------------------------+
bool CBsvSqlServer::DbCheckAvailable(void)
  {
// работаем с ms sql через Oledb провайдер
   COleDbConnection *conn=new COleDbConnection();
   conn.ConnectionString(DbConnectionString());

// используем хранимую процедуру для создания таблицы
   COleDbCommand *cmd=new COleDbCommand();
   cmd.CommandText("CheckAvailable");
   cmd.CommandType(CMDTYPE_STOREDPROCEDURE);
   cmd.Connection(conn);

// передаем параметры в хранимую процедуру  
   CAdoValue *vSymbol=new CAdoValue();
   vSymbol.SetValue(Symbol());
   cmd.Parameters().Add("@symbol",vSymbol);

   conn.Open();

// выполняем хранимую процедуру
   cmd.ExecuteNonQuery();

   conn.Close();

   delete cmd;
   delete conn;

   if(CheckAdoError())
     {
      ResetAdoError();
      return false;
     }

   return true;
  }

Как видим, у нас имеется возможность работать с хранимыми процедурами сервера - нужно просто установить у команды свойство CommandType в CMDTYPE_STOREDPROCEDURE, затем передать нужные параметры и можно выполнять. Как и было задумано, в случае ошибки функция DbCheckAvailable вернет false. 

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

 Листинг 2.5 

СREATE PROCEDURE [dbo].[LoadData]
        @symbol NVARCHAR(30),   -- инструмент
        @startTime DATETIME,    -- начало расчета
        @endTime DATETIME,      -- конец расчета
        @period INT             -- период графика (в минутах)
AS
BEGIN
        SET NOCOUNT ON;
        
        -- конвертируем входные параметры в строки для передачи в динамический запрос
        DECLARE @sTime NVARCHAR(20) = CONVERT(NVARCHAR, @startTime, 112) + ' ' + CONVERT(NVARCHAR, @startTime, 114),
                @eTime NVARCHAR(20) = CONVERT(NVARCHAR, @endTime, 112) + ' ' + CONVERT(NVARCHAR, @endTime, 114),
                @p NVARCHAR(10) = CONVERT(NVARCHAR, @period);
                
        EXEC('        
                SELECT DATEADD(minute, Bar * ' + @p + ', ''' + @sTime + ''') AS BarTime, 
                        SUM(CASE WHEN Volume > 0 THEN Volume ELSE 0 END) as Buy,
                        SUM(CASE WHEN Volume < 0 THEN Volume ELSE 0 END) as Sell 
                FROM 
                (
                        SELECT DATEDIFF(minute, ''' + @sTime + ''', TIME) / ' + @p + ' AS Bar,
                                Volume 
                        FROM ' + @symbol + '
                        WHERE Time >= ''' + @sTime + ''' AND Time <= ''' + @eTime + '''
                ) x 
                GROUP BY Bar 
                ORDER BY 1;
        ');
END

Единственное, что нужно отметить – в качестве @startTime следует передавать время открытия первого заполняемого бара, иначе получим смещение.

Рассмотрим реализацию  DbLoadData() из следующего листинга:

Листинг 2.6  

//+------------------------------------------------------------------+
// Загружает данные из базы данных
//+------------------------------------------------------------------+
CAdoTable *CBsvSqlServer::DbLoadData(const datetime startTime,const datetime endTime)
  {
// работаем с ms sql через Oledb провайдер
   COleDbConnection *conn=new COleDbConnection();
   conn.ConnectionString(DbConnectionString());

// используем хранимую процедуру для расчета данных  
   COleDbCommand *cmd=new COleDbCommand();
   cmd.CommandText("LoadData");
   cmd.CommandType(CMDTYPE_STOREDPROCEDURE);
   cmd.Connection(conn);

// передаем параметры в хранимую процедуру  
   CAdoValue *vSymbol=new CAdoValue();
   vSymbol.SetValue(Symbol());
   cmd.Parameters().Add("@symbol",vSymbol);

   CAdoValue *vStartTime=new CAdoValue();
   vStartTime.SetValue(startTime);
   cmd.Parameters().Add("@startTime",vStartTime);

   CAdoValue *vEndTime=new CAdoValue();
   vEndTime.SetValue(endTime);
   cmd.Parameters().Add("@endTime",vEndTime);

   CAdoValue *vPeriod=new CAdoValue();
   vPeriod.SetValue(PeriodSeconds()/60);
   cmd.Parameters().Add("@period",vPeriod);

   COleDbDataAdapter *adapter=new COleDbDataAdapter();
   adapter.SelectCommand(cmd);

// создаем таблицу и заполняем ее данными, которые возвратила хранимка
   CAdoTable *table=new CAdoTable();
   adapter.Fill(table);

   delete adapter;
   delete conn;

   if(CheckAdoError())
     {
      delete table;
      ResetAdoError();
      return NULL;
     }

   return table;
  }

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

 И завершающим шагом будет реализация функционала для DbSaveData():

 Листинг 2.7 

CREATE PROCEDURE [dbo].[SaveData]
        @symbol NVARCHAR(30),
        @ticks NVARCHAR(MAX)
AS
BEGIN
        EXEC('
                DECLARE @xmlId INT,
                        @xmlTicks XML = ''' + @ticks + ''';

                EXEC sp_xml_preparedocument 
                        @xmlId OUTPUT, 
                        @xmlTicks;
                
                -- считываем данные из xml в таблицу
                INSERT INTO ' + @symbol + '
                SELECT *
                FROM OPENXML( @xmlId, N''*/*'', 0)
                WITH
                (
                        Time DATETIME N''Time'', 
                        Price REAL N''Price'',
                        Volume REAL N''Volume'' 
                );

                EXEC sp_xml_removedocument @xmlId;
        ');
END

Хочу обратить ваше внимание, что в качестве параметра @ticks в процедуру должен передаваться xml с данными о сохраняемых тиках. Такое решение было принято из соображений производительности – проще вызвать процедуру 1 раз и передать туда 20 тиков, чем вызвать 20 раз, передавая по тику. Давайте посмотрим, как же должна формироваться строка с xml из следующего листинга:

Листинг 2.8

//+------------------------------------------------------------------+
// Сохраняет данные в базу данных
//+------------------------------------------------------------------+
CBsvSqlServer::DbSaveData(CAdoTable *table)
  {
// если нечего записывать, то вернемся
   if(table.Records().Total()==0) return;

// формируем xml с данными для передачи в хранимую процедуру
   string xml;
   StringAdd(xml,"<Ticks>");

   for(int i=0; i<table.Records().Total(); i++)
     {
      CAdoRecord *row=table.Records().GetRecord(i);

      StringAdd(xml,"<Tick>");

      StringAdd(xml,"<Time>");
      MqlDateTime mdt;
      mdt=row.GetValue(0).ToDatetime();
      StringAdd(xml,StringFormat("%04u%02u%02u %02u:%02u:%02u",mdt.year,mdt.mon,mdt.day,mdt.hour,mdt.min,mdt.sec));
      StringAdd(xml,"</Time>");

      StringAdd(xml,"<Price>");
      StringAdd(xml,DoubleToString(row.GetValue(1).ToDouble()));
      StringAdd(xml,"</Price>");

      StringAdd(xml,"<Volume>");
      StringAdd(xml,DoubleToString(row.GetValue(2).ToDouble()));
      StringAdd(xml,"</Volume>");

      StringAdd(xml,"</Tick>");
     }

   StringAdd(xml,"</Ticks>");

// работаем с ms sql через Oledb провайдер
   COleDbConnection *conn=new COleDbConnection();
   conn.ConnectionString(DbConnectionString());

// используем хранимую процедуру для записи данных
   COleDbCommand *cmd=new COleDbCommand();
   cmd.CommandText("SaveData");
   cmd.CommandType(CMDTYPE_STOREDPROCEDURE);
   cmd.Connection(conn);

   CAdoValue *vSymbol=new CAdoValue();
   vSymbol.SetValue(Symbol());
   cmd.Parameters().Add("@symbol",vSymbol);

   CAdoValue *vTicks=new CAdoValue();
   vTicks.SetValue(xml);
   cmd.Parameters().Add("@ticks",vTicks);

   conn.Open();

// выполняем хранимую процедуру
   cmd.ExecuteNonQuery();

   conn.Close();

   delete cmd;
   delete conn;

   ResetAdoError();
  }

Добрую половину функции как раз и занимает формирование этой самой строки с xml. Далее эта строка попадает в хранимку и там уже парсится.

На этом реализация взаимодействия с SQL Server 2008 закончена, и можно заняться реализацией индикатора BuySellVolume SqlServer.mq5.

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

 Листинг 2.9

// подключаем файл с классом индикатора
#include "BsvSqlServer.mqh"

//+------------------------------------------------------------------+
//| свойства индикатора
//+------------------------------------------------------------------+
#property indicator_separate_window

#property indicator_buffers 2
#property indicator_plots   2

#property indicator_type1   DRAW_HISTOGRAM
#property indicator_color1  Red
#property indicator_width1  2

#property indicator_type2   DRAW_HISTOGRAM
#property indicator_color2  SteelBlue
#property indicator_width2  2

//+------------------------------------------------------------------+
//| входные параметры индикатора
//+------------------------------------------------------------------+
input datetime StartTime=D'2010.04.04';   // Начинать расчеты с

//+------------------------------------------------------------------+
//| буфера данных
//+------------------------------------------------------------------+
double ExtBuyBuffer[];
double ExtSellBuffer[];

//+------------------------------------------------------------------+
//| переменные
//+------------------------------------------------------------------+
// объявляем класс индикатора
CBsvSqlServer bsv;
//+------------------------------------------------------------------+
//| OnInit
//+------------------------------------------------------------------+
int OnInit()
  {
// устанавливаем свойства индикатора
   IndicatorSetString(INDICATOR_SHORTNAME,"BuySellVolume");
   IndicatorSetInteger(INDICATOR_DIGITS,2);
// буфер для buy
   SetIndexBuffer(0,ExtBuyBuffer,INDICATOR_DATA);
   PlotIndexSetString(0,PLOT_LABEL,"Buy");
// буфер для sell
   SetIndexBuffer(1,ExtSellBuffer,INDICATOR_DATA);
   PlotIndexSetString(1,PLOT_LABEL,"Sell");

// вызовем функцию Init класса индикатора
   bsv.Init();

// устанавливаем таймер для выгрузки тиков в базу
   EventSetTimer(60);

   return(0);
  }
//+------------------------------------------------------------------+
//| OnDeinit
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
// если остались несохраненные данные, то сохраним их
   bsv.SaveData();
  }
//+------------------------------------------------------------------+
//| OnCalculate
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
   if(prev_calculated==0)
     {
      // вычисляем время ближайшего бара
      datetime st[];
      CopyTime(Symbol(),Period(),StartTime,1,st);
      // загружаем данные
      bsv.LoadData(st[0],time,ExtBuyBuffer,ExtSellBuffer);
     }

// обрабатываем пришедший тик  
   bsv.ProcessTick(ExtBuyBuffer,ExtSellBuffer);

   return(rates_total);
  }
//+------------------------------------------------------------------+
//| OnTimer
//+------------------------------------------------------------------+
void OnTimer()
  {
// сохраним данные
   bsv.SaveData();
  }

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

Второе отличие - тип переменной bsv у нас поменялся на другой.

Третье отличие - была добавлена загрузка данных при первом расчете данных индикатора, а также функции Init() в OnInit() и SaveData() в OnDeinit().

Теперь попробуем скомпилировать индикатор и посмотреть результат: 

 BuySellVolume с сохранением данных в Sql Server

 Рис 2.  Индикатор BuySellVolume c привязкой к базе данных SQL Server 2008 на EURUSD M15

Готово! Теперь наши данные сохраняются, и мы можем свободно переключаться между таймфреймами.

3. Привязываемся к SQLite 3.6

«Из пушки по воробьям» - думаю, вы понимаете, о чем я. Для поставленной задачи развертывать SQL Server на машине слегка нелепо. Конечно, если у вас уже установлена эта СУБД, и вы ей активно пользуетесь, то это может быть предпочтительным вариантом. Но что если вы захотите дать индикатор кому-то, кто далек от всех этих технологий и хочет минимум движений, чтобы решение заработало?

Предлагаю вашему вниманию третий вариант индикатора, который, в отличии от предыдущего, работает с БД, имеющей файл-серверную архитектуру. При таком подходе в большинстве случаев нужно лишь таскать с собой пару dll с ядром СУБД.

Хотя я прежде не работал с SQLite, мой выбор пал на именно на нее из-за простоты, быстроты и легковесности. Изначально разработчик предоставляет лишь API для работы из программ, написанных на C++ и TCL, но в сети я также нашел ODBC драйвер и ADO.NET провайдер от сторонних разработчиков. Так как AdoSuite позволяет работать с источниками данных через ODBC, то, казалось бы, лучше скачать и установить драйвер ODBC. Но насколько я понял, поддержка оного прекращена более года назад, и к тому же ADO.NET теоретически должен работать быстрее.

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

К поставленной цели нас приведут два действия:

 Глобальный кеш сборок

  Рис 3. Проводник умеет отображать GAC (глобальный кеш сборок) в виде списка


Теперь, перетащим (!) System.Data.SQLite.dll в эту папку.

В результате сборка будет помещена в глобальный кеш сборок (GAC) и мы сможем работать с ней:

Сборка установлена в GAC

Рис 4.  System.Data.SQLite.dll установлена в GAC

На этом установка провайдера завершена.

Теперь – когда все готово - можно приступать к написанию самого индикатора. Для базы данных SQLite создадим новый пустой файл в папке MQL5\Files. SQLite не привередлива к расширению файла, поэтому назовем его просто – BuySellVolume.sqlite.

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

Создадим новый файл BsvSqlite.mqh, подключим наш базовый класс и написанный провайдер для SQLite: 

#include "BsvEngine.mqh"
#include <Ado\Providers\SQLite.mqh>

 Производный класс имеет такой же вид, как и предыдущий, за исключением имени:

  Листинг 3.1

//+------------------------------------------------------------------+
// Класс индикатора BuySellVolume, прикрученный к базе SQLite
//+------------------------------------------------------------------+
class CBsvSqlite : public CBsvEngine
  {
protected:

   virtual string    DbConnectionString();
   virtual bool      DbCheckAvailable();
   virtual CAdoTable *DbLoadData(const datetime startTime,const datetime endTime);
   virtual void      DbSaveData(CAdoTable *table);

  };

Теперь перейдем к реализации методов.

DbConnectionString() имеет вид:

  Листинг 3.2

//+------------------------------------------------------------------+
// Возвращает строку соединения с базой
//+------------------------------------------------------------------+
string CBsvSqlite::DbConnectionString(void)
  {
   return "Data Source=MQL5\Files\BuySellVolume.sqlite";
  }

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

Здесь указан относительный путь, но допускается также и полный: "Data Source=c:\Program Files\Metatrader 5\MQL5\Files\BuySellVolume.sqlite"

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

  Листинг 3.3 

//+------------------------------------------------------------------+
// Выполняет проверку имеется ли возможность подключиться к бд
//+------------------------------------------------------------------+
bool CBsvSqlite::DbCheckAvailable(void)
  {
// работаем с SQLite через написанный SQLite провайдер
   CSQLiteConnection *conn=new CSQLiteConnection();
   conn.ConnectionString(DbConnectionString());

// команда, проверяющая наличие таблицы для инструмента
   CSQLiteCommand *cmdCheck=new CSQLiteCommand();
   cmdCheck.Connection(conn);
   cmdCheck.CommandText(StringFormat("SELECT EXISTS(SELECT name FROM sqlite_master WHERE name = '%s')", Symbol()));

// команда, создающая таблицу по инструменту
   CSQLiteCommand *cmdTable=new CSQLiteCommand();
   cmdTable.Connection(conn);
   cmdTable.CommandText(StringFormat("CREATE TABLE %s(Time DATETIME NOT NULL, " +
                        "Price DOUBLE NOT NULL, "+
                        "Volume DOUBLE NOT NULL)",Symbol()));

// команда, создающая индекс для времени
   CSQLiteCommand *cmdIndex=new CSQLiteCommand();
   cmdIndex.Connection(conn);
   cmdIndex.CommandText(StringFormat("CREATE INDEX Ind%s ON %s(Time)", Symbol(), Symbol()));

   conn.Open();

   if(CheckAdoError())
     {
      ResetAdoError();
      delete cmdCheck;
      delete cmdTable;
      delete cmdIndex;
      delete conn;
      return false;
     }

   CSQLiteTransaction *tran=conn.BeginTransaction();

   CAdoValue *vExists=cmdCheck.ExecuteScalar();

// если таблицы нет, то создадим
   if(vExists.ToLong()==0)
     {
      cmdTable.ExecuteNonQuery();
      cmdIndex.ExecuteNonQuery();
     }

   if(!CheckAdoError()) tran.Commit();
   else tran.Rollback();

   conn.Close();

   delete vExists;
   delete cmdCheck;
   delete cmdTable;
   delete cmdIndex;
   delete tran;
   delete conn;

   if(CheckAdoError())
     {
      ResetAdoError();
      return false;
     }

   return true;
  }

Результат работы этой функции идентичен аналогичной для SQL Server. Единственное, что хотелось бы отметить - так это типы полей для таблицы. Самое забавное, что типы полей имеют для SQLite достаточно посредственное значение. Более того, типов DOUBLE и DATETIME там вообще не имеется (по крайней мере, они не входят в 4 стандартных). Все значения хранятся в строчном виде, а потом динамически преобразовываются к нужному типу.

Так какой же смысл тогда объявлять столбцы как DOUBLE и DATETIME? Не знаю тонкостей функционирования, но при выборке ADO.NET преобразует их к типам double и datetime автоматически. Но это не всегда так, существуют некоторые моменты, один из которых всплывет в следующем листинге.

Итак, давайте рассмотрим листинг следующей функции DbLoadData():

  Листинг 3.4

//+------------------------------------------------------------------+
// Загружает данные из базы данных
//+------------------------------------------------------------------+
CAdoTable *CBsvSqlite::DbLoadData(const datetime startTime,const datetime endTime)
  {
// работаем с SQLite через написанный SQLite провайдер
   CSQLiteConnection *conn=new CSQLiteConnection();
   conn.ConnectionString(DbConnectionString());

   CSQLiteCommand *cmd=new CSQLiteCommand();
   cmd.Connection(conn);
   cmd.CommandText(StringFormat(
                   "SELECT DATETIME(@startTime, '+' || CAST(Bar*@period AS TEXT) || ' minutes') AS BarTime, "+
                   "  SUM(CASE WHEN Volume > 0 THEN Volume ELSE 0 END) as Buy, "+
                   "  SUM(CASE WHEN Volume < 0 THEN Volume ELSE 0 END) as Sell "+
                   "FROM "+
                   "("+
                   "  SELECT CAST(strftime('%%s', julianday(Time)) - strftime('%%s', julianday(@startTime)) AS INTEGER)/ (60*@period) AS Bar, "+
                   "     Volume "+
                   "  FROM %s "+
                   "  WHERE Time >= @startTime AND Time <= @endTime "+
                   ") x "+
                   "GROUP BY Bar "+
                   "ORDER BY 1",Symbol()));

// подставляем параметры
   CAdoValue *vStartTime=new CAdoValue();
   vStartTime.SetValue(startTime);
   cmd.Parameters().Add("@startTime",vStartTime);

   CAdoValue *vEndTime=new CAdoValue();
   vEndTime.SetValue(endTime);
   cmd.Parameters().Add("@endTime",vEndTime);

   CAdoValue *vPeriod=new CAdoValue();
   vPeriod.SetValue(PeriodSeconds()/60);
   cmd.Parameters().Add("@period",vPeriod);

   CSQLiteDataAdapter *adapter=new CSQLiteDataAdapter();
   adapter.SelectCommand(cmd);

// создаем таблицу и заполняем ее данными
   CAdoTable *table=new CAdoTable();
   adapter.Fill(table);

   delete adapter;
   delete conn;

   if(CheckAdoError())
     {
      delete table;
      ResetAdoError();
      return NULL;
     }

// так как мы получаем строку с датой, а не саму дату, то необходимо переконвертировать
   for(int i=0; i<table.Records().Total(); i++)
     {
      CAdoRecord* row= table.Records().GetRecord(i);
      string strDate = row.GetValue(0).AnyToString();
      StringSetCharacter(strDate,4,'.');
      StringSetCharacter(strDate,7,'.');
      row.GetValue(0).SetValue(StringToTime(strDate));
     }

   return table;
  }

Данная функция работает так же, как и ее реализация для MS SQL. Но откуда взялся цикл в конце функции? Да, в этом чудо-запросе все мои попытки вернуть datetime были безуспешны. Отсутствие типа DATETIME в SQLite дало о себе знать - вместо даты возвращается строка в формате YYYY-MM-DD hh:mm:ss. Но ее можно легко привести к виду, понятному для функции StringToTime, чем мы и воспользовались.

Ну и, наконец, функция DbSaveData():

 Листинг 3.5

//+------------------------------------------------------------------+
// Сохраняет данные в базу данных
//+------------------------------------------------------------------+
CBsvSqlite::DbSaveData(CAdoTable *table)
  {
// если нечего записывать, то вернемся
   if(table.Records().Total()==0) return;

// работаем с SQLite через SQLite провайдер
   CSQLiteConnection *conn=new CSQLiteConnection();
   conn.ConnectionString(DbConnectionString());

// используем хранимую процедуру для записи данных
   CSQLiteCommand *cmd=new CSQLiteCommand();
   cmd.CommandText(StringFormat("INSERT INTO %s VALUES(@time, @price, @volume)", Symbol()));
   cmd.Connection(conn);

// добавляем параметры
   CSQLiteParameter *pTime=new CSQLiteParameter();
   pTime.ParameterName("@time");
   cmd.Parameters().Add(pTime);

   CSQLiteParameter *pPrice=new CSQLiteParameter();
   pPrice.ParameterName("@price");
   cmd.Parameters().Add(pPrice);

   CSQLiteParameter *pVolume=new CSQLiteParameter();
   pVolume.ParameterName("@volume");
   cmd.Parameters().Add(pVolume);

   conn.Open();

   if(CheckAdoError())
     {
      ResetAdoError();
      delete cmd;
      delete conn;
      return;
     }

// ! начинаем транзакцию явно
   CSQLiteTransaction *tran=conn.BeginTransaction();

   for(int i=0; i<table.Records().Total(); i++)
     {
      CAdoRecord *row=table.Records().GetRecord(i);

      // заполняем параметры значениями
      CAdoValue *vTime=new CAdoValue();
      MqlDateTime mdt;
      mdt=row.GetValue(0).ToDatetime();
      vTime.SetValue(mdt);
      pTime.Value(vTime);

      CAdoValue *vPrice=new CAdoValue();
      vPrice.SetValue(row.GetValue(1).ToDouble());
      pPrice.Value(vPrice);

      CAdoValue *vVolume=new CAdoValue();
      vVolume.SetValue(row.GetValue(2).ToDouble());
      pVolume.Value(vVolume);

      // добавляем запись
      cmd.ExecuteNonQuery();
     }

// завершаем транзакцию
   if(!CheckAdoError())
      tran.Commit();
   else tran.Rollback();

   conn.Close();

   delete tran;
   delete cmd;
   delete conn;

   ResetAdoError();
  }

Хочу немного остановиться на особенностях реализации этой функции. 

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

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

Итак, ближе к делу. Давайте взглянем на сам индикатор BuySellVolume SQLite.mq5:

 Листинг 3.6

// подключаем файл с классом индикатора
#include "BsvSqlite.mqh"

//+------------------------------------------------------------------+
//| свойства индикатора
//+------------------------------------------------------------------+
#property indicator_separate_window

#property indicator_buffers 2
#property indicator_plots   2

#property indicator_type1   DRAW_HISTOGRAM
#property indicator_color1  Red
#property indicator_width1  2

#property indicator_type2   DRAW_HISTOGRAM
#property indicator_color2  SteelBlue
#property indicator_width2  2

//+------------------------------------------------------------------+
//| входные параметры индикатора
//+------------------------------------------------------------------+
input datetime StartTime=D'2010.04.04';   // Начинать расчеты с

//+------------------------------------------------------------------+
//| буфера данных
//+------------------------------------------------------------------+
double ExtBuyBuffer[];
double ExtSellBuffer[];

//+------------------------------------------------------------------+
//| переменные
//+------------------------------------------------------------------+
// объявляем класс индикатора
CBsvSqlite bsv;
//+------------------------------------------------------------------+
//| OnInit
//+------------------------------------------------------------------+
int OnInit()
  {
// устанавливаем свойства индикатора
   IndicatorSetString(INDICATOR_SHORTNAME,"BuySellVolume");
   IndicatorSetInteger(INDICATOR_DIGITS,2);
// буфер для buy
   SetIndexBuffer(0,ExtBuyBuffer,INDICATOR_DATA);
   PlotIndexSetString(0,PLOT_LABEL,"Buy");
// буфер для sell
   SetIndexBuffer(1,ExtSellBuffer,INDICATOR_DATA);
   PlotIndexSetString(1,PLOT_LABEL,"Sell");

// вызовем функцию Init класса индикатора
   bsv.Init();

// устанавливаем таймер для выгрузки тиков в базу
   EventSetTimer(60);

   return(0);
  }
//+------------------------------------------------------------------+
//| OnDeinit
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
// если остались несохраненные данные, то сохраним их
   bsv.SaveData();
  }
//+------------------------------------------------------------------+
//| OnCalculate
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
   if(prev_calculated==0)
     {
      // вычисляем время ближайшего бара
      datetime st[];
      CopyTime(Symbol(),Period(),StartTime,1,st);
      // загружаем данные
      bsv.LoadData(st[0],time,ExtBuyBuffer,ExtSellBuffer);
     }

// обрабатываем пришедший тик  
   bsv.ProcessTick(ExtBuyBuffer,ExtSellBuffer);

   return(rates_total);
  }
//+------------------------------------------------------------------+
//| OnTimer
//+------------------------------------------------------------------+
void OnTimer()
  {
// сохраним данные
   bsv.SaveData();
  }

Поменялся только класс функции, остальной код остался неизменным.

На этом реализация третьей версии индикатора окончена - можно смотреть результат.

 BuySellVolume c привязкой к SQLite

  Рис 5. Индикатор BuySellVolume c привязкой к базе данных SQLite 3.6 на EURUSDM5

Кстати, в отличии от Sql Server'овского Management Studio у SQLite нет никаких стандартных утилит для работы с базами данных. Поэтому, чтобы не работать с "черным ящиком", можете скачать соответствующую утилиту от сторонних разработчиков. Лично мне пришлась по душе программа SQLiteMan - она проста в использовании и в то же время обладает всей необходимой функциональностью. Скачать можно отсюда: http://sourceforge.net/projects/sqliteman/ .

Заключение

Если вы добрались до этих строк, значит все уже позади ;) Признаюсь, сам не ожидал, что статья получится такой объемной. Поэтому возникновение вопросов, на которые непременно отвечу, неизбежно.

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

Полезен ли реализованный индикатор? Тоже решать вам. Как по мне - получился весьма интересный экземпляр.

На этом позвольте распрощаться. До новых встреч!

Описание содержимого архивов:

 № Имя файла Описание
1
 Sources.zip
 Содержит архив с исходными кодами всех индикаторов и библиотеки AdoSuite, его нужно распаковать в соответствующий каталог вашего терминала. Предназначение индикаторов: без использования БД (BuySellVolume.mq5), для работы с БД SQL Server 2008 (BuySellVolume SqlServer.mq5) и БД SQLite (BuySellVolume SQLite.mq5).
2
 BuySellVolume-DB-SqlServer.zip
 Архив БД SQL Server 2008*
3
 BuySellVolume-DB-SQLite.zip
 Архив БД SQLite*
4
 System.Data.SQLite.zip
 Архив с System.Data.SQLite.dll, необходимой для работы с БД SQLite
  5  Databases_MQL5_doc.zip  Архив с документацией по исходным кодам индикаторов и библиотеке AdoSuite

*Обе базы содержат данные тикового индикатора с 5 по 9 апреля включительно для инструментов:  AUDNZD, EURUSD, GBPUSD, USDCAD, USDCHF, USDJPY.