1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

デイトレ自動売買の検証基盤をClickHouseで爆速化し、RAGで「似ている相場」を引っ張ってきた話

1
Posted at

この記事の立ち位置

日本株のデイトレード用に、Pythonで自動売買・バックテストの仕組みを自作しています。
日々の検証で一番つらいのは、**「分足・ティックを数年分ためた巨大テーブルを、検証のたびに何度もスキャンする」**という地味で重い処理です。

最初はSQLiteとParquetでなんとかしていたのですが、特徴量を1つ足すたびに全期間を読み直す検証ループが現実的な時間で回らなくなり、データ基盤を ClickHouse に載せ替えました。さらに最近は「今の相場、過去のどの局面に似ている?」を機械的に引くために、ClickHouseのベクトル検索を使ってRAG(Retrieval-Augmented Generation)を組んでいます。

この記事では、

  1. なぜトレード検証基盤にClickHouseが効くのか
  2. 時系列マーケットデータの爆速スキーマ設計
  3. SQLite/Parquetからの移行
  4. 「似ている相場」をベクトル検索 + HNSWで引く
  5. それをRAGとしてLLMにつなぐ
  6. ハマりどころと、AI時代のDBに関する考察

を、実際に動くSQL/Pythonと一緒にまとめます。入門者でも環境構築から追えるように書いたつもりです。

⚠️ 免責:本記事はデータ基盤・検証手法の技術解説であり、特定銘柄の売買や投資助言を目的とするものではありません。コード中の銘柄・数値はすべてサンプルです。


1. なぜトレード検証基盤に ClickHouse なのか

デイトレの検証で扱うデータは、性質がとてもはっきりしています。

  • 追記中心・更新ほぼなし(過去の約定値は書き換わらない)
  • 時系列で、ほぼ常に「期間 × 銘柄」で絞り込む
  • 列単位の集計が主役(終値だけ、出来高だけ、を全期間なめる)
  • 行数が一気に億単位になる(1分足5銘柄でも年に数十万行、ティックなら桁が2〜3個増える)

これは行指向のRDB(SQLite/PostgreSQL/MySQL)が一番苦手で、列指向OLAPが一番得意とするワークロードそのものです。ClickHouseは列ごとに圧縮して持ち、WHEREで触らない列は一切読まないので、「終値だけ全期間集計」のようなクエリが桁違いに速くなります。

加えて、ここ1〜2年のClickHouseは ベクトル検索(HNSW)全文検索(テキストインデックス) を本体に取り込んでおり、「構造化データの分析」と「AI向けの類似検索」を1つのエンジンでやれるようになっています。トレード基盤の文脈だと、これは「価格データと特徴量を置いてある場所に、そのままRAG用の埋め込みも置ける」という意味で、運用するDBが増えないのが地味に効きます。


2. とにかく動かす(環境構築)

一番速いのはDockerです。

docker run -d --name ch \
  -p 8123:8123 -p 9000:9000 \
  --ulimit nofile=262144:262144 \
  clickhouse/clickhouse-server:latest

# クライアントで接続
docker exec -it ch clickhouse-client

ローカルで素振りしたいだけなら、サーバーを立てずに使える clickhouse-local や、Pythonに組み込める chDBpip install chdb)も便利です。検証スクリプトに埋め込むなら chDB が手軽。

補足:2026年4月にリリースされた公式CLI clickhousectl(beta)を使うと、ローカルのバージョン管理やローカルインスタンスの起動・破棄、Cloud側のリソース管理までまとめて面倒を見てくれます。ローカル検証 → Cloud と行き来する人は触っておくと楽です。


3. 時系列マーケットデータの爆速スキーマ設計

ここがこの記事の本丸です。スキーマ設計を間違えると、ClickHouseでも遅くなります。

