回測與最佳化 / Backtesting & Optimization

回測引擎(backtest/engine.py)

逐根 K 線模擬做多進出,完全離線、可重現。

流程:從第 2 根起,對「目前看到的歷史切片」呼叫策略 generate:

  • buy 且空手:以 position_fraction 比例的現金買進
  • sell 且持有:全數賣出,記錄一筆交易
  • 策略資料不足(ValueError)視為 hold

每根記錄權益(現金+部位市值),計算回撤。

成交時點(M0.2,消除前視偏差)

訊號以「資料 ≤ close[i]」決策,但成交於下一根開盤 open[i+1]——絕不用觸發訊號的當根收盤成交(那是前視偏差:實際下單時無法知道當根會收在哪)。最後一根訊號無次根可成交→不開新倉(明確記為無交易)。權益於每根 close[i] 標記,反映「至該根開盤前已成交」建立的部位。

交易成本(M0.1)

每一筆成交都套用 trading/costs.pyCostModel(手續費、台股證交稅僅賣出、滑價),成本預設 ON——零成本的報酬數字是錯的。run_backtest(..., market=..., cost_model=None):cost_model=NoneSettings 設定值;測量毛報酬時傳 CostModel.zero()Trade.pnl淨額,另有 gross_pnlcost 明細。買→賣的已實現淨損益恆等於 gross_pnl − buy_cost − sell_cost − sell_tax

風險/報酬指標(M0.3)

純函式在 backtest/metrics.py(可單元測試、walk_forward M0.4 共用)。年化用的 periods_per_yeartimeframe 推導(如 1h→8766、1d→365.25);無風險利率由 Settings.backtest_risk_free_rate 設定(預設 0)。

run_backtest(candles, strategy, starting_cash=100000, position_fraction=1.0, market=crypto, cost_model=None, timeframe="1h", risk_free_rate=None) -> BacktestResult:

指標說明
total_return_pct / buy_hold_return_pct總報酬(已計入成本)/ 買入持有對照
cagr年化複合成長率
annualized_volatility年化波動度(每根報酬樣本標準差 × √ppy)
sharpe / sortino風險調整報酬(Sortino 只罰下檔波動)
calmarCAGR / 最大回撤
profit_factor毛利 / 毛損(無虧損交易時為 null)
avg_win / avg_loss平均獲利 / 平均虧損(淨)
exposure_pct持倉根數佔比
max_consecutive_losses最長連續虧損次數
turnover總成交名目 / 起始現金
num_trades / wins / win_rate完成交易數 / 獲利筆數(淨) / 勝率(不可作唯一排序依據)
max_drawdown_pct權益曲線最大回撤
trades[] / equity_curve[]交易明細(含 gross_pnl/cost)與權益曲線
繪製圖表中…

多策略比較(api/backtest.py /compare)

同一段歷史一次跑完多個策略(只抓一次行情),依 total_return_pct 排名,回傳每策略摘要。 單一策略出錯不影響其他(該列帶 error)。

參數最佳化(backtest/optimize.py)

grid_search(candles, strategy_name, param_grid, metric, max_combinations=200):

  • param_grid(如 {fast:[5,10,15], slow:[20,30,40]})做笛卡兒積
  • 每組合跑一次回測,依 metric(total_return_pctwin_rate)排名
  • 組合數超過上限 fail loud;單組合出錯記為 error 列並排到最後
繪製圖表中…

前端 BacktestPanel 提供 Run / Compare all / Optimize 三鈕;最佳化結果可一鍵「use」套回參數。

樣本外排序與 Walk-forward(M0.4,消除過擬合)

只在「全資料樣本內」挑報酬最高的參數會過擬合——那組數字在實盤通常崩掉。M0.4 引入兩個機制。

grid_search 的 train/test 切分模式

grid_search(..., split=True, oos_fraction=0.3, rank_metric="oos_sharpe"):

  • 把歷史切成「樣本內(IS)前段」與「樣本外(OOS)後段 = oos_fraction」。
  • 每組參數在 IS 與 OOS 各跑一次 run_backtest,OptimizeRow 同時揭露兩邊:is_return_pctoos_return_pctis_oos_gap_pct(= IS − OOS,正值代表 OOS 衰退,不隱藏)、oos_sharpe/oos_sortino/oos_calmar/oos_return_over_maxddrank_score
  • 排名依風險調整後的 OOS 指標(rank_metric,預設 oos_sharpe;另支援 oos_sortino/oos_calmar/oos_return_over_maxdd)——絕不用原始報酬。未知 rank_metric fail loud。
  • 既有非切分模式(預設)維持向後相容:仍依原始 metric(total_return_pct/win_rate)在全資料排名。
  • rows[0] 即 OOS 選出的最佳參數;/api/backtest/optimizesplit/oos_fraction/rank_metric,前端「use」套用的就是這組。

backtest/validation.py walk_forward(...)

