first commit
This commit is contained in:
0
backend/app/backtest/__init__.py
Normal file
0
backend/app/backtest/__init__.py
Normal file
104
backend/app/backtest/broker.py
Normal file
104
backend/app/backtest/broker.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""PaperBroker —— 回测中的虚拟撮合 / 账户。
|
||||
|
||||
建模 A 股规则:
|
||||
- 100 股整数手(1手=100股)
|
||||
- T+1:当日买入次日才可卖(fast_mode 关闭此约束)
|
||||
- 印花税(卖出)、过户费(双边)、佣金(万1 最低5元)、滑点
|
||||
- 撮合价:以当根 bar 收盘价近似(阶段1 接 VWAP / 限价单)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..commission import CostSchedule, DEFAULT, buy_cost, sell_cost
|
||||
from ..domain import Fill, Side
|
||||
|
||||
LOT = 100 # A股 1 手 = 100 股
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperBroker:
|
||||
initial_cash: float = 100000.0
|
||||
schedule: CostSchedule = field(default_factory=lambda: DEFAULT)
|
||||
enable_costs: bool = True
|
||||
enable_t_plus_1: bool = True
|
||||
|
||||
cash: float = field(init=False)
|
||||
holdings: float = 0.0 # 可卖数量
|
||||
locked: float = 0.0 # 当日买入(T+1 锁定)
|
||||
avg_price: float = 0.0
|
||||
fills: list[Fill] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.cash = self.initial_cash
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
return self.holdings + self.locked
|
||||
|
||||
def equity(self, price: float) -> float:
|
||||
return self.cash + self.position * price
|
||||
|
||||
@staticmethod
|
||||
def _to_lots(qty: float) -> int:
|
||||
return int(qty // LOT) * LOT
|
||||
|
||||
def buy_max(self, ts, price: float) -> Fill | None:
|
||||
"""用当前现金买尽可能多的整手。"""
|
||||
if price <= 0:
|
||||
return None
|
||||
rate = (
|
||||
self.schedule.commission_rate
|
||||
+ self.schedule.transfer_fee_rate
|
||||
+ self.schedule.slippage_rate
|
||||
) if self.enable_costs else 0.0
|
||||
affordable_qty = self.cash / (price * (1 + rate))
|
||||
qty = self._to_lots(affordable_qty)
|
||||
if qty <= 0:
|
||||
return None
|
||||
return self._execute_buy(ts, price, qty)
|
||||
|
||||
def sell_all(self, ts, price: float) -> Fill | None:
|
||||
"""卖出全部可卖持仓(整手)。"""
|
||||
qty = self._to_lots(self.holdings)
|
||||
if qty <= 0:
|
||||
return None
|
||||
return self._execute_sell(ts, price, qty)
|
||||
|
||||
def _execute_buy(self, ts, price: float, qty: int) -> Fill:
|
||||
if self.enable_costs:
|
||||
fp, comm, tf = buy_cost(price, qty, self.schedule)
|
||||
else:
|
||||
fp, comm, tf = price, 0.0, 0.0
|
||||
cost = fp * qty + comm + tf
|
||||
prev_pos = self.position
|
||||
new_pos = prev_pos + qty
|
||||
self.avg_price = (self.avg_price * prev_pos + fp * qty) / new_pos if new_pos else 0.0
|
||||
if self.enable_t_plus_1:
|
||||
self.locked += qty
|
||||
else:
|
||||
self.holdings += qty
|
||||
self.cash -= cost
|
||||
f = Fill(ts=ts, side=Side.BUY, price=fp, qty=qty, commission=comm, transfer_fee=tf)
|
||||
self.fills.append(f)
|
||||
return f
|
||||
|
||||
def _execute_sell(self, ts, price: float, qty: int) -> Fill:
|
||||
if self.enable_costs:
|
||||
fp, comm, tf, sd = sell_cost(price, qty, self.schedule)
|
||||
else:
|
||||
fp, comm, tf, sd = price, 0.0, 0.0, 0.0
|
||||
proceeds = fp * qty - comm - tf - sd
|
||||
self.holdings -= qty
|
||||
self.cash += proceeds
|
||||
if self.position == 0:
|
||||
self.avg_price = 0.0
|
||||
f = Fill(ts=ts, side=Side.SELL, price=fp, qty=qty, commission=comm, stamp_duty=sd, transfer_fee=tf)
|
||||
self.fills.append(f)
|
||||
return f
|
||||
|
||||
def release_t_plus_1(self) -> None:
|
||||
"""每根 bar 结束时调用:当日锁定转为次日可卖。"""
|
||||
if self.enable_t_plus_1:
|
||||
self.holdings += self.locked
|
||||
self.locked = 0.0
|
||||
114
backend/app/backtest/engine.py
Normal file
114
backend/app/backtest/engine.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""单一回测引擎(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,
|
||||
}
|
||||
41
backend/app/backtest/metrics.py
Normal file
41
backend/app/backtest/metrics.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""绩效统计(阶段1 补基准归因:超额/信息比率/beta/alpha)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def compute_metrics(equity: pd.Series, bars_per_year: int = 252) -> dict:
|
||||
equity = equity.dropna()
|
||||
if len(equity) < 2 or equity.iloc[0] == 0:
|
||||
return {"total_return": 0.0, "max_drawdown": 0.0, "sharpe": 0.0,
|
||||
"volatility": 0.0, "win_rate": 0.0}
|
||||
|
||||
total_return = float(equity.iloc[-1] / equity.iloc[0] - 1)
|
||||
|
||||
returns = equity.pct_change().dropna()
|
||||
cummax = equity.cummax()
|
||||
drawdown = (equity - cummax) / cummax
|
||||
max_drawdown = float(abs(drawdown.min()))
|
||||
|
||||
std = float(returns.std())
|
||||
sharpe = float(returns.mean() / std * np.sqrt(bars_per_year)) if std > 0 else 0.0
|
||||
volatility = std * np.sqrt(bars_per_year)
|
||||
|
||||
return {
|
||||
"total_return": total_return,
|
||||
"max_drawdown": max_drawdown,
|
||||
"sharpe": sharpe,
|
||||
"volatility": float(volatility),
|
||||
"win_rate": 0.0, # 由 engine 用成交对计算后注入
|
||||
}
|
||||
|
||||
|
||||
def win_rate_from_fills(fills) -> float:
|
||||
"""按"卖出-对应买入"配对估算胜率(粗略,阶段1 用 FIFO 精确配对)。"""
|
||||
sells = [f for f in fills if f.side.value == "sell"]
|
||||
if not sells:
|
||||
return 0.0
|
||||
wins = sum(1 for f in fills if f.side.value == "sell" and f.price > 0)
|
||||
# 简化:有成交即计;真实胜率需配对,这里先返回 0 占位,由 engine 精算
|
||||
return 0.0
|
||||
24
backend/app/backtest/strategies/__init__.py
Normal file
24
backend/app/backtest/strategies/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""策略注册表与工厂。
|
||||
|
||||
新增策略:实现 Strategy(base.py),在此注册 {name: Class},前端下拉即可选。
|
||||
策略构造参数由请求的 params(dict) 以 **kwargs 传入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Strategy
|
||||
from .macd_cross import MACDCrossStrategy
|
||||
from .ma_strategies import MACrossStrategy, SingleMAStrategy
|
||||
|
||||
STRATEGIES: dict[str, type[Strategy]] = {
|
||||
"macd_cross": MACDCrossStrategy,
|
||||
"ma_cross": MACrossStrategy,
|
||||
"single_ma": SingleMAStrategy,
|
||||
}
|
||||
|
||||
|
||||
def build_strategy(name: str, params: dict | None) -> Strategy:
|
||||
cls = STRATEGIES.get(name)
|
||||
if cls is None:
|
||||
raise ValueError(f"未知策略: {name}(可用: {', '.join(STRATEGIES)})")
|
||||
kwargs = {k: v for k, v in (params or {}).items()}
|
||||
return cls(**kwargs)
|
||||
20
backend/app/backtest/strategies/base.py
Normal file
20
backend/app/backtest/strategies/base.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""策略抽象基类。策略只产生买卖意图(向 broker 下单),不负责撮合/费用。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ..broker import PaperBroker
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""compute 预算指标;on_bar 逐 bar 决策并向 broker 下单。"""
|
||||
|
||||
@abstractmethod
|
||||
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
|
||||
...
|
||||
74
backend/app/backtest/strategies/ma_strategies.py
Normal file
74
backend/app/backtest/strategies/ma_strategies.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""均线类策略:双均线交叉、单均线(价格上穿/下穿)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ...indicators import ma
|
||||
from ..broker import PaperBroker
|
||||
from .base import Strategy
|
||||
|
||||
|
||||
class MACrossStrategy(Strategy):
|
||||
"""双均线交叉:快线上穿慢线买入,下穿卖出(金叉/死叉)。"""
|
||||
|
||||
def __init__(self, fast: float = 5, slow: float = 20):
|
||||
self.fast = int(fast)
|
||||
self.slow = int(slow)
|
||||
self._ind: pd.DataFrame | None = None
|
||||
self._prev_fast: float | None = None
|
||||
self._prev_slow: float | None = None
|
||||
|
||||
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
|
||||
self._ind = pd.DataFrame({"fast": ma(close, self.fast), "slow": ma(close, self.slow)})
|
||||
return self._ind
|
||||
|
||||
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
|
||||
f = float(self._ind["fast"].iloc[i])
|
||||
s = float(self._ind["slow"].iloc[i])
|
||||
if self._prev_fast is None:
|
||||
self._prev_fast, self._prev_slow = f, s
|
||||
return
|
||||
|
||||
golden = self._prev_fast <= self._prev_slow and f > s
|
||||
death = self._prev_fast >= self._prev_slow and f < s
|
||||
|
||||
price = float(row["close"])
|
||||
ts = row["ts"]
|
||||
if golden and pd.notna(f):
|
||||
broker.buy_max(ts, price)
|
||||
elif death and broker.position > 0 and pd.notna(f):
|
||||
broker.sell_all(ts, price)
|
||||
|
||||
self._prev_fast, self._prev_slow = f, s
|
||||
|
||||
|
||||
class SingleMAStrategy(Strategy):
|
||||
"""单均线:收盘价上穿均线买入,下穿均线卖出。"""
|
||||
|
||||
def __init__(self, period: float = 20):
|
||||
self.period = int(period)
|
||||
self._ind: pd.DataFrame | None = None
|
||||
self._prev_above: bool | None = None
|
||||
|
||||
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
|
||||
self._ind = pd.DataFrame({"ma": ma(close, self.period)})
|
||||
return self._ind
|
||||
|
||||
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
|
||||
m = float(self._ind["ma"].iloc[i])
|
||||
price = float(row["close"])
|
||||
ts = row["ts"]
|
||||
above = price > m
|
||||
if self._prev_above is None or pd.isna(m):
|
||||
self._prev_above = above
|
||||
return
|
||||
|
||||
cross_up = above and not self._prev_above # 上穿
|
||||
cross_down = (not above) and self._prev_above # 下穿
|
||||
|
||||
if cross_up:
|
||||
broker.buy_max(ts, price)
|
||||
elif cross_down and broker.position > 0:
|
||||
broker.sell_all(ts, price)
|
||||
|
||||
self._prev_above = above
|
||||
42
backend/app/backtest/strategies/macd_cross.py
Normal file
42
backend/app/backtest/strategies/macd_cross.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""MACD 金叉死叉策略。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ...indicators import macd
|
||||
from ..broker import PaperBroker
|
||||
from .base import Strategy
|
||||
|
||||
|
||||
class MACDCrossStrategy(Strategy):
|
||||
def __init__(self, fast: float = 12, slow: float = 26, signal: float = 9):
|
||||
self.fast = int(fast)
|
||||
self.slow = int(slow)
|
||||
self.signal = int(signal)
|
||||
self._ind: pd.DataFrame | None = None
|
||||
self._prev_dif: float | None = None
|
||||
self._prev_dea: float | None = None
|
||||
|
||||
def compute(self, close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
|
||||
self._ind = macd(close, self.fast, self.slow, self.signal)
|
||||
return self._ind
|
||||
|
||||
def on_bar(self, i: int, row: pd.Series, broker: PaperBroker) -> None:
|
||||
dif = float(self._ind["macd"].iloc[i])
|
||||
dea = float(self._ind["signal"].iloc[i])
|
||||
|
||||
if self._prev_dif is None:
|
||||
self._prev_dif, self._prev_dea = dif, dea
|
||||
return
|
||||
|
||||
golden = self._prev_dif <= self._prev_dea and dif > dea # 金叉
|
||||
death = self._prev_dif >= self._prev_dea and dif < dea # 死叉
|
||||
|
||||
price = float(row["close"])
|
||||
ts = row["ts"]
|
||||
if golden and pd.notna(dif):
|
||||
broker.buy_max(ts, price)
|
||||
elif death and broker.position > 0 and pd.notna(dif):
|
||||
broker.sell_all(ts, price)
|
||||
|
||||
self._prev_dif, self._prev_dea = dif, dea
|
||||
Reference in New Issue
Block a user