English Русский Deutsch 日本語
preview
基于协整股票的统计套利(第三部分):数据库搭建

基于协整股票的统计套利(第三部分):数据库搭建

MetaTrader 5 — 交易系统 |
59 0
Jocimar Lopes
Jocimar Lopes

引言

在本系列的前一篇文章(第二部分)中,我们对一套统计套利策略开展了回测;该策略由来自微处理器行业(纳斯达克市场个股)的一篮子协整股票构成。我们先在数百个股票代码中筛选出与英伟达相关性最高的股票。随后对筛选出的股票组合使用约翰森检验做协整检验,借助ADF检验与KPSS检验验证价差序列的平稳性;提取Johansen检验在第一协整秩(first rank)下对应的特征向量,以得到投资组合的相对权重。本次回测结果表现良好。

两项或多项资产可能在过去两年内一直保持协整,但是到明天这种关系也许就不复存在了。也就是说,无法保证一对或一组协整资产会一直维持协整特性。公司管理层变动、宏观经济环境变化或是行业层面的调整,都可能破坏原本支撑资产协整关系的基本面。反过来,原本毫无协整关系的资产,也可能因上述原因在下一刻建立起协整关系。市场是“一个持续变化的谜题”。我们必须应对这种变化。[PALOMAR, 2025]

一篮子协整股票的投资组合相对权重几乎处于持续变动状态。而组合权重不仅决定下单的交易量,还决定交易方向(做多还是做空)。因此,我们同样需要应对这类权重变动。尽管协整属于偏长期的关系,但投资组合权重时刻都在变化。所以我们需要更频繁地校验权重,一旦权重发生改变就立刻更新模型。当检测到模型已经失效时,我们必须马上采取行动,即时替换掉过时模型。

我们的智能交易系统(EA)必须实时感知当前正在使用的投资组合权重是否仍然有效,或是已经发生变动。若权重发生变化,EA需要尽快获取新的组合权重。同时,EA还需要判断模型本身是否依旧有效。如果模型失效,EA需要获知应当替换哪些资产,并尽快在当前使用的投资组合中完成资产轮换。

我们一直在使用MetaTrader 5 Python接口,以及statsmodels 库中成熟的统计函数。但在此之前,我们仅使用实时数据,按需下载报价(价格数据)。这种方式在探索阶段因实现简单而非常实用。但当我们需要进行投资组合轮换、更新模型或者调整组合权重时,就需要考虑数据持久化。也就是说,我们需要把数据存入数据库,因为每次用到数据都重新下载并不现实。除此之外,我们可能还要研究不同资产类别之间的关联,分析那些并未参与首轮协整检验的交易品种。

一套高质量、可扩展、附带丰富元数据的数据库,是任何严谨统计套利项目的核心。数据库设计本身具有很强的定制属性:合适的数据库必须匹配业务需求。基于这一点,本文将介绍一种面向统计套利场景的数据库搭建方案。


我们的数据库需要回答哪些问题?

我们正在搭建一套“平民版统计套利框架” —— 即适配普通个人交易者,仅使用消费级笔记本电脑与常规网络带宽的框架。在这个过程中,我们面临不少难题,主要源于个人在统计学、软件开发等专业领域的积累有限。数据库设计同样是必备专业能力之一。数据库设计本身就是一门庞大的学科,相关专著汗牛充栋,难以穷尽。理想方案是聘请专业人员(甚至多人团队)来设计、实现并维护数据库。

但这套统计套利框架面向普通个人交易者,所以我们只能依托现有条件:查阅专业书籍、技术论坛与专栏;向资深从业者学习;通过试错、实验不断摸索;敢于承担风险;一旦发现现有设计无法满足需求,随时做好修改架构的准备。我们需要保持架构灵活性,从小处着手,采用自下而上的开发思路,而非自上而下,避免过度设计。

归根结底,我们的数据库只需要回答一个非常简单的问题:当下应该交易什么,才能获得尽可能高的回报?

回顾本系列上一篇文章,当时我们依靠股票篮子的组合权重确定下单方向与交易量,对此,数据库可以给出类似这样的答案:

