From 48a97c2a126566888ac2d465d7375d85c1dbf62c Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 1 Oct 2026 17:57:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E4=B9=B0=E5=8D=96=E7=82=B9?= =?UTF-8?q?=E7=90=86=E7=94=B1=EF=BC=88=E7=94=A8=E6=95=B0=E6=8D=AE=E8=AF=B4?= =?UTF-8?q?=E8=AF=9D=EF=BC=89+=20=E5=9B=A0=E5=AD=90=E6=9B=B2=E7=BA=BF=20+?= =?UTF-8?q?=20=E6=9B=B2=E7=BA=BF=E6=96=B0=E9=A1=B5=E9=9D=A2=E6=94=BE?= =?UTF-8?q?=E5=A4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线 (买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。 一、买卖理由(后端产出结构化数据,前端只展示) - 新增 `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 渲染,截图确认表格与曲线数值正确。 --- backend/app/domain/entities/research.py | 57 +++ backend/app/quant/combo_engine.py | 189 +++++++- backend/app/quant/composite.py | 19 +- backend/app/quant/factors.py | 17 +- backend/app/quant/trade_reasons.py | 404 ++++++++++++++++++ backend/tests/test_trade_reasons.py | 241 +++++++++++ frontend/web/app/backtest/page.tsx | 13 +- frontend/web/app/charts/[id]/page.tsx | 237 ++++++++++ frontend/web/app/factors/compose/page.tsx | 80 +--- frontend/web/app/globals.css | 124 ++++++ .../web/components/BacktestResultView.tsx | 199 ++++++--- frontend/web/components/ChartPopoutLink.tsx | 50 +++ frontend/web/components/TradeReasons.tsx | 308 +++++++++++++ frontend/web/components/charts/LwChart.tsx | 38 +- frontend/web/lib/chartSeries.ts | 152 +++++++ frontend/web/lib/types.ts | 78 +++- scripts/verify_backtest_page_contract.py | 55 ++- 17 files changed, 2103 insertions(+), 158 deletions(-) create mode 100644 backend/app/quant/trade_reasons.py create mode 100644 backend/tests/test_trade_reasons.py create mode 100644 frontend/web/app/charts/[id]/page.tsx create mode 100644 frontend/web/components/ChartPopoutLink.tsx create mode 100644 frontend/web/components/TradeReasons.tsx create mode 100644 frontend/web/lib/chartSeries.ts diff --git a/backend/app/domain/entities/research.py b/backend/app/domain/entities/research.py index 02f629f..6d6265e 100644 --- a/backend/app/domain/entities/research.py +++ b/backend/app/domain/entities/research.py @@ -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, diff --git a/backend/app/quant/combo_engine.py b/backend/app/quant/combo_engine.py index 8e4328d..6f02555 100644 --- a/backend/app/quant/combo_engine.py +++ b/backend/app/quant/combo_engine.py @@ -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):组合参数 + 当时各策略定义 + 当时成本/复权 diff --git a/backend/app/quant/composite.py b/backend/app/quant/composite.py index 848e378..2d7ccf6 100644 --- a/backend/app/quant/composite.py +++ b/backend/app/quant/composite.py @@ -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 diff --git a/backend/app/quant/factors.py b/backend/app/quant/factors.py index 0543d68..3d9f2da 100644 --- a/backend/app/quant/factors.py +++ b/backend/app/quant/factors.py @@ -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}),), ) -) \ No newline at end of file +) diff --git a/backend/app/quant/trade_reasons.py b/backend/app/quant/trade_reasons.py new file mode 100644 index 0000000..a85be9d --- /dev/null +++ b/backend/app/quant/trade_reasons.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_trade_reasons.py b/backend/tests/test_trade_reasons.py new file mode 100644 index 0000000..375b625 --- /dev/null +++ b/backend/tests/test_trade_reasons.py @@ -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) \ No newline at end of file diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index 3b26340..2673040 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -127,6 +127,9 @@ function BacktestInner() { const [draft, setDraft] = useState(emptyDraft(range)); const [savedComboId, setSavedComboId] = useState(null); const [result, setResult] = useState(null); + // 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。 + // 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。 + const [resultArchiveId, setResultArchiveId] = useState(null); const [running, setRunning] = useState(false); const [runningComboId, setRunningComboId] = useState(null); const [jobId, setJobId] = useState(""); @@ -239,6 +242,7 @@ function BacktestInner() { setNotice(""); setJobId(""); setResult(null); + setResultArchiveId(null); try { const { job_id } = await submit(); setJobId(job_id); @@ -250,6 +254,7 @@ function BacktestInner() { if (out.status === "success" && out.result) { setResult(out.result); const exp = out.experimentId ?? null; + setResultArchiveId(exp); setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。"); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); @@ -649,7 +654,13 @@ function BacktestInner() { ) : null} - {result ? : null} + {result ? ( + + ) : null} ); } diff --git a/frontend/web/app/charts/[id]/page.tsx b/frontend/web/app/charts/[id]/page.tsx new file mode 100644 index 0000000..e20f1ba --- /dev/null +++ b/frontend/web/app/charts/[id]/page.tsx @@ -0,0 +1,237 @@ +/** + * 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。 + * + * 设计取舍: + * - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图); + * 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 ``,不需要客户端状态。 + * - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足 + * (`CHART_HEIGHT`),并给出该曲线的口径说明与买卖点标注。 + * - **数据只来自归档**:不重新跑回测、不从内存里取,避免「放大页与归档不一致」。 + * - 归档不存在 → 404;归档类型没有该曲线 → 如实说明并给回归档详情页的入口。 + */ +import { notFound } from "next/navigation"; +import Link from "next/link"; + +import { LwChart } from "@/components/charts/LwChart"; +import { CHART, fmtNum } from "@/components/charts/theme"; +import { Card, Pill, Banner } from "@/components/ui"; +import type { LwFormatKey } from "@/components/charts/LwChart"; +import { + drawdownSeries, + equitySeries, + factorCurveNote, + factorFormatKey, + factorSeries, + monthlySeries, + portfolioMarkers, + symbolMarkers, + symbolSeries, +} from "@/lib/chartSeries"; +import { experimentKindLabel } from "@/lib/labels"; +import type { BacktestResult, ExperimentDetail } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, ""); +/** 放大页的主要目的就是「看大图」,给足高度(窄屏靠 CSS 缩到视口内) */ +const CHART_HEIGHT = 640; + +async function serverGet(path: string): Promise { + try { + const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" }); + if (!r.ok) return null; + return (await r.json()) as T; + } catch { + return null; + } +} + +interface Option { + key: string; + label: string; + hint: string; +} + +/** 该归档里所有可放大的曲线(顺序即推荐阅读顺序) */ +function optionsFor(result: BacktestResult): Option[] { + const out: Option[] = [ + { key: "equity", label: "组合净值", hint: "含买卖点(成交日)" }, + { key: "drawdown", label: "回撤", hint: "距历史最高的回撤(%)" }, + ]; + for (const f of result.factor_curves ?? []) { + out.push({ key: `factor:${f.name}`, label: `因子 · ${f.label}`, hint: "持仓加权平均原始值" }); + } + for (const c of result.symbol_curves ?? []) { + out.push({ key: `sym:${c.symbol}`, label: `个股 · ${c.symbol}`, hint: "持仓期累计收益(%)" }); + } + if ((result.monthly_returns ?? []).length) { + out.push({ key: "monthly", label: "月度收益", hint: "每月收益(%)" }); + } + return out; +} + +export default async function ChartPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ s?: string }>; +}) { + const { id: rawId } = await params; + const id = decodeURIComponent(rawId ?? ""); + const sp = await searchParams; + const detail = await serverGet(`/experiments/${encodeURIComponent(id)}`); + if (!detail) notFound(); + + const result = detail.result as BacktestResult | null; + const isBacktest = Boolean(result && Array.isArray(result.equity_curve)); + if (!isBacktest) { + return ( + + + 归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 / + 因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。 + +
+ + 打开归档详情 + +
+
+ ); + } + + const res = result as BacktestResult; + const options = optionsFor(res); + const want = sp?.s ?? options[0]?.key ?? "equity"; + const current = options.find((o) => o.key === want) ?? options[0]; + const factorKey = current?.key.startsWith("factor:") ? current.key.slice("factor:".length) : null; + const symKey = current?.key.startsWith("sym:") ? current.key.slice("sym:".length) : null; + + let series = equitySeries(res); + let markers = portfolioMarkers( + res.fills, + new Set(res.equity_curve.map((p) => p.date)) + ); + let formatKey: LwFormatKey = "num"; + let note = "组合净值(元):每日收盘后按持仓市值结算;▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交。"; + let zeroLine = false; + + if (factorKey) { + const curve = (res.factor_curves ?? []).find((f) => f.name === factorKey); + if (curve) { + const idx = (res.factor_curves ?? []).findIndex((f) => f.name === factorKey); + series = [factorSeries(curve, idx)]; + formatKey = factorFormatKey(curve); + note = factorCurveNote(curve); + // 因子曲线上的买卖点:把组合的成交日标在因子曲线上,直接看「买卖发生在什么水平」 + markers = portfolioMarkers(res.fills, new Set(curve.points.map((p) => p.date))); + } + } else if (symKey) { + const curve = (res.symbol_curves ?? []).find((c) => c.symbol === symKey); + if (curve) { + series = symbolSeries(curve); + markers = symbolMarkers(curve); + formatKey = "pct2"; + note = + `${curve.symbol} 持仓期间的累计收益率(%,以建仓日收盘为 0% 基准,按日复利),` + + "只在该股持仓的交易日落点;买卖点为实际成交。"; + zeroLine = true; + } + } else if (current?.key === "drawdown") { + series = drawdownSeries(res); + markers = []; + formatKey = "pct2"; + note = "回撤(%):净值相对历史最高点的跌幅,越负越深。"; + zeroLine = true; + } else if (current?.key === "monthly") { + series = monthlySeries(res); + markers = []; + formatKey = "pct2"; + note = "月度收益(%):每个月末相对上月末的净值变化。"; + zeroLine = true; + } + + const seriesLabel = series[0]?.label ?? current?.label ?? "曲线"; + + return ( + <> +
+
+ 曲线放大 + {experimentKindLabel(detail.kind)} + {id} + {detail.created_at ? ( + 归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")} + ) : null} +
+
+ + 打开归档详情 + +
+
+ +
+ {options.map((o) => ( + + {o.label} + + ))} +
+ + + {series[0]?.data.length ?? 0} 个点 + {markers.length ? ` · ${markers.length} 个买卖标注` : ""} + + } + > + +
+ {note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏 + (换设备打开还原同一张图,因为数据来自归档快照)。 +
+
+ + {res.summary ? ( + +
+ + 区间 {res.summary.start} ~{" "} + {res.summary.end} + + + 总收益{" "} + = 0 ? "tone-pos" : "tone-neg"}> + {res.summary.total_return_pct.toFixed(2)}% + + + 年化 {res.summary.annual_return_pct.toFixed(2)}% + Sharpe {res.summary.sharpe.toFixed(3)} + 最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}% + 期末权益 {fmtNum(res.summary.final_equity)} + + 共 {res.summary.total_trades} 笔成交 + +
+
+ ) : null} + + ); +} \ No newline at end of file diff --git a/frontend/web/app/factors/compose/page.tsx b/frontend/web/app/factors/compose/page.tsx index 245f994..fbf582f 100644 --- a/frontend/web/app/factors/compose/page.tsx +++ b/frontend/web/app/factors/compose/page.tsx @@ -15,12 +15,8 @@ import { Banner, Progress, Signed, - BacktestMetrics, - MonthlyReturnsTable, - UnimplementedNote, } from "@/components/ui"; -import { LwChart, type LwSeries } from "@/components/charts/LwChart"; -import { CHART, fmtNum, fmtPct } from "@/components/charts/theme"; +import { BacktestResultView } from "@/components/BacktestResultView"; import { Icon } from "@/components/icons"; interface Pick { @@ -37,6 +33,8 @@ export default function ComposePage() { const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [result, setResult] = useState(null); + /** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */ + const [archiveId, setArchiveId] = useState(null); const [running, setRunning] = useState(false); const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); @@ -88,6 +86,7 @@ export default function ComposePage() { setError(""); setJobId(""); setResult(null); + setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果 try { const spec: ResearchSpec = { type: "backtest", @@ -102,6 +101,7 @@ export default function ComposePage() { const out = await waitJob(job_id); if (out.status === "success" && out.result) { setResult(out.result); + setArchiveId(out.experimentId ?? null); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } @@ -289,7 +289,13 @@ export default function ComposePage() { {error ?
{error}
: null} - {result ? : null} + {result ? ( + + ) : null} ); } @@ -297,36 +303,17 @@ export default function ComposePage() { function ResultView({ result, params, + archiveId, }: { result: BacktestResult; params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string }; + archiveId?: string | null; }) { const s = result.summary; - - // 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射 - const equitySeries: LwSeries[] = [ - { - key: "equity", - label: "净值", - type: "area", - color: CHART.pos, - data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })), - }, - ]; - const drawdownSeries: LwSeries[] = [ - { - key: "drawdown", - label: "回撤", - type: "line", - color: CHART.neg, - data: result.drawdown.map((p) => ({ time: p.date, value: p.value })), - }, - ]; - return ( <>
-
+
回测完成 @@ -334,41 +321,20 @@ function ResultView({ {params.rebalance === "monthly" ? "月度调仓" : "周度调仓"} {params.excludeSt ? 剔除 ST : null} {params.start} ~ {params.end} + {params.picks.length} 个因子
区间收益
- - -
- 期末 {s.final_equity.toLocaleString()}}> - fmtNum(v, 2)} - ariaLabel="净值曲线" - emptyHint="该区间没有净值数据" - /> - - 最大 {s.max_drawdown_pct.toFixed(2)}%}> - fmtPct(v, 2)} - ariaLabel="回撤曲线" - emptyHint="该区间没有回撤数据" - /> - -
- - - - - - + {/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里, + 两处各写一份迟早出现「同一个结果两套图」的口径漂移 */} + ); } diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css index b321b1f..89010b2 100644 --- a/frontend/web/app/globals.css +++ b/frontend/web/app/globals.css @@ -2185,3 +2185,127 @@ button.chip:hover { min-width: 0; flex: 1 1 320px; } + +/* ---------- 作业反馈条(JobProgress):点「运行」后必须立刻看得见 ---------- */ +.job-progress { + margin: 12px 0; + padding: 12px 14px; + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: var(--r-md); + background: var(--surface-2); +} +.job-progress--run { + border-left-color: var(--accent); + background: var(--accent-soft); +} +.job-progress--ok { + border-left-color: var(--pos); + background: rgba(61, 220, 151, 0.08); +} +.job-progress--bad { + border-left-color: var(--neg); + background: rgba(255, 106, 118, 0.08); +} +.job-progress__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); +} +.job-progress__title { + font-weight: 600; + font-size: var(--fs-sm); +} +.job-progress__meta { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); + font-size: var(--fs-xs); + color: var(--text-2); + font-variant-numeric: tabular-nums; +} +.job-progress__actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-left: auto; +} +.job-progress__err { + margin-top: 8px; + padding: 8px 10px; + border-radius: var(--r-sm); + background: var(--surface-1); + color: var(--neg); + font-size: var(--fs-xs); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ---------- 买卖说明:分段筛选 + 理由单元格 ---------- */ +.seg { + display: inline-flex; + border: 1px solid var(--line); + border-radius: var(--r-sm); + overflow: hidden; +} +.seg__btn { + appearance: none; + border: 0; + background: var(--surface-2); + color: var(--text-2); + font: inherit; + font-size: var(--fs-xs); + padding: 5px 10px; + cursor: pointer; +} +.seg__btn + .seg__btn { + border-left: 1px solid var(--line); +} +.seg__btn.is-on { + background: var(--accent-soft); + color: var(--accent-strong); + font-weight: 600; +} +.th-sort { + appearance: none; + border: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; +} +.reason-cell { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 260px; +} +.reason-cell__head { + display: flex; + align-items: flex-start; + gap: 6px; + flex-wrap: wrap; +} +.reason-cell__text { + font-size: var(--fs-xs); + color: var(--text-1); + overflow-wrap: anywhere; +} +.reason-cell__facts { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.reason-brief { + font-size: var(--fs-xs); + color: var(--text-2); + max-width: 160px; + overflow-wrap: anywhere; +} +.nowrap { + white-space: nowrap; +} diff --git a/frontend/web/components/BacktestResultView.tsx b/frontend/web/components/BacktestResultView.tsx index 08f02c7..b1f0789 100644 --- a/frontend/web/components/BacktestResultView.tsx +++ b/frontend/web/components/BacktestResultView.tsx @@ -29,6 +29,20 @@ import Link from "next/link"; import { SymbolLink, useSymbolNames } from "@/lib/symbols"; import { adjustLabel } from "@/lib/labels"; import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types"; +import { reasonLabel } from "@/lib/types"; +import { ChartPopoutLink } from "@/components/ChartPopoutLink"; +import { TradeReasonsCard, type FactorLabels } from "@/components/TradeReasons"; +import { + drawdownSeries, + equitySeries, + equityValueFormat, + factorCurveNote, + factorSeries, + factorValueFormat, + portfolioMarkers, + symbolMarkers, + symbolSeries, +} from "@/lib/chartSeries"; export interface ArchiveInfo { id: string; @@ -70,37 +84,19 @@ export function BacktestResultView({ const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? ""; const topRef = useRef(null); - // 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上 - const equitySeries = useMemo( - () => [ - { - key: "equity", - label: "组合净值(元)", - type: "area", - color: CHART.pos, - data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })), - lastValueVisible: true, - }, - ], - [result.equity_curve] + // 曲线序列统一走 lib/chartSeries(与「新页面放大」共用同一套口径与格式) + const equityData = useMemo(() => equitySeries(result), [result]); + const equityMarkers = useMemo( + () => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))), + [result] ); - const equityMarkers = useMemo(() => { - const byDate = new Map(); - for (const f of result.fills ?? []) { - const cur = byDate.get(f.date) ?? { BUY: false, SELL: false }; - cur[f.signal] = true; - byDate.set(f.date, cur); - } - const equity = new Set(result.equity_curve.map((p) => p.date)); - const out: LwMarker[] = []; - for (const [d, kinds] of byDate) { - if (!equity.has(d)) continue; - if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" }); - if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" }); - } + // 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键 + const factorLabels = useMemo(() => { + const out: FactorLabels = {}; + for (const f of result.factor_curves ?? []) out[f.name] = f.label; return out; - }, [result.fills, result.equity_curve]); + }, [result.factor_curves]); const filteredCurves = useMemo(() => { const q = curveQuery.trim().toLowerCase(); @@ -119,10 +115,12 @@ export function BacktestResultView({ const SECTIONS = [ { id: "sec-equity", label: "整体收益" }, + { id: "sec-factors", label: "因子曲线" }, { id: "sec-symbols", label: "个股曲线" }, { id: "sec-monthly", label: "月度/年度" }, { id: "sec-holdings", label: "持仓" }, { id: "sec-trades", label: "成交明细" }, + { id: "sec-reasons", label: "买卖说明" }, ]; return ( @@ -175,16 +173,19 @@ export function BacktestResultView({ icon="chartLine" title="整体收益趋势(含买卖点)" tools={ - - 期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日 - +
+ + 期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日 + + +
} > fmtNum(v)} + valueFormat={equityValueFormat} ariaLabel="组合净值曲线与买卖点" />
@@ -195,19 +196,15 @@ export function BacktestResultView({ 最大 {s.max_drawdown_pct.toFixed(2)}%} + tools={ +
+ 最大 {s.max_drawdown_pct.toFixed(2)}% + +
+ } > ({ time: p.date, value: p.value })), - lastValueVisible: true, - }, - ]} + series={drawdownSeries(result)} height={320} valueFormat={(v) => `${v.toFixed(2)}%`} zeroLine @@ -261,6 +258,11 @@ export function BacktestResultView({ 曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制, 分段间以直线连接,请以买卖点区分持仓区间。 +
{activeCurve ? : null}
@@ -303,7 +305,14 @@ export function BacktestResultView({ )} - + + + } + > 买价 卖价 收益 + 为什么买 + 为什么卖 @@ -423,6 +434,12 @@ export function BacktestResultView({ = 0 ? "tone-pos" : "tone-neg"}> {t.return_pct.toFixed(2)}% + + {reasonLabel(t.entry_reason?.code)} + + + {reasonLabel(t.exit_reason?.code)} + ))} @@ -433,6 +450,8 @@ export function BacktestResultView({ + + ); @@ -465,31 +484,10 @@ function NotFilledCard({ signals }: { signals: ActionRecord[] }) { } function SymbolCurveChart({ curve }: { curve: SymbolCurve }) { - const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]); - const markers = useMemo( - () => - (curve.marks ?? []) - .filter((a) => valueByDate.has(a.date)) - .map((a) => ({ - time: a.date, - kind: a.signal, - text: a.signal === "BUY" ? "买" : "卖", - })), - [curve, valueByDate] - ); return ( ({ time: p.date, value: p.value })), - lastValueVisible: true, - }, - ]} - markers={markers} + series={symbolSeries(curve)} + markers={symbolMarkers(curve)} height={300} valueFormat={(v) => `${v.toFixed(2)}%`} zeroLine @@ -498,6 +496,71 @@ function SymbolCurveChart({ curve }: { curve: SymbolCurve }) { ); } +/** + * 因子曲线:策略里每个因子一张图(**原始值**,不做 z-score)。 + * + * 用户要求:「本因子的买卖依据是股息率,那么要增加股息率曲线」。这里把**同一批成交日** + * 标在因子曲线上,于是能一眼看出「买在什么水平、卖在什么水平」,而不是只看净值曲线 + * 猜原因。口径(持仓加权平均、不按方向取反、空仓不落点)写在每张图下方。 + */ +function FactorCurvesCard({ + result, + fills, + archiveId, +}: { + result: BacktestResult; + fills?: ActionRecord[]; + archiveId?: string | null; +}) { + const curves = result.factor_curves ?? []; + if (!curves.length) return null; + return ( + 持仓加权平均原始值} + > +
+ 每个因子一条曲线:值为当日**持仓股票按市值加权平均**的因子原始值,用来回答 + 「买入时这个因子处于什么水平、卖出时又变到哪」。图上 ▲/▼ 是**组合的成交日**(同一套买卖点), + 因此能直接对照「因子在什么水平触发买卖」。曲线未做 z-score、也未按方向取反; + 低为好的因子(方向标注为「越低越好」)曲线升高不等于更好。 +
+
+ {curves.map((c, i) => { + const dates = new Set(c.points.map((p) => p.date)); + return ( + + + {c.direction === "lower_is_better" ? "越低越好" : "越高越好"} + + +
+ } + > + +
+ {factorCurveNote(c)} +
+
+ ); + })} +
+ + ); +} + function poolLabel(result: BacktestResult): string { const sel = result.config_snapshot?.selection as | { top_n?: number; hold_top_x?: number | null } diff --git a/frontend/web/components/ChartPopoutLink.tsx b/frontend/web/components/ChartPopoutLink.tsx new file mode 100644 index 0000000..4cdc328 --- /dev/null +++ b/frontend/web/components/ChartPopoutLink.tsx @@ -0,0 +1,50 @@ +"use client"; + +/** + * 「新页面放大」入口:把某条曲线在 `/charts/{归档id}?s={曲线}` 里整页打开。 + * + * 为什么要走归档而不是把数据塞进新标签页:新标签页与原页不共享内存/存储, + * 唯一可靠的传递方式就是 URL;而回测结果本来就**已经归档**(`experiment_id`), + * 让放大页从归档读同一份数据,还能顺带保证「看到的图与归档一致」。 + * + * 没有归档 id 时(同步接口没归档、或归档已被删除)**不给假按钮**:直接说明原因。 + */ + +import Link from "next/link"; + +import { Icon } from "@/components/icons"; + +export function ChartPopoutLink({ + archiveId, + series, + label = "新页面放大", + size = "sm", +}: { + archiveId?: string | null; + series: string; + label?: string; + size?: "sm" | "md"; +}) { + if (!archiveId) { + return ( + + 未归档,无法放大 + + ); + } + return ( + + + {label} + + ); +} \ No newline at end of file diff --git a/frontend/web/components/TradeReasons.tsx b/frontend/web/components/TradeReasons.tsx new file mode 100644 index 0000000..38edf9a --- /dev/null +++ b/frontend/web/components/TradeReasons.tsx @@ -0,0 +1,308 @@ +"use client"; + +/** + * 买卖理由表(回测结果的「所有买卖点为什么买 / 为什么卖」)。 + * + * 用户要求:「在所有买卖点详细说明买卖理由。用数据说话。」因此这里的原则是: + * 1. **一个点都不省**:成交的、没成交的(涨停/停牌/现金不足)、Tmin 保护暂留的, + * 全部来自 `signal_history`,不漏; + * 2. **数字只来自引擎**:`reason.data` 里的名次 / 综合分 / 因子原始值 / 持有交易日 + * 直接展示,前端不做任何推算(免得出现「看起来像真的」的数字); + * 3. **可核对**:原因分类(code)给中文短标签,点击筛选;理由原文可读。 + * + * 老归档(2026-10 之前)没有 `reason` 字段,退化为展示原有的 `reject_reason`, + * 并明确标注「旧归档无结构化理由」——不假装有数据。 + */ + +import { useMemo, useState } from "react"; + +import type { ActionRecord, TradeReason } from "@/lib/types"; +import { reasonLabel } from "@/lib/types"; +import { Card, Pill } from "@/components/ui"; +import { SymbolLink } from "@/lib/symbols"; + +/** 因子键 → 展示名(来自结果里的 factor_curves;取不到就用引擎键) */ +export type FactorLabels = Record; + +function fmtNum(v: number, digits = 2): string { + return v.toLocaleString("zh-CN", { maximumFractionDigits: digits }); +} + +/** + * 定点格式化,**与后端 `f"{v:.4f}"` 的舍入规则一致**(四舍六入五成双)。 + * + * 为什么不能用 `toFixed`:引擎理由原文里 0.03125 写成 `0.0312`(Python 是 bankers' + * rounding),而 JS `toFixed` 是「五入」→ `0.0313`。同一个综合分在「理由原文」和旁边的 + * 数字标签里显示成两个数,用户会合理地怀疑数据不一致 —— 这类不一致必须消掉。 + * + * 实现:先展开成**足够长的十进制**(40 位小数,覆盖 double 的有效位,避免「先按 + * digits+2 位舍入」制造出假的中点),再对这个十进制字符串做「五成双」舍入。 + */ +function fmtFixed(v: number, digits: number): string { + if (!Number.isFinite(v)) return String(v); + const neg = v < 0; + const s = Math.abs(v).toFixed(40); + const [intPart, fracPart = ""] = s.split("."); + const keep = fracPart.slice(0, digits).padEnd(digits, "0"); + const rest = fracPart.slice(digits); + const firstDropped = rest.length ? rest.charCodeAt(0) - 48 : 0; + const laterNonZero = /[1-9]/.test(rest.slice(1)); + const digitsArr = (intPart + keep).split(""); + const lastDigit = Number(digitsArr[digitsArr.length - 1]); + if (firstDropped > 5 || (firstDropped === 5 && (laterNonZero || lastDigit % 2 === 1))) { + let i = digitsArr.length - 1; + for (; i >= 0; i -= 1) { + const d = Number(digitsArr[i]) + 1; + if (d < 10) { + digitsArr[i] = String(d); + break; + } + digitsArr[i] = "0"; + } + if (i < 0) digitsArr.unshift("1"); + } + const all = digitsArr.join(""); + const intOut = all.slice(0, all.length - digits) || "0"; + const fracOut = digits ? all.slice(all.length - digits) : ""; + return `${neg ? "-" : ""}${intOut}${digits ? `.${fracOut}` : ""}`; +} + +/** 结构化理由里「用数据说话」的那几个数字(有才显示,没有不编) */ +function reasonFacts(reason: TradeReason, factorLabels: FactorLabels): string[] { + const d = reason.data ?? {}; + const out: string[] = []; + if (typeof d.rank === "number") { + out.push( + `综合分第 ${d.rank}${typeof d.total === "number" ? `/${d.total}` : ""} 名` + + (typeof d.top_n === "number" ? `(TopN=${d.top_n})` : "") + ); + } else if (typeof d.total === "number") { + out.push(`候选 ${d.total} 只(当日无该股分数)`); + } + if (typeof d.score === "number") out.push(`综合分 ${fmtFixed(d.score, 4)}`); + if (typeof d.hold_days === "number") { + out.push( + `持有 ${d.hold_days} 个交易日` + + (typeof d.tmin === "number" ? `(Tmin=${d.tmin})` : "") + + (typeof d.tmax === "number" ? `(Tmax=${d.tmax})` : "") + ); + } + if (typeof d.close_prev_ratio === "number") { + out.push( + `收盘/前收 = ${fmtFixed(d.close_prev_ratio, 3)}` + + (typeof d.limit_ratio === "number" ? `(阈值 ${fmtFixed(d.limit_ratio, 3)})` : "") + ); + } + if (typeof d.budget === "number") out.push(`可用预算 ${fmtNum(d.budget)} 元`); + if (typeof d.min_commission === "number") out.push(`最低佣金 ${fmtNum(d.min_commission)} 元`); + if (d.in_pool === false) out.push("已不在候选池(被股票池/条件过滤)"); + if (typeof d.return_pct === "number") out.push(`本笔收益 ${fmtFixed(d.return_pct, 2)}%`); + const factors = d.factors ?? {}; + for (const [key, value] of Object.entries(factors)) { + if (typeof value !== "number") continue; + out.push(`${factorLabels[key] ?? key} = ${fmtFixed(value, 4)}`); + } + return out; +} + +/** 单条理由:分类标签 + 理由原文 + 关键数字 */ +export function TradeReasonCell({ + reason, + fallback, + factorLabels = {}, +}: { + reason?: TradeReason | null; + fallback?: string | null; + factorLabels?: FactorLabels; +}) { + if (!reason) { + // 老归档没有结构化理由:如实标注,并退回执行层文案(不假装有数据) + return ( + + {fallback ? `${fallback}(旧归档无结构化理由)` : "—(旧归档无结构化理由)"} + + ); + } + const facts = reasonFacts(reason, factorLabels); + return ( +
+
+ {reasonLabel(reason.code)} + {reason.text} +
+ {facts.length ? ( +
+ {facts.map((f) => ( + + {f} + + ))} +
+ ) : null} +
+ ); +} + +type Side = "all" | "BUY" | "SELL"; +type Fill = "all" | "filled" | "unfilled"; + +/** + * 全部买卖点 + 理由(默认按日期倒序,最新的一笔在最上面)。 + * + * 为什么默认倒序:回测结果里「最近发生了什么」通常是最想看的;要按时间顺序读, + * 点表头「日期」即可切换。 + */ +export function TradeReasonsCard({ + signals, + factorLabels = {}, +}: { + signals: ActionRecord[]; + factorLabels?: FactorLabels; +}) { + const [side, setSide] = useState("all"); + const [fill, setFill] = useState("all"); + const [q, setQ] = useState(""); + const [desc, setDesc] = useState(true); + + const rows = useMemo(() => { + const needle = q.trim().toLowerCase(); + const out = signals.filter((a) => { + if (side !== "all" && a.signal !== side) return false; + if (fill === "filled" && !a.filled) return false; + if (fill === "unfilled" && a.filled) return false; + if (!needle) return true; + return ( + a.symbol.toLowerCase().includes(needle) || + (a.name ?? "").toLowerCase().includes(needle) || + (a.reason?.text ?? "").toLowerCase().includes(needle) + ); + }); + out.sort((a, b) => (a.date === b.date ? a.symbol.localeCompare(b.symbol) : a.date < b.date ? -1 : 1)); + return desc ? out.reverse() : out; + }, [signals, side, fill, q, desc]); + + const filled = signals.filter((a) => a.filled).length; + const withReason = signals.filter((a) => a.reason).length; + + if (!signals.length) return null; + + return ( + + 已成交 {filled} + 未成交 {signals.length - filled} + + {withReason}/{signals.length} 条带结构化理由 + +
+ } + > +
+
+ {( + [ + ["all", "全部"], + ["BUY", "买入"], + ["SELL", "卖出"], + ] as [Side, string][] + ).map(([v, label]) => ( + + ))} +
+
+ {( + [ + ["all", "不限成交"], + ["filled", "只看成交"], + ["unfilled", "只看未成交"], + ] as [Fill, string][] + ).map(([v, label]) => ( + + ))} +
+ setQ(e.target.value)} + aria-label="搜索买卖点" + /> + {rows.length} 条 +
+ +
+ + + + + + + + + + + + {rows.map((a) => ( + + + + + + + + ))} + +
+ + 方向股票价格买卖理由(引擎给出的当时数字)
{a.date} + {a.filled ? ( + + {a.signal === "BUY" ? "买入" : "卖出"} + + ) : ( + + {a.signal === "BUY" ? "想买未成" : "想卖未成"} + + )} + + + {a.price != null ? fmtFixed(a.price, 2) : "—"} + +
+
+ +
+ 「想买未成 / 想卖未成」= 策略当天确实要下单,但被涨停、跌停、停牌或现金挡住 + (执行层原因在理由里写清)。名次、综合分、因子值、持有交易日都取自**引擎当时的计算**, + 界面不做二次推算;因子值是原始值(未做 z-score、不按方向取反)。 + {withReason < signals.length + ? ` 有 ${signals.length - withReason} 条来自 2026-10 之前的旧归档,当时还没有结构化理由。` + : ""} +
+ + ); +} \ No newline at end of file diff --git a/frontend/web/components/charts/LwChart.tsx b/frontend/web/components/charts/LwChart.tsx index e1843d8..b5c7c4f 100644 --- a/frontend/web/components/charts/LwChart.tsx +++ b/frontend/web/components/charts/LwChart.tsx @@ -62,8 +62,17 @@ export interface LwChartProps { series: LwSeries[]; markers?: LwMarker[]; height?: number; - /** 悬浮提示与价格轴的数值格式 */ + /** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */ valueFormat?: (v: number) => string; + /** + * 数值格式的**可序列化标识**。 + * + * 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat` + * 会直接 500:「Functions cannot be passed directly to Client Components」)。 + * 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在 + * 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。 + */ + formatKey?: LwFormatKey; /** 画一条 0 基准虚线(收益率曲线推荐开启) */ zeroLine?: boolean; legend?: boolean; @@ -73,6 +82,27 @@ export interface LwChartProps { type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">; +/** 数值格式的可序列化标识(Server Component ↔ Client Component 的桥) */ +export type LwFormatKey = + | "num" + | "num0" + | "pct2" + | "pct3" + | "times3" + | "auto4" + /** 原值为小数、按百分数显示(0.15 → 15.00%) */ + | "frac-pct"; + +export const FORMATTERS: Record string> = { + num: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }), + num0: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }), + pct2: (v) => `${v.toFixed(2)}%`, + pct3: (v) => `${v.toFixed(3)}%`, + times3: (v) => `${v.toFixed(3)}×`, + auto4: (v) => v.toFixed(4), + "frac-pct": (v) => `${(v * 100).toFixed(2)}%`, +}; + function toTime(t: string): Time { return t as Time; } @@ -102,6 +132,7 @@ export function LwChart({ markers = [], height = 320, valueFormat, + formatKey, zeroLine = false, legend = true, ariaLabel, @@ -114,8 +145,9 @@ export function LwChart({ const zeroLineDrawn = useRef(false); /** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */ const hiddenRef = useRef>(new Set()); - const fmtRef = useRef(valueFormat); - fmtRef.current = valueFormat; + const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined); + const fmtRef = useRef(resolvedFormat); + fmtRef.current = resolvedFormat; const [hidden, setHidden] = useState>(new Set()); const [tip, setTip] = useState<{ diff --git a/frontend/web/lib/chartSeries.ts b/frontend/web/lib/chartSeries.ts new file mode 100644 index 0000000..5e026a6 --- /dev/null +++ b/frontend/web/lib/chartSeries.ts @@ -0,0 +1,152 @@ +/** + * 回测结果曲线 → 图表序列的**唯一构造处**。 + * + * 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、 + * 以及「新页面放大」的 `/charts/{id}`。三处各写一份格式化逻辑,迟早出现 + * 「同一张图两个页面数值口径不一样」。这里把每类曲线的取数、颜色、数值格式、 + * 买卖点标注统一成函数,页面只管摆放。 + */ + +import type { LwFormatKey, LwSeries, LwMarker } from "@/components/charts/LwChart"; +import { CHART, fmtNum } from "@/components/charts/theme"; +import type { ActionRecord, BacktestResult, FactorCurve, SymbolCurve } from "@/lib/types"; + +/** 因子值的量纲说明与人读格式("%" / "倍数" / "小数",None = 无量纲) */ +export function factorValueFormat(curve: FactorCurve): (v: number) => string { + if (curve.unit === "%") return (v) => `${v.toFixed(3)}%`; + if (curve.unit === "倍数") return (v) => `${v.toFixed(3)}×`; + // 小数:原值 0.15 = 15% —— 图上按百分数显示更好读,但标签里会注明「原值为小数」 + if (curve.unit === "小数") return (v) => `${(v * 100).toFixed(2)}%`; + return (v) => v.toFixed(4); +} + +/** + * 因子曲线的格式标识(给 Server Component 用)。 + * + * 与 `factorValueFormat` 必须一致:两处定义同一件事会漂移,因此这里直接按 unit 分支, + * 且在单测/自检里比对两者的输出。 + */ +export function factorFormatKey(curve: FactorCurve): LwFormatKey { + if (curve.unit === "%") return "pct3"; + if (curve.unit === "倍数") return "times3"; + if (curve.unit === "小数") return "frac-pct"; + return "auto4"; +} + +/** 因子曲线口径的完整说明(图上必须写,避免把「持仓加权平均」读成别的口径) */ +export function factorCurveNote(curve: FactorCurve): string { + const unitNote = + curve.unit === "小数" + ? "原值为小数(0.15 即 15%),图上按百分数显示" + : curve.unit + ? `单位:${curve.unit}` + : "无量纲"; + const dir = curve.direction === "lower_is_better" ? "越低越好" : "越高越好"; + return ( + `口径:每个交易日**当日持仓按市值加权平均**的因子原始值(不做 z-score、不按方向取反),` + + `空仓日不落点。${unitNote};方向 ${dir}。` + ); +} + +/** 因子曲线序列 */ +export function factorSeries(curve: FactorCurve, index = 0): LwSeries { + const colors = [CHART.accent, CHART.violet, CHART.pos, CHART.neg, CHART.amber]; + return { + key: `factor-${curve.name}`, + label: `${curve.label}(持仓加权)`, + type: "line", + lineWidth: 2, + color: colors[index % colors.length], + data: curve.points.map((p) => ({ time: p.date, value: p.value })), + lastValueVisible: true, + }; +} + +/** 同一日多次成交合并成一个标记(图上不叠字) */ +export function portfolioMarkers( + fills: ActionRecord[] | undefined, + validDates?: Set +): LwMarker[] { + const byDate = new Map(); + for (const f of fills ?? []) { + if (validDates && !validDates.has(f.date)) continue; + const cur = byDate.get(f.date) ?? { BUY: false, SELL: false }; + cur[f.signal] = true; + byDate.set(f.date, cur); + } + const out: LwMarker[] = []; + for (const [time, kinds] of byDate) { + if (kinds.BUY) out.push({ time, kind: "BUY", text: "买" }); + if (kinds.SELL) out.push({ time, kind: "SELL", text: "卖" }); + } + return out.sort((a, b) => (a.time < b.time ? -1 : 1)); +} + +export function equitySeries(result: BacktestResult): LwSeries[] { + return [ + { + key: "equity", + label: "组合净值(元)", + type: "area", + color: CHART.pos, + data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })), + lastValueVisible: true, + }, + ]; +} + +export function drawdownSeries(result: BacktestResult): LwSeries[] { + return [ + { + key: "dd", + label: "回撤(%)", + type: "area", + color: CHART.neg, + data: result.drawdown.map((p) => ({ time: p.date, value: p.value })), + lastValueVisible: true, + }, + ]; +} + +export function symbolSeries(curve: SymbolCurve): LwSeries[] { + return [ + { + key: "sym", + label: `${curve.symbol} 持仓期累计收益(%)`, + type: "area", + color: CHART.accent, + data: curve.points.map((p) => ({ time: p.date, value: p.value })), + lastValueVisible: true, + }, + ]; +} + +export function symbolMarkers(curve: SymbolCurve): LwMarker[] { + const dates = new Set(curve.points.map((p) => p.date)); + return (curve.marks ?? []) + .filter((a) => dates.has(a.date)) + .map((a) => ({ + time: a.date, + kind: a.signal, + text: a.signal === "BUY" ? "买" : "卖", + })); +} + +/** 月度收益(柱状) */ +export function monthlySeries(result: BacktestResult): LwSeries[] { + return [ + { + key: "monthly", + label: "月度收益(%)", + type: "bar", + color: CHART.accent, + data: result.monthly_returns.map((m) => ({ + time: `${m.year}-${String(m.month).padStart(2, "0")}-01`, + value: m.return_pct, + })), + lastValueVisible: true, + }, + ]; +} + +export const equityValueFormat = (v: number) => fmtNum(v); \ No newline at end of file diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index 75e7382..85f03d2 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -191,6 +191,59 @@ export interface MarkPoint { } /** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */ +/** + * 一次交易意图 / 成交的**结构化理由**(引擎给出的真实数字)。 + * + * `code` 是封闭的原因分类(后端 `quant/trade_reasons.py`),前端据此筛选, + * 不去解析 `text`;`data` 里的每个数字都来自引擎当时的计算 —— 界面只展示,不推算。 + */ +export interface TradeReason { + code: string; + text: string; + data: { + rank?: number; + total?: number; + top_n?: number; + score?: number; + /** 各因子当时的**原始值**(key = 因子引擎键,可能是参数化键) */ + factors?: Record; + hold_days?: number; + tmin?: number; + tmax?: number; + price?: number; + budget?: number; + min_commission?: number; + close?: number; + prev_close?: number; + close_prev_ratio?: number; + limit_ratio?: number; + return_pct?: number; + /** false = 该股已不在候选池(被股票池/条件过滤) */ + in_pool?: boolean; + [k: string]: unknown; + }; +} + +/** 原因分类的中文短标签(与后端 REASON_LABELS 对齐) */ +export const REASON_LABELS: Record = { + buy_enter_topn: "按名次建仓", + 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: "跌停未卖", +}; + +export function reasonLabel(code?: string | null): string { + if (!code) return "—"; + return REASON_LABELS[code] ?? code; +} + export interface ActionRecord { date: string; symbol: string; @@ -200,6 +253,17 @@ export interface ActionRecord { filled: boolean; reject_reason?: string | null; price?: number | null; + reason?: TradeReason | null; +} + +/** 单个因子的时间序列(回测期内持仓组合加权平均的**原始值**) */ +export interface FactorCurve { + name: string; + label: string; + direction: string; + /** 量纲:% / 倍数 / 小数(小数意味着 0.15 = 15%,图上不换算) */ + unit?: string | null; + points: CurvePoint[]; } /** 个股收益率曲线 + 该股买卖点标注 */ @@ -219,11 +283,23 @@ export interface BacktestResult { monthly_returns: { year: number; month: number; return_pct: number }[]; yearly_returns: { year: number; return_pct: number }[]; positions: { date: string; symbol: string; name?: string | null; weight: number }[]; - trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[]; + trades: { + entry_date: string; + exit_date: string; + symbol: string; + name?: string | null; + entry_price?: number; + exit_price?: number; + return_pct: number; + entry_reason?: TradeReason | null; + exit_reason?: TradeReason | null; + }[]; selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[]; signal_history?: ActionRecord[]; fills?: ActionRecord[]; symbol_curves?: SymbolCurve[]; + /** 策略用到的每个因子的时间序列(持仓加权平均原始值):解释买卖依据 */ + factor_curves?: FactorCurve[]; turnover_pct: number; unimplemented: string[]; config_snapshot: Record; diff --git a/scripts/verify_backtest_page_contract.py b/scripts/verify_backtest_page_contract.py index a313790..6e9d86c 100644 --- a/scripts/verify_backtest_page_contract.py +++ b/scripts/verify_backtest_page_contract.py @@ -93,7 +93,7 @@ def main() -> int: required = [ "summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns", "positions", "trades", "selection_history", "signal_history", "fills", - "symbol_curves", "turnover_pct", "unimplemented", "config_snapshot", + "symbol_curves", "factor_curves", "turnover_pct", "unimplemented", "config_snapshot", ] missing = [k for k in required if k not in res] assert not missing, f"结果缺少页面读取的字段:{missing}" @@ -139,6 +139,59 @@ def main() -> int: print(f"[contract] config_snapshot.selection={sel}") print(f"[contract] config_snapshot.price_basis={basis}") + # —— 买卖理由(「用数据说话」的字段契约)—— + # 每个买卖点都必须带结构化理由,且关键数字(名次/综合分/因子值)齐全; + # 文案由后端词表生成,前端只展示 —— 这里断言的是「页面要读的字段真的在」。 + KNOWN = { + "buy_enter_topn", "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", + } + missing_reason = [a for a in res["signal_history"] if not a.get("reason")] + assert not missing_reason, f"有买卖点没有理由:{missing_reason[:3]}" + bad_code = [a["reason"]["code"] for a in res["signal_history"] if a["reason"]["code"] not in KNOWN] + assert not bad_code, f"出现词表外的理由代码:{sorted(set(bad_code))}" + for a in res["signal_history"]: + r = a["reason"] + assert r["text"], f"{a['date']} {a['symbol']} 理由文案为空" + assert isinstance(r["data"], dict), f"{a['date']} {a['symbol']} 理由缺少结构化数据" + # 成交类理由必须能回答「第几名 / 多少候选 / 综合分 / 因子当时的值」 + with_rank = [a for a in res["signal_history"] if isinstance(a["reason"]["data"].get("rank"), int)] + assert with_rank, "没有任何理由给出名次(名次是买入选股的核心依据)" + sample = next(a for a in res["signal_history"] if a["reason"]["code"] == "buy_enter_topn") + sd = sample["reason"]["data"] + assert sd["rank"] >= 1 and sd["total"] >= sd["rank"] and sd["top_n"] >= 1, sd + assert "score" in sd and sd.get("factors"), f"买入理由缺少综合分/因子值:{sd}" + print(f"[contract] 买卖理由 {len(res['signal_history'])} 条全部带理由与数字;样例 " + f"{sample['date']} {sample['symbol']} {sample['reason']['code']} " + f"rank={sd['rank']}/{sd['total']} score={sd['score']} factors={sd['factors']}") + + # —— 成交明细两端的理由(页面「为什么买 / 为什么卖」两列)—— + trades = res["trades"] + no_entry = [t for t in trades if not t.get("entry_reason")] + no_exit = [t for t in trades if not t.get("exit_reason")] + assert not no_entry, f"{len(no_entry)} 笔成交缺建仓理由" + assert not no_exit, f"{len(no_exit)} 笔成交缺卖出理由" + t0_ = trades[0] + print(f"[contract] 成交明细两端理由齐全({len(trades)} 笔);样例 {t0_['symbol']} " + f"买={t0_['entry_reason']['code']} 卖={t0_['exit_reason']['code']}") + + # —— 因子曲线(页面「因子曲线」区块 + 放大页的数据源)—— + fcs = res["factor_curves"] + assert fcs, "结果里没有因子曲线(页面会少一整块「买卖依据的水平」)" + for fc in fcs: + assert fc["name"] and fc["label"] and fc["direction"], fc + assert fc["points"], f"因子 {fc['name']} 曲线无数据点" + dates = [p_["date"] for p_ in fc["points"]] + assert dates == sorted(dates), f"因子 {fc['name']} 曲线日期未按时间升序" + assert len(set(dates)) == len(dates), f"因子 {fc['name']} 曲线有重复日期" + used = {f["name"] for f in spec["factors"]} | {"dividend_yield"} + names = {fc["name"] for fc in fcs} + assert names & used, f"因子曲线与本次策略用到的因子对不上:{names} vs {used}" + fc = fcs[0] + print(f"[contract] 因子曲线 {len(fcs)} 条;首条 {fc['name']}({fc['label']}," + f"单位 {fc.get('unit')},{len(fc['points'])} 点,方向 {fc['direction']})") + # —— 未成交意图卡片(signal_history 中 filled=False 且带原因)—— rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]] print(f"[contract] 未成交意图 {len(rejects)} 条,样例:"