diff --git a/backend/app/quant/engine.py b/backend/app/quant/engine.py index 4c16c08..62ef85e 100644 --- a/backend/app/quant/engine.py +++ b/backend/app/quant/engine.py @@ -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() diff --git a/backend/app/quant/local_engine.py b/backend/app/quant/local_engine.py index 915cc8d..76a5bd0 100644 --- a/backend/app/quant/local_engine.py +++ b/backend/app/quant/local_engine.py @@ -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"), diff --git a/backend/app/quant/qlib_adapter/engine.py b/backend/app/quant/qlib_adapter/engine.py index 305766d..9d19eee 100644 --- a/backend/app/quant/qlib_adapter/engine.py +++ b/backend/app/quant/qlib_adapter/engine.py @@ -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] diff --git a/backend/app/quant/trade_reasons.py b/backend/app/quant/trade_reasons.py index a85be9d..72fe15f 100644 --- a/backend/app/quant/trade_reasons.py +++ b/backend/app/quant/trade_reasons.py @@ -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: diff --git a/backend/tests/test_local_engine_reasons.py b/backend/tests/test_local_engine_reasons.py new file mode 100644 index 0000000..c3db37d --- /dev/null +++ b/backend/tests/test_local_engine_reasons.py @@ -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) \ No newline at end of file