回測與最佳化 / 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.py 的 CostModel(手續費、台股證交稅僅賣出、滑價),成本預設 ON——零成本的報酬數字是錯的。run_backtest(..., market=..., cost_model=None):cost_model=None 用 Settings 設定值;測量毛報酬時傳 CostModel.zero()。Trade.pnl 為淨額,另有 gross_pnl 與 cost 明細。買→賣的已實現淨損益恆等於 gross_pnl − buy_cost − sell_cost − sell_tax。
風險/報酬指標(M0.3)
純函式在 backtest/metrics.py(可單元測試、walk_forward M0.4 共用)。年化用的 periods_per_year 由 timeframe 推導(如 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 只罰下檔波動) |
calmar | CAGR / 最大回撤 |
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_pct或win_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_pct、oos_return_pct、is_oos_gap_pct(= IS − OOS,正值代表 OOS 衰退,不隱藏)、oos_sharpe/oos_sortino/oos_calmar/oos_return_over_maxdd、rank_score。 - 排名依風險調整後的 OOS 指標(
rank_metric,預設oos_sharpe;另支援oos_sortino/oos_calmar/oos_return_over_maxdd)——絕不用原始報酬。未知rank_metricfail loud。 - 既有非切分模式(預設)維持向後相容:仍依原始
metric(total_return_pct/win_rate)在全資料排名。 rows[0]即 OOS 選出的最佳參數;/api/backtest/optimize加split/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_params、is_metric、oos_metric、oos_return_pct、窗界 index 等)與aggregate_oos_metric(各折 OOS 指標平均)。 - 選參指標為風險調整後(預設 Sharpe);未知
metric、n_folds<2、資料不足皆 fail loud。
驗收(已寫成測試): 構造一組「IS 極佳、OOS 失敗」的參數,在 OOS 排序下不會排第 1(backend/app/tests/test_optimize.py::test_overfit_combo_does_not_rank_first、test_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-forward | Walk-forward 驗證 |
POST /api/strategies/{sid}/backtest | 策略庫單策略回測 |
同時提供 start+end 時走區間模式;只提供其中一個不觸發區間(退回 limit 模式)。
守衛(Fail Loud)
start >= end→ HTTP 422(由_fetch_candleshelper 在進 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 + params | WorkflowGraph(可含多個 data_source / order 節點) |
| 資產數 | 1 | ≥1;圖中每個 order 節點對應一個 symbol |
| 倉位計算 | position_fraction × cash | Equal-weight:現金平均分配給當下所有 active-long 資產 |
| 成交時點 | 次根開盤(next-bar-open) | 相同,次根開盤 |
| 交易成本 | CostModel 預設開啟 | 相同 |
| AI 節點 | N/A | 支援,但每根 K 線呼叫一次 LLM;最多 200 根,超過 fail loud |
| 回傳 | BacktestResult | BacktestResult + symbols[] + signals[] + 持久化 run_id |
執行流程
- 拉取圖中所有 symbol 的歷史 K 線,取最短對齊長度。
- 逐根 K 線以
BacktestContext重放完整工作流:data_source→ 截至當根的切片(無前視偏差)order→ 記錄訊號意圖(不呼叫 broker)risk_exit→ 讀取模擬持倉
- 每根收集所有 symbol 的 Signal 後送入
PortfolioSim,以次根開盤成交、equal-weight 配置、套用成本。 - 全部 K 線跑完後計算與單資產回測相同的指標集,並以
WorkflowRun+WorkflowSignal持久化至 DB。
驗證(fail loud)
- 圖中需至少 1 個
order節點。 - 每個
order節點必須解析到唯一一個 symbol。 - 所有
data_source節點必須用相同timeframe。 - 對齊後的歷史至少需 2 根 K 線。
端點:POST /api/backtest/workflow,詳見 api-reference.md 的 Backtest 段。