backtesting.py Oracle Validation for Range Bar Patterns
Configuration and anti-patterns for using backtesting.py to validate ClickHouse SQL sweep results. Ensures bit-atomic replicability between SQL and Python trade evaluation.
Companion skills: clickhouse-antipatterns (SQL correctness, AP-16) | sweep-methodology (sweep design) | rangebar-eval-metrics (evaluation metrics)
Validated: Gen600 oracle verification (2026-02-12) — 3 assets, 5 gates, ALL PASS.
Critical Configuration (NEVER omit)
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 )
Why: SQL evaluates each signal independently (overlapping trades allowed). Without hedging=True , backtesting.py skips signals while a position is open, producing fewer trades than SQL. This was discovered when SOLUSDT produced 105 Python trades vs 121 SQL trades — 16 signals were silently skipped.
Anti-Patterns (Ordered by Severity)
BP-01: Missing Multi-Position Mode (CRITICAL)
Symptom: Python produces fewer trades than SQL. Gate 1 (signal count) fails.
Root Cause: Default exclusive_orders=True prevents opening new positions while one is active.
Fix: Always use hedging=True, exclusive_orders=False .
BP-02: ExitTime Sort Order (CRITICAL)
Symptom: Entry prices appear mismatched (Gate 3 fails) even though both SQL and Python use the same price source.
Root Cause: stats._trades is sorted by ExitTime, not EntryTime. When overlapping trades exit in a different order than they entered, trade[i] no longer maps to signal[i].
Fix:
trades = stats._trades.sort_values("EntryTime").reset_index(drop=True)
BP-03: NaN Poisoning in Rolling Quantile (CRITICAL)
Symptom: Cross-asset tests fail with far fewer Python trades. Feature quantile becomes NaN and propagates forward indefinitely.
Root Cause: np.percentile with NaN inputs returns NaN. If even one NaN feature value enters the rolling window, all subsequent quantiles become NaN, making all subsequent filter comparisons fail.
Fix: Skip NaN values when building the signal window:
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: Data Range Mismatch (MODERATE)
Symptom: Different signal counts between SQL and Python for assets with early data (BNB, XRP).
Root Cause: load_range_bars() defaults to start='2020-01-01' but SQL has no lower bound.
Fix: Always pass start='2017-01-01' to cover all available data.
BP-05: Margin Exhaustion with Overlapping Positions (MODERATE)
Symptom: Orders canceled with insufficient margin. Fewer trades than expected.
Root Cause: With hedging=True and default full-equity sizing, overlapping positions exhaust available margin.
Fix: Use fixed fractional sizing:
self.buy(size=0.01) # 1% equity per trade
BP-06: Signal Timestamp vs Entry Timestamp (LOW)
Symptom: Gate 2 (timestamp match) fails because SQL uses signal bar timestamps while Python uses entry bar timestamps.
Root Cause: SQL outputs the signal detection bar's timestamp_ms . Python's EntryTime is the fill bar (next bar after signal). These differ by 1 bar.
Fix: Record signal bar timestamps in the strategy's next() method:
Before calling self.buy()
self._signal_timestamps.append(int(self.data.index[-1].timestamp() * 1000))
5-Gate Oracle Validation Framework
Gate Metric Threshold What it catches
1 Signal Count <5% diff Missing signals, filter misalignment
2 Timestamp Match
95% Timing offset, warmup differences
3 Entry Price
95% Price source mismatch, sort ordering
4 Exit Type
90% Barrier logic differences
5 Kelly Fraction <0.02 Aggregate outcome alignment
Expected residual: 1-2 exit type mismatches per asset at TIME barrier boundary (bar 50). SQL uses fwd_closes[max_bars] , backtesting.py closes at current bar price. Impact on Kelly < 0.006.
Strategy Architecture: Single vs Multi-Position
Mode Constructor Use Case Position Sizing
Single-position hedging=False (default) Champion 1-bar hold Full equity
Multi-position hedging=True, exclusive_orders=False
SQL oracle validation Fixed fractional (size=0.01 )
Multi-Position Strategy Template
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)
Data Loading
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 )
Project Artifacts (rangebar-patterns repo)
Artifact Path
Oracle comparison script scripts/gen600_oracle_compare.py
Gen600 strategy (reference) backtest/backtesting_py/gen600_strategy.py
SQL oracle query template sql/gen600_oracle_trades.sql
Oracle validation findings findings/2026-02-12-gen600-oracle-validation.md
Backtest CLAUDE.md backtest/CLAUDE.md
ClickHouse AP-16 .claude/skills/clickhouse-antipatterns/SKILL.md
Fork source ~/fork-tools/backtesting.py/