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

129 lines
5.5 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.
"""数据集快照指纹:`experiment.data_version` 的真实取值来源。
归档的用途是「往复查看 / 复现」,因此除了代码版本(code_version)还必须记录
**当时的数据口径**:数据同步到哪一天、各表规模多大。此前该字段从未被写入
(永远 NULL),本模块补上。
诚实性与成本(AGENT.md §7 不静默 / §24 不假装支持):
- 交易日 `MAX(trade_date)` 走索引,实测 ~0.2ms,**真实值**;
- 大表(stock_daily / adjust_factor / daily_basic,各 800 万行量级)的全表
`COUNT(*)` 实测单次 ~1.2s,三次合计 ~3.6s —— 不允许出现在请求路径上;
故 MySQL 下改用 `information_schema.TABLES.TABLE_ROWS`(单次查询实测 ~0.5ms),
它是 InnoDB 统计缓存的**近似值**(实测 stock_daily 7688126 vs 真实 COUNT(*)
8052698,偏差 ~4.6%),因此字符串里用 `≈` 明确标注为近似,绝不冒充精确计数。
- 非 MySQL 方言(测试用的 SQLite 等)数据量小,直接 `COUNT(*)` 得到精确值,
不带 `≈` 标记;
- 任何一段取不到(连接失败 / 表不存在 / 权限不足)都**降级**:要么丢弃该段,
要么整体返回 `unavailable`,绝不编造数字。
字符串格式(≤40 字符 —— experiment.data_version 列是 varchar(40),本文件不新增
迁移,故必须塞得下;段超长时从右往左丢弃低优先级段,再退化到 `d<date>`):
d<YYYYMMDD> stock_daily 的最大交易日(`d-` 表示该表无数据 / 取不到交易日)
n<count> stock_daily 行数
a<count> adjust_factor 行数
b<count> daily_basic 行数
行数段写法:`≈<N>k` = MySQL 近似值(k = 千行,四舍五入);`<N>` = 精确值。
示例:`d20260904;n≈8053k;a≈8189k;b≈7718k`
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.infrastructure.persistence.sqlalchemy.models.market import (
AdjustFactorModel,
DailyBasicModel,
StockDailyModel,
)
logger = logging.getLogger(__name__)
# experiment.data_version 列宽(varchar(40),见 models/jobs.py + 既有迁移)
DATA_VERSION_MAX_LEN = 40
# 段定义:(段名, model),顺序即优先级(越靠前越重要)
_TABLES: tuple[tuple[str, type], ...] = (
("n", StockDailyModel),
("a", AdjustFactorModel),
("b", DailyBasicModel),
)
_FALLBACK = "unavailable"
def _fmt_rows(rows: int | None, *, approx: bool) -> str | None:
"""行数段:近似值用「≈Nk」(千行),精确值用原样数字。"""
if rows is None:
return None
if not approx:
return str(rows)
return f"≈{round(rows / 1000)}k"
def _latest_trade_date(session: Session) -> str:
"""stock_daily 最大交易日 → `YYYYMMDD`;无数据 / 取不到 → `-`。"""
try:
day = session.execute(select(func.max(StockDailyModel.trade_date))).scalar()
except Exception as exc: # noqa: BLE001 —— 指纹取不到必须降级,不影响归档主体
logger.warning("data_version: MAX(trade_date) 取不到:%s: %s", type(exc).__name__, exc)
return "-"
return day.strftime("%Y%m%d") if day is not None else "-"
def _approx_rows_mysql(session: Session) -> dict[str, int]:
"""MySQL:一次 information_schema 查询拿全部表的近似行数(实测 ~0.5ms)。"""
try:
rows = session.execute(
text(
"SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN :names"
).bindparams(names=tuple(m.__tablename__ for _, m in _TABLES))
).all()
except Exception as exc: # noqa: BLE001
logger.warning("data_version: information_schema 查询失败:%s: %s", type(exc).__name__, exc)
return {}
return {str(name): int(n) for name, n in rows if n is not None}
def _exact_rows(session: Session, model: type) -> int | None:
"""其它方言(SQLite 等,数据量小):精确 COUNT(*)。"""
try:
return int(session.execute(select(func.count()).select_from(model)).scalar() or 0)
except Exception as exc: # noqa: BLE001
logger.warning("data_version: COUNT(*) 失败:%s: %s", type(exc).__name__, exc)
return None
def compute_data_version(session: Session) -> str:
"""计算数据集快照指纹(格式与取舍见模块 docstring)。失败降级为 `unavailable`。"""
try:
dialect = session.get_bind().dialect.name
except Exception: # noqa: BLE001
dialect = ""
approx = dialect == "mysql"
approx_rows = _approx_rows_mysql(session) if approx else {}
# 首段恒为交易日段:`d-` 本身也是真实信息(表为空 / 取不到交易日),保留
segments: list[str] = [f"d{_latest_trade_date(session)}"]
for name, model in _TABLES:
rows = (
approx_rows.get(model.__tablename__) if approx else _exact_rows(session, model)
)
seg = _fmt_rows(rows, approx=approx)
if seg is not None:
segments.append(f"{name}{seg}")
# 列宽护栏:超 40 字符时从右往左丢弃低优先级段(保留的段仍是真实值,绝不截断数字)
while len(segments) > 1 and len(";".join(segments)) > DATA_VERSION_MAX_LEN:
segments.pop()
if len(segments) == 1 and segments[0] == "d-":
# 三张表连行数都读不到、交易日也没有:如实标记「不可用」,不编造
return _FALLBACK
return ";".join(segments)