"""选股系统领域对象(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 FactorSpec, UniverseSpec class SelectionQuery(BaseModel): """一次选股查询(v2 §14.2 Selection 输入)。""" universe: UniverseSpec = UniverseSpec() price_adjustment: str = Field( default="none", pattern="^(none|qfq)$", description="行情口径:none 不复权(默认)/ qfq 前复权(结果 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 class ConditionSpec(BaseModel): """结构化选股条件(M6.2 使用)。 field 域: - static.*:股票基础字段(industry / market / area / exchange / status…) - 行情/技术字段:close / ma20 / ma60 / volume 及全部已注册因子名(momentum_60 等) - fundamental.*:财务字段(eps / roe / total_revenue / net_profit / gross_margin), 仅取 announce_date <= as_of 的最新已公告值(防未来函数) 右操作数取 value(字面量)或 ref(另一字段名),二者二选一。 """ 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 SelectionCandidate(BaseModel): """单只候选股(v2 §14.3/§21.1)。""" symbol: str 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