first commit

This commit is contained in:
2026-08-07 16:08:34 +08:00
commit e0b5228008
51 changed files with 5175 additions and 0 deletions

View File

View 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

View 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,
}

View 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

View File

@@ -0,0 +1,24 @@
"""策略注册表与工厂。
新增策略:实现 Strategybase.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)

View 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:
...

View 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

View 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