股票代码 权重 时间周期
 

"MU"、"NVDA"、"MPWR"和"MCHP"

 2.699439, 1.000000, -1.877447, -2.505294  D1

表1. 用于实时模型更新的虚构查询返回样例

如果数据库能够以合适的更新频率提供这类简洁信息,我们就具备持续以最优状态进行交易所需的全部条件。



以数据库为核心的更新服务

在此之前,我们一直通过Python代码读取MetaTrader 5终端的实时报价来开展数据分析(技术层面,大多数时候我们实际取用的是终端底层引擎缓存的报价)。在确定交易品种与组合权重后,需要手动把新交易品种或新的组合权重更新至EA。

从本节开始,我们将把数据分析模块与交易终端解耦,改用数据库内存储的数据驱动EA更新:一旦得到新组合权重就立刻更新EA;如果协整关系消失则停止交易;或是切换到另一组预期收益更佳的交易品种。也就是说,我们希望优化各交易品种的市场敞口,根据数据分析结论实时更新组合权重,或是执行投资组合轮换。

为实现数据库更新,我们将开发一个MetaTrader 5服务组件。

由《MetaTrader 5官方文档》可见: 

  • 服务(Service)不绑定任何特定图表。

  • 如果服务在终端关闭时仍处于运行状态,那么下次启动终端后会立即自动加载。

  • 服务在独立线程中运行。

由此可以得出结论:服务是保持数据库及时更新的理想方案。如果在结束交易会话并关闭终端时该服务仍在运行,那么当您再次启动终端开启新一轮交易会话时,服务会自动恢复运行,不受当前打开的图表或交易品种影响。除此之外,由于服务运行在独立线程,它不会干扰其他正在运行的服务、指标、脚本或EA,也不会被这些程序所影响。

因此,我们的工作流程如下:

  1. 所有数据分析都在Python中完成,独立于MetaTrader 5环境。执行分析时,我们下载历史数据并写入数据库。
  2. 每当修改当前持仓组合(新增或移除交易品种),就把包含品种列表和周期列表的数组更新至服务的输入参数。
  3. 每当修改当前持仓组合(新增或移除交易品种),同步更新EA的输入参数。

目前,第2和3步的更新操作将采用手动方式。后续,我们会将这部分流程自动化。


数据库搭建

撰写本文调研阶段我意识到,该场景的理想工具是面向时序数据的列式数据库。市面上有大量满足该需求的产品,包含付费和免费、闭源和开源多种方案。这类数据库专为专业负载场景设计,能够承载海量数据,在数据写入和实时查询两方面都可实现亚秒级响应。

但我们这里的重点并不在于系统规模或扩展性本身。首要目标是简易性,适合个人使用者,而不是面向由高水平专业数据库管理员(DBA)和时序数据管理设计人员共同维护的系统。因此,我们从最简单的方案起步,清楚它存在局限,同时预留空间,未来随业务需求迭代升级整套系统。

我们选用MetaTrader 5内置的SQLite数据库。关于如何在MetaTrader 5环境创建和使用内置SQLite数据库,相关资料十分丰富。您可以在以下位置找到相关资料:

如果您打算基于内置SQLite数据库开展正式开发,强烈建议仔细研读以上全部链接内容。下文将针对本文特定场景,对搭建步骤做简要汇总,并说明各项选择背后的设计思路。


数据库表结构(Schema)

本文附带文件db-setup.mq5,这是一段MQL5脚本。该脚本接收两个输入参数:SQLite数据库文件名、数据库表结构定义文件。参数默认值分别为statarb-0.1.db和schema-0.1.sql。 

警告:强烈建议将数据库表结构文件纳入版本控制管理,不要在文件系统中保留多份副本。MetaTrader 5平台内置一套完善的版本控制系统。

使用脚本默认输入参数运行后,会在终端目录的MQL5/Files/StatArb文件夹下生成SQLite数据库(注意不是Common公共目录),数据库包含下文注释说明的全部数据表与字段。脚本还会在MQL5/Files目录下生成output.sql文件,该文件仅用于调试。如果遇到问题,可以查看这个文件,确认系统读取表结构文件的方式。一切正常后,可直接删除该文件。

