Files
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

142 lines
5.6 KiB
Python

"""交易日探针 (oracle) — 回答「今天是否 A 股交易日」。
消费方 (实时行情轮询 / 盘中分钟增量) 在周几+时段门控之后调用, 用于把
「工作日但休市」的节假日从轮询窗口里剔除; 返回 None (未知) 时调用方
维持现状行为 (周几近似 + 快照新鲜度判据兜底), 不引入新依赖。
探测链 (按确定性排序, 先到先得):
1. fuyao 交易日历 (已配置 fuyao 时): GET /api/a-share/calendar/trading-days,
今天在近一年交易日列表内 ⇔ 交易日。权威日历, 无时段依赖, 无开盘缓冲问题。
2. tickflow 实时行情时间戳: 拉一篮流动性票快照 (单请求), max(timestamp)
日期 == 今天 ⇔ 交易日。非交易日全市场戳停在上一交易日 (2026-08-29 周六
实测 5551/5551, 含停牌股 — 戳是快照定版时刻, 非最后成交时刻);
交易日集合竞价阶段 (9:15-9:30) 戳是否已翻新未实测 → 开盘缓冲窗内
戳过期不作数, 保守视为未知。
3. 均不可用 → None: 调用方按周几近似继续。
安全约束:
- 周末直接返回 False (周几判断零成本, 不打任何请求)。
- 探针是纯读: 只产出一个布尔判定, 不落盘、不进行情管道、不碰归属链路。
- 只用于「降档」(休市不轮询); 休市结论 TTL 较短 (30 分钟) 定期复探,
探针误判最坏损失一段快照且可自愈; 未知结论短 TTL (5 分钟) 防止
轮询循环每拍重打失败的探测。
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from datetime import datetime, time as dt_time
from app.market_time import CN_TZ, cn_now
# tickflow 戳探针的开盘缓冲窗: 此时刻之前戳仍是上一交易日属正常 (集合竞价),
# 不据此判休市。周一实测竞价戳翻新时机后可收紧。仅上午首个窗口需要。
_STALE_BUFFER_UNTIL = dt_time(9, 40)
# 一篮流动性票: 探 max(timestamp), 任一戳为今日即交易日 (OR 语义)。
# 大盘蓝筹同日全部停牌 = 市场性事件, 与休市同处理无碍。
_BASKET = ("000001.SZ", "600519.SH", "600036.SH", "601318.SH", "000651.SZ")
_TTL_TRADING_S = 3600.0 # 交易日结论每小时复探 (跨日天然失效)
_TTL_HOLIDAY_S = 1800.0 # 休市结论 30 分钟复探, 误判自愈上限
_TTL_UNKNOWN_S = 300.0 # 未知结论 5 分钟后重试探测
_CACHE_LOCK = threading.Lock()
@dataclass
class _Cache:
day: object | None = None
verdict: bool | None = None
probed_at: float = 0.0
_CACHE = _Cache()
def reset_cache() -> None:
"""清空探针缓存 (测试用)。"""
with _CACHE_LOCK:
_CACHE.day = None
_CACHE.verdict = None
_CACHE.probed_at = 0.0
def _probe_fuyao(now: datetime) -> bool | None:
"""fuyao 交易日历: 今天在列表内 ⇔ 交易日。未配置 fuyao / 失败 → None。"""
try:
from app.data_providers import custom as custom_sources
if not custom_sources.is_custom_provider("fuyao"):
return None
provider = custom_sources.get_provider("fuyao")
days = provider.trading_days()
return now.date() in days if days else None
except Exception: # noqa: BLE001 — 探针失败按未知处理, 不上抛
return None
def _probe_tickflow(now: datetime) -> bool | None:
"""tickflow 行情时间戳: max(timestamp) 日期 == 今天 ⇔ 交易日。
戳停在上一交易日: 开盘缓冲窗内 → None (可能是竞价未翻新), 窗后 → False。
无实时权限 / 网络失败 / 无有效戳 → None。
"""
try:
from app.tickflow.client import get_client
rows = get_client().quotes.get(symbols=list(_BASKET)) or []
stamps = [r.get("timestamp") for r in rows if isinstance(r, dict)]
valid = [int(t) for t in stamps if isinstance(t, (int, float)) and t]
if not valid:
return None
latest_day = datetime.fromtimestamp(max(valid) / 1000, tz=CN_TZ).date()
if latest_day == now.date():
return True
if now.time() < _STALE_BUFFER_UNTIL:
return None
return False
except Exception: # noqa: BLE001 — 无权限/网络失败按未知处理
return None
def is_trading_day(now: datetime | None = None) -> bool | None:
"""今天是否 A 股交易日。True=交易日, False=确定休市, None=未知 (维持周几近似)。
周末零成本直判; 工作日走探测链 (fuyao 日历 → tickflow 时间戳),
结论按 TTL 缓存。线程安全: 实时行情与分钟增量两个线程共用。
"""
now = now or cn_now()
if now.weekday() >= 5:
return False
with _CACHE_LOCK:
# 「未知」(None) 也是一个结论, 同样按 TTL 缓存 —— 它正是 _TTL_UNKNOWN_S 要
# 挡住的场景 (未配 fuyao 且 tickflow 不可用时, 轮询每拍都会重打一次探测)。
# _CACHE.day 只在探测写回时设置, 因此「当天已探过」用它判定即可。
if (
_CACHE.day == now.date()
and (time.monotonic() - _CACHE.probed_at) < _ttl_of(_CACHE.verdict)
):
return _CACHE.verdict
verdict = _probe_fuyao(now)
if verdict is None:
verdict = _probe_tickflow(now)
with _CACHE_LOCK:
_CACHE.day = now.date()
_CACHE.verdict = verdict
_CACHE.probed_at = time.monotonic()
return verdict
def _ttl_of(verdict: bool | None) -> float:
if verdict is True:
return _TTL_TRADING_S
if verdict is False:
return _TTL_HOLIDAY_S
return _TTL_UNKNOWN_S