ポイントは3つ。

  1. ORDER BY を「絞り込み順」に合わせる(マーケットデータなら (symbol, ts)
  2. PARTITION BY は月単位くらいの粗さで(古い月をまるごと落とせる)
  3. 列ごとに時系列向けコーデックを当てる(圧縮率と速度が体感で変わる)

1分足を入れるテーブル例:

CREATE TABLE market.bars_1m
(
    symbol     LowCardinality(String),          -- 銘柄数は少ないので LowCardinality
    ts         DateTime64(3, 'Asia/Tokyo')  CODEC(DoubleDelta, ZSTD(3)),
    open       Float64                      CODEC(Gorilla, ZSTD(3)),
    high       Float64                      CODEC(Gorilla, ZSTD(3)),
    low        Float64                      CODEC(Gorilla, ZSTD(3)),
    close      Float64                      CODEC(Gorilla, ZSTD(3)),
    volume     UInt64                       CODEC(T64, ZSTD(3))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (symbol, ts);

設計意図:

  • DoubleDelta:単調増加するタイムスタンプ向け。差分の差分を取るのでほぼ消える。
  • Gorilla:連続する浮動小数点(価格)向けのコーデック。隣り合う値が近いほど効く。
  • T64 / Delta:整数(出来高)向け。
  • LowCardinality(String):銘柄コードのように種類が少ない文字列はこれにするとディクショナリ圧縮されて速い。
  • ORDER BY (symbol, ts):「8035.T の 9:00〜10:00」のような絞り込みが主キーのプレフィックスで効くようになり、読むデータ量が激減します。

実運用していて効くのは、何より触らない列を読まないこと。SELECT close FROM bars_1m WHERE symbol='8035.T'open/high/low/volume のディスクI/Oがそもそも発生しません。行指向DBではこれができません。


4. データ投入と、SQLite/Parquetからの移行

すでにParquetやSQLiteで持っている人向けに、移行の現実的なルートを2つ。

A. Parquet → ClickHouse(最速)

ClickHouseは s3 / file テーブル関数でParquetを直接読めます。ローカルのParquetをそのまま流し込む例:

INSERT INTO market.bars_1m
SELECT symbol, ts, open, high, low, close, volume
FROM file('/var/lib/clickhouse/user_files/bars_*.parquet', Parquet);

数年分のParquetでも、これ一発で取り込めます。ETLスクリプトを書かずに済むのが移行コストを大きく下げてくれました。

B. Python(pandas / Polars)から投入

検証パイプラインの中から入れるなら clickhouse-connect が素直です。

import clickhouse_connect
import pandas as pd

client = clickhouse_connect.get_client(host="localhost", port=8123)

df = pd.read_parquet("bars_2024.parquet")  # 既存資産をそのまま
# 列順・型をテーブルに合わせておく
client.insert_df("market.bars_1m", df)

移行時の所感:SQLiteの .db を引きずるより、いったんParquetに吐いてClickHouseに流すのが結局いちばん速くて事故りませんでした。型の食い違い(特にタイムゾーンとミリ秒)は最初に潰しておくこと。私は過去に「reindex時のタイムゾーン不一致で特徴量が全部ゼロになっていた」事故を踏んでいるので、ts まわりは投入直後に必ず SELECT min(ts), max(ts), count() FROM ... GROUP BY symbol で目視します。


5. 集計クエリの速さを体感する

たとえば「各銘柄の日中VWAPと値幅を全期間で出す」。検証で死ぬほど叩くやつです。

SELECT
    symbol,
    toDate(ts)                                   AS d,
    sum(close * volume) / sum(volume)            AS vwap,
    max(high) - min(low)                         AS range_abs,
    (max(high) - min(low)) / argMin(open, ts)    AS range_pct
FROM market.bars_1m
WHERE ts >= '2024-01-01'
GROUP BY symbol, d
ORDER BY symbol, d;

1分足を5分足にリサンプルするのもSQLだけで完結します。

SELECT
    symbol,
    toStartOfInterval(ts, INTERVAL 5 MINUTE) AS ts5,
    argMin(open, ts)  AS open,
    max(high)         AS high,
    min(low)          AS low,
    argMax(close, ts) AS close,
    sum(volume)       AS volume
FROM market.bars_1m
GROUP BY symbol, ts5
ORDER BY symbol, ts5;

argMin/argMax で「区間内の最初/最後の値」を取れるのが時系列で本当に便利です。Pythonで resample().agg() していた処理が、そのままサーバー側で並列実行されます。

速度の目安について:環境差が大きいので具体的なミリ秒は書きません。各自 clickhouse-clientSET send_logs_level='trace' を付けるか、クエリ末尾の Elapsed / Processed rows を見て、「読んだ行数」と「実時間」を自分の環境で測ってください。設計が正しければ、WHERE で絞った分だけ Processed rows が小さくなるのが確認できます。これが見えるだけで、行指向DBに戻れなくなります。


6. ここからAI:「似ている相場」をベクトル検索で引く

ここからが本題の半分です。やりたいのはこれ:

今日のザラ場の状態(始値からの推移・出来高プロファイル・地合い)を1本のベクトルにして、過去の全営業日の中から「形が似た日」を高速に引く。

似た日が引ければ、「その後どう動いたか」を束ねて、LLMに地合い解説をさせる(=RAG)ところまで繋げられます。

6-1. 何をベクトルにするか

アプローチは2系統あります。

  • (a) テキスト要約を埋め込む:「寄り付き強い・前場で失速・後場膿出し・出来高薄い」みたいな日次の状態説明文を作り、文埋め込みモデル(sentence-transformers 等)でベクトル化。LLMとの相性が良い。
  • (b) 数値特徴量をそのままベクトルにする:始値基準の正規化リターン系列+出来高プロファイルを固定長ベクトルに。意味は持たないが「形の近さ」は素直に出る。

今回は LLM に繋ぐ前提なので (a) テキスト埋め込み を使います。

6-2. テーブル定義(HNSWインデックス付き)

埋め込みは Array(Float32) で持ち、vector_similarity インデックス(中身はHNSW)を貼ります。

-- バージョンによっては明示的に有効化が必要
-- SET allow_experimental_vector_similarity_index = 1;   -- 旧
-- SET enable_vector_similarity_index = 1;               -- 環境による

CREATE TABLE market.session_embeddings
(
    symbol     LowCardinality(String),
    d          Date,
    summary    String,                    -- 人間/LLMが読む日次サマリ
    next_ret   Float64,                    -- 翌日リターン等、後段で使う結果ラベル
    embedding  Array(Float32),
    INDEX idx_emb embedding
        TYPE vector_similarity('hnsw', 'cosineDistance', 384, 'bf16', 32, 128)
        GRANULARITY 100000000,
    CONSTRAINT emb_len CHECK length(embedding) = 384
)
ENGINE = MergeTree
ORDER BY (symbol, d);

vector_similarity の引数(ドキュメント準拠):

位置 引数 今回の値 意味
1 method 'hnsw' 現状ANNはHNSWのみ
2 distance 'cosineDistance' 距離関数。クエリ側と一致必須
3 dimensions 384 埋め込み次元(モデルに合わせる)
4 quantization 'bf16' 既定もbf16。f32/f16/i8/b1 も可
5 M 32 HNSWのノード接続数
6 ef_construction 128 構築時の探索幅

ハマりポイント:インデックスの距離関数とクエリの距離関数が違うとインデックスが使われません(コサインで貼ったらコサインで引く)。

6-3. 埋め込みを作って投入(Python)

import clickhouse_connect
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("intfloat/multilingual-e5-small")  # 384次元
client = clickhouse_connect.get_client(host="localhost", port=8123)

# d, symbol, summary, next_ret は自分の検証ロジックで作った日次レコード
rows = build_daily_sessions()  # -> list[dict]

texts = [r["summary"] for r in rows]
vecs = model.encode(texts, normalize_embeddings=True).tolist()  # 正規化しておく

data = [
    [r["symbol"], r["d"], r["summary"], r["next_ret"], v]
    for r, v in zip(rows, vecs)
]
client.insert(
    "market.session_embeddings",
    data,
    column_names=["symbol", "d", "summary", "next_ret", "embedding"],
)

既存データに後からインデックスを効かせる場合は materialize が必要です。

ALTER TABLE market.session_embeddings MATERIALIZE INDEX idx_emb SETTINGS mutations_sync = 2;

6-4. 「今日に似た過去日」を引く

「今日のサマリ」を同じモデルで埋め込み、その配列を渡してコサイン距離の近い順に取得します。

SELECT
    d,
    summary,
    next_ret,
    cosineDistance(embedding, {q:Array(Float32)}) AS dist
FROM market.session_embeddings
WHERE symbol = {sym:String}
ORDER BY dist ASC
LIMIT 10;

Python側:

q = model.encode(["寄りは強いが前場で失速、出来高は前日比で薄い"],
                 normalize_embeddings=True)[0].tolist()

res = client.query(
    """
    SELECT d, summary, next_ret,
           cosineDistance(embedding, %(q)s) AS dist
    FROM market.session_embeddings
    WHERE symbol = %(sym)s
    ORDER BY dist ASC
    LIMIT 10
    """,
    parameters={"q": q, "sym": "8035.T"},
)
for row in res.result_rows:
    print(row)

注意:HNSWインデックスを効かせるには LIMITmax_limit_for_vector_search_queries(既定100)以下である必要があります。LIMIT 1000 みたいに大きくすると、インデックスを使わず全件スキャン(=正確だが遅い)にフォールバックします。

ClickHouseの良いところは、ベクトル検索に構造化フィルタを素直に混ぜられることです。「同じ銘柄・地合いが弱気・前場だけ」みたいなWHEREを足しても、同じSQLの中で完結します。専用ベクトルDBに価格データを二重持ちしなくて済むのが、運用上いちばん効きます。


7. RAGとして組み立てる

似ている過去日が引けたら、それを文脈としてLLMに渡し、地合いの言語化をさせます。売買シグナルを出すのではなく、検証・振り返りの叩き台を作る用途です。

def retrieve(symbol: str, today_summary: str, k: int = 8):
    q = model.encode([today_summary], normalize_embeddings=True)[0].tolist()
    res = client.query(
        """
        SELECT d, summary, next_ret,
               cosineDistance(embedding, %(q)s) AS dist
        FROM market.session_embeddings
        WHERE symbol = %(sym)s
        ORDER BY dist ASC
        LIMIT %(k)s
        """,
        parameters={"q": q, "sym": symbol, "k": k},
    )
    return res.result_rows


def build_prompt(symbol, today_summary, neighbors):
    ctx = "\n".join(
        f"- {d}(距離{dist:.3f}): {summary} / 翌日リターン {ret:+.2%}"
        for d, summary, ret, dist in neighbors
    )
    return f"""あなたは検証アシスタントです。以下は{symbol}について、
今日と形が似ていた過去のセッションです。

# 今日のサマリ
{today_summary}

# 似ている過去セッション(近い順)
{ctx}

これらの共通点・相違点を整理し、過去に似た形が出たあとの値動きの
「傾向のばらつき」を中立的に要約してください。断定や売買推奨はしないこと。
"""

あとは好きなLLMに build_prompt(...) を投げるだけです。ポイントは 検索(ClickHouse)と生成(LLM)を分離していること。検索の根拠(どの過去日を引いたか、距離いくつか)が必ずSQLの結果として残るので、「なぜそう言ったのか」を後から追えます。私の設計原則は 「ライブ / 学習BT / 疑似BTの三者一致」 で、解釈不能なブラックボックスを増やさないこと。RAGでも同じで、引いた根拠がSELECTで再現できるのは外せません。


8. ハマりどころ・運用メモ

実際に運用して踏んだ/気をつけている点。

  • HNSWはメモリに乗る:インデックスは検索時にメモリへフルロードされます。ベクトル数 × 次元 × 量子化バイト数 が効くので、bf16(2バイト)や i8 で大きく節約できます。数百万ベクトルくらいなら全然現実的。
  • INSERTが重くなる:HNSW構築のぶん挿入が遅くなります。大量バルク投入時は一度マージを止めて入れてから MATERIALIZE INDEX する手もあります。
  • 距離関数の一致(再掲):インデックスとクエリで距離関数を揃える。
  • LIMIT 上限(再掲):既定100超でインデックスが効かなくなる。
  • バージョン差vector_similarity は時期によって実験的フラグの有無や引数順が変わっています。手元のバージョンの公式ドキュメントを必ず確認。
  • 疑似BTを信用しすぎない:これはRAGに限らずですが、検索で「似ている」と出た過去日がそのまま将来を予言するわけではない。あくまで仮説出しの道具として扱う。

おまけ:QBit(実行時に精度を選べる)

2025年10月の25.10で QBit という、浮動小数点をビットプレーンで持つベクトル向けの型が入り、26.1でbetaに昇格しました。読むビット数を“クエリ時に”変えて、再現率と速度のトレードオフを後から調整できるのが面白いところ。事前に量子化を決め打ちして失敗する、というありがちな事故を避けられます。精度と速度を探りながら詰めたい検証用途と相性が良さそうなので、次はこれを試す予定です。


9. 考察:AI時代にデータベースはどう進化すべきか

今回いじってみて、はっきり感じたことを3つ。

(1) 「分析DB」と「ベクトルDB」の境界が溶けつつある。
従来は「価格データはOLAP、埋め込みは専用ベクトルDB」と分けるのが普通でした。でも、ベクトル検索の主役であるRAGは結局構造化フィルタとの併用(この銘柄・この期間・この地合いの中で近いもの)でこそ実用になります。だとすると、WHEREcosineDistance を同じSQLで書ける環境のほうが、運用するコンポーネントが減って結果的に速い。少なくとも「数百万〜数億ベクトル+リッチなフィルタ+分析」という帯域では、専用ベクトルDBを別に立てる理由が薄くなってきました。

(2) ただし万能ではない。
「インメモリで超低レイテンシ・超高QPSのANNだけが欲しい」「ベクトルが小さくメモリに余裕で乗る」といったケースは、専用エンジンのほうが有利なこともあります。DBの統合は“銀の弾丸”ではなく、自分のワークロードの帯域で判断するもの。トレード基盤のように「巨大な構造化データが主、ベクトルは脇役」という構成では統合が効く、というのが今回の結論です。

(3) DBが「エージェントから操作される前提」に寄ってきている。
2026年に入って、公式CLI(clickhousectl)にコーディングエージェント向けのskillインストーラが付いたり、各種IDE/エージェントからMCP経由で自然言語クエリを投げられるようになったりと、「人間がSQLを書く」以外の入口が一気に整備されてきました。これは「DBは速ければいい」から「エージェントが安全に・決定的に叩けること」へ評価軸が広がっている、ということだと思います。検証の自動化を突き詰めると、最終的にここに行き着く気がしています。


10. まとめ

  • デイトレ検証基盤のように 追記中心・期間×銘柄で絞る・列集計が主役・億行 というデータは、列指向OLAPのClickHouseが圧倒的に得意。
  • スキーマは ORDER BY (symbol, ts) / 月パーティション / 時系列向けコーデック が肝。
  • Parquet経由ならETLをほぼ書かずに移行できる。
  • ベクトル検索(HNSW)も本体内蔵なので、価格データと同じDBで「似ている相場」を引け、構造化フィルタと混ぜられる。
  • それを 検索(ClickHouse)と生成(LLM)を分離したRAG にすると、根拠がSELECTで再現できる、説明可能な振り返りツールになる。
  • 一方で統合は万能ではなく、自分のワークロードの帯域で選ぶのが大事。

「価格データの分析基盤を速くしたい人」と「RAGの土台を増築なしで持ちたい人」、その両方に刺さるのがClickHouseの面白さだと思います。同じことを別構成でやっている方がいたら、ぜひ知見を交換させてください。

繰り返しになりますが、本記事はデータ基盤・検証手法の技術記事です。投資判断はご自身の責任で。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?