feat(quant): 单策略引擎也给出买卖理由与因子曲线(口径与组合引擎一致)

「因子组合」/`POST /api/research/backtests` 走的是 `LocalEngine/TopKBacktestRunner`,
上一版只把理由接进了组合引擎,同一件事在两个引擎上就会有两种说法。这次补齐:

- `engine.py` / `qlib_adapter/engine.py`:因子面板**只算一次**
  (`build_factor_panels_full`)→ 复合分与「理由里引用的因子原始值」同源同张面板;
  复合分口径逐字未变(与 `selection.score_panel_for_factors` 相同)。
- `local_engine.py`:调仓日保留完整排名与合格集,各站点写入结构化理由 ——
  买入(按名次建仓 / 顺延成交 / 涨停 / 停牌 / 现金不足 / 不足最低佣金)、
  卖出(全量换仓 / 跌出 TopN / 不在候选池 / 停牌顺延 / 跌停顺延);
  `Trade.entry_reason/exit_reason` 两端齐全;每个交易日记录持仓市值,
  结果填 `factor_curves`(持仓市值加权平均的因子原始值,空仓日不落点)。
- 新增 `SELL_REBALANCE_FULL`(「调仓换仓卖出」):单策略调仓是「先全清再建仓」,
  被卖出的股票**可能仍排在 TopN 内**(如 rank=1),这时写「跌出 TopN」就是假解释;
  按事实分 code(仍在 TopN 内 → 全量换仓;否则 → 跌出 TopN / 不在候选池)。
- 顺延成交不拿挂单日的旧名次冒充当日名次(rank/total/score=None,因子值/成交价/预算
  取成交当日真实值);「候选池不足」的提示记录保持 reason=None(词表里没有对应语义,
  硬套就是编理由)。

验证:
- 新增 `tests/test_local_engine_reasons.py` 14 条:理由数字对回面板、涨停比值对回行情与
  板块规则、停牌/跌停/现金不足/最低佣金、顺延成交、全量换仓 vs 不在池两个分支、
  Trade 两端理由、因子曲线市值加权(手算加权值断言 + 等权平均对不上)、空仓不落点。
  后端 524 条全过(510 + 14),ruff clean。
- 强回归:用改前引擎并排跑 9 个场景,`signal_history`(日期/方向/成交/原因文案/价格)、
  `trades`、`positions`、`summary`、净值/回撤、`unimplemented` 逐条一致 —— 理由与曲线
  是纯新增字段,成交行为零变化。
