后端: - settle_prediction 拒绝 degraded/failed(明确错误信息) - get_eval_summary 支持 provider/model/prompt_version/mode 筛选 - 返回 filtered_settled/evaluated/skipped_degraded 等计数 - matches 游标分页方向修复(scheduled ASC 用 > 条件) - available_at 加 2h 缓冲(近似完赛时间) - bzzziro 统计字段映射注释(待真实响应验证) - injuries 区分 no_local_data 与 success 空名单 前端: - 新增 EvalPage(筛选控件 + 汇总卡片 + 准确率表格) - 挂载 /admin/eval 路由与导航 测试: - test_matches_cursor.py:游标方向 - test_available_at.py:2h 缓冲与回测防泄漏 - test_bzzoirot_stats.py:统计字段映射 - test_injuries_no_local_data.py:no_local_data vs success - test_injuries_inserted_count.py:失败批不计入 - test_eval_excludes_degraded.py:degraded 排除准确率
138 lines
5.8 KiB
Python
138 lines
5.8 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. 写入策略: available_at = match_date + 2h 缓冲
|
|
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
|
|
"""
|
|
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/understat 写入 available_at 使用 match_date + 2h 缓冲。"""
|
|
|
|
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
|
|
"""bzzoiro 新建比赛时 available_at 应为开球 + 2 小时。"""
|
|
import inspect
|
|
from src.data import bzzoiro
|
|
|
|
source = inspect.getsource(bzzoiro)
|
|
# 验证:使用 timedelta(hours=2) 作为缓冲
|
|
assert 'timedelta(hours=2)' in source, \
|
|
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
|
|
|
def test_bzzoirot_existing_match_uses_two_hour_buffer(self):
|
|
"""bzzoiro 更新已有比赛时也应使用 2 小时缓冲。"""
|
|
import inspect
|
|
from src.data import bzzoiro
|
|
|
|
source = inspect.getsource(bzzoiro)
|
|
# 两处写入都应使用 timedelta(hours=2)
|
|
count = source.count('timedelta(hours=2)')
|
|
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count} 处"
|
|
|
|
def test_understat_uses_two_hour_buffer(self):
|
|
"""understat 回填 xG 时也应使用 2 小时缓冲。"""
|
|
import inspect
|
|
from src.data import understat
|
|
|
|
source = inspect.getsource(understat)
|
|
assert 'timedelta(hours=2)' in source, \
|
|
"understat 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
|
|
|
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)"
|