Compare commits
12
Commits
36fe018075
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d53c4d3ca9 | ||
|
|
a395db892d | ||
|
|
e58367af27 | ||
|
|
c974415691 | ||
|
|
57d6082f91 | ||
|
|
a3055eff5d | ||
|
|
2a88ca6076 | ||
|
|
bb48c91853 | ||
|
|
e8fa67ad40 | ||
|
|
7e369d9680 | ||
|
|
633176a3d1 | ||
|
|
48a97c2a12 |
@@ -817,6 +817,54 @@ factor_exposure
|
||||
|
||||
前端组件只依赖这个结构。
|
||||
|
||||
## 27.1 买卖理由:必须能回答「为什么买 / 为什么卖」,且数字来自引擎
|
||||
|
||||
用户看回测结果时的第一个问题不是「赚了多少」,而是「**为什么在这里买 / 卖**」。
|
||||
因此每个买卖点(**成交的与未成交的都要**)必须带结构化理由
|
||||
(`TradeReason{code, text, data}`,见 `backend/app/quant/trade_reasons.py`):
|
||||
|
||||
- **原因分类是封闭词表**:组合引擎(`combo_engine.py`)与单策略引擎(`local_engine.py`)
|
||||
共用同一套 code 与文案构造器,禁止各自手写措辞 —— 否则同一件事会出现两种说法。
|
||||
- **`data` 里只能是引擎当时的真实数字**(名次 / 候选数 / 综合分 / 各因子**原始值** /
|
||||
持有交易日 / 预算 / 涨停比值…)。前端**只展示不推算**;拿不到名次就写「未给出名次」,
|
||||
不拿旧名次或其他日期的数据冒充。
|
||||
- **把事实说准**:跌出 TopN ≠ 不在候选池(被股票池/条件过滤)≠ 全量换仓
|
||||
(策略每次调仓先清仓,被卖的股票可能仍排在前列)≠ Tmin 保护暂留 ≠ 超 Tmax 强制了结
|
||||
≠ 涨停/跌停/停牌/现金不足。宁可为一种情况新增一个 code,也不要套一个语义不符的旧 code。
|
||||
- **因子曲线**(`result.factor_curves`)= 当日**持仓按市值加权平均的原始值**
|
||||
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),
|
||||
界面必须同时写出方向与单位(如「股息率 %,越高越好」),否则读者会误判曲线的含义。
|
||||
- **每条曲线都要能新页面放大**(`/charts/{归档id}?s=...`):放大页从**归档**读同一份数据
|
||||
(URL 可分享、口径不漂移);没有归档 id 时如实说明「未归档,无法放大」,不给坏链接。
|
||||
|
||||
## 27.2 长任务反馈:点了立刻有字、看得出在动、出事了能自救
|
||||
|
||||
用户原话:「点了回测没有任何反馈,不清楚是不是已经开始」。因此跑异步 Job 的页面必须:
|
||||
|
||||
- 提交**瞬间**就有反馈(提交中 → 排队中),不等到后端返回;
|
||||
- 显示**真实**作业号 / 阶段 / 逐秒自增的已用时间(用 `lib/jobs.ts` 的 `useJobRunner`
|
||||
+ `components/JobProgress.tsx`,不要各页手写一套);
|
||||
- 排队/运行中可**取消**;失败显示后端原文;成功给「打开归档 / 去对比」入口;
|
||||
- 反馈条出现时**自动滚入视野**,并带 `role="status" aria-live="polite"`;
|
||||
- 同步接口(如 `POST /api/signals`)**没有**作业号与阶段时,如实说明「同步请求、无阶段、
|
||||
不可取消」,**禁止**合成假作业号或假进度喂给反馈组件。
|
||||
|
||||
## 27.3 宽表:两端固定 + 按需提示,绝不"右侧被切掉"
|
||||
|
||||
用户原话:「experiments 页面右侧内容溢出了」。实测原因不是整页溢出,而是**卡片内的横向滚动**:
|
||||
在 macOS 上覆盖式滚动条不滚动就不显示,于是看起来就是内容被切掉、也没有滚动条可拉。因此宽表:
|
||||
|
||||
- 列数 ≥ 9 或列宽会随数据增长的表(实验列表 `.tbl--wide`、对比表 `.tbl--pin-first`、
|
||||
月度收益表 `.tbl--monthly`)必须**固定首列**;操作列在右端时用 `.tbl--wide` 固定右端,
|
||||
保证任何窗口宽度下「看的是哪一行」和「能点哪里」都在视野内;
|
||||
- 固定列必须有不透明底色(卡片是渐变,取近似纯色)并覆盖 `:hover` / `.row-active`,
|
||||
否则中间列会从下面透出来、整行高亮在两端断开;
|
||||
- 列宽按**实测单行内容宽度**定(用 CDP 量 `scrollWidth` 与单元格内容宽度),不要凭感觉写,
|
||||
并且**不要在 JSX 里写行内 `width`**——它会盖掉 CSS(曾让「操作」列多占 88px,整表放不下);
|
||||
- 「可横向滚动」提示只在实测 `scrollWidth > clientWidth` 时出现(`useTableScrollHint`),
|
||||
窗口够宽时不显示废话;
|
||||
- 门禁:`python3 scripts/verify_ui_alignment.py`(160 项)必须 0 失败,含 375px 与 1500px 两档。
|
||||
|
||||
---
|
||||
|
||||
# 28. AI Agent 约束
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。"""
|
||||
"""Experiment API(Phase 4):列表 / 详情 / 删除(单个 + 批量)/ 一键复跑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
|
||||
from app.application.services.job_executor import new_id, run_job_background
|
||||
@@ -25,6 +26,20 @@ router = APIRouter(prefix="/experiments", tags=["experiments"])
|
||||
LIST_DEFAULT_LIMIT = 200
|
||||
LIST_MAX_LIMIT = 1000
|
||||
|
||||
# 单次批量删除的 id 上限:够覆盖列表一页(默认 200 条),又不至于让一次请求
|
||||
# 把整张表拖进内存。超了直接 422(附上限值),不静默截断成前 N 个。
|
||||
BULK_DELETE_MAX_IDS = 200
|
||||
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
"""批量删除请求体(**必填** id 列表,1~200 个)。"""
|
||||
|
||||
ids: list[str] = Field(
|
||||
min_length=1,
|
||||
max_length=BULK_DELETE_MAX_IDS,
|
||||
description=f"要删除的归档 id,1~{BULK_DELETE_MAX_IDS} 个(重复 id 自动去重)",
|
||||
)
|
||||
|
||||
|
||||
def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
|
||||
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
|
||||
@@ -85,6 +100,29 @@ def list_experiments(
|
||||
return [_experiment_meta(e) for e in rows]
|
||||
|
||||
|
||||
@router.post("/bulk-delete", summary=f"批量删除归档(1~{BULK_DELETE_MAX_IDS} 个,逐个回报)")
|
||||
def bulk_delete_experiments(
|
||||
body: BulkDeleteRequest,
|
||||
session: DbSession,
|
||||
experiment_repo: ExperimentRepoDep,
|
||||
) -> dict:
|
||||
"""批量删除归档,返回 `{"deleted": [...], "missing": [...], "count": n}`。
|
||||
|
||||
语义与单个删除完全一致(只删 experiment 行,job 历史保留),另外:
|
||||
- **重复 id 先按出现顺序去重**(同一个 id 报两次没有意义,也不该算两次成功);
|
||||
- **不静默跳过**:库里没有的 id 单独放进 `missing`,让界面能如实说
|
||||
「删了 3 个,2 个没找到(可能已被别处删掉)」——把缺失当成功会更难排查;
|
||||
- 一次请求内的 id 上限 `BULK_DELETE_MAX_IDS`,超了 Pydantic 直接 422 并带上限值。
|
||||
"""
|
||||
ids = list(dict.fromkeys(body.ids))
|
||||
deleted: list[str] = []
|
||||
missing: list[str] = []
|
||||
for experiment_id in ids:
|
||||
(deleted if experiment_repo.delete(experiment_id) else missing).append(experiment_id)
|
||||
session.commit()
|
||||
return {"deleted": deleted, "missing": missing, "count": len(deleted)}
|
||||
|
||||
|
||||
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
|
||||
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
|
||||
exp = experiment_repo.get(experiment_id)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
@@ -144,6 +173,11 @@ _UNIMPLEMENTED_BASE = [
|
||||
"多策略打分采用 Borda 秩和(各策略 1/名次 求和):不假设不同策略的因子分值可比,"
|
||||
"但极端情况下某策略覆盖极少股票会使其秩和贡献偏大"
|
||||
),
|
||||
(
|
||||
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
|
||||
"但「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
|
||||
"要看完整候选与名次请查选股明细(selection_history)"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -158,6 +192,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 +201,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 +269,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 +285,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 +296,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 +352,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 +394,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 +408,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 +454,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 +502,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 +535,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 +659,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 +712,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):组合参数 + 当时各策略定义 + 当时成本/复权
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}),),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
@@ -728,6 +927,13 @@ class TopKBacktestRunner:
|
||||
|
||||
def _unimplemented(self, curve_note: str | None = None) -> list[str]:
|
||||
notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio)
|
||||
# 如实说明买卖理由的覆盖边界:每个买卖点(含涨停/停牌/现金不足等未成交)都有理由,
|
||||
# 但「排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点(看选股明细即可)。
|
||||
notes.append(
|
||||
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
|
||||
"「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
|
||||
"要看完整候选与名次请查选股明细(selection_history)"
|
||||
)
|
||||
if self._no_prev_close_symbols:
|
||||
notes.append(
|
||||
f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价,"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
"""买卖理由(数据化)与因子曲线的共享构造器。
|
||||
|
||||
**为什么单独一个模块**:两套回测引擎(`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"
|
||||
# 卖出(成交):策略在调仓日**全量换仓**(先清仓再建仓),该股当时仍在 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"
|
||||
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_REBALANCE_FULL,
|
||||
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_REBALANCE_FULL: "调仓换仓卖出",
|
||||
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 都分开写。
|
||||
|
||||
`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:
|
||||
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
|
||||
@@ -6,9 +6,11 @@ import sys
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
from app.api.experiments import BULK_DELETE_MAX_IDS
|
||||
from app.application.services import job_executor as je
|
||||
from app.application.services.job_executor import execute_job
|
||||
from app.domain.entities.research import (
|
||||
ExperimentRecord,
|
||||
JobRecord,
|
||||
JobStatus,
|
||||
ResearchSpec,
|
||||
@@ -266,6 +268,53 @@ class TestJobsApi:
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/jobs/JOB-NOPE").status_code == 404
|
||||
|
||||
def test_bulk_delete_reports_deleted_and_missing(self, seeded_api_db) -> None:
|
||||
"""批量删除:成功 / 库里没有的 id **分开回报**,且真的从列表里消失。
|
||||
|
||||
为什么坚持分开回报:把「没找到」也算成功,界面上就会显示「已删除 4 个」
|
||||
而实际只删了 2 个 —— 用户下次刷新发现还在,却查不出原因。
|
||||
"""
|
||||
spec_json = _spec().model_dump_json()
|
||||
with sess_mod.SessionLocal() as session:
|
||||
repo = SqlAlchemyExperimentRepository(session)
|
||||
for eid in ("EXP-A", "EXP-B"):
|
||||
repo.save(
|
||||
ExperimentRecord(
|
||||
id=eid,
|
||||
kind="backtest",
|
||||
spec_json=spec_json,
|
||||
result_json="{}",
|
||||
summary_text="自检用",
|
||||
created_at=datetime(2026, 10, 1, 10, 0, 0),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 重复 id 只算一次(EXP-A 出现两次)
|
||||
resp = client.post(
|
||||
"/api/experiments/bulk-delete",
|
||||
json={"ids": ["EXP-A", "EXP-B", "EXP-A", "EXP-NOPE"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] == ["EXP-A", "EXP-B"]
|
||||
assert body["missing"] == ["EXP-NOPE"]
|
||||
assert body["count"] == 2
|
||||
left = {e["id"] for e in client.get("/api/experiments").json()}
|
||||
assert not ({"EXP-A", "EXP-B"} & left)
|
||||
|
||||
def test_bulk_delete_validates_ids(self, seeded_api_db) -> None:
|
||||
"""空列表 / 超上限 / 缺字段一律 422(不静默截断成前 N 个)。"""
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/api/experiments/bulk-delete", json={"ids": []}).status_code == 422
|
||||
assert client.post("/api/experiments/bulk-delete", json={}).status_code == 422
|
||||
too_many = [f"EXP-{i}" for i in range(BULK_DELETE_MAX_IDS + 1)]
|
||||
resp = client.post("/api/experiments/bulk-delete", json={"ids": too_many})
|
||||
assert resp.status_code == 422
|
||||
# 错误里必须写清上限,用户才知道要分几批
|
||||
assert str(BULK_DELETE_MAX_IDS) in resp.text
|
||||
|
||||
def test_experiments_list_and_detail(self, seeded_api_db) -> None:
|
||||
with TestClient(app) as client:
|
||||
# 先跑一个 job 生成归档,再验证列表与详情
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+50
-4
@@ -508,6 +508,40 @@ PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job \
|
||||
**归档完整度会明示**:归档页顶部标注「归档完整 N/N」;若曲线因体积预算被裁剪,或该快照产生于
|
||||
完整存档上线之前(个股曲线只有 60 只),页面直接显示「**归档不完整**」并给出差异与「以此参数重跑」入口。
|
||||
|
||||
> **买卖理由 + 因子曲线 + 曲线放大**(2026-10):回测结果不再只给「成交了哪些」,
|
||||
> 而是回答「**为什么买 / 为什么卖**」,且数字全部来自引擎当时的计算:
|
||||
> - `ActionRecord.reason`(`TradeReason{code,text,data}`)覆盖**成交与未成交**的每个买卖点:
|
||||
> 名次 / 候选数 / 综合分 / 各因子**原始值** / 持有交易日 / 现金预算 / 涨停比值…;
|
||||
> 原因分类是封闭词表(`quant/trade_reasons.py`),组合引擎与单策略引擎共用同一套构造器,
|
||||
> 两个引擎对同一件事不会写出两种说法。
|
||||
> - 区分「**跌出 TopN**」「**不在候选池**(被股票池/条件过滤,如转 ST)」「**全量换仓**」
|
||||
> (单策略引擎每次调仓先清仓再建仓,被卖出的股票可能仍排在 TopN 内,这时不能写「跌出 TopN」)
|
||||
> 「**Tmin 保护暂留**」「**超 Tmax 强制了结**」「涨停/停牌/现金不足/不足最低佣金」。
|
||||
> - `result.factor_curves`:每个策略因子一条曲线 = 当日**持仓按市值加权平均的原始值**
|
||||
> (不做 z-score、不按方向取反,**空仓日不落点、不插值、不用 0 填充**),带
|
||||
> `label / direction / unit`,界面据此写明口径(如「股息率 %」。收益曲线对照成交日,
|
||||
> 一眼看出「买在什么水平、卖在什么水平」)。
|
||||
> - **每条曲线都能新页面放大**:`/charts/{归档id}?s={equity|drawdown|factor:<name>|sym:<code>|monthly}`。
|
||||
> 放大页是 Server Component,数据从**归档**直出(URL 可分享、刷新还原同一张图);
|
||||
> 结果没归档时不显示假按钮,而是写明「未归档,无法放大」。
|
||||
> 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双),避免同一个综合分在理由原文与
|
||||
> 数字标签里显示成两个数。
|
||||
|
||||
> **作业反馈(全站统一)**(2026-10):`lib/jobs.ts` 的 `useJobRunner` + `components/JobProgress.tsx`。
|
||||
> 解决的问题是用户原话「点了回测没有任何反馈,不清楚是不是已经开始」:
|
||||
> **提交瞬间**就显示「排队中 + 作业号 + 发起时间」,**已用时间每秒自增**(不等后端报新阶段),
|
||||
> 阶段取后端真实 `stage`,排队/运行中可**取消任务**(`POST /jobs/{id}/cancel`),
|
||||
> 失败显示后端原文、成功给「打开归档 / 去对比」;反馈条出现时自动滚入视野
|
||||
> (运行按钮常在长表单底部,不滚过去等于没显示),并带 `role="status" aria-live="polite"`。
|
||||
> 各页多个作业互不干扰(各自 `anchorId`)。
|
||||
> 阶段小圆点**按作业类型分别定义**(`STAGE_PIPELINES`):因子测试是
|
||||
> 「加载行情与因子数据 → 计算因子值 → 汇总指标与曲线」,回测/回测组合是
|
||||
> 「加载行情与因子数据 → 撮合与净值结算 → 汇总指标与曲线」,异步选股只有
|
||||
> 「逐择股日选股」一段 —— 后端没上报的阶段不会画成「○ 待开始」(那是看似有数据的假象),
|
||||
> 后端新阶段的文案没跟上时也不会露出裸英文枚举(显示「执行中(stage)」并保留已推进的圆点)。
|
||||
> `POST /api/signals` 是**同步**接口,页面如实写明「同步请求、没有作业号与阶段、不能取消」,
|
||||
> 而不是给它编一个作业号。
|
||||
|
||||
**图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`),
|
||||
ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上,
|
||||
并按时间升序提交(Lightweight Charts 的硬约束),因此不会出现标记丢失或错位。
|
||||
@@ -524,6 +558,16 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
|
||||
# 只验接口与页面(跳过回测 Job):加 --skip-job
|
||||
```
|
||||
|
||||
**回测结果契约**(跑一次真实区间,约 3 分钟;校验页面实际读取的每个字段):
|
||||
|
||||
```bash
|
||||
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py --end 2023-12-31
|
||||
```
|
||||
|
||||
它除了净值/个股曲线/买卖点落点,还会断言:**每个买卖点都有结构化理由且原因代码在词表内**、
|
||||
成交类理由带名次/候选数/综合分/因子原始值、`trades` 两端理由齐全、`factor_curves` 非空且
|
||||
日期升序无重复 —— 也就是「买了什么原因、图上的因子曲线」这些字段真的在。
|
||||
|
||||
#### 6.3.2 界面规范与对齐自检(控件尺寸 / 输入友好)
|
||||
|
||||
界面不是"能看就行":控件错位、尺寸与内容不匹配、点不中、报错说不清,都会直接变成操作错误。
|
||||
@@ -625,12 +669,12 @@ Agent 能力边界(10 个内置受控工具,只读 + 受控写库):
|
||||
```bash
|
||||
cd backend
|
||||
uv run ruff check app tests && uv run ruff format --check app tests
|
||||
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 500 条)
|
||||
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 524 条)
|
||||
|
||||
cd frontend/web
|
||||
pnpm run typecheck # tsc --noEmit,0 error
|
||||
pnpm run test:charts # 图表标记逻辑单测(7 条,node --test,无需额外依赖)
|
||||
pnpm run build # 13 条路由,含 /strategies /backtest /experiments /experiments/[id]
|
||||
pnpm run build # 路由含 /strategies /backtest /experiments /experiments/[id] /charts/[id]
|
||||
```
|
||||
|
||||
**端到端契约脚本**(会真实提交回测 Job,用于验证「页面实现」与「后端字段」不漂移):
|
||||
@@ -638,7 +682,7 @@ pnpm run build # 13 条路由,含 /strategies /backtest /e
|
||||
```bash
|
||||
cd backend
|
||||
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 选股策略/字段库/因子目录+参数化/公共配置/回测组合库/归档链路(145 项 skip-job,含真实组合回测更多,约 5 分钟)
|
||||
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
|
||||
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约:字段 + 买卖理由词表/名次/因子值 + 因子曲线(约 3 分钟)
|
||||
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(8 个页面 × 2 宽度 = 160 项,约 3 分钟)
|
||||
python3 ../scripts/verify_unit_conversion.py # 字段库单位:只能在给定范围内选 + 界面单位⇄基准单位换算(约 2 分钟)
|
||||
python3 ../scripts/verify_factor_params.py # 因子参数化:暴露真实参数/界面新建参数化因子/越界拒绝/停用不影响历史解析(约 3 分钟)
|
||||
@@ -651,7 +695,9 @@ python3 ../scripts/verify_factor_params.py # 因
|
||||
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
|
||||
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、
|
||||
**UI 对齐与控件一致性**(同排等高 / 尺寸归一 / 点击目标 / 标签 / 无障碍名 / 横向溢出,
|
||||
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)。
|
||||
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)、**买卖理由与因子曲线**
|
||||
(理由词表封闭、数字来自引擎、因子曲线持仓市值加权且空仓不落点,见
|
||||
`tests/test_trade_reasons.py` 8 条 + `tests/test_local_engine_reasons.py` 14 条)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs";
|
||||
import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs";
|
||||
import { recentRange } from "@/lib/dates";
|
||||
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
|
||||
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
|
||||
import { BacktestResultView } from "@/components/BacktestResultView";
|
||||
import { JobProgress } from "@/components/JobProgress";
|
||||
import { adjustLabel } from "@/lib/labels";
|
||||
|
||||
export default function BacktestPage() {
|
||||
@@ -61,9 +62,18 @@ function emptyDraft(range: { start: string; end: string }): Draft {
|
||||
};
|
||||
}
|
||||
|
||||
function draftToCombo(d: Draft): BacktestCombo {
|
||||
/**
|
||||
* 草稿 → 组合对象。
|
||||
*
|
||||
* `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里
|
||||
* `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文
|
||||
* (`String should have at least 1 character`),点了「运行」却像什么都没发生。
|
||||
* 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字,
|
||||
* 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。
|
||||
*/
|
||||
function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo {
|
||||
return {
|
||||
name: d.name.trim(),
|
||||
name: d.name.trim() || nameFallback || "",
|
||||
description: d.description.trim(),
|
||||
strategy_ids: d.strategyIds,
|
||||
initial_capital: d.initialCapital,
|
||||
@@ -127,11 +137,17 @@ function BacktestInner() {
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
|
||||
const [savedComboId, setSavedComboId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
// 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。
|
||||
// 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。
|
||||
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runningComboId, setRunningComboId] = useState<string | null>(null);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [stage, setStage] = useState("");
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
/**
|
||||
* 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」,
|
||||
* 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的
|
||||
* 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。
|
||||
*/
|
||||
const job = useJobRunner<BacktestResult>("回测组合");
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -222,7 +238,13 @@ function BacktestInner() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runVia(() => submitComboJob(draftToCombo(draft)), null);
|
||||
// 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签;
|
||||
// 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。
|
||||
const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)";
|
||||
if (fallback) {
|
||||
setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。");
|
||||
}
|
||||
await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,20 +259,24 @@ function BacktestInner() {
|
||||
setRunningComboId(comboId);
|
||||
setError("");
|
||||
setNotice("");
|
||||
setJobId("");
|
||||
setResult(null);
|
||||
setResultArchiveId(null);
|
||||
try {
|
||||
const { job_id } = await submit();
|
||||
setJobId(job_id);
|
||||
setStage("queued");
|
||||
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
|
||||
setStage(info.stage);
|
||||
setElapsed(info.elapsedMs);
|
||||
const out = await job.run(submit, {
|
||||
label: draft.name ? `回测组合「${draft.name}」` : "回测组合",
|
||||
});
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
setResult(out.result as BacktestResult);
|
||||
const exp = out.experimentId ?? null;
|
||||
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
|
||||
setResultArchiveId(exp);
|
||||
setNotice(
|
||||
exp
|
||||
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
|
||||
: "回测完成,已自动归档。",
|
||||
);
|
||||
} else if (out.status === "cancelled") {
|
||||
// 主动取消不是错误:如实说明,并提示可重新运行
|
||||
setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。");
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
@@ -259,7 +285,6 @@ function BacktestInner() {
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setRunningComboId(null);
|
||||
setJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,23 +646,19 @@ function BacktestInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div className="stages" style={{ marginTop: 12 }}>
|
||||
{["data_loading", "backtesting", "analysis"].map((st) => {
|
||||
const order = ["queued", "data_loading", "backtesting", "analysis", "done"];
|
||||
const cur = order.indexOf(stage);
|
||||
const mine = order.indexOf(st);
|
||||
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
|
||||
return (
|
||||
<span className={cls} key={st}>
|
||||
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="hint">
|
||||
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
|
||||
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
|
||||
</span>
|
||||
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消。
|
||||
kind="backtest":本页跑的是 combo 作业,但后端上报的阶段与 backtest 完全一致
|
||||
(combo_service.py:68/70/90),所以共用同一条阶段序列。 */}
|
||||
<JobProgress
|
||||
state={job.state}
|
||||
onCancel={job.cancel}
|
||||
onDismiss={job.reset}
|
||||
anchorId="backtest-job-progress"
|
||||
kind="backtest"
|
||||
/>
|
||||
{job.state.phase === "idle" && running ? (
|
||||
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
|
||||
正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
@@ -649,7 +670,13 @@ function BacktestInner() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
|
||||
{result ? (
|
||||
<BacktestResultView
|
||||
result={result}
|
||||
name={draft.name || "回测组合"}
|
||||
archive={resultArchiveId ? { id: resultArchiveId } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。
|
||||
*
|
||||
* 设计取舍:
|
||||
* - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图);
|
||||
* 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 `<Link>`,不需要客户端状态。
|
||||
* - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足
|
||||
* (`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<T>(path: string): Promise<T | null> {
|
||||
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<ExperimentDetail>(`/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 (
|
||||
<Card icon="chartLine" title="该归档没有可放大的回测曲线">
|
||||
<Banner tone="info">
|
||||
归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 /
|
||||
因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。
|
||||
</Banner>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
|
||||
<span>打开归档详情</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="between" style={{ margin: "4px 0 12px", gap: 12, flexWrap: "wrap" }}>
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<b style={{ fontSize: 16 }}>曲线放大</b>
|
||||
<Pill tone="accent">{experimentKindLabel(detail.kind)}</Pill>
|
||||
<span className="mono hint">{id}</span>
|
||||
{detail.created_at ? (
|
||||
<span className="hint">归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
|
||||
<span>打开归档详情</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
{options.map((o) => (
|
||||
<Link
|
||||
key={o.key}
|
||||
className={o.key === current?.key ? "btn btn--sm btn--primary" : "btn btn--sm"}
|
||||
href={`/charts/${encodeURIComponent(id)}?s=${encodeURIComponent(o.key)}`}
|
||||
title={o.hint}
|
||||
>
|
||||
<span>{o.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title={seriesLabel}
|
||||
tools={
|
||||
<Pill tone={series[0]?.type === "bar" ? "violet" : "pos"}>
|
||||
{series[0]?.data.length ?? 0} 个点
|
||||
{markers.length ? ` · ${markers.length} 个买卖标注` : ""}
|
||||
</Pill>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={series}
|
||||
markers={markers}
|
||||
height={CHART_HEIGHT}
|
||||
formatKey={formatKey}
|
||||
zeroLine={zeroLine}
|
||||
ariaLabel={`${seriesLabel}(放大)`}
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
{note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏
|
||||
(换设备打开还原同一张图,因为数据来自归档快照)。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{res.summary ? (
|
||||
<Card icon="gauge" title="这次回测的关键指标(与曲线同一份快照)">
|
||||
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
|
||||
<span>
|
||||
区间 <span className="mono">{res.summary.start}</span> ~{" "}
|
||||
<span className="mono">{res.summary.end}</span>
|
||||
</span>
|
||||
<span>
|
||||
总收益{" "}
|
||||
<b className={res.summary.total_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
|
||||
{res.summary.total_return_pct.toFixed(2)}%
|
||||
</b>
|
||||
</span>
|
||||
<span>年化 {res.summary.annual_return_pct.toFixed(2)}%</span>
|
||||
<span>Sharpe {res.summary.sharpe.toFixed(3)}</span>
|
||||
<span>最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}%</span>
|
||||
<span>期末权益 {fmtNum(res.summary.final_equity)}</span>
|
||||
<span className="hint" style={{ color: CHART.faint }}>
|
||||
共 {res.summary.total_trades} 笔成交
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,15 @@
|
||||
* 微调时一眼看出「这次到底改了什么」。
|
||||
*
|
||||
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
|
||||
*
|
||||
* 后续补上的三件事(都是「列表页该有的基本操作」):
|
||||
* - **批量删除**:每行第一列的复选框(与「对比」那列刻意分开)攒出待删集合,
|
||||
* 顶部工具条一次提交;后端按 id 逐个回报 `deleted` / `missing`,界面**照实**显示。
|
||||
* - **测试发起时间列**:用 `created_at` 转本地时区渲染,并可点击表头排序。
|
||||
* - **详情改成图层**:列表原地看结果,不再把用户从列表页带走;「打开归档」仍在
|
||||
* 新标签页打开完整视图(需要 URL 可分享/可刷新时用它)。
|
||||
*/
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
|
||||
@@ -26,13 +33,14 @@ import {
|
||||
Btn,
|
||||
Banner,
|
||||
Empty,
|
||||
Metric,
|
||||
SkeletonLines,
|
||||
Loading,
|
||||
} from "@/components/ui";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
||||
import { CHART, seriesColor } from "@/components/charts/theme";
|
||||
import { seriesColor } from "@/components/charts/theme";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { ArchiveResultView } from "@/components/ArchiveResultView";
|
||||
|
||||
/**
|
||||
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
|
||||
@@ -94,6 +102,40 @@ function shortDataVersion(v: string): string {
|
||||
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试发起时间:后端给的是 ISO 字符串(本机时区的 naive datetime,如 `2026-10-01T17:45:05`)。
|
||||
*
|
||||
* 必须过一遍 `new Date(...)` 再取本地字段,**不能直接截字符串**:截字符串等于把后端
|
||||
* 字符串里的时间当本地时间照搬,一旦后端改成带时区的 UTC(`...Z`)就会差 8 小时,
|
||||
* 而且错得没有任何提示。由 Date 负责时区换算,格式异常时原样回显(不假装是时间)。
|
||||
*/
|
||||
function fmtLocalTime(v?: string | null): string {
|
||||
if (!v) return "—";
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return v;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(
|
||||
d.getMinutes()
|
||||
)}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 后端批量删除单次最多接受的 id 数(`BULK_DELETE_MAX_IDS`);超了接口直接 422,一个都不删 */
|
||||
const BULK_DELETE_MAX_IDS = 200;
|
||||
|
||||
/** 按上限切片:超过 200 个选中必须分批提交,否则整批被 422 拒掉 */
|
||||
function chunkIds(ids: string[], size: number): string[][] {
|
||||
const out: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 批量删除的响应(后端语义:存在的进 deleted,库里没有的如实进 missing,绝不静默吞掉) */
|
||||
interface BulkDeleteResult {
|
||||
deleted: string[];
|
||||
missing: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
|
||||
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
||||
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
||||
@@ -118,15 +160,52 @@ export default function ExperimentsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽表此刻是否需要横向滚动(按实测 `scrollWidth > clientWidth`,容器尺寸变化时重算)。
|
||||
*
|
||||
* 为什么要提示:卡片内的横向滚动条在 macOS 上是覆盖式的(不滚就不显示),
|
||||
* 用户看到的就是「右侧内容被切掉了」——这正是「experiments 页面右侧内容溢出了」的来源。
|
||||
* 为什么要在运行时判断而不是写死一句提示:列宽固定,窗口够宽时根本不需要滚动,
|
||||
* 那种情况下还挂着提示就是废话(还让人以为页面坏了)。
|
||||
*/
|
||||
function useTableScrollHint(deps: React.DependencyList) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [scrolls, setScrolls] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const check = () => setScrolls(el.scrollWidth > el.clientWidth + 1);
|
||||
check();
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
// deps 由调用方给(行数 / 筛选变化时要重新量)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
return { ref, scrolls };
|
||||
}
|
||||
|
||||
function ExperimentsInner() {
|
||||
const search = useSearchParams();
|
||||
const [exps, setExps] = useState<ExperimentMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// 列表容器:窄窗口下宽表要横向滚动,提示只在真需要时出现
|
||||
const { ref: tableWrapRef, scrolls: tableScrolls } = useTableScrollHint([exps.length, loading]);
|
||||
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState("");
|
||||
/** 图层是否打开(打开的是哪一条)—— detail 本身可能还在路上,所以不能只看 detail */
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
/** 打开图层的触发按钮:关闭后焦点要还给它(键盘用户不能"掉回页首") */
|
||||
const [detailTrigger, setDetailTrigger] = useState<HTMLElement | null>(null);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
/**
|
||||
* 操作结果提示(删除回执等):与 error 分开 —— 红色横幅只留给真正的失败。
|
||||
* tone 显式存下来,而不是在渲染时用文案里有没有"不存在"去猜(文案一改就会猜错)。
|
||||
*/
|
||||
const [notice, setNotice] = useState<{ text: string; tone: "info" | "warn" } | null>(null);
|
||||
|
||||
// 对比状态
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
@@ -134,6 +213,16 @@ function ExperimentsInner() {
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [onlyDiff, setOnlyDiff] = useState(true);
|
||||
|
||||
// 批量删除的选择:与 picked(对比,上限 3 个)**刻意分开**——
|
||||
// 复用同一个集合的话「全选本页」会被对比上限卡住,而且删除是不可逆动作,
|
||||
// 不该和「我想比一比」共用一次勾选(误删代价太大)。
|
||||
const [toDelete, setToDelete] = useState<string[]>([]);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// 列表排序:默认按发起时间倒序(与后端 list_filtered 的 created_at desc 一致),
|
||||
// 点表头在升降之间切换。
|
||||
const [timeSort, setTimeSort] = useState<"desc" | "asc">("desc");
|
||||
|
||||
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
|
||||
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
|
||||
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
|
||||
@@ -186,13 +275,113 @@ function ExperimentsInner() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function open(id: string) {
|
||||
setDetailLoading(true);
|
||||
/**
|
||||
* 详情加载序号:用户可能连点两行的「详情」,先发的请求后到会把 A 的内容
|
||||
* 盖到 B 的图层上(旧代码原地渲染时同样有这个竞态)。每次打开自增,回调里
|
||||
* 只认最后一次,迟到的一律丢弃。
|
||||
*/
|
||||
const detailSeq = useRef(0);
|
||||
|
||||
/**
|
||||
* 打开详情图层:失败**原文**显示在后端错误里(不吞成"加载失败"),
|
||||
* 因为这里通常能直接看出是 404(已删)/ 500 还是网络问题。
|
||||
*/
|
||||
function openDetail(id: string, trigger: HTMLElement | null) {
|
||||
const seq = ++detailSeq.current;
|
||||
setDetailTrigger(trigger);
|
||||
setDetailId(id);
|
||||
setDetail(null);
|
||||
apiGet<ExperimentDetail>(`/experiments/${id}`)
|
||||
.then((d) => setDetail(d))
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setDetailLoading(false));
|
||||
setDetailError("");
|
||||
setDetailLoading(true);
|
||||
apiGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`)
|
||||
.then((d) => {
|
||||
if (seq === detailSeq.current) setDetail(d);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (seq === detailSeq.current) setDetailError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === detailSeq.current) setDetailLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
// 自增序号:关闭之后迟到的响应不能再把图层"顶"回来
|
||||
detailSeq.current += 1;
|
||||
setDetailId(null);
|
||||
setDetail(null);
|
||||
setDetailError("");
|
||||
setDetailLoading(false);
|
||||
}
|
||||
|
||||
/** 单行「删除选择」勾选:不设上限——批量删除本来就要跨多行选 */
|
||||
function toggleDelete(id: string) {
|
||||
setNotice(null);
|
||||
setToDelete((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
/** 批量删除:一次最多 200 个 id,超了前端分批,最后把各批的 deleted/missing 合并上报 */
|
||||
async function removeSelected() {
|
||||
if (!toDelete.length || deleting) return;
|
||||
const ids = [...toDelete];
|
||||
const n = ids.length;
|
||||
const ok = window.confirm(
|
||||
`将永久删除 ${n} 个归档,不可恢复。\n\n` +
|
||||
`· 结果只存归档这一份:删除后这些回测/因子测试/选股的净值曲线、成交与 spec 都无法再查看,也无法导出;\n` +
|
||||
`· 关联的作业记录会保留,但结果读不回(接口会如实标注"结果已随归档删除");\n` +
|
||||
`· 想留底请先用每行「打开归档」进详情页导出完整 JSON。\n\n确认删除这 ${n} 个归档?`
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
setDeleting(true);
|
||||
setError("");
|
||||
setNotice(null);
|
||||
const deleted: string[] = [];
|
||||
const missing: string[] = [];
|
||||
try {
|
||||
for (const batch of chunkIds(ids, BULK_DELETE_MAX_IDS)) {
|
||||
const r = await apiPost<BulkDeleteResult>("/experiments/bulk-delete", { ids: batch });
|
||||
deleted.push(...r.deleted);
|
||||
missing.push(...r.missing);
|
||||
}
|
||||
} catch (e) {
|
||||
// 分批提交时前面几批**已经真删了**:必须把"已删几个"说出来,
|
||||
// 否则用户会以为一个都没删(或以为全删了)——两种都是谎报。
|
||||
setError(`批量删除未全部完成:已删除 ${deleted.length} 个,之后失败 —— ${(e as Error).message}`);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
// 只清掉「确实删掉了」与「本来就不存在」的选中项;因网络/接口失败没删成的仍留在
|
||||
// 选中里,用户可直接重试,不用重新勾一遍。
|
||||
const gone = new Set([...deleted, ...missing]);
|
||||
setToDelete((prev) => prev.filter((id) => !gone.has(id)));
|
||||
// 正开着的详情/对比里若含已删归档,必须一起清掉,否则会继续展示一份已不存在的快照。
|
||||
// 用 detailId 而不是 detail 判定:详情可能还在加载(detail 还是 null),
|
||||
// 这时请求回来仍会把一份已删的快照渲染进图层。
|
||||
if (detailId && gone.has(detailId)) closeDetail();
|
||||
setCompare((prev) => {
|
||||
if (!prev) return prev;
|
||||
const left = prev.filter((r) => !gone.has(r.id));
|
||||
if (left.length === prev.length) return prev;
|
||||
// 剩不到 2 条就没有"对比"可言了:关掉对比区,而不是画一条什么都比不出来的曲线
|
||||
return left.length >= 2 ? left : null;
|
||||
});
|
||||
setPicked((prev) => prev.filter((id) => !gone.has(id)));
|
||||
|
||||
if (deleted.length || missing.length) {
|
||||
// 照实报告:missing 非空必须显示("不存在的 id 也算删成功"会让用户以为库里干净了)
|
||||
setNotice({
|
||||
text:
|
||||
`已删除 ${deleted.length} 个归档` +
|
||||
(missing.length
|
||||
? `,${missing.length} 个不存在(可能已被别处删掉,未计入删除数)`
|
||||
: "") +
|
||||
"。",
|
||||
// missing 非空用警示色:不能让"有 id 没找到"被当成完全成功一眼扫过去
|
||||
tone: missing.length ? "warn" : "info",
|
||||
});
|
||||
}
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
function rerun(exp: ExperimentMeta) {
|
||||
@@ -218,7 +407,8 @@ function ExperimentsInner() {
|
||||
if (j.status === "success" || j.status === "failed" || tries > 60) {
|
||||
window.clearInterval(timer);
|
||||
load();
|
||||
setDetail(null);
|
||||
// 复跑会新增归档;图层里那份可能是刚被替换的旧快照,关掉避免看串
|
||||
closeDetail();
|
||||
}
|
||||
})
|
||||
.catch(() => window.clearInterval(timer));
|
||||
@@ -258,7 +448,47 @@ function ExperimentsInner() {
|
||||
}
|
||||
}
|
||||
|
||||
const s = detail && isBacktestDetail(detail) ? detail.result.summary : undefined;
|
||||
/**
|
||||
* 展示用的行序:按「测试发起时间」升/降序排列(表头可切换)。
|
||||
*
|
||||
* 两个细节:
|
||||
* - `created_at` 缺失的归档一律排在**末尾**,不参与升降 —— 让「—」混在时间中间会
|
||||
* 让人以为那是一条很新/很旧的归档,那是假信息;
|
||||
* - 同秒(MySQL datetime(0) 只有秒精度)用 id 兜底,方向与时间一致,
|
||||
* 否则等长时间的行序会随机抖动(也和后端 created_at desc, id desc 的口径一致)。
|
||||
*/
|
||||
const rows = useMemo(() => {
|
||||
const ts = (e: ExperimentMeta) => (e.created_at ? new Date(e.created_at).getTime() : NaN);
|
||||
const byId = (a: ExperimentMeta, b: ExperimentMeta) =>
|
||||
timeSort === "desc" ? (a.id < b.id ? 1 : a.id > b.id ? -1 : 0) : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
return [...exps].sort((a, b) => {
|
||||
const ta = ts(a);
|
||||
const tb = ts(b);
|
||||
const aBad = Number.isNaN(ta);
|
||||
const bBad = Number.isNaN(tb);
|
||||
if (aBad && bBad) return byId(a, b);
|
||||
if (aBad) return 1;
|
||||
if (bBad) return -1;
|
||||
if (ta !== tb) return timeSort === "desc" ? tb - ta : ta - tb;
|
||||
return byId(a, b);
|
||||
});
|
||||
}, [exps, timeSort]);
|
||||
|
||||
const pageIds = rows.map((e) => e.id);
|
||||
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => toDelete.includes(id));
|
||||
|
||||
/**
|
||||
* 表头「全选本页」:只作用于**当前显示**的这些行。
|
||||
* 列表可能有后端单页上限(total > rows.length),"全选筛选结果"会把看不见的行也删掉,
|
||||
* 那是拿用户没看过的数据冒险,所以这里不做,并在工具条里说明范围。
|
||||
*/
|
||||
function toggleAllOnPage() {
|
||||
setNotice(null);
|
||||
setToDelete((prev) => {
|
||||
if (allOnPageSelected) return prev.filter((id) => !pageIds.includes(id));
|
||||
return Array.from(new Set([...prev, ...pageIds]));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -276,19 +506,56 @@ function ExperimentsInner() {
|
||||
|
||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
||||
|
||||
{picked.length > 0 ? (
|
||||
{/* 删除回执等操作结果:missing 非空时是 warn 色,避免"部分不存在"被当成完全成功 */}
|
||||
{notice ? <Banner tone={notice.tone}>{notice.text}</Banner> : null}
|
||||
|
||||
{picked.length > 0 || toDelete.length > 0 ? (
|
||||
<div className="sticky-bar">
|
||||
<Pill tone="violet" icon="layers">
|
||||
已选 {picked.length} 个
|
||||
</Pill>
|
||||
<span className="hint">{picked.join(" · ")}</span>
|
||||
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
|
||||
对比选中的实验
|
||||
</Btn>
|
||||
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
|
||||
清空
|
||||
</Btn>
|
||||
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
|
||||
{toDelete.length > 0 ? (
|
||||
<>
|
||||
<Pill tone="neg" icon="trash">
|
||||
已选 {toDelete.length} 个(待删除)
|
||||
</Pill>
|
||||
<Btn
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
loading={deleting}
|
||||
disabled={deleting}
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{deleting ? "删除中…" : "删除所选"}
|
||||
</Btn>
|
||||
<Btn icon="x" disabled={deleting} onClick={() => setToDelete([])}>
|
||||
取消选择
|
||||
</Btn>
|
||||
<span className="hint">
|
||||
范围:当前列表显示的本页 {rows.length} 条
|
||||
{total !== null && total > rows.length
|
||||
? `(筛选结果共 ${total} 条,本页之外的未列出、也不会被删)`
|
||||
: ""}
|
||||
;删除不可恢复。
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{picked.length > 0 ? (
|
||||
<>
|
||||
{toDelete.length > 0 ? (
|
||||
/* 两组选择同时在时用一条分隔线隔开,否则会看成一整排互相矛盾的按钮 */
|
||||
<span aria-hidden style={{ width: 1, alignSelf: "stretch", background: "var(--line)" }} />
|
||||
) : null}
|
||||
<Pill tone="violet" icon="layers">
|
||||
已选 {picked.length} 个(对比)
|
||||
</Pill>
|
||||
<span className="hint">{picked.join(" · ")}</span>
|
||||
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
|
||||
对比选中的实验
|
||||
</Btn>
|
||||
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
|
||||
清空
|
||||
</Btn>
|
||||
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -311,11 +578,21 @@ function ExperimentsInner() {
|
||||
icon="archive"
|
||||
title="实验列表"
|
||||
sub={
|
||||
total !== null
|
||||
? `显示 ${exps.length} 条 / 共 ${total} 条${
|
||||
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
|
||||
}`
|
||||
: "勾选左侧方框可选 2~3 个做对比"
|
||||
<span>
|
||||
{total !== null
|
||||
? `显示 ${exps.length} 条 / 共 ${total} 条${
|
||||
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
|
||||
}`
|
||||
: "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"}
|
||||
{tableScrolls ? (
|
||||
// 只在真的放不下时才说:卡片内的横向滚动条容易看不见(触控板尤其),
|
||||
// 而右端「操作」列已固定,用户不必先滚到底才知道能点哪里。
|
||||
<>
|
||||
{" "}
|
||||
<span className="hint">列较多,可横向滚动;选择列与「操作」列固定不动。</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
@@ -372,27 +649,73 @@ function ExperimentsInner() {
|
||||
hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。"
|
||||
/>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<div className="table-wrap table-wrap--wide" ref={tableWrapRef}>
|
||||
<table className="tbl tbl--wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }} aria-label="选择对比" />
|
||||
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开,
|
||||
因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */}
|
||||
<th
|
||||
title={
|
||||
total !== null && total > rows.length
|
||||
? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)`
|
||||
: `全选本页(${rows.length} 个)`
|
||||
}
|
||||
>
|
||||
<label className="check check--cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOnPageSelected}
|
||||
onChange={toggleAllOnPage}
|
||||
aria-label={`全选本页 ${rows.length} 个归档用于删除`}
|
||||
/>
|
||||
</label>
|
||||
</th>
|
||||
<th>
|
||||
<span className="hint" title="勾选这一列可挑 2~3 个实验做对比">
|
||||
对比
|
||||
</span>
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>类型</th>
|
||||
<th>
|
||||
{/* 排序交互沿用全站既有的表头按钮风格(见 TradeReasons 的日期列) */}
|
||||
<button
|
||||
type="button"
|
||||
className="th-sort"
|
||||
onClick={() => setTimeSort((d) => (d === "desc" ? "asc" : "desc"))}
|
||||
title="按测试发起时间排序,点击切换升序/降序(空值始终排在最后)"
|
||||
aria-label={`按测试发起时间排序:当前${timeSort === "desc" ? "降序" : "升序"},点击切换`}
|
||||
>
|
||||
发起时间 {timeSort === "desc" ? "↓" : "↑"}
|
||||
</button>
|
||||
</th>
|
||||
<th>因子</th>
|
||||
<th>区间</th>
|
||||
<th>摘要</th>
|
||||
<th>版本(代码 / 数据)</th>
|
||||
<th>体积</th>
|
||||
<th style={{ width: 260, textAlign: "right" }}>操作</th>
|
||||
{/* 列宽由 .tbl--wide 统一给(含固定右端):行内 width 会盖掉 CSS,
|
||||
曾经就是它让「操作」列保持 260px、整表多出 88px 放不下 */}
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exps.map((e) => (
|
||||
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
|
||||
{rows.map((e) => (
|
||||
<tr key={e.id} className={detailId === e.id ? "row-active" : undefined}>
|
||||
<td>
|
||||
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
|
||||
<label className="check check--cell" title={`选择 ${e.id} 用于对比`}>
|
||||
<label className="check check--cell" title={`选择 ${e.id} 用于批量删除`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toDelete.includes(e.id)}
|
||||
onChange={() => toggleDelete(e.id)}
|
||||
aria-label={`选择 ${e.id} 用于批量删除`}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<label className="check check--cell" title={`选择 ${e.id} 用于对比(最多 3 个)`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.includes(e.id)}
|
||||
@@ -401,8 +724,13 @@ function ExperimentsInner() {
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td className="cell-mono cell-strong">{e.id}</td>
|
||||
<td className="cell-mono cell-strong" title={e.id}>
|
||||
{e.id}
|
||||
</td>
|
||||
<td>{kindView(e.kind)}</td>
|
||||
<td className="cell-mono" title={e.created_at ?? "该归档没有记录发起时间"}>
|
||||
{fmtLocalTime(e.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
{e.factors.map((f) => (
|
||||
@@ -412,7 +740,12 @@ function ExperimentsInner() {
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
|
||||
<td
|
||||
className="cell-mono"
|
||||
title={e.period ? `区间 ${e.period[0]} ~ ${e.period[1]}` : "该归档没有记录区间"}
|
||||
>
|
||||
{e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"}
|
||||
</td>
|
||||
<td className="hint">{e.summary_text ?? "—"}</td>
|
||||
<td>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
@@ -435,10 +768,22 @@ function ExperimentsInner() {
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
|
||||
<Link href={`/experiments/${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
||||
{/* 完整归档是"可分享、可刷新"的只读视图,用新标签页打开,
|
||||
列表页的筛选/选择状态原地不动(看完关掉标签页就回来)。 */}
|
||||
<a
|
||||
href={`/experiments/${encodeURIComponent(e.id)}`}
|
||||
className="btn btn--sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>打开归档</span>
|
||||
</Link>
|
||||
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
|
||||
</a>
|
||||
<Btn
|
||||
size="sm"
|
||||
icon="search"
|
||||
// 把触发按钮传进图层:关闭后焦点要回到它(键盘用户不"掉回页首")
|
||||
onClick={(ev) => openDetail(e.id, ev.currentTarget)}
|
||||
>
|
||||
详情
|
||||
</Btn>
|
||||
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
||||
@@ -463,83 +808,126 @@ function ExperimentsInner() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{detail ? (
|
||||
<Card
|
||||
icon="book"
|
||||
title={`${detail.id} · ${detail.factors.join("、")}`}
|
||||
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
|
||||
{/* 详情图层:原地看归档,不再把用户从列表带走(列表的筛选/选择/滚动位置都留着)。
|
||||
「选股条件 / 交易执行依据」那部分说明由后端 describe_strategy 依归档 spec 推导
|
||||
(归档详情页是 Server Component,在服务端调用它)—— 图层里不重复这份推导,
|
||||
也不自行拼造文案;要看它就点标题栏的「在新页面打开完整归档」。 */}
|
||||
{detailId ? (
|
||||
<Modal
|
||||
title={
|
||||
<>
|
||||
{detailId} 归档详情
|
||||
{detail ? ` · ${experimentKindLabel(detail.kind)}` : ""}
|
||||
</>
|
||||
}
|
||||
onClose={closeDetail}
|
||||
returnFocusTo={detailTrigger}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm">
|
||||
<span>打开归档(完整视图)</span>
|
||||
</Link>
|
||||
<Btn size="sm" onClick={() => setDetail(null)}>
|
||||
<Icon name="x" size={13} />
|
||||
关闭
|
||||
</Btn>
|
||||
</div>
|
||||
<a
|
||||
className="btn btn--sm"
|
||||
href={`/experiments/${encodeURIComponent(detailId)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>在新页面打开完整归档</span>
|
||||
</a>
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<SkeletonLines n={3} />
|
||||
) : (
|
||||
{detailError ? (
|
||||
/* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同,
|
||||
只写「加载失败」等于把可排查的信息丢掉。 */
|
||||
<Banner tone="error">详情加载失败:{detailError}</Banner>
|
||||
) : detailLoading ? (
|
||||
<>
|
||||
{s ? (
|
||||
<div className="metric-grid" style={{ marginBottom: 0 }}>
|
||||
<Metric
|
||||
label="区间收益"
|
||||
tone={s.total_return_pct >= 0 ? "pos" : "neg"}
|
||||
value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`}
|
||||
/>
|
||||
<Metric
|
||||
label="年化收益"
|
||||
tone={s.annual_return_pct >= 0 ? "pos" : "neg"}
|
||||
value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`}
|
||||
/>
|
||||
<Metric label="Sharpe" value={s.sharpe.toFixed(2)} />
|
||||
<Metric label="最大回撤" tone="neg" value={`${s.max_drawdown_pct.toFixed(2)}%`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
|
||||
)}
|
||||
{detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
key: "eq",
|
||||
label: "累计收益率(%)",
|
||||
type: "area",
|
||||
color: CHART.accent,
|
||||
data: normalize(detail.result),
|
||||
lastValueVisible: true,
|
||||
},
|
||||
]}
|
||||
height={260}
|
||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
||||
zeroLine
|
||||
ariaLabel={`${detail.id} 累计收益率曲线`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.spec ? (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary className="hint" style={{ cursor: "pointer" }}>
|
||||
查看 spec(可复现输入)
|
||||
</summary>
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
|
||||
{JSON.stringify(detail.spec, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
正在读取归档 {detailId}…
|
||||
</div>
|
||||
<SkeletonLines n={5} />
|
||||
</>
|
||||
) : detail ? (
|
||||
<>
|
||||
<div className="kv-grid">
|
||||
<MetaKV label="实验 ID" value={detail.id} mono />
|
||||
<MetaKV label="类型" value={experimentKindLabel(detail.kind)} />
|
||||
<MetaKV label="测试发起时间" value={fmtLocalTime(detail.created_at)} mono />
|
||||
<MetaKV label="代码版本" value={detail.code_version ?? "—"} mono />
|
||||
<MetaKV
|
||||
label="数据版本"
|
||||
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
|
||||
mono
|
||||
warn={!detail.data_version}
|
||||
/>
|
||||
<MetaKV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
|
||||
<MetaKV
|
||||
label="区间"
|
||||
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
|
||||
mono
|
||||
/>
|
||||
<MetaKV
|
||||
label="归档体积"
|
||||
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
|
||||
/>
|
||||
</div>
|
||||
<div className="hint" style={{ margin: "10px 0 14px" }}>
|
||||
结果区与归档页用的是同一个 <code>ArchiveResultView</code>(按 kind 分发),
|
||||
所以回测的净值/成交、因子测试的 IC、选股的候选明细都在这里;
|
||||
「选股条件 / 交易执行依据」这类需要后端依 spec 推导的说明只在完整归档页展示。
|
||||
</div>
|
||||
{detail.result ? (
|
||||
<ArchiveResultView
|
||||
kind={detail.kind}
|
||||
result={detail.result}
|
||||
archive={{
|
||||
id: detail.id,
|
||||
created_at: detail.created_at,
|
||||
code_version: detail.code_version,
|
||||
data_version: detail.data_version,
|
||||
job_id: detail.job_id,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
icon="archive"
|
||||
title="该归档没有可展示的结果"
|
||||
hint="结果为空(可能是历史版本归档)。可用标题栏的「在新页面打开完整归档」,在那边「导出完整 JSON」查看原始内容。"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="hint">详情为空 —— 这条归档可能刚被删除,请刷新列表核对。</div>
|
||||
)}
|
||||
</Card>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层里的元数据行:沿用归档详情页同一套 `kv` 样式。
|
||||
*
|
||||
* 字段与取值口径跟归档页对齐(同一份归档在两个入口看到的 id/版本/作业 id/时间必须一致,
|
||||
* 否则用户会怀疑哪个是真的);文案上列表与图层统一叫「测试发起时间」,归档页沿用
|
||||
* 既有的「归档时间」—— 同一个 `created_at`,不在这里改归档页的既有措辞。
|
||||
*/
|
||||
function MetaKV({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
warn,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="kv">
|
||||
<span className="kv__k">{label}</span>
|
||||
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
|
||||
@@ -581,6 +969,11 @@ function CompareView({
|
||||
setOnlyDiff: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// 指标对比 / 参数差异两张表都是「一行一个指标 + 每个实验一列」:列数随对比个数增长,
|
||||
// 窄窗口必然横向滚动。首列(指标名 / 参数名)必须固定,否则滚到右边不知道这一行是什么。
|
||||
const metricTable = useTableScrollHint([rows.length]);
|
||||
const paramTable = useTableScrollHint([rows.length, onlyDiff]);
|
||||
|
||||
const series = useMemo<LwSeries[]>(
|
||||
() =>
|
||||
rows.map((r, i) => ({
|
||||
@@ -635,12 +1028,12 @@ function CompareView({
|
||||
ariaLabel="实验净值曲线叠加对比"
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。
|
||||
曲线已归一化为「累计收益率 %」(各自以首个净值为 0%),因此初始资金不同也能直接比。
|
||||
点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{ marginTop: 14 }}>
|
||||
<table className="tbl">
|
||||
<div className="table-wrap" style={{ marginTop: 14 }} ref={metricTable.ref}>
|
||||
<table className="tbl tbl--pin-first">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标</th>
|
||||
@@ -685,18 +1078,19 @@ function CompareView({
|
||||
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差
|
||||
(收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "}
|
||||
{base.total_return_pct.toFixed(2)}%。
|
||||
{metricTable.scrolls ? " 列较多,可横向滚动;首列「指标」固定不动。" : ""}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<b style={{ fontSize: 13 }}>参数差异(config_snapshot 逐项对比)</b>
|
||||
{diffRows.length === 0 ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。
|
||||
这几个实验的参数快照完全一致(说明结果是「同一配置」下的数据变动,或只是复跑)。
|
||||
已排除 price_basis.* 运行元数据。
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap" style={{ marginTop: 6 }}>
|
||||
<table className="tbl">
|
||||
<div className="table-wrap" style={{ marginTop: 6 }} ref={paramTable.ref}>
|
||||
<table className="tbl tbl--pin-first">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数</th>
|
||||
@@ -725,9 +1119,10 @@ function CompareView({
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{onlyDiff ? (
|
||||
{onlyDiff || paramTable.scrolls ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
|
||||
{onlyDiff ? "已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。" : null}
|
||||
{paramTable.scrolls ? " 参数表列较多,可横向滚动;首列「参数」固定不动。" : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { submitJob, waitJob } from "@/lib/jobs";
|
||||
import { submitJob, useJobRunner } from "@/lib/jobs";
|
||||
import { factorLabel, pickableFactors } from "@/lib/factors";
|
||||
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
|
||||
import { recentRange } from "@/lib/dates";
|
||||
@@ -13,14 +13,10 @@ import {
|
||||
Field,
|
||||
Btn,
|
||||
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 { JobProgress } from "@/components/JobProgress";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
interface Pick {
|
||||
@@ -37,8 +33,18 @@ export default function ComposePage() {
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
|
||||
const [archiveId, setArchiveId] = useState<string | null>(null);
|
||||
/**
|
||||
* 作业反馈统一交给 `useJobRunner`(与 /backtest 同一套):点了就有「正在提交作业…」,
|
||||
* 之后是后端真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
|
||||
* 此前这里只有一句「任务 JOB-xxx 后台执行中」的假进度条(value 固定 30),
|
||||
* 用户既不知道提交成功没、也不知道跑到哪一步。
|
||||
*/
|
||||
const job = useJobRunner<BacktestResult>("因子组合回测");
|
||||
const active =
|
||||
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
|
||||
/** 只留「参数不合法 / 目录加载失败」这类**作业之外**的错误;作业自身的失败由反馈条显示后端原文 */
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,33 +90,27 @@ export default function ComposePage() {
|
||||
setError("请选择至少一个因子并设置大于 0 的权重");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setJobId("");
|
||||
setResult(null);
|
||||
try {
|
||||
const spec: ResearchSpec = {
|
||||
type: "backtest",
|
||||
universe: { exclude_st: excludeSt, min_listing_days: 0 },
|
||||
factors: valid.map((p) => ({ name: p.name, weight: p.weight })),
|
||||
selection: { top_n: topN },
|
||||
rebalance,
|
||||
period: [start, end],
|
||||
};
|
||||
const { job_id } = await submitJob(spec);
|
||||
setJobId(job_id);
|
||||
const out = await waitJob<BacktestResult>(job_id);
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setJobId("");
|
||||
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
|
||||
const spec: ResearchSpec = {
|
||||
type: "backtest",
|
||||
universe: { exclude_st: excludeSt, min_listing_days: 0 },
|
||||
factors: valid.map((p) => ({ name: p.name, weight: p.weight })),
|
||||
selection: { top_n: topN },
|
||||
rebalance,
|
||||
period: [start, end],
|
||||
};
|
||||
const out = await job.run(() => submitJob(spec), {
|
||||
label: `因子组合回测(${valid.length} 个因子)`,
|
||||
});
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
// 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」
|
||||
setArchiveId(out.experimentId ?? null);
|
||||
}
|
||||
// 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、
|
||||
// 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -266,30 +266,32 @@ export default function ComposePage() {
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="play"
|
||||
loading={running}
|
||||
disabled={running || picks.length === 0}
|
||||
loading={active}
|
||||
disabled={active || picks.length === 0}
|
||||
onClick={run}
|
||||
>
|
||||
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
|
||||
{active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
value={30}
|
||||
label={
|
||||
<>
|
||||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
|
||||
<JobProgress
|
||||
state={job.state}
|
||||
onCancel={job.cancel}
|
||||
onDismiss={job.reset}
|
||||
anchorId="factors-compose-job-progress"
|
||||
kind="backtest"
|
||||
/>
|
||||
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
|
||||
</Card>
|
||||
|
||||
{result ? <ResultView result={result} params={{ picks, topN, rebalance, excludeSt, start, end }} /> : null}
|
||||
{result ? (
|
||||
<ResultView
|
||||
result={result}
|
||||
params={{ picks, topN, rebalance, excludeSt, start, end }}
|
||||
archiveId={archiveId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -297,36 +299,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 (
|
||||
<>
|
||||
<div className="between" style={{ margin: "6px 0 14px" }}>
|
||||
<div className="row">
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
<Pill tone="pos" icon="check">
|
||||
回测完成
|
||||
</Pill>
|
||||
@@ -334,41 +317,20 @@ function ResultView({
|
||||
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
|
||||
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
|
||||
<Pill>{params.start} ~ {params.end}</Pill>
|
||||
<Pill>{params.picks.length} 个因子</Pill>
|
||||
</div>
|
||||
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
区间收益 <Signed value={s.total_return_pct} suffix="%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BacktestMetrics s={s} />
|
||||
|
||||
<div className="chart-grid">
|
||||
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
|
||||
<LwChart
|
||||
series={equitySeries}
|
||||
height={300}
|
||||
valueFormat={(v) => fmtNum(v, 2)}
|
||||
ariaLabel="净值曲线"
|
||||
emptyHint="该区间没有净值数据"
|
||||
/>
|
||||
</Card>
|
||||
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
|
||||
<LwChart
|
||||
series={drawdownSeries}
|
||||
height={300}
|
||||
zeroLine
|
||||
valueFormat={(v) => fmtPct(v, 2)}
|
||||
ariaLabel="回撤曲线"
|
||||
emptyHint="该区间没有回撤数据"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card icon="calendar" title="月度收益(%)">
|
||||
<MonthlyReturnsTable rows={result.monthly_returns} />
|
||||
</Card>
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
|
||||
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
|
||||
<BacktestResultView
|
||||
result={result}
|
||||
name="因子组合"
|
||||
archive={archiveId ? { id: archiveId } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||
import { submitJob, waitJob } from "@/lib/jobs";
|
||||
import { submitJob, useJobRunner } from "@/lib/jobs";
|
||||
import type {
|
||||
FactorMeta,
|
||||
FactorParam,
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
Field,
|
||||
Btn,
|
||||
Banner,
|
||||
Progress,
|
||||
Empty,
|
||||
SkeletonLines,
|
||||
} from "@/components/ui";
|
||||
import { JobProgress } from "@/components/JobProgress";
|
||||
|
||||
/**
|
||||
* 因子研究页。
|
||||
@@ -180,10 +180,15 @@ export default function FactorsPage() {
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [reports, setReports] = useState<Record<string, FactorTestReport>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [processed, setProcessed] = useState(0);
|
||||
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
|
||||
const [jobId, setJobId] = useState("");
|
||||
/**
|
||||
* 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景,
|
||||
* 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端,
|
||||
* 不再由前端按 processed/selected 算一个百分比假进度条(用户看不出到底跑到哪了)。
|
||||
*/
|
||||
const job = useJobRunner<FactorTestReport>("单因子测试");
|
||||
/** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */
|
||||
const active =
|
||||
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
|
||||
const [error, setError] = useState("");
|
||||
/** 目录级成功提示(停用 / 启用)。 */
|
||||
const [notice, setNotice] = useState("");
|
||||
@@ -319,47 +324,41 @@ export default function FactorsPage() {
|
||||
setError("请至少选择一个因子");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setJobId("");
|
||||
setReports({});
|
||||
setProcessed(0);
|
||||
try {
|
||||
let index = 0;
|
||||
for (const name of names) {
|
||||
index += 1;
|
||||
setCurrent({ idx: index, total: names.length, name });
|
||||
const spec: ResearchSpec = {
|
||||
type: "factor_test",
|
||||
universe: { exclude_st: true, min_listing_days: 0 },
|
||||
factors: [{ name, weight: 1 }],
|
||||
selection: { top_n: 10 },
|
||||
rebalance: "monthly",
|
||||
period: [start, end],
|
||||
};
|
||||
const { job_id } = await submitJob(spec);
|
||||
setJobId(job_id);
|
||||
const out = await waitJob<FactorTestReport>(job_id);
|
||||
if (out.status === "success" && out.result) {
|
||||
setReports((prev) => ({ ...prev, [name]: out.result! }));
|
||||
} else {
|
||||
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
|
||||
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
|
||||
}
|
||||
setProcessed(index);
|
||||
// 多因子是**一个因子一个 Job**(后端逐因子归档),所以这里顺序提交、逐个 await;
|
||||
// 每轮都把进度交给统一反馈条:点了第一个就有「正在提交作业…」+ 作业号,不必等全部跑完。
|
||||
let index = 0;
|
||||
for (const name of names) {
|
||||
index += 1;
|
||||
const spec: ResearchSpec = {
|
||||
type: "factor_test",
|
||||
universe: { exclude_st: true, min_listing_days: 0 },
|
||||
factors: [{ name, weight: 1 }],
|
||||
selection: { top_n: 10 },
|
||||
rebalance: "monthly",
|
||||
period: [start, end],
|
||||
};
|
||||
const out = await job.run(() => submitJob(spec), {
|
||||
label: `单因子测试 · ${name}`,
|
||||
progress: { index, total: names.length },
|
||||
});
|
||||
if (out.status === "success" && out.result) {
|
||||
setReports((prev) => ({ ...prev, [name]: out.result! }));
|
||||
} else if (out.status === "cancelled") {
|
||||
// 取消是用户的明确意图,不是错误:已经跑完的报告留在页面上,后面的因子不再跑
|
||||
break;
|
||||
} else {
|
||||
// 单个因子失败不该吞掉其它因子的报告:把后端原文累积成一份批级错误清单,
|
||||
// 逐个跑(而不是整批放弃)也和老行为一致
|
||||
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
|
||||
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setCurrent(null);
|
||||
setJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = checked.size;
|
||||
const reportNames = Object.keys(reports);
|
||||
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
|
||||
|
||||
const tpl = templates.find((x) => x.name === tplName) ?? null;
|
||||
const tplSpecs = tpl?.param_specs ?? [];
|
||||
@@ -372,12 +371,12 @@ export default function FactorsPage() {
|
||||
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
|
||||
/>
|
||||
|
||||
{error && !running ? (
|
||||
{error && !active ? (
|
||||
<Banner tone="error">
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
|
||||
</Banner>
|
||||
) : null}
|
||||
{notice && !running ? <Banner tone="info">{notice}</Banner> : null}
|
||||
{notice && !active ? <Banner tone="info">{notice}</Banner> : null}
|
||||
|
||||
<Card
|
||||
title="因子目录"
|
||||
@@ -579,7 +578,7 @@ export default function FactorsPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
|
||||
<Card title="运行单因子测试" icon="play" tools={active ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
|
||||
<div className="form-grid">
|
||||
<Field label="开始日期">
|
||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
@@ -590,34 +589,30 @@ export default function FactorsPage() {
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="play"
|
||||
loading={running}
|
||||
disabled={running || selectedCount === 0}
|
||||
loading={active}
|
||||
disabled={active || selectedCount === 0}
|
||||
onClick={run}
|
||||
>
|
||||
{running
|
||||
? `运行中 ${processed}/${selectedCount}`
|
||||
{/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
|
||||
{active
|
||||
? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}`
|
||||
: selectedCount === 0
|
||||
? "请先勾选因子"
|
||||
: `运行因子测试(${selectedCount} 个)`}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
value={progressPct}
|
||||
label={
|
||||
<>
|
||||
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "}
|
||||
<span className="mono">{jobId || "排队中…"}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
|
||||
<JobProgress
|
||||
state={job.state}
|
||||
onCancel={job.cancel}
|
||||
onDismiss={job.reset}
|
||||
anchorId="factors-job-progress"
|
||||
kind="factor_test"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{reportNames.length === 0 && !running && !error ? (
|
||||
{reportNames.length === 0 && !active && !error ? (
|
||||
<Card>
|
||||
<Empty
|
||||
icon="chartLine"
|
||||
|
||||
@@ -1078,6 +1078,168 @@ table.tbl {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* 月度收益表:13 列,默认内边距(9px 12px)下要 1070px,装不进 1032px 的详情图层,
|
||||
右侧会被切掉 38px(就是「右侧内容溢出」的样子)。收到 8px 6px 后 13 列省下约 150px。 */
|
||||
.tbl--monthly th,
|
||||
.tbl--monthly td {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
/* ---------- 对比表(指标对比 / 参数差异):首列固定 ----------
|
||||
两张表都是「一行一个指标/参数 + 每个实验一列」,列数随对比个数增长,窄窗口必然横向滚动。
|
||||
首列(指标名、参数名)不固定的话,滚到右边看到一串数字却不知道对应哪一项。 */
|
||||
.tbl--pin-first th:first-child,
|
||||
.tbl--pin-first td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.tbl--pin-first thead th:first-child {
|
||||
z-index: 4;
|
||||
background: #131c2e;
|
||||
}
|
||||
.tbl--pin-first tbody tr:hover td:first-child {
|
||||
background: #17202f;
|
||||
}
|
||||
|
||||
/* ---------- 宽表(实验列表):固定两端 + 中间横向滚动 ----------
|
||||
实验列表有 11 列(选择 / 对比 / ID / 类型 / 发起时间 / 因子 / 区间 / 摘要 / 版本 / 体积 / 操作)。
|
||||
1366px 及更窄的窗口一屏放不下:不给右端「操作」列做固定,它会被推到卡片外 ——
|
||||
用户看到的是「右侧内容溢出了」,勾了「删除」却找不到「详情 / 复跑 / 打开归档」。
|
||||
所以:左端两列选择框与 ID、右端操作列**固定**,中间列横向滚动;
|
||||
同时用固定列宽 + 收紧内边距,让 1366px 以上仍然一屏放得下(不出现没必要的滚动条)。
|
||||
固定列必须有不透明底色,否则中间列会从下面透出来(卡片是渐变,这里取近似纯色)。 */
|
||||
.table-wrap--wide {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tbl--wide {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.tbl--wide th,
|
||||
.tbl--wide td {
|
||||
padding: 8px 9px;
|
||||
}
|
||||
.tbl--wide th:nth-child(1),
|
||||
.tbl--wide td:nth-child(1) {
|
||||
width: 42px;
|
||||
}
|
||||
.tbl--wide th:nth-child(2),
|
||||
.tbl--wide td:nth-child(2) {
|
||||
width: 42px;
|
||||
}
|
||||
.tbl--wide th:nth-child(3),
|
||||
.tbl--wide td:nth-child(3) {
|
||||
width: 117px;
|
||||
}
|
||||
.tbl--wide th:nth-child(4),
|
||||
.tbl--wide td:nth-child(4) {
|
||||
width: 62px;
|
||||
}
|
||||
.tbl--wide th:nth-child(5),
|
||||
.tbl--wide td:nth-child(5) {
|
||||
width: 104px;
|
||||
}
|
||||
.tbl--wide th:nth-child(6),
|
||||
.tbl--wide td:nth-child(6) {
|
||||
width: 117px;
|
||||
}
|
||||
.tbl--wide th:nth-child(7),
|
||||
.tbl--wide td:nth-child(7) {
|
||||
width: 104px;
|
||||
}
|
||||
.tbl--wide th:nth-child(8),
|
||||
.tbl--wide td:nth-child(8) {
|
||||
width: 140px;
|
||||
}
|
||||
.tbl--wide th:nth-child(9),
|
||||
.tbl--wide td:nth-child(9) {
|
||||
width: 137px;
|
||||
}
|
||||
/* 这个表头文字比列宽长(「版本(代码 / 数据)」),允许它折两行;
|
||||
其它 th 保持 nowrap,否则表头会挤成多行、列宽失控 */
|
||||
.tbl--wide th:nth-child(9) {
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tbl--wide th:nth-child(10),
|
||||
.tbl--wide td:nth-child(10) {
|
||||
width: 71px;
|
||||
}
|
||||
.tbl--wide th:nth-child(11),
|
||||
.tbl--wide td:nth-child(11) {
|
||||
width: 186px;
|
||||
text-align: right;
|
||||
}
|
||||
/* 版本列的数据指纹 chip 很长(d20260904;n≈7688k;a≈7922k;b≈7718k):
|
||||
不设上限就会横向顶进「操作」列(看起来就是「右侧内容溢出」)。这里在单元格内省略,
|
||||
完整值仍在 chip 的 title 里(悬停可见),不靠截断字符串假装完整。 */
|
||||
.tbl--wide td:nth-child(9) .row {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tbl--wide td:nth-child(9) .tag,
|
||||
.tbl--wide td:nth-child(9) .hint {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* ID:等宽字符串不能折行(折成 "EXP-"/"8EA2819B" 就没法扫读),宽度不够时省略号 +
|
||||
title 给完整值。发起时间与区间**允许折行**:它们按「日期 / 时刻」「起 / 止」自然断行,
|
||||
比折在日期中间("2020-01-")可读,也让这两列不必占满一行的宽度。 */
|
||||
.tbl--wide td:nth-child(3) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 左端固定:选择(删除)/ 对比 / ID。left 偏移 = 前面各固定列的宽度之和,
|
||||
所以上面的列宽改动必须同步改这里的 left(table-layout: fixed 下宽度是确定的)。 */
|
||||
.tbl--wide th:nth-child(-n + 3),
|
||||
.tbl--wide td:nth-child(-n + 3) {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.tbl--wide th:nth-child(1),
|
||||
.tbl--wide td:nth-child(1) {
|
||||
left: 0;
|
||||
}
|
||||
.tbl--wide th:nth-child(2),
|
||||
.tbl--wide td:nth-child(2) {
|
||||
left: 38px;
|
||||
}
|
||||
.tbl--wide th:nth-child(3),
|
||||
.tbl--wide td:nth-child(3) {
|
||||
left: 76px;
|
||||
}
|
||||
/* 右端固定:操作列。加一点内阴影,滚动时能看出「这一列是浮在上面的」 */
|
||||
.tbl--wide th:nth-child(11),
|
||||
.tbl--wide td:nth-child(11) {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
background: var(--surface-1);
|
||||
box-shadow: -10px 0 12px -12px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.tbl--wide thead th {
|
||||
z-index: 4;
|
||||
background: #131c2e;
|
||||
}
|
||||
/* 行 hover / 选中:固定列的底色也要跟着变,否则整行高亮时两端是「断开」的 */
|
||||
.tbl--wide tbody tr:hover td:nth-child(-n + 3),
|
||||
.tbl--wide tbody tr:hover td:nth-child(11) {
|
||||
background: #17202f;
|
||||
}
|
||||
.tbl--wide tbody tr.row-active td:nth-child(-n + 3),
|
||||
.tbl--wide tbody tr.row-active td:nth-child(11) {
|
||||
background: #17253d;
|
||||
}
|
||||
|
||||
/* 扩展行 / 详情内嵌 */
|
||||
.expand-cell {
|
||||
background: rgba(10, 15, 28, 0.6);
|
||||
@@ -2185,3 +2347,135 @@ 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;
|
||||
}
|
||||
/* 可排序表头:本体必须和同一行里的控件(如「全选本页」复选框,命中区 34px)等高,
|
||||
否则 19.2px 高的点击目标既难点中,也会被 UI 规范门禁判为「同排控件不等高」。
|
||||
行高不因此变化:表头行本来就由 34px 的复选框决定。 */
|
||||
.th-sort {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
min-height: var(--ctl-h-md);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.th-sort:hover {
|
||||
background: rgba(150, 165, 195, 0.075);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
import { waitJob } from "@/lib/jobs";
|
||||
import { useJobRunner } from "@/lib/jobs";
|
||||
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
|
||||
import type {
|
||||
FactorMeta,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Empty,
|
||||
SkeletonLines,
|
||||
} from "@/components/ui";
|
||||
import { JobProgress } from "@/components/JobProgress";
|
||||
|
||||
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
|
||||
{ value: "static.industry", label: "行业 industry", kind: "str" },
|
||||
@@ -80,8 +81,22 @@ export default function SelectionPage() {
|
||||
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
|
||||
*/
|
||||
const [savedSelectionId, setSavedSelectionId] = useState("");
|
||||
/**
|
||||
* 同步选股(POST /selections)正在执行 —— 这条路径是**同步接口**,没有 job_id,
|
||||
* 所以不能套 useJobRunner(它要轮询 /jobs/{id})。保留 running 只用于这条路径的按钮态。
|
||||
*/
|
||||
const [running, setRunning] = useState(false);
|
||||
const [asyncBusy, setAsyncBusy] = useState(false);
|
||||
/**
|
||||
* 异步选股(POST /selections/jobs)的作业反馈统一交给 `useJobRunner`:
|
||||
* 提交瞬间就有「正在提交作业…」,之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
|
||||
* 此前只有一个 asyncBusy 布尔值,用户点了「异步(全市场)」只看到按钮变灰,
|
||||
* 不知道到底提交成功没、作业号是多少(正是「点了没反应」的投诉点)。
|
||||
*/
|
||||
const job = useJobRunner<SelectionResult>("异步选股(全市场)");
|
||||
/** 异步作业是否在跑:既用于禁用两个按钮,也用于切换异步按钮的文案。 */
|
||||
const asyncActive =
|
||||
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
|
||||
/** 只留「同步选股失败 / 历史读取失败」这类**作业之外**的错误;异步作业的失败由反馈条显示后端原文 */
|
||||
const [error, setError] = useState("");
|
||||
const [history, setHistory] = useState<SelectionMeta[] | null>(null);
|
||||
|
||||
@@ -146,26 +161,24 @@ export default function SelectionPage() {
|
||||
|
||||
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
|
||||
async function runAsync() {
|
||||
setAsyncBusy(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
|
||||
setSavedSelectionId("");
|
||||
try {
|
||||
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
|
||||
const out = await waitJob<SelectionResult>(job_id);
|
||||
if (out.status === "success" && out.result) {
|
||||
setSelectionId(job_id);
|
||||
setResult(out.result);
|
||||
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setAsyncBusy(false);
|
||||
// 提交闭包把真实 job_id 记下来:页头那个 Pill 沿用老行为显示本次运行的 id,
|
||||
// 而 job.state.jobId 在 await 之后读到的仍是本轮渲染的旧值(React 状态不会在同一 tick 内更新)
|
||||
let submittedId = "";
|
||||
const out = await job.run(async () => {
|
||||
const r = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
|
||||
submittedId = r.job_id;
|
||||
return r;
|
||||
});
|
||||
if (out.status === "success" && out.result) {
|
||||
setSelectionId(submittedId);
|
||||
setResult(out.result);
|
||||
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
|
||||
}
|
||||
// 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
|
||||
}
|
||||
|
||||
async function openHistory(id: string) {
|
||||
@@ -378,13 +391,21 @@ export default function SelectionPage() {
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncBusy} onClick={run}>
|
||||
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncActive} onClick={run}>
|
||||
{running ? "筛选中…" : "执行选股"}
|
||||
</Btn>
|
||||
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}>
|
||||
{asyncBusy ? "后台执行中…" : "异步(全市场)"}
|
||||
<Btn variant="ghost" icon="clock" loading={asyncActive} disabled={running || asyncActive} onClick={runAsync}>
|
||||
{asyncActive ? "后台执行中…" : "异步(全市场)"}
|
||||
</Btn>
|
||||
</div>
|
||||
{/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
|
||||
<JobProgress
|
||||
state={job.state}
|
||||
onCancel={job.cancel}
|
||||
onDismiss={job.reset}
|
||||
anchorId="selection-job-progress"
|
||||
kind="selection"
|
||||
/>
|
||||
{error ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Banner tone="error">{error}</Banner>
|
||||
|
||||
@@ -138,6 +138,21 @@ export default function SignalsPage() {
|
||||
{running ? "生成中…" : "生成信号"}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
为什么这里没有 JobProgress:`POST /signals` 是**同步**接口(backend/app/api/signals.py
|
||||
只有 POST "",没有 /signals/jobs),拿不到 job_id,也就无从轮询阶段 —— 套 useJobRunner
|
||||
会去 GET /jobs/{signal_id} 撞 404,而合成一个假的 JobRunState 更是「看似有数据的假象」。
|
||||
所以这里如实说清三件事:同步连接、没有作业号/阶段/取消、出错时显示后端原文。
|
||||
*/}
|
||||
{running ? (
|
||||
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
|
||||
同步请求已发出:请求期间浏览器一直连着后端(这条路径不是后台作业,不能关页离开)。
|
||||
因为是同步接口,<b>没有作业号与执行阶段,也没有取消按钮</b>。
|
||||
该请求通常在数秒内返回;若后端报错或连接中断,下方会显示后端原文。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
|
||||
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
|
||||
const equitySeries = useMemo<LwSeries[]>(
|
||||
() => [
|
||||
{
|
||||
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<LwSeries[]>(() => equitySeries(result), [result]);
|
||||
const equityMarkers = useMemo<LwMarker[]>(
|
||||
() => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))),
|
||||
[result]
|
||||
);
|
||||
|
||||
const equityMarkers = useMemo<LwMarker[]>(() => {
|
||||
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
|
||||
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<FactorLabels>(() => {
|
||||
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={
|
||||
<Pill tone="pos">
|
||||
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
|
||||
</Pill>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone="pos">
|
||||
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
|
||||
</Pill>
|
||||
<ChartPopoutLink archiveId={archive?.id} series="equity" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={equitySeries}
|
||||
series={equityData}
|
||||
markers={equityMarkers}
|
||||
height={320}
|
||||
valueFormat={(v) => fmtNum(v)}
|
||||
valueFormat={equityValueFormat}
|
||||
ariaLabel="组合净值曲线与买卖点"
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
@@ -195,19 +196,15 @@ export function BacktestResultView({
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title="回撤(%)"
|
||||
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>
|
||||
<ChartPopoutLink archiveId={archive?.id} series="drawdown" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
key: "dd",
|
||||
label: "回撤(%)",
|
||||
type: "area",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ 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%);未持有期间不绘制,
|
||||
分段间以直线连接,请以买卖点区分持仓区间。
|
||||
</span>
|
||||
<ChartPopoutLink
|
||||
archiveId={archive?.id}
|
||||
series={`sym:${activeCurve?.symbol ?? ""}`}
|
||||
label="放大当前个股"
|
||||
/>
|
||||
</div>
|
||||
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
|
||||
<div className="table-wrap" style={{ marginTop: 12 }}>
|
||||
@@ -303,7 +305,14 @@ export function BacktestResultView({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
|
||||
<FactorCurvesCard result={result} fills={result.fills} archiveId={archive?.id} />
|
||||
|
||||
<Card
|
||||
id="sec-monthly"
|
||||
icon="calendar"
|
||||
title="月度收益(%)"
|
||||
tools={<ChartPopoutLink archiveId={archive?.id} series="monthly" />}
|
||||
>
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
@@ -408,6 +417,8 @@ export function BacktestResultView({
|
||||
<th>买价</th>
|
||||
<th>卖价</th>
|
||||
<th>收益</th>
|
||||
<th>为什么买</th>
|
||||
<th>为什么卖</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -423,6 +434,12 @@ export function BacktestResultView({
|
||||
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
|
||||
{t.return_pct.toFixed(2)}%
|
||||
</td>
|
||||
<td className="reason-brief" title={t.entry_reason?.text ?? undefined}>
|
||||
<span>{reasonLabel(t.entry_reason?.code)}</span>
|
||||
</td>
|
||||
<td className="reason-brief" title={t.exit_reason?.text ?? undefined}>
|
||||
<span>{reasonLabel(t.exit_reason?.code)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -433,6 +450,8 @@ export function BacktestResultView({
|
||||
|
||||
<NotFilledCard signals={result.signal_history ?? []} />
|
||||
|
||||
<TradeReasonsCard signals={result.signal_history ?? []} factorLabels={factorLabels} />
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
</>
|
||||
);
|
||||
@@ -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<LwMarker[]>(
|
||||
() =>
|
||||
(curve.marks ?? [])
|
||||
.filter((a) => valueByDate.has(a.date))
|
||||
.map((a) => ({
|
||||
time: a.date,
|
||||
kind: a.signal,
|
||||
text: a.signal === "BUY" ? "买" : "卖",
|
||||
})),
|
||||
[curve, valueByDate]
|
||||
);
|
||||
return (
|
||||
<LwChart
|
||||
series={[
|
||||
{
|
||||
key: "sym",
|
||||
label: `${curve.symbol} 持仓期累计收益(%)`,
|
||||
type: "area",
|
||||
color: CHART.accent,
|
||||
data: curve.points.map((p) => ({ 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 (
|
||||
<Card
|
||||
id="sec-factors"
|
||||
icon="layers"
|
||||
title={`因子曲线 · ${curves.length} 个(买卖依据的水平)`}
|
||||
tools={<Pill tone="violet">持仓加权平均原始值</Pill>}
|
||||
>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
每个因子一条曲线:值为当日「持仓股票按市值加权平均」的因子原始值,用来回答
|
||||
「买入时这个因子处于什么水平、卖出时又变到哪」。图上 ▲/▼ 是「组合的成交日」(同一套买卖点),
|
||||
因此能直接对照「因子在什么水平触发买卖」。曲线未做 z-score、也未按方向取反;
|
||||
低为好的因子(方向标注为「越低越好」)曲线升高不等于更好。
|
||||
</div>
|
||||
<div className="chart-grid">
|
||||
{curves.map((c, i) => {
|
||||
const dates = new Set(c.points.map((p) => p.date));
|
||||
return (
|
||||
<Card
|
||||
key={c.name}
|
||||
title={c.label}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Pill tone={c.direction === "lower_is_better" ? "warn" : "pos"}>
|
||||
{c.direction === "lower_is_better" ? "越低越好" : "越高越好"}
|
||||
</Pill>
|
||||
<ChartPopoutLink archiveId={archiveId} series={`factor:${c.name}`} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={[factorSeries(c, i)]}
|
||||
markers={portfolioMarkers(fills, dates)}
|
||||
height={280}
|
||||
valueFormat={factorValueFormat(c)}
|
||||
ariaLabel={`因子 ${c.label} 的持仓加权曲线与买卖点`}
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
{factorCurveNote(c)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function poolLabel(result: BacktestResult): string {
|
||||
const sel = result.config_snapshot?.selection as
|
||||
| { top_n?: number; hold_top_x?: number | null }
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className="hint"
|
||||
title="本次结果没有归档 id(未归档或归档已删除),无法在新页面打开同一份数据"
|
||||
>
|
||||
未归档,无法放大
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
className={size === "sm" ? "btn btn--sm" : "btn"}
|
||||
href={`/charts/${encodeURIComponent(archiveId)}?s=${encodeURIComponent(series)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="在新标签页整页打开这条曲线"
|
||||
>
|
||||
<Icon name="arrowUpRight" size={13} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业反馈条(全站统一)。
|
||||
*
|
||||
* 解决的问题(用户反馈原话):「点了回测没有任何反馈,不清楚是不是已经开始回测」。
|
||||
* 因此这里有三条硬要求:
|
||||
* 1. **点了立刻有字**:`submitting` 阶段就显示「正在提交作业…」,不等到后端返回;
|
||||
* 2. **看得出在动**:作业号 + 真实阶段 + **每秒自增**的已用时间(阶段没变也在走);
|
||||
* 3. **出事了能自救**:排队中可「取消」,失败显示后端原文,成功给「打开归档 / 去对比」。
|
||||
*
|
||||
* 反馈条出现时会自动滚到视野中央 —— 按钮常在长表单底部,反馈条在页面顶部,
|
||||
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Btn, Pill } from "@/components/ui";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { STAGE_LABEL, STAGE_PIPELINES, type JobKind, type JobRunState } from "@/lib/jobs";
|
||||
|
||||
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
|
||||
function fmtElapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function fmtClock(d: Date | null): string {
|
||||
if (!d) return "";
|
||||
return d.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function JobProgress<T>({
|
||||
state,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
|
||||
anchorId = "job-progress",
|
||||
/** 作业类型:决定阶段小圆点画哪几条 —— 因子测试没有「逐择股日选股 / 撮合与净值结算」,
|
||||
* 选股也只有一个真实阶段,全站共用一张表会把这些不存在的阶段画成「○ 待开始」。 */
|
||||
kind = "backtest",
|
||||
}: {
|
||||
state: JobRunState<T>;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
anchorId?: string;
|
||||
kind?: JobKind;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const scrolled = useRef(false);
|
||||
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
|
||||
/** 本类作业真实会走的阶段(不含 queued / done —— 那是作业状态,不是后端阶段) */
|
||||
const pipeline = STAGE_PIPELINES[kind];
|
||||
/**
|
||||
* 后端阶段是前端还不认识的枚举时(后端先加、前端文案没跟上),
|
||||
* 保留**上一个已知阶段**的进度而不是把整排圆点打成灰(那看起来像「什么都没在跑」);
|
||||
* 原始枚举另在 Pill 上兜底显示(如「执行中(factor_calculation)」),不出现裸英文。
|
||||
*/
|
||||
const [lastKnown, setLastKnown] = useState("");
|
||||
useEffect(() => {
|
||||
// 新一轮提交(submitting)先清空:否则同一页第二次运行时,会拿上一轮的最后阶段当进度,
|
||||
// 新作业明明刚排队却显示「已完成到分析的 ✓」——那是假的进度。
|
||||
if (state.phase === "submitting") {
|
||||
setLastKnown("");
|
||||
return;
|
||||
}
|
||||
if (pipeline.includes(state.stage)) setLastKnown(state.stage);
|
||||
}, [state.phase, state.stage, pipeline]);
|
||||
|
||||
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
|
||||
// 顶部出现的反馈条不滚过去就等于没显示。
|
||||
useEffect(() => {
|
||||
if (state.phase === "idle") {
|
||||
scrolled.current = false;
|
||||
return;
|
||||
}
|
||||
if ((state.phase === "submitting" || state.phase === "queued") && !scrolled.current) {
|
||||
scrolled.current = true;
|
||||
ref.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
}, [state.phase]);
|
||||
|
||||
if (state.phase === "idle") return null;
|
||||
|
||||
const stageText = state.stage
|
||||
? state.stage === "cancelling"
|
||||
? "正在取消…"
|
||||
: (STAGE_LABEL[state.stage] ?? `执行中(${state.stage})`)
|
||||
: "等待后端回报阶段";
|
||||
const cur = pipeline.indexOf(pipeline.includes(state.stage) ? state.stage : lastKnown);
|
||||
const archiveHref = state.experimentId
|
||||
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
|
||||
: null;
|
||||
|
||||
const tone =
|
||||
state.phase === "success" ? "job-progress--ok" : state.phase === "failed" || state.phase === "timeout" ? "job-progress--bad" : "job-progress--run";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={anchorId}
|
||||
className={`job-progress ${tone}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-phase={state.phase}
|
||||
>
|
||||
<div className="job-progress__head">
|
||||
{active ? (
|
||||
<Pill tone="accent" icon="spinner">
|
||||
{state.phase === "submitting" ? "正在提交作业…" : stageText}
|
||||
</Pill>
|
||||
) : state.phase === "success" ? (
|
||||
<Pill tone="pos" icon="check">
|
||||
已完成
|
||||
</Pill>
|
||||
) : state.phase === "cancelled" ? (
|
||||
<Pill tone="warn" icon="x">
|
||||
已取消
|
||||
</Pill>
|
||||
) : (
|
||||
<Pill tone="neg" icon="alert">
|
||||
{state.phase === "timeout" ? "等待超时" : "执行失败"}
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
<span className="job-progress__title">
|
||||
{state.label || "研究任务"}
|
||||
{state.progress ? (
|
||||
<span className="hint">
|
||||
{" "}
|
||||
(第 {state.progress.index}/{state.progress.total} 个)
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span className="job-progress__meta">
|
||||
{state.jobId ? (
|
||||
<>
|
||||
作业 <span className="mono">{state.jobId}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="hint">正在换取作业号…</span>
|
||||
)}
|
||||
{state.startedAt ? <span className="hint">发起于 {fmtClock(state.startedAt)}</span> : null}
|
||||
{active ? <span className="mono">已用 {fmtElapsed(state.elapsedMs)}</span> : null}
|
||||
</span>
|
||||
|
||||
<span className="job-progress__actions">
|
||||
{active && onCancel && state.jobId ? (
|
||||
<Btn size="sm" icon="x" loading={state.cancelling} disabled={state.cancelling} onClick={onCancel}>
|
||||
取消任务
|
||||
</Btn>
|
||||
) : null}
|
||||
{!active && archiveHref ? (
|
||||
<Link className="btn btn--sm" href={archiveHref}>
|
||||
<span>去对比</span>
|
||||
</Link>
|
||||
) : null}
|
||||
{!active && state.experimentId ? (
|
||||
<Link
|
||||
className="btn btn--sm"
|
||||
href={`/experiments/${encodeURIComponent(state.experimentId)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>打开归档</span>
|
||||
<Icon name="arrowUpRight" size={13} />
|
||||
</Link>
|
||||
) : null}
|
||||
{!active && onDismiss ? (
|
||||
<Btn size="sm" onClick={onDismiss}>
|
||||
收起
|
||||
</Btn>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div className="stages" style={{ marginTop: 8 }}>
|
||||
{pipeline.map((st) => {
|
||||
const mine = pipeline.indexOf(st);
|
||||
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
|
||||
return (
|
||||
<span className={cls} key={st}>
|
||||
{cur >= 0 && mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{active ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
后台执行中:可以离开本页(刷新 / 关页都不影响),跑完会自动归档到「实验对比」。
|
||||
全市场多年区间通常 3~5 分钟;阶段与已用时间来自后端真实状态,不是假进度条。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.phase === "failed" || state.phase === "timeout" ? (
|
||||
<div className="job-progress__err" role="alert">
|
||||
{state.error || "任务失败,后端没有给出原因。"}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.phase === "cancelled" ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
任务已取消,没有归档(取消的任务不产生实验记录)。可以改完参数再跑一次。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.phase === "success" ? (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
{state.experimentId
|
||||
? `已归档为实验 ${state.experimentId},可在「实验对比」里与其它版本叠曲线、比参数。`
|
||||
: "已完成(本次没有归档,可能是同步任务)。"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 图层样式(只服务 components/Modal.tsx)。
|
||||
*
|
||||
* 刻意用 CSS Module 而不是 app/globals.css:全局样式表被多个页面共用,
|
||||
* 而这里的选择器名(backdrop/dialog/head/body)太通用,写进全局极易与别处冲突;
|
||||
* CSS Module 会在构建时把类名变成局部哈希,天然隔离。
|
||||
* 颜色/圆角/间距全部取 globals.css 里已有的设计令牌,保证与全站同一套视觉。
|
||||
*/
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-5);
|
||||
/* 半透明遮罩 + 轻微毛玻璃:背后的列表仍可辨认位置,但不会与图层内容抢注意力 */
|
||||
background: rgba(4, 8, 18, 0.66);
|
||||
backdrop-filter: blur(3px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1100px, 100%);
|
||||
/* 小屏/长内容时图层本身不超过 90vh,滚动交给 .body */
|
||||
max-height: 90vh;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--surface-1);
|
||||
box-shadow: var(--shadow-pop);
|
||||
/* 对话框用 tabIndex={-1} 聚焦:去掉浏览器默认的聚焦描边,改用下面的 :focus-visible */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.close {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 图标按钮用标准控件高度(34px)而不是密集行的 28px:图层是浮层,
|
||||
关闭是唯一出口,点击目标不该比正文按钮更小;字号也调大一档,
|
||||
避免「×」在深色底上细到看不见(截图逐字核对时发现对比度偏低)。 */
|
||||
width: var(--ctl-h-md);
|
||||
height: var(--ctl-h-md);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-1);
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-1);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.close:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 内容区:归档结果可以很长(净值曲线 + 持仓 + 成交表),所以这里独立滚动,
|
||||
标题栏与关闭按钮始终可见(不用把用户滚回顶部才能关掉图层)。 */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通用图层(overlay / modal)。
|
||||
*
|
||||
* 为什么自建而不引第三方弹窗库:全站只有「实验详情预览」这一处需要图层,为一个
|
||||
* 需求引入 react-modal / focus-trap 之类依赖不划算。但下面四件事直接影响可用性,
|
||||
* 必须自己做对(不是装饰):
|
||||
* ① **锁 body 滚动**:不锁的话滚轮会滚到背后的列表,用户会以为图层没盖住;
|
||||
* ② **ESC 关闭 + 点遮罩关闭**:且点内容区不关(否则在图层里选中文字、拖滚动条
|
||||
* 都会误关);遮罩判定用 mousedown 而不是 click —— 从内容区按下、拖到遮罩上
|
||||
* 再松手时 click 的目标是两者的共同祖先(遮罩),用 click 会误判为「点了遮罩」;
|
||||
* ③ **焦点管理**:打开时焦点进对话框,关闭后**还给触发它的按钮** —— 否则键盘
|
||||
* 用户关掉图层后焦点回到 <body>,再按 Tab 会从页首重新走一遍;
|
||||
* ④ **role="dialog" + aria-modal + aria-labelledby**:屏幕阅读器才知道「这是一个
|
||||
* 对话框、它的标题是什么、背景内容应当被屏蔽」。
|
||||
*
|
||||
* 用 createPortal 挂到 document.body:图层必须脱离列表页的层叠上下文与 overflow
|
||||
* 裁剪 —— 放在带 transform / overflow:hidden 的祖先里会被裁掉或压不住子元素。
|
||||
*
|
||||
* 样式走 `components/Modal.module.css`(CSS Module),刻意不写进 app/globals.css:
|
||||
* 全局样式表被多处共用,改它容易与并行的改动互相覆盖;图层样式只服务本组件。
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@/components/icons";
|
||||
import styles from "./Modal.module.css";
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
onClose,
|
||||
tools,
|
||||
children,
|
||||
returnFocusTo,
|
||||
}: {
|
||||
/** 标题栏文案:同时作为 aria-labelledby 指向的可见标题 */
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
/** 标题栏右侧动作(如「在新页面打开完整归档」);关闭按钮由本组件自己追加 */
|
||||
tools?: ReactNode;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* 关闭后要把焦点还给谁。
|
||||
* 调用方显式传入触发按钮最可靠(例如每行的「详情」按钮);不传时退化为
|
||||
* 「打开那一刻 document.activeElement 是谁」,对点击入口通常也成立。
|
||||
*/
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
/**
|
||||
* createPortal 需要 document,而 Next 会对客户端组件做一次 SSR:
|
||||
* 直接 portal 会在服务端拿不到 document 而抛错。首帧不渲染,挂载后再上图层。
|
||||
*/
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
/**
|
||||
* onClose 每次父组件渲染都是新函数;用 ref 保存,下面的副作用就能只在挂载时
|
||||
* 绑定一次键盘监听 —— 反复解绑/重绑期间正好按下的 ESC 会被漏掉。
|
||||
*/
|
||||
const closeRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const restore = returnFocusTo ?? (document.activeElement as HTMLElement | null);
|
||||
|
||||
// 锁滚动:保存原值而不是关闭时写 "",否则会覆盖页面本来就设置过的 overflow
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// 打开时把焦点移进对话框:tabIndex={-1} 让它可聚焦,但不会进 Tab 序列
|
||||
dialogRef.current?.focus();
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
// 图层是 aria-modal:ESC 只应关掉图层,不应冒泡去触发页面的其它快捷键
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
// 焦点陷阱:不拦住的话 Tab 会跑到图层背后的列表里(视觉上"消失"了)
|
||||
const root = dialogRef.current;
|
||||
if (!root) return;
|
||||
const items = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
root.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || active === root)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
// 触发按钮可能已随列表刷新消失(例如刚删掉这一行):只有还在文档里才回焦,
|
||||
// 否则 focus() 无效,焦点会掉回 <body>(键盘用户会"掉回页首")。
|
||||
if (restore && restore.isConnected) restore.focus();
|
||||
};
|
||||
// 只在挂载/卸载时执行;returnFocusTo 只在打开那一刻读一次,不参与后续渲染
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mounted]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
// 点遮罩关闭:判定 target === currentTarget,点内容区(子元素)不会命中
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeRef.current();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={styles.dialog}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={styles.head}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<div className={styles.tools}>
|
||||
{tools}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
onClick={() => closeRef.current()}
|
||||
aria-label="关闭详情图层"
|
||||
title="关闭(ESC)"
|
||||
>
|
||||
<Icon name="x" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"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<string, string>;
|
||||
|
||||
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 (
|
||||
<span className="hint">
|
||||
{fallback ? `${fallback}(旧归档无结构化理由)` : "—(旧归档无结构化理由)"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const facts = reasonFacts(reason, factorLabels);
|
||||
return (
|
||||
<div className="reason-cell">
|
||||
<div className="reason-cell__head">
|
||||
<Pill tone={reason.code.startsWith("buy") ? "pos" : "warn"}>{reasonLabel(reason.code)}</Pill>
|
||||
<span className="reason-cell__text">{reason.text}</span>
|
||||
</div>
|
||||
{facts.length ? (
|
||||
<div className="reason-cell__facts">
|
||||
{facts.map((f) => (
|
||||
<span className="chip" key={f}>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Side = "all" | "BUY" | "SELL";
|
||||
type Fill = "all" | "filled" | "unfilled";
|
||||
|
||||
/**
|
||||
* 全部买卖点 + 理由(默认按日期倒序,最新的一笔在最上面)。
|
||||
*
|
||||
* 为什么默认倒序:回测结果里「最近发生了什么」通常是最想看的;要按时间顺序读,
|
||||
* 点表头「日期」即可切换。
|
||||
*/
|
||||
export function TradeReasonsCard({
|
||||
signals,
|
||||
factorLabels = {},
|
||||
}: {
|
||||
signals: ActionRecord[];
|
||||
factorLabels?: FactorLabels;
|
||||
}) {
|
||||
const [side, setSide] = useState<Side>("all");
|
||||
const [fill, setFill] = useState<Fill>("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 (
|
||||
<Card
|
||||
id="sec-reasons"
|
||||
icon="book"
|
||||
title={`买卖说明 · ${signals.length} 个买卖点`}
|
||||
tools={
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
<Pill>已成交 {filled}</Pill>
|
||||
<Pill tone="warn">未成交 {signals.length - filled}</Pill>
|
||||
<Pill tone={withReason === signals.length ? "pos" : "warn"}>
|
||||
{withReason}/{signals.length} 条带结构化理由
|
||||
</Pill>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
<div className="seg">
|
||||
{(
|
||||
[
|
||||
["all", "全部"],
|
||||
["BUY", "买入"],
|
||||
["SELL", "卖出"],
|
||||
] as [Side, string][]
|
||||
).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={side === v ? "seg__btn is-on" : "seg__btn"}
|
||||
onClick={() => setSide(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="seg">
|
||||
{(
|
||||
[
|
||||
["all", "不限成交"],
|
||||
["filled", "只看成交"],
|
||||
["unfilled", "只看未成交"],
|
||||
] as [Fill, string][]
|
||||
).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={fill === v ? "seg__btn is-on" : "seg__btn"}
|
||||
onClick={() => setFill(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="input input--search"
|
||||
placeholder="搜索代码 / 名称 / 理由"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
aria-label="搜索买卖点"
|
||||
/>
|
||||
<Pill>{rows.length} 条</Pill>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className="th-sort" onClick={() => setDesc((d) => !d)}>
|
||||
日期 {desc ? "↓" : "↑"}
|
||||
</button>
|
||||
</th>
|
||||
<th>方向</th>
|
||||
<th>股票</th>
|
||||
<th>价格</th>
|
||||
<th>买卖理由(引擎给出的当时数字)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr key={`${a.signal}-${a.date}-${a.symbol}-${a.price ?? ""}`}>
|
||||
<td className="mono dim nowrap">{a.date}</td>
|
||||
<td>
|
||||
{a.filled ? (
|
||||
<Pill tone={a.signal === "BUY" ? "pos" : "neg"}>
|
||||
{a.signal === "BUY" ? "买入" : "卖出"}
|
||||
</Pill>
|
||||
) : (
|
||||
<Pill tone="warn" icon="alert">
|
||||
{a.signal === "BUY" ? "想买未成" : "想卖未成"}
|
||||
</Pill>
|
||||
)}
|
||||
</td>
|
||||
<td className="sym-cell">
|
||||
<SymbolLink symbol={a.symbol} name={a.name} />
|
||||
</td>
|
||||
<td className="mono nowrap">{a.price != null ? fmtFixed(a.price, 2) : "—"}</td>
|
||||
<td>
|
||||
<TradeReasonCell
|
||||
reason={a.reason}
|
||||
fallback={a.reject_reason}
|
||||
factorLabels={factorLabels}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
「想买未成 / 想卖未成」= 策略当天确实要下单,但被涨停、跌停、停牌或现金挡住
|
||||
(执行层原因在理由里写清)。名次、综合分、因子值、持有交易日都取自「引擎当时的计算」,
|
||||
界面不做二次推算;因子值是原始值(未做 z-score、不按方向取反)。
|
||||
{withReason < signals.length
|
||||
? ` 有 ${signals.length - withReason} 条来自 2026-10 之前的旧归档,当时还没有结构化理由。`
|
||||
: ""}
|
||||
{" "}覆盖边界:「当日排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点
|
||||
(要看完整候选与名次,请看选股明细);每个真正的买卖点 —— 包括涨停、停牌、
|
||||
跌停、现金不足这类没成交的 —— 都在上表里。
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<LwFormatKey, (v: number) => 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<Set<string>>(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<Set<string>>(new Set());
|
||||
const [tip, setTip] = useState<{
|
||||
|
||||
@@ -395,7 +395,9 @@ export function MonthlyReturnsTable({
|
||||
if (years.length === 0) return <Empty title="无月度收益数据" />;
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
{/* 13 列(年份 + 12 个月):窄容器(实验详情图层 1032px、手机)一定放不下,
|
||||
所以收紧内边距 + 首列「年份」固定,让月份横向滚动时也知道看的是哪一年。 */}
|
||||
<table className="tbl tbl--monthly tbl--pin-first">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>年份</th>
|
||||
|
||||
@@ -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<string>
|
||||
): LwMarker[] {
|
||||
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
|
||||
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);
|
||||
+253
-1
@@ -3,6 +3,8 @@
|
||||
* 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
|
||||
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { apiGet, apiPost } from "./api";
|
||||
import type { ResearchSpec } from "./types";
|
||||
|
||||
@@ -65,16 +67,65 @@ export async function waitJob<T>(
|
||||
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
|
||||
}
|
||||
|
||||
/** 阶段中文名(后端 stage 枚举 → 用户可读) */
|
||||
/** 阶段中文名(后端 stage 枚举 → 用户可读)。
|
||||
*
|
||||
* 必须覆盖每种作业**上报过的全部阶段**:漏一个,JobProgress 只能把裸枚举摆给用户
|
||||
* —— 实测漏过 `factor_calculation`(/factors 反馈条上直接出现过英文 `factor_calculation`)。
|
||||
* 新增阶段时请同时补这里的中文名与下方 `STAGE_PIPELINES` 里对应的序列。
|
||||
*/
|
||||
export const STAGE_LABEL: Record<string, string> = {
|
||||
queued: "排队中",
|
||||
data_loading: "加载行情与因子数据",
|
||||
factor_calculation: "计算因子值",
|
||||
selection: "逐择股日选股",
|
||||
backtesting: "撮合与净值结算",
|
||||
analysis: "汇总指标与曲线",
|
||||
done: "完成",
|
||||
};
|
||||
|
||||
/** 作业类型 —— **只列后端真的会异步执行的 kind**:
|
||||
* - `signals` 不在其中:`POST /api/signals` 是同步接口(没有 /signals/jobs),没有作业阶段可言;
|
||||
* - `combo`(回测组合)也不单列:它上报的阶段与 backtest 完全一致(证据见下),直接映射到 "backtest"。
|
||||
*/
|
||||
export type JobKind = "factor_test" | "backtest" | "selection";
|
||||
|
||||
/**
|
||||
* 每种作业**真实会走**的阶段序列 —— 只放后端真的会上报的阶段,**不含 `queued` / `done`**:
|
||||
* 「排队中」「已完成」是作业**状态**(由 `phase` 表达,见 JobRunState),不是后端上报的执行阶段,
|
||||
* 混进圆点序列会让「尚未开始的第一个圆点」和「已结束」看起来像同一条进度。
|
||||
*
|
||||
* 为什么必须按作业类型分开:全站共用一条「阶段小圆点」之后,用一张全局表会把因子测试
|
||||
* 根本不存在的阶段(逐择股日选股 / 撮合与净值结算)也画出来 —— 那正是要避免的
|
||||
* 「看似有数据的假象」;反过来,后端新阶段如果不在表里,圆点会整排变灰。
|
||||
*
|
||||
* 证据(后端上报点,改动前请重新 grep 核对,勿凭印象):
|
||||
* - factor_test:backend/app/quant/service.py:304 data_loading → :306 factor_calculation → :308 analysis
|
||||
* - backtest :backend/app/quant/service.py:314 data_loading → :316 backtesting → :319 analysis
|
||||
* - selection :backend/app/application/services/job_executor.py:156 selection(真的只有这一段)
|
||||
* - combo(复用 backtest 表):backend/app/application/services/combo_service.py:68 data_loading
|
||||
* → :70 backtesting → :90 analysis;组合的「逐择股日选股」是在 run_combo_backtest 内部完成的,
|
||||
* 后端从未单独上报 selection 阶段,所以回测这条线不放 selection 圆点。
|
||||
*/
|
||||
export const STAGE_PIPELINES: Record<JobKind, string[]> = {
|
||||
factor_test: ["data_loading", "factor_calculation", "analysis"],
|
||||
backtest: ["data_loading", "backtesting", "analysis"],
|
||||
selection: ["selection"],
|
||||
};
|
||||
|
||||
/** @deprecated 已改为按作业类型取 `STAGE_PIPELINES[kind]`。保留导出仅为兼容旧引用,
|
||||
* 其值等于回测的圆点序列(不再含 queued/done —— 它们是 phase 不是阶段)。 */
|
||||
export const STAGE_ORDER = STAGE_PIPELINES.backtest;
|
||||
|
||||
/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
|
||||
export function cancelJob(
|
||||
jobId: string,
|
||||
): Promise<{ job_id: string; status: string; cancelled: boolean }> {
|
||||
return apiPost<{ job_id: string; status: string; cancelled: boolean }>(
|
||||
`/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
|
||||
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
||||
@@ -90,3 +141,204 @@ export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
||||
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
|
||||
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 统一的「提交 → 轮询 → 终态」状态机(useJobRunner)
|
||||
*
|
||||
* 为什么要有它:以前每个页面各写一遍 running/jobId/error,结果参差不齐 ——
|
||||
* 有的页面点了「运行」只在按钮上转圈,用户不知道到底提交没提交、跑到哪一步;
|
||||
* 有的页面「已用秒数」只在后端阶段变化时才更新,看起来像卡死了(0s 一动不动)。
|
||||
* 这里把状态、秒表、取消、终态收敛到一处,页面只管渲染。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export type JobPhase =
|
||||
| "idle"
|
||||
| "submitting"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timeout";
|
||||
|
||||
export interface JobRunState<T> {
|
||||
phase: JobPhase;
|
||||
/** 作业号(提交成功后立即拿到;这是「到底提交没提交」的凭据) */
|
||||
jobId: string;
|
||||
/** 后端上报的执行阶段(queued/data_loading/selection/backtesting/analysis/done) */
|
||||
stage: string;
|
||||
/** 已用毫秒:**每秒自增**,不依赖后端阶段变化,所以界面不会看起来卡住 */
|
||||
elapsedMs: number;
|
||||
/** 这次跑的是什么(如「因子测试 · momentum_60」),显示在反馈条上 */
|
||||
label: string;
|
||||
/** 批量场景的进度(第 index / total 个),单跑时为 null */
|
||||
progress: { index: number; total: number } | null;
|
||||
error: string;
|
||||
result: T | null;
|
||||
experimentId: string | null;
|
||||
/** 提交时刻(本地时间,用于「发起于 hh:mm:ss」) */
|
||||
startedAt: Date | null;
|
||||
/** 正在请求取消 */
|
||||
cancelling: boolean;
|
||||
}
|
||||
|
||||
function emptyState<T>(label = ""): JobRunState<T> {
|
||||
return {
|
||||
phase: "idle",
|
||||
jobId: "",
|
||||
stage: "",
|
||||
elapsedMs: 0,
|
||||
label,
|
||||
progress: null,
|
||||
error: "",
|
||||
result: null,
|
||||
experimentId: null,
|
||||
startedAt: null,
|
||||
cancelling: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface JobRunOptions {
|
||||
/** 反馈条上显示的任务名(如「因子测试 · momentum_60」) */
|
||||
label?: string;
|
||||
/** 批量进度(第 index / total 个) */
|
||||
progress?: { index: number; total: number } | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface JobRunner<T> {
|
||||
state: JobRunState<T>;
|
||||
/** 提交并轮询到终态;返回终态结果(供页面继续处理,如渲染报告) */
|
||||
run: (
|
||||
submit: () => Promise<{ job_id: string }>,
|
||||
opts?: JobRunOptions,
|
||||
) => Promise<JobOutcome<T>>;
|
||||
cancel: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业运行状态机。
|
||||
*
|
||||
* 生命周期:idle → submitting(已点击,正在换 job_id)→ queued/running(轮询中,秒表走)
|
||||
* → success | failed | cancelled | timeout。**submitting 也是状态**:点了按钮立刻就有
|
||||
* 反馈文字,不会出现「点了没反应」的空白期。
|
||||
*/
|
||||
export function useJobRunner<T>(defaultLabel = ""): JobRunner<T> {
|
||||
const [state, setState] = useState<JobRunState<T>>(() => emptyState<T>(defaultLabel));
|
||||
const t0 = useRef(0);
|
||||
const timer = useRef<number | null>(null);
|
||||
const alive = useRef(true);
|
||||
const currentJob = useRef("");
|
||||
// 后端最近上报的阶段放在 ref 里:终态那一帧要写「最后一个阶段」,但不想让
|
||||
// `run` 依赖 state(否则每次 state 变化都换一个新函数,页面 effect 会连环触发)。
|
||||
const stageRef = useRef("");
|
||||
|
||||
const stopTick = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
window.clearInterval(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
alive.current = false;
|
||||
if (timer.current !== null) window.clearInterval(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const patch = useCallback((next: Partial<JobRunState<T>>) => {
|
||||
if (!alive.current) return;
|
||||
setState((prev) => ({ ...prev, ...next }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopTick();
|
||||
currentJob.current = "";
|
||||
if (alive.current) setState(emptyState<T>(defaultLabel));
|
||||
}, [defaultLabel, stopTick]);
|
||||
|
||||
const run = useCallback(
|
||||
async (
|
||||
submit: () => Promise<{ job_id: string }>,
|
||||
opts: JobRunOptions = {},
|
||||
): Promise<JobOutcome<T>> => {
|
||||
stopTick();
|
||||
currentJob.current = "";
|
||||
stageRef.current = "";
|
||||
t0.current = Date.now();
|
||||
setState({
|
||||
...emptyState<T>(opts.label ?? defaultLabel),
|
||||
phase: "submitting",
|
||||
label: opts.label ?? defaultLabel,
|
||||
progress: opts.progress ?? null,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
// 秒表独立于后端阶段:即使后端几秒内不报新阶段,「已用 Xs」也在动
|
||||
timer.current = window.setInterval(() => {
|
||||
if (!alive.current) return;
|
||||
setState((prev) =>
|
||||
prev.phase === "submitting" || prev.phase === "queued" || prev.phase === "running"
|
||||
? { ...prev, elapsedMs: Date.now() - t0.current }
|
||||
: prev,
|
||||
);
|
||||
}, 500);
|
||||
try {
|
||||
const { job_id } = await submit();
|
||||
currentJob.current = job_id;
|
||||
stageRef.current = "queued";
|
||||
patch({ jobId: job_id, phase: "queued", stage: "queued" });
|
||||
const out = await waitJob<T>(job_id, opts.timeoutMs ?? 900_000, (info) => {
|
||||
stageRef.current = info.stage;
|
||||
patch({
|
||||
stage: info.stage,
|
||||
elapsedMs: info.elapsedMs,
|
||||
phase: info.stage && info.stage !== "queued" ? "running" : "queued",
|
||||
});
|
||||
});
|
||||
const finalPhase: JobPhase =
|
||||
out.status === "success"
|
||||
? "success"
|
||||
: out.status === "cancelled"
|
||||
? "cancelled"
|
||||
: out.status === "timeout"
|
||||
? "timeout"
|
||||
: "failed";
|
||||
patch({
|
||||
phase: finalPhase,
|
||||
stage: out.status === "success" ? "done" : stageRef.current,
|
||||
elapsedMs: Date.now() - t0.current,
|
||||
result: out.result,
|
||||
experimentId: out.experimentId ?? null,
|
||||
error: out.error ?? "",
|
||||
});
|
||||
return out;
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
patch({ phase: "failed", error: msg, elapsedMs: Date.now() - t0.current });
|
||||
return { status: "failed", result: null, error: msg };
|
||||
} finally {
|
||||
stopTick();
|
||||
}
|
||||
},
|
||||
[defaultLabel, patch, stopTick],
|
||||
);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
const jobId = currentJob.current;
|
||||
if (!jobId) return;
|
||||
patch({ cancelling: true });
|
||||
try {
|
||||
await cancelJob(jobId);
|
||||
// 取消后轮询会在 1.5s 内看到 cancelled 终态;这里先把阶段文字改掉,
|
||||
// 让用户马上知道「取消请求已发出」而不是等下一个轮询周期。
|
||||
patch({ stage: "cancelling" });
|
||||
} finally {
|
||||
patch({ cancelling: false });
|
||||
}
|
||||
}, [patch]);
|
||||
|
||||
return { state, run, cancel, reset };
|
||||
}
|
||||
|
||||
@@ -191,6 +191,60 @@ 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<string, number>;
|
||||
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<string, string> = {
|
||||
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_rebalance_full: "调仓换仓卖出",
|
||||
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 +254,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 +284,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<string, unknown>;
|
||||
|
||||
@@ -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)} 条,样例:"
|
||||
|
||||
Reference in New Issue
Block a user