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

97 lines
4.4 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.
"""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))