feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。
二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
(股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。
三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。
验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
`scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
全部 200 渲染,截图确认表格与曲线数值正确。
This commit is contained in:
@@ -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 = [
|
||||
@@ -158,6 +187,7 @@ class HoldingBandRunner:
|
||||
score: pd.DataFrame,
|
||||
close: pd.DataFrame,
|
||||
eligibility_fn=None,
|
||||
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
|
||||
) -> None:
|
||||
self.combo = combo
|
||||
self.costs = costs
|
||||
@@ -166,11 +196,19 @@ class HoldingBandRunner:
|
||||
self.close = close.sort_index()
|
||||
self.score = score.reindex(self.close.index).sort_index()
|
||||
self.eligibility_fn = eligibility_fn
|
||||
# 策略用到的因子原始面板:买卖理由里的因子值、以及因子曲线都从这里取
|
||||
self.factor_panels = factor_panels or {}
|
||||
self.selection_history: list[RankedPick] = []
|
||||
self.signal_history: list[ActionRecord] = []
|
||||
self.traded_symbols: list[str] = []
|
||||
self._traded: set[str] = set()
|
||||
self._no_prev_close: set[str] = set()
|
||||
# 调仓日的完整排名与合格集:卖出理由要能说出「第几名掉出去的」,
|
||||
# 以及「是掉出 TopN 还是根本不在候选池(被股票池/条件过滤)」
|
||||
self._ranked_by_day: dict[pd.Timestamp, pd.Series] = {}
|
||||
self._elig_by_day: dict[pd.Timestamp, set[str] | None] = {}
|
||||
# 每个交易日的持仓市值权重(因子曲线用;空仓日空 dict → 不落点)
|
||||
self._weights_by_day: dict[pd.Timestamp, dict[str, float]] = {}
|
||||
|
||||
# ---- 主循环 ----
|
||||
|
||||
@@ -226,6 +264,7 @@ class HoldingBandRunner:
|
||||
|
||||
equity_rows[d] = _equity(d)
|
||||
self._mark_curve(d, holdings, cum, curve_rows)
|
||||
self._weights_by_day[d] = self._holding_weights(d, holdings)
|
||||
|
||||
equity = pd.Series(equity_rows).sort_index()
|
||||
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
|
||||
@@ -241,6 +280,9 @@ class HoldingBandRunner:
|
||||
if elig is not None:
|
||||
score_d = score_d[score_d.index.isin(elig)]
|
||||
ranked = score_d.sort_values(ascending=False)
|
||||
# 完整排名留下来:卖出理由要说「第几名掉出去的」,只有 TopN 说不出这个数
|
||||
self._ranked_by_day[d] = ranked
|
||||
self._elig_by_day[d] = set(elig) if elig is not None else None
|
||||
top = ranked.head(n).index.tolist()
|
||||
day = d.date()
|
||||
for r, sym in enumerate(top, start=1):
|
||||
@@ -249,6 +291,50 @@ class HoldingBandRunner:
|
||||
)
|
||||
return top
|
||||
|
||||
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
|
||||
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
|
||||
|
||||
卖出理由必须能区分三件事:**在池但排名掉出去**、**已被股票池/条件过滤**
|
||||
(如转为 ST)、**当日没有分数**(数据缺失)。都写成「跌出 TopN」会掩盖真相。
|
||||
"""
|
||||
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
|
||||
ranked = self._ranked_by_day.get(d)
|
||||
if ranked is None:
|
||||
return out # 非调仓日(如 Tmax 强制了结发生在普通交易日):没有当日排名
|
||||
elig = self._elig_by_day.get(d)
|
||||
out["total"] = int(len(ranked))
|
||||
out["in_pool"] = True if elig is None else (symbol in elig)
|
||||
if symbol in ranked.index:
|
||||
loc = ranked.index.get_loc(symbol)
|
||||
if isinstance(loc, int):
|
||||
out["rank"] = loc + 1
|
||||
out["score"] = round(float(ranked.loc[symbol]), 6)
|
||||
return out
|
||||
|
||||
def _holding_weights(self, d: pd.Timestamp, holdings) -> dict[str, float]:
|
||||
"""当日持仓市值权重(因子曲线用)。取不到价的持仓不参与,空仓日返回空 dict。"""
|
||||
out: dict[str, float] = {}
|
||||
for s, h in holdings.items():
|
||||
if h.qty <= 0 or s not in self.close.columns:
|
||||
continue
|
||||
px = self.close.at[d, s]
|
||||
if _nan(px) or px <= 0:
|
||||
continue
|
||||
out[s] = float(h.qty) * float(px)
|
||||
return out
|
||||
|
||||
def _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
|
||||
"""构造理由所需的公共上下文(排名 + 各因子当时的原始值)。"""
|
||||
ctx = self._rank_of(d, symbol)
|
||||
return {
|
||||
"rank": ctx["rank"],
|
||||
"total": ctx["total"],
|
||||
"top_n": top_n,
|
||||
"score": ctx["score"],
|
||||
"factors": factor_values(self.factor_panels, d, symbol) or None,
|
||||
"not_in_pool": ctx["in_pool"] is False,
|
||||
}
|
||||
|
||||
# ---- Tmax 强制了结(每日) ----
|
||||
|
||||
def _force_exit_over_max(self, d, day, holdings, cash, trades, tmax) -> float:
|
||||
@@ -261,19 +347,33 @@ class HoldingBandRunner:
|
||||
continue
|
||||
c = close_d.get(s)
|
||||
p = prev_d.get(s) if prev_d is not None else None
|
||||
ctx = self._reason_ctx(d, s, top_n=None)
|
||||
if _nan(c):
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||||
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延")
|
||||
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延",
|
||||
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
|
||||
hold_days=held, tmax=tmax, **ctx))
|
||||
)
|
||||
continue
|
||||
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||||
reject_reason=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延")
|
||||
reject_reason=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延",
|
||||
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
|
||||
hold_days=held, tmax=tmax,
|
||||
close=float(c), prev_close=float(p),
|
||||
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
|
||||
**ctx))
|
||||
)
|
||||
continue
|
||||
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
|
||||
cash = self._sell(
|
||||
s, h, float(c), day, cash, trades, holdings,
|
||||
reason=sell_filled(code=SELL_FORCE_TMAX, rank=None, total=ctx["total"],
|
||||
top_n=None, score=ctx["score"], factors=ctx["factors"],
|
||||
hold_days=held, tmax=tmax, price=float(c),
|
||||
return_pct=(float(c) / h.entry_price - 1.0) * 100),
|
||||
)
|
||||
return cash
|
||||
|
||||
# ---- 调仓日:增量调向目标 ----
|
||||
@@ -289,10 +389,13 @@ class HoldingBandRunner:
|
||||
continue
|
||||
h = holdings[s]
|
||||
held = self._held_trading_days(h.entry_ts, d)
|
||||
ctx = self._reason_ctx(d, s, top_n=n)
|
||||
if held < tmin:
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||||
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留")
|
||||
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留",
|
||||
reason=sell_deferred(SELL_DEFER_TMIN, cause="tmin", hold_days=held,
|
||||
tmin=tmin, **ctx))
|
||||
)
|
||||
continue
|
||||
c = close_d.get(s)
|
||||
@@ -300,16 +403,30 @@ class HoldingBandRunner:
|
||||
if _nan(c):
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||||
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓")
|
||||
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓",
|
||||
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
|
||||
hold_days=held, tmin=tmin, **ctx))
|
||||
)
|
||||
continue
|
||||
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
|
||||
reject_reason="掉出 TopN,但跌停无法卖出,保留到下一调仓")
|
||||
reject_reason="掉出 TopN,但跌停无法卖出,保留到下一调仓",
|
||||
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
|
||||
hold_days=held, tmin=tmin,
|
||||
close=float(c), prev_close=float(p),
|
||||
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
|
||||
**ctx))
|
||||
)
|
||||
continue
|
||||
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
|
||||
cash = self._sell(
|
||||
s, h, float(c), day, cash, trades, holdings,
|
||||
reason=sell_filled(code=SELL_DROP_TOPN, rank=ctx["rank"], total=ctx["total"],
|
||||
top_n=n, score=ctx["score"], factors=ctx["factors"],
|
||||
hold_days=held, tmin=tmin, price=float(c),
|
||||
return_pct=(float(c) / h.entry_price - 1.0) * 100,
|
||||
not_in_pool=ctx["not_in_pool"]),
|
||||
)
|
||||
|
||||
# b) 补买:从 TopN 里挑尚未持有的,按等权目标用可用现金买入,直到 N 只或现金耗尽
|
||||
current = [s for s in topn if s in holdings and holdings[s].qty > 0]
|
||||
@@ -332,30 +449,45 @@ class HoldingBandRunner:
|
||||
for s in buys:
|
||||
c = close_d.get(s)
|
||||
p = prev_d.get(s) if prev_d is not None else None
|
||||
ctx = self._reason_ctx(d, s, top_n=n)
|
||||
if _nan(c):
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason="无行情(停牌),无法买入")
|
||||
reject_reason="无行情(停牌),无法买入",
|
||||
reason=buy_skipped(BUY_SKIP_HALTED, **ctx))
|
||||
)
|
||||
continue
|
||||
if not _nan(p) and p > 0 and c / p >= _limit_up_ratio(s):
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason="涨停,无法追买")
|
||||
reject_reason="涨停,无法追买",
|
||||
reason=buy_skipped(BUY_SKIP_LIMIT_UP, close=float(c),
|
||||
prev_close=float(p),
|
||||
limit_ratio=_limit_up_ratio(s), **ctx))
|
||||
)
|
||||
continue
|
||||
budget = min(per_budget, cash)
|
||||
if budget <= 1e-9:
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason="可用现金不足,未成交")
|
||||
reject_reason="可用现金不足,未成交",
|
||||
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
|
||||
)
|
||||
continue
|
||||
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional)
|
||||
buy_reason = buy_filled(
|
||||
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
|
||||
factors=ctx["factors"], price=float(c) * (1 + self.costs.slippage_rate),
|
||||
budget=budget,
|
||||
)
|
||||
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional,
|
||||
reason=buy_reason)
|
||||
if not ok:
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
|
||||
reject_reason="预算不足以覆盖最低佣金,未成交")
|
||||
reject_reason="预算不足以覆盖最低佣金,未成交",
|
||||
reason=buy_skipped(BUY_SKIP_MIN_COMMISSION, budget=budget,
|
||||
min_commission=self.costs.min_commission,
|
||||
**ctx))
|
||||
)
|
||||
continue
|
||||
cash -= spent
|
||||
@@ -365,25 +497,29 @@ class HoldingBandRunner:
|
||||
|
||||
# ---- 买卖原子操作 ----
|
||||
|
||||
def _sell(self, s, h, close_price, day, cash, trades, holdings) -> float:
|
||||
def _sell(self, s, h, close_price, day, cash, trades, holdings, reason=None) -> float:
|
||||
proceeds = h.qty * close_price * (1 - self.costs.slippage_rate)
|
||||
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
|
||||
fee = commission + proceeds * self.costs.stamp_tax_rate
|
||||
cash += proceeds - fee
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price)
|
||||
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price,
|
||||
reason=reason)
|
||||
)
|
||||
trades.append(
|
||||
Trade(
|
||||
entry_date=h.entry_date, exit_date=day, symbol=s,
|
||||
entry_price=h.entry_price, exit_price=close_price,
|
||||
return_pct=(close_price / h.entry_price - 1.0) * 100,
|
||||
# 买卖理由跟着成交走:成交明细里「为什么买、为什么卖」都齐
|
||||
entry_reason=h.entry_reason,
|
||||
exit_reason=reason,
|
||||
)
|
||||
)
|
||||
holdings.pop(s, None)
|
||||
return cash
|
||||
|
||||
def _buy(self, s, budget, d, close_price, day, holdings, notional) -> tuple[bool, float]:
|
||||
def _buy(self, s, budget, d, close_price, day, holdings, notional, reason=None) -> tuple[bool, float]:
|
||||
price_in = close_price * (1 + self.costs.slippage_rate)
|
||||
commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
|
||||
invest = budget - commission
|
||||
@@ -394,10 +530,13 @@ class HoldingBandRunner:
|
||||
pv = prev.get(s) if prev is not None else float("nan")
|
||||
if _nan(pv) or pv <= 0:
|
||||
self._no_prev_close.add(s)
|
||||
holdings[s] = _Holding(qty=qty, entry_date=day, entry_price=price_in, entry_ts=d)
|
||||
holdings[s] = _Holding(
|
||||
qty=qty, entry_date=day, entry_price=price_in, entry_ts=d, entry_reason=reason
|
||||
)
|
||||
notional.append(budget)
|
||||
self.signal_history.append(
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=True, price=round(price_in, 4))
|
||||
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
|
||||
price=round(price_in, 4), reason=reason)
|
||||
)
|
||||
if s not in self._traded:
|
||||
self._traded.add(s)
|
||||
@@ -515,6 +654,7 @@ class HoldingBandRunner:
|
||||
signal_history=self.signal_history,
|
||||
fills=[a for a in self.signal_history if a.filled],
|
||||
symbol_curves=curves,
|
||||
factor_curves=build_factor_curves(self.factor_panels, self._weights_by_day),
|
||||
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
|
||||
unimplemented=self._unimplemented(),
|
||||
config_snapshot={}, # 由服务层填入 ComboRunSpec(含策略+成本快照)
|
||||
@@ -567,10 +707,13 @@ def run_combo_backtest(
|
||||
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
|
||||
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。
|
||||
"""
|
||||
score, combined_elig = combine_strategy_scores(daily, strategies, eligibility_fns)
|
||||
score, combined_elig, factor_panels = combine_strategy_scores(
|
||||
daily, strategies, eligibility_fns
|
||||
)
|
||||
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
|
||||
runner = HoldingBandRunner(
|
||||
combo=combo, costs=costs, score=score, close=close, eligibility_fn=combined_elig,
|
||||
factor_panels=factor_panels,
|
||||
)
|
||||
result = runner.run()
|
||||
# 固化可复现规格(AGENT.md §21):组合参数 + 当时各策略定义 + 当时成本/复权
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}),),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""买卖理由(数据化)与因子曲线的共享构造器。
|
||||
|
||||
**为什么单独一个模块**:两套回测引擎(`combo_engine` 组合回测、`local_engine`
|
||||
单策略回测)都要回答同一个问题 ——「这一买一卖,当时的数字是多少?」。如果各写一份,
|
||||
措辞、口径、字段名迟早分叉,用户在两处看到的「理由」会互相矛盾。
|
||||
|
||||
因此这里只放两件事:
|
||||
|
||||
1. **封闭的原因词表 + 构造器**:每个 `code` 对应一类可判定的原因,`text` 里带关键数字,
|
||||
`data` 里放当时的原始数值(排名 / 候选数 / 综合分 / 因子原始值 / 持有交易日 / 预算 …)。
|
||||
引擎只允许用词表里的 code(`REASON_CODES`),避免出现「文案随手写」的漂移。
|
||||
2. **因子曲线的口径实现**:持仓股票的**权重加权平均原始值**,空仓日不落点、
|
||||
不插值、不按方向取负(低为好的因子也原样画,方向由 `FactorCurve.direction` 说明)。
|
||||
|
||||
数值一律「引擎当时算出来的」:前端只展示,不推算 —— 界面上不该出现看起来像真的数字。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.research import CurvePoint, FactorCurve, TradeReason
|
||||
from app.quant.factors import FactorDef
|
||||
|
||||
# ---------- 封闭原因词表 ----------
|
||||
# 买入(成交)
|
||||
BUY_ENTER = "buy_enter_topn"
|
||||
BUY_DEFER_FILLED = "buy_defer_filled"
|
||||
# 买入(未成交 / 未执行)
|
||||
BUY_SKIP_LIMIT_UP = "buy_skip_limit_up"
|
||||
BUY_SKIP_HALTED = "buy_skip_halted"
|
||||
BUY_SKIP_NO_CASH = "buy_skip_no_cash"
|
||||
BUY_SKIP_MIN_COMMISSION = "buy_skip_min_commission"
|
||||
# 卖出(成交)
|
||||
SELL_DROP_TOPN = "sell_drop_topn"
|
||||
SELL_FORCE_TMAX = "sell_force_tmax"
|
||||
# 卖出(顺延 / 未成交)
|
||||
SELL_DEFER_TMIN = "sell_defer_tmin"
|
||||
SELL_DEFER_HALTED = "sell_defer_halted"
|
||||
SELL_DEFER_LIMIT_DOWN = "sell_defer_limit_down"
|
||||
|
||||
REASON_CODES = frozenset(
|
||||
{
|
||||
BUY_ENTER,
|
||||
BUY_DEFER_FILLED,
|
||||
BUY_SKIP_LIMIT_UP,
|
||||
BUY_SKIP_HALTED,
|
||||
BUY_SKIP_NO_CASH,
|
||||
BUY_SKIP_MIN_COMMISSION,
|
||||
SELL_DROP_TOPN,
|
||||
SELL_FORCE_TMAX,
|
||||
SELL_DEFER_TMIN,
|
||||
SELL_DEFER_HALTED,
|
||||
SELL_DEFER_LIMIT_DOWN,
|
||||
}
|
||||
)
|
||||
|
||||
# 原因分类的中文短标签(前端筛选 / 表格上色用;改文案只改这里)
|
||||
REASON_LABELS: dict[str, str] = {
|
||||
BUY_ENTER: "按名次建仓",
|
||||
BUY_DEFER_FILLED: "顺延后成交",
|
||||
BUY_SKIP_LIMIT_UP: "涨停未买",
|
||||
BUY_SKIP_HALTED: "停牌未买",
|
||||
BUY_SKIP_NO_CASH: "现金不足",
|
||||
BUY_SKIP_MIN_COMMISSION: "不足最低佣金",
|
||||
SELL_DROP_TOPN: "跌出 TopN",
|
||||
SELL_FORCE_TMAX: "持有超 Tmax",
|
||||
SELL_DEFER_TMIN: "Tmin 保护暂留",
|
||||
SELL_DEFER_HALTED: "停牌未卖",
|
||||
SELL_DEFER_LIMIT_DOWN: "跌停未卖",
|
||||
}
|
||||
|
||||
|
||||
def _num(v, digits: int = 6):
|
||||
"""把 numpy/pandas 数值安全地压成原生 float(NaN/inf 一律不带进理由里)。"""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
return None
|
||||
return round(f, digits)
|
||||
|
||||
|
||||
def factor_values(
|
||||
factor_panels: Mapping[str, tuple[FactorDef, pd.DataFrame]],
|
||||
day,
|
||||
symbol: str,
|
||||
) -> dict[str, float]:
|
||||
"""该个股在 `day` 的各因子**原始值**(缺失因子不写进 data,不用 0 冒充)。
|
||||
|
||||
用交易日精确匹配:调仓日的打分与理由是同一份面板,所以这里取不到值就意味着
|
||||
「该股当日无该因子值」,如实缺失比填 0 更可信。
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for name, (_defn, panel) in factor_panels.items():
|
||||
if day not in panel.index or symbol not in panel.columns:
|
||||
continue
|
||||
v = _num(panel.at[day, symbol])
|
||||
if v is not None:
|
||||
out[name] = v
|
||||
return out
|
||||
|
||||
|
||||
def _rank_data(
|
||||
*,
|
||||
rank: int | None,
|
||||
total: int | None,
|
||||
top_n: int | None,
|
||||
score: float | None,
|
||||
factors: dict[str, float] | None,
|
||||
) -> dict:
|
||||
data: dict = {}
|
||||
if rank is not None:
|
||||
data["rank"] = int(rank)
|
||||
if total is not None:
|
||||
data["total"] = int(total)
|
||||
if top_n is not None:
|
||||
data["top_n"] = int(top_n)
|
||||
if score is not None:
|
||||
data["score"] = _num(score)
|
||||
if factors:
|
||||
data["factors"] = factors
|
||||
return data
|
||||
|
||||
|
||||
def _rank_text(rank: int | None, total: int | None, top_n: int | None, score: float | None) -> str:
|
||||
if rank is None:
|
||||
return "调仓日综合分未给出名次"
|
||||
parts = [f"综合分第 {rank}"]
|
||||
if total:
|
||||
parts.append(f"/{total}")
|
||||
parts.append(" 名")
|
||||
if top_n is not None:
|
||||
parts.append(f"(TopN={top_n})")
|
||||
if score is not None:
|
||||
parts.append(f",综合分 {score:.4f}")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def buy_filled(
|
||||
*,
|
||||
rank: int | None,
|
||||
total: int | None,
|
||||
top_n: int | None,
|
||||
score: float | None,
|
||||
factors: dict[str, float] | None,
|
||||
price: float | None,
|
||||
budget: float | None = None,
|
||||
deferred: bool = False,
|
||||
) -> TradeReason:
|
||||
"""买入成交的理由:名次 + 综合分 + 各因子当时的原始值 + 成交价。"""
|
||||
head = "顺延买入成交" if deferred else "调仓日选中并建仓"
|
||||
text = f"{head}:{_rank_text(rank, total, top_n, score)}"
|
||||
if price is not None:
|
||||
text += f";成交价 {price:.2f} 元"
|
||||
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
|
||||
if price is not None:
|
||||
data["price"] = _num(price, 4)
|
||||
if budget is not None:
|
||||
data["budget"] = _num(budget, 2)
|
||||
return TradeReason(code=BUY_DEFER_FILLED if deferred else BUY_ENTER, text=text, data=data)
|
||||
|
||||
|
||||
def buy_skipped(
|
||||
code: str,
|
||||
*,
|
||||
rank: int | None = None,
|
||||
total: int | None = None,
|
||||
top_n: int | None = None,
|
||||
score: float | None = None,
|
||||
factors: dict[str, float] | None = None,
|
||||
close: float | None = None,
|
||||
prev_close: float | None = None,
|
||||
limit_ratio: float | None = None,
|
||||
budget: float | None = None,
|
||||
min_commission: float | None = None,
|
||||
not_in_pool: bool = False,
|
||||
) -> TradeReason:
|
||||
"""买入未成交 / 未执行的理由(涨停、停牌、现金不足、佣金门槛)。"""
|
||||
if code not in REASON_CODES:
|
||||
raise ValueError(f"未知的买入未成交原因:{code}")
|
||||
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
|
||||
base = _rank_text(rank, total, top_n, score)
|
||||
if code == BUY_SKIP_LIMIT_UP:
|
||||
ratio = (
|
||||
_num(close / prev_close, 4)
|
||||
if close is not None and prev_close not in (None, 0)
|
||||
else None
|
||||
)
|
||||
text = f"{base},但当日涨停"
|
||||
if ratio is not None:
|
||||
text += f"(收盘 {close:.2f} / 前收 {prev_close:.2f} = {ratio:.3f}"
|
||||
text += f" ≥ 涨停阈值 {limit_ratio:.3f})" if limit_ratio else ")"
|
||||
text += ",无法追买"
|
||||
if ratio is not None:
|
||||
data["close_prev_ratio"] = ratio
|
||||
if limit_ratio is not None:
|
||||
data["limit_ratio"] = _num(limit_ratio, 4)
|
||||
elif code == BUY_SKIP_HALTED:
|
||||
text = f"{base},但当日无行情(停牌),无法买入"
|
||||
elif code == BUY_SKIP_NO_CASH:
|
||||
text = f"{base},但可用现金不足,未成交"
|
||||
if budget is not None:
|
||||
text += f"(可用预算 {budget:.2f} 元)"
|
||||
elif code == BUY_SKIP_MIN_COMMISSION:
|
||||
text = f"{base},但预算不足以覆盖最低佣金,未成交"
|
||||
if budget is not None and min_commission is not None:
|
||||
text += f"(预算 {budget:.2f} 元 < 最低佣金 {min_commission:.2f} 元)"
|
||||
else: # pragma: no cover - 上面的分支已覆盖全部代码
|
||||
text = base
|
||||
if close is not None:
|
||||
data["close"] = _num(close, 4)
|
||||
if prev_close is not None:
|
||||
data["prev_close"] = _num(prev_close, 4)
|
||||
if budget is not None:
|
||||
data["budget"] = _num(budget, 2)
|
||||
if min_commission is not None:
|
||||
data["min_commission"] = _num(min_commission, 2)
|
||||
if not_in_pool:
|
||||
data["in_pool"] = False
|
||||
return TradeReason(code=code, text=text, data=data)
|
||||
|
||||
|
||||
def sell_filled(
|
||||
*,
|
||||
code: str,
|
||||
rank: int | None,
|
||||
total: int | None,
|
||||
top_n: int | None,
|
||||
score: float | None,
|
||||
factors: dict[str, float] | None,
|
||||
hold_days: int,
|
||||
tmin: int | None = None,
|
||||
tmax: int | None = None,
|
||||
price: float | None = None,
|
||||
return_pct: float | None = None,
|
||||
not_in_pool: bool = False,
|
||||
) -> TradeReason:
|
||||
"""卖出成交的理由(跌出 TopN / 持有超 Tmax),带持有交易日与当时名次。
|
||||
|
||||
`not_in_pool=True` 表示该股已**不在候选池**(被股票池/条件过滤,如转为 ST),
|
||||
与「在池内但排名掉出去」是两回事,文案与 data 都分开写。
|
||||
"""
|
||||
if code == SELL_FORCE_TMAX:
|
||||
text = f"持有 {hold_days} 个交易日 > Tmax={tmax},强制了结(与排名无关)"
|
||||
else:
|
||||
code = SELL_DROP_TOPN
|
||||
if not_in_pool:
|
||||
text = f"调仓日已不在候选池(被股票池/条件过滤);持有 {hold_days} 个交易日"
|
||||
else:
|
||||
text = f"调仓日跌出 TopN:{_rank_text(rank, total, top_n, score)};持有 {hold_days} 个交易日"
|
||||
if tmin is not None:
|
||||
text += f" ≥ Tmin={tmin}"
|
||||
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
|
||||
data["hold_days"] = int(hold_days)
|
||||
if not_in_pool:
|
||||
data["in_pool"] = False
|
||||
if tmin is not None:
|
||||
data["tmin"] = int(tmin)
|
||||
if tmax is not None:
|
||||
data["tmax"] = int(tmax)
|
||||
if price is not None:
|
||||
text += f";卖出价 {price:.2f} 元"
|
||||
data["price"] = _num(price, 4)
|
||||
if return_pct is not None:
|
||||
data["return_pct"] = _num(return_pct, 4)
|
||||
return TradeReason(code=code, text=text, data=data)
|
||||
|
||||
|
||||
def sell_deferred(
|
||||
code: str,
|
||||
*,
|
||||
cause: str,
|
||||
rank: int | None = None,
|
||||
total: int | None = None,
|
||||
top_n: int | None = None,
|
||||
score: float | None = None,
|
||||
factors: dict[str, float] | None = None,
|
||||
hold_days: int | None = None,
|
||||
tmin: int | None = None,
|
||||
tmax: int | None = None,
|
||||
close: float | None = None,
|
||||
prev_close: float | None = None,
|
||||
limit_ratio: float | None = None,
|
||||
not_in_pool: bool = False,
|
||||
) -> TradeReason:
|
||||
"""卖出未成交(顺延 / 暂留)的理由:Tmin 保护 / 停牌 / 跌停。
|
||||
|
||||
`tmax` 有值时说明是 Tmax 强制了结被卡住,文案据此区分 —— 两者后续行为不同
|
||||
(Tmin 保护等到满 Tmin,Tmax 每天重试且不认排名)。
|
||||
"""
|
||||
if code not in REASON_CODES:
|
||||
raise ValueError(f"未知的卖出顺延原因:{code}")
|
||||
if tmax is not None:
|
||||
head = f"持有 {hold_days} 个交易日超 Tmax={tmax},本应强制了结"
|
||||
elif code == SELL_DEFER_TMIN:
|
||||
why = (
|
||||
"调仓日已不在候选池(被股票池/条件过滤)"
|
||||
if not_in_pool
|
||||
else f"掉出 TopN({_rank_text(rank, total, top_n, score)})"
|
||||
)
|
||||
head = f"{why}但仅持 {hold_days} 个交易日 < Tmin={tmin},按 Tmin 保护暂留"
|
||||
else:
|
||||
head = (
|
||||
"调仓日已不在候选池(被股票池/条件过滤)"
|
||||
if not_in_pool
|
||||
else f"调仓日跌出 TopN({_rank_text(rank, total, top_n, score)})"
|
||||
)
|
||||
if cause == "tmin":
|
||||
text = f"{head},暂留至满 Tmin"
|
||||
elif cause == "halted":
|
||||
text = f"{head},但当日无行情(停牌),顺延"
|
||||
elif cause == "limit_down":
|
||||
ratio = _num(close / prev_close, 4) if close is not None and prev_close not in (None, 0) else None
|
||||
text = f"{head},但当日跌停"
|
||||
if ratio is not None:
|
||||
text += f"(收盘 {close:.2f} / 前收 {prev_close:.2f} = {ratio:.3f}"
|
||||
text += f" ≤ 跌停阈值 {limit_ratio:.3f})" if limit_ratio else ")"
|
||||
text += ",无法卖出,顺延"
|
||||
if ratio is not None:
|
||||
data_ratio = ratio
|
||||
close_v, prev_v = _num(close, 4), _num(prev_close, 4)
|
||||
else:
|
||||
data_ratio, close_v, prev_v = None, None, None
|
||||
else: # pragma: no cover - 调用方只传 halted / limit_down
|
||||
text = f"{head},顺延"
|
||||
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
|
||||
if not_in_pool:
|
||||
data["in_pool"] = False
|
||||
if hold_days is not None:
|
||||
data["hold_days"] = int(hold_days)
|
||||
if tmin is not None:
|
||||
data["tmin"] = int(tmin)
|
||||
if tmax is not None:
|
||||
data["tmax"] = int(tmax)
|
||||
if cause == "limit_down":
|
||||
if data_ratio is not None:
|
||||
data["close_prev_ratio"] = data_ratio
|
||||
data["close"] = close_v
|
||||
data["prev_close"] = prev_v
|
||||
if limit_ratio is not None:
|
||||
data["limit_ratio"] = _num(limit_ratio, 4)
|
||||
if close is not None and "close" not in data and cause == "halted":
|
||||
data["close"] = _num(close, 4)
|
||||
return TradeReason(code=code, text=text, data=data)
|
||||
|
||||
|
||||
# ---------- 因子曲线(持仓加权平均原始值) ----------
|
||||
|
||||
|
||||
def weighted_average(values: Mapping[str, float], weights: Mapping[str, float]) -> float | None:
|
||||
"""权重加权平均;没有任何有效样本时返回 None(调用方据此不落点)。"""
|
||||
total_w = 0.0
|
||||
acc = 0.0
|
||||
for symbol, w in weights.items():
|
||||
v = values.get(symbol)
|
||||
if v is None or w <= 0:
|
||||
continue
|
||||
acc += float(v) * float(w)
|
||||
total_w += float(w)
|
||||
if total_w <= 0:
|
||||
return None
|
||||
return acc / total_w
|
||||
|
||||
|
||||
def build_factor_curves(
|
||||
factor_panels: Mapping[str, tuple[FactorDef, pd.DataFrame]],
|
||||
weights_by_day: Mapping[object, dict[str, float]],
|
||||
) -> list[FactorCurve]:
|
||||
"""按「每日持仓权重」聚合出每个因子的曲线。
|
||||
|
||||
- `weights_by_day`:`{交易日: {symbol: 该股市值}}`,空仓日给空字典(不落点);
|
||||
- 值 = 该日持仓上该因子的权重加权平均**原始值**(不做 z-score、不按方向取负);
|
||||
- 曲线按因子键排序,保证同一份数据每次归档的顺序一致(便于 diff)。
|
||||
"""
|
||||
out: list[FactorCurve] = []
|
||||
for name in sorted(factor_panels):
|
||||
defn, panel = factor_panels[name]
|
||||
points: list[CurvePoint] = []
|
||||
for day, weights in weights_by_day.items():
|
||||
if not weights or day not in panel.index:
|
||||
continue
|
||||
row = panel.loc[day]
|
||||
values = {s: _num(row.get(s)) for s in weights}
|
||||
avg = weighted_average(values, weights)
|
||||
if avg is None:
|
||||
continue
|
||||
points.append(CurvePoint(date=day.date() if hasattr(day, "date") else day, value=round(avg, 6)))
|
||||
out.append(
|
||||
FactorCurve(
|
||||
name=name,
|
||||
label=defn.display,
|
||||
direction=defn.direction,
|
||||
unit=defn.unit,
|
||||
points=points,
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -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)
|
||||
@@ -127,6 +127,9 @@ 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("");
|
||||
@@ -239,6 +242,7 @@ function BacktestInner() {
|
||||
setNotice("");
|
||||
setJobId("");
|
||||
setResult(null);
|
||||
setResultArchiveId(null);
|
||||
try {
|
||||
const { job_id } = await submit();
|
||||
setJobId(job_id);
|
||||
@@ -250,6 +254,7 @@ function BacktestInner() {
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
const exp = out.experimentId ?? null;
|
||||
setResultArchiveId(exp);
|
||||
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
@@ -649,7 +654,13 @@ function BacktestInner() {
|
||||
</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,8 @@ import {
|
||||
Banner,
|
||||
Progress,
|
||||
Signed,
|
||||
BacktestMetrics,
|
||||
MonthlyReturnsTable,
|
||||
UnimplementedNote,
|
||||
} from "@/components/ui";
|
||||
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
||||
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
|
||||
import { BacktestResultView } from "@/components/BacktestResultView";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
interface Pick {
|
||||
@@ -37,6 +33,8 @@ export default function ComposePage() {
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
|
||||
const [archiveId, setArchiveId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -88,6 +86,7 @@ export default function ComposePage() {
|
||||
setError("");
|
||||
setJobId("");
|
||||
setResult(null);
|
||||
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
|
||||
try {
|
||||
const spec: ResearchSpec = {
|
||||
type: "backtest",
|
||||
@@ -102,6 +101,7 @@ export default function ComposePage() {
|
||||
const out = await waitJob<BacktestResult>(job_id);
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
setArchiveId(out.experimentId ?? null);
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
@@ -289,7 +289,13 @@ export default function ComposePage() {
|
||||
{error ? <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 +303,17 @@ export default function ComposePage() {
|
||||
function ResultView({
|
||||
result,
|
||||
params,
|
||||
archiveId,
|
||||
}: {
|
||||
result: BacktestResult;
|
||||
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
|
||||
archiveId?: string | null;
|
||||
}) {
|
||||
const s = result.summary;
|
||||
|
||||
// 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射
|
||||
const equitySeries: LwSeries[] = [
|
||||
{
|
||||
key: "equity",
|
||||
label: "净值",
|
||||
type: "area",
|
||||
color: CHART.pos,
|
||||
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
|
||||
},
|
||||
];
|
||||
const drawdownSeries: LwSeries[] = [
|
||||
{
|
||||
key: "drawdown",
|
||||
label: "回撤",
|
||||
type: "line",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 +321,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2185,3 +2185,127 @@ button.chip:hover {
|
||||
min-width: 0;
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
/* ---------- 作业反馈条(JobProgress):点「运行」后必须立刻看得见 ---------- */
|
||||
.job-progress {
|
||||
margin: 12px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.job-progress--run {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.job-progress--ok {
|
||||
border-left-color: var(--pos);
|
||||
background: rgba(61, 220, 151, 0.08);
|
||||
}
|
||||
.job-progress--bad {
|
||||
border-left-color: var(--neg);
|
||||
background: rgba(255, 106, 118, 0.08);
|
||||
}
|
||||
.job-progress__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.job-progress__title {
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.job-progress__meta {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.job-progress__actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.job-progress__err {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-1);
|
||||
color: var(--neg);
|
||||
font-size: var(--fs-xs);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- 买卖说明:分段筛选 + 理由单元格 ---------- */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.seg__btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg__btn + .seg__btn {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.seg__btn.is-on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.th-sort {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.reason-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 260px;
|
||||
}
|
||||
.reason-cell__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.reason-cell__text {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reason-cell__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.reason-brief {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-2);
|
||||
max-width: 160px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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,308 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 买卖理由表(回测结果的「所有买卖点为什么买 / 为什么卖」)。
|
||||
*
|
||||
* 用户要求:「在所有买卖点详细说明买卖理由。用数据说话。」因此这里的原则是:
|
||||
* 1. **一个点都不省**:成交的、没成交的(涨停/停牌/现金不足)、Tmin 保护暂留的,
|
||||
* 全部来自 `signal_history`,不漏;
|
||||
* 2. **数字只来自引擎**:`reason.data` 里的名次 / 综合分 / 因子原始值 / 持有交易日
|
||||
* 直接展示,前端不做任何推算(免得出现「看起来像真的」的数字);
|
||||
* 3. **可核对**:原因分类(code)给中文短标签,点击筛选;理由原文可读。
|
||||
*
|
||||
* 老归档(2026-10 之前)没有 `reason` 字段,退化为展示原有的 `reject_reason`,
|
||||
* 并明确标注「旧归档无结构化理由」——不假装有数据。
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { ActionRecord, TradeReason } from "@/lib/types";
|
||||
import { reasonLabel } from "@/lib/types";
|
||||
import { Card, Pill } from "@/components/ui";
|
||||
import { SymbolLink } from "@/lib/symbols";
|
||||
|
||||
/** 因子键 → 展示名(来自结果里的 factor_curves;取不到就用引擎键) */
|
||||
export type FactorLabels = Record<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 之前的旧归档,当时还没有结构化理由。`
|
||||
: ""}
|
||||
</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<{
|
||||
|
||||
@@ -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);
|
||||
@@ -191,6 +191,59 @@ export interface MarkPoint {
|
||||
}
|
||||
|
||||
/** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */
|
||||
/**
|
||||
* 一次交易意图 / 成交的**结构化理由**(引擎给出的真实数字)。
|
||||
*
|
||||
* `code` 是封闭的原因分类(后端 `quant/trade_reasons.py`),前端据此筛选,
|
||||
* 不去解析 `text`;`data` 里的每个数字都来自引擎当时的计算 —— 界面只展示,不推算。
|
||||
*/
|
||||
export interface TradeReason {
|
||||
code: string;
|
||||
text: string;
|
||||
data: {
|
||||
rank?: number;
|
||||
total?: number;
|
||||
top_n?: number;
|
||||
score?: number;
|
||||
/** 各因子当时的**原始值**(key = 因子引擎键,可能是参数化键) */
|
||||
factors?: Record<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_defer_tmin: "Tmin 保护暂留",
|
||||
sell_defer_halted: "停牌未卖",
|
||||
sell_defer_limit_down: "跌停未卖",
|
||||
};
|
||||
|
||||
export function reasonLabel(code?: string | null): string {
|
||||
if (!code) return "—";
|
||||
return REASON_LABELS[code] ?? code;
|
||||
}
|
||||
|
||||
export interface ActionRecord {
|
||||
date: string;
|
||||
symbol: string;
|
||||
@@ -200,6 +253,17 @@ export interface ActionRecord {
|
||||
filled: boolean;
|
||||
reject_reason?: string | null;
|
||||
price?: number | null;
|
||||
reason?: TradeReason | null;
|
||||
}
|
||||
|
||||
/** 单个因子的时间序列(回测期内持仓组合加权平均的**原始值**) */
|
||||
export interface FactorCurve {
|
||||
name: string;
|
||||
label: string;
|
||||
direction: string;
|
||||
/** 量纲:% / 倍数 / 小数(小数意味着 0.15 = 15%,图上不换算) */
|
||||
unit?: string | null;
|
||||
points: CurvePoint[];
|
||||
}
|
||||
|
||||
/** 个股收益率曲线 + 该股买卖点标注 */
|
||||
@@ -219,11 +283,23 @@ export interface BacktestResult {
|
||||
monthly_returns: { year: number; month: number; return_pct: number }[];
|
||||
yearly_returns: { year: number; return_pct: number }[];
|
||||
positions: { date: string; symbol: string; name?: string | null; weight: number }[];
|
||||
trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[];
|
||||
trades: {
|
||||
entry_date: string;
|
||||
exit_date: string;
|
||||
symbol: string;
|
||||
name?: string | null;
|
||||
entry_price?: number;
|
||||
exit_price?: number;
|
||||
return_pct: number;
|
||||
entry_reason?: TradeReason | null;
|
||||
exit_reason?: TradeReason | null;
|
||||
}[];
|
||||
selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
|
||||
signal_history?: ActionRecord[];
|
||||
fills?: ActionRecord[];
|
||||
symbol_curves?: SymbolCurve[];
|
||||
/** 策略用到的每个因子的时间序列(持仓加权平均原始值):解释买卖依据 */
|
||||
factor_curves?: FactorCurve[];
|
||||
turnover_pct: number;
|
||||
unimplemented: string[];
|
||||
config_snapshot: Record<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