共和分株式による統計的裁定取引(第3回):データベースのセットアップ
はじめに
前回の記事(第2回)では、マイクロプロセッサー分野(ナスダック銘柄)の共和分関係にある株式バスケットを使った統計的アービトラージ戦略をバックテストしました。まず、数百の銘柄の中からNvidiaと最も相関の高い銘柄を絞り込みました。その後、絞り込んだグループについてジョハンセン検定で共和分関係を検証し、スプレッドの定常性をADFとKPSSで確認し、最終的にジョハンセン検定の第1ランクの固有ベクトルから相対的なポートフォリオ比率(重み)を求めました。バックテスト結果は有望なものでした。
しかし、ある2つ(あるいはそれ以上)の資産が過去2年間は共和分関係にあったとしても、明日その関係を失うかもしれません。つまり、共和分ペアや資産グループが将来も共和分関係を保つ保証はありません。企業経営の変化、マクロ経済環境、またはセクター固有の変化などが、もともと資産同士の共和分関係を生み出していたファンダメンタルズに影響を与える可能性があります。逆に、それまで共和分関係が無かった資産が、同じ理由から次の瞬間には共和分関係を持ち始めることもあります。市場とは「絶え間なく変化し続ける謎」なのです。この変化に対応しなければなりません。[PALOMAR, 2025]
共和分関係にある株式バスケットでは、相対的なポートフォリオ比率が常に変動します。この比率は注文量(数量)だけでなく、売買方向(買い/売り)も決定します。そのため、この変化にも対応する必要があります。共和分関係はより長期的な関係ではあるものの、ポートフォリオ比率は常に変化します。そのため、それらを頻繁に確認し、変化があればすぐにモデルを更新する必要があります。モデルが古くなったと判断した場合には、即座に対応し、入れ替えをおこなう必要があります。
私たちのエキスパートアドバイザー(EA)は、現在使用しているポートフォリオ比率がまだ適用可能か、あるいは変更されているかをリアルタイムで把握する必要があります。もし変更されていれば、EAはできるだけ早く新しい比率を知らされなければなりません。また、そもそもモデル自体が依然として有効かどうかも把握すべきです。有効でない場合、どの資産を入れ替える必要があるのかをEAが知り、アクティブなポートフォリオに対して速やかにローテーションを適用すべきです。
これまで私たちはMetaTrader 5のPython連携とstatsmodelsライブラリの信頼性の高い統計関数を利用してきましたが、リアルタイムデータのみを使い、その都度必要な時に価格データをダウンロードする方法で作業してきました。この方法はシンプルであるため探索段階では有用です。しかし、ポートフォリオをローテーションしたり、モデルやポートフォリオ比率を更新したりすることを考えると、データの永続化について検討を始める時期に来ています。つまり、必要な度にデータをダウンロードするのは実用的ではないため、データをデータベースに保存する必要があります。さらに、最初の共和分検定では関係のなかった銘柄や、異なるアセットクラス間の関係を調べたい場面も出てくるでしょう。
高品質でスケーラブル、かつメタデータが豊富なデータベースは、真剣な統計的アービトラージ戦略の中核となるものです。データベース設計は、各ビジネスの要件に適合しているかどうかが重要となる、非常に固有性の高い作業です。本記事では、統計的アービトラージ向けデータベースを構築するための一つのアプローチを紹介します。
私たちのデータベースはどのような質問に答えられなければならないのでしょうか。
「低コストな統計的アービトラージフレームワーク」、つまり一般的な個人トレーダーが普通のノートPCと標準的なネットワーク帯域で扱えるフレームワークを目指す私たちは、統計学やソフトウェア開発など専門的な領域に精通していないことに起因するさまざまな課題に直面しています。データベース設計も例外ではありません。データベース設計はそれ自体が非常に広い分野であり、このテーマだけで一冊の本を書けるほど奥深いものです。本来であれば、複数名の専門家がデータベースの設計、実装、保守を担当するのが理想です。
しかし、私たちは平均的な個人トレーダー向けにこの統計的アービトラージフレームワークを構築しているため、自分たちにあるものを活用する必要があります。書籍や専門フォーラム、チャンネルで調べ、経験豊富な専門家から学び、試行錯誤し、リスクを取りつつ、設計が不十分だと分かったときには柔軟に変更できる姿勢が必要です。 変更に対応できる柔軟性を持ち、過剰性能を避けるためにも、トップダウンではなくボトムアップのアプローチで小さく始めるべきです。
結局のところ、私たちのデータベースは「今、どの銘柄を取引すれば最大のリターンを得られるのか」という非常に単純な問いに答えられれば良いのです。
前回の記事で、株式バスケットのポートフォリオ比率に基づいて注文の方向と数量を定義したことを思い返すと、データベースが返す理想的な回答の一例は次のような形式になります。
| 銘柄 | 重み | 時間足 |
|---|---|---|
| "MU", "NVDA", "MPWR", "MCHP" | 2.699439, 1.000000, -1.877447, -2.505294 | D1 |
表1:リアルタイムモデル更新のための想定クエリ応答サンプル(架空データ)
私たちのデータベースがこのような非常にシンプルな情報を適切な頻度で更新して提供できるのであれば、最適化されたレベルで継続的に取引するために必要な条件は整うことになります。
Serviceとしてのデータベース更新
これまで私たちは、MetTrader 5ターミナルのリアルタイムクオートをPythonコード経由で利用し(技術的には、ほとんどの場合ターミナル内部エンジンが保持しているクオートを利用していました)、データ分析をおこなってきました。銘柄やポートフォリオ比率を決定した後は、新しい銘柄や新しいポートフォリオ比率を、手動でEAに更新していました。
これからは、データ分析をターミナルから切り離し、データベースに保存されたデータを使用して、ポートフォリオ比率が新しくなった時点でEAを更新し、共和分関係が失われた場合は取引を停止し、別の銘柄グループの方が有望と判断された場合は入れ替える、といった処理をおこないます。つまり、各銘柄への市場エクスポージャーを改善し、ポートフォリオ比率をリアルタイムで更新し、データ分析が推奨するたびにポートフォリオを回転させたいのです。
データベースを更新するために、MetaTrader 5 Serviceを実装します。
MetaTrader 5のドキュメントから、次のことがわかります。
- Serviceは特定のチャートに紐付けられない。
- Serviceはターミナルを終了した時点で起動されていれば、ターミナル再起動後に自動的に読み込まれる。
- Serviceは独自のスレッドで動作する。
これらを踏まえると、Serviceはデータベースを最新状態に保つための理想的な手段だと言えます。取引セッションを終了してターミナルを閉じた際にServiceが稼働していれば、新しいセッションでターミナルを再起動した時点で、どのチャートや銘柄を開いているかに関係なく再開されます。さらに、独立したスレッドで実行されるため、他のService、インジケーター、スクリプト、EAなどに影響を受けたり与えたりすることもありません。
したがって、ワークフローは次のようになります。
- すべてのデータ分析はMetaTrader 5環境の外側、Pythonでおこないます。分析を実行するために、履歴データをダウンロードし、それをデータベースに挿入します。
- アクティブなポートフォリオに銘柄を追加または削除するたびに、Serviceの入力パラメータを更新し、銘柄の配列と時間軸の配列を指定します。
- アクティブなポートフォリオに銘柄を追加または削除するたびに、EAの入力パラメータを更新します。
現時点では、手順2と3の更新は手動でおこないますが、後ほど自動化する予定です。
データベースのセットアップ
本記事のために調査を進める中で、最適なツールは時系列データに特化した列指向データベースであることを再認識しました。この要件を満たす製品は市場に多数存在し、有料/無料、商用/オープンソースなどさまざまな選択肢があります。それらは高度に専門化されたワークロードを扱うために設計されており、大量データをリアルタイムで取り込み、サブセカンドでクエリ応答を返すことが可能です。
しかし、ここでの焦点はスケールではありません。私たちが最も重視するのは、シンプルであり、個人レベルでも実用的であることです。高度な専門知識を持つ複数名のデータベース管理者(DBA)や時系列データ管理の設計者が必要となるような仕組みは、今回の目的には適しません。したがって、限界を理解しつつも、最もシンプルな解決策から始め、必要に応じて将来的にシステムを進化させることを念頭に置きます。
そこでまず、MetaTrader 5に統合されているSQLiteデータベースを使用します。MetaTrader 5環境に統合されたSQLiteデータベースを作成し利用する方法については、非常に豊富な情報が存在します。情報源としては以下があります。
- データベースの作成、使用、管理のためのMetaeditorグラフィカルインターフェースに関するMetaeditorドキュメント
- データベース操作用API関数に関するMetaeditorドキュメント
- MQL5におけるSQLデータベースのネイティブ処理を紹介したMetaQuotesの入門記事
- SQLite統合データベースの高度な利用方法を解説したMQL5 Book
統合SQLiteデータベースを本格的に活用する予定であれば、これらすべてを深く読み込むことを強く推奨します。本記事では、本特有のユースケースに必要な手順をまとめ、選択した理由についての簡単な注釈を付けて説明します。
スキーマ
本記事にはdb-setup.mq5ファイルが添付されています。このファイルはMQL5スクリプトであり、データベースのファイル名とデータベーススキーマのファイル名を入力パラメータとして受け取ります。これらのパラメータのデフォルトは、それぞれtatarb-0.1.dbとschema-0.1.sqlになっています。
警告:データベーススキーマファイルは必ずバージョン管理下に置き、ファイルシステム内で複製しないことを強く推奨します。MetaTrader 5には、プラットフォームに統合された堅牢なバージョン管理システムが用意されています。
このスクリプトをデフォルトの入力パラメータで実行すると、ターミナルのMQL5/Files/StatArbフォルダ内(共通フォルダではありません)にSQLiteデータベースが作成され、以下で説明するすべてのテーブルとフィールドが作成されます。またデバッグ目的のためだけに、MQL/Filesフォルダにoutput.sqlというファイルも生成されます。問題が発生した場合には、このファイルを見ることでスキーマファイルがどのように読み込まれているかを確認できます。すべてが正常に動作した場合、このファイルは安全に削除して構いません。
別の方法として、SQLite3クライアント、MetaeditorのUI、WindowsPowerShell、またはSQLite3コマンドラインを使って、データベースを作成しスキーマファイルを読み込むこともできます。少なくとも最初の1回は、添付スクリプトを実行してデータベースを作成することをお勧めします。後からプロセスを自由にカスタマイズできます。
データベーススキーマ
初期データベーススキーマは4つのテーブルで構成されており、そのうち2つは今後のステップのためのプレースホルダーです。つまり、現時点ではsymbolテーブルとmarket_dataテーブルのみを使用します。
図1は、この初期スキーマの実体関連モデル(ERD)を示しています。