除此之外,您也可以选用其他方式创建数据库,并在任意SQLite3客户端、MetaEditor图形界面、Windows PowerShell或者SQLite3命令行工具中读取表结构文件。我建议至少首次创建数据库时,运行附带脚本完成创建。后续您也可以随时自定义这套流程。

数据库表结构

我们初始的数据库表结构一共包含四张表,其中两张表作为预留占位,供后续开发使用。也就是说,现阶段只会用到“symbol”表与“market_data”表。

图1为这套初始表结构的实体关系图(ERD)。

图1. 初始数据库表结构的实体关系图(ERD)

图1. 初始数据库表结构的实体关系图(ERD)

不出所料,“corporate_event”表用于保存组合内上市公司的相关事件,例如分红金额、股票拆分、股票回购、并购等事件。现阶段我们暂不使用该表。

“trade”表用于存储每一笔成交记录。汇总这些数据后,我们就能得到统一数据集,用于聚合统计与分析。等到正式开始交易时,才会启用这张表。

“market_data”表用来存储OHLC数据,包含MqlRates结构体的全部字段。该表采用复合主键(symbol_id、时间周期和时间戳),保证每条记录唯一。“market_data”表通过外键关联到“symbol”表。

由此可见,这是唯一一张采用复合主键的数据表。其余所有数据表都将以整型(INTEGER)格式存储的时间戳作为主键。这样设计是有原因的。根据《SQLite3官方文档》:

“rowid(行ID)表的数据以B树结构存储,表中每一行对应一条记录,使用rowid值作为键。这意味着根据rowid检索或排序记录的速度很快。根据指定rowid查找单条记录,或是查询rowid落在指定区间内的全部记录,其速度大约是使用其他主键或索引字段进行同类查询的两倍。

(……)如果一张rowid表的主键为单列,且该字段声明类型为"INTEGER",那么该字段会成为rowid的别名。这类字段通常被称为“整型主键”。

(……)“如果使用整型作为主键,查询速度大约可以提升一倍。Unix纪元时间戳可以直接以整型形式存入SQLite3数据库。”

(……)“SQLite以64位补码格式存储整型数值¹。数值存储范围为-9223372036854775808至+9223372036854775807(包含两端)。落在该区间内的整数可以精确保存。(摘自SQLite3官方文档)”

因此我们可以把日期时间从字符串转为Unix纪元时间戳,并将其作为主键存入数据库,以此获得显著的查询性能提升。🙂

以下几张表完整记录了表结构文档(数据字典),供团队以及后续查阅。

表:symbol

存储所有交易或跟踪金融品种的元数据。

字段 数据类型 Null 键 描述
symbol_id INTEGER NO PK 每种金融工具的唯一标识ID
ticker TEXT(长度≤10) NO
资产的股票代码(例如:"AAPL"、"MSFT")
exchange TEXT(长度≤50) NO
资产的上市交易所(例如:"NASDAQ"、"NYSE")
asset_type TEXT(长度≤50) YES
资产类别(例如:"Equity"、"ETF"、"FX"、"Crypto")
sector TEXT(长度≤50) YES

资产所属板块(例如:"Technology"、"Healthcare")
industry TEXT(长度≤50) YES

板块下的细分行业
 currency TEXT(长度≤50) YES   资产的计价货币(例如:"EUR"、"USD")

表2. symbol表数据字典说明(v0.1版)

表:corporate_event

记录会影响资产价格的各类事件,例如分红、股票拆细、财报公告

字段 数据类型 Null 键 描述 实例
tstamp INTEGER NO PK 事件生效的Unix时间戳 1678905600
event_type TEXT枚举,可选值{'dividend', 'split', 'earnings'} NO
公司行为类型 "dividend"(分红)
event_value REAL YES
事件对应的数值:•每股分红金额• 拆股比例• 每股收益(EPS) 0.85, 2.0, 1.35
details TEXT(长度≤255) YES
补充说明或背景信息 “第二季度分红发放“
 symbol_id  INTEGER NO   FK  关联symbol表的symbol_id;将事件绑定至对应资产  1

