mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
像其他能力一样可路由: 声明 full_minute 数据集并在设置页路由, 即接入 盘中全市场分钟落盘服务 (与 TickFlow Expert 同一能力键 intraday.universe)。 - policy: _DATASET_CAP_MAP 增补 full_minute → INTRADAY_UNIVERSE, 自定义源 声明且被路由时自动补授能力, 服务门控对两类源统一口径 - capabilities 注册表: full_minute 从不可路由 (field=None) 改为 full_minute_data_provider 偏好路由; preferences/settings API 收发新字段 - 插件契约: get_intraday_batch (修复轮, 未实现回退 get_minute 当日窗口) + get_intraday_latest (稳态增量, 可选; 未实现降级仅修复轮, 节奏下限 60s) - YAML 声明式源: full_minute 数据集与 minute 同形, loader 白名单放行, 源编辑器 UI 可配置; 仅修复轮语义 - kline_sync 边界: fetch_intraday_custom_batch / _fetch_intraday_custom_latest, 帧过北京墙钟守卫, 与 TickFlow 边界函数同纪律 - minute_refresh: 删「自定义分钟源让位」逻辑, 改按路由取数; status 增 provider / provider_effective / repair_only - 前端: 监控页卡片按能力驱动, 数据源页路由矩阵, api.ts 类型 - 测试: 门控/路由/降级/增广新增用例, 后端 1332 全过 - 文档: plugin-development / custom-data-source / configuration / features / CONTRIBUTING 契约细化 (AI 可照文档接入)
257 lines
9.2 KiB
Python
257 lines
9.2 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)}
|