Files
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

128 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""选股系统领域对象(ARCHITECTURE_v2 §14 Selection Engine)。
回答两个核心问题(v2 §8):
- 「某历史日(as_of)为什么选出这些股票?」→ SelectionResult 带 factor_values / selection_reason
- 「当前(as_of)有哪些股票满足策略?」→ 同一条查询对当前日期执行
设计:
- SelectionQuery = v2 §14.2 的 Selection 输入(universe 范围 + 评分因子 + TopN 截断 + as_of)。
- method=score:按因子加权复合分取 TopN(复用现有 9 个内置因子);
method=condition:结构化条件选股(M6.2 加入 ConditionSpec)。
- 结果不落库由本实体负责(落库表在 M6.3);本实体是前后端/Agent 的统一 DTO(v2 §21.1)。
- 所有查询天然带 as_of 语义:只允许使用 <= as_of 的数据(v2 §9 防未来函数)。
"""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator, model_validator
from app.domain.entities.research import ConditionSpec, FactorSpec, UniverseSpec # noqa: F401
class SelectionQuery(BaseModel):
"""一次选股查询(v2 §14.2 Selection 输入)。"""
universe: UniverseSpec = UniverseSpec()
price_adjustment: str = Field(
default="none", pattern="^(none|qfq|hfq)$",
description=(
"行情口径:none 不复权(默认)/ qfq 前复权 / hfq 后复权(按 adjust_factor 折算)。"
"与回测(ResearchSpec.price_adjustment)同一口径域,结果 config_snapshot 中显式记录"
),
)
# 研究时点:None → 引擎用 <= 今天最近可用交易日;显式给历史日期即做历史选股
as_of: date | None = Field(
default=None, description="选股时点;历史回测/解释用具体日期,当前选股可留空"
)
method: str = Field(default="score", pattern="^(score|condition)$")
# method=score:因子 + 权重(至少 1 个;方向由因子元数据决定)
factors: list[FactorSpec] = Field(default_factory=list)
# method=condition:结构化条件(M6.2 引入 ConditionSpec 后启用)
conditions: list[ConditionSpec] = Field(default_factory=list)
# 截断:top_n(绝对数量)与 top_pct(占可评分股票比例)二选一;可选 min_score 下限
top_n: int | None = Field(default=None, ge=1, le=2000)
top_pct: float | None = Field(default=None, gt=0, le=1)
min_score: float | None = None
# 因子预热窗口(自然日):覆盖 lookback 前导数据,Lookback 放大时需同步加大
warmup_days: int = Field(default=300, ge=0)
@model_validator(mode="after")
def _check_method_args(self) -> SelectionQuery:
if self.method == "score":
if not self.factors:
raise ValueError("method=score 需要至少一个 factors")
if self.top_n is None and self.top_pct is None:
raise ValueError("method=score 需要 top_n 与 top_pct 至少提供一个")
if self.method == "condition" and not self.conditions:
raise ValueError("method=condition 需要至少一个 conditions")
return self
@model_validator(mode="after")
def _no_duplicate_factors(self) -> SelectionQuery:
names = [f.name for f in self.factors]
if len(set(names)) != len(names):
raise ValueError("factors 存在重复因子名")
return self
# ConditionSpec 的实体定义已上移到 research.py(ResearchSpec 回测条件与
# SelectionQuery 选股条件共用同一模型)。此处 re-export:`selection.ConditionSpec`
# 就是 research.ConditionSpec 这**同一个类对象**,因此两侧 isinstance 判断一致。
# (见文件顶部 import 处的引用)
class SelectionCandidate(BaseModel):
"""单只候选股(v2 §14.3/§21.1)。
`name` 为展示增强字段(可选默认 None):由 SelectionService 用已装配的股票池
统一回填,引擎/选股算法本身不感知名称 —— 避免在每个出口零散 join。
"""
symbol: str
name: str | None = Field(default=None, description="股票名称(展示用)")
rank: int
score: float
factor_values: dict[str, float] = Field(default_factory=dict)
filter_status: list[str] = Field(default_factory=list, description="各条件通过/未通过")
selection_reason: list[str] = Field(default_factory=list, description="为什么选它(可解释)")
class SelectionStatistics(BaseModel):
universe_size: int = 0 # 股票池过滤后数量
evaluated: int = 0 # 有有效分数的股票数量
selected: int = 0 # 最终选出数量
class SelectionResult(BaseModel):
"""选股结果(v2 §21.1)。前端 / Agent 只依赖该结构。"""
as_of_date: date
method: str
statistics: SelectionStatistics
candidates: list[SelectionCandidate] = Field(default_factory=list)
unimplemented: list[str] = Field(
default_factory=list,
description="本结果中未建模的约束(如 exclude_suspended 依赖停牌数据未实现)",
)
config_snapshot: dict = Field(default_factory=dict, description="复现用查询快照")
@field_validator("candidates")
@classmethod
def _rank_sorted(cls, candidates: list[SelectionCandidate]) -> list[SelectionCandidate]:
return sorted(candidates, key=lambda c: c.rank)
SelectionQuery.model_rebuild()
class SelectionMeta(BaseModel):
"""选股运行元数据(列表/历史查询用,不含候选明细)。"""
id: str
as_of: date
method: str
universe_size: int = 0
selected: int = 0
created_at: datetime | None = None