跳轉到

回測引擎

在較高時間框架上的標準回測有一個根本性缺陷:訊號在已完成的 K 棒上計算,而成交則在 K 棒收盤時執行。4 小時 K 棒上的 MACD 交叉可能在 K 棒開始 2 小時後於 $49,800 觸發,但回測卻在 K 棒收盤時記錄成交——$51,200。誤差隨時間框架增大:1 分鐘資料可忽略不計,4 小時可達 $1,000+,日線可達 $5,000+(以 BTC 級別資產而言)。放大鏡技術 (Magnifier Technique) 透過在子 K 棒解析度模擬盤中執行來解決此問題,在訊號實際觸發的時刻成交。

為什麼這對交易者重要

誇大的回測報酬導致過度自信的倉位配置和意外的實盤回撤。一個以收盤價成交顯示 +40% 年報酬的策略,實際上可能只有 +25% — 更糟的情況是,將虧損策略偽裝成盈利策略。Magnifier 技術在子 K 棒解析度內產生成交價格(例如 4h 圖表的 15 分鐘刻度),讓交易者在投入資金前獲得真實的損益預期。


兩種執行模式

標準模式 放大鏡模式
旗標 --no-magnify 預設(時間框架 > 1 分鐘)
訊號計算 一次性對完整 DataFrame 計算 逐子 K 棒漸進式計算
成交價格 K 棒收盤價 觸發時刻的子 K 棒收盤價
速度 非常快(單次向量化呼叫) 中等(約 10K 次 compute 呼叫 / 1,000 根 K 棒)
成交真實性 較高時間框架上較差 良好(子 K 棒解析度成交)
使用場景 1 分鐘資料、參數掃描、快速篩選 1 小時以上時間框架的正式回測
運算路徑 _compute(df, params) — pandas 優先使用 _compute_fast(),退回使用 _compute()
flowchart TB SRC["PineScript 策略"] --> COMPILE["編譯<br/>(詞法分析 → 語法分析 → 程式碼產生)"] COMPILE --> DECIDE{"時間框架 > 1m<br/>且啟用放大鏡?"} DECIDE -->|"是"| MAG["放大鏡模式<br/>子 K 棒迭代"] DECIDE -->|"否"| STD["標準模式<br/>單次向量化呼叫"] STD --> VBT["vectorbt<br/>Portfolio.from_signals()"] MAG --> VBT VBT --> RESULT["BacktestResult<br/>統計 + 權益 + 交易"]

放大鏡技術

概念

對於每根圖表時間框架的 K 棒,放大鏡以更細的解析度逐一迭代子 K 棒,漸進式建構一根「形成中」的 OHLCV K 棒。在每個子 K 棒 tick,它以一組已完成的 K 棒加上形成中的 K 棒來執行策略。一旦訊號觸發,進出場就記錄在該子 K 棒的收盤價——而非圖表 K 棒的收盤價。

圖表 K 棒(4h):  [════════════════════════════════════]
                     開盤 $50,000                   收盤 $51,200

放大鏡(15m):     [══|══|══|══|══|══|══|══|══|══|══|══|══|══|══|══]
                          Tick 4:MACD 交叉觸發
                          以 $49,800 成交(子 K 棒收盤價)
                          跳過剩餘 12 個 tick

詳細流程

sequenceDiagram participant B as 回測器 participant D as 資料來源 participant M as 放大鏡迴圈 participant S as 策略(compute) participant V as vectorbt B->>D: 載入 1m 基礎資料 D-->>B: df_1m(原始 K 棒) B->>B: 重新取樣至圖表時間框架(如 4h) B->>B: 重新取樣至放大鏡時間框架(如 15m) B->>B: 選擇解析度:4h → 15m(16 個 tick) loop 對每根圖表 K 棒(暖身期後) B->>M: 處理 K 棒 [bar_start, bar_end] M->>M: 取得該 K 棒內的放大鏡 tick M->>M: 初始化形成中的 K 棒(open = 第一個 tick 的 open) loop 對每個子 K 棒 tick M->>M: 更新形成中的 OHLCV<br/>high = max, low = min, close = tick close M->>M: 建構窗口 = 已完成 K 棒 + 形成中 K 棒 M->>S: compute(window, params) S-->>M: (long_entry, long_exit, short_entry, short_exit) alt 在窗口最後一根 K 棒觸發訊號 M->>M: 記錄訊號於 tick 索引 M->>M: 更新倉位狀態(in_long / in_short) M->>M: 跳出 — 每根圖表 K 棒一個訊號 end end end B->>V: Portfolio.from_signals(mag_close, signals) V-->>B: 具真實成交的投資組合 B->>B: 萃取 BacktestResult