図1:初期データベーススキーマの実体関連モデル(ERD)
corporate_eventテーブルは、予想どおり、配当金、株式分割、自社株買い、合併など、ポートフォリオ企業に関するイベントを保存するためのものです。今は扱いません。
tradeテーブルは取引(約定)を保存します。このデータを収集することで、集計分析のための一意なデータセットが得られます。実際の取引を開始するときに使用します。
market_dataテーブルは、OHLCデータとすべてのMqlRatesフィールドを保存します。一意性を保証するため、このテーブルは複合主キー(symbol_id、timeframe、timestamp)を持ちます。market_dataテーブルは外部キーによってsymbolテーブルと結び付けられています。
ご覧のとおり、複合主キーを使用しているのはこのテーブルだけです。他のテーブルはすべて、INTEGERとして保存されるtimestampを主キーとして使用しています。この選択には理由があります。SQLite3ドキュメントによると、
rowidテーブルのデータは、B-Tree構造として保存され、rowid値をキーとして1行につき1つのエントリが格納されます。つまり、rowidでレコードを取得したり、並べ替えたりするのが高速です。特定のrowidを持つレコードを検索したり、特定範囲のrowidを持つすべてのレコードを検索するのは、他のPRIMARYKEYやインデックス値で検索する場合に比べて約2倍高速です。
(...)単一列で構成され、その列の型が大文字と小文字の区別なしでINTEGERと宣言されている場合、その列はrowidのエイリアスになります。そのような列は通常「整数型主キー」と呼ばれます。
(...)INTEGERを主キーとして使用すると、クエリが約2倍高速になることがあります。Unixエポックタイムスタンプは、SQLite3データベースにINTEGERとして挿入できます。
(...)SQLiteは整数値を64ビット2の補数形式で保存します。これにより、−9223372036854775808から+9223372036854775807までの範囲が格納可能です。この範囲内の整数は正確です。(SQLite3ドキュメント)」
このように、日時を文字列ではなくUnixエポックタイムスタンプに置き換えて主キーとして挿入することで、光速のさらなる上を狙えるわけです。🙂
以下のテーブルは、将来の自分たちやチームの参照用として、スキーマ全体の文書(データディクショナリ)を示しています。
テーブル:symbol
取引対象または追跡対象の金融商品のメタデータを保存します。
| フィールド | データ型 | Null? | キー | 説明 |
|---|---|---|---|---|
| symbol_id | INTEGER | いいえ | PK | 各金融商品の一意の識別子 |
| ticker | TEXT(≤10) | いいえ | 銘柄ティッカー(例:AAPL、MSFT) | |
| exchange | TEXT(≤50) | いいえ | 上場取引所(例:NASDAQ、NYSE) | |
| asset_type | TEXT(≤50) | はい | 資産タイプ(例:株式、ETF、FX、暗号通貨) | |
| sector | TEXT(≤50) | はい | 経済セクター分類(例:テクノロジー、ヘルスケア) | |
| industry | TEXT(≤50) | はい | セクター内の業界分類 | |
| currency | TEXT(≤50) | はい | 資産の単位通貨(例:EUR、USD) |
表2:symbolテーブルデータ辞書の説明(v0.1)
テーブル:corporate_event
配当、株式分割、決算発表など、資産に影響を与えるイベントを保存します。
| フィールド | データ型 | Null? | キー | 説明 | 例 |
|---|---|---|---|---|---|
| tstamp | INTEGER | いいえ | PK | イベントが有効になるときのUnixタイムスタンプ | 1678905600 |
| event_type | TEXT ENUM {'dividend', 'split', 'earnings'} | いいえ | イベントタイプ | "dividend" | |
| event_value | REAL | はい | イベント値(配当額、分割比率、EPSなど) | 0.85, 2.0, 1.35 | |
| details | TEXT(≤255) | はい | 追加情報 | "Q2 dividend payout" | |
| symbol_id | INTEGER | いいえ | FK | symbol(symbol_id)への参照(イベントを資産に関連付ける) | 1 |
表3:corporate_event’テーブルデータ辞書の説明(v0.1)
テーブル:market_data
資産のOHLCVおよび関連する時系列データを保存します。
| フィールド | データ型 | Null? | キー | 説明 | 例 |
|---|---|---|---|---|---|
| tstamp | INTEGER | いいえ | PK* | バー(ローソク足)のUnixタイムスタンプ | 1678905600 |
| timeframe | TEXT ENUM {M1,M2,M3,M4,M5,M6, M10,M12,M15,M20,M30,H1,H2,H3, H4,H6,H8,H12,D1,W1,MN1} | いいえ | PK* | データの時間粒度 | "M5", "D1" |
| price_open | REAL | いいえ | 始値 | 145.20 | |
| price_high | REAL | いいえ | 高値 | 146.00 | |
| price_low | REAL | いいえ | 安値 | 144.80 | |
| price_close | REAL | いいえ | 終値 | 145.75 | |
| tick_volume | INTEGER | はい | ティック(価格更新)数 | 200 | |
| real_volume | INTEGER | はい | 取引量 | 15000 | |
| spread | REAL | はい | 平均またはスナップショットスプレッド | 0.02 | |
| symbol_id | INTEGER | いいえ | PK*, FK | symbol(symbol_id)への参照 | 1 |
表4:market_dataテーブルデータ辞書の説明(v0.1)
テーブル:trade
戦略の実取引またはシミュレーション取引を追跡します。
| フィールド | データ型 | Null? | キー | 説明 | 例 |
|---|---|---|---|---|---|
| tstamp | INTEGER | いいえ | PK | 取引実行時のUnixタイムスタンプ | 1678905600 |
| ticket | INTEGER | いいえ | 取引チケット/注文ID | 20230001 | |
| side | TEXT ENUM {'buy', 'sell'} | いいえ | 取引の方向 | "buy" | |
| quantity | INTEGER (>0) | いいえ | 数量 | 100 | |
| price | いいえ | 約定価格 | 145.50 | ||
| strategy | はい | 戦略識別子 | "StatArb_Pairs" | ||
| symbol_id | INTEGER | いいえ | 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;
これは、SQLiteの便利な型親和性ではなく、厳格な型付けを選択したということです。私たちは、これが今後のトラブルを避けられると考えています。
「CREATE TABLE文の末尾、閉じ括弧の後にSTRICTキーワードを追加すると、そのテーブルには厳密な型ルールが適用されます。」(SQLiteドキュメント)
CHECK LENGTH
また、複数のTEXTフィールドに文字列長チェックを要求しています。これは、SQLiteがCHAR(n)やVARCHAR(n)で指定される長さを他のRDBMSのように強制したり、文字列を切り詰めたりしないためです。
インデックスは?
なぜインデックスを作成していないのか疑問に思うかもしれません。クエリを実行し始めてから、どこにインデックスが必要かを確実に判断し、その時点でインデックスを作成します。
初期データ挿入
データベースは、MQL5 Python統合でデータ分析を実行している間に透過的に入力されることが期待されています。しかし、利便性のため、銘柄のリスト、特定の時間足、指定した時間間隔からクオートを保存するための補助として、Pythonスクリプト(db_store_quotes.ipynb)が添付されています。今後は、この保存されたデータを使ってデータ分析(相関、共通積分、定常性検定)を実行していきます。
図2:Pythonスクリプトを使用して初期データを挿入した後のsymbolテーブル
ご覧のとおり、symbolメタデータの大部分はUNKNOWNになっています。これは、SymbolInfo用のPython関数が、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で環境変数がエディタから認識されないという問題を回避するためです。
python-dotenv拡張と対応する*.envファイルを読み込むJupyter Notebookは、スクリプトの冒頭で表示されます。
%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 Serviceにあります。データベースが進化するにつれて、このServiceも更新が必要になります。
erviceはローカルのSQLiteデータベースファイルに接続(または作成)します。
警告:データベース更新Serviceを使って新しいデータベースを作成する場合は、前述のとおり、db_setupスクリプトでデータベースを初期化することを忘れないでください。
その後、このServiceはデータ整合性のために必要な制約を設定し、無限ループに入ります。このループでは以下をチェックします。
新しい市場データ。各銘柄と時間足について以下をおこないます。
- 最新の確定バー(始値、高値、安値、終値、出来高、スプレッドを含む)を取得する
- それが既にデータベースに存在するかを確認する
- 存在しなければ新しいクオートを挿入する
Serviceは挿入処理をデータベーストランザクション内で実行し、原子性を保証します。問題が発生した場合(例:データベースエラー)、1秒の間隔で最大回数(デフォルト3回)まで再試行します。ログ出力は任意です。ループは更新間にスリープし、Serviceが手動で停止された場合のみ終了します。
では、その構成要素の一部を見ていきましょう。
入力パラメータでは、ファイルシステム上のデータベースパス、更新頻度(分単位)、挿入失敗時の最大再試行回数、そして成功/失敗メッセージを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データベース更新serviceのパラメータ入力ダイアログ
更新する銘柄とそれぞれの時間足を選択する必要があります。これらの入力項目は、次の段階で自動化される予定です。
//+------------------------------------------------------------------+ //| Global vars | //+------------------------------------------------------------------+ string symbols[] = {"EURUSD", "GBPUSD", "USDJPY"}; ENUM_TIMEFRAMES timeframes[] = {PERIOD_M5};
ここで、データベースハンドルをINVALID_HANDLEとして初期化します。次にデータベースを開いたときにチェックされます。
// Database handle int dbHandle = INVALID_HANDLE;
OnStart()
Service固有のイベントハンドラである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を返して制御をメインループに戻します。したがって、データベース初期化に問題がある場合でも、Serviceを安全に実行させたまま、データベース初期化の問題を修正することができます。次のループでは、再度初期化を試みます。
//+------------------------------------------------------------------+ //| 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; If the database is initialized (open), we start processing each symbol and timeframe. // 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; }
次に、最後に確定したバーのレートを取得します。
// 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; }
SQLiteの統合データベースを使用する場合、この「PRAGMA foreign_keys = ON」ディレクティブは必須ではありません。なぜなら、このデータベースはすでにこの機能が有効になるようにコンパイルされていることが分かっているからです。しかし、外部データベースを使用する場合に備えて、安全策として設定しています。
// 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()
シンプルなswitch文でMQL5 ENUM_TIMEFRAMESを文字列型に変換する関数です。
//+------------------------------------------------------------------+ //| 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関数を用いてINSERTクエリを実行します。
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; }
Service実行中…
図5:DB更新Service実行時のMetaeditorナビゲータ
…[エキスパート]タブのログには次のような内容が表示されます。
図7:MetaTrader 5[エキスパート]タブのログに表示されているデータベース更新serviceの出力
market_dataテーブルはこのような状態になります。現時点では、すべての銘柄と時間足の市場データを単一テーブルに保存しています。後で必要に応じて改善しますが、現段階ではこれで十分です。これにより、より持続可能な形でデータ分析を開始することができます。
図8:Metaeditor統合SQLiteタブでのデータベース更新ビュー
結論
本記事では、瞬間的にダウンロードされる価格データから、統計的裁定取引フレームワークのための初期データベースバージョンに移行する方法を説明しました。初期スキーマ設計の根拠、データベースの初期化方法、および初期データの挿入方法についても確認しました。
スキーマのすべてのテーブル、フィールド、およびリレーションは、テーブルの説明、制約、例とともに文書化されています。
また、MetaTrader 5 Serviceを構築してデータベースを更新し続ける手順についても詳しく説明しました。このServiceの1つの実装例と、任意の銘柄(ティッカー)および時間足の初期データを挿入するためのPythonスクリプトも提供しました。
これらのツールを用いれば、コードを1行も書くことなく、統計的裁定取引フレームワークの想定読者である一般の個人トレーダーでも、即時の市場データ(価格クオート)だけでなく、共和分戦略で扱う株式のメタデータや取引履歴も保存して利用を開始できます。
この初期設計は次のステップで進化します。バスケットの共和分関係が弱くなった場合には、ポートフォリオ比率をリアルタイムで更新し、手動操作なしで銘柄を入れ替えたり追加したりしてポートフォリオをローテーションできるようになります。
参照文献
Daniel P. Palomar (2025).Portfolio Optimization:Theory and Application.Cambridge University Press.
* SQLiteの制約の1つとして、時系列特化型データベースにある「as-of joins」が存在しない点が挙げられます。市場データをタイムスタンプでインデックス化するため、将来的にテーブル間での「結合」を使用することはほぼ確実にできません。これは、タイムスタンプを主キーとして使用しているためで、複数のテーブルでタイムスタンプが一致することはほとんどなく、場合によっては全く一致しない可能性があります。そして、inner、left、outer joinなどの「結合」は、このインデックスの整合性に依存します。そのため、「結合」を使用すると空(null)の結果セットが返されることになります。この問題については、ブログ投稿で詳細に説明されています。
| ファイル名 | 説明 |
|---|---|
| StatArb/db-setup.mq5 | schema-0.1.sqlスキーマファイルを読み込んでSQLiteデータベースを作成および初期化するMQL5スクリプト |
| StatArb/db-update-statarb-0.1.mq5 | SQLiteデータベースを最新の確定価格バーで更新するMQL5 Service |
| 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
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。
MQL5入門(第20回):ハーモニックパターンの基礎
MetaTrader 5機械学習の設計図(第2回):機械学習のための金融データのラベリング
MQL5で自己最適化エキスパートアドバイザーを構築する(第13回):行列分解を用いた制御理論の簡単な入門
初心者からエキスパートへ:MQL5を使ったアニメーションニュース見出し(IX) - ニュース取引のための単一チャートでのマルチペア管理
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索