表3. corporate_event表数据字典说明(v0.1版)

表:market_data

存储资产的OHLCV(开盘价、最高价、最低价、收盘价、成交量)以及相关时序数据

字段 数据类型 Null 键 描述 实例
tstamp INTEGER NO PK*
K线/柱对应的Unix时间戳 1678905600
timeframe TEXT枚举,可选值{M1,M2,M3,M4,M5,M6, M10,M12,M15,M20,M30,H1,H2,H3, H4,H6,H8,H12,D1,W1,MN1} NO
PK*
时序数据的时间粒度 "M5", "D1"
price_open REAL NO

K线起始时刻的开盘价 145.20
price_high REAL
NO

该K线周期内的最高价 146.00
price_low REAL
NO

该K线周期内的最低价 144.80
price_close REAL
NO

K线结束时刻的收盘价格 145.75
tick_volume INTEGER
YES
该周期内的报价变动(Tick)次数 200
real_volume INTEGER
YES

成交单位数量/合约数量(如有数据源支持) 15000
spread REAL
YES

该K线周期内的平均或快照买卖价差 0.02
symbol_id  INTEGER
 NO PK*, FK  关联symbol表的symbol_id  1

表4. market_data表数据字典说明(v0.1版)

表:trade

用于记录策略的实盘交易或模拟交易记录

字段 数据类型 Null 键 描述 实例
tstamp INTEGER
NO PK 交易执行的Unix时间戳 1678905600
ticket INTEGER
NO

交易单号/订单ID 20230001
side TEXT ENUM {'buy', 'sell'} NO

交易方向 "buy"
quantity INTEGER (>0)
NO

成交股票数量/合约数量 100
price
NO

成交价格 145.50
strategy
YES
产生本次交易的策略标识 "StatArb_Pairs"
symbol_id   INTEGER  NO  FK  关联symbol表的symbol_id  1

表5. trade表数据字典说明(v0.1版)

STRICT(严格模式)

查看表结构定义文件可见,所有数据表均为STRICT表。

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

这意味着我们在数据表中选用严格类型约束(strict typing),而不是SQLite默认更方便的类型亲和机制(type affinity)。我们做出此选择,以规避后续可能出现的数据问题。

在CREATE TABLE建表语句中,如果在右括号末尾添加"STRICT"表选项关键字,那么该表将启用严格类型校验规则。(SQLite官方文档)

长度校验(CHECK LENGTH)

除此之外,我们还对多个TEXT字段增加字符串长度检查约束。原因在于其他关系型数据库(RDBMS)通常会对CHAR(n)或VARCHAR(n)强制限定字符串长度或自动截断超长文本,但SQLite并不会执行这类限制。

请注意,类型名称后面括号内的数值参数(例如"VARCHAR(255)")会被SQLite忽略。除全局上限SQLITE_MAX_LENGTH之外,SQLite不会对字符串、二进制大对象(BLOB)或数值施加任何长度限制。(SQLite官方文档)
我们手动增加长度限制,是为了防止外部来源写入异常超长字符串引发故障。

索引相关

您可能会感到疑惑:为什么目前还没有创建任何索引?我们会在后续正式编写查询语句时再创建索引,这样能够精准判断哪些位置真正需要建立索引。


初始数据写入

数据库会在MQL5与Python接口执行数据分析的过程中,自动完成数据填充,对上层调用方透明。同时为方便使用,文中附带一份Python脚本(db_store_quotes.ipynb)。借助该脚本,您可以批量导入一组交易品种、指定时间周期、选定时间区间内的行情报价数据。后续,我们将基于这份已存入数据库的数据开展数据分析:相关性计算、协整检验和平稳性检验。

图2. 通过Python脚本完成初始数据插入后的symbol表

图2. 通过Python脚本完成初始数据插入后的symbol表

