backtesting-py-oracle について
ClickHouse SQLのスイープ結果をbacktesting.pyで検証する量的研究者向けの設定・トラブル対処スキルです。ポジション重複、データの整合、チャート表示を扱い、5つの基準でPythonとSQLの結果を照合します。
主な機能
- 01SQLに合わせた複数ポジション設定
- 02取引順序とタイムスタンプの差異特定
- 03ローリング分位点のNaN処理
- 045つの基準による結果検証
- 05ズーム時のチャート軸調整
活用シーン
- 01SQLとPythonの取引件数の比較
- 02エントリー価格や時刻の不一致調査
- 03複数アセット間のシグナル条件確認
- 04ズームした損益曲線の範囲確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ terrylica/ cc-skills --skill backtesting-py-oracleインストール後、Claude Code で/backtesting-py-oracleを入力して読み込まれたか確認します。
同梱ファイルreferences/evolution-log.md
詳細
Range Bar パターンの backtesting.py オラクル検証
ClickHouse SQL スイープ結果を検証するための backtesting.py の設定とアンチパターンです。SQL と Python のトレード評価をビット単位で再現できるようにします。
関連スキル:clickhouse-antipatterns(SQL の正確性、AP-16) | sweep-methodology(スイープ設計) | rangebar-eval-metrics(評価指標)
検証済み:Gen600 オラクル検証(2026-02-12)— 3 アセット、5 ゲート、すべて合格。
自己進化するスキル:このスキルは使用を通じて改善されます。指示に誤りがある、パラメーターにずれが生じた、または回避策が必要になった場合は、先送りせず、すぐにこのファイルを修正してください。実際に再現可能な問題がある場合にのみ更新してください。
重要な設定(絶対に省略しないでください)
from backtesting import Backtest
bt = Backtest(
df,
Strategy,
cash=100_000,
commission=0,
hedging=True, # REQUIRED: Multiple concurrent positions
exclusive_orders=False, # REQUIRED: Don't auto-close on new signal
)
理由:SQL は各シグナルを個別に評価します(トレードの重複が許可されます)。hedging=True を指定しないと、ポジションが開いている間 backtesting.py はシグナルをスキップするため、SQL よりトレード数が少なくなります。この問題は、SOLUSDT で Python のトレードが 105 件、SQL のトレードが 121 件となり、16 件のシグナルが通知なしにスキップされた際に発見されました。
アンチパターン(重大度順)
BP-01:複数ポジションモードの指定漏れ(重大)
症状:Python のトレード数が SQL より少なくなります。ゲート 1(シグナル数)が失敗します。
根本原因:デフォルトの exclusive_orders=True により、ポジションが有効な間、新しいポジションを開けません。
対処法:必ず hedging=True, exclusive_orders=False を使用してください。
BP-02:ExitTime のソート順(重大)
症状:SQL と Python が同じ価格ソースを使用しているにもかかわらず、エントリー価格が一致しないように見えます(ゲート 3 が失敗します)。
根本原因:stats._trades は EntryTime ではなく ExitTime でソートされます。重複するトレードの決済順がエントリー順と異なる場合、trade[i] が signal[i] に対応しなくなります。
対処法:
trades = stats._trades.sort_values("EntryTime").reset_index(drop=True)
BP-03:ローリング分位点での NaN 汚染(重大)
症状:アセット横断テストで、Python のトレード数が大幅に少なくなり失敗します。特徴量の分位点が NaN になり、その状態が以降ずっと伝播します。
根本原因:NaN を含む入力に対して np.percentile を実行すると、NaN が返されます。特徴量の値に NaN が 1 つでもローリングウィンドウに入ると、それ以降の分位点がすべて NaN になり、後続のフィルター比較がすべて失敗します。
対処法:シグナルウィンドウを作成する際は NaN 値をスキップしてください。
def _rolling_quantile_on_signals(feature_arr, is_signal_arr, quantile_pct, window=1000):
result = np.full(len(feature_arr), np.nan)
signal_values = []
for i in range(len(feature_arr)):
if is_signal_arr[i]:
if len(signal_values) > 0:
window_data = signal_values[-window:]
result[i] = np.percentile(window_data, quantile_pct * 100)
# Only append non-NaN values (matches SQL quantileExactExclusive NULL handling)
if not np.isnan(feature_arr[i]):
signal_values.append(feature_arr[i])
return result
BP-04:データ範囲の不一致(中程度)
症状:古い時期のデータがあるアセット(BNB、XRP)で、SQL と Python のシグナル数が異なります。
根本原因:load_range_bars() のデフォルトは start='2020-01-01' ですが、SQL には下限がありません。
対処法:利用可能なデータをすべて対象にするため、必ず start='2017-01-01' を渡してください。
BP-05:ポジション重複時の証拠金不足(中程度)
症状:証拠金不足により注文がキャンセルされます。予想よりトレード数が少なくなります。
根本原因:hedging=True とデフォルトの全資産を使ったポジションサイズ指定を組み合わせると、ポジションが重複した際に利用可能な証拠金を使い果たします。
対処法:固定比率のポジションサイズを使用してください。
self.buy(size=0.01) # 1% equity per trade
BP-06:シグナルのタイムスタンプとエントリーのタイムスタンプ(軽微)
症状:SQL はシグナルバーのタイムスタンプを使用する一方、Python はエントリーバーのタイムスタンプを使用するため、ゲート 2(タイムスタンプの一致)が失敗します。
根本原因:SQL はシグナル検出バーの timestamp_ms を出力します。Python の EntryTime は約定バー(シグナルの次のバー)です。この 2 つには 1 バー分のずれがあります。
対処法:ストラテジーの next() メソッドでシグナルバーのタイムスタンプを記録してください。
# Before calling self.buy()
self._signal_timestamps.append(int(self.data.index[-1].timestamp() * 1000))
5-Gate Oracle 検証フレームワーク
| ゲート | 指標 | しきい値 | 検出する問題 |
|---|---|---|---|
| 1 | シグナル数 | <5% diff | シグナルの欠落、フィルターの不整合 |
| 2 | タイムスタンプの一致 | >95% | タイミングのずれ、ウォームアップの違い |
| 3 | エントリー価格 | >95% | 価格ソースの不一致、ソート順 |
| 4 | イグジットタイプ | >90% | バリアロジックの違い |
| 5 | ケリー比率 | <0.02 | 集計結果の整合性 |
想定される残差:TIME バリア境界(バー 50)で、アセットあたり 1~2 件のイグジットタイプの不一致が発生します。SQL は fwd_closes[max_bars] を使用し、backtesting.py は現在のバー価格でクローズします。ケリー比率への影響は < 0.006 です。
戦略アーキテクチャ:単一ポジションと複数ポジション
| モード | コンストラクター | 用途 | ポジションサイジング |
|---|---|---|---|
| 単一ポジション | hedging=False (default) | チャンピオンの 1 バー保有 | 全資産 |
| 複数ポジション | hedging=True, exclusive_orders=False | SQL オラクル検証 | 固定フラクショナル(size=0.01) |
複数ポジション戦略テンプレート
class Gen600Strategy(Strategy):
def next(self):
current_bar = len(self.data) - 1
# 1. Register newly filled trades and set barriers
for trade in self.trades:
tid = id(trade)
if tid not in self._known_trades:
self._known_trades.add(tid)
self._trade_entry_bar[tid] = current_bar
actual_entry = trade.entry_price
if self.tp_mult > 0:
trade.tp = actual_entry * (1.0 + self.tp_mult * self.threshold_pct)
if self.sl_mult > 0:
trade.sl = actual_entry * (1.0 - self.sl_mult * self.threshold_pct)
# 2. Check time barrier for each open trade
for trade in list(self.trades):
tid = id(trade)
entry_bar = self._trade_entry_bar.get(tid, current_bar)
if self.max_bars > 0 and (current_bar - entry_bar) >= self.max_bars:
trade.close()
self._trade_entry_bar.pop(tid, None)
# 3. Check for new signal (no position guard — overlapping allowed)
if self._is_signal[current_bar]:
self.buy(size=0.01)
データの読み込み
from data_loader import load_range_bars
df = load_range_bars(
symbol="SOLUSDT",
threshold=1000,
start="2017-01-01", # Cover all available data
end="2025-02-05", # Match SQL cutoff
extra_columns=["volume_per_trade", "lookback_price_range"], # Gen600 features
)
HTML 資産プロット:ズーム時の Y 軸自動フィット(BP-07~BP-10)
backtesting.py は bt.plot() を使って Bokeh の HTML プロットを生成します。デフォルトでは Y 軸は固定されており、X 軸をズームしても、Y 軸は表示データに合わせて再スケーリングされません。そのため、4 桁以上のオーダーにわたる資産曲線の拡大領域を調べることができません。
参照実装:example-org/research-repo の scripts/gen800/plotting.py。
BP-07:資産に LogScale を絶対に使わない(重要)
症状:ズーム時に資産パネルの Y 軸が自動フィットしません。JS コールバックはエラーを出さずに失敗します。
根本原因:Bokeh の LogScale は、複数パネルがリンクされた x_range を共有するレイアウトで、CustomJS の Y 範囲コールバックを壊します。js_on_change コールバックは実行されますが、LogScale レンダラーでは y_range.start/y_range.end への代入が無視されます。
POC による実証:LogScale と JS コールバックの組み合わせは単一パネルモードでは機能しますが、5 つのパネルがリンクされた x_range を共有する場合は失敗します。
修正方法:Python で資産データを log10() に変換し、線形スケールで表示します。CustomJSTickFormatter を使って 10^tick を読みやすい値として表示します。
import numpy as np
from bokeh.models import CustomJSTickFormatter, Range1d
# Transform data
raw = np.asarray(src.data["equity"], dtype=float)
raw = np.where(raw > 0, raw, 1e-10)
src.data["equity"] = np.log10(raw).tolist()
# Custom tick formatter (shows 1%, 100%, 10K%, 1M%)
child.yaxis[0].formatter = CustomJSTickFormatter(code="""
const v = Math.pow(10, tick);
if (v >= 1e6) return (v/1e6).toFixed(1) + 'M%';
if (v >= 1e3) return (v/1e3).toFixed(0) + 'K%';
if (v >= 1) return v.toFixed(0) + '%';
return v.toFixed(2) + '%';
""")
# MUST set initial y_range explicitly (backtesting.py leaves it as NaN)
valid = eq[np.isfinite(eq)]
pad = (valid.max() - valid.min()) * 0.05
child.y_range = Range1d(start=valid.min() - pad, end=valid.max() + pad)
BP-08:パネルを考慮した列の照合(重要)
症状:ズーム時にドローダウン/損益パネルが空白になります。数百万パーセントと表示されます。
根本原因:複数のパネルが同じ ColumnDataSource を共有します(backtesting.py の最適化)。資産パネルの Line レンダラーとドローダウンパネルの Line レンダラーは、どちらも equity、drawdown、High、Low などを含むソースを参照します。単純に「最初に見つかった y 列」を使う方法では、ドローダウンパネルに equity が選ばれてしまいます。
修正方法:まず panel_label で照合し、次に列名で照合します。
panel_label = child.yaxis[0].axis_label or ""
# Label-first matching (panels share data sources!)
if "Drawdown" in panel_label and "drawdown" in d:
hi_col = lo_col = "drawdown"
elif "Equity" in panel_label and "equity" in d:
hi_col = lo_col = "equity"
elif "High" in d and "Low" in d: # OHLC
hi_col, lo_col = "High", "Low"
BP-09:Y 軸自動フィットの JS コールバックパターン(実証済み)
各パネルの x_range.js_on_change に CustomJS コールバックを追加します。コールバックは表示範囲のデータを走査し、y_range を直接設定します。
from bokeh.models import CustomJS
cb = CustomJS(
args={"source": src, "yr": child.y_range,
"x_col": "index", "y_col": "equity"},
code="""
const xs = source.data[x_col];
const ys = source.data[y_col];
const x0 = cb_obj.start, x1 = cb_obj.end;
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < xs.length; i++) {
if (xs[i] >= x0 && xs[i] <= x1 && isFinite(ys[i])) {
if (ys[i] < lo) lo = ys[i];
if (ys[i] > hi) hi = ys[i];
}
}
if (isFinite(lo) && isFinite(hi) && lo < hi) {
const pad = (hi - lo) * 0.05 || 0.001;
yr.start = lo - pad;
yr.end = hi + pad;
}
""",
)
child.x_range.js_on_change("start", cb)
child.x_range.js_on_change("end", cb)
BP-10:DataRange1d(only_visible=True) は信頼性がありません
症状:DataRange1d(only_visible=True) は単純なケースでは機能しますが、以下の場合に失敗します。
- LogScale パネル(Y 範囲が誤った空間で計算される)
- VBar レンダラー(ローソク足。境界が正しく追跡されない)
- パネル間で共有されたデータソース
修正方法:DataRange1d(only_visible=True) ではなく、常に JS コールバックパターン(BP-09)を使います。
パネルデータソースのリファレンス(backtesting.py の内部仕様)
| パネル | ラベル | レンダラー | Y 列 | ソース |
|---|---|---|---|---|
| Equity | "Equity" | Patch(equity_dd)、Line(equity) | equity | 共有 OHLC ソース |
| Drawdown | "Drawdown" | Line(drawdown)、Scatter(ピーク) | drawdown | 共有 OHLC ソース |
| P/L | "Profit / Loss" | Scatter(returns)、MultiLine | y または returns | 個別の取引ソース |
| OHLC | ""(ラベルなし) | Segment、VBar | High/Low | 共有 OHLC ソース |
| Volume | "Volume" | VBar | Volume(上部) | 共有 OHLC ソース |
プロジェクト成果物(rangebar-patterns リポジトリ)
| 成果物 | パス |
|---|---|
| Oracle 比較スクリプト | scripts/gen600_oracle_compare.py |
| Gen600 戦略(リファレンス) | backtest/backtesting_py/gen600_strategy.py |
| SQL Oracle クエリテンプレート | sql/gen600_oracle_trades.sql |
| Oracle 検証結果 | findings/2026-02-12-gen600-oracle-validation.md |
| バックテスト CLAUDE.md | backtest/CLAUDE.md |
| ClickHouse AP-16 | .claude/skills/clickhouse-antipatterns/SKILL.md |
| フォークのソース | ~/fork-tools/backtesting.py/ |
実行後の振り返り
このスキルの実行が完了したら、終了前に以下を確認してください。
- コマンドは成功しましたか? — 成功しなかった場合は、失敗の原因となった手順またはエラーテーブルを修正してください。
- パラメーターや出力は変わりましたか? — 基盤となるツールのインターフェースに変更があった場合は、使用例とパラメーター表をそれに合わせて更新してください。
- 回避策が必要でしたか? — 別のフラグや追加手順などの工夫が必要だった場合は、次回同じ回避策が不要になるよう、この SKILL.md を更新してください。
問題が実際に発生し、再現可能な場合にのみ更新してください。推測に基づいて更新しないでください。