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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
@@ -2,10 +2,10 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.domain.entities.research import ExperimentRecord, JobRecord
from app.domain.entities.research import ExperimentRecord, ExperimentSummary, JobRecord
from app.infrastructure.persistence.sqlalchemy.models.jobs import ExperimentModel, JobModel
@@ -53,6 +53,12 @@ class SqlAlchemyJobRepository:
class SqlAlchemyExperimentRepository:
"""Experiment 归档仓储。
`list_filtered` 刻意**不加载 `result_json`**(MEDIUMTEXT,完整存档后单条数 MB),
只 SELECT 元数据列 + SQL 侧算出的字符长度,避免列表接口把上百 MB 拉进内存。
"""
def __init__(self, session: Session) -> None:
self._session = session
@@ -61,6 +67,12 @@ class SqlAlchemyExperimentRepository:
self._session.flush()
return experiment
def upsert(self, experiment: ExperimentRecord) -> ExperimentRecord:
"""按 id 插入或覆盖(`merge` 语义):重建归档时保持原 id 不变。"""
self._session.merge(ExperimentModel(**experiment.model_dump()))
self._session.flush()
return experiment
def get(self, experiment_id: str) -> ExperimentRecord | None:
row = self._session.get(ExperimentModel, experiment_id)
return ExperimentRecord.model_validate(row, from_attributes=True) if row else None
@@ -70,3 +82,98 @@ class SqlAlchemyExperimentRepository:
select(ExperimentModel).order_by(ExperimentModel.created_at.desc()).limit(limit)
).all()
return [ExperimentRecord.model_validate(r, from_attributes=True) for r in rows]
# -- 列表 / 过滤(SQL 层完成过滤+分页,参数绑定,不做字符串拼接)-------------
@staticmethod
def _escape_like(value: str) -> str:
"""转义 LIKE 元字符:用户输入里的 % / _ / \\ 不应被当成通配符。"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _filter_conditions(self, *, kind: str | None, q: str | None):
"""kind 精确匹配 + q 大小写不敏感模糊匹配(id / spec_json=因子名 / summary_text)。"""
conditions = []
if kind:
conditions.append(ExperimentModel.kind == kind)
if q:
pattern = f"%{self._escape_like(q.lower())}%"
conditions.append(
func.lower(ExperimentModel.id).like(pattern, escape="\\")
| func.lower(ExperimentModel.spec_json).like(pattern, escape="\\")
| func.lower(func.coalesce(ExperimentModel.summary_text, "")).like(
pattern, escape="\\"
)
)
return conditions
def _summary_stmt(self, conditions) -> Select:
return select(
ExperimentModel.id,
ExperimentModel.kind,
ExperimentModel.spec_json,
ExperimentModel.summary_text,
ExperimentModel.code_version,
ExperimentModel.data_version,
ExperimentModel.job_id,
ExperimentModel.created_at,
self._char_length().label("result_bytes"),
).where(*conditions)
def _char_length(self):
"""归档 JSON 的**字符数**(不取大字段本身)。
MySQL 的 `LENGTH()` 返回字节数,字符数要用 `CHAR_LENGTH()`;SQLite 只有
`length()`(对 TEXT 返回字符数)。按方言选择,避免中文归档在两个库上口径
不一致。
"""
if self._session.get_bind().dialect.name == "sqlite":
return func.length(ExperimentModel.result_json)
return func.char_length(ExperimentModel.result_json)
def list_filtered(
self,
*,
kind: str | None = None,
q: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[ExperimentSummary]:
stmt = (
self._summary_stmt(self._filter_conditions(kind=kind, q=q))
# created_at 在 MySQL datetime(0) 下只有秒精度,同秒创建的归档必须有
# 稳定的次级排序键,否则分页会重复/漏项
.order_by(ExperimentModel.created_at.desc(), ExperimentModel.id.desc())
.limit(max(limit, 0))
.offset(max(offset, 0))
)
return [
ExperimentSummary(
id=row.id,
kind=row.kind,
spec_json=row.spec_json,
summary_text=row.summary_text,
code_version=row.code_version,
data_version=row.data_version,
job_id=row.job_id,
created_at=row.created_at,
result_bytes=int(row.result_bytes or 0),
)
for row in self._session.execute(stmt).all()
]
def count_filtered(self, *, kind: str | None = None, q: str | None = None) -> int:
stmt = (
select(func.count())
.select_from(ExperimentModel)
.where(*self._filter_conditions(kind=kind, q=q))
)
return int(self._session.execute(stmt).scalar() or 0)
def delete(self, experiment_id: str) -> bool:
"""删除归档本身;返回 False 表示不存在。不触碰 job 表。"""
row = self._session.get(ExperimentModel, experiment_id)
if row is None:
return False
self._session.delete(row)
self._session.flush()
return True