用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `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 渲染,截图确认表格与曲线数值正确。
556 lines
22 KiB
Python
556 lines
22 KiB
Python
"""研究领域对象:Research Specification、标准化研究结果。
|
||
|
||
原则(AGENT.md §16/§21/§24、ARCHITECTURE §14):
|
||
- 前端 / Agent / 后端统一经 Research Specification 描述任务,禁止直接拼引擎配置
|
||
- 回测结果一律标准化为 BacktestResult;未建模的成本/市场约束显式列在
|
||
unimplemented,禁止默认「无成本 / 永远可成交」假设
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
|
||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||
|
||
# ---------- Research Specification ----------
|
||
|
||
|
||
class UniverseSpec(BaseModel):
|
||
"""股票池口径。MVP:市场 + 过滤条件;指数成分等 Phase 3 扩展。
|
||
|
||
symbols 白名单:非空时仅这些股票参与(再叠加其余过滤);供自选池/测试使用。
|
||
market 目前为预留字段(stock.market 存储主板/创业板/科创板等中文枚举,过滤未启用)。
|
||
"""
|
||
|
||
market: str = Field(default="CN_A", description="CN_A / CN_B / ...(预留)")
|
||
exclude_st: bool = True
|
||
exclude_suspended: bool = True
|
||
min_listing_days: int = Field(default=250, ge=0, description="上市至少 N 个自然日")
|
||
index_code: str | None = Field(
|
||
default=None, description="指数成分过滤(如 000300.SH):按 as_of 当日历史成分(v3 §9)"
|
||
)
|
||
symbols: list[str] = Field(
|
||
default_factory=list,
|
||
description="白名单(可选):非空时仅这些 symbol 参与选股/回测",
|
||
)
|
||
|
||
|
||
class FactorSpec(BaseModel):
|
||
"""引用一个已注册因子并给定权重。"""
|
||
|
||
name: str
|
||
weight: float = Field(default=1.0, gt=0)
|
||
|
||
|
||
class ConditionSpec(BaseModel):
|
||
"""结构化选股条件(回测与选股共用)。
|
||
|
||
字段域:
|
||
- static.*:股票基础字段(industry / market / area / exchange / status…)
|
||
- 行情/技术字段:close / ma20 / ma60 / volume 及全部已注册因子名(momentum_60 等),
|
||
以及每日指标列(dv_ratio / dv_ttm / pe / pb / total_mv …)
|
||
- fundamental.*:财务字段(eps / roe / total_revenue / net_profit / gross_margin),
|
||
仅取 announce_date <= as_of 的最新已公告值(防未来函数)
|
||
|
||
右操作数取 value(字面量)或 ref(另一字段名),二者二选一。
|
||
|
||
字段域的事实来源:`quant/condition_fields.py`(字段库注册表,含中文名与口径)。
|
||
`/api/condition-fields`(前端下拉)、该注册表与引擎求值共用同一份定义,
|
||
避免「前端列一个、引擎算另一个」的漂移。
|
||
|
||
定义位置说明:本模型被 ResearchSpec(回测)与 SelectionQuery(选股)共用,
|
||
故落在 research.py(被 selection.py 依赖的低层模块),selection.py 再 re-export,
|
||
避免循环导入。
|
||
"""
|
||
|
||
field: str
|
||
op: str = Field(pattern="^(gt|gte|lt|lte|eq|ne|in|not_in)$")
|
||
value: float | int | str | list | None = None
|
||
ref: str | None = None # 与另一字段比较(如 close vs ma60)
|
||
|
||
@model_validator(mode="after")
|
||
def _require_operand(self) -> ConditionSpec:
|
||
if self.value is None and self.ref is None:
|
||
raise ValueError("value 与 ref 必须提供一个")
|
||
if self.value is not None and self.ref is not None:
|
||
raise ValueError("value 与 ref 只能提供一个")
|
||
if self.op in ("in", "not_in") and not isinstance(self.value, list):
|
||
raise ValueError("in/not_in 的 value 必须是列表")
|
||
return self
|
||
|
||
|
||
class SelectionSpec(BaseModel):
|
||
"""选股方式(两级截断)。
|
||
|
||
口径(用户案例「选 n 只 → 持仓前 x 只」):
|
||
- `top_n` = n:**候选池**大小。universe ∩ conditions 过滤后,按复合因子分降序取前 n
|
||
只 → 这就是「择股条件选出来的股数」(写入 selection_history)。
|
||
- `hold_top_x` = x:**实际持仓数**,取候选池前 x 只等权。x 必须 ≤ n;
|
||
另受「池内实际可买股票数」约束(过滤/缺数据会让实际池子小于 n)。
|
||
None → 等于 top_n(此时与旧行为一致:选出多少就持多少)。
|
||
|
||
`allow_substitute` 与 `defer_buy` 决定「买不进」时的处理(两者互斥,只能选一个):
|
||
- `allow_substitute=True`(**默认**,保持历史语义不变):从 n 名**之外**继续往下找
|
||
可买标的补足 x 只 —— 引擎既有行为,见 v3 §20.3 的 Signal↔Fill 测试。
|
||
- `defer_buy=True`(本项目「只买选出来的前 x 只」口径,推荐显式开启):
|
||
**不替补**,把这只股票的买单**顺延到之后第一个可成交的交易日**(涨停/停牌解除后
|
||
按当日收盘价买入);到下一次调仓仍未成交则作废,未投入资金留作现金。
|
||
- 两者都 False:意图被拒后直接放弃,资金留现金(不替补也不顺延)。
|
||
|
||
默认值刻意保持「向后兼容」:既有 Strategy / Experiment 的语义不因本次扩展而静默改变
|
||
(AGENT.md §35)。高股息案例在前端与 spec 中显式设置 defer_buy=True。
|
||
"""
|
||
|
||
top_n: int = Field(default=30, ge=1, le=1000, description="n:候选池大小")
|
||
hold_top_x: int | None = Field(
|
||
default=None, ge=1, le=1000, description="x:实际持仓数;None → = top_n"
|
||
)
|
||
allow_substitute: bool = Field(
|
||
default=True,
|
||
description="True(默认,历史语义):从 n 名之外替补补足;False:不引入计划外标的",
|
||
)
|
||
defer_buy: bool = Field(
|
||
default=False,
|
||
description="True:买不进(涨停/停牌)时顺延到之后首个可成交日的收盘买入",
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def _check_x_le_n(self) -> SelectionSpec:
|
||
if self.hold_top_x is not None and self.hold_top_x > self.top_n:
|
||
raise ValueError(
|
||
f"hold_top_x(持仓 x={self.hold_top_x})不能大于 top_n(候选池 n={self.top_n})"
|
||
)
|
||
if self.allow_substitute and self.defer_buy:
|
||
raise ValueError(
|
||
"allow_substitute=True(往下替补)与 defer_buy=True(顺延买入)语义互斥,只能选一个"
|
||
)
|
||
return self
|
||
|
||
@property
|
||
def x(self) -> int:
|
||
"""实际持仓目标数(未显式给 x 时等于 n)。"""
|
||
return self.hold_top_x if self.hold_top_x is not None else self.top_n
|
||
|
||
|
||
class PortfolioSpec(BaseModel):
|
||
"""组合构建(v2 §16)。MVP:等权;单股/行业上限等约束字段预留,
|
||
未建模约束在回测结果 unimplemented 中如实标注(禁止假装支持)。
|
||
"""
|
||
|
||
weighting: str = Field(default="equal", pattern="^(equal)$")
|
||
max_position_pct: float | None = Field(
|
||
default=None, gt=0, le=1, description="单股最大权重(预留,未建模)"
|
||
)
|
||
max_industry_weight_pct: float | None = Field(
|
||
default=None, gt=0, le=1, description="行业最大权重(预留,未建模)"
|
||
)
|
||
|
||
|
||
class CostSpec(BaseModel):
|
||
"""交易成本模型。
|
||
|
||
买:commission(≥ min_commission)+ slippage
|
||
卖:commission(≥ min_commission)+ stamp_tax + slippage
|
||
|
||
`min_commission` 为**单笔最低佣金**(A 股常见 5 元)。默认 0.0 = 不启用,
|
||
以保持既有回测数值不变(AGENT.md §35);高股息等实盘贴近场景建议显式设 5.0。
|
||
注意:最低佣金对**小额单**影响显著,x 越多、单笔越小,成本占比越高。
|
||
"""
|
||
|
||
commission_rate: float = Field(default=0.0003, ge=0, le=0.01)
|
||
stamp_tax_rate: float = Field(default=0.0005, ge=0, le=0.01)
|
||
slippage_rate: float = Field(default=0.001, ge=0, le=0.05)
|
||
min_commission: float = Field(
|
||
default=0.0, ge=0, le=100.0, description="单笔最低佣金(元);0 = 不启用"
|
||
)
|
||
benchmark: str = Field(default="000300.SH", description="对照基准指数代码")
|
||
|
||
|
||
class ResearchSpec(BaseModel):
|
||
"""一次研究的完整描述。type 决定执行路径。"""
|
||
|
||
type: str = Field(default="backtest", pattern="^(factor_test|backtest)$")
|
||
universe: UniverseSpec = UniverseSpec()
|
||
price_adjustment: str = Field(
|
||
default="none", pattern="^(none|qfq|hfq)$",
|
||
description=(
|
||
"研究行情口径:none 不复权(默认)/ qfq 前复权 / hfq 后复权。"
|
||
"qfq/hfq 基于 adjust_factor 折算(v3 §20.5);结果与 config_snapshot 中显式记录。"
|
||
),
|
||
)
|
||
factors: list[FactorSpec] = Field(min_length=1)
|
||
conditions: list[ConditionSpec] = Field(
|
||
default_factory=list,
|
||
description=(
|
||
"选股过滤条件(AND,可选):universe 之后、因子排序之前执行。"
|
||
"字段域同 SelectionQuery.conditions(static.* / 行情列 / 已注册因子 / "
|
||
"fundamental.*),回测与 /api/selections 共用同一求值器(v2 §25 一致性)。"
|
||
),
|
||
)
|
||
selection: SelectionSpec = SelectionSpec()
|
||
rebalance: str = Field(default="monthly", pattern="^(weekly|monthly)$")
|
||
selection_interval_months: int | None = Field(
|
||
default=None, ge=1, le=60,
|
||
description=(
|
||
"m:择股间隔(月)。None → 每次调仓都重新择股(等价于 rebalance 频率)。"
|
||
"择股日 = 起始月锚定,月序号 % m == 0 的月份的首个交易日。"
|
||
),
|
||
)
|
||
rebalance_interval_months: int | None = Field(
|
||
default=None, ge=1, le=60,
|
||
description=(
|
||
"y:调仓间隔(月)。None → 等于 selection_interval_months(未给则按 rebalance 频率)。"
|
||
"y < m 时池子在下一次择股前保持不变(结果中会标注池子陈旧)。"
|
||
),
|
||
)
|
||
period: tuple[date, date]
|
||
costs: CostSpec = CostSpec()
|
||
portfolio: PortfolioSpec = PortfolioSpec()
|
||
initial_capital: float = Field(default=1_000_000.0, gt=0)
|
||
|
||
@field_validator("period")
|
||
@classmethod
|
||
def _period_ordered(cls, period: tuple[date, date]) -> tuple[date, date]:
|
||
if period[0] >= period[1]:
|
||
raise ValueError("period 必须满足 start < end")
|
||
return period
|
||
|
||
@model_validator(mode="after")
|
||
def _no_duplicate_factors(self) -> ResearchSpec:
|
||
names = [f.name for f in self.factors]
|
||
if len(set(names)) != len(names):
|
||
raise ValueError("factors 存在重复因子名")
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _check_intervals(self) -> ResearchSpec:
|
||
m = self.selection_interval_months
|
||
y = self.rebalance_interval_months
|
||
# 未给 m 却给了 y:语义不完整(y 无锚点可依)→ 明确拒绝而非猜
|
||
if m is None and y is not None and y != 1:
|
||
raise ValueError(
|
||
"只给了 rebalance_interval_months(y) 而没给 selection_interval_months(m):"
|
||
"请同时给出 m,否则无法确定择股日集合"
|
||
)
|
||
if m is not None and y is not None and y < m and y != 1:
|
||
# 允许但不静默:池子会在多个调仓日复用(陈旧),交由结果 unimplemented 标注
|
||
return self
|
||
return self
|
||
|
||
@property
|
||
def effective_selection_months(self) -> int | None:
|
||
"""实际择股间隔(月);None 表示「每次调仓都择股」。"""
|
||
return self.selection_interval_months
|
||
|
||
@property
|
||
def effective_rebalance_months(self) -> int | None:
|
||
"""实际调仓间隔(月);None 表示按 rebalance 频率(周/月)。"""
|
||
if self.rebalance_interval_months is not None:
|
||
return self.rebalance_interval_months
|
||
return self.selection_interval_months
|
||
|
||
|
||
# ---------- 回测结果 ----------
|
||
|
||
|
||
class CurvePoint(BaseModel):
|
||
date: date
|
||
value: float
|
||
|
||
|
||
class MonthlyReturn(BaseModel):
|
||
year: int
|
||
month: int
|
||
return_pct: float # 百分数,如 3.2 表示 +3.2%
|
||
|
||
|
||
class YearlyReturn(BaseModel):
|
||
year: int
|
||
return_pct: float
|
||
|
||
|
||
class BacktestSummary(BaseModel):
|
||
start: date
|
||
end: date
|
||
initial_capital: float
|
||
final_equity: float
|
||
total_return_pct: float
|
||
annual_return_pct: float
|
||
sharpe: float
|
||
max_drawdown_pct: float
|
||
volatility_pct: float
|
||
win_rate_pct: float
|
||
total_trades: int
|
||
avg_turnover_pct: float
|
||
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
|
||
symbol: str
|
||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||
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):
|
||
date: date
|
||
symbol: str
|
||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||
weight: float
|
||
|
||
|
||
class RankedPick(BaseModel):
|
||
"""调仓日选股意图候选(与 select(as_of) 同源;v3 §22.3 selection_history)。"""
|
||
|
||
date: date
|
||
symbol: str
|
||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||
rank: int
|
||
score: float
|
||
|
||
|
||
class ActionRecord(BaseModel):
|
||
"""一次交易意图(Signal)及其成交结果(Fill)—— v3 §20.3 Signal↔Fill 区分。
|
||
|
||
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
|
||
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
|
||
|
||
`reason` 是**数据化**的为什么:`reject_reason` 只说「没成交」(执行层),
|
||
`reason` 同时覆盖成交与未成交(策略层 + 执行层),并带上当时的排名 / 综合分 /
|
||
各因子原始值,前端「买卖说明」直接用,不再二次推断。
|
||
|
||
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
|
||
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
|
||
"""
|
||
|
||
date: date
|
||
symbol: str
|
||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||
signal: str = Field(pattern="^(BUY|SELL)$")
|
||
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):
|
||
"""个股收益率趋势曲线 + 该股买卖点标注(回测结果可视化用)。
|
||
|
||
`points[].value` 语义:该股**持仓期间**的累计收益率(%,以建仓日收盘为 0% 基准,
|
||
按日复利)。只在该股被持有的交易日落点(未持有期间不落点,以压缩结果体积);
|
||
建仓当日会补一个基准点,保证买卖点标注总能在曲线上取到数值。多段持仓以累计值
|
||
连乘衔接,读图时以 marks 中的 BUY/SELL 区分各段持仓区间。
|
||
|
||
`marks` 为该股实际成交(BUY/SELL fill)的日期与价格,与 `signal_history`
|
||
中 filled=True 的记录一致(v3 §20.3 的成交口径)。
|
||
"""
|
||
|
||
symbol: str
|
||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||
points: list[CurvePoint] = Field(default_factory=list)
|
||
marks: list[ActionRecord] = Field(default_factory=list)
|
||
final_return_pct: float = Field(
|
||
default=0.0, description="该股持仓期累计收益率(%,多段持仓连乘)"
|
||
)
|
||
|
||
|
||
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)。前端只依赖该结构。"""
|
||
|
||
summary: BacktestSummary
|
||
equity_curve: list[CurvePoint]
|
||
drawdown: list[CurvePoint]
|
||
monthly_returns: list[MonthlyReturn]
|
||
yearly_returns: list[YearlyReturn]
|
||
positions: list[Position]
|
||
trades: list[Trade]
|
||
selection_history: list[RankedPick] = Field(
|
||
default_factory=list, description="各调仓日选股意图候选(同 select(as_of))"
|
||
)
|
||
signal_history: list[ActionRecord] = Field(
|
||
default_factory=list, description="交易意图与是否成交(v3 §20.3)"
|
||
)
|
||
fills: list[ActionRecord] = Field(
|
||
default_factory=list, description="实际成交(signal_history 中 filled=True 的子集)"
|
||
)
|
||
symbol_curves: list[SymbolCurve] = Field(
|
||
default_factory=list,
|
||
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
|
||
)
|
||
factor_curves: list[FactorCurve] = Field(
|
||
default_factory=list,
|
||
description=(
|
||
"策略用到的每个因子的时间序列(持仓加权平均原始值):"
|
||
"用来解释「买卖依据的那个因子在各时点是什么水平」"
|
||
),
|
||
)
|
||
turnover_pct: float
|
||
unimplemented: list[str] = Field(
|
||
default_factory=list,
|
||
description="本结果中未建模的约束(AGENT §24:必须显式标注,禁止假装支持)",
|
||
)
|
||
config_snapshot: dict = Field(default_factory=dict, description="复现用完整配置快照")
|
||
archive_meta: dict = Field(
|
||
default_factory=dict,
|
||
description=(
|
||
"归档元数据(由 experiment_archive 在落库时写入):curves_stored / "
|
||
"curves_total / truncated / budget_chars / budget_bytes / result_chars / "
|
||
"result_bytes。用于说明归档是否因体积预算被裁剪(AGENT §24 不静默)"
|
||
),
|
||
)
|
||
|
||
|
||
SymbolCurve.model_rebuild()
|
||
|
||
|
||
# ---------- 因子测试结果 ----------
|
||
|
||
|
||
class QuantileReturn(BaseModel):
|
||
"""分层收益:按因子值升序分 N 层后各层等权组合的区间收益。"""
|
||
|
||
quantile: int
|
||
return_pct: float
|
||
|
||
|
||
class FactorTestReport(BaseModel):
|
||
factor_name: str
|
||
ic_mean: float
|
||
icir: float
|
||
rank_ic_mean: float
|
||
positive_ratio_pct: float
|
||
quantile_returns: list[QuantileReturn]
|
||
spread_quantile: int | None = Field(
|
||
default=None, description="分层价差 = 最高层收益 - 最低层收益(若多头/空头语义适用)"
|
||
)
|
||
sample_days: int
|
||
unimplemented: list[str] = Field(default_factory=list)
|
||
config_snapshot: dict = Field(default_factory=dict)
|
||
|
||
|
||
# ---------- 因子相关性 / 暴露分析(C1,v3 §12) ----------
|
||
|
||
|
||
class FactorCorrelationReport(BaseModel):
|
||
"""多因子两两相关(横截面相关逐日均值;v3 §12 冗余剔除前置)。"""
|
||
|
||
factors: list[str]
|
||
corr_matrix: dict[str, dict[str, float]] = Field(
|
||
default_factory=dict, description="{f1: {f2: spearman 相关系数}}(对角线=1)"
|
||
)
|
||
sample_days: int = 0
|
||
sample_min_symbols: int = 0
|
||
unimplemented: list[str] = Field(default_factory=list)
|
||
config_snapshot: dict = Field(default_factory=dict)
|
||
|
||
|
||
# ---------- 异步 Job 与 Experiment(Phase 4) ----------
|
||
|
||
|
||
class JobStatus(str):
|
||
"""统一状态机(AGENT.md §20):queued→running→(success|failed|cancelled)。"""
|
||
|
||
QUEUED = "queued"
|
||
RUNNING = "running"
|
||
SUCCESS = "success"
|
||
FAILED = "failed"
|
||
CANCELLED = "cancelled"
|
||
|
||
|
||
class JobRecord(BaseModel):
|
||
"""一次异步研究任务。spec/result 以 JSON 文本存储(保持 Schema 演进自由)。"""
|
||
|
||
id: str
|
||
kind: str # factor_test | backtest
|
||
status: str = JobStatus.QUEUED
|
||
stage: str | None = None
|
||
spec_json: str
|
||
error: str | None = None
|
||
result_json: str | None = None
|
||
experiment_id: str | None = None
|
||
created_at: datetime | None = None
|
||
started_at: datetime | None = None
|
||
finished_at: datetime | None = None
|
||
|
||
|
||
class ExperimentRecord(BaseModel):
|
||
"""一次研究的可复现存档(AGENT.md §21)。"""
|
||
|
||
id: str
|
||
kind: str # factor_test | backtest
|
||
spec_json: str
|
||
result_json: str
|
||
summary_text: str | None = None # 便于列表展示的摘要(如 total_return_pct)
|
||
code_version: str | None = None # git commit / 代码指纹
|
||
data_version: str | None = None
|
||
job_id: str | None = None
|
||
created_at: datetime | None = None
|
||
|
||
|
||
class ExperimentSummary(BaseModel):
|
||
"""归档列表项(**不含 result_json**)。
|
||
|
||
列表接口一次可能返回上百条归档,而 `result_json` 是 MEDIUMTEXT(完整存档后
|
||
单条可达数 MB):为避免把上百 MB 拉进内存,仓储的列表查询只取元数据列,
|
||
`result_bytes` 由 SQL 的字符长度函数(MySQL CHAR_LENGTH / SQLite length)
|
||
在库侧算出,不取回大字段本身。
|
||
"""
|
||
|
||
id: str
|
||
kind: str
|
||
spec_json: str
|
||
summary_text: str | None = None
|
||
code_version: str | None = None
|
||
data_version: str | None = None
|
||
job_id: str | None = None
|
||
created_at: datetime | None = None
|
||
result_bytes: int = 0 # 归档 JSON 的字符数(SQL 侧计算,不拉大字段)
|