Files
tick-stock-panel/backend/app/services/trading_day.py
T
shy3130 9d290f003d feat(market): 交易日探针——工作日休市剔除行情轮询与分钟增量
- 探测链: fuyao 交易日历(权威) → tickflow 行情时间戳(5只篮子 OR 判定, 9:40 开盘缓冲窗) → None 未知回退周几近似
- 周末零成本直判; 结论分档 TTL 缓存 (交易日 1h / 休市 30min 自愈 / 未知 5min 防重试风暴), 纯读不落盘
- 实时行情: 休市日跳过轮询/盘前预热/定版, fuyao 周五数据误归属从源头消除
- 分钟增量: gate_reason 新增 holiday; 探针未知时空轮升级机制仍为第二道防线
- 实测(周六): 三层链路真实验证, fuyao 日历连调休周一均正确判休市
2026-08-30 19:05:28 +08:00

140 lines
5.3 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:
if (
_CACHE.day == now.date()
and _CACHE.verdict is not None
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