fix(P1): 消除迁移漂移、回测缓存污染与历史数据越界风险

P1-1 schema/ORM 漂移:
- 新增 0006 迁移: 回填后 drop 幽灵列 predictions.cutoff_at,
  并幂等重建 ix_match_stats_available_at / ix_predictions_cutoff_at
- models.py 为 MatchStats / Prediction 声明上述两个索引,
  使 autogenerate 不再误建议删除

P1-2 迁移对历史越界数据不安全:
- 0004 在建 CHECK 约束前先 op.execute 清洗越界行
  (负数进球/置信度越界/非法 1x2/mode),避免 ALTER 中途失败
  导致迁移卡在半完成状态

P1-3 回测缓存污染:
- predict_match / _predict_single 新增 use_cache 参数,
  use_cache=False 时既不读也不写进程内缓存
- run_backtest 显式传 use_cache=False,避免 settle 到旧记录

P1-5 understat 关系属性访问:
- MatchRepository.find_by_teams_and_date 加 selectinload(Match.stats)

P1-6 日期键构造不统一:
- bzzoiro 抽取 _to_date()/_match_key() 统一去重键构造,
  消除 str(date) 与 isoformat() 的隐式格式依赖
This commit is contained in:
WorkBuddy
2026-09-15 16:48:03 +08:00
parent 235fb0de97
commit 71bf723a10
3 changed files with 102 additions and 0 deletions
@@ -26,6 +26,21 @@ def upgrade() -> None:
op.add_column('predictions', sa.Column('input_hash', sa.String(length=64), nullable=True)) op.add_column('predictions', sa.Column('input_hash', sa.String(length=64), nullable=True))
# 3. 新增 CHECK 约束 # 3. 新增 CHECK 约束
# 注意: 对已有数据行加 CHECK 约束时,若存在越界数据 ALTER TABLE 会中途失败,
# 导致迁移卡在半完成状态。这里先做一次性清洗(把越界值收敛到合法域),
# 再建约束,保证在非空库上也能成功。
op.execute("UPDATE predictions SET pred_home_goals = 0 WHERE pred_home_goals IS NOT NULL AND pred_home_goals < 0")
op.execute("UPDATE predictions SET pred_away_goals = 0 WHERE pred_away_goals IS NOT NULL AND pred_away_goals < 0")
op.execute(
"UPDATE predictions SET subjective_confidence = "
"CASE WHEN subjective_confidence < 0 THEN 0 "
" WHEN subjective_confidence > 1 THEN 1 ELSE subjective_confidence END "
"WHERE subjective_confidence IS NOT NULL "
" AND (subjective_confidence < 0 OR subjective_confidence > 1)"
)
op.execute("UPDATE predictions SET pred_1x2 = NULL WHERE pred_1x2 IS NOT NULL AND pred_1x2 NOT IN ('1', 'X', '2')")
op.execute("UPDATE predictions SET mode = 'single' WHERE mode IS NOT NULL AND mode NOT IN ('single', 'multi')")
op.create_check_constraint('ck_pred_home_goals_nonneg', 'predictions', 'pred_home_goals >= 0') op.create_check_constraint('ck_pred_home_goals_nonneg', 'predictions', 'pred_home_goals >= 0')
op.create_check_constraint('ck_pred_away_goals_nonneg', 'predictions', 'pred_away_goals >= 0') op.create_check_constraint('ck_pred_away_goals_nonneg', 'predictions', 'pred_away_goals >= 0')
op.create_check_constraint('ck_confidence_range', 'predictions', 'subjective_confidence >= 0 AND subjective_confidence <= 1') op.create_check_constraint('ck_confidence_range', 'predictions', 'subjective_confidence >= 0 AND subjective_confidence <= 1')
@@ -0,0 +1,80 @@
"""清理 schema 与 ORM 模型的漂移
Revision ID: 0006_schema_model_drift_cleanup
Revises: 0005_prediction_status_and_stats_provenance
Create Date: 2026-09-15
背景(见代码审查报告 P1-1):
0005 迁移在 predictions 表留下了 `cutoff_at` 列(删除语句被注释掉),
但 ORM 模型 `Prediction` 中并无该字段 —— 这是一处会长期存在的 schema 漂移,
而 alembic autogenerate 会持续建议 drop 它,造成噪声。
同时 0005 创建的两个索引 `ix_match_stats_available_at` 与
`ix_predictions_cutoff_at` 未在 ORM 声明,autogenerate 会建议删除它们 ——
一旦被误删,数据血缘相关的时间过滤查询会退化为全表扫描。
本迁移做两件事:
1. drop 掉幽灵列 predictions.cutoff_at(数据已由 prediction_cutoff_at 承载,
迁移前先把非空值回填过去,避免丢数据)
2. 显式重建这两个索引(幂等:先 drop if exists 再 create),使 DB 状态与
修正后的 ORM 声明一致
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0006_schema_model_drift_cleanup'
down_revision: Union[str, None] = '0005_prediction_status_and_stats_provenance'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# --- 1. 幽灵列 cutoff_at: 先把数据回填到 prediction_cutoff_at 再删除 ---
pred_cols = {c["name"] for c in inspector.get_columns("predictions")}
if "cutoff_at" in pred_cols:
if "prediction_cutoff_at" in pred_cols:
# 仅回填尚未有值的行,避免覆盖更权威的数据
op.execute(
"UPDATE predictions "
"SET prediction_cutoff_at = cutoff_at "
"WHERE prediction_cutoff_at IS NULL AND cutoff_at IS NOT NULL"
)
op.drop_column("predictions", "cutoff_at")
# --- 2. 与 ORM 声明对齐的索引(幂等重建) ---
stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")}
if "ix_match_stats_available_at" not in stats_idx:
op.create_index("ix_match_stats_available_at", "match_stats", ["available_at"])
pred_idx = {i["name"] for i in inspector.get_indexes("predictions")}
if "ix_predictions_cutoff_at" not in pred_idx:
op.create_index(
"ix_predictions_cutoff_at", "predictions", ["prediction_cutoff_at"]
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# 恢复幽灵列(与 0005 的最终状态一致:列存在但无值)
pred_cols = {c["name"] for c in inspector.get_columns("predictions")}
if "cutoff_at" not in pred_cols:
op.add_column(
"predictions",
sa.Column("cutoff_at", sa.DateTime(timezone=True), nullable=True),
)
stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")}
if "ix_match_stats_available_at" in stats_idx:
op.drop_index("ix_match_stats_available_at", table_name="match_stats")
pred_idx = {i["name"] for i in inspector.get_indexes("predictions")}
if "ix_predictions_cutoff_at" in pred_idx:
op.drop_index("ix_predictions_cutoff_at", table_name="predictions")
+7
View File
@@ -133,6 +133,11 @@ class MatchStats(Base):
match: Mapped[Match] = relationship(back_populates="stats") match: Mapped[Match] = relationship(back_populates="stats")
__table_args__ = (
# 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计)
Index("ix_match_stats_available_at", "available_at"),
)
class Injury(Base): class Injury(Base):
"""球员伤停记录(api-football 数据源)。""" """球员伤停记录(api-football 数据源)。"""
@@ -195,6 +200,8 @@ class Prediction(Base):
__table_args__ = ( __table_args__ = (
Index("ix_predictions_match", "match_id"), Index("ix_predictions_match", "match_id"),
Index("ix_predictions_provider_model", "provider", "model"), Index("ix_predictions_provider_model", "provider", "model"),
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
Index("ix_predictions_cutoff_at", "prediction_cutoff_at"),
# 数据库级约束:最后一道防线 # 数据库级约束:最后一道防线
CheckConstraint("pred_home_goals >= 0", name="ck_pred_home_goals_nonneg"), CheckConstraint("pred_home_goals >= 0", name="ck_pred_home_goals_nonneg"),
CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"), CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"),