"""回归测试: 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)"