Files
qlib/backend/app/quant/local_engine.py
T
Simon 57d6082f91 fix: 如实说明买卖理由的覆盖边界(哪些不算买卖点)
`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的
每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、
策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时,
应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。

- 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history`
  可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。
- 「买卖说明」卡片底部同步写出这条边界。
2026-10-01 18:15:26 +08:00

994 lines
46 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 简单可替换优先)。
无未来函数纪律:
- 择股日 s 的选股只使用 <= s 的因子值、条件字段与收盘价
- 成交发生在调仓日 t 收盘(价格 = close[t] ± 滑点);t 当日组合收益用 t-1 收盘持仓结算,
调仓在 t 收盘生效、自 t+1 起计收益 —— 不存在「当日买入当日计收益」的未来函数
- 顺延买入(defer_buy)只在**之后的交易日**补成交,绝不回溯到择股日之前
- 涨跌停 / 停牌约束按可达信息近似建模,未建模部分显式写入结果 unimplemented
周期模型(本次扩展,见 ResearchSpec):
- 择股日集合 S:每 m 个月(selection_interval_months),锚定回测起始月
- 调仓日集合 R:每 y 个月(rebalance_interval_months,缺省 = m)
- m 未给 → S = R(每次调仓都重新择股,与历史行为一致)
- 候选池 = S 日按因子分排序的 Top n(top_n);实际持仓 = 池内前 x(hold_top_x)
"""
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,
SymbolCurve,
Trade,
TradeReason,
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.factors import FactorDef
from app.quant.portfolio import (
allocate_with_max_position,
equal_weight_budget,
unimplemented_notes,
)
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_DROP_TOPN,
SELL_REBALANCE_FULL,
build_factor_curves,
buy_filled,
buy_skipped,
factor_values,
sell_deferred,
sell_filled,
)
TRADING_DAYS = 252
# 个股收益曲线数量上限:
# None(默认)= 不截断,期内持有的每只都输出(「完整存档」;体积由归档侧的
# 字节预算兜底,见 app/application/services/experiment_archive.py);
# 数字 = 按 |期末收益| 降序截断,且如实写入 unimplemented 说明。
# 取值优先级:本模块变量(测试 monkeypatch 用)> config research.archive_curve_limit。
_MAX_SYMBOL_CURVES: int | None = None
def _resolved_curve_limit() -> int | None:
"""当前生效的曲线数量上限(None = 完整输出)。"""
if _MAX_SYMBOL_CURVES is not None:
return _MAX_SYMBOL_CURVES
from app.core.config import get_settings
return get_settings().research_archive_curve_limit
# 恒定的未建模说明(AGENT.md §24:未实现项必须在结果中显式标注)
_DEFAULT_UNIMPLEMENTED = [
"涨跌停按收盘价相对上一有效收盘近似判定(未建模开盘一字 / 集合竞价路径)",
"成交假设发生在调仓日收盘(未建模盘中价格路径与流动性冲击)",
(
"调仓为「全部卖出 → 按目标等权重新买入」,未做权重漂移微调:"
"保留在目标名单中的股票也会产生一次完整买卖往返,交易成本估计偏保守"
),
(
"股票池来自本地行情表(已含退市股:stock.status='D' 且带 delist_date,"
"退市日之后自动退出池子)。残余偏差:库里仅有 2019-12 之后退市的标的,"
"更早退市者无行情数据"
),
(
"exclude_st 的名称口径见 config_snapshot.price_basis.name_basis:时点口径依赖 "
"stock_name_history(sync namechange),未同步时回退最新名称快照,"
"会漏掉「曾是高股息、后来才变 ST」的股息陷阱样本"
),
]
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 _month_firsts(index: pd.Index) -> list[pd.Timestamp]:
"""每个自然月的首个交易日(按 index 顺序)。"""
periods = index.to_period("M")
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 order
def _week_firsts(index: pd.Index) -> list[pd.Timestamp]:
"""每个自然周的首个交易日。"""
periods = index.to_period("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 order
def _month_seq(ts: pd.Timestamp) -> int:
"""月序号(year*12+month),用于「每 m 个月」的锚定计算。"""
return int(ts.year) * 12 + int(ts.month)
def rebalance_dates(
index: pd.Index,
rebalance: str,
start: date,
end: date | None = None,
every_months: int | None = None,
) -> list[pd.Timestamp]:
"""调仓/择股日集合(按频率取首个交易日,>= start)。
every_months=n(n>0):忽略 rebalance 频率,改用「每 n 个月」——
锚定 **首个 >= start 的交易日所在月**(锚点月 t0),取月序号满足
`(t - t0) % n == 0` 的月份的首个交易日。
这样 2020-01-01 起、n=6 → 2020-01、2020-07、2021-01 …;
起始日改为 2020-03-15(该月首个交易日 03-02 早于 start)→ **2020-03-16**
(03 月内首个 >= start 的交易日)、2020-09、2021-03 …。
⚠️ 刻意**不丢弃锚点月**:若把锚点月整体过滤掉,m=y=6 且起始日非月初时
会白等 6 个月才首次建仓(净值在前期恒等于初始资金,指标明显失真)。
every_months=None:沿用 weekly / monthly / **daily** 频率。
- daily:区间内**每个交易日**都是调仓日(回测组合的「日频调仓」)。
- weekly / monthly:原行为,保持向后兼容。
"""
if every_months and every_months > 0:
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
# 锚点 = start 所在月内首个 >= start 的交易日(可能不是该月首个交易日)
days = pd.DatetimeIndex(index)
after_start = days[days >= pd.Timestamp(start)]
if len(after_start) == 0:
return []
anchor_ts = after_start[0]
anchor = _month_seq(anchor_ts)
# 后续月份:月序号与锚点月相差整数倍 m,取该月首个交易日
out = [anchor_ts] + [
ts
for ts in firsts
if _month_seq(ts) > anchor
and (_month_seq(ts) - anchor) % every_months == 0
and ts.date() >= start
and ts != anchor_ts
]
elif rebalance == "daily":
# 日频:区间内每个交易日
days = pd.DatetimeIndex(index)
out = [ts for ts in days if ts.date() >= start]
else:
firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index)
out = [ts for ts in firsts if ts.date() >= start]
if end is not None:
out = [ts for ts in out if ts.date() <= end]
return out
@dataclass
class PendingBuy:
"""顺延买单:调仓日买不进(涨停/停牌)时挂起,之后逐日重试。
仅当 SelectionSpec.defer_buy=True 时产生;到下一次调仓日仍未成交则作废。
`budget` 是调仓日按等权/上限为该标的预留的资金,成交时按 min(budget, 可用现金) 执行。
"""
symbol: str
budget: float
since: date
@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,
eligibility_fn=None,
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
) -> 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)
# 条件过滤(可选):(as_of: date) -> set[symbol] | None
# 由 Service 注入(复用 selection.eligible_symbols),保证回测与选股同一套求值逻辑
self.eligibility_fn = eligibility_fn
# 策略因子的**原始**面板(由 LocalEngine 用 build_factor_panels_full 一次算完后注入):
# 买卖理由里的「各因子当时的值」与 factor_curves 都从这里取,
# 与复合分用的是同一份数据 —— 理由不会去重算一遍因子而得到另一个数
self.factor_panels = factor_panels or {}
# M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3)
self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = []
# 当前候选池(择股日刷新):current_ranked 为全市场可评分排序,current_pool = 前 n
self.current_ranked: list[str] = []
self.current_pool: list[str] = []
# 择股日的**完整排名**与合格集:卖出理由要能说出「第几名」,以及
# 「是排名掉出去、还是根本不在候选池(被股票池/条件过滤)」
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]] = {}
# 交易日位置索引:持有交易日按「交易日」计(跨周末不会虚增天数)
self._tday_pos: dict[pd.Timestamp, int] = {}
# 本次回测期内被持有过的股票(用于个股收益曲线)
self.traded_symbols: list[str] = []
self._traded: set[str] = set()
# 无前收导致涨停无法判定、按可买处理并**实际成交**的标的集合(结果中如实标注)
self._no_prev_close_symbols: set[str] = set()
# ---- 主流程 ----
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]
if not dates:
raise ValueError(
f"回测区间 {self.spec.period[0]}~{end_date} 内没有任何行情数据,无法回测"
)
m = self.spec.effective_selection_months
y = self.spec.effective_rebalance_months
rebal = set(
rebalance_dates(
self.close.index, self.spec.rebalance, self.spec.period[0], end_date, every_months=y
)
)
if m is None:
# 未给 m:每次调仓都重新择股(与历史行为一致)
select = set(rebal)
else:
select = set(
rebalance_dates(
self.close.index, self.spec.rebalance, self.spec.period[0], end_date,
every_months=m,
)
)
cash = float(self.spec.initial_capital)
shares: dict[str, float] = {}
entry_date: dict[str, date] = {}
entry_price: dict[str, float] = {}
# 建仓理由存进持仓结构:持有期间没有别的机会带上它,卖出成交时原样写进
# Trade.entry_reason,成交明细里「为什么买、为什么卖」才都齐
entry_reason: dict[str, TradeReason] = {}
equity_rows: dict[pd.Timestamp, float] = {}
trades: list[Trade] = []
positions: list[Position] = []
notional: list[float] = []
pending: list[PendingBuy] = []
# 个股收益曲线:cum = 该股「持仓期间」的累计净值(1.0 = 未涨未跌)
cum: dict[str, float] = {}
curve_rows: dict[str, list[CurvePoint]] = {}
# 持有交易日按交易日序号相减(自然日会跨周末失真)
self._tday_pos = {ts: i for i, ts in enumerate(self.close.index)}
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:
# 1) 先用「上一交易日收盘持仓」结算当日个股收益(与组合净值同一时序口径:
# 当日收益来自昨日持仓)→ 建仓当日不计收益、卖出当日仍有收益
self._accrue_symbol_returns(d, shares, cum, curve_rows)
# 2) 择股 / 调仓(成交发生在当日收盘)
if d in select:
self.current_ranked, self.current_pool = self._select(d)
if d in rebal:
# 上一次调仓挂起的顺延单作废(只在两次调仓之间有效)
pending = []
cash = self._rebalance(
d, cash, shares, entry_date, entry_price, entry_reason, trades, positions,
notional, pending,
)
elif pending:
cash = self._fill_pending(
d, cash, shares, entry_date, entry_price, entry_reason, pending, notional
)
equity_rows[d] = _value(d)
# 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注
# 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates)
self._mark_curve_dates(d, shares, cum, curve_rows)
# 4) 记录当日持仓市值(因子曲线按此加权;空仓日记录空 dict → 曲线不落点)
self._weights_by_day[d] = self._holding_weights(d, shares)
equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
# ---- 择股(择股日 s:只用 <= s 的数据) ----
def _select(self, d: pd.Timestamp) -> tuple[list[str], list[str]]:
"""返回 (全市场可评分排序, 候选池 top n),并记录 selection_history。"""
score_d = self.score.loc[d].dropna()
eligible = None
if self.eligibility_fn is not None:
eligible = self.eligibility_fn(d.date())
if eligible is not None:
score_d = score_d[score_d.index.isin(eligible)]
ranked = score_d.sort_values(ascending=False)
# 完整排名留下来:卖出理由要说「第几名」;只有 TopN 说不出这个数
self._ranked_by_day[d] = ranked
self._elig_by_day[d] = set(eligible) if eligible is not None else None
order = ranked.index.tolist()
n = self.spec.selection.top_n
pool = order[:n]
day = d.date()
for rank, sym in enumerate(pool, start=1):
self.selection_history.append(
RankedPick(date=day, symbol=sym, rank=rank, score=round(float(score_d[sym]), 6))
)
return order, pool
# ---- 买卖理由的上下文(与组合引擎同口径) ----
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
三者必须分开:**在池但排名靠后**、**已被股票池/条件过滤**(如转为 ST)、
**当日没有分数**(非择股日 / 数据缺失)—— 都写成「跌出 TopN」会掩盖真相。
非择股日没有当日排名,返回 None 而不是拿上一次择股的名次冒充。
"""
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
ranked = self._ranked_by_day.get(d)
if ranked is None:
return out # 非择股日(如顺延成交发生在两次调仓之间):没有当日排名
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 _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
"""理由构造器的公共参数:当日排名 + 各因子当时的**原始值**。
`factors` 取不到值就传 None(而不是空 dict):构造器据此不写这个字段,
空 dict 与「真的没有因子值」在 data 里应当可区分。
"""
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,
}
def _buy_skip_reason(
self, code: str, *, symbol: str, ctx: dict, close=None, prev_close=None, budget=None
) -> TradeReason:
"""买入未成交理由:只有涨停需要用「收盘 / 前收 vs 阈值」的真实比值解释。
文案与 data 一律由 trade_reasons 的构造器决定(两套引擎不许各写一份措辞)。
"""
if code == BUY_SKIP_LIMIT_UP:
return buy_skipped(
code, close=float(close), prev_close=float(prev_close),
limit_ratio=_limit_up_ratio(symbol), **ctx,
)
if code == BUY_SKIP_NO_CASH:
return buy_skipped(code, budget=budget, **ctx)
if code == BUY_SKIP_MIN_COMMISSION:
return buy_skipped(
code, budget=budget, min_commission=self.costs.min_commission, **ctx
)
return buy_skipped(code, **ctx)
def _holding_weights(self, d: pd.Timestamp, shares) -> dict[str, float]:
"""当日持仓市值(因子曲线加权用)。取不到价的持仓不参与,空仓日返回空 dict。"""
out: dict[str, float] = {}
for s, qty in shares.items():
if qty <= 0 or s not in self.close.columns:
continue
px = self.close.at[d, s] if d in self.close.index else None
if _nan(px) or px <= 0:
continue
out[s] = float(qty) * float(px)
return out
def _held_trading_days(self, entry_day: date, d: pd.Timestamp) -> int:
"""从入场到当前经过的**交易日**数(不含入场当日)。"""
e = self._tday_pos.get(pd.Timestamp(entry_day))
c = self._tday_pos.get(d)
if e is None or c is None:
return 0
return max(0, c - e)
# ---- 调仓(t 收盘执行,自 t+1 生效) ----
def _rebalance(
self, d, cash, shares, entry_date, entry_price, entry_reason, trades, positions,
notional, pending,
):
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
day = d.date()
n = self.spec.selection.top_n # 候选池大小:理由里的 TopN 口径
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
for s in [s for s in shares if shares[s] > 0]:
c, p = close_d[s], prev_d[s]
held = self._held_trading_days(entry_date[s], d)
ctx = self._reason_ctx(d, s, top_n=n)
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="无行情(停牌),保留持仓",
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
hold_days=held, **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="跌停无法卖出,保留到下一调仓",
reason=sell_deferred(
SELL_DEFER_LIMIT_DOWN, cause="limit_down", hold_days=held,
close=float(c), prev_close=float(p),
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0), **ctx))
)
continue # 跌停无法卖出:保留到下一调仓
qty = shares[s]
proceeds = qty * float(c) * (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
# 本引擎的调仓是「全部卖出 → 按目标等权重新买入」(见 unimplemented):
# 若该股**当时仍排在 TopN 内**,卖它不是因为掉出榜单,而是策略本身的换仓方式,
# 用 SELL_REBALANCE_FULL 如实说明;只有确实不在池 / 名次掉出 / 当日无分数
# 才归 SELL_DROP_TOPN。数字照旧取当日真实值,code 只是把事实说准。
in_topn = (
ctx["rank"] is not None and not ctx["not_in_pool"] and ctx["rank"] <= n
)
sell_reason = sell_filled(
code=SELL_REBALANCE_FULL if in_topn else SELL_DROP_TOPN,
rank=ctx["rank"], total=ctx["total"], top_n=n,
score=ctx["score"], factors=ctx["factors"], hold_days=held, price=float(c),
return_pct=(float(c) / entry_price[s] - 1.0) * 100,
not_in_pool=ctx["not_in_pool"],
)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c),
reason=sell_reason)
)
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,
# 买卖理由跟着成交走:明细里「为什么买、为什么卖」两端齐全
entry_reason=entry_reason.get(s),
exit_reason=sell_reason,
)
)
shares[s] = 0.0
entry_date.pop(s, None)
entry_price.pop(s, None)
entry_reason.pop(s, None)
# 2) 买入意图:候选池(= selection_history 记录的那批)
picks = list(self.current_pool)
x = min(self.spec.selection.x, len(picks))
sel = self.spec.selection
if x < self.spec.selection.x:
self.signal_history.append(
ActionRecord(
date=day,
symbol="",
signal="BUY",
filled=False,
reject_reason=(
f"候选池仅 {len(picks)} 只(< 目标持仓 x={self.spec.selection.x}),"
"按池内数量持仓"
),
)
)
def _buyable(sym) -> tuple[bool, str | None, str | None]:
"""(可否买入, 拒绝文案, 未成交原因 code)。
文案保持原样(既有结果里的 reject_reason 不许变),额外把原因 code 带出来,
让 ActionRecord.reason 用**词表里的 code** 表达同一件事,而不是去解析文案。
"""
c, p = close_d[sym], prev_d[sym]
if _nan(c):
return False, "无行情(停牌),无法买入", BUY_SKIP_HALTED
if _nan(p) or p <= 0:
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
return True, None, None
if c / p >= _limit_up_ratio(sym):
return False, "涨停,无法追买", BUY_SKIP_LIMIT_UP
return True, None, None
# 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找
targets: list[str] = []
if sel.allow_substitute:
for sym in self.current_ranked:
if len(targets) >= self.spec.selection.x:
break
ok, _, _code = _buyable(sym)
if ok:
targets.append(sym)
else:
targets = picks[:x]
pending_specs: list[tuple[str, str | None]] = []
spends: dict[str, float] = {}
if targets:
cap = self.spec.portfolio.max_position_pct
if cap is None:
# 默认等权:按「目标持仓数」均分可用现金(顺延未成交的部分留作现金)
budget = equal_weight_budget(cash, len(targets))
spends = {s: budget for s in targets}
else:
# Portfolio v1.1:按单股上限(相对当日组合市值)分配,超出部分留现金
equity_now = 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])
)
spends = allocate_with_max_position(cash, targets, equity_now, cap)
for s in targets:
budget = spends[s]
ctx = self._reason_ctx(d, s, top_n=n)
if budget <= 1e-9:
# 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕
self.signal_history.append(
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason="分配额不足(可用现金≈0),未成交",
reason=self._buy_skip_reason(
BUY_SKIP_NO_CASH, symbol=s, ctx=ctx, budget=budget
),
)
)
continue
ok, reason, code = _buyable(s)
if not ok:
skip_reason = self._buy_skip_reason(
code, symbol=s, ctx=ctx, close=close_d[s], prev_close=prev_d[s]
)
if sel.defer_buy:
# 顺延:挂单到之后首个可成交交易日(本次不成交,资金留现金)
pending_specs.append((s, reason))
self.signal_history.append(
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason=f"{reason},顺延到之后首个可成交日买入",
reason=skip_reason,
)
)
else:
self.signal_history.append(
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason=reason or "不可买入",
reason=skip_reason,
)
)
continue
buy_reason = buy_filled(
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
factors=ctx["factors"], price=float(close_d[s]) * (1 + self.costs.slippage_rate),
budget=budget,
)
if not self._execute_buy(
s, budget, d, close_d[s], shares, entry_date, entry_price, entry_reason,
notional, reason=buy_reason,
):
self.signal_history.append(
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason="预算不足以覆盖最低佣金,未成交",
reason=self._buy_skip_reason(
BUY_SKIP_MIN_COMMISSION, symbol=s, ctx=ctx, budget=budget
),
)
)
continue
cash -= budget
# 替补模式下目标名单取自 n 名之外,池内被跳过的标的也要记录意图,
# 否则「信号有了却没买」无法解释(v3 §20.3 Signal↔Fill 透明化)。
# 非替补模式下 targets == picks[:x],池内标的都已在上面留痕,无需再遍历。
if sel.allow_substitute:
for sym in picks:
if sym in set(targets):
continue
_ok, reason, code = _buyable(sym)
if code is None:
code = BUY_SKIP_NO_CASH # 可买却未入选目标:资金分配已给别人
self.signal_history.append(
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
reject_reason=reason or "资金不足(未成交)",
reason=self._buy_skip_reason(
code, symbol=sym, ctx=self._reason_ctx(d, sym, top_n=n),
close=close_d.get(sym), prev_close=prev_d.get(sym),
budget=cash,
))
)
# 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)
)
)
# 顺延单登记:预留额度 = 调仓日的等权/上限分配额(不因后续价格变化而变)
for sym, _reason in pending_specs:
pending.append(PendingBuy(symbol=sym, budget=spends.get(sym, 0.0), since=day))
return cash
def _execute_buy(
self, s, budget, d, close_value, shares, entry_date, entry_price, entry_reason,
notional, reason=None,
) -> bool:
"""按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。
现金支出恒为 budget:shares = (budget - 佣金) / (收盘价 × (1 + 滑点))。
返回是否成交(预算不足以覆盖最低佣金时不成交,调用方不得扣减现金)。
"""
c = float(close_value)
price_in = c * (1 + self.costs.slippage_rate)
commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
invest = budget - commission
if invest <= 0:
return False
# 累加而非覆盖:避免「跌停/停牌未卖出而保留的旧仓位」被静默清零
shares[s] = shares.get(s, 0.0) + invest / price_in
entry_date[s] = d.date()
entry_price[s] = price_in
# 建仓理由存进持仓结构:等真正卖出时写进 Trade.entry_reason(中间不会丢)
entry_reason[s] = reason
prev = self.prev_close.at[d, s] if d in self.prev_close.index else float("nan")
if _nan(prev) or prev <= 0:
self._no_prev_close_symbols.add(s) # 无前收→涨停不可判定,如实记入标注
notional.append(budget)
self.signal_history.append(
ActionRecord(date=d.date(), 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
# ---- 顺延买入(defer_buy):之后逐日重试 ----
def _fill_pending(
self, d, cash, shares, entry_date, entry_price, entry_reason, pending, notional
):
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
remaining: list[PendingBuy] = []
for order in pending:
if order.symbol in shares and shares[order.symbol] > 0:
continue # 期间已通过其他路径持有 → 撤销该顺延单
c, p = close_d.get(order.symbol), prev_d.get(order.symbol)
if _nan(c) or _nan(p) or p <= 0:
remaining.append(order)
continue
if c / p >= _limit_up_ratio(order.symbol):
remaining.append(order) # 仍涨停 → 继续顺延
continue
budget = min(order.budget, cash)
if budget <= 1e-9:
remaining.append(order) # 无可用现金(理论上不会发生)
continue
# 顺延成交发生在两次调仓之间的普通交易日,**当日没有择股排名**:
# rank/total/score 一律为 None(不拿上次择股的名次冒充当日名次);
# 因子原始值与成交价/预算取成交当日的真实值。挂单当日「为什么被选中」
# 已记在那条 filled=False 的 BUY 信号上(reason=buy_skipped(...))。
fill_reason = buy_filled(
rank=None, total=None, top_n=None, score=None,
factors=factor_values(self.factor_panels, d, order.symbol) or None,
price=float(c) * (1 + self.costs.slippage_rate), budget=budget, deferred=True,
)
if not self._execute_buy(
order.symbol, budget, d, c, shares, entry_date, entry_price, entry_reason,
notional, reason=fill_reason,
):
remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化)
continue
cash -= budget
pending[:] = remaining
return cash
# ---- 个股收益曲线 ----
def _accrue_symbol_returns(self, d, shares, cum, curve_rows) -> None:
"""逐日累计各持仓股的「持仓期收益」(以建仓日收盘为 0% 基准)。
口径:cum 以 1.0 起算,仅在该股**持有期间**按日复利(close/prev_close)。
本方法在当日调仓**之前**调用,因此:
- 建仓当日不计收益(成交发生在当日收盘)→ 不存在当日买入当日计收益的未来函数
- 卖出当日仍计收益(当日收益来自昨日持仓)
未持有期间不产生数据点(曲线不落点),多段持仓则以 cum 连乘衔接;
前端以买卖点标注区分各段持仓区间。
"""
prev_d = self.prev_close.loc[d]
close_d = self.close.loc[d]
for s, qty in shares.items():
if qty <= 0:
continue
c, p = close_d.get(s), prev_d.get(s)
if _nan(c) or _nan(p) or p <= 0:
continue # 停牌/无前收:无有效收益
cum[s] = cum.get(s, 1.0) * (float(c) / float(p))
# 只为「当日持有」的股票落点(未持有期间不落点,显著压缩结果体积)
for s, qty in shares.items():
if qty <= 0:
continue
curve_rows.setdefault(s, []).append(
CurvePoint(date=d.date(), value=round((cum.get(s, 1.0) - 1.0) * 100, 4))
)
def _mark_curve_dates(self, d, shares, cum, curve_rows) -> None:
"""为当日持有但尚未落点的股票补一个基准点(建仓当日 / 顺延成交当日)。
值为该股当前的 `cum`(新标的为 1.0 → 0%,复买标的延续上一段的累计值),
因此曲线总能在买卖点当日取到数值,前端标注不会因缺数据点而被丢弃。
"""
day = d.date()
for s, qty in shares.items():
if qty <= 0:
continue
points = curve_rows.setdefault(s, [])
if points and points[-1].date == day:
continue
points.append(
CurvePoint(date=day, value=round((cum.get(s, 1.0) - 1.0) * 100, 4))
)
# ---- 指标 ----
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.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),
)
curves, curve_note = 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,
# 因子曲线 = 当日持仓按市值加权的因子**原始值**(空仓日不落点,见 trade_reasons)
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(curve_note),
config_snapshot=self.spec.model_dump(mode="json"),
)
def _symbol_curves(self, curve_rows, cum) -> tuple[list[SymbolCurve], str | None]:
"""按「期末收益绝对值」降序输出个股曲线(前端默认展示前若干只)。
返回 (曲线列表, 截断说明)。默认**不截断**(`config research.archive_curve_limit`
为 null):期内持有的每只都输出,保证归档完整;体积由归档侧的字节预算兜底
(见 experiment_archive)。仅当配置了数字上限时才截断,并如实标注哪一部分
被丢弃、为什么(AGENT §24:不静默降级,绝不假装完整)。
"""
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)
note = None
limit = _resolved_curve_limit()
if limit is not None and len(out) > limit:
note = (
f"个股收益曲线仅输出收益绝对值最大的 {limit} 只"
f"(期内共持有 {len(out)} 只):完整明细见 trades / signal_history"
)
out = out[:limit]
return out, note
def _unimplemented(self, curve_note: str | None = None) -> list[str]:
notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio)
# 如实说明买卖理由的覆盖边界:每个买卖点(含涨停/停牌/现金不足等未成交)都有理由,
# 但「排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点(看选股明细即可)。
notes.append(
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
"「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
"要看完整候选与名次请查选股明细(selection_history)"
)
if self._no_prev_close_symbols:
notes.append(
f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价,"
"无法判定涨停(数据窗口起点或长期停牌后复牌),按可买处理"
)
if curve_note:
notes.append(curve_note)
sel = self.spec.selection
m = self.spec.effective_selection_months
y = self.spec.effective_rebalance_months
if m is not None and y is not None and y < m:
notes.append(
f"调仓间隔 y={y} 个月 < 择股间隔 m={m} 个月:两次择股之间会复用同一候选池"
"(池子陈旧),并非每次调仓都重新择股"
)
if sel.defer_buy:
notes.append(
"顺延买入:调仓日涨停/停牌无法买入的标的挂单至之后首个可成交交易日,"
"按该日收盘价成交;到下一次调仓仍未成交则作废并留作现金"
)
if self.spec.price_adjustment == "none":
notes.append(
"行情口径为不复权:现金分红未计入收益,除权日的价格下移会被计为亏损。"
"股息类策略建议使用 price_adjustment=hfq(后复权)"
)
if not self.spec.conditions:
notes.append("未配置选股过滤条件(conditions),候选池仅由 universe + 因子排序决定")
return notes
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:
"""缺失判定:None / NaN / 不可转 float 一律视为「无有效值」。
注意 Series.get(key) 对不存在的键返回 None(而非 NaN),故必须把 None 判为缺失。
"""
if v is None:
return True
try:
return bool(math.isnan(float(v)))
except (TypeError, ValueError):
return True