重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
backtesting-py-oracle by terrylica/cc-skills
npx skills add https://github.com/terrylica/cc-skills --skill backtesting-py-oracle用于验证 ClickHouse SQL 扫描结果的 backtesting.py 配置和反模式。确保 SQL 和 Python 交易评估之间的位原子级可复现性。
配套技能 : clickhouse-antipatterns (SQL 正确性, AP-16) | sweep-methodology (扫描设计) | rangebar-eval-metrics (评估指标)
已验证 : Gen600 oracle 验证 (2026-02-12) — 3 种资产, 5 个关卡, 全部通过。
自我进化技能 : 此技能通过使用而改进。如果指令错误、参数漂移或需要变通方案 — 请立即修复此文件,不要推迟。仅针对真实、可复现的问题进行更新。
from backtesting import Backtest
bt = Backtest(
df,
Strategy,
cash=100_000,
commission=0,
hedging=True, # 必需:允许多个并发持仓
exclusive_orders=False, # 必需:新信号出现时不自动平仓
)
原因 : SQL 独立评估每个信号(允许重叠交易)。没有 hedging=True,backtesting.py 会在有持仓时跳过信号,导致比 SQL 更少的交易。这是在 SOLUSDT 产生 105 个 Python 交易 vs 121 个 SQL 交易时发现的 — 16 个信号被静默跳过。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
症状 : Python 产生的交易数量少于 SQL。关卡 1 (信号数量) 失败。
根本原因 : 默认 exclusive_orders=True 阻止在有活跃持仓时开新仓。
修复 : 始终使用 hedging=True, exclusive_orders=False。
症状 : 即使 SQL 和 Python 使用相同的价格源,入场价格也显示不匹配 (关卡 3 失败)。
根本原因 : stats._trades 按 ExitTime 排序,而不是 EntryTime。当重叠交易的退出顺序与入场顺序不同时,trade[i] 不再映射到 signal[i]。
修复 :
trades = stats._trades.sort_values("EntryTime").reset_index(drop=True)
症状 : 跨资产测试失败,Python 交易数量少得多。特征分位数变为 NaN 并无限向前传播。
根本原因 : 带有 NaN 输入的 np.percentile 返回 NaN。如果即使一个 NaN 特征值进入滚动窗口,所有后续分位数都会变为 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)
# 仅追加非 NaN 值 (匹配 SQL quantileExactExclusive NULL 处理)
if not np.isnan(feature_arr[i]):
signal_values.append(feature_arr[i])
return result
症状 : 对于具有早期数据的资产 (BNB, XRP),SQL 和 Python 之间的信号数量不同。
根本原因 : load_range_bars() 默认 start='2020-01-01',但 SQL 没有下限。
修复 : 始终传递 start='2017-01-01' 以覆盖所有可用数据。
症状 : 订单因保证金不足被取消。交易数量少于预期。
根本原因 : 使用 hedging=True 和默认的全权益头寸规模时,重叠持仓会耗尽可用保证金。
修复 : 使用固定比例头寸规模:
self.buy(size=0.01) # 每笔交易 1% 的权益
症状 : 关卡 2 (时间戳匹配) 失败,因为 SQL 使用信号柱的时间戳,而 Python 使用入场柱的时间戳。
根本原因 : SQL 输出信号检测柱的 timestamp_ms。Python 的 EntryTime 是成交柱 (信号后的下一根柱)。这两者相差 1 根柱。
修复 : 在策略的 next() 方法中记录信号柱时间戳:
# 在调用 self.buy() 之前
self._signal_timestamps.append(int(self.data.index[-1].timestamp() * 1000))
| 关卡 | 指标 | 阈值 | 捕获的问题 |
|---|---|---|---|
| 1 | 信号数量 | <5% 差异 | 信号缺失,过滤器错位 |
| 2 | 时间戳匹配 | >95% | 时间偏移,预热差异 |
| 3 | 入场价格 | >95% | 价格源不匹配,排序问题 |
| 4 | 退出类型 | >90% | 屏障逻辑差异 |
| 5 | 凯利分数 | <0.02 | 聚合结果对齐 |
预期残差 : 每个资产在 TIME 屏障边界 (第 50 根柱) 处有 1-2 个退出类型不匹配。SQL 使用 fwd_closes[max_bars],backtesting.py 在当前柱价格平仓。对凯利分数的影响 < 0.006。
| 模式 | 构造函数 | 用例 | 头寸规模 |
|---|---|---|---|
| 单持仓 | hedging=False (默认) | 冠军 1 柱持有 | 全权益 |
| 多持仓 | hedging=True, exclusive_orders=False | SQL oracle 验证 | 固定比例 (size=0.01) |
class Gen600Strategy(Strategy):
def next(self):
current_bar = len(self.data) - 1
# 1. 注册新成交的交易并设置屏障
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. 检查每个未平仓交易的时间屏障
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. 检查新信号 (无持仓守卫 — 允许重叠)
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", # 覆盖所有可用数据
end="2025-02-05", # 匹配 SQL 截止日期
extra_columns=["volume_per_trade", "lookback_price_range"], # Gen600 特征
)
backtesting.py 通过 bt.plot() 生成 Bokeh HTML 图表。默认情况下,y 轴是固定的 — 在 x 轴上缩放时,y 轴不会重新缩放以适应可见数据。这使得无法检查跨越 4 个以上数量级的权益曲线的缩放区域。
参考实现 : opendeviationbar-patterns 中的 scripts/gen800/plotting.py。
症状 : 权益面板的 y 轴在缩放时不会自动适配。JS 回调静默失败。
根本原因 : Bokeh 的 LogScale 在多面板共享链接 x_range 的布局中破坏了 CustomJS y 范围回调。js_on_change 回调会触发,但 y_range.start/y_range.end 赋值被 LogScale 渲染器忽略。
通过 POC 验证 : LogScale + JS 回调在单面板模式下有效,但在 5 个面板共享链接 x_range 时失败。
修复 : 在 Python 中将权益数据转换为 log10(),在线性刻度上显示,并使用 CustomJSTickFormatter 将 10^tick 显示为可读值:
import numpy as np
from bokeh.models import CustomJSTickFormatter, Range1d
# 转换数据
raw = np.asarray(src.data["equity"], dtype=float)
raw = np.where(raw > 0, raw, 1e-10)
src.data["equity"] = np.log10(raw).tolist()
# 自定义刻度格式化器 (显示 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) + '%';
""")
# 必须显式设置初始 y_range (backtesting.py 将其留为 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)
症状 : 缩放时,回撤/P&L 面板变为空白。显示数百万百分比。
根本原因 : 多个面板共享相同的 ColumnDataSource (backtesting.py 优化)。权益面板的 Line 渲染器和回撤面板的 Line 渲染器都引用一个包含 equity、drawdown、High、Low 等的源。一个简单的“查找第一个 y 列”方法会为回撤面板选择 equity。
修复 : 首先按 panel_label 匹配,然后按列名匹配:
panel_label = child.yaxis[0].axis_label or ""
# 标签优先匹配 (面板共享数据源!)
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"
为每个面板的 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)
症状 : DataRange1d(only_visible=True) 适用于简单情况,但在以下情况下失败:
修复 : 始终使用 JS 回调模式 (BP-09) 而不是 DataRange1d(only_visible=True)。
| 面板 | 标签 | 渲染器 | Y 列 | 数据源 |
|---|---|---|---|---|
| 权益 | "Equity" | Patch (equity_dd), Line (equity) | equity | 共享的 OHLC 源 |
| 回撤 | "Drawdown" | Line (drawdown), Scatter (峰值) | drawdown | 共享的 OHLC 源 |
| 盈亏 | "Profit / Loss" | Scatter (returns), MultiLine | y 或 returns | 独立的交易源 |
| OHLC | "" (无标签) | Segment, VBar | High/Low | 共享的 OHLC 源 |
| 成交量 | "Volume" | VBar | Volume (顶部) | 共享的 OHLC 源 |
| 产物 | 路径 |
|---|---|
| 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/ |
此技能完成后,在关闭前检查:
仅当问题是真实且可复现时才更新 — 而非推测性的。
每周安装次数
67
仓库
GitHub 星标
28
首次出现
2026年2月13日
安全审计
安装于
opencode63
github-copilot62
codex62
kimi-cli62
gemini-cli62
amp62
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.
Self-Evolving Skill : This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
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.
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.
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)
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
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.
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
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))
| 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.
| 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) |
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
)
backtesting.py generates Bokeh HTML plots via bt.plot(). By default, the y-axis is fixed — zooming on the x-axis does NOT rescale the y-axis to fit visible data. This makes it impossible to inspect zoomed-in regions of equity curves that span 4+ orders of magnitude.
Reference implementation : scripts/gen800/plotting.py in opendeviationbar-patterns.
Symptom : Equity panel y-axis doesn't auto-fit on zoom. JS callbacks fail silently.
Root Cause : Bokeh's LogScale breaks CustomJS y-range callbacks in multi-panel linked x_range layouts. The js_on_change callback fires but y_range.start/y_range.end assignments are ignored by the LogScale renderer.
Proven via POC : LogScale + JS callback works in single-panel mode but fails when 5 panels share a linked x_range.
Fix : Transform equity data to log10() in Python, display on linear scale with a CustomJSTickFormatter that shows 10^tick as readable values:
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)
Symptom : Drawdown/P&L panels go blank when zooming. Shows millions of percent.
Root Cause : Multiple panels share the same ColumnDataSource (backtesting.py optimization). The Equity panel's Line renderer and the Drawdown panel's Line renderer both reference a source containing equity, drawdown, High, Low, etc. A naive "find first y column" approach picks equity for the Drawdown panel.
Fix : Match by panel_label FIRST, then by column name:
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"
Attach a CustomJS callback to x_range.js_on_change for each panel. The callback scans visible data and sets y_range directly:
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)
Symptom : DataRange1d(only_visible=True) works for simple cases but fails with:
Fix : Always use the JS callback pattern (BP-09) instead of DataRange1d(only_visible=True).
| Panel | Label | Renderer | Y Column | Source |
|---|---|---|---|---|
| Equity | "Equity" | Patch (equity_dd), Line (equity) | equity | Shared OHLC source |
| Drawdown | "Drawdown" | Line (drawdown), Scatter (peak) | drawdown |
| 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 |
After this skill completes, check before closing:
Only update if the issue is real and reproducible — not speculative.
Weekly Installs
67
Repository
GitHub Stars
28
First Seen
Feb 13, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode63
github-copilot62
codex62
kimi-cli62
gemini-cli62
amp62
前端代码审计工具 - 自动化检测可访问性、性能、响应式设计、主题化与反模式
49,600 周安装
| Shared OHLC source |
| P/L | "Profit / Loss" | Scatter (returns), MultiLine | y or returns | Separate trade source |
| OHLC | "" (no label) | Segment, VBar | High/Low | Shared OHLC source |
| Volume | "Volume" | VBar | Volume (top) | Shared OHLC source |
| Fork source | ~/fork-tools/backtesting.py/ |