Files
stock/backend/app/backtest/engine.py
2026-08-07 16:08:34 +08:00

115 lines
3.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""单一回测引擎fast/strict 两档,同一代码路径、同一撮合逻辑)。
不做"快层 vectorbt + 真层自研"双引擎——用户必然信任更快的那层,
一旦快层省略 T+1/费用,两套结论背离即反复扯皮"以谁为准"
这里用开关控制规则是否注入,引擎只有一份。
数据流:
bars -> DataFrame -> strategy.compute(指标) -> 逐 bar 喂 strategy.on_bar(broker)
-> broker 产生 fills -> 每根 bar 记录 equity -> metrics
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
from ..domain import Bar
from .broker import PaperBroker
from .metrics import compute_metrics
@dataclass
class BacktestConfig:
initial_cash: float = 1000000.0
fast_mode: bool = False # True: 关 T+1/费用,交互试探
bars_per_year: int = 252 # 日线 252分钟级另算
def run_backtest(bars: list[Bar], strategy, cfg: BacktestConfig | None = None) -> dict:
cfg = cfg or BacktestConfig()
if not bars:
return _empty_result()
df = pd.DataFrame(
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low,
"close": b.close, "volume": b.volume} for b in bars]
).sort_values("ts").reset_index(drop=True)
# 指标预计算(由策略持有,单一事实源)
ind = strategy.compute(df["close"], df["high"], df["low"])
indicator_cols = list(ind.columns)
df = pd.concat([df, ind], axis=1)
broker = PaperBroker(
initial_cash=cfg.initial_cash,
enable_costs=not cfg.fast_mode,
enable_t_plus_1=not cfg.fast_mode,
)
equity_values = []
for i in range(len(df)):
row = df.iloc[i]
strategy.on_bar(i, row, broker)
equity_values.append(broker.equity(row["close"]))
broker.release_t_plus_1()
equity = pd.Series(equity_values, index=df["ts"], name="equity")
metrics = compute_metrics(equity, cfg.bars_per_year)
metrics["num_trades"] = len(broker.fills)
metrics["win_rate"] = _win_rate(broker.fills)
return {
"df": df,
"indicator_cols": indicator_cols,
"fills": broker.fills,
"equity": equity,
"metrics": metrics,
"final_cash": broker.cash,
"final_position": broker.position,
}
def _win_rate(fills) -> float:
"""FIFO 配对计算卖出胜率。"""
from collections import deque
buys: deque = deque()
wins = 0
total_sells = 0
for f in fills:
if f.side.value == "buy":
buys.append((f.price, f.qty))
else: # sell
remaining = f.qty
total_sells += 1
profitable = True
while remaining > 0 and buys:
buy_price, buy_qty = buys[0]
if f.price >= buy_price:
pass
else:
profitable = False
take = min(remaining, buy_qty)
buy_qty -= take
remaining -= take
if buy_qty <= 0:
buys.popleft()
else:
buys[0] = (buy_price, buy_qty)
if profitable and f.qty > 0:
wins += 1
return wins / total_sells if total_sells else 0.0
def _empty_result() -> dict:
return {
"df": pd.DataFrame(),
"indicator_cols": [],
"fills": [],
"equity": pd.Series(dtype=float),
"metrics": {"total_return": 0.0, "max_drawdown": 0.0, "sharpe": 0.0,
"volatility": 0.0, "num_trades": 0, "win_rate": 0.0},
"final_cash": 0.0,
"final_position": 0.0,
}