Files
Profeto/alembic/versions/0004_snapshot_and_constraints.py
WorkBuddy 71bf723a10 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() 的隐式格式依赖
2026-09-15 16:48:03 +08:00

65 lines
3.1 KiB
Python

"""add snapshot fields and check constraints
Revision ID: 0004_snapshot_and_constraints
Revises: 0003_injuries
Create Date: 2026-09-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0004_snapshot_and_constraints'
down_revision: Union[str, None] = '0003_injuries'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 重命名 confidence → subjective_confidence
op.alter_column('predictions', 'confidence', new_column_name='subjective_confidence')
# 2. 新增快照字段
op.add_column('predictions', sa.Column('cutoff_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('predictions', sa.Column('input_hash', sa.String(length=64), nullable=True))
# 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_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_pred_1x2_enum', 'predictions', "pred_1x2 IN ('1', 'X', '2')")
op.create_check_constraint('ck_mode_enum', 'predictions', "mode IN ('single', 'multi')")
def downgrade() -> None:
# 1. 删除 CHECK 约束
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
op.drop_constraint('ck_pred_1x2_enum', 'predictions', type_='check')
op.drop_constraint('ck_confidence_range', 'predictions', type_='check')
op.drop_constraint('ck_pred_away_goals_nonneg', 'predictions', type_='check')
op.drop_constraint('ck_pred_home_goals_nonneg', 'predictions', type_='check')
# 2. 删除快照字段
op.drop_column('predictions', 'input_hash')
op.drop_column('predictions', 'cutoff_at')
# 3. 恢复列名
op.alter_column('predictions', 'subjective_confidence', new_column_name='confidence')