This commit is contained in:
Simon
2026-10-01 18:08:42 +08:00
parent 633176a3d1
commit 7e369d9680
5 changed files with 767 additions and 33 deletions
+14 -4
View File
@@ -10,9 +10,10 @@ from typing import Protocol
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.composite import build_factor_panels_full, composite_score
from app.quant.factors import FactorError, get_factor
from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test
from app.quant.selection import condition_needed_columns, score_panel_for_factors
from app.quant.selection import condition_needed_columns
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
_CLOSE = {"close"}
@@ -73,7 +74,16 @@ class LocalEngine:
def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult:
# 评分面板与选股共用同一构建(v2 §25:回测与当前选股同引擎)
score = score_panel_for_factors(daily, spec.factors)
# 因子面板**只算一次**:复合分(选股)与原始值(买卖理由 / 因子曲线)同源。
# 若先 score_panel_for_factors 再单独算一遍原始面板,同一份行情会被算两遍,
# 且两次结果理论上可能分叉 —— 打分用的面板与理由里引用的面板必须是同一张。
# 复合分构建口径不变(与 selection.score_panel_for_factors 同为 z-score 加权和,
# v2 §25:回测与当前选股同引擎)。
panels = build_factor_panels_full(daily, spec.factors) # 未知因子在此抛 FactorError
score = composite_score([(d.name, p, w, d.direction) for d, p, w in panels])
# 同名因子只留一份(spec 已禁止重复因子名,这里再兜一层)
factor_panels = {d.name: (d, p) for d, p, _w in panels}
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
return TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
return TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()
+223 -24
View File
@@ -34,6 +34,7 @@ from app.domain.entities.research import (
ResearchSpec,
SymbolCurve,
Trade,
TradeReason,
YearlyReturn,
)
from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用)
@@ -42,11 +43,28 @@ from app.quant.composite import ( # noqa: F401 —— re-export(模块化后
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
@@ -209,6 +227,7 @@ class TopKBacktestRunner:
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()
@@ -221,12 +240,24 @@ class TopKBacktestRunner:
# 条件过滤(可选):(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()
@@ -265,6 +296,9 @@ class TopKBacktestRunner:
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] = []
@@ -273,6 +307,8 @@ class TopKBacktestRunner:
# 个股收益曲线: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
@@ -296,15 +332,19 @@ class TopKBacktestRunner:
# 上一次调仓挂起的顺延单作废(只在两次调仓之间有效)
pending = []
cash = self._rebalance(
d, cash, shares, entry_date, entry_price, trades, positions, notional,
pending,
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, pending, notional)
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)
@@ -319,38 +359,131 @@ class TopKBacktestRunner:
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).index.tolist()
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 = ranked[: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 ranked, pool
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, trades, positions, notional, pending
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="无行情(停牌),保留持仓")
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="跌停无法卖出,保留到下一调仓")
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]
@@ -358,8 +491,23 @@ class TopKBacktestRunner:
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))
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c),
reason=sell_reason)
)
trades.append(
Trade(
@@ -369,11 +517,15 @@ class TopKBacktestRunner:
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)
@@ -393,18 +545,23 @@ class TopKBacktestRunner:
)
)
def _buyable(sym) -> tuple[bool, str | None]:
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, "无行情(停牌),无法买入"
return False, "无行情(停牌),无法买入", BUY_SKIP_HALTED
if _nan(p) or p <= 0:
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
return True, None
return True, None, None
if c / p >= _limit_up_ratio(sym):
return False, "涨停,无法追买"
return True, None
return False, "涨停,无法追买", BUY_SKIP_LIMIT_UP
return True, None, None
# 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找
targets: list[str] = []
@@ -412,7 +569,7 @@ class TopKBacktestRunner:
for sym in self.current_ranked:
if len(targets) >= self.spec.selection.x:
break
ok, _ = _buyable(sym)
ok, _, _code = _buyable(sym)
if ok:
targets.append(sym)
else:
@@ -437,17 +594,24 @@ class TopKBacktestRunner:
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 = _buyable(s)
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))
@@ -455,6 +619,7 @@ class TopKBacktestRunner:
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason=f"{reason},顺延到之后首个可成交日买入",
reason=skip_reason,
)
)
else:
@@ -462,16 +627,26 @@ class TopKBacktestRunner:
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, notional
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
@@ -484,10 +659,17 @@ class TopKBacktestRunner:
for sym in picks:
if sym in set(targets):
continue
_ok, reason = _buyable(sym)
_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 "资金不足(未成交)")
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) 记录调仓后仓位
@@ -510,7 +692,8 @@ class TopKBacktestRunner:
return cash
def _execute_buy(
self, s, budget, d, close_value, shares, entry_date, entry_price, notional
self, s, budget, d, close_value, shares, entry_date, entry_price, entry_reason,
notional, reason=None,
) -> bool:
"""按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。
@@ -527,13 +710,15 @@ class TopKBacktestRunner:
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))
price=round(price_in, 4), reason=reason)
)
if s not in self._traded:
self._traded.add(s)
@@ -542,7 +727,9 @@ class TopKBacktestRunner:
# ---- 顺延买入(defer_buy):之后逐日重试 ----
def _fill_pending(self, d, cash, shares, entry_date, entry_price, pending, notional):
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] = []
@@ -560,8 +747,18 @@ class TopKBacktestRunner:
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, notional
order.symbol, budget, d, c, shares, entry_date, entry_price, entry_reason,
notional, reason=fill_reason,
):
remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化)
continue
@@ -686,6 +883,8 @@ class TopKBacktestRunner:
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"),
+8 -4
View File
@@ -21,10 +21,10 @@ from pathlib import Path
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.composite import build_factor_panels_full
from app.quant.engine import QuantEngine
from app.quant.local_engine import (
TopKBacktestRunner,
build_factor_panels,
composite_score,
run_spec_factor_test,
)
@@ -69,8 +69,10 @@ class QlibEngine(QuantEngine):
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
"""
panels = build_factor_panels(daily, spec.factors)
score = composite_score(panels)
# 与 LocalEngine 同口径:复合分与买卖理由/因子曲线用**同一张**原始因子面板
full = build_factor_panels_full(daily, spec.factors)
score = composite_score([(d.name, p, w, d.direction) for d, p, w in full])
factor_panels = {d.name: (d, p) for d, p, _w in full}
self.qlib_dir.mkdir(parents=True, exist_ok=True)
uri = build_qlib_dataset(daily, self.qlib_dir)
@@ -85,7 +87,9 @@ class QlibEngine(QuantEngine):
)
close = close.sort_index()
result = TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
result = TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()
result.config_snapshot = spec.model_dump(mode="json")
note = _ENGINE_NOTE
result.unimplemented = [note, *result.unimplemented]
+19 -1
View File
@@ -37,6 +37,11 @@ BUY_SKIP_MIN_COMMISSION = "buy_skip_min_commission"
# 卖出(成交)
SELL_DROP_TOPN = "sell_drop_topn"
SELL_FORCE_TMAX = "sell_force_tmax"
# 卖出(成交):策略在调仓日**全量换仓**(先清仓再建仓),该股当时仍在 TopN 内。
# 为什么单列一个 code:单策略回测(TopK runner)的调仓语义就是「全清再买」,
# 被卖出的股票很可能仍然排在前列 —— 这时说「跌出 TopN」与 data 里的 rank=1 自相矛盾,
# 等于给用户一个假的解释。分开写才是如实描述。
SELL_REBALANCE_FULL = "sell_rebalance_full"
# 卖出(顺延 / 未成交)
SELL_DEFER_TMIN = "sell_defer_tmin"
SELL_DEFER_HALTED = "sell_defer_halted"
@@ -52,6 +57,7 @@ REASON_CODES = frozenset(
BUY_SKIP_MIN_COMMISSION,
SELL_DROP_TOPN,
SELL_FORCE_TMAX,
SELL_REBALANCE_FULL,
SELL_DEFER_TMIN,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
@@ -68,6 +74,7 @@ REASON_LABELS: dict[str, str] = {
BUY_SKIP_MIN_COMMISSION: "不足最低佣金",
SELL_DROP_TOPN: "跌出 TopN",
SELL_FORCE_TMAX: "持有超 Tmax",
SELL_REBALANCE_FULL: "调仓换仓卖出",
SELL_DEFER_TMIN: "Tmin 保护暂留",
SELL_DEFER_HALTED: "停牌未卖",
SELL_DEFER_LIMIT_DOWN: "跌停未卖",
@@ -242,13 +249,24 @@ def sell_filled(
return_pct: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""卖出成交的理由(跌出 TopN / 持有超 Tmax),带持有交易日与当时名次。
"""卖出成交的理由(跌出 TopN / 持有超 Tmax / 全量换仓),带持有交易日与当时名次。
`not_in_pool=True` 表示该股已**不在候选池**(被股票池/条件过滤,如转为 ST),
与「在池内但排名掉出去」是两回事,文案与 data 都分开写。
`SELL_REBALANCE_FULL` 用于「策略每次调仓都先全清再建仓」的引擎:该股当时仍在前列,
卖它不是因为掉出 TopN,而是策略本身的调仓方式 —— 不能套用跌出 TopN 的说法。
"""
if code == SELL_FORCE_TMAX:
text = f"持有 {hold_days} 个交易日 > Tmax={tmax},强制了结(与排名无关)"
elif code == SELL_REBALANCE_FULL:
text = (
f"调仓日全量换仓:该策略每次调仓先清仓再按新名单建仓"
f"(该股当时仍在 TopN 内:{_rank_text(rank, total, top_n, score)});"
f"持有 {hold_days} 个交易日"
)
if tmin is not None:
text += f" ≥ Tmin={tmin}"
else:
code = SELL_DROP_TOPN
if not_in_pool:
+503
View File
@@ -0,0 +1,503 @@
"""LocalEngine(单策略回测)买卖理由与因子曲线的常驻回归。
用户要求「回测结果里所有买卖点详细说明买卖理由,用数据说话」,因此这里验证的**不是文案
长什么样**,而是:理由里的每个数字都等于引擎当时算出来的值,能独立地对回算出来 ——
- 名次 / 候选数 / 综合分对得上复合分面板;`factors` 原始值对得上因子面板同一格;
- 涨跌停比值、预算、最低佣金、持有交易日对得上行情与配置;
- code 按**事实**选:仍在前列的清仓换仓(本引擎每次调仓先全清再建仓)用
`sell_rebalance_full`,只有确实不在池 / 名次掉出 / 当日无分数才用 `sell_drop_topn`;
- `Trade.entry_reason / exit_reason` 两端齐全,且与成交价一致(理由不是事后补的);
- `factor_curves` = 当日持仓**市值加权平均原始值**(用手算的加权值断言),空仓日不落点。
数据全部由本文件确定性合成(无外部依赖、无随机数),场景通过覆盖个别交易日的收盘价
(精确到「上一有效收盘 × 目标幅度」)来触发涨停 / 跌停 / 停牌。
"""
from __future__ import annotations
import math
from datetime import date
import pandas as pd
import pytest
from app.domain.entities.research import (
CostSpec,
FactorSpec,
ResearchSpec,
SelectionSpec,
UniverseSpec,
)
from app.quant.composite import build_factor_panels_full
from app.quant.engine import LocalEngine
from app.quant.selection import score_panel_for_factors
from app.quant.trade_reasons import (
BUY_DEFER_FILLED,
BUY_ENTER,
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,
)
# 三只标的的确定性漂移:600000 最强、600002 最弱(动量排序稳定可预期)
_SYMS = (("600000.SH", 0.004), ("600001.SH", 0.0015), ("600002.SH", -0.002))
_START = date(2024, 3, 1)
_END = date(2024, 10, 31)
_APR_REBAL = date(2024, 4, 1) # 4 月调仓日
_MAY_REBAL = date(2024, 5, 1) # 5 月调仓日
_MOMENTUM = [FactorSpec(name="momentum_20")]
_VOLUME = [FactorSpec(name="volume_ratio_5_60")]
def _daily(*, overrides=None, nan_quotes=None, n=320, base=100.0) -> pd.DataFrame:
"""确定性合成日线长表:p[j] = p[j-1] * (1 + drift + 0.012·sin((j+i)·0.8))。
`overrides={(symbol, date): close}` 制造涨停/跌停,`nan_quotes` 制造停牌(无行情);
两者只改当日收盘(成交/撮合与因子都据此计算),保证场景可复现。
"""
dates = pd.bdate_range("2024-01-01", periods=n)
overrides = overrides or {}
nan_quotes = set(nan_quotes or ())
rows: list[dict] = []
for i, (sym, drift) in enumerate(_SYMS):
price = base
for j, d in enumerate(dates):
prev = price
price = price * (1 + drift + 0.012 * math.sin((j + i) * 0.8))
px = float(overrides.get((sym, d.date()), price))
if (sym, d.date()) in nan_quotes:
px = float("nan")
volume = float(1_000_000 + j * 1000 + i * 3000)
rows.append(
{
"symbol": sym,
"trade_date": d.date(),
"open": prev,
"high": float("nan") if math.isnan(px) else max(prev, px) * 1.008,
"low": float("nan") if math.isnan(px) else min(prev, px) * 0.992,
"close": px,
"volume": volume,
"amount": float("nan") if math.isnan(px) else px * volume,
}
)
return pd.DataFrame(rows)
def _spec(**over) -> ResearchSpec:
base = dict(
type="backtest",
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
factors=list(_MOMENTUM),
selection=SelectionSpec(top_n=1),
rebalance="monthly",
period=(_START, _END),
costs=CostSpec(),
)
base.update(over)
return ResearchSpec(**base)
def _run(daily: pd.DataFrame, **spec_over):
return LocalEngine().run_backtest(daily, _spec(**spec_over))
def _by_code(result, code, *, signal=None, filled=None):
"""按 code 取记录(可再按 BUY/SELL 与是否成交过滤)。"""
return [
a
for a in result.signal_history
if a.reason is not None
and a.reason.code == code
and (signal is None or a.signal == signal)
and (filled is None or a.filled == filled)
]
def _panels(daily: pd.DataFrame, spec: ResearchSpec):
"""因子原始面板 {name: (defn, panel)}(与引擎注入理由的来源同一构建函数)。"""
return {d.name: (d, p) for d, p, _w in build_factor_panels_full(daily, spec.factors)}
def _close_panel(daily: pd.DataFrame) -> pd.DataFrame:
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
close.index = pd.to_datetime(close.index)
return close
def _close_before(close: pd.DataFrame, day: date, symbol: str) -> float:
"""`day` 之前最后一个有效收盘价(用来精确构造涨停/跌停的当日价)。"""
series = close[symbol].dropna()
return float(series[series.index < pd.Timestamp(day)].iloc[-1])
def _leader_at(daily: pd.DataFrame, factors, day: date) -> str:
"""该日复合分第一名(据此构造「涨停/停牌」的标的,避免写死代码)。"""
score = score_panel_for_factors(daily, factors)
return str(score.loc[pd.Timestamp(day)].dropna().idxmax())
def _held_on(daily: pd.DataFrame, day: date, **spec_over) -> set[str]:
"""基线回测在该日的持仓(据此决定把哪只标的的行情改成跌停/停牌)。"""
result = LocalEngine().run_backtest(daily, _spec(**spec_over))
return {p.symbol for p in result.positions if p.date == day}
# ---------- 1. 买入理由的数字来源 ----------
def test_buy_reason_numbers_come_from_engine():
"""买入理由的 rank/total/top_n/score 与 factors 原始值都能对回引擎面板。"""
daily = _daily()
spec = _spec()
result = LocalEngine().run_backtest(daily, spec)
buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled]
assert buys, "主路径应有成交买入"
rec = buys[0]
reason = rec.reason
assert reason is not None and reason.code == BUY_ENTER
# 名次 / 候选数 / 综合分 == 复合分面板当日真实排序(独立算一遍)
score = score_panel_for_factors(daily, spec.factors)
d = pd.Timestamp(rec.date)
ranked = score.loc[d].dropna().sort_values(ascending=False)
assert reason.data["rank"] == ranked.index.get_loc(rec.symbol) + 1
assert reason.data["rank"] == 1
assert reason.data["total"] == len(ranked)
assert reason.data["top_n"] == spec.selection.top_n
assert reason.data["score"] == pytest.approx(round(float(ranked[rec.symbol]), 6))
# 因子原始值 == 因子面板同一格(不是重算、不是估算)
_defn, panel = _panels(daily, spec)["momentum_20"]
assert reason.data["factors"]["momentum_20"] == pytest.approx(
round(float(panel.at[d, rec.symbol]), 6)
)
assert f"第 {reason.data['rank']}" in reason.text and "成交价" in reason.text
# ---------- 2. 涨停未买 ----------
def test_buy_skip_limit_up_uses_real_ratio():
"""涨停未买:data 里的收盘/前收/比值/阈值全部来自当日行情与板块规则。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
leader = _leader_at(base_daily, _MOMENTUM, _APR_REBAL)
prev = _close_before(close0, _APR_REBAL, leader)
daily = _daily(overrides={(leader, _APR_REBAL): prev * 1.12})
result = LocalEngine().run_backtest(daily, _spec())
skips = _by_code(result, BUY_SKIP_LIMIT_UP, signal="BUY", filled=False)
assert skips, "当日涨停应记录 buy_skip_limit_up"
rec = skips[0]
assert rec.symbol == leader
reason = rec.reason
close = _close_panel(daily)
d = pd.Timestamp(_APR_REBAL)
real_close = float(close.at[d, leader])
real_prev = float(close.ffill().shift(1).at[d, leader])
assert reason.data["close"] == pytest.approx(round(real_close, 4))
assert reason.data["prev_close"] == pytest.approx(round(real_prev, 4))
assert reason.data["close_prev_ratio"] == pytest.approx(round(real_close / real_prev, 4))
assert reason.data["close_prev_ratio"] == pytest.approx(1.12)
assert reason.data["limit_ratio"] == pytest.approx(round(1.0 + (1.099 - 1.0), 4))
assert reason.data["close_prev_ratio"] >= reason.data["limit_ratio"]
assert rec.reject_reason == "涨停,无法追买" # 既有文案未被理由改动
assert "涨停" in reason.text and "无法追买" in reason.text
# ---------- 3. 停牌未买 / 停牌未卖 ----------
def test_buy_skip_halted_keeps_rank_and_reject_text():
"""停牌未买:标的仍被选中(有真实名次),只是当日无行情无法成交。"""
base_daily = _daily()
# 用「只依赖 volume」的因子:close 缺失时该股仍能进候选池,才能走到执行层停牌分支
leader = _leader_at(base_daily, _VOLUME, _MAY_REBAL)
daily = _daily(nan_quotes={(leader, _MAY_REBAL)})
result = LocalEngine().run_backtest(daily, _spec(factors=list(_VOLUME)))
skips = _by_code(result, BUY_SKIP_HALTED, signal="BUY", filled=False)
assert skips, "停牌应记录 buy_skip_halted"
rec = skips[0]
assert rec.symbol == leader
assert rec.reject_reason == "无行情(停牌),无法买入" # 既有文案未变
assert rec.reason.data["rank"] == 1 # 停牌的是被选中的第一名,不是随便一只
assert "停牌" in rec.reason.text
def test_sell_defer_halted_keeps_position():
"""停牌未卖:顺延理由带真实持有交易日,且 reject_reason 保持原文案。"""
base_daily = _daily()
held = _held_on(base_daily, _MAY_REBAL)
assert held, "基线在 5 月调仓日应有持仓"
daily = _daily(nan_quotes={(s, _MAY_REBAL) for s in held})
result = _run(daily)
defers = _by_code(result, SELL_DEFER_HALTED, signal="SELL", filled=False)
assert defers, "持仓股无行情应记录 sell_defer_halted"
rec = defers[0]
assert rec.symbol in held
assert rec.reject_reason == "无行情(停牌),保留持仓"
assert rec.reason.data["hold_days"] > 0
# 当日没有该股的成交卖出(停牌只是顺延,仓位保留)
assert not [
a
for a in result.signal_history
if a.symbol == rec.symbol
and a.date == _MAY_REBAL
and a.signal == "SELL"
and a.filled
]
# ---------- 4. 跌停未卖 ----------
def test_sell_defer_limit_down_uses_real_ratio():
"""跌停未卖:data 里的收盘/前收/比值/阈值与行情一致(比值 ≤ 阈值)。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
held = _held_on(base_daily, _APR_REBAL)
assert held
overrides = {
(s, _APR_REBAL): _close_before(close0, _APR_REBAL, s) * 0.90 for s in held
}
daily = _daily(overrides=overrides)
result = _run(daily)
defers = _by_code(result, SELL_DEFER_LIMIT_DOWN, signal="SELL", filled=False)
assert defers, "持仓股跌停应记录 sell_defer_limit_down"
rec = defers[0]
reason = rec.reason
close = _close_panel(daily)
d = pd.Timestamp(_APR_REBAL)
assert reason.data["close"] == pytest.approx(round(float(close.at[d, rec.symbol]), 4))
assert reason.data["prev_close"] == pytest.approx(
round(float(close.ffill().shift(1).at[d, rec.symbol]), 4)
)
assert reason.data["close_prev_ratio"] == pytest.approx(0.90)
assert reason.data["limit_ratio"] == pytest.approx(0.901)
assert reason.data["close_prev_ratio"] <= reason.data["limit_ratio"]
assert reason.data["hold_days"] > 0
assert rec.reject_reason == "跌停无法卖出,保留到下一调仓"
assert "跌停" in reason.text and "顺延" in reason.text
# ---------- 5. 现金不足 / 不足最低佣金 ----------
def test_buy_skip_no_cash_when_budget_exhausted():
"""现金分配耗尽后,池内第二只留痕「资金不足」,budget = 当时真实剩余现金。"""
daily = _daily()
result = _run(daily, selection=SelectionSpec(top_n=2, hold_top_x=1))
skips = _by_code(result, BUY_SKIP_NO_CASH, signal="BUY", filled=False)
assert skips
no_cash = [a for a in skips if a.reject_reason == "资金不足(未成交)"]
assert no_cash, "替补路径下池内被跳过的标的应给出 buy_skip_no_cash"
assert no_cash[0].reason.data["budget"] == pytest.approx(0.0) # 唯一目标吃光现金
assert "可用预算" in no_cash[0].reason.text
def test_buy_skip_min_commission_from_budget_and_config():
"""不足最低佣金:budget = 等权分配额、min_commission = 配置值,两者都来自引擎。"""
daily = _daily()
result = LocalEngine().run_backtest(
daily,
_spec(costs=CostSpec(min_commission=5.0), initial_capital=4.0),
)
skips = _by_code(result, BUY_SKIP_MIN_COMMISSION, signal="BUY", filled=False)
assert skips
reason = skips[0].reason
assert reason.data["budget"] == pytest.approx(4.0) # 4 元全给唯一目标
assert reason.data["min_commission"] == pytest.approx(5.0)
assert reason.data["budget"] < reason.data["min_commission"]
assert skips[0].reject_reason == "预算不足以覆盖最低佣金,未成交"
assert result.trades == [] # 该场景确实一笔未成
# ---------- 6. 顺延买入成交 ----------
def test_buy_defer_filled_after_limit_up():
"""顺延买入:挂单当日涨停未买,之后按真实成交日的价格/因子值成交(不编当日名次)。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
leader = _leader_at(base_daily, _MOMENTUM, _APR_REBAL)
prev = _close_before(close0, _APR_REBAL, leader)
daily = _daily(overrides={(leader, _APR_REBAL): prev * 1.12})
spec = _spec(
selection=SelectionSpec(top_n=1, allow_substitute=False, defer_buy=True)
)
result = LocalEngine().run_backtest(daily, spec)
pending = _by_code(result, BUY_SKIP_LIMIT_UP, signal="BUY", filled=False)
filled = _by_code(result, BUY_DEFER_FILLED, signal="BUY", filled=True)
assert pending and filled, "顺延应有「挂单当日涨停」+「之后成交」两条记录"
assert pending[0].symbol == filled[0].symbol == leader
assert pending[0].date == _APR_REBAL
assert filled[0].date > _APR_REBAL # 只在之后的交易日补成交,不回溯
reason = filled[0].reason
# 成交日不是择股日 → 不拿旧名次冒充当日名次
assert "rank" not in reason.data and "score" not in reason.data
# 因子原始值 / 成交价 / 预算取成交当日的真实值
_defn, panel = _panels(daily, spec)["momentum_20"]
fd = pd.Timestamp(filled[0].date)
assert reason.data["factors"]["momentum_20"] == pytest.approx(
round(float(panel.at[fd, leader]), 6)
)
close = _close_panel(daily)
price_in = float(close.at[fd, leader]) * (1 + spec.costs.slippage_rate)
assert reason.data["price"] == pytest.approx(round(price_in, 4))
assert reason.data["budget"] > 0
# 成交明细里的建仓理由是「顺延成交」,不是笼统的按名次建仓
trade = next(
t for t in result.trades if t.symbol == leader and t.entry_date == filled[0].date
)
assert trade.entry_reason is not None and trade.entry_reason.code == BUY_DEFER_FILLED
# ---------- 7. 成交卖出:全量换仓 vs 跌出 TopN ----------
def test_sell_rebalance_full_when_still_in_topn():
"""仍排在 TopN 内却被清仓(本引擎「先全清再建仓」)→ sell_rebalance_full。
这是本次新增 code 的关键回归:老实现会把它说成「跌出 TopN」,与 data 里的
rank=1/top_n=1 自相矛盾 —— 用假解释掩盖真实原因。
"""
daily = _daily()
spec = _spec() # top_n=1:最强的 600000.SH 每月都排第一
result = LocalEngine().run_backtest(daily, spec)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert sells, "调仓应产生成交卖出"
rec = sells[0]
reason = rec.reason
assert reason.code == SELL_REBALANCE_FULL
assert reason.data["rank"] == 1
assert reason.data["top_n"] == 1
assert reason.data["rank"] <= reason.data["top_n"] # 关键:当时仍在前列
assert reason.data["hold_days"] > 0
assert "全量换仓" in reason.text
# 该股当日确实有分(不是「当日无分数」才落到这个 code)
score = score_panel_for_factors(daily, spec.factors)
assert not math.isnan(float(score.at[pd.Timestamp(rec.date), rec.symbol]))
# 关键 data(跑 -s 时可读;失败时也在断言里可见)
print(
f"[sell_rebalance_full] code={reason.code} rank={reason.data['rank']} "
f"total={reason.data['total']} top_n={reason.data['top_n']} "
f"hold_days={reason.data['hold_days']}"
)
def test_sell_drop_topn_when_filtered_out_of_pool():
"""被股票池/条件过滤(已不在候选池)才归 sell_drop_topn,与换仓卖出分开。"""
daily = _daily()
spec = _spec()
result = LocalEngine().run_backtest(
daily,
spec,
eligibility_fn=lambda as_of: {"600001.SH"} if as_of >= _APR_REBAL else None,
)
drops = _by_code(result, SELL_DROP_TOPN, signal="SELL", filled=True)
assert drops, "持仓股被条件过滤后应卖出并归 sell_drop_topn"
reason = drops[0].reason
assert reason.data["in_pool"] is False
assert "已不在候选池" in reason.text
# 对照:换仓卖出的 code 不应出现在同一条记录上
assert reason.code != SELL_REBALANCE_FULL
def test_trade_carries_both_end_reasons():
"""成交明细两端齐全,且理由里的价格与 Trade 的成交价一致(理由跟着成交走)。"""
daily = _daily()
result = _run(daily)
assert result.trades
trade = result.trades[0]
assert trade.entry_reason is not None and trade.entry_reason.code == BUY_ENTER
assert trade.exit_reason is not None and trade.exit_reason.code == SELL_REBALANCE_FULL
assert trade.entry_reason.data["price"] == pytest.approx(round(trade.entry_price, 4))
assert trade.exit_reason.data["price"] == pytest.approx(round(trade.exit_price, 4))
# ---------- 8. 因子曲线:市值加权原始值 / 空仓日不落点 ----------
def test_factor_curves_are_market_value_weighted():
"""曲线值 == 当日持仓按市值加权平均的因子原始值(用反推股数的手算值断言)。"""
daily = _daily()
spec = _spec(selection=SelectionSpec(top_n=2)) # 两只持仓,权重会随行情漂移
result = LocalEngine().run_backtest(daily, spec)
_defn, panel = _panels(daily, spec)["momentum_20"]
curve = next(c for c in result.factor_curves if c.name == "momentum_20")
points = {p.date: p.value for p in curve.points}
assert points, "有持仓就应有因子曲线点"
# 从首个「两只持仓」的调仓日反推股数(引擎给的 weight × 当日权益 ÷ 当日收盘)
close = _close_panel(daily)
by_day: dict = {}
for pos in result.positions:
by_day.setdefault(pos.date, {})[pos.symbol] = pos.weight
d0 = next(d for d in sorted(by_day) if len(by_day[d]) == 2)
equity0 = next(q.value for q in result.equity_curve if q.date == d0)
qty = {
s: by_day[d0][s] * equity0 / float(close.at[pd.Timestamp(d0), s])
for s in by_day[d0]
}
# 取之后第 10 个交易日(仍在同一持仓期内):权重已随价格漂移,非等权
idx = list(close.index)
d1 = idx[idx.index(pd.Timestamp(d0)) + 10]
market_value = {s: qty[s] * float(close.at[d1, s]) for s in qty}
values = {s: float(panel.at[d1, s]) for s in market_value}
manual = sum(values[s] * market_value[s] for s in values) / sum(market_value.values())
equal = sum(values.values()) / len(values)
assert abs(market_value["600000.SH"] - market_value["600001.SH"]) > 1.0 # 确实漂移了
assert abs(equal - manual) > 1e-5 # 等权平均对不上 → 能区分「市值加权」
assert points[d1.date()] == pytest.approx(round(manual, 6), abs=1e-6)
# 曲线上是因子的**原始值**(未 z-score、未按方向取负):量级与动量本身一致
assert all(abs(v) < 5 for v in points.values())
def test_factor_curves_skip_days_without_holdings():
"""空仓日不落点(不插值、不用 0 填充):涨停买不进且不替补的整月没有曲线点。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
leader = _leader_at(base_daily, _MOMENTUM, _APR_REBAL)
prev = _close_before(close0, _APR_REBAL, leader)
daily = _daily(overrides={(leader, _APR_REBAL): prev * 1.12})
spec = _spec(
selection=SelectionSpec(top_n=1, allow_substitute=False, defer_buy=False)
)
result = LocalEngine().run_backtest(daily, spec)
curve = next(c for c in result.factor_curves if c.name == "momentum_20")
point_dates = {p.date for p in curve.points}
april = {d.date() for d in pd.bdate_range("2024-04-01", "2024-04-30")}
assert not (point_dates & april), "4 月空仓(涨停未买且不替补),不应有任何曲线点"
assert date(2024, 3, 1) in point_dates # 3 月建仓后有持仓 → 有点
period_days = {d.date() for d in pd.bdate_range(_START, _END)}
assert len(point_dates) < len(period_days) # 有缺口 = 没按交易日补齐
def test_factor_curves_empty_when_no_fills():
"""一笔都没成交(预算不足最低佣金)→ 面板非空但曲线 0 个点,而不是一堆 0 值。"""
daily = _daily()
result = LocalEngine().run_backtest(
daily, _spec(costs=CostSpec(min_commission=5.0), initial_capital=4.0)
)
assert result.trades == []
assert result.factor_curves, "因子曲线按策略因子输出(即便没成交)"
assert all(c.points == [] for c in result.factor_curves)