Files
Profeto/tests/test_available_at.py
shangfangjianandnew-provider/LongCat-2.0 < f05dc1ae15 refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填

采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)

预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新

管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源

前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)

迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 19:13:07 +08:00

128 lines
5.4 KiB
Python

"""回归测试: match_stats.available_at 回测防泄漏语义。
验证:
1. _is_stats_available: available_at is None + cutoff 不为 None → 不可用
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
3. _is_stats_available: available_at > cutoff → 不可用
4. _is_stats_available: available_at <= cutoff → 可用
5. 写入策略: bzzoiro 写入 available_at 使用 match_date + 2h 缓冲
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
7. stats 回填(bzzoiro event stats)也使用 2h 缓冲
"""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock
import pytest
from src.llm.context_builder import _is_stats_available
def _make_stats(available_at):
s = MagicMock()
s.available_at = available_at
return s
class TestIsStatsAvailable:
"""_is_stats_available 回测防泄漏语义。"""
def test_none_before_allows_none_available_at(self):
"""实盘模式(before=None): available_at 为 None 时允许(兼容旧数据)。"""
stats = _make_stats(available_at=None)
assert _is_stats_available(stats, before=None) is True
def test_cutoff_with_none_available_at_is_unavailable(self):
"""回测模式(before=cutoff): available_at 为 None → 不可用(保守)。
这是核心修复:防止无血缘时间的后期回填数据进入回测。
"""
stats = _make_stats(available_at=None)
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
assert _is_stats_available(stats, before=cutoff) is False
def test_available_at_after_cutoff_is_unavailable(self):
"""available_at 在 cutoff 之后 → 不可用。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff + timedelta(hours=1))
assert _is_stats_available(stats, before=cutoff) is False
def test_available_at_before_cutoff_is_available(self):
"""available_at 在 cutoff 之前 → 可用。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff - timedelta(hours=1))
assert _is_stats_available(stats, before=cutoff) is True
def test_available_at_equals_cutoff_is_available(self):
"""available_at == cutoff → 可用(边界包含)。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff)
assert _is_stats_available(stats, before=cutoff) is True
def test_real_world_scenario_backtest_avoids_future_data(self):
"""真实场景:回测时,赛后才生成的统计数据不应出现。
比赛:2026-01-15 20:00
cutoff(回测):2026-01-14 20:00(赛前 1 天)
stats 在赛后才生成(available_at=2026-01-15 22:00)
→ 不可用
"""
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=datetime(2026, 1, 15, 22, 0, tzinfo=timezone.utc))
assert _is_stats_available(stats, before=cutoff) is False
class TestWriteBufferStrategy:
"""验证 bzzoiro(events + stats 回填)写入 available_at 使用 match_date + 2h 缓冲。"""
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
"""bzzoiro 新建比赛(stats 回填创建 MatchStats)时 available_at 应为开球 + 2 小时。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
assert 'timedelta(hours=2)' in source, \
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
def test_bzzoirot_multiple_writes_use_two_hour_buffer(self):
"""bzzoiro 多处写入(创建/更新)都应使用 2 小时缓冲。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
count = source.count('timedelta(hours=2)')
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count} 处"
def test_cutoff_within_buffer_makes_stats_unavailable(self):
"""cutoff 在 2 小时缓冲内时,统计学不可用(回测防泄漏)。
开球:2026-01-15 20:00
available_at:2026-01-15 22:00(开球 + 2h)
cutoff:2026-01-15 21:00(开赛后 1h, statistics 尚未可用)
→ 不可用
"""
kickoff = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
available_at = kickoff + timedelta(hours=2) # 22:00
cutoff = kickoff + timedelta(hours=1) # 21:00,在缓冲内
stats = _make_stats(available_at=available_at)
assert _is_stats_available(stats, before=cutoff) is False, \
"cutoff 在 2h 缓冲内时应不可用(available_at > cutoff)"
def test_cutoff_after_buffer_makes_stats_available(self):
"""cutoff 超过 2 小时缓冲后,统计变为可用。
开球:2026-01-15 20:00
available_at:2026-01-15 22:00(开球 + 2h)
cutoff:2026-01-16 20:00(开赛后 1 天,超过缓冲)
→ 可用
"""
kickoff = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
available_at = kickoff + timedelta(hours=2) # 22:00
cutoff = kickoff + timedelta(days=1) # 2026-01-16 20:00
stats = _make_stats(available_at=available_at)
assert _is_stats_available(stats, before=cutoff) is True, \
"cutoff 超过 2h 缓冲后应可用(available_at < cutoff)"