以前、以下のRustで実装した高速なバックテストエンジンをPythonから使う記事を書きました。
Rustを採用することでコアロジックは爆速になったものの、本格的なBot運用や大規模なパラメータチューニングに投入しようとすると、新たなボトルネックが見えてきました。
- Pythonへのデータ転送が重い: Rustで計算しても、結果をPythonのリストや辞書に変換するコストが高い(数百万件のトレード履歴など)。
- メモリが足りない: ティック単位で数年分のバックテストを行うと、全約定履歴を保持するためメモリが枯渇する。
- パラメータ模索に時間がかかる: 1つのバックテストは速くても、パラメータを1000通り試すには直列実行だと遅すぎる。
これらを解決するために、Apache ArrowによるZero-Copy転送、O(1)メモリモード、Rayonによる並列化 を実装し、さらなる性能向上を実現しました。本記事ではその技術的な改善点を紹介します。
GitHub: https://github.com/takurot/crypto-rs-backtester
改善の3本柱
1. Zero-Copy Result Export (Apache Arrow / Polars)
Rustで作ったデータをPython (PolarsやPandas) で分析したい場合、通常は以下のようなコピーが発生します。
- Rustの構造体 (
Vec<TradeFill>) - Pythonのオブジェクト (
listofdict) へ変換 (重い!) - Polars/PandasがそれをパースしてDataFrameを作成
数百万件のトレードが発生する高頻度取引のバックテストでは、この「結果の受け渡し」だけで数秒〜数十秒フリーズすることがありました。
今回の改善では、Apache Arrow (pyarrow) を全面的に採用しました。
Rust側で arrow クレートを使って一度だけカラムを組み立て、そのポインタをPython側に渡すことで、Python側への受け渡し時に追加コピーを行わず(Zero-Copy) Polars DataFrameを構築できます(Rust内でのビルドコストは1回だけ発生)。
Rust側 (イメージ):
// ArrowのBuilderを使ってカラムごとにデータを詰める
let mut price_builder = Int64Builder::with_capacity(n);
for t in &self.trades {
price_builder.append_value(t.price);
}
// ArrayDataに変換してPythonへ渡す
let price_array = price_builder.finish();
d.set_item("price", price_array.to_data().to_pyarrow(py)?)?;
Python側:
# 一瞬でDataFrame化完了
df = pl.DataFrame(result.trades_df())
これにより、RustとPythonの境界コストが極小化され、分析サイクルが劇的に高速化しました。
ArrowバッファはRust側でArc保持しているため、Pythonで参照中に解放される心配もありません。
2. メモリ使用量を O(1) に抑える SummaryOnly モード
長期のティックデータのバックテストを行う際、全ての約定(Fill)や注文イベントをメモリに残していると、簡単に数十GBのRAMを消費してしまいます。
しかし、パラメータチューニングの段階では「最終的なシャープレシオやドローダウンだけ知りたい」というケースが大半です。
そこで、TradeLogMode を導入し、メモリ管理を柔軟にしました。
-
All: 全履歴を保持(詳細分析用) -
SummaryOnly: 履歴は一切保存せず、統計量(累積損益、勝数、負数、最大ドローダウン用データなど)のみをインクリメンタルに更新。 -
RingBuffer(N): 最新N件のみ保持(直近のデバッグ用)
特に SummaryOnly モードでは、データ量がどれだけ増えてもメモリ使用量は O(1)(一定) に保たれます。これにより、スペックの限られたマシンでも、数年分の高解像度データを使ったバックテストが可能になりました。
制約として、個別トレードの後追いやヒストグラム/分布系の指標は取れないため、詳細分析が必要な場合は All か RingBuffer を使い分けます。
3. Rayon による並列パラメータスイープ (run_many)
Pythonの multiprocessing は手軽ですが、プロセスごとのデータコピーやIPCのオーバーヘッドがあります。
今回は Rust 側の並列化ライブラリ Rayon を活用し、run_many API を実装しました。
- データの共有: 巨大な価格データ(Polars LazyFrameなどから変換)は、Rust側でパースされた後、読み取り専用として全スレッドで共有されます(コピー不要)。
- GILの解放: 計算中は PythonのGIL(Global Interpreter Lock)を解放し、全CPUコアを使ってバックテスト並列実行します。
# Python側からはリストを渡すだけ
strategies = [MyStrategy(param=x) for x in range(100)]
results = bt.run_many(strategies)
これにより、Pythonスクリプトの書きやすさを維持したまま、ネイティブ並みの並列性能を引き出せるようになりました。
run_many に渡すストラテジは Send + Sync を満たす必要があり、結果の並びは入力順で返す設計です(副作用を持つ共有状態は避ける想定)。
性能等の結果
最新のベンチマーク(M1 Max MacBook Pro等で実施)では、以下のようなパフォーマンスが出ています。
-
計測条件
- macOS 14 / MacBook Pro 14" (M1 Max 10C CPU, 32GB RAM)
- Rust 1.82.0 (stable) releaseビルド,
cargo bench -p backtester-core --bench bench_core - Python 3.11 +
pytest -m bench -q(FFI経由のバッチ実行) - 入力: Tick/Order 合計 100万イベント, シングルシンボル, デフォルト設定
-
スループット: 100万イベント(Tick/Order)の処理が 約 205ms(
bench_engine_e2e_batch_4x250k中央値 206ms, サンプル30) -
Python呼び出しオーバーヘッド: 256 ticks × 1000回バッチコールで 0.413 ms → 約 1.6 ns / tick(
pytest -m bench -sの測定値) -
メモリスケーラビリティ:
SummaryOnlyモードにより、数億件のデータに対してもメモリ枯渇なしで実行可能 -
再現コマンド
- Rust:
cargo bench -p backtester-core --bench bench_core -- --sample-size 30 - Python:
python -m pytest -m bench -q
- Rust:
まとめ
「Rustで書けば速い」のは確かですが、実運用レベルで使いやすくするためには、Pythonとのインターフェース部分(データ転送やメモリ管理)の最適化が不可欠です。
- Zero-Copy でデータ転送をなくす
- インクリメンタル計算 でメモリを節約する
- Rust側で並列化 してCPUを使い切る
これらの施策により、crypto-rs-backtester は「速いだけ」のおもちゃではなく、大規模なリサーチに耐えうるツールへと進化しました。
もし興味があれば、GitHubリポジトリを覗いてみてください。そして、GitHubでスター⭐️で応援をお願いします!