汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
128 lines
5.6 KiB
Python
128 lines
5.6 KiB
Python
"""选股系统领域对象(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
|