walk_forward(candles, strategy_name, param_grid, n_folds=4, metric="sharpe", anchored=True, ...):

  • 把歷史切成 n_folds 段連續的 OOS 測試窗;每折在其之前的資料(anchored:從頭累積;rolling:僅前一段)選 IS 最佳參數,再於該折 OOS 窗評分。
  • 回傳 WalkForwardReport:每折 FoldResult(best_paramsis_metricoos_metricoos_return_pct、窗界 index 等)與 aggregate_oos_metric(各折 OOS 指標平均)。
  • 選參指標為風險調整後(預設 Sharpe);未知 metricn_folds<2、資料不足皆 fail loud。

驗收(已寫成測試): 構造一組「IS 極佳、OOS 失敗」的參數,在 OOS 排序下不會排第 1(backend/app/tests/test_optimize.py::test_overfit_combo_does_not_rank_firsttest_validation.py)。

日期區間回測(Date-Range Backtest)

概述

預設用「最近 N 根 K 線(limit)」拉歷史資料。新增 start / end 日期區間模式,讓你指定任意一段時間做回測,而不受 limit 筆數限制。兩者向後相容:省略 start/end 時仍走舊的 limit 路徑。

日期區間邏輯僅作用於資料擷取層(Broker.get_ohlcv_range);run_backtest 引擎本身接收 K 線清單,行為完全不變。

API 欄位

以下端點皆新增可選的 start/end 欄位(ISO 8601 datetime 字串):

端點說明
POST /api/backtest單策略回測
POST /api/backtest/compare多策略比較
POST /api/backtest/optimize參數最佳化(含 train/test 切分)
POST /api/backtest/walk-forwardWalk-forward 驗證
POST /api/strategies/{sid}/backtest策略庫單策略回測

同時提供 start+end 時走區間模式;只提供其中一個不觸發區間(退回 limit 模式)。

守衛(Fail Loud)

  • start >= end → HTTP 422(由 _fetch_candles helper 在進 broker 前攔截)
  • 區間取回的 K 線 < 2 根 → HTTP 422(引擎 fail loud)
  • Broker 未實作 get_ohlcv_range → HTTP 501

Broker 實作細節

繪製圖表中…

CcxtBroker(crypto_ccxt.py):

  • 呼叫 ccxt fetch_ohlcv(since=...) 分頁迴圈,每輪推進 last_ts + step(依 timeframe 換算毫秒)。
  • 跨頁重疊的 timestamp(ts <= last_ts)自動去重。
  • 硬性上限 MAX_RANGE_BARS = 5000——超過時截斷並回傳已蒐集的資料。
  • Naive datetime 視為 UTC 處理。
  • 全範圍無資料 → RuntimeError(fail loud)。

CsvDataBroker(brokers/csv_data.py):

  • 從已匯入的 CSV K 線以 start <= ts <= end 過濾。
  • start > end(reversed range) → ValueError(fail loud)。

Broker 基礎類別(brokers/base.py):

  • 預設實作 get_ohlcv_range 拋出 NotImplementedError——尚未實作 range 的 broker 會 loud fail,而非靜默退回。

工作流多資產組合回測(Workflow Portfolio Backtest)

以上章節的「單資產回測」以一個策略對一個 symbol 為單位執行。本節說明另一種回測模式:把整張 WorkflowGraph逐根重放,模擬多資產共用現金的投資組合。

與單資產回測的差異

單資產回測工作流組合回測
輸入symbol + strategy + paramsWorkflowGraph(可含多個 data_source / order 節點)
資產數1≥1;圖中每個 order 節點對應一個 symbol
倉位計算position_fraction × cashEqual-weight:現金平均分配給當下所有 active-long 資產
成交時點次根開盤(next-bar-open)相同,次根開盤
交易成本CostModel 預設開啟相同
AI 節點N/A支援,但每根 K 線呼叫一次 LLM;最多 200 根,超過 fail loud
回傳BacktestResultBacktestResult + symbols[] + signals[] + 持久化 run_id

執行流程

  1. 拉取圖中所有 symbol 的歷史 K 線,取最短對齊長度。
  2. 逐根 K 線以 BacktestContext 重放完整工作流:
    • data_source → 截至當根的切片(無前視偏差)
    • order → 記錄訊號意圖(不呼叫 broker)
    • risk_exit → 讀取模擬持倉
  3. 每根收集所有 symbol 的 Signal 後送入 PortfolioSim,以次根開盤成交、equal-weight 配置、套用成本。
  4. 全部 K 線跑完後計算與單資產回測相同的指標集,並以 WorkflowRun + WorkflowSignal 持久化至 DB。

驗證(fail loud)

  • 圖中需至少 1 個 order 節點。
  • 每個 order 節點必須解析到唯一一個 symbol。
  • 所有 data_source 節點必須用相同 timeframe
  • 對齊後的歷史至少需 2 根 K 線。

端點:POST /api/backtest/workflow,詳見 api-reference.md 的 Backtest 段。