由此可见,“symbol”表内大部分元数据都显示为UNKNOWN。这是因为Python端的SymbolInfo相关接口,无法完整读取MQL5 API中提供的全部交易品种的元数据。这部分缺失字段我们后续再补充完善。

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

数据库路径应当通过环境变量传入,我们使用python-dotenv模块来加载该变量。这样可以避免编辑器无法识别终端或PowerShell环境变量带来的问题。

您可以在脚本起始位置看到Jupyter Notebook代码,用于加载python-dotenv扩展并读取对应的.env配置文件:

%load_ext dotenv
%dotenv .env

本文也附带了一份*.env示例配置文件。

# keep this file at the root of your project 
# or in the same folder of the Python script that uses it
STATARB_DB_PATH="your/db/path/here"

db_store_quotes.ipynb的主调用入口写在脚本的末尾。

symbols = ['MPWR', 'AMAT', 'MU']  # Symbols from Market Watch
timeframe = mt5.TIMEFRAME_M5    # 5-minute timeframe
start_date = '2024-02-01'
end_date = '2024-03-31'
db_path = os.getenv('STATARB_DB_PATH')  # Path to your SQLite database

if db_path is None:
        print("Error: STATARB_DB_PATH environment variable is not set.")
else:
        print("db_path: " + db_path)
# Download historical quotes and store them in the database
        download_mt5_historical_quotes(symbols, timeframe, start_date, end_date, db_path)


更新

整套数据库维护工作的核心,就是这个在“后台”运行、负责模型自动更新与投资组合轮换的MQL5服务。随着数据库迭代演进,该服务本身也需要同步更新。

该服务会连接本地SQLite数据库文件;如果文件不存在,则新建数据库。 

警告:如果通过数据库更新服务新建数据库,请记得使用前文提到的db_setup脚本完成数据库初始化。

随后,服务会配置保证数据完整性的约束条件,并进入无限循环,

持续检测新的行情数据。针对每一支股票与时间周期,执行如下操作: 

  1. 获取最新一根已完成的K线(包含开盘价、最高价、最低价、收盘价、成交量与点差);
  2. 校验该K线记录是否已经存在于数据库; 
  3. 如果不存在,则插入这条新行情数据。

服务使用数据库事务封装插入操作,以此保证原子性。一旦发生异常(例如数据库报错),会按设定上限重试(默认3次),每次重试间隔1秒。日志记录功能为可选配置。循环在两次更新之间会暂停,只有手动停止该服务时,循环才会终止。

下面我们来看一下该服务的几个关键组成部分。

在输入参数中,您可以设置:文件系统里的数据库路径、更新频率(单位:分钟)、插入失败时的最大重试次数,以及是否在EA日志中打印成功/失败信息。等开发完成、代码稳定之后,最后这项参数会很实用。

//+------------------------------------------------------------------+
//|   Inputs                                                         |
//+------------------------------------------------------------------+
input string   InpDbPath      = "StatArb\\statarb-0.1.db";  // Database filename
input int      InpUpdateFreq  = 1;     // Update frequency in minutes
input int      InpMaxRetries  = 3;     // Max retries
input bool     InpShowLogs    = true; // Enable logging?

图3. MetaTrader 5数据库更新服务输入参数配置对话框

图3. MetaTrader 5数据库更新服务输入参数配置对话框

您需要指定要更新的交易品种及其对应的时间周期。这部分参数录入工作将在下一阶段实现自动化。

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

此处将数据库句柄初始化为INVALID_HANDLE(无效句柄)。后续打开数据库时会对该句柄做有效性校验。

// Database handle
int dbHandle = INVALID_HANDLE;

OnStart()

在服务程序唯一的事件处理函数OnStart中,仅搭建无限循环逻辑,并调用 UpdateMarketData 函数;实际业务逻辑都在该函数内部执行。Sleep函数的参数是两次循环(两次行情更新请求)之间等待的毫秒数。为了让参数更直观,我们改用分钟作为更新频率的单位。同时,我们不支持小于1分钟的更新频率。

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

UpdateMarketData()

