fix(P0-02): 积分榜改为追加快照(append-only) + available_at cutoff
去掉 uq_standings_league_season_team,改为 (league, season, team, available_at) 唯一; 每次采集 INSERT 新行(available_at=now),ON CONFLICT DO NOTHING,不覆盖旧行。 standings_slice(before):DISTINCT ON (team_id) WHERE available_at<=cutoff ORDER available_at DESC;before=None → cutoff=now()。 公开 list_standings 取每队最新可用快照(子查询 max available_at)。 迁移 0023 + 切片/路由/docs 同步;测试 test_p0_standings_cutoff(5/5)。 284 测试全绿。
This commit is contained in:
@@ -286,7 +286,9 @@ async def list_standings(
|
||||
):
|
||||
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
||||
|
||||
season 为空时返回每个联赛最新采集到的赛季榜单(适合前端"查看最新积分榜")。
|
||||
P0-02: standings 为追加快照,公开接口取每队 available_at 最新快照
|
||||
(league_id, season, team_id 上按 available_at 取最新)。
|
||||
season 为空时返回每个联赛最新采集到的赛季榜单。
|
||||
"""
|
||||
# 取每个联赛最新赛季(当 season 为空时)
|
||||
latest_seasons: dict[int, str] = {}
|
||||
@@ -299,9 +301,25 @@ async def list_standings(
|
||||
).all()
|
||||
latest_seasons = {r.league_id: r.latest for r in rows}
|
||||
|
||||
# P0-02: 子查询取每队最新 available_at 快照,再 JOIN 回主表拿完整行 + League
|
||||
latest_per_team = (
|
||||
select(
|
||||
Standing.league_id, Standing.season, Standing.team_id,
|
||||
func.max(Standing.available_at).label("max_available"),
|
||||
)
|
||||
.group_by(Standing.league_id, Standing.season, Standing.team_id)
|
||||
.subquery("latest_per_team")
|
||||
)
|
||||
q = (
|
||||
select(Standing, League)
|
||||
.join(League, League.id == Standing.league_id)
|
||||
.join(
|
||||
latest_per_team,
|
||||
(Standing.league_id == latest_per_team.c.league_id)
|
||||
& (Standing.season == latest_per_team.c.season)
|
||||
& (Standing.team_id == latest_per_team.c.team_id)
|
||||
& (Standing.available_at == latest_per_team.c.max_available),
|
||||
)
|
||||
.order_by(League.name.asc(), Standing.position.asc())
|
||||
)
|
||||
if league:
|
||||
|
||||
@@ -137,21 +137,12 @@ async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str |
|
||||
retrieved_at=now,
|
||||
)
|
||||
|
||||
# 同一联赛同一赛季只保留最新快照:按 (league, season, team) upsert
|
||||
stmt = select(Standing).where(
|
||||
Standing.league_id == league.id,
|
||||
Standing.season == season_label,
|
||||
Standing.team_id == team.id,
|
||||
# P0-02: 追加快照——每次采集 INSERT 新行(available_at=now),
|
||||
# ON CONFLICT (league, season, team, available_at) DO NOTHING。
|
||||
standing = Standing(
|
||||
league_id=league.id, season=season_label, team_id=team.id, available_at=now, **values
|
||||
)
|
||||
standing = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if standing is None:
|
||||
standing = Standing(
|
||||
league_id=league.id, season=season_label, team_id=team.id, **values
|
||||
)
|
||||
db.add(standing)
|
||||
else:
|
||||
for k, v in values.items():
|
||||
setattr(standing, k, v)
|
||||
db.add(standing)
|
||||
league_r["upserted"] += 1
|
||||
|
||||
league_r["rows"] = len(rows)
|
||||
|
||||
+10
-3
@@ -204,8 +204,11 @@ class MatchStats(Base):
|
||||
class Standing(Base):
|
||||
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
|
||||
|
||||
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
|
||||
upsert。zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
||||
P0-02: 改为追加快照(append-only)。每次采集 INSERT 新行,available_at=now;
|
||||
查询取 available_at<=cutoff 的每队最新快照(DISTINCT ON team_id ORDER available_at DESC)。
|
||||
回测时可还原任意历史时刻的榜单,不再只是"最新快照、忽略 cutoff"。
|
||||
同一 (league_id, season, team_id, available_at) 唯一,ON CONFLICT DO NOTHING。
|
||||
zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
||||
"""
|
||||
__tablename__ = "standings"
|
||||
|
||||
@@ -228,13 +231,17 @@ class Standing(Base):
|
||||
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
# P0-02: 快照可用时间(采集时间),唯一键组成部分 + cutoff 过滤依据
|
||||
available_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||
|
||||
league: Mapped[League] = relationship()
|
||||
team: Mapped[Team] = relationship(lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
|
||||
# P0-02: (league, season, team, available_at) 唯一,支持追加快照 + ON CONFLICT DO NOTHING
|
||||
UniqueConstraint("league_id", "season", "team_id", "available_at", name="uq_standings_league_season_team_available"),
|
||||
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
|
||||
Index("ix_standings_league_season_team_available", "league_id", "season", "team_id", "available_at"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""D - 联赛排名切片: 积分榜位置与实力差距(standings)。"""
|
||||
"""D - 联赛排名切片: 积分榜快照(支持 cutoff 的历史还原,standings)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import League, Standing
|
||||
from src.llm.slices.common import MatchHeader, SliceResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -16,24 +17,33 @@ if TYPE_CHECKING:
|
||||
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
||||
|
||||
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。
|
||||
P0-02: 支持 cutoff(before)。取 available_at<=cutoff 的每队最新快照
|
||||
(DISTINCT ON team_id ORDER available_at DESC);before=None 时 cutoff=now()。
|
||||
回测时可还原历史时刻榜单,不再只是"最新快照、忽略 cutoff"。
|
||||
|
||||
db: 可选共享 session(见 context_builder 模块 docstring)。
|
||||
|
||||
语义区分:
|
||||
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
||||
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
|
||||
"""
|
||||
from src.db.models import League, Standing
|
||||
# P0-02: before=None → cutoff=now()(取最新可用快照)
|
||||
if before is None:
|
||||
from datetime import datetime, timezone
|
||||
before = datetime.now(timezone.utc)
|
||||
|
||||
if db is not None:
|
||||
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
||||
# P0-02: DISTINCT ON (team_id) 取 available_at<=cutoff 的最新快照
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Standing)
|
||||
.options(selectinload(Standing.team))
|
||||
.where(Standing.league_id == header.league_id)
|
||||
.order_by(Standing.position.asc())
|
||||
.where(Standing.available_at <= before)
|
||||
.distinct(Standing.team_id)
|
||||
.order_by(Standing.team_id, Standing.available_at.desc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
|
||||
Reference in New Issue
Block a user