演算法

for bar_idx in range(warmup, len(df_chart)):
    bar_start = df_chart.index[bar_idx]
    bar_end = bar_start + chart_timedelta

    # Magnifier ticks within this chart bar
    mag_start = df_mag.index.searchsorted(bar_start, side="left")
    mag_end   = df_mag.index.searchsorted(bar_end,   side="left")

    # Completed chart-TF bars as indicator context
    completed = df_chart.iloc[max(0, bar_idx - warmup*3) : bar_idx]

    # Progressive forming bar
    forming_open = df_mag["open"].iloc[mag_start]
    forming_high, forming_low, forming_vol = -inf, inf, 0.0

    for pos in range(mag_start, mag_end):
        forming_high  = max(forming_high, df_mag["high"].iloc[pos])
        forming_low   = min(forming_low,  df_mag["low"].iloc[pos])
        forming_close = df_mag["close"].iloc[pos]
        forming_vol  += df_mag["volume"].iloc[pos]

        window = concat([completed, forming_bar])
        le, lx, se, sx = strategy.compute(window, params)

        if signal_triggered(le, lx, se, sx):
            record_signal_at(pos)
            break  # One signal per chart bar

關鍵:訊號後 break

每根圖表 K 棒僅允許一個訊號。記錄訊號後,放大鏡會跳出子 K 棒迴圈。若沒有此 break,同一根 K 棒可能產生多個相互矛盾的進出場。


動態解析度

放大鏡會選擇一個能整除圖表時間框架的子 K 棒解析度,目標約為每根 K 棒 10 個 tick(最多約 16 個):

圖表時間框架 放大鏡時間框架 每根 K 棒的 Tick 數
5m 1m 5
15m 1m 15
30m 3m 10
1h 5m 12
4h 15m 16
1d 1h 24
VALID_RESOLUTIONS = [1, 3, 5, 15, 30, 60, 240]  # minutes

def pick_magnifier_resolution(chart_tf, target_ticks=10):
    chart_min = TF_MINUTES[chart_tf]
    if chart_min <= 1:
        return "1m"
    max_ticks = int(target_ticks * 1.6)  # ~16
    best = "1m"
    for res in VALID_RESOLUTIONS:
        if res >= chart_min or chart_min % res != 0:
            continue
        ticks = chart_min // res
        if ticks > max_ticks:
            continue
        if abs(ticks - target_ticks) < best_dist:
            best = minutes_to_tf(res)
    return best

需求:1 分鐘基礎資料

放大鏡需要 1 分鐘 K 線資料作為基礎解析度。所有較高時間框架均從 1 分鐘資料重新取樣。若 1 分鐘資料不可用,回測器會自動退回標準模式。


效能最佳化

快速路徑 vs. 慢速路徑

路徑 使用時機 每次呼叫額外負擔
快速路徑_compute_fast 策略具有純 NumPy 實作 ~0.1ms
慢速路徑_compute 複雜策略、退回機制 ~2ms

對於 4 小時回測,跨越 1,000 根圖表 K 棒,每根有 16 個放大鏡 tick:

  • 快速路徑: 16,000 次呼叫 x 0.1ms = 1.6 秒
  • 慢速路徑: 16,000 次呼叫 x 2ms = 32 秒

PineScript 編譯器會在可能時產生兩條路徑。回測器優先嘗試 _compute_fast,若不可用則透明地退回 _compute

效能矩陣

組態 速度 成交真實性 適合用途
標準,1m 優秀 正式環境(1m 已具有 K 棒級精度)
標準,4h 非常快 參數掃描、篩選
放大鏡,1h(快速路徑) ~2 秒 良好(5m 成交) 正式回測
放大鏡,4h(快速路徑) ~4 秒 良好(15m 成交) 正式回測
放大鏡,1d(快速路徑) ~6 秒 良好(1h 成交) 日線策略

回測結果模型 (BacktestResult)

回測器將所有結果萃取至結構化、可 JSON 序列化的模型中:

