Files
tick-stock-panel/backend/tests/test_trading_day.py
T
kevin9327 e956e3a6d7 fix(trading-day): 「未知」结论也按 TTL 缓存, 不再每拍重打探测
is_trading_day 的缓存命中条件带了 `_CACHE.verdict is not None`, 于是探测链
全部返回 None 时缓存永远读不回来, _TTL_UNKNOWN_S(300s) 形同虚设。

消费方 quote_service._holiday_gate 与 minute_refresh._gate_reason 在轮询循环里
每拍都调 is_trading_day: 未配 fuyao 且 tickflow 实时不可用/网络失败时, 每拍都会
重跑一遍探测链 (含一次 quotes.get 网络请求), 白白消耗限流额度并刷日志。

_CACHE.day 只在探测写回时设置, 「当天已探过」用它判定即可, 去掉多余的
verdict 判空条件。已有的 test_unknown_verdict_retries_after_short_ttl 仍然通过
(TTL 过期后照常重探)。
2026-09-10 19:36:30 +09:00

279 lines
9.9 KiB
Python

"""交易日探针 (trading_day oracle) 与两个消费方接入的测试。
不依赖真实网络: 探测函数 (_probe_fuyao / _probe_tickflow) 全部 monkeypatch。
覆盖: 周末零成本直判、探测链优先级 (fuyao 日历权威, 无开盘缓冲问题)、
tickflow 戳的 OR 语义与开盘缓冲窗、失败/无权限 → None、TTL 缓存、
实时行情门控与分钟增量 gate_reason 的 holiday 分支。
"""
from __future__ import annotations
from datetime import date, datetime, time as dt_time, timezone, timedelta
import pytest
from app.services import trading_day
from app.services.trading_day import is_trading_day, reset_cache
CN = timezone(timedelta(hours=8))
@pytest.fixture(autouse=True)
def _clean_cache():
reset_cache()
yield
reset_cache()
def _no_probes(monkeypatch):
"""探测函数替换为爆炸 — 用于验证未被打到。"""
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: (_ for _ in ()).throw(AssertionError("不应探测")))
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: (_ for _ in ()).throw(AssertionError("不应探测")))
# ---- 周末零成本直判 ----
def test_weekend_returns_false_without_probing(monkeypatch):
sat = datetime(2026, 8, 29, 10, 0, tzinfo=CN) # 周六
sun = datetime(2026, 8, 30, 10, 0, tzinfo=CN) # 周日
_no_probes(monkeypatch)
assert is_trading_day(sat) is False
assert is_trading_day(sun) is False
# ---- 探测链优先级 ----
def test_fuyao_calendar_is_authoritative_even_before_open(monkeypatch):
"""fuyao 日历结论无时段依赖: 开盘缓冲窗内也直接生效。"""
holiday_mon = datetime(2026, 9, 7, 9, 31, tzinfo=CN) # 周一 (缓冲窗内)
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: False)
monkeypatch.setattr(
trading_day, "_probe_tickflow",
lambda now: (_ for _ in ()).throw(AssertionError("fuyao 已有结论不应继续探测")),
)
assert is_trading_day(holiday_mon) is False
def test_chain_falls_through_to_tickflow_when_fuyao_unknown(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: None)
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: True)
assert is_trading_day(monday) is True
def test_all_probes_unknown_returns_none(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: None)
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: None)
assert is_trading_day(monday) is None
# ---- tickflow 戳语义 ----
def test_tickflow_stale_stamp_before_buffer_is_unknown(monkeypatch):
"""开盘缓冲窗内戳停在昨日: 可能是集合竞价未翻新, 保守判未知。"""
monday_935 = datetime(2026, 9, 7, 9, 35, tzinfo=CN)
import app.tickflow.client as tf_client_mod
class _FakeQuotes:
def get(self, symbols):
# 周一 9:35 拉到上周五 15:30 的戳
return [{"symbol": s, "timestamp": 1787902251001} for s in symbols]
class _FakeClient:
quotes = _FakeQuotes()
monkeypatch.setattr(tf_client_mod, "get_client", lambda: _FakeClient())
monkeypatch.setattr(trading_day, "_probe_fuyao", lambda now: None)
assert trading_day._probe_tickflow(monday_935) is None
def test_tickflow_stale_stamp_after_buffer_is_holiday(monkeypatch):
monday_1041 = datetime(2026, 9, 7, 10, 41, tzinfo=CN)
import app.tickflow.client as tf_client_mod
class _FakeQuotes:
def get(self, symbols):
return [{"symbol": s, "timestamp": 1787902251001} for s in symbols] # 上周五
class _FakeClient:
quotes = _FakeQuotes()
monkeypatch.setattr(tf_client_mod, "get_client", lambda: _FakeClient())
assert trading_day._probe_tickflow(monday_1041) is False
def test_tickflow_fresh_stamp_is_trading_anytime(monkeypatch):
"""缓冲窗内只要戳是今天就判交易日 (OR 语义, 任一翻新即可)。"""
monday_920 = datetime(2026, 9, 7, 9, 20, tzinfo=CN)
import app.tickflow.client as tf_client_mod
class _FakeQuotes:
def get(self, symbols):
# 一只翻新 + 其余停在周五 → max 为今日
fresh_ms = int(monday_920.timestamp() * 1000)
return [
{"symbol": "000001.SZ", "timestamp": fresh_ms},
{"symbol": "600519.SH", "timestamp": 1787902251001},
]
class _FakeClient:
quotes = _FakeQuotes()
monkeypatch.setattr(tf_client_mod, "get_client", lambda: _FakeClient())
assert trading_day._probe_tickflow(monday_920) is True
def test_tickflow_error_returns_none(monkeypatch):
monday = datetime(2026, 9, 7, 10, 41, tzinfo=CN)
import app.tickflow.client as tf_client_mod
def _boom():
raise RuntimeError("无实时权限 (free 档)")
monkeypatch.setattr(tf_client_mod, "get_client", _boom)
assert trading_day._probe_tickflow(monday) is None
# ---- TTL 缓存 ----
def test_verdict_cached_within_ttl(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
calls = {"n": 0}
def _counting_probe(now):
calls["n"] += 1
return True
monkeypatch.setattr(trading_day, "_probe_fuyao", _counting_probe)
assert is_trading_day(monday) is True
assert is_trading_day(monday) is True
assert is_trading_day(monday) is True
assert calls["n"] == 1 # 命中缓存, 只探一次
def test_unknown_verdict_retries_after_short_ttl(monkeypatch):
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
calls = {"n": 0}
def _counting_probe(now):
calls["n"] += 1
return None
monkeypatch.setattr(trading_day, "_probe_fuyao", _counting_probe)
monkeypatch.setattr(trading_day, "_probe_tickflow", lambda now: None) # 隔离真实网络
assert is_trading_day(monday) is None
# 手动把缓存时间拨回 10 分钟前 (超过 unknown TTL 300s) → 重探
with trading_day._CACHE_LOCK:
trading_day._CACHE.probed_at -= 600
assert is_trading_day(monday) is None
assert calls["n"] == 2
# ---- 消费方 1: 实时行情门控 ----
def test_quote_service_holiday_gate_blocks_polling(monkeypatch):
from app.services.quote_service import QuoteService
qs = QuoteService()
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: False)
assert qs._should_poll_for_phase("morning") is False
assert qs._should_poll_for_phase("preopen") is False
# final 定版同样被剔除: 休市日没有需要定版的当日行情
assert qs._should_poll_for_phase("close_final") is False
def test_quote_service_polls_when_trading_or_unknown(monkeypatch):
from app.services.quote_service import QuoteService
qs = QuoteService()
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: True)
assert qs._should_poll_for_phase("morning") is True
# 未知 → 维持现状 (周几近似)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: None)
assert qs._should_poll_for_phase("morning") is True
# ---- 消费方 2: 分钟增量 gate_reason ----
def _minute_service(monkeypatch):
from app.services.minute_refresh import MinuteRefreshService
svc = MinuteRefreshService.__new__(MinuteRefreshService)
svc._app_state = None
monkeypatch.setattr(
"app.services.minute_refresh.preferences.get_minute_refresh_enabled",
lambda: True,
)
monkeypatch.setattr(svc, "capability_ok", lambda: True)
return svc
def test_minute_refresh_gate_returns_holiday(monkeypatch):
"""周几+时段门控放行 (周一盘中) 但探针判休市 → holiday。"""
svc = _minute_service(monkeypatch)
monday_1030 = datetime(2026, 9, 7, 10, 30, tzinfo=CN)
monkeypatch.setattr(
"app.services.minute_refresh._in_continuous_session", lambda now=None: True
)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: False)
assert svc._gate_reason() == "holiday"
def test_minute_refresh_gate_passes_when_trading(monkeypatch):
svc = _minute_service(monkeypatch)
monkeypatch.setattr(
"app.services.minute_refresh._in_continuous_session", lambda now=None: True
)
monkeypatch.setattr(trading_day, "is_trading_day", lambda now=None: True)
assert svc._gate_reason() is None
# ---- fuyao 日历解析 ----
def test_fuyao_provider_trading_days_conversion(monkeypatch):
from app.plugins.fuyao.provider import FuyaoProvider
from app.plugins.fuyao import provider as fp
class _CalClient:
def trading_days(self):
# 上海零点戳按 provider 的 _ms_of_date 口径构造
def _midnight_ms(d):
import calendar
return (calendar.timegm(d.timetuple()) - 28800) * 1000
d4, d7 = date(2026, 9, 4), date(2026, 9, 7)
return [
{"date": "20260904", "date_ms": _midnight_ms(d4)},
{"date": "20260907", "date_ms": _midnight_ms(d7)},
]
monkeypatch.setattr(
fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: _CalClient()})
)
monkeypatch.setattr(fp, "get_api_key", lambda: "test-key")
days = FuyaoProvider().trading_days()
assert days == {date(2026, 9, 4), date(2026, 9, 7)}
def test_unknown_verdict_is_cached_within_short_ttl(monkeypatch):
"""未知结论也要按 _TTL_UNKNOWN_S 缓存: 轮询每拍重探会重复打 tickflow 请求。"""
monday = datetime(2026, 9, 7, 10, 0, tzinfo=CN)
calls = {"fuyao": 0, "tickflow": 0}
def _fuyao(now):
calls["fuyao"] += 1
return None
def _tickflow(now):
calls["tickflow"] += 1
return None
monkeypatch.setattr(trading_day, "_probe_fuyao", _fuyao)
monkeypatch.setattr(trading_day, "_probe_tickflow", _tickflow)
assert is_trading_day(monday) is None
assert is_trading_day(monday) is None
assert is_trading_day(monday) is None
assert calls == {"fuyao": 1, "tickflow": 1}