Files
qlib/backend/app/quant/local_engine.py
T
Simon bfeac7aa4c feat(backtest): M9-2 回测补 selection_history/signal_history/fills(Signal↔Fill 区分)
- BacktestResult 新增:RankedPick(调仓意图,与 select(as_of) 同源排序)、
  ActionRecord(BUY/SELL 意图 + filled + reject_reason/price)字段 selection_history /
  signal_history / fills(fills=signal_history 中 filled 子集)(v3 §20.3/§22.3)
- TopKBacktestRunner:调仓记录卖出/买入逐动作与是否成交;涨停/停牌导致的
  「BUY 信号未成交」保留原因;意图 picks 与执行 targets 分离(不因涨停悄悄改选股视图)
- ChartService.backtest_stock_chart 改用 history 生成三类标记(selection/signal/fill),
  未成交意图在图上可见(v3 §20.4)
- tests/test_backtest_history.py:意图=select 一致、fills 推导、涨停拒绝可见(构造 +10%
  涨停日)、序列化 roundtrip;相关回归(quant/consistency/charts)全过;全量 pytest 通过
2026-09-09 07:12:36 +08:00

343 lines
13 KiB
Python
Raw 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.
"""LocalEngine —— 默认研究引擎(纯 pandas,AGENT.md §40 简单可替换优先)。
无未来函数纪律:
- 调仓日 t 的选股只使用 <=t 的因子值与收盘价
- 成交发生在 t 收盘(价格 = close[t] ± 滑点);t 当日组合收益用 t-1 收盘持仓结算,
调仓在 t 收盘生效、自 t+1 起计收益 —— 不存在「当日买入当日计收益」的未来函数
- 涨跌停 / 停牌约束按可达信息近似建模,未建模部分显式写入结果 unimplemented
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date
import pandas as pd
from app.domain.entities.research import (
ActionRecord,
BacktestResult,
BacktestSummary,
CurvePoint,
FactorTestReport,
MonthlyReturn,
Position,
RankedPick,
ResearchSpec,
Trade,
YearlyReturn,
)
from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用)
build_factor_panels,
composite_score,
cross_sectional_zscore,
)
from app.quant.evaluation import run_factor_test
from app.quant.portfolio import equal_weight_budget, unimplemented_notes
TRADING_DAYS = 252
_DEFAULT_UNIMPLEMENTED = [
"涨跌停按收盘价相对上一有效收盘近似判定(未建模开盘一字 / 集合竞价路径)",
"成交假设发生在调仓日收盘(未建模盘中价格路径与流动性冲击)",
]
def _limit_up_ratio(symbol: str) -> float:
"""按板块近似涨跌停幅度。"""
code = symbol[:3]
if code in {"300", "301", "688"}:
return 1.199
if code.startswith(("8", "4", "92")):
return 1.299
return 1.099
def rebalance_dates(index: pd.Index, rebalance: str, start: date) -> list[pd.Timestamp]:
"""按频率取首个交易日(>= start)。"""
periods = index.to_period("M" if rebalance == "monthly" else "W")
seen: dict = {}
order: list[pd.Timestamp] = []
for ts, per in zip(index, periods, strict=True):
if per not in seen:
seen[per] = ts
order.append(ts)
return [ts for ts in order if ts.date() >= start]
@dataclass
class EngineResult:
equity: pd.Series # index=date -> equity
trades: list[Trade]
positions: list[Position]
rebalance_notional: list[float]
class TopKBacktestRunner:
"""TopK 等权、固定调仓频率的低频回测。"""
def __init__(self, spec: ResearchSpec, score: pd.DataFrame, close: pd.DataFrame) -> None:
self.spec = spec
close = close.copy()
close.index = pd.to_datetime(close.index)
self.close = close.sort_index()
self.score = score.reindex(self.close.index).sort_index()
self.costs = spec.costs
# 上一有效收盘(用于涨跌停与收益结算,处理停牌日)
self.prev_close = self.close.ffill().shift(1)
# M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3)
self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = []
def run(self) -> BacktestResult:
end_date = self.spec.period[1]
dates = [d for d in self.close.index if self.spec.period[0] <= d.date() <= end_date]
rebal = {
d
for d in rebalance_dates(self.close.index, self.spec.rebalance, self.spec.period[0])
if d.date() <= end_date
}
cash = float(self.spec.initial_capital)
shares: dict[str, float] = {}
entry_date: dict[str, date] = {}
entry_price: dict[str, float] = {}
equity_rows: dict[pd.Timestamp, float] = {}
trades: list[Trade] = []
positions: list[Position] = []
notional: list[float] = []
def _value(d: pd.Timestamp) -> float:
total = cash
for s, qty in shares.items():
if qty <= 0:
continue
px = self.close.at[d, s] if d in self.close.index else None
if px is None or (isinstance(px, float) and math.isnan(px)):
continue # 无行情日不计该仓(停牌近似,见 unimplemented)
total += float(qty * px)
return total
for d in dates:
if d in rebal:
cash = self._rebalance(
d, cash, shares, entry_date, entry_price, trades, positions, notional
)
equity_rows[d] = _value(d)
equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional)
# ---- 调仓(t 收盘执行,自 t+1 生效) ----
def _rebalance(self, d, cash, shares, entry_date, entry_price, trades, positions, notional):
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
sold_notional = 0.0
day = d.date()
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
for s in [s for s in shares if shares[s] > 0]:
c, p = close_d[s], prev_d[s]
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="无行情(停牌),保留持仓")
)
continue # 停牌无价:保留
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="跌停无法卖出,保留到下一调仓")
)
continue # 跌停无法卖出:保留到下一调仓
qty = shares[s]
proceeds = qty * float(c) * (1 - self.costs.slippage_rate)
fee = proceeds * (self.costs.commission_rate + self.costs.stamp_tax_rate)
cash += proceeds - fee
sold_notional += proceeds
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True,
price=float(c))
)
trades.append(
Trade(
entry_date=entry_date[s],
exit_date=day,
symbol=s,
entry_price=entry_price[s],
exit_price=float(c),
return_pct=(float(c) / entry_price[s] - 1.0) * 100,
)
)
shares[s] = 0.0
entry_date.pop(s, None)
entry_price.pop(s, None)
# 2) 买入:先记录「选股意图」(= select(as_of) 前 top_n,v3 §22.3)
score_d = self.score.loc[d].dropna()
top = score_d.sort_values(ascending=False).index.tolist()
top_n = self.spec.selection.top_n
picks = top[:top_n]
for rank, sym in enumerate(picks, start=1):
self.selection_history.append(
RankedPick(date=day, symbol=sym, rank=rank,
score=round(float(score_d[sym]), 6))
)
# 执行:顺序寻找可买(涨停/无价剔除;替补仅在意图被拒时进入)
def _buyable(sym) -> tuple[bool, str | None]:
c, p = close_d[sym], prev_d[sym]
if _nan(c) or _nan(p) or p <= 0:
return False, "无行情(停牌),无法买入"
if c / p >= _limit_up_ratio(sym):
return False, "涨停,无法追买"
return True, None
targets: list[str] = []
for sym in top:
if len(targets) >= top_n:
break
ok, _ = _buyable(sym)
if ok:
targets.append(sym)
target_set = set(targets)
# BUY 信号/成交记录:意图入选(filled)或意图被拒(原因);替补成交同样如实记录
if targets:
budget = equal_weight_budget(cash, len(targets))
for s in targets:
c = float(close_d[s])
price_in = c * (1 + self.costs.slippage_rate)
invest = budget * (1 - self.costs.commission_rate)
shares[s] = invest / price_in
entry_date[s] = day
entry_price[s] = price_in
notional.append(budget)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
price=round(price_in, 4))
)
cash -= budget * len(targets)
for sym in picks:
if sym in target_set:
continue
_ok, reason = _buyable(sym)
self.signal_history.append(
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
reject_reason=reason or "资金不足(未成交)")
)
# 3) 记录调仓后仓位
total = cash + sum(
float(self.close.at[d, s] * qty)
for s, qty in shares.items()
if qty > 0 and not _nan(self.close.at[d, s])
)
if total > 0:
for s, qty in shares.items():
if qty > 0 and not _nan(self.close.at[d, s]):
positions.append(
Position(
date=day, symbol=s, weight=float(qty * self.close.at[d, s] / total)
)
)
return cash
# ---- 指标 ----
def _to_result(self, equity, trades, positions, notional) -> BacktestResult:
start, end = equity.index[0].date(), equity.index[-1].date()
init = float(self.spec.initial_capital)
final = float(equity.iloc[-1])
rets = equity.pct_change().dropna()
n = len(rets)
total_ret = (final / init - 1.0) * 100 if init else 0.0
annual = (
((final / init) ** (TRADING_DAYS / max(n, 1)) - 1.0) * 100
if final > 0 and init > 0
else -100.0
)
mean_r, std_r = (float(rets.mean()), float(rets.std(ddof=1))) if n else (0.0, 0.0)
sharpe = mean_r / std_r * math.sqrt(TRADING_DAYS) if std_r and mean_r else 0.0
vol = std_r * math.sqrt(TRADING_DAYS) * 100
dd = (equity / equity.cummax() - 1.0).min() * 100
wins = [t for t in trades if t.return_pct > 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0.0
avg_turn = (sum(notional) / len(notional) / ((init + final) / 2)) * 100 if notional else 0.0
eq_pts = [CurvePoint(date=d.date(), value=round(float(v), 2)) for d, v in equity.items()]
dd_series = (equity / equity.cummax() - 1.0) * 100
drawdown = [
CurvePoint(date=d.date(), value=round(float(v), 3)) for d, v in dd_series.items()
]
monthly: list[MonthlyReturn] = []
yearly: list[YearlyReturn] = []
if len(equity) > 1:
m = equity.resample("ME").last().pct_change().dropna()
monthly = [
MonthlyReturn(
year=int(d.year), month=int(d.month), return_pct=round(float(v) * 100, 3)
)
for d, v in m.items()
]
y = equity.resample("YE").last().pct_change().dropna()
yearly = [
YearlyReturn(year=int(d.year), return_pct=round(float(v) * 100, 3))
for d, v in y.items()
]
summary = BacktestSummary(
start=start,
end=end,
initial_capital=round(init, 2),
final_equity=round(final, 2),
total_return_pct=round(total_ret, 3),
annual_return_pct=round(annual, 3),
sharpe=round(sharpe, 3),
max_drawdown_pct=round(float(dd), 3),
volatility_pct=round(vol, 3),
win_rate_pct=round(win_rate, 2),
total_trades=len(trades),
avg_turnover_pct=round(avg_turn, 2),
)
return BacktestResult(
summary=summary,
equity_curve=eq_pts,
drawdown=drawdown,
monthly_returns=monthly,
yearly_returns=yearly,
positions=positions,
trades=trades,
selection_history=self.selection_history,
signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled],
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio),
config_snapshot=self.spec.model_dump(mode="json"),
)
def run_spec_factor_test(
daily: pd.DataFrame,
spec: ResearchSpec,
horizon_days: int = 21,
) -> tuple[FactorTestReport, dict[str, pd.DataFrame]]:
"""单因子测试:因子面板 + 未来 horizon 收益 → FactorTestReport。"""
assert spec.type == "factor_test"
factor_name = spec.factors[0].name
panels = build_factor_panels(daily, spec.factors)
panel = panels[0][1]
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
close.index = pd.to_datetime(close.index)
forward = close.shift(-horizon_days) / close - 1.0
report = run_factor_test(panel, forward, factor_name=factor_name)
return report, {factor_name: panel}
def _nan(v) -> bool:
try:
return bool(math.isnan(float(v)))
except (TypeError, ValueError):
return False