中繼資料

欄位 型別 描述
strategy_name str 來自 strategy("name", ...)
symbol str 交易標的(如 BTCUSDT
exchange str 資料來源交易所
timeframe str 圖表時間框架(1h4h1d
period str 日期範圍(2024-01-01 to 2025-01-01
mode str standardmagnifier
params dict 使用的策略輸入參數

績效指標

欄位 型別 範例
total_return_pct float +28.82
annualized_return_pct float +34.15
sharpe_ratio float \| null 2.15
sortino_ratio float \| null 3.20
calmar_ratio float \| null 1.85
max_drawdown_pct float -12.50

交易統計

欄位 型別 範例
total_trades int 28
win_rate_pct float 57.14
profit_factor float 1.85
expectancy float 106.75
best_trade_pct float +8.32
worst_trade_pct float -4.15
avg_trade_pct float +1.03

時間序列(取樣後)

欄位 資料點數 格式
equity_curve ~1,000 [{timestamp, value}, ...]
returns ~1,000 [{timestamp, return}, ...]
drawdown_curve ~1,000 [{timestamp, drawdown_pct}, ...]

明細記錄

欄位 型別 關鍵欄位
trades list[TradeRecord] 方向、進出場時間+價格、損益、報酬率、持有時間
orders list[OrderRecord] 時間戳、方向、價格、數量、手續費
drawdowns list[DrawdownRecord] 高點時間、低點時間、回撤百分比、持續時間

圖表資料

欄位 資料點數 用途
ohlcv_bars ~5,000 用於圖表渲染的 OHLCV 資料
trade_markers 每筆交易 價格圖表上的進出場疊加標記

NaN → null

許多統計在未定義時回傳 NaN(如零變異數下的 Sharpe、無虧損交易時的 Profit Factor)。結果萃取器在 JSON 序列化前將所有 NaNInf 值轉換為 null。前端將 null 指標顯示為 ,而非 0


資料來源

回測器透過 DataSource 協定 (Protocol) 載入資料:

class DataSource(Protocol):
    def load_1m(self, symbol: str, exchange: str,
                start: datetime, end: datetime) -> pd.DataFrame:
        """Load 1-minute OHLCV candles."""
        ...
來源 狀態 儲存方式 備註
DiskSource 啟用中 .npz 檔案(透過 CCXT) 壓縮 NumPy 存檔,本地快速讀取
TimescaleSource 規劃中 PostgreSQL / TimescaleDB 使用連續聚合的超表 (Hypertable),跨使用者共享

所有較高時間框架均從 1 分鐘基礎資料重新取樣:

def resample_ohlcv(df_1m: pd.DataFrame, target_tf: str) -> pd.DataFrame:
    rule = TF_RESAMPLE_RULES[target_tf]  # "5T", "1H", "4H", "1D"
    return df_1m.resample(rule).agg({
        "open": "first",
        "high": "max",
        "low": "min",
        "close": "last",
        "volume": "sum",
    }).dropna()

使用範例

# Standard magnifier mode (default for TF > 1m)
python -m backtest --script strategies/macd_crossover.pine --timeframe 4h

# Disable magnifier for faster parameter sweeps
python -m backtest --script strategies/supertrend.pine --timeframe 1h --no-magnify

# JSON output for programmatic consumption
python -m backtest --script strategies/rsi_overbought.pine --timeframe 1d --json

# Specify date range and initial capital
python -m backtest --script strategies/bb_squeeze.pine --timeframe 4h \
    --start 2024-01-01 --end 2025-01-01 --capital 50000

# Override strategy parameters
python -m backtest --script strategies/macd_crossover.pine --timeframe 1h \
    --param "Fast Length=8" --param "Slow Length=21"

檔案對應表

概念 檔案
回測器類別 backtest/backtester.pyBacktester
標準模式執行 backtest/backtester.py_run_standard
放大鏡模式執行 backtest/backtester.py_run_magnified
結果萃取 backtest/backtester.py_extract_result
BacktestResult 資料類別 backtest/strategy.pyBacktestResult
TradeRecord / OrderRecord / DrawdownRecord backtest/strategy.py
動態解析度選擇器 backtest/backtester.pypick_magnifier_resolution
DataSource 協定 backtest/backtester.py
CLI 進入點 backtest/__main__.py