feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大

用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
  避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
  综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
  卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
  超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
  老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
  绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。

二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
  (不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
  label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
  (股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。

三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
  筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
  每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
  整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
  未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
  显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。

验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
  Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
  100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
  `scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
  理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
  全部 200 渲染,截图确认表格与曲线数值正确。
This commit is contained in:
Simon
2026-10-01 17:57:00 +08:00
parent 36fe018075
commit 48a97c2a12
17 changed files with 2103 additions and 158 deletions
+57
View File
@@ -285,6 +285,24 @@ class BacktestSummary(BaseModel):
benchmark_return_pct: float | None = None
class TradeReason(BaseModel):
"""一次交易意图 / 成交的**结构化理由**:用当时的真实数字解释「为什么买 / 为什么卖」。
为什么不让前端自己推:界面上出现的每个数字(排名、综合分、因子值、持有天数)
都必须来自引擎当时的计算,否则就是「看着像真的」。理由因此分三层:
- `code`:机器可判定的原因分类(封闭取值,见 `quant/trade_reasons.py`)。
前端据此筛选 / 上色,不去解析文案;
- `text`:给人读的一句话(自带关键数字,可单独展示);
- `data`:当时真实数值(`rank` / `total` / `score` / `top_n` / `hold_days` /
`factors`(各因子当时的原始值)/ `budget` …)。前端只展示,不推算。
"""
code: str
text: str
data: dict = Field(default_factory=dict)
class Trade(BaseModel):
entry_date: date
exit_date: date
@@ -293,6 +311,12 @@ class Trade(BaseModel):
entry_price: float
exit_price: float
return_pct: float
entry_reason: TradeReason | None = Field(
default=None, description="买入理由(建仓当日引擎给出的结构化理由)"
)
exit_reason: TradeReason | None = Field(
default=None, description="卖出理由(了结当日引擎给出的结构化理由)"
)
class Position(BaseModel):
@@ -318,6 +342,10 @@ class ActionRecord(BaseModel):
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
`reason` 是**数据化**的为什么:`reject_reason` 只说「没成交」(执行层),
`reason` 同时覆盖成交与未成交(策略层 + 执行层),并带上当时的排名 / 综合分 /
各因子原始值,前端「买卖说明」直接用,不再二次推断。
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
"""
@@ -329,6 +357,9 @@ class ActionRecord(BaseModel):
filled: bool
reject_reason: str | None = None
price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
reason: TradeReason | None = Field(
default=None, description="结构化理由(成交与未成交都有;旧归档为 null)"
)
class SymbolCurve(BaseModel):
@@ -352,6 +383,25 @@ class SymbolCurve(BaseModel):
)
class FactorCurve(BaseModel):
"""单个因子在回测期内的时间序列(**持仓组合加权平均原始值**)。
口径必须写死,否则读图会读反:
- 值为该因子在**当日持仓股票**上的权重加权平均(权重 = 该股当日市值 / 组合权益),
是**原始值**:不做 z-score、不按方向取负 —— 图上看到的就是因子本身;
- 空仓日不落点(不插值、不用 0 假填充),曲线中间会出现空档;
- `direction` 一并归档:低为好的因子,曲线升高不等于「更好」;
- `unit` 是代码注册表里的事实(`%` / `倍数` / `小数`),用于坐标轴与提示文案。
"""
name: str
label: str
direction: str = "higher_is_better"
unit: str | None = None
points: list[CurvePoint] = Field(default_factory=list)
class BacktestResult(BaseModel):
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
@@ -375,6 +425,13 @@ class BacktestResult(BaseModel):
default_factory=list,
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
)
factor_curves: list[FactorCurve] = Field(
default_factory=list,
description=(
"策略用到的每个因子的时间序列(持仓加权平均原始值):"
"用来解释「买卖依据的那个因子在各时点是什么水平」"
),
)
turnover_pct: float
unimplemented: list[str] = Field(
default_factory=list,
+166 -23
View File
@@ -45,8 +45,26 @@ from app.domain.entities.research import (
UniverseSpec,
YearlyReturn,
)
from app.quant.composite import build_score_panel
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
@@ -84,18 +102,26 @@ def combine_strategy_scores(
daily: pd.DataFrame,
strategies: list[SelectionStrategyRef],
eligibility_fns: list,
) -> tuple[pd.DataFrame, object]:
"""多策略 → (综合分面板, 合并合格集闭包)。
) -> 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)
panels.append(build_score_panel(daily, factors))
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)
@@ -117,7 +143,7 @@ def combine_strategy_scores(
out |= s
return out
return borda, combined
return borda, combined, raw_panels
# ---------- 持仓区间回测 runner ----------
@@ -129,6 +155,9 @@ class _Holding:
entry_date: date
entry_price: float
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
# 建仓理由(结构化):了结时原样写进 Trade.entry_reason,保证「为什么买」在
# 成交明细里能一路带出来 —— 持仓中途没有别的机会把它丢掉。
entry_reason: object = None
_UNIMPLEMENTED_BASE = [
@@ -158,6 +187,7 @@ class HoldingBandRunner:
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
@@ -166,11 +196,19 @@ class HoldingBandRunner:
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]] = {}
# ---- 主循环 ----
@@ -226,6 +264,7 @@ class HoldingBandRunner:
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)
@@ -241,6 +280,9 @@ class HoldingBandRunner:
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):
@@ -249,6 +291,50 @@ class HoldingBandRunner:
)
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:
@@ -261,19 +347,33 @@ class HoldingBandRunner:
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},但当日无行情,顺延")
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},但跌停无法卖出,顺延")
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)
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
# ---- 调仓日:增量调向目标 ----
@@ -289,10 +389,13 @@ class HoldingBandRunner:
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},暂留")
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)
@@ -300,16 +403,30 @@ class HoldingBandRunner:
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓")
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,但跌停无法卖出,保留到下一调仓")
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)
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]
@@ -332,30 +449,45 @@ class HoldingBandRunner:
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="无行情(停牌),无法买入")
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="涨停,无法追买")
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="可用现金不足,未成交")
reject_reason="可用现金不足,未成交",
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
)
continue
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional)
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="预算不足以覆盖最低佣金,未成交")
reject_reason="预算不足以覆盖最低佣金,未成交",
reason=buy_skipped(BUY_SKIP_MIN_COMMISSION, budget=budget,
min_commission=self.costs.min_commission,
**ctx))
)
continue
cash -= spent
@@ -365,25 +497,29 @@ class HoldingBandRunner:
# ---- 买卖原子操作 ----
def _sell(self, s, h, close_price, day, cash, trades, holdings) -> float:
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)
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) -> tuple[bool, float]:
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
@@ -394,10 +530,13 @@ class HoldingBandRunner:
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)
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))
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)
@@ -515,6 +654,7 @@ class HoldingBandRunner:
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(含策略+成本快照)
@@ -567,10 +707,13 @@ def run_combo_backtest(
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。
"""
score, combined_elig = combine_strategy_scores(daily, strategies, eligibility_fns)
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):组合参数 + 当时各策略定义 + 当时成本/复权
+16 -3
View File
@@ -57,11 +57,24 @@ def build_factor_panels(
daily: pd.DataFrame, factor_specs
) -> list[tuple[str, pd.DataFrame, float, str]]:
"""按 spec.factors 计算面板与权重(因子不存在即报错)。"""
panels: list[tuple[str, pd.DataFrame, float, str]] = []
return [
(defn.name, panel, weight, defn.direction)
for defn, panel, weight in build_factor_panels_full(daily, factor_specs)
]
def build_factor_panels_full(
daily: pd.DataFrame, factor_specs
) -> list[tuple[FactorDef, pd.DataFrame, float]]:
"""同 `build_factor_panels`,但把 `FactorDef` 一并带出来。
回测的「买卖理由」与「因子曲线」需要用到因子的显示名 / 方向 / 单位(`FactorDef`),
而只拿 name 就得回注册表再查一遍 —— 这里一次算完,避免同一次回测里重复计算面板。
"""
panels: list[tuple[FactorDef, pd.DataFrame, float]] = []
for fs in factor_specs:
defn: FactorDef
defn, panel = compute_factor(fs.name, daily)
panels.append((fs.name, panel, fs.weight, defn.direction))
panels.append((defn, panel, fs.weight))
return panels
+16 -1
View File
@@ -117,6 +117,7 @@ class FactorDef:
param_specs: tuple[ParamSpec, ...] = () # 可编辑参数与约束(供目录/界面)
source: str = "builtin" # builtin(代码注册表)| custom(目录里创建的参数化实例)
label: str = "" # 中文显示名(含参数),如「动量(窗口 90,越高越好)」
unit: str | None = None # 因子值的量纲(% / 倍数 / 小数),供图表坐标轴与说明用
@property
def display(self) -> str:
@@ -141,6 +142,11 @@ class FactorTemplate:
requires: tuple[str, ...] = ("close",)
frequency: str = "daily"
direction_default: str = DIRECTION_HIGHER
# 因子值的量纲(由算法口径决定,不是可调参数):
# "%" = 数值本身就是百分数(股息率 5.2 读作 5.2%)
# "倍数" = 比值(量比 1.2 表示 1.2 倍)
# "小数" = 无单位比例,0.15 表示 15%(图上按小数显示,不做 ×100 换算)
unit: str | None = None
lookback_of: Callable[[Mapping[str, Any]], int] | None = None
check: Callable[[Mapping[str, Any]], str | None] | None = None # 跨参数约束
instances: tuple[tuple[str, Mapping[str, Any]], ...] = () # ((历史名, 参数), ...)
@@ -366,6 +372,7 @@ def build_factor_def(
param_specs=template.specs(),
source=source,
label=label_of(checked),
unit=template.unit,
)
@@ -496,6 +503,7 @@ register_template(
description="过去 {window} 个交易日收益率",
formula="close / close.shift({window}) - 1",
brief="动量:强者延续,适合趋势延续环境;窗口越短越敏感、越长越稳。",
unit="小数",
fn=lambda fields, params: _rolling_return(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),),
lookback_of=lambda params: params[P_WINDOW],
@@ -514,6 +522,7 @@ register_template(
description="过去 {window} 个交易日收益率波动率",
formula="std(pct_change, {window})",
brief="低波动防御:近段波动小的股票抗跌,弱市/熊市阶段相对占优(方向越低越好)。",
unit="小数",
fn=lambda fields, params: _rolling_vol(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),),
direction_default=DIRECTION_LOWER,
@@ -532,6 +541,7 @@ register_template(
description="收盘价相对 {window} 日最高价的接近程度",
formula="close / rolling_max(high, {window})",
brief="贴近 n 日高点(接近新高):趋势确认型强势股,常与动量互补;需配合市场热度判断。",
unit="倍数",
fn=lambda fields, params: fields["close"] / fields["high"].rolling(params[P_WINDOW]).max(),
param_specs=(_window_spec(),),
requires=("close", "high"),
@@ -559,6 +569,7 @@ register_template(
description="量比:{fast} 日均量 / {slow} 日均量",
formula="mean(volume, {fast}) / mean(volume, {slow})",
brief="量比放大提示资金关注(短线活跃型);高换手也伴随更高波动,注意与波动因子搭配。",
unit="倍数",
fn=lambda fields, params: (
fields["volume"].rolling(params[P_FAST]).mean()
/ fields["volume"].rolling(params[P_SLOW]).mean()
@@ -598,6 +609,7 @@ register_template(
description="{window} 日均线乖离率",
formula="(close - ma(close, {window})) / ma(close, {window})",
brief="均线乖离:上行趋势中正乖离偏强;乖离过大易回落,需警惕过热。",
unit="小数",
fn=lambda fields, params: (
(fields["close"] - fields["close"].rolling(params[P_WINDOW]).mean())
/ fields["close"].rolling(params[P_WINDOW]).mean()
@@ -615,6 +627,7 @@ register_template(
description="短期反转:过去 {window} 日收益率取负",
formula="-1 * (close / close.shift({window}) - 1)",
brief="短期反转:前期跌幅大的超跌反弹机会,适合震荡/修复行情。",
unit="小数",
fn=lambda fields, params: -1.0 * _rolling_return(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),),
lookback_of=lambda params: params[P_WINDOW],
@@ -641,6 +654,7 @@ register_template(
"建议配合 dv_ratio 上限过滤与盈利质量条件使用。"
),
fn=lambda fields, params: fields["dv_ratio"],
unit="%",
requires=("dv_ratio",),
lookback_of=lambda params: 0, # 时点截面值,无滚动窗口
instances=(("dividend_yield", {P_DIRECTION: DIRECTION_HIGHER}),),
@@ -654,9 +668,10 @@ register_template(
description="股息率 TTM(近 12 个月滚动现金分红 / 总市值 × 100,%)",
formula="dv_ttm(Tushare daily_basic,逐日时点值)",
brief="同股息率,但口径为 TTM;与 dv_ratio 多数日期取值一致,可作交叉验证。",
unit="%",
fn=lambda fields, params: fields["dv_ttm"],
requires=("dv_ttm",),
lookback_of=lambda params: 0,
instances=(("dividend_yield_ttm", {P_DIRECTION: DIRECTION_HIGHER}),),
)
)
)
+404
View File
@@ -0,0 +1,404 @@
"""买卖理由(数据化)与因子曲线的共享构造器。
**为什么单独一个模块**:两套回测引擎(`combo_engine` 组合回测、`local_engine`
单策略回测)都要回答同一个问题 ——「这一买一卖,当时的数字是多少?」。如果各写一份,
措辞、口径、字段名迟早分叉,用户在两处看到的「理由」会互相矛盾。
因此这里只放两件事:
1. **封闭的原因词表 + 构造器**:每个 `code` 对应一类可判定的原因,`text` 里带关键数字,
`data` 里放当时的原始数值(排名 / 候选数 / 综合分 / 因子原始值 / 持有交易日 / 预算 …)。
引擎只允许用词表里的 code(`REASON_CODES`),避免出现「文案随手写」的漂移。
2. **因子曲线的口径实现**:持仓股票的**权重加权平均原始值**,空仓日不落点、
不插值、不按方向取负(低为好的因子也原样画,方向由 `FactorCurve.direction` 说明)。
数值一律「引擎当时算出来的」:前端只展示,不推算 —— 界面上不该出现看起来像真的数字。
"""
from __future__ import annotations
import math
from collections.abc import Mapping
import pandas as pd
from app.domain.entities.research import CurvePoint, FactorCurve, TradeReason
from app.quant.factors import FactorDef
# ---------- 封闭原因词表 ----------
# 买入(成交)
BUY_ENTER = "buy_enter_topn"
BUY_DEFER_FILLED = "buy_defer_filled"
# 买入(未成交 / 未执行)
BUY_SKIP_LIMIT_UP = "buy_skip_limit_up"
BUY_SKIP_HALTED = "buy_skip_halted"
BUY_SKIP_NO_CASH = "buy_skip_no_cash"
BUY_SKIP_MIN_COMMISSION = "buy_skip_min_commission"
# 卖出(成交)
SELL_DROP_TOPN = "sell_drop_topn"
SELL_FORCE_TMAX = "sell_force_tmax"
# 卖出(顺延 / 未成交)
SELL_DEFER_TMIN = "sell_defer_tmin"
SELL_DEFER_HALTED = "sell_defer_halted"
SELL_DEFER_LIMIT_DOWN = "sell_defer_limit_down"
REASON_CODES = frozenset(
{
BUY_ENTER,
BUY_DEFER_FILLED,
BUY_SKIP_LIMIT_UP,
BUY_SKIP_HALTED,
BUY_SKIP_NO_CASH,
BUY_SKIP_MIN_COMMISSION,
SELL_DROP_TOPN,
SELL_FORCE_TMAX,
SELL_DEFER_TMIN,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
}
)
# 原因分类的中文短标签(前端筛选 / 表格上色用;改文案只改这里)
REASON_LABELS: dict[str, str] = {
BUY_ENTER: "按名次建仓",
BUY_DEFER_FILLED: "顺延后成交",
BUY_SKIP_LIMIT_UP: "涨停未买",
BUY_SKIP_HALTED: "停牌未买",
BUY_SKIP_NO_CASH: "现金不足",
BUY_SKIP_MIN_COMMISSION: "不足最低佣金",
SELL_DROP_TOPN: "跌出 TopN",
SELL_FORCE_TMAX: "持有超 Tmax",
SELL_DEFER_TMIN: "Tmin 保护暂留",
SELL_DEFER_HALTED: "停牌未卖",
SELL_DEFER_LIMIT_DOWN: "跌停未卖",
}
def _num(v, digits: int = 6):
"""把 numpy/pandas 数值安全地压成原生 float(NaN/inf 一律不带进理由里)。"""
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
if math.isnan(f) or math.isinf(f):
return None
return round(f, digits)
def factor_values(
factor_panels: Mapping[str, tuple[FactorDef, pd.DataFrame]],
day,
symbol: str,
) -> dict[str, float]:
"""该个股在 `day` 的各因子**原始值**(缺失因子不写进 data,不用 0 冒充)。
用交易日精确匹配:调仓日的打分与理由是同一份面板,所以这里取不到值就意味着
「该股当日无该因子值」,如实缺失比填 0 更可信。
"""
out: dict[str, float] = {}
for name, (_defn, panel) in factor_panels.items():
if day not in panel.index or symbol not in panel.columns:
continue
v = _num(panel.at[day, symbol])
if v is not None:
out[name] = v
return out
def _rank_data(
*,
rank: int | None,
total: int | None,
top_n: int | None,
score: float | None,
factors: dict[str, float] | None,
) -> dict:
data: dict = {}
if rank is not None:
data["rank"] = int(rank)
if total is not None:
data["total"] = int(total)
if top_n is not None:
data["top_n"] = int(top_n)
if score is not None:
data["score"] = _num(score)
if factors:
data["factors"] = factors
return data
def _rank_text(rank: int | None, total: int | None, top_n: int | None, score: float | None) -> str:
if rank is None:
return "调仓日综合分未给出名次"
parts = [f"综合分第 {rank}"]
if total:
parts.append(f"/{total}")
parts.append(" 名")
if top_n is not None:
parts.append(f"(TopN={top_n})")
if score is not None:
parts.append(f",综合分 {score:.4f}")
return "".join(parts)
def buy_filled(
*,
rank: int | None,
total: int | None,
top_n: int | None,
score: float | None,
factors: dict[str, float] | None,
price: float | None,
budget: float | None = None,
deferred: bool = False,
) -> TradeReason:
"""买入成交的理由:名次 + 综合分 + 各因子当时的原始值 + 成交价。"""
head = "顺延买入成交" if deferred else "调仓日选中并建仓"
text = f"{head}:{_rank_text(rank, total, top_n, score)}"
if price is not None:
text += f";成交价 {price:.2f} 元"
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
if price is not None:
data["price"] = _num(price, 4)
if budget is not None:
data["budget"] = _num(budget, 2)
return TradeReason(code=BUY_DEFER_FILLED if deferred else BUY_ENTER, text=text, data=data)
def buy_skipped(
code: str,
*,
rank: int | None = None,
total: int | None = None,
top_n: int | None = None,
score: float | None = None,
factors: dict[str, float] | None = None,
close: float | None = None,
prev_close: float | None = None,
limit_ratio: float | None = None,
budget: float | None = None,
min_commission: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""买入未成交 / 未执行的理由(涨停、停牌、现金不足、佣金门槛)。"""
if code not in REASON_CODES:
raise ValueError(f"未知的买入未成交原因:{code}")
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
base = _rank_text(rank, total, top_n, score)
if code == BUY_SKIP_LIMIT_UP:
ratio = (
_num(close / prev_close, 4)
if close is not None and prev_close not in (None, 0)
else None
)
text = f"{base},但当日涨停"
if ratio is not None:
text += f"(收盘 {close:.2f} / 前收 {prev_close:.2f} = {ratio:.3f}"
text += f" ≥ 涨停阈值 {limit_ratio:.3f})" if limit_ratio else ")"
text += ",无法追买"
if ratio is not None:
data["close_prev_ratio"] = ratio
if limit_ratio is not None:
data["limit_ratio"] = _num(limit_ratio, 4)
elif code == BUY_SKIP_HALTED:
text = f"{base},但当日无行情(停牌),无法买入"
elif code == BUY_SKIP_NO_CASH:
text = f"{base},但可用现金不足,未成交"
if budget is not None:
text += f"(可用预算 {budget:.2f} 元)"
elif code == BUY_SKIP_MIN_COMMISSION:
text = f"{base},但预算不足以覆盖最低佣金,未成交"
if budget is not None and min_commission is not None:
text += f"(预算 {budget:.2f} 元 < 最低佣金 {min_commission:.2f} 元)"
else: # pragma: no cover - 上面的分支已覆盖全部代码
text = base
if close is not None:
data["close"] = _num(close, 4)
if prev_close is not None:
data["prev_close"] = _num(prev_close, 4)
if budget is not None:
data["budget"] = _num(budget, 2)
if min_commission is not None:
data["min_commission"] = _num(min_commission, 2)
if not_in_pool:
data["in_pool"] = False
return TradeReason(code=code, text=text, data=data)
def sell_filled(
*,
code: str,
rank: int | None,
total: int | None,
top_n: int | None,
score: float | None,
factors: dict[str, float] | None,
hold_days: int,
tmin: int | None = None,
tmax: int | None = None,
price: float | None = None,
return_pct: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""卖出成交的理由(跌出 TopN / 持有超 Tmax),带持有交易日与当时名次。
`not_in_pool=True` 表示该股已**不在候选池**(被股票池/条件过滤,如转为 ST),
与「在池内但排名掉出去」是两回事,文案与 data 都分开写。
"""
if code == SELL_FORCE_TMAX:
text = f"持有 {hold_days} 个交易日 > Tmax={tmax},强制了结(与排名无关)"
else:
code = SELL_DROP_TOPN
if not_in_pool:
text = f"调仓日已不在候选池(被股票池/条件过滤);持有 {hold_days} 个交易日"
else:
text = f"调仓日跌出 TopN:{_rank_text(rank, total, top_n, score)};持有 {hold_days} 个交易日"
if tmin is not None:
text += f" ≥ Tmin={tmin}"
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
data["hold_days"] = int(hold_days)
if not_in_pool:
data["in_pool"] = False
if tmin is not None:
data["tmin"] = int(tmin)
if tmax is not None:
data["tmax"] = int(tmax)
if price is not None:
text += f";卖出价 {price:.2f} 元"
data["price"] = _num(price, 4)
if return_pct is not None:
data["return_pct"] = _num(return_pct, 4)
return TradeReason(code=code, text=text, data=data)
def sell_deferred(
code: str,
*,
cause: str,
rank: int | None = None,
total: int | None = None,
top_n: int | None = None,
score: float | None = None,
factors: dict[str, float] | None = None,
hold_days: int | None = None,
tmin: int | None = None,
tmax: int | None = None,
close: float | None = None,
prev_close: float | None = None,
limit_ratio: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""卖出未成交(顺延 / 暂留)的理由:Tmin 保护 / 停牌 / 跌停。
`tmax` 有值时说明是 Tmax 强制了结被卡住,文案据此区分 —— 两者后续行为不同
(Tmin 保护等到满 Tmin,Tmax 每天重试且不认排名)。
"""
if code not in REASON_CODES:
raise ValueError(f"未知的卖出顺延原因:{code}")
if tmax is not None:
head = f"持有 {hold_days} 个交易日超 Tmax={tmax},本应强制了结"
elif code == SELL_DEFER_TMIN:
why = (
"调仓日已不在候选池(被股票池/条件过滤)"
if not_in_pool
else f"掉出 TopN({_rank_text(rank, total, top_n, score)})"
)
head = f"{why}但仅持 {hold_days} 个交易日 < Tmin={tmin},按 Tmin 保护暂留"
else:
head = (
"调仓日已不在候选池(被股票池/条件过滤)"
if not_in_pool
else f"调仓日跌出 TopN({_rank_text(rank, total, top_n, score)})"
)
if cause == "tmin":
text = f"{head},暂留至满 Tmin"
elif cause == "halted":
text = f"{head},但当日无行情(停牌),顺延"
elif cause == "limit_down":
ratio = _num(close / prev_close, 4) if close is not None and prev_close not in (None, 0) else None
text = f"{head},但当日跌停"
if ratio is not None:
text += f"(收盘 {close:.2f} / 前收 {prev_close:.2f} = {ratio:.3f}"
text += f" ≤ 跌停阈值 {limit_ratio:.3f})" if limit_ratio else ")"
text += ",无法卖出,顺延"
if ratio is not None:
data_ratio = ratio
close_v, prev_v = _num(close, 4), _num(prev_close, 4)
else:
data_ratio, close_v, prev_v = None, None, None
else: # pragma: no cover - 调用方只传 halted / limit_down
text = f"{head},顺延"
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
if not_in_pool:
data["in_pool"] = False
if hold_days is not None:
data["hold_days"] = int(hold_days)
if tmin is not None:
data["tmin"] = int(tmin)
if tmax is not None:
data["tmax"] = int(tmax)
if cause == "limit_down":
if data_ratio is not None:
data["close_prev_ratio"] = data_ratio
data["close"] = close_v
data["prev_close"] = prev_v
if limit_ratio is not None:
data["limit_ratio"] = _num(limit_ratio, 4)
if close is not None and "close" not in data and cause == "halted":
data["close"] = _num(close, 4)
return TradeReason(code=code, text=text, data=data)
# ---------- 因子曲线(持仓加权平均原始值) ----------
def weighted_average(values: Mapping[str, float], weights: Mapping[str, float]) -> float | None:
"""权重加权平均;没有任何有效样本时返回 None(调用方据此不落点)。"""
total_w = 0.0
acc = 0.0
for symbol, w in weights.items():
v = values.get(symbol)
if v is None or w <= 0:
continue
acc += float(v) * float(w)
total_w += float(w)
if total_w <= 0:
return None
return acc / total_w
def build_factor_curves(
factor_panels: Mapping[str, tuple[FactorDef, pd.DataFrame]],
weights_by_day: Mapping[object, dict[str, float]],
) -> list[FactorCurve]:
"""按「每日持仓权重」聚合出每个因子的曲线。
- `weights_by_day`:`{交易日: {symbol: 该股市值}}`,空仓日给空字典(不落点);
- 值 = 该日持仓上该因子的权重加权平均**原始值**(不做 z-score、不按方向取负);
- 曲线按因子键排序,保证同一份数据每次归档的顺序一致(便于 diff)。
"""
out: list[FactorCurve] = []
for name in sorted(factor_panels):
defn, panel = factor_panels[name]
points: list[CurvePoint] = []
for day, weights in weights_by_day.items():
if not weights or day not in panel.index:
continue
row = panel.loc[day]
values = {s: _num(row.get(s)) for s in weights}
avg = weighted_average(values, weights)
if avg is None:
continue
points.append(CurvePoint(date=day.date() if hasattr(day, "date") else day, value=round(avg, 6)))
out.append(
FactorCurve(
name=name,
label=defn.display,
direction=defn.direction,
unit=defn.unit,
points=points,
)
)
return out
+241
View File
@@ -0,0 +1,241 @@
"""买卖理由(数据化)与因子曲线的单测。
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线」。
因此这里验证的是**数字真的来自引擎当时计算**,而不是后补的文案:
1. 买入理由带名次 / 候选数 / 综合分 / 每个因子当时的原始值;
2. 卖出理由区分「跌出 TopN(第几名)」「被股票池过滤」「持有超 Tmax」;
3. Tmin 保护、涨停未买等未成交点也有结构化理由;
4. `Trade.entry_reason / exit_reason` 跟着成交记录走;
5. `factor_curves` = 持仓权重加权平均的**原始值**,空仓日不落点。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
import pytest
from app.domain.entities.combo import BacktestCombo
from app.domain.entities.research import CostSpec
from app.quant.combo_engine import HoldingBandRunner
from app.quant.factors import get_factor
def _days(n: int = 12, start: date = date(2024, 1, 2)) -> list[pd.Timestamp]:
out: list[date] = []
d = start
while len(out) < n:
if d.weekday() < 5:
out.append(d)
d += timedelta(days=1)
return [pd.Timestamp(x) for x in out]
def _close(days, series: dict[str, list[float]]) -> pd.DataFrame:
return pd.DataFrame(series, index=pd.DatetimeIndex(days))
def _combo(**over) -> BacktestCombo:
base = dict(
name="理由单测",
strategy_ids=["S1"],
initial_capital=1_000_000.0,
hold_count=1,
hold_min_days=0,
hold_max_days=None,
rebalance_freq="daily",
period=(date(2024, 1, 2), date(2024, 1, 31)),
)
base.update(over)
return BacktestCombo(**base)
def _factor_panels(days, values: dict[str, float]):
"""用一个真实注册因子(momentum_20)承载合成面板:label/方向/单位来自注册表。"""
panel = pd.DataFrame(
{sym: [v] * len(days) for sym, v in values.items()}, index=pd.DatetimeIndex(days)
)
return {"momentum_20": (get_factor("momentum_20")[0], panel)}
def _run(
*,
score_rows: list[dict[str, float]],
close_series: dict[str, list[float]],
factor_values: dict[str, float] | None = None,
eligibility_fn=None,
**combo_over,
):
days = _days(len(score_rows))
score = pd.DataFrame(score_rows, index=pd.DatetimeIndex(days))
close = _close(days, close_series)
runner = HoldingBandRunner(
combo=_combo(**combo_over),
costs=CostSpec(),
score=score,
close=close,
eligibility_fn=eligibility_fn,
factor_panels=_factor_panels(days, factor_values or {"A": 0.1, "B": 0.2}),
)
return runner.run(), days
# ---------- 买入理由 ----------
def test_buy_reason_has_real_numbers():
"""买入成交的理由 = 名次 + 候选数 + 综合分 + 各因子当时的原始值(全部来自引擎)。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 6,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
factor_values={"A": 0.123, "B": 0.456},
)
buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled]
assert len(buys) == 1
r = buys[0].reason
assert r is not None
assert r.code == "buy_enter_topn"
assert r.data["rank"] == 1
assert r.data["total"] == 2
assert r.data["top_n"] == 1
assert r.data["score"] == pytest.approx(0.9)
# 因子原始值(面板里 A=0.123)——区间内买入理由必须能对上这个数
assert r.data["factors"]["momentum_20"] == pytest.approx(0.123)
assert "第 1" in r.text and "0.9000" in r.text
def test_sell_reason_ranks_and_hold_days():
"""跌出 TopN 的卖出理由要说出「第几名掉出去」与持有交易日。"""
# 第 1 天 A 第一 → 买入 A;第 3 天起 B 第一 → 卖出 A(名次 2/2)
rows = [{"A": 0.9, "B": 0.5}, {"A": 0.9, "B": 0.5}, {"A": 0.1, "B": 0.9}] + [
{"A": 0.1, "B": 0.9}
] * 3
result, _ = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
hold_min_days=0,
)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert len(sells) == 1
r = sells[0].reason
assert r is not None
assert r.code == "sell_drop_topn"
assert r.data["rank"] == 2
assert r.data["total"] == 2
assert r.data["hold_days"] == 2 # 第 1 天买、第 3 天卖 → 2 个交易日
assert "第 2/2" in r.text
# 成交明细里的买卖理由两端齐全
trade = result.trades[0]
assert trade.entry_reason is not None and trade.entry_reason.code == "buy_enter_topn"
assert trade.exit_reason is not None and trade.exit_reason.code == "sell_drop_topn"
def test_sell_reason_not_in_pool_is_distinct():
"""被股票池/条件过滤掉(不在候选池)≠ 排名掉出去:理由要分开写。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 4,
close_series={"A": [100.0] * 4, "B": [100.0] * 4},
# 第 4 天把 B 之外的 A 挡在候选池外(A 持有中,属于「已不在候选池」)
eligibility_fn=lambda as_of: {"B"} if as_of >= date(2024, 1, 5) else None,
)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert sells, "A 不在候选池后应被卖出"
r = sells[0].reason
assert r is not None and r.code == "sell_drop_topn"
assert r.data["in_pool"] is False
assert "已不在候选池" in r.text
def test_sell_reason_tmax_force_exit():
"""Tmax 强制了结:理由说明「持有 N 天 > Tmax」,与排名无关。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 8,
close_series={"A": [100.0] * 8, "B": [100.0] * 8},
hold_max_days=3,
)
forced = [
a for a in result.signal_history if a.signal == "SELL" and a.filled
and a.reason is not None and a.reason.code == "sell_force_tmax"
]
assert forced, "超 Tmax 应有强制了结"
r = forced[0].reason
assert r is not None
assert r.data["hold_days"] > r.data["tmax"] == 3
assert "Tmax=3" in r.text
def test_tmin_protection_reason():
"""未满 Tmin 掉出 TopN:理由写明「仅持 N 天 < Tmin,暂留」。"""
rows = [{"A": 0.9, "B": 0.5}, {"A": 0.1, "B": 0.9}] + [{"A": 0.1, "B": 0.9}] * 4
result, _ = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
hold_min_days=3,
)
deferred = [
a for a in result.signal_history
if not a.filled and a.reason is not None and a.reason.code == "sell_defer_tmin"
]
assert deferred, "未满 Tmin 应记录暂留理由"
r = deferred[0].reason
assert r is not None
assert r.data["hold_days"] < r.data["tmin"] == 3
assert "Tmin=3" in r.text and "暂留" in r.text
def test_buy_blocked_by_limit_up_reason():
"""涨停无法追买:理由里带「收盘 / 前收 = 比值 ≥ 阈值」的真实数字。"""
# 第 3 天 A 相对前收涨 10% 以上(600xxx 主板阈值 1.099)→ 当日买不进
a = [100.0, 100.0, 111.0, 111.0]
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 2 + [{"A": 0.9, "B": 0.5}] * 2,
close_series={"A": a, "B": [100.0] * 4},
# 前几天 A 不可选,逼到第 3 天涨停时才想买
eligibility_fn=lambda as_of: {"B"} if as_of < date(2024, 1, 4) else {"A", "B"},
)
blocked = [
a_ for a_ in result.signal_history
if a_.signal == "BUY" and not a_.filled and a_.reason is not None
and a_.reason.code == "buy_skip_limit_up"
]
assert blocked, "涨停日应记录未成交理由"
r = blocked[0].reason
assert r is not None
assert r.data["close_prev_ratio"] == pytest.approx(1.11, abs=1e-3)
assert r.data["limit_ratio"] == pytest.approx(1.099)
assert "涨停" in r.text
# ---------- 因子曲线 ----------
def test_factor_curve_is_holding_weighted_raw_value():
"""因子曲线 = 持仓权重加权平均的原始值(有 label/方向/单位),空仓日不落点。"""
rows = [{"A": 0.9, "B": 0.5}] * 6
result, days = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [200.0] * 6},
factor_values={"A": 0.2, "B": 0.8},
)
assert len(result.factor_curves) == 1
fc = result.factor_curves[0]
assert fc.name == "momentum_20"
assert fc.direction == "higher_is_better"
assert fc.unit == "小数"
assert fc.label.startswith("动量")
# 只买 A(N=1),因子值恒为 A 的 0.2;第一天调仓在收盘后建仓 → 第一天也落点
assert all(p.value == pytest.approx(0.2) for p in fc.points)
assert len(fc.points) == len(days)
def test_factor_curve_skips_empty_holding_days():
"""空仓日不落点(不插值、不用 0 假填充),曲线点数少于交易日数。"""
# 只有第 3 天有股票可选:之前空仓,之后持仓
rows = [{"A": float("nan"), "B": float("nan")}] * 2 + [{"A": 0.9, "B": 0.5}] * 4
result, days = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
)
fc = result.factor_curves[0]
assert 0 < len(fc.points) < len(days)