mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 21:24:16 +08:00
- tickflow SDK 0.1.25: intraday.universe 标的池单请求拉全市场当日分钟 - 新能力位 Cap.INTRADAY_UNIVERSE (TickFlow Expert 专有) + 探测/别名/schema v6 - 分钟刷新两阶段: 冷启动与缺口修复走 intraday_batch 全天突发(分块容错), 稳态走 universe 增量 - 覆盖看门狗: 落后>3min / 无数据 / 连续空轮自动升级全量自愈 - 刷新间隔钳制 [3,300]s 默认 6s; 监控页全量分钟开关与状态入口 - CONTRIBUTING: 分钟 K 北京时间墙钟契约 (naive, 入口强制归一)
186 lines
7.2 KiB
Python
186 lines
7.2 KiB
Python
"""分钟 K datetime 北京墙钟契约测试。
|
|
|
|
契约 (CONTRIBUTING §3.3): kline_minute.datetime 必须是北京墙钟 naive。
|
|
守卫 _enforce_minute_beijing_wallclock 在两个源头入口强制:
|
|
- _normalize_minute (TickFlow 帧, timestamp 毫秒为 UTC 基准)
|
|
- _try_custom_minute (插件/自定义源帧)
|
|
|
|
覆盖: 显式转换 / 北京墙钟直通 / UTC 特征自愈 +8 / tz-aware 换算 /
|
|
fail-closed 拒收 / 路由级契约违规回退 TickFlow。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import MagicMock
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.services import kline_sync
|
|
|
|
|
|
def _minute_frame(datetimes: list, symbol: str = "600519.SH") -> pl.DataFrame:
|
|
n = len(datetimes)
|
|
return pl.DataFrame({
|
|
"symbol": [symbol] * n,
|
|
"datetime": datetimes,
|
|
"open": [10.0] * n,
|
|
"high": [10.5] * n,
|
|
"low": [9.5] * n,
|
|
"close": [10.2] * n,
|
|
"volume": [100.0] * n,
|
|
"amount": [1020.0] * n,
|
|
})
|
|
|
|
|
|
def _beijing_day() -> list[datetime]:
|
|
"""一个正常交易日墙钟样本: 开盘/午盘首/收盘。"""
|
|
return [
|
|
datetime(2026, 1, 15, 9, 30),
|
|
datetime(2026, 1, 15, 13, 0),
|
|
datetime(2026, 1, 15, 15, 0),
|
|
]
|
|
|
|
|
|
# ---------- TickFlow 路径: timestamp 毫秒 (UTC 基准) → 北京墙钟 ----------
|
|
|
|
def test_tickflow_timestamp_normalizes_to_beijing_wallclock():
|
|
"""09:30 北京 = 01:30 UTC; SDK 帧 timestamp 毫秒归一后必须回到 09:30。"""
|
|
ts_ms = [
|
|
int(datetime(2026, 1, 15, 1, 30, tzinfo=UTC).timestamp() * 1000), # 09:30 北京
|
|
int(datetime(2026, 1, 15, 5, 0, tzinfo=UTC).timestamp() * 1000), # 13:00 北京
|
|
int(datetime(2026, 1, 15, 7, 0, tzinfo=UTC).timestamp() * 1000), # 15:00 北京
|
|
]
|
|
df = pl.DataFrame({
|
|
"symbol": ["600519.SH"] * 3,
|
|
"timestamp": ts_ms,
|
|
"open": [10.0] * 3, "high": [10.5] * 3,
|
|
"low": [9.5] * 3, "close": [10.2] * 3,
|
|
"volume": [100.0] * 3, "amount": [1020.0] * 3,
|
|
})
|
|
out = kline_sync._normalize_minute(df)
|
|
assert out["datetime"].to_list() == _beijing_day()
|
|
|
|
|
|
def test_tickflow_timestamp_partial_day_lunch_unaffected():
|
|
"""午间 11:30 (03:30 UTC) 同样正确归一, 不被误判为越界。"""
|
|
df = pl.DataFrame({
|
|
"symbol": ["000001.SZ"],
|
|
"timestamp": [int(datetime(2026, 1, 15, 3, 30, tzinfo=UTC).timestamp() * 1000)],
|
|
"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0],
|
|
"volume": [1.0], "amount": [1.0],
|
|
})
|
|
out = kline_sync._normalize_minute(df)
|
|
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 11, 30)]
|
|
|
|
|
|
# ---------- 守卫: 各口径分类 ----------
|
|
|
|
def test_guard_beijing_naive_passthrough():
|
|
df = _minute_frame(_beijing_day())
|
|
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
assert out["datetime"].to_list() == _beijing_day()
|
|
|
|
|
|
def test_guard_utc_naive_selfhealed_plus8():
|
|
"""01:30/05:00/07:00 (UTC 墙钟特征) → 自动 +8 → 09:30/13:00/15:00。"""
|
|
df = _minute_frame([
|
|
datetime(2026, 1, 15, 1, 30),
|
|
datetime(2026, 1, 15, 5, 0),
|
|
datetime(2026, 1, 15, 7, 0),
|
|
])
|
|
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
assert out["datetime"].to_list() == _beijing_day()
|
|
|
|
|
|
def test_guard_selfheal_is_idempotent():
|
|
df = _minute_frame([datetime(2026, 1, 15, 1, 30), datetime(2026, 1, 15, 3, 0)])
|
|
once = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
twice = kline_sync._enforce_minute_beijing_wallclock(once, source="t")
|
|
assert once["datetime"].to_list() == twice["datetime"].to_list()
|
|
|
|
|
|
def test_guard_tzaware_utc_converted():
|
|
"""tz-aware UTC 01:30 → 北京墙钟 09:30 (naive)。"""
|
|
df = _minute_frame([datetime(2026, 1, 15, 1, 30, tzinfo=UTC)])
|
|
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
assert out["datetime"].dtype == pl.Datetime("us")
|
|
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30)]
|
|
|
|
|
|
def test_guard_tzaware_shanghai_converted():
|
|
"""tz-aware +08:00 09:30 → 北京墙钟 09:30 (naive), 数值不变。"""
|
|
df = _minute_frame([datetime(2026, 1, 15, 9, 30, tzinfo=_shanghai_tz())])
|
|
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30)]
|
|
|
|
|
|
def _shanghai_tz():
|
|
from zoneinfo import ZoneInfo
|
|
return ZoneInfo("Asia/Shanghai")
|
|
|
|
|
|
def test_guard_unrecognized_convention_fails_closed():
|
|
"""21:30/22:15 (境外墙钟特征) 既非北京时段也非 UTC 平移 → 拒收。"""
|
|
df = _minute_frame([datetime(2026, 1, 15, 21, 30), datetime(2026, 1, 15, 22, 15)])
|
|
with pytest.raises(ValueError, match="口径无法识别"):
|
|
kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
|
|
|
|
def test_guard_all_null_datetimes_passthrough():
|
|
"""全 null datetime 维持原行为 (下游落盘过滤), 不误伤。"""
|
|
df = _minute_frame([None, None])
|
|
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
assert out.height == 2
|
|
assert out["datetime"].null_count() == 2
|
|
|
|
|
|
def test_guard_string_datetimes_classified_after_parse():
|
|
"""trade_time 字符串路径: 先解析再分类 (UTC 特征串同样自愈)。"""
|
|
df = pl.DataFrame({
|
|
"symbol": ["600519.SH"],
|
|
"trade_time": ["2026-01-15 01:30:00"],
|
|
"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0],
|
|
"volume": [1.0], "amount": [1.0],
|
|
}).rename({"trade_time": "datetime"})
|
|
out = kline_sync._enforce_minute_beijing_wallclock(df, source="t")
|
|
assert out["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30)]
|
|
|
|
|
|
# ---------- 路由级: 自定义源契约违规 → 回退 TickFlow ----------
|
|
|
|
def _setup_custom_provider(monkeypatch, provider: object) -> None:
|
|
monkeypatch.setattr(kline_sync.preferences, "get_minute_data_provider", lambda: "mock_src")
|
|
monkeypatch.setattr("app.data_providers.custom.provider_has_dataset", lambda name, ds: True)
|
|
monkeypatch.setattr("app.data_providers.custom.get_provider", lambda name: provider)
|
|
|
|
|
|
def test_custom_provider_utc_frame_selfhealed(monkeypatch):
|
|
"""插件返回 UTC 墙钟帧 → 路由层守卫 +8 后下发, 不回退。"""
|
|
mock_provider = MagicMock()
|
|
mock_provider.get_minute = MagicMock(return_value=_minute_frame(
|
|
[datetime(2026, 1, 15, 1, 30), datetime(2026, 1, 15, 5, 0)]))
|
|
_setup_custom_provider(monkeypatch, mock_provider)
|
|
|
|
df, fallback = kline_sync._try_custom_minute(
|
|
["600519.SH"], datetime(2026, 1, 15, 9, 25), datetime(2026, 1, 15, 15, 5),
|
|
asset_type="stock",
|
|
)
|
|
assert fallback is False
|
|
assert df["datetime"].to_list() == [datetime(2026, 1, 15, 9, 30), datetime(2026, 1, 15, 13, 0)]
|
|
|
|
|
|
def test_custom_provider_garbage_datetime_falls_back(monkeypatch):
|
|
"""插件返回无法识别口径 → fail-closed 回退 TickFlow。"""
|
|
mock_provider = MagicMock()
|
|
mock_provider.get_minute = MagicMock(return_value=_minute_frame(
|
|
[datetime(2026, 1, 15, 21, 30)]))
|
|
_setup_custom_provider(monkeypatch, mock_provider)
|
|
|
|
df, fallback = kline_sync._try_custom_minute(
|
|
["600519.SH"], datetime(2026, 1, 15, 9, 25), datetime(2026, 1, 15, 15, 5),
|
|
asset_type="stock",
|
|
)
|
|
assert fallback is True
|
|
assert df is None
|