From bfeac7aa4c17ed8c43c49e83072be4ba0b3c1cbb Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 9 Sep 2026 07:12:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20M9-2=20=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E8=A1=A5=20selection=5Fhistory/signal=5Fhistory/fills=EF=BC=88?= =?UTF-8?q?Signal=E2=86=94Fill=20=E5=8C=BA=E5=88=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 通过 --- .../app/application/services/chart_service.py | 49 +++--- backend/app/domain/entities/research.py | 33 ++++ backend/app/quant/local_engine.py | 79 +++++++-- backend/tests/test_backtest_history.py | 153 ++++++++++++++++++ 4 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 backend/tests/test_backtest_history.py diff --git a/backend/app/application/services/chart_service.py b/backend/app/application/services/chart_service.py index 0988759..1088fb1 100644 --- a/backend/app/application/services/chart_service.py +++ b/backend/app/application/services/chart_service.py @@ -21,7 +21,7 @@ from app.domain.entities.chart import ( VolumePoint, ) from app.domain.entities.market import AdjustFactor, DailyBar, Stock -from app.domain.entities.research import BacktestResult, Trade +from app.domain.entities.research import BacktestResult from app.domain.repositories.market import ( AdjustFactorRepository, DailyBarRepository, @@ -132,39 +132,50 @@ class ChartService: end: date, adjust: str = "none", ) -> ChartResult: - """回测个股视图:K 线 + 该股实际成交 fills(v3 §20.3 Signal↔Fill 展示)。""" + """回测个股视图:K 线 + 选股意图/未成交信号/实际成交三类标记(v3 §20.3)。""" basis = (result.config_snapshot or {}).get("price_adjustment", "none") - markers = _trades_to_markers(result.trades, symbol, basis) + markers = _result_to_markers(result, symbol) return self.stock_chart(symbol, start, end, adjust, execution_price_basis=basis, extra_markers=markers) -def _trades_to_markers(trades: list[Trade], symbol: str, basis: str) -> list[EventMarker]: +def _result_to_markers(result: BacktestResult, symbol: str) -> list[EventMarker]: + """由回测 history 生成个股标记:fills(成交)/ signals(未成交意图)/ selections(选股)。""" markers: list[EventMarker] = [] - for t in trades: - if t.symbol != symbol: + # 实际成交(fills)与未成交信号(signal_history 中 filled=False) + for a in result.signal_history: + if a.symbol != symbol: + continue + if a.filled: + kind = "fill_buy" if a.signal == "BUY" else "fill_sell" + text = [f"{'买入' if a.signal=='BUY' else '卖出'} @ {a.price:.2f}(basis={_basis_of(result)})"] + markers.append( + EventMarker(time=a.date, kind=kind, symbol=symbol, price=a.price, text=text) + ) + else: + kind = "signal_buy" if a.signal == "BUY" else "signal_sell" + text = [a.reject_reason or f"{a.signal} 未成交"] + markers.append(EventMarker(time=a.date, kind=kind, symbol=symbol, price=a.price, text=text)) + # 选股意图(selection_history 中该 symbol 的命中) + for pk in result.selection_history: + if pk.symbol != symbol: continue markers.append( EventMarker( - time=t.entry_date, - kind="fill_buy", + time=pk.date, + kind="selection", symbol=symbol, - price=t.entry_price, - text=[f"买入 @ {t.entry_price:.2f}(basis={basis})"], - ) - ) - markers.append( - EventMarker( - time=t.exit_date, - kind="fill_sell", - symbol=symbol, - price=t.exit_price, - text=[f"卖出 @ {t.exit_price:.2f},收益 {t.return_pct:.2f}%(basis={basis})"], + score=pk.score, + text=[f"选股意图 rank #{pk.rank}"], ) ) return markers +def _basis_of(result: BacktestResult) -> str: + return (result.config_snapshot or {}).get("price_adjustment", "none") + + def _convert_markers(markers: list[EventMarker], mult: dict[date, float]) -> list[EventMarker]: """显示口径与执行价 basis 不一致时,把 marker 价格折算到 K 线坐标系(v3 §20.5)。""" out: list[EventMarker] = [] diff --git a/backend/app/domain/entities/research.py b/backend/app/domain/entities/research.py index 62f06f5..40e167f 100644 --- a/backend/app/domain/entities/research.py +++ b/backend/app/domain/entities/research.py @@ -153,6 +153,30 @@ class Position(BaseModel): weight: float +class RankedPick(BaseModel): + """调仓日选股意图候选(与 select(as_of) 同源;v3 §22.3 selection_history)。""" + + date: date + symbol: str + rank: int + score: float + + +class ActionRecord(BaseModel): + """一次交易意图(Signal)及其成交结果(Fill)—— v3 §20.3 Signal↔Fill 区分。 + + signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因 + (涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。 + """ + + date: date + symbol: str + signal: str = Field(pattern="^(BUY|SELL)$") + filled: bool + reject_reason: str | None = None + price: float | None = Field(default=None, description="成交价(fill)或意图参考价") + + class BacktestResult(BaseModel): """标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。""" @@ -163,6 +187,15 @@ class BacktestResult(BaseModel): yearly_returns: list[YearlyReturn] positions: list[Position] trades: list[Trade] + selection_history: list[RankedPick] = Field( + default_factory=list, description="各调仓日选股意图候选(同 select(as_of))" + ) + signal_history: list[ActionRecord] = Field( + default_factory=list, description="交易意图与是否成交(v3 §20.3)" + ) + fills: list[ActionRecord] = Field( + default_factory=list, description="实际成交(signal_history 中 filled=True 的子集)" + ) turnover_pct: float unimplemented: list[str] = Field( default_factory=list, diff --git a/backend/app/quant/local_engine.py b/backend/app/quant/local_engine.py index 58747cb..66933f5 100644 --- a/backend/app/quant/local_engine.py +++ b/backend/app/quant/local_engine.py @@ -16,12 +16,14 @@ 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, @@ -83,6 +85,9 @@ class TopKBacktestRunner: 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] @@ -128,23 +133,36 @@ class TopKBacktestRunner: close_d = self.close.loc[d] prev_d = self.prev_close.loc[d] sold_notional = 0.0 + day = d.date() - # 1) 卖出:跌停或无价(停牌)持仓保留,其余卖出 + # 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=d.date(), + exit_date=day, symbol=s, entry_price=entry_price[s], exit_price=float(c), @@ -155,20 +173,36 @@ class TopKBacktestRunner: entry_date.pop(s, None) entry_price.pop(s, None) - # 2) 买入:取得分最高且可买的 TopN(涨停 / 无价剔除) + # 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() - targets: list[str] = [] - for s in top: - if len(targets) >= self.spec.selection.top_n: - break - c, p = close_d[s], prev_d[s] - if _nan(c) or _nan(p) or p <= 0: - continue - if c / p >= _limit_up_ratio(s): - continue # 涨停不可追买 - targets.append(s) + 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: @@ -176,10 +210,22 @@ class TopKBacktestRunner: price_in = c * (1 + self.costs.slippage_rate) invest = budget * (1 - self.costs.commission_rate) shares[s] = invest / price_in - entry_date[s] = d.date() + 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( @@ -192,7 +238,7 @@ class TopKBacktestRunner: if qty > 0 and not _nan(self.close.at[d, s]): positions.append( Position( - date=d.date(), symbol=s, weight=float(qty * self.close.at[d, s] / total) + date=day, symbol=s, weight=float(qty * self.close.at[d, s] / total) ) ) return cash @@ -263,6 +309,9 @@ class TopKBacktestRunner: 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"), diff --git a/backend/tests/test_backtest_history.py b/backend/tests/test_backtest_history.py new file mode 100644 index 0000000..a4a350c --- /dev/null +++ b/backend/tests/test_backtest_history.py @@ -0,0 +1,153 @@ +"""M9-2 回测历史测试:selection_history(意图=select 同源)、signal_history(BUY/SELL +与成交与否)、fills(Signal↔Fill 区分)——含「涨停导致 BUY 信号未成交」场景。 +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pandas as pd +import pytest +from app.application.services.selection_service import SelectionService +from app.domain.entities.market import DailyBar, Stock +from app.domain.entities.research import BacktestResult, ResearchSpec +from app.domain.entities.selection import SelectionQuery +from app.quant.engine import LocalEngine + +from conftest_quant import synthetic_daily + +_SYMS = ["600000.SH", "600001.SH", "600002.SH", "600003.SH", "600004.SH"] + + +class _MemStockRepo: + def __init__(self, stocks): + self._stocks = stocks + + def list(self): + return self._stocks + + def get_by_symbol(self, symbol): + return next((s for s in self._stocks if s.symbol == symbol), None) + + +class _MemDailyRepo: + def __init__(self, df: pd.DataFrame) -> None: + self._bars = [ + DailyBar( + symbol=r.symbol, trade_date=r.trade_date, + open=Decimal(str(r.open)), high=Decimal(str(r.high)), + low=Decimal(str(r.low)), close=Decimal(str(r.close)), + volume=Decimal(str(r.volume)), amount=Decimal(str(r.amount)), + ) + for r in df.itertuples() + ] + + def get_range(self, symbol, start, end): + return [b for b in self._bars if b.symbol == symbol and start <= b.trade_date <= end] + + def get_range_many(self, symbols, start, end, adjust="none"): + syms = set(symbols) + return [b for b in self._bars if b.symbol in syms and start <= b.trade_date <= end] + + +def _stocks() -> list[Stock]: + return [ + Stock(symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)) for i, s in enumerate(_SYMS) + ] + + +def _spec(**kw) -> ResearchSpec: + base = dict( + type="backtest", + universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS}, + factors=[{"name": "momentum_60", "weight": 1.0}], + selection={"top_n": 2}, + rebalance="monthly", + period=(date(2024, 5, 1), date(2024, 12, 31)), + ) + base.update(kw) + return ResearchSpec(**base) + + +@pytest.fixture() +def daily_df() -> pd.DataFrame: + return synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320) + + +class TestBacktestHistory: + def test_selection_history_matches_select(self, daily_df) -> None: + spec = _spec() + result = LocalEngine().run_backtest(daily_df, spec) + svc = SelectionService(_MemStockRepo(_stocks()), _MemDailyRepo(daily_df)) + + rebal_dates = sorted({p.date for p in result.positions}) # 有持仓的调仓日近似 + assert result.selection_history + hist_by_date: dict[date, list] = {} + for pick in result.selection_history: + hist_by_date.setdefault(pick.date, []).append(pick) + + sample = [d for d in hist_by_date if d in rebal_dates][:3] + for d in sample: + res = svc.select( + SelectionQuery( + universe=spec.universe, + factors=[{"name": "momentum_60", "weight": 1.0}], + top_n=2, + as_of=d, + ) + ) + picked = {c.symbol: c.rank for c in res.candidates} + hist = {p.symbol: p.rank for p in hist_by_date[d]} + assert hist == picked, f"as_of={d}:意图 {hist} ≠ select {picked}" + + def test_signal_and_fills_consistency(self, daily_df) -> None: + result = LocalEngine().run_backtest(daily_df, _spec()) + fills = result.fills + assert fills == [a for a in result.signal_history if a.filled] + # 每个 SELL fill 对应一笔 Trade 平仓;每个 BUY fill 在交易中体现 + assert all(a.filled for a in fills) + buy_fills = [a for a in fills if a.signal == "BUY"] + assert len(buy_fills) > 0 + # fills 价格与滑点一致(buy price >= close) + assert all(a.price is not None for a in fills) + + def test_limit_up_buy_rejected_visible(self) -> None: + """把最高动量股在某调仓日设为涨停:意图仍在 selection_history, + 但 signal_history 记录 BUY 未成交(涨停),fills/持仓无该笔。""" + df = synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320) + # 找首个 >= 2024-05-01 的交易日作为首个调仓日 + dates = sorted(pd.to_datetime(df["trade_date"].unique())) + d0 = next(x for x in dates if x.date() >= date(2024, 5, 1)) + top = _SYMS[0] # 最高漂移股 + prev_date = dates[dates.index(d0) - 1] + prev_close = float( + df[(df["symbol"] == top) & (df["trade_date"] == prev_date.date())]["close"].iloc[0] + ) + # 把 d0 该股 close 抬高到 +10%(近似涨停) + mask = (df["symbol"] == top) & (df["trade_date"] == d0.date()) + df.loc[mask, "close"] = prev_close * 1.10 + df.loc[mask, "high"] = prev_close * 1.10 + + spec = _spec() + result = LocalEngine().run_backtest(df, spec) + + picks_at_d0 = [p for p in result.selection_history if p.date == d0.date()] + assert any(p.symbol == top for p in picks_at_d0), "涨停股仍应在选股意图中" + # 涨停买入被拒 + rejects = [ + a for a in result.signal_history + if a.date == d0.date() and a.symbol == top and a.signal == "BUY" and not a.filled + ] + assert rejects and any("涨停" in (r.reject_reason or "") for r in rejects) + # 该日无该股成交 + assert not any( + a.date == d0.date() and a.symbol == top and a.signal == "BUY" and a.filled + for a in result.fills + ) + + def test_result_serializes_history(self, daily_df) -> None: + result = LocalEngine().run_backtest(daily_df, _spec()) + js = result.model_dump(mode="json") + assert "selection_history" in js and "signal_history" in js and "fills" in js + BacktestResult.model_validate(js) # roundtrip