"""Universe:选股/回测的股票范围执行器(ARCHITECTURE_v2 §14/§20 Universe 输入)。 把 ResearchService.filter_stocks 的语义规则化并集中于此: - 当前日与历史日(as_of)都必须正确:退市股(delist < as_of)、上市时间(list_date) - exclude_st 优先用**时点名称**(`name_at` 映射,来自 stock_name_history)判定; 未提供或该股无记录时回退最新名称快照(历史改名无法回溯,属近似,见 unimplemented 说明) - exclude_suspended 依赖停牌数据表(尚未建模),此处不做剔除,由上层显式标注 - symbols 白名单:非空时仅这些 symbol 参与(自选池 / 测试用) """ from __future__ import annotations import logging from collections.abc import Mapping, Sequence from datetime import date from app.domain.entities.market import Stock from app.domain.entities.research import UniverseSpec logger = logging.getLogger(__name__) def resolve_members(index_repo, universe: UniverseSpec, as_of: date) -> set[str] | None: """若 universe 指定指数成分 → 取 as_of 当日历史成分;否则 None(不过滤)。""" if index_repo is None or not universe.index_code: return None return index_repo.members_at(universe.index_code, as_of) def filter_stocks( stocks: Sequence[Stock], universe: UniverseSpec, as_of: date, members: set[str] | None = None, name_at: Mapping[str, str] | None = None, ) -> list[Stock]: """按股票池口径过滤,返回 as_of 时点应纳入的股票列表。 members:指数历史成分集合(resolve_members 结果);提供时取交集。 name_at:各股票在 as_of **时点生效的名称**(stock_name_history 查询结果)。 提供时 `exclude_st` 用它判定,缺失的股票回退 `stock.name`(最新快照)。 这是「股息陷阱」能否被正确纳入的关键:用最新名称会把「后来才 ST」的标的 在整段历史上提前排除(实测影响约 3.70pp 收益,见 DEV_PLAN §10.5)。 """ symbols = set(universe.symbols) if universe.symbols else None out: list[Stock] = [] for s in stocks: if symbols is not None and s.symbol not in symbols: continue if members is not None and s.symbol not in members: continue if s.delist_date is not None and s.delist_date < as_of: continue if universe.exclude_st: name = (name_at or {}).get(s.symbol) or s.name if name and "ST" in name.upper(): continue if ( universe.min_listing_days and s.list_date and (as_of - s.list_date).days < universe.min_listing_days ): continue out.append(s) return out def names_as_of( stocks: Sequence[Stock], as_of: date, name_repo, ) -> tuple[dict[str, str] | None, tuple[bool, int]]: """装配 as_of 时点的名称映射,供 `filter_stocks(exclude_st=True)` 使用。 返回 `(name_at, (是否时点口径, 覆盖只数))`: - `name_repo` 为 None(未注入 / 表为空)→ `(None, (False, 0))`, 调用方回退 `stock.name`(最新快照,旧行为),结果页据此标注残余偏差; - 否则返回时点名称映射,`filter_stocks` 逐股优先取时点名称、缺失回退最新名称。 时点语义(无未来函数):取 `start_date <= as_of <= end_date` 的生效名称区间。 名称自 `start_date` 起即对市场可见,故不构成前视;`ann_date` 仅作留痕。 """ if name_repo is None: return None, (False, 0) try: name_at = name_repo.names_as_of([s.symbol for s in stocks], as_of) except Exception: # noqa: BLE001 —— 名称历史缺失不应让选股/回测整体失败 logger.warning("名称变更历史查询失败,回退最新名称快照判定 exclude_st", exc_info=True) return None, (False, 0) if not name_at: # 表存在但**无任何生效区间**(未执行 sync namechange / 表被清空): # `filter_stocks` 会逐股回退最新名称,因此口径仍是快照,**不得**声称时点, # 否则结果页会把「未被修正的股息陷阱偏差」当成已修正上报(AGENT.md §24)。 logger.warning("名称变更历史为空(as_of=%s),exclude_st 回退最新名称快照", as_of) return None, (False, 0) return name_at, (True, len(name_at))