"""数据集快照指纹:`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`): d stock_daily 的最大交易日(`d-` 表示该表无数据 / 取不到交易日) n stock_daily 行数 a adjust_factor 行数 b daily_basic 行数 行数段写法:`≈k` = MySQL 近似值(k = 千行,四舍五入);`` = 精确值。 示例:`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)