在此,我们首先调用数据库初始化函数。如果初始化过程发生任何错误,函数会返回false,并将程序控制权交还给主循环。因此,当数据库初始化遇到问题时,您可以保持服务继续运行,同时排查并修复初始化故障。在下一轮循环中,服务会再次尝试执行初始化。

//+------------------------------------------------------------------+
//| Update market data for multiple symbols and timeframes           |
//+------------------------------------------------------------------+
bool UpdateMarketData(string &symbols_array[], ENUM_TIMEFRAMES &time_frames[], int max_retries = 3)
  {
// Initialize database
   if(!InitializeDatabase())
     {
      LogMessage("Failed to initialize database");
      return false;
     }
   bool allSuccess = true;

数据库成功初始化(打开)后,我们开始逐一处理各个交易品种和时间周期。

// Process each symbol
   for(int i = 0; i < ArraySize(symbols_array); i++)
     {
      string symbol = symbols_array[i];
      // Process each timeframe
      for(int j = 0; j < ArraySize(time_frames); j++)
        {
         ENUM_TIMEFRAMES timeframe = time_frames[j];
         int retryCount = 0;
         bool success = false;

在此while循环中,我们控制最大重试次数,并实际调用数据库更新函数。

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

UpdateSymbolTimeframeData()

由于market_data表需要symbol_id作为外键,我们必须先获取该编号。因此,我们会先检查symbol_id是否已存在 ;如果不存在,则在symbol表中新建一条记录并生成对应的symbol_id。

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

我们将时间周期从MQL5的枚举类型ENUM_TIMEFRAMES转换为数据表所需的字符串(TEXT)类型。

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

我们复制最新一根已闭合K线的行情数据。

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

我们检查该数据点是否已存在。如果已存在,则写入日志并返回true。

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

如果这是新数据点(新行情记录),则开启数据库事务以保证操作原子性。一旦发生任何错误,就执行事务回滚。如果全部执行成功,则提交事务并返回true。

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

InitializeDatabase()

这里,我们打开数据库并校验句柄的有效性。

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

在使用MQL5内置SQLite数据库时,这条用于开启外键约束的“PRAGMA”指令并非严格必需,因为内置数据库编译时已经启用该功能。但如果您使用外部数据库,这条指令就属于一项安全保障措施。

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

TimeframeToString()

将MQL5的枚举类型ENUM_TIMEFRAMES转换为字符串类型的函数,采用简易的switch分支结构实现。

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

MarketDataExists()

为校验行情数据是否已存在,我们针对market_data表的复合主键执行一条简单查询语句。

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

InsertMarketData()

最后,为了插入新行情数据(本次更新),我们按官方文档的建议,使用MQL5的StringFormat函数拼装插入用的SQL语句。

在替换VALUES中的字符串时务必小心。字符串必须加上引号包裹。踩坑之后您会感谢我这条提醒。🙂

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

当服务正在运行时……

图4. 数据库更新服务处于运行状态的Metaeditor导航器

图4. 数据库更新服务处于运行状态的Metaeditor导航器

… … 在专家日志(Experts log)的标签页中,您会看到类似这样的输出内容。

图5. 带有数据库更新服务输出的MetaTrader 5专家日志标签页

图5. 带有数据库更新服务输出的MetaTrader 5专家日志标签页

此时您的market_data行情数据表应呈现如下效果。请注意,现阶段我们将所有交易品种和所有时间周期的行情数据统一存放在一张表内。后续我们会对这一设计进行优化,但仅在有实际需求时才调整。就目前而言,这套方案已经完全足够支撑我们更加稳定地开展数据分析工作。

图6. 可显示数据库更新情况的Metaeditor内置SQLite面板

图6. 可显示数据库更新情况的Metaeditor内置SQLite面板


结论

本文介绍了我们如何从临时下载的行情数据,过渡到服务于统计套利框架的第一版数据库。我们讲解了数据库初始表结构背后的设计思路,以及数据库初始化和初始数据写入的方法。

表结构中所有的数据表、字段和关联关系均已完成文档化,包含表说明、约束条件与示例数据。

文中还详细阐述了通过搭建MetaTrader 5服务来及时更新数据库的完整流程,并提供了该服务的一套实现代码,同时附带Python脚本,支持为任意可用交易品种与时间周期写入初始行情数据。

借助这些工具,普通个人交易者(也就是本统计套利框架的目标用户)无需编写任何代码,就可以开始存储实时行情报价,同时保存协整策略相关股票的元信息以及交易历史记录。

在下一阶段,这套初始设计将会进一步迭代:当股票篮子的协整关系减弱时,系统将实时更新投资组合权重,并自动轮换篮子,新增或替换交易品种,全程无需人工干预。


参考文献

Daniel P. Palomar (2025). 《投资组合优化:理论与应用》,剑桥大学出版社。

*SQLite相比部分时序数据库有一个明显短板:不支持“ASOF连接”(as-of joins)。由于我们采用时间戳作为行情数据的索引,未来几乎无法在多张表之间使用普通”JOIN“(连接)操作。原因在于我们把时间戳设为主键,但多张表的时间戳很难对齐,甚至永远无法完全匹配;而普通连接(内连接、左连接、外连接)依赖索引的严格对齐。因此,执行普通“JOIN”查询时,要么返回空结果,要么得到大量为null的记录。此博客文章详细阐述了该问题。

文件名 描述
StatArb/db-setup.mq5

通过读取schema-0.1.sql表结构文件,创建并初始化SQLite数据库的MQL5脚本。

StatArb/db-update-statarb-0.1.mq5 用于将最新已收盘价格K线更新至SQLite数据库的MQL5服务。
StatArb/schema-0.1.sql SQL表结构文件(DDL),用于初始化数据库(生成数据表、字段与各类约束)。
db_store_quotes.ipynb 包含Python代码的Jupyter Notebook。辅助脚本,用于向SQLite数据库批量写入指定时间区间、指定周期的交易品种行情数据。
.env 环境变量示例文件,供上述Python辅助脚本读取。(可选)

本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/19242

附加的文件 |
市场模拟:MQL5 中的 SQL 入门(五) 市场模拟:MQL5 中的 SQL 入门(五)
在上一篇文章中,我展示了如何添加查询机制。这是必要的,以便在 MQL5 代码中能够充分利用 SQL,并使用 SQL SELECT 查询来检索结果。但还有一个函数需要实现,即 DatabaseReadBind 函数。由于要正确理解这个函数需要稍作详细解释,因此决定不在上一篇文章中介绍,而是在今天的文章中详述。鉴于这个主题内容较多,让我们直接进入下一节。
神经网络在交易中的应用:概率时间序列预测(K²VAE) 神经网络在交易中的应用:概率时间序列预测(K²VAE)
我们诚邀您探索 K²VAE 框架的原始实现 — 这是一个灵活的模型,能够线性近似潜在空间中的复杂动态。本文将演示如何在 MQL5 中实现关键组件,包括参数化矩阵,以及如何在标准神经网络层之外对这些矩阵进行管理。对于任何寻求构建可解释时间序列模型实用方法的人来说,本文材料都将大有裨益。
MQL5自优化智能交易系统(第十三部分):基于矩阵分解浅谈控制理论 MQL5自优化智能交易系统(第十三部分):基于矩阵分解浅谈控制理论
金融市场不可预测,那些在历史回测中表现盈利的交易策略,投入实盘后往往会失效。究其原因:大多数策略部署后逻辑固定,无法自适应市场,也不能从失误中学习。我们借鉴控制理论思想,利用反馈控制器监测策略与市场的交互情况,并调整策略行为,使其趋向盈利。测试结果表明:在简单移动平均策略中加入反馈控制器后,利润提升、风险降低、交易效率提高,证明该方案在交易领域具备巨大潜力。
梦境优化算法(DOA) 梦境优化算法(DOA)
这是一种基于种群的优化算法,其灵感源自一种尚存争议、研究较少的现象 —— 人类梦境的机制。该算法拥有一个具备不同“记忆”的智能体种群,用余弦波调制移动步长,并采用独特的99/1时间分配比例。让我们看一下这些特性会如何影响交易策略的优化效率。