新增 matches.score_status(known/missing/unknown): - 替换 ck_matches_finished_has_score 为 ck_matches_score_integrity: known → 必须有分; missing/unknown → goals 必须 NULL(不伪造 0:0) - normalize: 完赛缺分不再静默降级为 scheduled,改设 score_status=missing - events ingest: 创建/更新 Match 同步 score_status(比分由缺变 known / 确认缺分 missing) - slices(form/h2h/home_away)/backtest: 显式加 score_status='known' 过滤完赛样本 - 迁移 0022 回填现有数据(绝不 UPDATE goals=0) 测试 tests/test_p0_score_status.py(9/9):约束存在性/Match 构造/normalize 行为。 54 相关测试全绿。
87 lines
3.9 KiB
Python
87 lines
3.9 KiB
Python
"""A - 近期状态切片: 近 N 场赛果 / 走势(form)。"""
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from src.db.base import AsyncSessionLocal
|
|
from src.db.models import Match
|
|
from src.llm.slices.common import MatchHeader, SliceResult, _is_stats_available, _outcome
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: AsyncSession | None = None) -> SliceResult:
|
|
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。
|
|
|
|
db: 可选共享 session,避免每个切片独立建连(见 context_builder 模块 docstring)。
|
|
"""
|
|
if db is not None:
|
|
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
|
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
|
else:
|
|
async with AsyncSessionLocal() as new_db:
|
|
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
|
|
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
|
|
lines = []
|
|
n_scored = 0
|
|
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
|
|
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
|
|
for label, name, form, team_id in (
|
|
("主队", header.home_name, home_form, header.home_team_id),
|
|
("客队", header.away_name, away_form, header.away_team_id),
|
|
):
|
|
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
|
|
if form:
|
|
wins = draws = losses = 0
|
|
for fm in form:
|
|
is_home = (fm.home_team_id == team_id)
|
|
side = "home" if is_home else "away"
|
|
o = _outcome(fm.home_goals, fm.away_goals, side)
|
|
if o == "W": wins += 1
|
|
elif o == "D": draws += 1
|
|
else: losses += 1
|
|
if fm.home_goals is not None:
|
|
n_scored += 1
|
|
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
|
xg = ""
|
|
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
|
own = fm.stats.home_xg if is_home else fm.stats.away_xg
|
|
xg = f" (xG {own:.1f})"
|
|
opp = fm.away_team.name if is_home else fm.home_team.name
|
|
lines.append(f" {o} {score} vs {opp}{xg}")
|
|
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
|
else:
|
|
lines.append(" 无数据")
|
|
return SliceResult(text="\n".join(lines), has_data=n_scored > 0, n_records=n_scored)
|
|
|
|
|
|
async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
|
|
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。
|
|
|
|
必须预加载 stats / home_team / away_team:切片函数会读取这些关系,
|
|
而 async session 下惰性加载会抛 MissingGreenlet。
|
|
(models.py 已声明 lazy="selectin",此处显式声明以固化查询意图。)
|
|
"""
|
|
stmt = (
|
|
select(Match)
|
|
.options(
|
|
selectinload(Match.stats),
|
|
selectinload(Match.home_team),
|
|
selectinload(Match.away_team),
|
|
)
|
|
.where(Match.match_status == "finished")
|
|
.where(Match.score_status == "known")
|
|
.where(Match.home_goals.is_not(None))
|
|
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
|
|
.order_by(Match.match_date.desc())
|
|
.limit(limit)
|
|
)
|
|
if before is not None:
|
|
stmt = stmt.where(Match.match_date < before)
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|