`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的 每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、 策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时, 应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。 - 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history` 可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。 - 「买卖说明」卡片底部同步写出这条边界。
729 lines
33 KiB
Python
729 lines
33 KiB
Python
"""组合回测引擎(2026-09 重构):多选股策略 + 持仓天数区间 + 日/周/月调仓。
|
||
|
||
与 `local_engine.TopKBacktestRunner`(单策略、固定 m/y、全卖全买)并存:
|
||
旧 runner 继续服务 `/api/backtests`(ResearchSpec)与因子测试相关的既有路径,
|
||
本模块服务新的「回测组合」产品。两者产出**同一种 BacktestResult**,前端可视化无需改动。
|
||
|
||
执行模型(用户确认的语义):
|
||
- **打分 = 并集 + Borda 秩和**:每个选股策略各自对「自己的股票池 ∩ 条件」内的股票
|
||
按复合因子分排名;合并取并集,综合分 = Σ(1 / 该策略内名次),未进入某策略排名的
|
||
股票在该策略贡献 0。好处是不假设不同策略的因子分值可比、能容纳各策略股票池不同。
|
||
- **调仓时机 daily/weekly/monthly**:决定「重新打分 + 调向目标」的节奏。
|
||
- **持仓天数区间 [Tmin, Tmax]**:
|
||
* 每个交易日都检查 Tmax —— 持有超过 Tmax 的个股**强制了结**(安全阀,
|
||
即便调仓是月频也不能让个股远超 Tmax);
|
||
* 仅在调仓日:把「掉出 TopN 且已持 ≥ Tmin」的卖出(Tmin 防频繁换手),
|
||
再从 TopN 里补买到 N 只(等权目标,只买不主动减持以尊重 Tmin)。
|
||
- 成交仍在调仓日收盘(与旧引擎同一时序纪律,无未来函数);涨跌停/停牌沿用旧近似。
|
||
|
||
复用 local_engine 的纯工具(涨跌停幅度、NaN 判定、调仓日集合),其余记账逻辑
|
||
为本模块自包含 —— 刻意不继承旧 runner,避免改动那条已验证的路径。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
|
||
import pandas as pd
|
||
|
||
from app.domain.entities.combo import BacktestCombo, ComboRunSpec, SelectionStrategyRef
|
||
from app.domain.entities.research import (
|
||
ActionRecord,
|
||
BacktestResult,
|
||
BacktestSummary,
|
||
ConditionSpec,
|
||
CostSpec,
|
||
CurvePoint,
|
||
FactorSpec,
|
||
MonthlyReturn,
|
||
Position,
|
||
RankedPick,
|
||
SymbolCurve,
|
||
Trade,
|
||
UniverseSpec,
|
||
YearlyReturn,
|
||
)
|
||
from app.quant.composite import build_factor_panels_full, composite_score
|
||
from app.quant.factors import FactorDef
|
||
from app.quant.local_engine import _limit_up_ratio, _nan, rebalance_dates
|
||
from app.quant.trade_reasons import (
|
||
BUY_SKIP_HALTED,
|
||
BUY_SKIP_LIMIT_UP,
|
||
BUY_SKIP_MIN_COMMISSION,
|
||
BUY_SKIP_NO_CASH,
|
||
SELL_DEFER_HALTED,
|
||
SELL_DEFER_LIMIT_DOWN,
|
||
SELL_DEFER_TMIN,
|
||
SELL_DROP_TOPN,
|
||
SELL_FORCE_TMAX,
|
||
build_factor_curves,
|
||
buy_filled,
|
||
buy_skipped,
|
||
factor_values,
|
||
sell_deferred,
|
||
sell_filled,
|
||
)
|
||
|
||
TRADING_DAYS = 252
|
||
|
||
|
||
# ---------- 多策略打分:Borda 秩和 ----------
|
||
|
||
|
||
def _ref_to_specs(ref: SelectionStrategyRef) -> tuple[UniverseSpec, list[FactorSpec], list[ConditionSpec]]:
|
||
"""把归档快照里的 dict 还原成强类型 spec(喂给既有因子/条件求值器)。"""
|
||
universe = UniverseSpec.model_validate(ref.universe)
|
||
factors = [FactorSpec.model_validate(f) for f in ref.factors]
|
||
conditions = [ConditionSpec.model_validate(c) for c in ref.conditions]
|
||
return universe, factors, conditions
|
||
|
||
|
||
def borda_combine(panels: list[pd.DataFrame]) -> pd.DataFrame:
|
||
"""把多张「复合分面板」按 Borda 秩和合并成一张综合分面板。
|
||
|
||
每张面板先按日降序排名(1 = 最高分),综合分 = Σ(1/名次);某面板里 NaN(该股
|
||
不在该策略可评分集)贡献 0。index/columns 取所有面板的并集。纯函数,便于单测。
|
||
"""
|
||
if not panels:
|
||
return pd.DataFrame()
|
||
dates = sorted({d for p in panels for d in p.index})
|
||
symbols = sorted({s for p in panels for s in p.columns})
|
||
borda = pd.DataFrame(0.0, index=dates, columns=symbols)
|
||
for panel in panels:
|
||
ranks = panel.rank(axis=1, ascending=False, na_option="keep")
|
||
contrib = (1.0 / ranks).fillna(0.0)
|
||
borda = borda.add(contrib.reindex(index=borda.index, columns=borda.columns), fill_value=0.0)
|
||
return borda
|
||
|
||
|
||
def combine_strategy_scores(
|
||
daily: pd.DataFrame,
|
||
strategies: list[SelectionStrategyRef],
|
||
eligibility_fns: list,
|
||
) -> tuple[pd.DataFrame, object, dict[str, tuple[FactorDef, pd.DataFrame]]]:
|
||
"""多策略 → (综合分面板, 合并合格集闭包, 原始因子面板)。
|
||
|
||
综合分面板 index=trade_date, columns=symbol,值为 Borda 秩和(越大越优先)。
|
||
合并合格集闭包 `combined(as_of) -> set[symbol] | None`:各策略合格集的并集;
|
||
全部策略都不过滤时返回 None(= 不过滤,交给面板的 dropna 处理)。
|
||
|
||
第三个返回值是「策略用到的每个因子的**原始**面板」(key = 因子键):
|
||
复合分是 z-score 后的无量纲分,解释不了「股息率到底几厘」,因此买卖理由与
|
||
因子曲线必须回到原始值。同一因子被多个策略引用时只算一次。
|
||
"""
|
||
# 每个策略一张「复合 zscore 面板」(已按方向加权求和)
|
||
panels: list[pd.DataFrame] = []
|
||
raw_panels: dict[str, tuple[FactorDef, pd.DataFrame]] = {}
|
||
for ref in strategies:
|
||
_universe, factors, _conditions = _ref_to_specs(ref)
|
||
full = build_factor_panels_full(daily, factors)
|
||
for defn, panel, _weight in full:
|
||
raw_panels.setdefault(defn.name, (defn, panel))
|
||
panels.append(composite_score([(d.name, p, w, d.direction) for d, p, w in full]))
|
||
|
||
borda = borda_combine(panels)
|
||
|
||
def combined(as_of: date) -> set[str] | None:
|
||
sets = []
|
||
any_filter = False
|
||
for fn in eligibility_fns:
|
||
if fn is None:
|
||
continue
|
||
s = fn(as_of)
|
||
if s is not None:
|
||
sets.append(s)
|
||
any_filter = True
|
||
if not any_filter:
|
||
return None
|
||
# 并集:任一策略认为合格即合格(Borda 会给没被某策略覆盖的股票较低分,自然靠后)
|
||
out: set[str] = set()
|
||
for s in sets:
|
||
out |= s
|
||
return out
|
||
|
||
return borda, combined, raw_panels
|
||
|
||
|
||
# ---------- 持仓区间回测 runner ----------
|
||
|
||
|
||
@dataclass
|
||
class _Holding:
|
||
qty: float
|
||
entry_date: date
|
||
entry_price: float
|
||
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
|
||
# 建仓理由(结构化):了结时原样写进 Trade.entry_reason,保证「为什么买」在
|
||
# 成交明细里能一路带出来 —— 持仓中途没有别的机会把它丢掉。
|
||
entry_reason: object = None
|
||
|
||
|
||
_UNIMPLEMENTED_BASE = [
|
||
"涨跌停按收盘价相对上一有效收盘近似判定(未建模开盘一字 / 集合竞价路径)",
|
||
"成交假设发生在调仓日收盘(未建模盘中价格路径与流动性冲击)",
|
||
(
|
||
"调仓日为「增量调仓」:只卖出超 Tmax / 掉出 TopN 且满 Tmin 的仓位,"
|
||
"并从 TopN 补买至 N 只;**不主动减持超重仓位**以尊重 Tmin,权重会随行情漂移"
|
||
"(非严格等权,买入侧按等权目标分配可用现金)"
|
||
),
|
||
"Tmax 强制卖出每个交易日检查;Tmin 保护与 TopN 重排仅在调仓日执行",
|
||
(
|
||
"多策略打分采用 Borda 秩和(各策略 1/名次 求和):不假设不同策略的因子分值可比,"
|
||
"但极端情况下某策略覆盖极少股票会使其秩和贡献偏大"
|
||
),
|
||
(
|
||
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
|
||
"但「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
|
||
"要看完整候选与名次请查选股明细(selection_history)"
|
||
),
|
||
]
|
||
|
||
|
||
class HoldingBandRunner:
|
||
"""持仓天数区间 + 日/周/月调仓的组合回测 runner。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
combo: BacktestCombo,
|
||
costs: CostSpec,
|
||
score: pd.DataFrame,
|
||
close: pd.DataFrame,
|
||
eligibility_fn=None,
|
||
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
|
||
) -> None:
|
||
self.combo = combo
|
||
self.costs = costs
|
||
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.eligibility_fn = eligibility_fn
|
||
# 策略用到的因子原始面板:买卖理由里的因子值、以及因子曲线都从这里取
|
||
self.factor_panels = factor_panels or {}
|
||
self.selection_history: list[RankedPick] = []
|
||
self.signal_history: list[ActionRecord] = []
|
||
self.traded_symbols: list[str] = []
|
||
self._traded: set[str] = set()
|
||
self._no_prev_close: set[str] = set()
|
||
# 调仓日的完整排名与合格集:卖出理由要能说出「第几名掉出去的」,
|
||
# 以及「是掉出 TopN 还是根本不在候选池(被股票池/条件过滤)」
|
||
self._ranked_by_day: dict[pd.Timestamp, pd.Series] = {}
|
||
self._elig_by_day: dict[pd.Timestamp, set[str] | None] = {}
|
||
# 每个交易日的持仓市值权重(因子曲线用;空仓日空 dict → 不落点)
|
||
self._weights_by_day: dict[pd.Timestamp, dict[str, float]] = {}
|
||
|
||
# ---- 主循环 ----
|
||
|
||
def run(self) -> BacktestResult:
|
||
start, end = self.spec_period()
|
||
dates = [d for d in self.close.index if start <= d.date() <= end]
|
||
if not dates:
|
||
raise ValueError(f"回测区间 {start}~{end} 内没有任何行情数据,无法回测")
|
||
|
||
cadence = set(
|
||
rebalance_dates(self.close.index, self.combo.rebalance_freq, start, end)
|
||
)
|
||
|
||
cash = float(self.combo.initial_capital)
|
||
holdings: dict[str, _Holding] = {}
|
||
equity_rows: dict[pd.Timestamp, float] = {}
|
||
trades: list[Trade] = []
|
||
positions: list[Position] = []
|
||
notional: list[float] = []
|
||
cum: dict[str, float] = {}
|
||
curve_rows: dict[str, list[CurvePoint]] = {}
|
||
|
||
n = self.combo.hold_count
|
||
tmin = self.combo.hold_min_days
|
||
tmax = self.combo.hold_max_days
|
||
# 交易日位置索引:持仓天数按「交易日」计(Tmin/Tmax 的自然单位),避免跨周末失真
|
||
self._tday_pos = {ts: i for i, ts in enumerate(self.close.index)}
|
||
|
||
def _equity(d: pd.Timestamp) -> float:
|
||
total = cash
|
||
for s, h in holdings.items():
|
||
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
|
||
total += h.qty * float(px)
|
||
return total
|
||
|
||
for d in dates:
|
||
day = d.date()
|
||
# 1) 用昨日持仓结算当日个股收益(与组合净值同一时序口径)
|
||
self._accrue(d, holdings, cum, curve_rows)
|
||
|
||
# 2) Tmax 安全阀:**每个交易日**强制了结超期仓位(不只调仓日)
|
||
if tmax is not None:
|
||
cash = self._force_exit_over_max(d, day, holdings, cash, trades, tmax)
|
||
|
||
# 3) 调仓日:重新打分 + 增量调向目标 N 只
|
||
if d in cadence:
|
||
topn = self._top_n(d, n)
|
||
cash = self._rebalance_to_target(
|
||
d, day, topn, holdings, cash, trades, positions, notional, tmin, n
|
||
)
|
||
|
||
equity_rows[d] = _equity(d)
|
||
self._mark_curve(d, holdings, cum, curve_rows)
|
||
self._weights_by_day[d] = self._holding_weights(d, holdings)
|
||
|
||
equity = pd.Series(equity_rows).sort_index()
|
||
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
|
||
|
||
def spec_period(self) -> tuple[date, date]:
|
||
return self.combo.period
|
||
|
||
# ---- 打分 / 选股 ----
|
||
|
||
def _top_n(self, d: pd.Timestamp, n: int) -> list[str]:
|
||
score_d = self.score.loc[d].dropna()
|
||
elig = self.eligibility_fn(d.date()) if self.eligibility_fn else None
|
||
if elig is not None:
|
||
score_d = score_d[score_d.index.isin(elig)]
|
||
ranked = score_d.sort_values(ascending=False)
|
||
# 完整排名留下来:卖出理由要说「第几名掉出去的」,只有 TopN 说不出这个数
|
||
self._ranked_by_day[d] = ranked
|
||
self._elig_by_day[d] = set(elig) if elig is not None else None
|
||
top = ranked.head(n).index.tolist()
|
||
day = d.date()
|
||
for r, sym in enumerate(top, start=1):
|
||
self.selection_history.append(
|
||
RankedPick(date=day, symbol=sym, rank=r, score=round(float(ranked[sym]), 6))
|
||
)
|
||
return top
|
||
|
||
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
|
||
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
|
||
|
||
卖出理由必须能区分三件事:**在池但排名掉出去**、**已被股票池/条件过滤**
|
||
(如转为 ST)、**当日没有分数**(数据缺失)。都写成「跌出 TopN」会掩盖真相。
|
||
"""
|
||
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
|
||
ranked = self._ranked_by_day.get(d)
|
||
if ranked is None:
|
||
return out # 非调仓日(如 Tmax 强制了结发生在普通交易日):没有当日排名
|
||
elig = self._elig_by_day.get(d)
|
||
out["total"] = int(len(ranked))
|
||
out["in_pool"] = True if elig is None else (symbol in elig)
|
||
if symbol in ranked.index:
|
||
loc = ranked.index.get_loc(symbol)
|
||
if isinstance(loc, int):
|
||
out["rank"] = loc + 1
|
||
out["score"] = round(float(ranked.loc[symbol]), 6)
|
||
return out
|
||
|
||
def _holding_weights(self, d: pd.Timestamp, holdings) -> dict[str, float]:
|
||
"""当日持仓市值权重(因子曲线用)。取不到价的持仓不参与,空仓日返回空 dict。"""
|
||
out: dict[str, float] = {}
|
||
for s, h in holdings.items():
|
||
if h.qty <= 0 or s not in self.close.columns:
|
||
continue
|
||
px = self.close.at[d, s]
|
||
if _nan(px) or px <= 0:
|
||
continue
|
||
out[s] = float(h.qty) * float(px)
|
||
return out
|
||
|
||
def _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
|
||
"""构造理由所需的公共上下文(排名 + 各因子当时的原始值)。"""
|
||
ctx = self._rank_of(d, symbol)
|
||
return {
|
||
"rank": ctx["rank"],
|
||
"total": ctx["total"],
|
||
"top_n": top_n,
|
||
"score": ctx["score"],
|
||
"factors": factor_values(self.factor_panels, d, symbol) or None,
|
||
"not_in_pool": ctx["in_pool"] is False,
|
||
}
|
||
|
||
# ---- Tmax 强制了结(每日) ----
|
||
|
||
def _force_exit_over_max(self, d, day, holdings, cash, trades, tmax) -> float:
|
||
prev_d = self.prev_close_at(d)
|
||
close_d = self.close.loc[d]
|
||
for s in [s for s in list(holdings)]:
|
||
h = holdings[s]
|
||
held = self._held_trading_days(h.entry_ts, d)
|
||
if held <= tmax:
|
||
continue
|
||
c = close_d.get(s)
|
||
p = prev_d.get(s) if prev_d is not None else None
|
||
ctx = self._reason_ctx(d, s, top_n=None)
|
||
if _nan(c):
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延",
|
||
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
|
||
hold_days=held, tmax=tmax, **ctx))
|
||
)
|
||
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=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延",
|
||
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
|
||
hold_days=held, tmax=tmax,
|
||
close=float(c), prev_close=float(p),
|
||
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
|
||
**ctx))
|
||
)
|
||
continue
|
||
cash = self._sell(
|
||
s, h, float(c), day, cash, trades, holdings,
|
||
reason=sell_filled(code=SELL_FORCE_TMAX, rank=None, total=ctx["total"],
|
||
top_n=None, score=ctx["score"], factors=ctx["factors"],
|
||
hold_days=held, tmax=tmax, price=float(c),
|
||
return_pct=(float(c) / h.entry_price - 1.0) * 100),
|
||
)
|
||
return cash
|
||
|
||
# ---- 调仓日:增量调向目标 ----
|
||
|
||
def _rebalance_to_target(self, d, day, topn, holdings, cash, trades, positions, notional, tmin, n) -> float:
|
||
close_d = self.close.loc[d]
|
||
prev_d = self.prev_close_at(d)
|
||
topn_set = set(topn)
|
||
|
||
# a) 卖出:掉出 TopN 且已满 Tmin 的(Tmin 保护:未满 Tmin 即使掉出也暂留)
|
||
for s in [s for s in list(holdings)]:
|
||
if s in topn_set:
|
||
continue
|
||
h = holdings[s]
|
||
held = self._held_trading_days(h.entry_ts, d)
|
||
ctx = self._reason_ctx(d, s, top_n=n)
|
||
if held < tmin:
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留",
|
||
reason=sell_deferred(SELL_DEFER_TMIN, cause="tmin", hold_days=held,
|
||
tmin=tmin, **ctx))
|
||
)
|
||
continue
|
||
c = close_d.get(s)
|
||
p = prev_d.get(s) if prev_d is not None else None
|
||
if _nan(c):
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓",
|
||
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
|
||
hold_days=held, tmin=tmin, **ctx))
|
||
)
|
||
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="掉出 TopN,但跌停无法卖出,保留到下一调仓",
|
||
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
|
||
hold_days=held, tmin=tmin,
|
||
close=float(c), prev_close=float(p),
|
||
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
|
||
**ctx))
|
||
)
|
||
continue
|
||
cash = self._sell(
|
||
s, h, float(c), day, cash, trades, holdings,
|
||
reason=sell_filled(code=SELL_DROP_TOPN, rank=ctx["rank"], total=ctx["total"],
|
||
top_n=n, score=ctx["score"], factors=ctx["factors"],
|
||
hold_days=held, tmin=tmin, price=float(c),
|
||
return_pct=(float(c) / h.entry_price - 1.0) * 100,
|
||
not_in_pool=ctx["not_in_pool"]),
|
||
)
|
||
|
||
# b) 补买:从 TopN 里挑尚未持有的,按等权目标用可用现金买入,直到 N 只或现金耗尽
|
||
current = [s for s in topn if s in holdings and holdings[s].qty > 0]
|
||
need = [s for s in topn if s not in holdings or holdings[s].qty <= 0]
|
||
slots_left = max(0, n - len(current))
|
||
buys = need[:slots_left]
|
||
if not buys:
|
||
self._record_positions(d, day, holdings, positions)
|
||
return cash
|
||
|
||
# 等权目标:每只 ≈ 当前权益 / N;单只预算 = min(目标, 可用现金均分)
|
||
equity_now = cash + sum(
|
||
holdings[s].qty * float(self.close.at[d, s])
|
||
for s in holdings
|
||
if holdings[s].qty > 0 and not _nan(self.close.at[d, s])
|
||
)
|
||
target_each = equity_now / n if n > 0 else 0.0
|
||
per_budget = min(target_each, cash / len(buys)) if buys else 0.0
|
||
|
||
for s in buys:
|
||
c = close_d.get(s)
|
||
p = prev_d.get(s) if prev_d is not None else None
|
||
ctx = self._reason_ctx(d, s, top_n=n)
|
||
if _nan(c):
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason="无行情(停牌),无法买入",
|
||
reason=buy_skipped(BUY_SKIP_HALTED, **ctx))
|
||
)
|
||
continue
|
||
if not _nan(p) and p > 0 and c / p >= _limit_up_ratio(s):
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason="涨停,无法追买",
|
||
reason=buy_skipped(BUY_SKIP_LIMIT_UP, close=float(c),
|
||
prev_close=float(p),
|
||
limit_ratio=_limit_up_ratio(s), **ctx))
|
||
)
|
||
continue
|
||
budget = min(per_budget, cash)
|
||
if budget <= 1e-9:
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason="可用现金不足,未成交",
|
||
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
|
||
)
|
||
continue
|
||
buy_reason = buy_filled(
|
||
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
|
||
factors=ctx["factors"], price=float(c) * (1 + self.costs.slippage_rate),
|
||
budget=budget,
|
||
)
|
||
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional,
|
||
reason=buy_reason)
|
||
if not ok:
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||
reject_reason="预算不足以覆盖最低佣金,未成交",
|
||
reason=buy_skipped(BUY_SKIP_MIN_COMMISSION, budget=budget,
|
||
min_commission=self.costs.min_commission,
|
||
**ctx))
|
||
)
|
||
continue
|
||
cash -= spent
|
||
|
||
self._record_positions(d, day, holdings, positions)
|
||
return cash
|
||
|
||
# ---- 买卖原子操作 ----
|
||
|
||
def _sell(self, s, h, close_price, day, cash, trades, holdings, reason=None) -> float:
|
||
proceeds = h.qty * close_price * (1 - self.costs.slippage_rate)
|
||
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
|
||
fee = commission + proceeds * self.costs.stamp_tax_rate
|
||
cash += proceeds - fee
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price,
|
||
reason=reason)
|
||
)
|
||
trades.append(
|
||
Trade(
|
||
entry_date=h.entry_date, exit_date=day, symbol=s,
|
||
entry_price=h.entry_price, exit_price=close_price,
|
||
return_pct=(close_price / h.entry_price - 1.0) * 100,
|
||
# 买卖理由跟着成交走:成交明细里「为什么买、为什么卖」都齐
|
||
entry_reason=h.entry_reason,
|
||
exit_reason=reason,
|
||
)
|
||
)
|
||
holdings.pop(s, None)
|
||
return cash
|
||
|
||
def _buy(self, s, budget, d, close_price, day, holdings, notional, reason=None) -> tuple[bool, float]:
|
||
price_in = close_price * (1 + self.costs.slippage_rate)
|
||
commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
|
||
invest = budget - commission
|
||
if invest <= 0:
|
||
return False, 0.0
|
||
qty = invest / price_in
|
||
prev = self.prev_close_at(d)
|
||
pv = prev.get(s) if prev is not None else float("nan")
|
||
if _nan(pv) or pv <= 0:
|
||
self._no_prev_close.add(s)
|
||
holdings[s] = _Holding(
|
||
qty=qty, entry_date=day, entry_price=price_in, entry_ts=d, entry_reason=reason
|
||
)
|
||
notional.append(budget)
|
||
self.signal_history.append(
|
||
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
|
||
price=round(price_in, 4), reason=reason)
|
||
)
|
||
if s not in self._traded:
|
||
self._traded.add(s)
|
||
self.traded_symbols.append(s)
|
||
return True, budget
|
||
|
||
# ---- 辅助 ----
|
||
|
||
def _held_trading_days(self, entry_ts, current_ts) -> int:
|
||
"""从入场到当前经过的**交易日**数(不含入场当日)。"""
|
||
e = self._tday_pos.get(entry_ts)
|
||
c = self._tday_pos.get(current_ts)
|
||
if e is None or c is None:
|
||
return 0
|
||
return max(0, c - e)
|
||
|
||
def prev_close_at(self, d: pd.Timestamp):
|
||
"""d 的前一有效收盘行(用于涨跌停判定);不存在返回 None。"""
|
||
idx = self.close.index
|
||
pos = idx.get_loc(d) if d in idx else None
|
||
if pos is None or pos == 0:
|
||
return None
|
||
prev_ts = idx[pos - 1]
|
||
return self.close.loc[prev_ts]
|
||
|
||
def _accrue(self, d, holdings, cum, curve_rows) -> None:
|
||
prev = self.prev_close_at(d)
|
||
if prev is None:
|
||
return
|
||
close_d = self.close.loc[d]
|
||
for s in holdings:
|
||
c, p = close_d.get(s), prev.get(s)
|
||
if _nan(c) or _nan(p) or p <= 0:
|
||
continue
|
||
cum[s] = cum.get(s, 1.0) * (float(c) / float(p))
|
||
curve_rows.setdefault(s, []).append(
|
||
CurvePoint(date=d.date(), value=round((cum[s] - 1.0) * 100, 4))
|
||
)
|
||
|
||
def _mark_curve(self, d, holdings, cum, curve_rows) -> None:
|
||
day = d.date()
|
||
for s in holdings:
|
||
pts = curve_rows.setdefault(s, [])
|
||
if pts and pts[-1].date == day:
|
||
continue
|
||
pts.append(CurvePoint(date=day, value=round((cum.get(s, 1.0) - 1.0) * 100, 4)))
|
||
|
||
def _record_positions(self, d, day, holdings, positions) -> None:
|
||
total = sum(
|
||
h.qty * float(self.close.at[d, s])
|
||
for s, h in holdings.items()
|
||
if h.qty > 0 and not _nan(self.close.at[d, s])
|
||
)
|
||
if total <= 0:
|
||
return
|
||
for s, h in holdings.items():
|
||
if h.qty > 0 and not _nan(self.close.at[d, s]):
|
||
positions.append(
|
||
Position(date=day, symbol=s, weight=float(h.qty * self.close.at[d, s] / total))
|
||
)
|
||
|
||
# ---- 结果装配(与旧 runner 同构,保证前端可视化不变) ----
|
||
|
||
def _to_result(self, equity, trades, positions, notional, cum, curve_rows) -> BacktestResult:
|
||
start, end = equity.index[0].date(), equity.index[-1].date()
|
||
init = float(self.combo.initial_capital)
|
||
final = float(equity.iloc[-1])
|
||
rets = equity.pct_change().dropna()
|
||
nn = len(rets)
|
||
total_ret = (final / init - 1.0) * 100 if init else 0.0
|
||
annual = (
|
||
((final / init) ** (TRADING_DAYS / max(nn, 1)) - 1.0) * 100
|
||
if final > 0 and init > 0 else -100.0
|
||
)
|
||
mean_r = float(rets.mean()) if nn else 0.0
|
||
std_r = float(rets.std(ddof=1)) if nn else 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),
|
||
)
|
||
curves = self._symbol_curves(curve_rows, cum)
|
||
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],
|
||
symbol_curves=curves,
|
||
factor_curves=build_factor_curves(self.factor_panels, self._weights_by_day),
|
||
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
|
||
unimplemented=self._unimplemented(),
|
||
config_snapshot={}, # 由服务层填入 ComboRunSpec(含策略+成本快照)
|
||
)
|
||
|
||
def _symbol_curves(self, curve_rows, cum) -> list[SymbolCurve]:
|
||
marks: dict[str, list[ActionRecord]] = {}
|
||
for a in self.signal_history:
|
||
if a.filled and a.symbol:
|
||
marks.setdefault(a.symbol, []).append(a)
|
||
out: list[SymbolCurve] = []
|
||
for s, points in curve_rows.items():
|
||
if not points:
|
||
continue
|
||
out.append(SymbolCurve(
|
||
symbol=s, points=points, marks=marks.get(s, []),
|
||
final_return_pct=round((cum.get(s, 1.0) - 1.0) * 100, 4),
|
||
))
|
||
out.sort(key=lambda c: abs(c.final_return_pct), reverse=True)
|
||
return out
|
||
|
||
def _unimplemented(self) -> list[str]:
|
||
notes = list(_UNIMPLEMENTED_BASE)
|
||
if self._no_prev_close:
|
||
notes.append(
|
||
f"有 {len(self._no_prev_close)} 只标的成交时缺少上一有效收盘价,"
|
||
"无法判定涨停(数据窗口起点或长期停牌后复牌),按可买处理"
|
||
)
|
||
c = self.combo
|
||
notes.append(
|
||
f"组合参数:N={c.hold_count}、持仓区间 [{c.hold_min_days}, "
|
||
f"{c.hold_max_days if c.hold_max_days is not None else '∞'}] 天、"
|
||
f"调仓 {c.rebalance_freq}、引用 {len(c.strategy_ids)} 个选股策略"
|
||
)
|
||
return notes
|
||
|
||
|
||
def run_combo_backtest(
|
||
*,
|
||
combo: BacktestCombo,
|
||
strategies: list[SelectionStrategyRef],
|
||
costs: CostSpec,
|
||
price_adjustment: str,
|
||
daily: pd.DataFrame,
|
||
eligibility_fns: list,
|
||
) -> BacktestResult:
|
||
"""组合回测入口:多策略 Borda 打分 → 持仓区间 runner → BacktestResult。
|
||
|
||
`eligibility_fns` 与 `strategies` 一一对应(每个策略一个「as_of→合格集」闭包,
|
||
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
|
||
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。
|
||
"""
|
||
score, combined_elig, factor_panels = combine_strategy_scores(
|
||
daily, strategies, eligibility_fns
|
||
)
|
||
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
|
||
runner = HoldingBandRunner(
|
||
combo=combo, costs=costs, score=score, close=close, eligibility_fn=combined_elig,
|
||
factor_panels=factor_panels,
|
||
)
|
||
result = runner.run()
|
||
# 固化可复现规格(AGENT.md §21):组合参数 + 当时各策略定义 + 当时成本/复权
|
||
result.config_snapshot = ComboRunSpec(
|
||
combo=combo, strategies=strategies, costs=costs, price_adjustment=price_adjustment,
|
||
).model_dump(mode="json")
|
||
return result
|