Compare commits

...
Author SHA1 Message Date
a043bc9eb1 fix(HEL-485): 盘中选择当天不再整页退回昨天
交易时段缺少盘后正式数据时继续展示当天盘中行情,只有开盘前、周末和历史日期才沿用最近收盘结果。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 10:16:52 +08:00
acde4de40d fix(HEL-484): 中枢分时接口空 date 按当天查询
缺少或为空的 date 不再 400,按当天处理;显式历史日期保持原行为。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 10:07:44 +08:00
13 changed files with 479 additions and 56 deletions
+10 -2
View File
@@ -3,7 +3,11 @@ from __future__ import annotations
from typing import Any from typing import Any
from backend.data.numbers import finite_number as _number from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_helpers import _display_time, _prices_equal from backend.data.providers.tushare_helpers import (
_display_time,
_prices_equal,
calendar_is_open,
)
class DailyMarketMixin: class DailyMarketMixin:
@@ -17,7 +21,11 @@ class DailyMarketMixin:
trade_date = requested trade_date = requested
else: else:
row = requested_rows[0] row = requested_rows[0]
trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested) trade_date = (
row["cal_date"]
if calendar_is_open(row.get("is_open"))
else row.get("pretrade_date", requested)
)
resolved_rows = self.query( resolved_rows = self.query(
"trade_cal", "trade_cal",
+15 -9
View File
@@ -16,6 +16,12 @@ from backend.data.providers.tushare_transport import TushareError
class DashboardMixin: class DashboardMixin:
def _now(self) -> datetime:
clock = getattr(self, "clock", None)
if callable(clock):
return clock()
return datetime.now().astimezone()
def dashboard(self, requested_date: str) -> dict[str, Any]: def dashboard(self, requested_date: str) -> dict[str, Any]:
trade_date, previous_trade_date = self.resolve_trade_context(requested_date) trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
if self.should_use_realtime(requested_date, trade_date): if self.should_use_realtime(requested_date, trade_date):
@@ -26,11 +32,12 @@ class DashboardMixin:
) )
daily = self._load_daily(trade_date) daily = self._load_daily(trade_date)
now = self._now()
if ( if (
not daily not daily
and requested_date == datetime.now().astimezone().strftime("%Y%m%d") and requested_date == now.strftime("%Y%m%d")
and trade_date == requested_date and trade_date == requested_date
and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15) and now.time().replace(tzinfo=None) >= dt_time(9, 15)
): ):
return self._realtime_dashboard( return self._realtime_dashboard(
requested_date, requested_date,
@@ -98,15 +105,14 @@ class DashboardMixin:
} }
return apply_sentiment_to_dashboard(dashboard) return apply_sentiment_to_dashboard(dashboard)
@staticmethod def should_use_realtime(self, requested_date: str, trade_date: str) -> bool:
def should_use_realtime(requested_date: str, trade_date: str) -> bool: """Use live quotes for today's open session until official daily settles."""
"""Use rt_k for today's open market until end-of-day datasets settle.""" now = self._now()
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d") today = now.strftime("%Y%m%d")
return ( return (
requested_date == today requested_date == today
and trade_date == today and trade_date == today
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30) and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(15, 5)
) )
def _realtime_dashboard( def _realtime_dashboard(
@@ -178,7 +184,7 @@ class DashboardMixin:
) )
sectors = _build_sectors(limits) sectors = _build_sectors(limits)
previous_sectors = _build_sectors(previous_limits) previous_sectors = _build_sectors(previous_limits)
now = datetime.now().astimezone() now = self._now()
market_status = _realtime_market_status(now.time().replace(tzinfo=None)) market_status = _realtime_market_status(now.time().replace(tzinfo=None))
dashboard = { dashboard = {
"meta": { "meta": {
@@ -234,7 +240,7 @@ class DashboardMixin:
{"trade_date": previous_trade_date}, {"trade_date": previous_trade_date},
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv", "ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
) )
if not basic_rows or not price_limits: if not basic_rows:
raise TushareError(f"Realtime reference data is incomplete for {trade_date}") raise TushareError(f"Realtime reference data is incomplete for {trade_date}")
result = { result = {
"basic_rows": basic_rows, "basic_rows": basic_rows,
+11
View File
@@ -6,6 +6,17 @@ from typing import Any
from backend.data.numbers import finite_number as _number from backend.data.numbers import finite_number as _number
def calendar_is_open(value: Any) -> bool:
if value in (True, 1, "1", "Y", "y"):
return True
if value in (False, 0, "0", "N", "n", None, ""):
return False
try:
return int(value) == 1
except (TypeError, ValueError):
return False
def _text(value: Any) -> str: def _text(value: Any) -> str:
if isinstance(value, (list, tuple, set)): if isinstance(value, (list, tuple, set)):
return "".join(str(item).strip() for item in value if str(item).strip()) return "".join(str(item).strip() for item in value if str(item).strip())
+64 -9
View File
@@ -65,9 +65,31 @@ class MarketServiceMixin:
# Compatibility for isolated legacy unit-test service stubs. # Compatibility for isolated legacy unit-test service stubs.
return TushareClient(self.token) return TushareClient(self.token)
def _now(self) -> datetime:
clock = getattr(self, "clock", None)
if callable(clock):
return clock()
return datetime.now().astimezone()
def _is_requested_open_session(self, requested_date: str) -> bool:
now = self._now()
if requested_date != now.strftime("%Y%m%d"):
return False
if now.time().replace(tzinfo=None) < dt_time(9, 15):
return False
client = self._tushare_client() if self.configured else None
resolve = getattr(client, "resolve_trade_context", None) if client else None
if resolve is None:
return now.weekday() < 5
try:
trade_date, _ = resolve(requested_date)
except Exception:
return now.weekday() < 5
return str(trade_date or "") == requested_date
def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]: def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date) normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone() now = self._now()
if ( if (
normalized_date == now.strftime("%Y%m%d") normalized_date == now.strftime("%Y%m%d")
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time() and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
@@ -174,14 +196,14 @@ class MarketServiceMixin:
def _should_retry_incomplete_snapshot( def _should_retry_incomplete_snapshot(
self, snapshot: dict[str, Any], requested_date: str self, snapshot: dict[str, Any], requested_date: str
) -> bool: ) -> bool:
if requested_date != date.today().strftime("%Y%m%d"): if requested_date != self._now().strftime("%Y%m%d"):
return False return False
meta = snapshot.get("meta") or {} meta = snapshot.get("meta") or {}
incomplete = ( actual = str(meta.get("trade_date") or "").replace("-", "")
meta.get("limit_data_source") == "derived" stale_carry = bool(meta.get("carried_forward") or actual != requested_date)
or bool(meta.get("carried_forward")) if stale_carry and self._is_requested_open_session(requested_date):
or str(meta.get("trade_date") or "").replace("-", "") != requested_date return True
) incomplete = meta.get("limit_data_source") == "derived" or stale_carry
return incomplete and self._snapshot_age_seconds(meta) >= 60 return incomplete and self._snapshot_age_seconds(meta) >= 60
def _annotate_data_status(self, dashboard: dict[str, Any]) -> dict[str, Any]: def _annotate_data_status(self, dashboard: dict[str, Any]) -> dict[str, Any]:
@@ -199,6 +221,9 @@ class MarketServiceMixin:
else: else:
meta["data_status"] = "preparing" meta["data_status"] = "preparing"
meta["display_notice"] = self._preparing_display_notice(actual, requested) meta["display_notice"] = self._preparing_display_notice(actual, requested)
elif meta.get("realtime"):
meta["data_status"] = "intraday"
meta.setdefault("display_notice", "")
else: else:
meta["data_status"] = "official" meta["data_status"] = "official"
meta.setdefault("display_notice", "") meta.setdefault("display_notice", "")
@@ -225,9 +250,9 @@ class MarketServiceMixin:
normalized_date: str, normalized_date: str,
snapshot: dict[str, Any], snapshot: dict[str, Any],
) -> bool: ) -> bool:
if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): if not self.configured or normalized_date != self._now().strftime("%Y%m%d"):
return False return False
now = datetime.now().astimezone() now = self._now()
local_time = now.time().replace(tzinfo=None) local_time = now.time().replace(tzinfo=None)
realtime_start = datetime.strptime("09:15", "%H:%M").time() realtime_start = datetime.strptime("09:15", "%H:%M").time()
morning_end = datetime.strptime("11:35", "%H:%M").time() morning_end = datetime.strptime("11:35", "%H:%M").time()
@@ -276,6 +301,12 @@ class MarketServiceMixin:
actual_date = normalize_date( actual_date = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or normalized_date) str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
) )
if actual_date != normalized_date and self._is_requested_open_session(
normalized_date
):
raise TushareError(
f"Intraday dashboard resolved {actual_date} instead of {normalized_date}"
)
self.database.save_snapshot(actual_date, source, dashboard) self.database.save_snapshot(actual_date, source, dashboard)
if actual_date != normalized_date: if actual_date != normalized_date:
dashboard.setdefault("meta", {}).update( dashboard.setdefault("meta", {}).update(
@@ -297,6 +328,30 @@ class MarketServiceMixin:
) )
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False)) return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
except TushareError as exc: except TushareError as exc:
if self._is_requested_open_session(normalized_date):
existing = self.database.get_snapshot(normalized_date)
existing_date = str(
((existing or {}).get("meta") or {}).get("trade_date") or ""
).replace("-", "")
if existing and existing_date == normalized_date:
kept = copy.deepcopy(existing)
kept.setdefault("meta", {}).update(
{
"requested_date": self._display_compact_date(normalized_date),
}
)
self.database.finish_sync(
sync_id,
"fallback",
self._record_count(kept),
str(exc),
"tushare",
)
return self._apply_reason_overrides(
self._with_storage(kept, cached=True)
)
self.database.finish_sync(sync_id, "failed", message=str(exc))
raise ValueError("当天盘中行情暂时不可用,请稍后重试。") from exc
fallback = self.database.get_latest_real_snapshot(normalized_date) fallback = self.database.get_latest_real_snapshot(normalized_date)
if fallback: if fallback:
actual = str((fallback.get("meta") or {}).get("trade_date") or "") actual = str((fallback.get("meta") or {}).get("trade_date") or "")
+2
View File
@@ -41,6 +41,8 @@ def official_catchup_due(today: str, snapshot: dict[str, object]) -> bool:
actual == today actual == today
and meta.get("limit_data_source") != "derived" and meta.get("limit_data_source") != "derived"
and not meta.get("carried_forward") and not meta.get("carried_forward")
and not meta.get("realtime")
and meta.get("mode") != "realtime"
): ):
return False return False
return True return True
+19 -19
View File
@@ -508,8 +508,8 @@
}, },
{ {
"path": "backend/data/providers/tushare_dashboard.py", "path": "backend/data/providers/tushare_dashboard.py",
"bytes": 28234, "bytes": 28327,
"lines": 648 "lines": 654
}, },
{ {
"path": "backend/data/providers/tushare_industries.py", "path": "backend/data/providers/tushare_industries.py",
@@ -551,6 +551,11 @@
"bytes": 15311, "bytes": 15311,
"lines": 387 "lines": 387
}, },
{
"path": "frontend/shared/dashboard.js",
"bytes": 15063,
"lines": 321
},
{ {
"path": "frontend/pages/pools/page.html", "path": "frontend/pages/pools/page.html",
"bytes": 14942, "bytes": 14942,
@@ -561,11 +566,6 @@
"bytes": 14743, "bytes": 14743,
"lines": 342 "lines": 342
}, },
{
"path": "frontend/shared/dashboard.js",
"bytes": 14740,
"lines": 316
},
{ {
"path": "frontend/shared/admin.js", "path": "frontend/shared/admin.js",
"bytes": 14410, "bytes": 14410,
@@ -638,8 +638,8 @@
}, },
{ {
"path": "backend/data/providers/tushare_daily.py", "path": "backend/data/providers/tushare_daily.py",
"bytes": 6837, "bytes": 6949,
"lines": 160 "lines": 168
}, },
{ {
"path": "backend/application.py", "path": "backend/application.py",
@@ -786,6 +786,11 @@
"bytes": 2514, "bytes": 2514,
"lines": 63 "lines": 63
}, },
{
"path": "backend/data/providers/tushare_helpers.py",
"bytes": 2360,
"lines": 75
},
{ {
"path": "backend/jobs/service.py", "path": "backend/jobs/service.py",
"bytes": 2337, "bytes": 2337,
@@ -811,11 +816,6 @@
"bytes": 2165, "bytes": 2165,
"lines": 35 "lines": 35
}, },
{
"path": "backend/data/providers/tushare_helpers.py",
"bytes": 2083,
"lines": 64
},
{ {
"path": "frontend/pages/market/breadth.js", "path": "frontend/pages/market/breadth.js",
"bytes": 2071, "bytes": 2071,
@@ -827,13 +827,13 @@
"lines": 45 "lines": 45
}, },
{ {
"path": "backend/features/system/routes.py", "path": "backend/jobs/refresh.py",
"bytes": 1791, "bytes": 1808,
"lines": 46 "lines": 48
}, },
{ {
"path": "backend/jobs/refresh.py", "path": "backend/features/system/routes.py",
"bytes": 1728, "bytes": 1791,
"lines": 46 "lines": 46
}, },
{ {
+5
View File
@@ -67,6 +67,11 @@ async function startAdminRefresh() {
const actualCompact = actualDate.replaceAll("-", ""); const actualCompact = actualDate.replaceAll("-", "");
const updated = formatTimestamp(meta.updated_at); const updated = formatTimestamp(meta.updated_at);
const freshness = dashboardFreshnessMessage(meta); const freshness = dashboardFreshnessMessage(meta);
if (meta.realtime && actualCompact === requestedCompact && !meta.carried_forward) {
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的盘中行情,更新时间 ${updated}`, "circle-check");
showToast(`刷新成功:已获取 ${actualDate} 的盘中行情`);
return;
}
if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") { if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert"); setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert");
setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据"); setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
+196 -15
View File
@@ -3,7 +3,8 @@ from __future__ import annotations
import copy import copy
import threading import threading
import unittest import unittest
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone, time as dt_time
from unittest.mock import patch
from pathlib import Path from pathlib import Path
from backend.features.market.service import MarketServiceMixin from backend.features.market.service import MarketServiceMixin
@@ -105,18 +106,58 @@ class FakeDerivedClient:
} }
SHANGHAI = timezone(timedelta(hours=8))
TRADE_DAY = date(2026, 9, 8)
def at_clock(hour: int, minute: int, day: date = TRADE_DAY) -> datetime:
return datetime(day.year, day.month, day.day, hour, minute, tzinfo=SHANGHAI)
class FakeMissingDailyClient: class FakeMissingDailyClient:
def __init__(self, open_today: bool = True):
self.open_today = open_today
def dashboard(self, trade_date: str): def dashboard(self, trade_date: str):
raise TushareError(f"No daily data returned for {trade_date}") raise TushareError(f"No daily data returned for {trade_date}")
def resolve_trade_context(self, requested: str):
if self.open_today:
return requested, "20260907"
return "20260907", "20260904"
class FakeRealtimeTodayClient:
def dashboard(self, trade_date: str):
return {
"meta": {
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
"requested_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
"realtime": True,
"mode": "realtime",
"market_status": "trading",
"notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"overview": {"limit_up_count": 15},
"limits": [{"code": "000001"}],
"broken": [],
"down_limits": [],
"yesterday_limits": [],
}
def resolve_trade_context(self, requested: str):
return requested, "20260907"
class SyncHarness(MarketServiceMixin): class SyncHarness(MarketServiceMixin):
def __init__(self, client, latest=None): def __init__(self, client, latest=None, clock=None):
self.configured = True self.configured = True
self.sync_lock = threading.Lock() self.sync_lock = threading.Lock()
self.database = FakeSyncDatabase(latest) self.database = FakeSyncDatabase(latest)
self._client = client self._client = client
self.current_user_id = 1 self.current_user_id = 1
self.clock = clock
def _tushare_client(self): def _tushare_client(self):
return self._client return self._client
@@ -142,23 +183,139 @@ class DashboardFreshnessTests(unittest.TestCase):
self.assertEqual(harness.database.finished[0][0][1], "success") self.assertEqual(harness.database.finished[0][0][1], "success")
self.assertEqual(verified_dashboard_result(payload), payload) self.assertEqual(verified_dashboard_result(payload), payload)
def test_missing_official_data_keeps_previous_day_with_preparing_notice(self): def test_intraday_refresh_keeps_today_and_does_not_fall_back_to_yesterday(self):
today = date.today() today = TRADE_DAY.strftime("%Y%m%d")
previous = (today - timedelta(days=1)).strftime("%Y-%m-%d")
latest = { latest = {
"meta": {"trade_date": previous, "source": "tushare"}, "meta": {"trade_date": "2026-09-07", "source": "tushare"},
"overview": {"limit_up_count": 20}, "overview": {"limit_up_count": 20},
} }
harness = SyncHarness(FakeMissingDailyClient(), latest) harness = SyncHarness(
payload = harness.sync_dashboard(today.strftime("%Y%m%d")) FakeRealtimeTodayClient(),
latest,
clock=lambda: at_clock(10, 5),
)
payload = harness.sync_dashboard(today)
meta = payload["meta"] meta = payload["meta"]
self.assertTrue(meta["carried_forward"]) self.assertFalse(meta.get("carried_forward"))
self.assertEqual(meta["data_status"], "preparing") self.assertTrue(meta["realtime"])
self.assertIn("今日数据正在准备,当前展示", meta["display_notice"]) self.assertEqual(meta["data_status"], "intraday")
self.assertIn("", meta["display_notice"]) self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
self.assertNotIn("No daily data", meta["display_notice"]) self.assertNotIn("今日数据正在准备", meta.get("display_notice") or "")
self.assertNotEqual(verified_dashboard_result(payload).get("status"), "failed") self.assertEqual(harness.database.saved[0][0], today)
def test_intraday_missing_quotes_do_not_carry_yesterday(self):
today = TRADE_DAY.strftime("%Y%m%d")
latest = {
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
"overview": {"limit_up_count": 20},
}
harness = SyncHarness(
FakeMissingDailyClient(),
latest,
clock=lambda: at_clock(10, 5),
)
with self.assertRaises(ValueError) as ctx:
harness.sync_dashboard(today)
self.assertIn("当天盘中行情", str(ctx.exception))
self.assertFalse(harness.database.saved)
def test_intraday_keeps_existing_today_snapshot_when_refresh_fails(self):
today = TRADE_DAY.strftime("%Y%m%d")
existing = {
"meta": {
"trade_date": "2026-09-08",
"realtime": True,
"mode": "realtime",
"source": "tushare",
},
"overview": {"limit_up_count": 11},
"limits": [{"code": "600000"}],
"broken": [],
"down_limits": [],
"yesterday_limits": [],
}
harness = SyncHarness(
FakeMissingDailyClient(),
clock=lambda: at_clock(10, 5),
)
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(existing)
payload = harness.sync_dashboard(today)
meta = payload["meta"]
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
self.assertTrue(meta["realtime"])
self.assertEqual(meta["data_status"], "intraday")
self.assertFalse(meta.get("carried_forward"))
def test_lunch_and_after_hours_keep_today_until_official_arrives(self):
today = TRADE_DAY.strftime("%Y%m%d")
for clock in (lambda: at_clock(12, 0), lambda: at_clock(16, 10)):
harness = SyncHarness(
FakeRealtimeTodayClient(),
clock=clock,
)
payload = harness.sync_dashboard(today)
self.assertEqual(str(payload["meta"]["trade_date"]).replace("-", ""), today)
self.assertFalse(payload["meta"].get("carried_forward"))
def test_preopen_and_weekend_still_carry_last_session(self):
latest = {
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
"overview": {"limit_up_count": 20},
}
preopen = SyncHarness(
FakeMissingDailyClient(),
latest,
clock=lambda: at_clock(8, 30),
)
preopen_payload = preopen.sync_dashboard(TRADE_DAY.strftime("%Y%m%d"))
self.assertTrue(preopen_payload["meta"]["carried_forward"])
self.assertEqual(preopen_payload["meta"]["data_status"], "preparing")
self.assertIn("今日数据正在准备,当前展示", preopen_payload["meta"]["display_notice"])
weekend = SyncHarness(
FakeMissingDailyClient(open_today=False),
latest,
clock=lambda: at_clock(10, 5, date(2026, 9, 5)),
)
weekend_payload = weekend.sync_dashboard("20260905")
self.assertTrue(weekend_payload["meta"]["carried_forward"])
def test_history_date_still_uses_official_or_preparing_notice(self):
latest = {
"meta": {"trade_date": "2026-09-01", "source": "tushare"},
"overview": {"limit_up_count": 8},
}
harness = SyncHarness(
FakeMissingDailyClient(),
latest,
clock=lambda: at_clock(10, 5),
)
payload = harness.sync_dashboard("20260902")
self.assertTrue(payload["meta"]["carried_forward"])
self.assertIn("所选日期数据尚未到齐", payload["meta"]["display_notice"])
def test_carried_today_snapshot_is_retried_immediately_in_session(self):
today = TRADE_DAY.strftime("%Y%m%d")
snapshot = {
"meta": {
"source": "tushare",
"trade_date": "2026-09-07",
"carried_forward": True,
"requested_date": "2026-09-08",
"updated_at": at_clock(10, 0).isoformat(),
},
"overview": {"limit_up_count": 1},
}
harness = SyncHarness(
FakeRealtimeTodayClient(),
clock=lambda: at_clock(10, 5),
)
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(snapshot)
payload = harness.get_dashboard(today)
self.assertEqual(str(payload["meta"]["trade_date"]).replace("-", ""), today)
self.assertEqual(payload["meta"]["data_status"], "intraday")
self.assertTrue(harness.database.saved)
def test_weekend_carry_is_not_labeled_as_preparing(self): def test_weekend_carry_is_not_labeled_as_preparing(self):
snapshot = { snapshot = {
@@ -200,19 +357,43 @@ class DashboardFreshnessTests(unittest.TestCase):
{"meta": {"trade_date": iso, "limit_data_source": "derived"}}, {"meta": {"trade_date": iso, "limit_data_source": "derived"}},
) )
now = datetime.now().astimezone().time().replace(tzinfo=None) now = datetime.now().astimezone().time().replace(tzinfo=None)
if datetime.strptime("15:05", "%H:%M").time() <= now < datetime.strptime("22:00", "%H:%M").time(): if dt_time(15, 5) <= now < dt_time(22, 0):
self.assertFalse(due) self.assertFalse(due)
self.assertTrue(derived_due) self.assertTrue(derived_due)
else: else:
self.assertFalse(due) self.assertFalse(due)
self.assertFalse(derived_due) self.assertFalse(derived_due)
def test_official_catchup_is_due_for_intraday_snapshot_after_close(self):
today = TRADE_DAY.strftime("%Y%m%d")
snapshot = {
"meta": {
"trade_date": "2026-09-08",
"realtime": True,
"mode": "realtime",
}
}
with patch("backend.jobs.refresh.datetime") as mocked:
mocked.now.return_value = at_clock(16, 10)
mocked.strptime = datetime.strptime
self.assertTrue(official_catchup_due(today, snapshot))
official = {
"meta": {
"trade_date": "2026-09-08",
"limit_data_source": "official",
"realtime": False,
}
}
self.assertFalse(official_catchup_due(today, official))
class FrontendRefreshCopyTests(unittest.TestCase): class FrontendRefreshCopyTests(unittest.TestCase):
def test_dashboard_script_distinguishes_partial_from_failure(self): def test_dashboard_script_distinguishes_partial_from_failure(self):
script = (Path(__file__).resolve().parents[1] / "frontend" / "shared" / "dashboard.js").read_text(encoding="utf-8") script = (Path(__file__).resolve().parents[1] / "frontend" / "shared" / "dashboard.js").read_text(encoding="utf-8")
self.assertIn("今日数据正在准备,当前展示", script) self.assertIn("今日数据正在准备,当前展示", script)
self.assertIn("部分正式数据尚未到齐", script) self.assertIn("部分正式数据尚未到齐", script)
self.assertIn("盘中行情", script)
self.assertIn("meta.realtime && actualCompact === requestedCompact", script)
self.assertIn('job.status === "failed"', script) self.assertIn('job.status === "failed"', script)
failed_block = script.split("if (job.status === \"failed\")", 1)[1].split("const query", 1)[0] failed_block = script.split("if (job.status === \"failed\")", 1)[1].split("const query", 1)[0]
self.assertIn("后台刷新失败", failed_block) self.assertIn("后台刷新失败", failed_block)
+7 -1
View File
@@ -193,8 +193,14 @@ class DatahubChartFallbackTests(unittest.TestCase):
def test_datahub_timeout_or_empty_falls_back_to_eastmoney(self): def test_datahub_timeout_or_empty_falls_back_to_eastmoney(self):
fallback = LookbackChartClient() fallback = LookbackChartClient()
for hub in (FakeHub(chart=None), FakeHub(error=RuntimeError("timeout"))): for hub in (
FakeHub(chart=None),
FakeHub(error=RuntimeError("timeout")),
FakeHub(error=RuntimeError("datahub exploded")),
FakeHub(chart={"points": []}),
):
EastmoneyChartClient._cache.clear() EastmoneyChartClient._cache.clear()
fallback.requests.clear()
client = MarketChartClient(IfindHttpClient(), fallback, hub) client = MarketChartClient(IfindHttpClient(), fallback, hub)
payload = client.stock_intraday("000001") payload = client.stock_intraday("000001")
self.assertEqual(payload["trade_date"], "2026-09-07") self.assertEqual(payload["trade_date"], "2026-09-07")
+16
View File
@@ -64,9 +64,11 @@ class FakeClient(DatahubClient):
meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0}, meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0},
) )
self.paths: list[str] = [] self.paths: list[str] = []
self.calls: list[tuple[str, dict[str, Any]]] = []
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse: def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
self.paths.append(path) self.paths.append(path)
self.calls.append((path, {key: value for key, value in (params or {}).items()}))
if TOKEN in json.dumps(params or {}) or TOKEN in path: if TOKEN in json.dumps(params or {}) or TOKEN in path:
raise AssertionError("token leaked into url") raise AssertionError("token leaked into url")
if self.error: if self.error:
@@ -397,6 +399,20 @@ class DatahubBridgeTests(unittest.TestCase):
self.assertEqual(len(chart["points"]), 1) self.assertEqual(len(chart["points"]), 1)
self.assertEqual(chart["points"][0]["average"], 56.4) self.assertEqual(chart["points"][0]["average"], 56.4)
self.assertEqual(ok.client.paths, ["/v1/intraday/points"]) self.assertEqual(ok.client.paths, ["/v1/intraday/points"])
self.assertEqual(ok.client.calls, [("/v1/intraday/points", {"code": "601318"})])
self.assertNotIn("date", ok.client.calls[0][1])
timeout = DatahubBridge(
flags(intraday=(True, False)),
FakeClient(error=DatahubError("TIMEOUT", "datahub request timed out")),
)
self.assertIsNone(timeout.try_intraday("601318"))
broken = DatahubBridge(
flags(intraday=(True, False)),
FakeClient(error=DatahubError("INTERNAL", "datahub exploded")),
)
self.assertIsNone(broken.try_intraday("601318"))
self.assertFalse(DatahubSettings.load(environ={}, credentials={}).flags("intraday").read)
def test_features_do_not_import_datahub_client(self) -> None: def test_features_do_not_import_datahub_client(self) -> None:
violations = [] violations = []
+54
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timedelta, timezone
from backend.data.providers.tushare_client import TushareClient from backend.data.providers.tushare_client import TushareClient
from backend.data.providers.tushare_helpers import calendar_is_open
class FakeRealtimeClient(TushareClient): class FakeRealtimeClient(TushareClient):
@@ -130,6 +132,58 @@ class RealtimeDashboardTests(unittest.TestCase):
self.assertEqual(dashboard["meta"]["limit_data_source"], "derived") self.assertEqual(dashboard["meta"]["limit_data_source"], "derived")
self.assertIn("日线数据推算", dashboard["meta"]["notice"]) self.assertIn("日线数据推算", dashboard["meta"]["notice"])
def test_calendar_open_flag_accepts_string_and_bool(self):
self.assertTrue(calendar_is_open(1))
self.assertTrue(calendar_is_open("1"))
self.assertTrue(calendar_is_open(True))
self.assertFalse(calendar_is_open(0))
self.assertFalse(calendar_is_open("0"))
self.assertFalse(calendar_is_open(False))
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "trade_cal":
return [
{
"cal_date": params.get("start_date"),
"is_open": "1",
"pretrade_date": "20260907",
}
]
return original_query(api_name, params, fields)
self.client.query = query
trade_date, previous = self.client.resolve_trade_context("20260908")
self.assertEqual(trade_date, "20260908")
self.assertEqual(previous, "20260907")
def test_session_clock_uses_realtime_until_official_window(self):
today = "20260908"
self.client.clock = lambda: datetime(
2026, 9, 8, 10, 5, tzinfo=timezone(timedelta(hours=8))
)
self.assertTrue(self.client.should_use_realtime(today, today))
self.client.clock = lambda: datetime(
2026, 9, 8, 16, 10, tzinfo=timezone(timedelta(hours=8))
)
self.assertFalse(self.client.should_use_realtime(today, today))
def test_realtime_dashboard_survives_missing_limit_table(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "stk_limit":
return []
return original_query(api_name, params, fields)
self.client.query = query
TushareClient._realtime_reference_cache.clear()
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertTrue(dashboard["meta"]["realtime"])
self.assertEqual(dashboard["meta"]["quote_count"], 3)
self.assertEqual(dashboard["overview"]["limit_up_count"], 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+6 -1
View File
@@ -269,8 +269,13 @@ class V1API:
code = str(q.get("code") or "").strip() code = str(q.get("code") or "").strip()
if not code: if not code:
raise ApiError("INVALID_ARGUMENT", "code is required") raise ApiError("INVALID_ARGUMENT", "code is required")
raw_date = str(q.get("date") or "").strip()
try: try:
return fetch_intraday(self.db, code, yyyymmdd(q.get("date") or "")) trade_date = yyyymmdd(raw_date or now_shanghai())
except ValueError as exc:
raise ApiError("INVALID_ARGUMENT", str(exc)) from exc
try:
return fetch_intraday(self.db, code, trade_date)
except RealtimeApiError as exc: except RealtimeApiError as exc:
raise ApiError(exc.code, exc.message) from exc raise ApiError(exc.code, exc.message) from exc
@@ -9,6 +9,8 @@ from datahub.adapters.base import AdapterError
from datahub.adapters.eastmoney import HIS_TRENDS_URL, TRENDS_URL, EastmoneyAdapter from datahub.adapters.eastmoney import HIS_TRENDS_URL, TRENDS_URL, EastmoneyAdapter
from datahub.db import HubDB from datahub.db import HubDB
from datahub.realtime_serve import fetch_intraday from datahub.realtime_serve import fetch_intraday
from datahub.serving import ApiError, V1API
from datahub.timeutil import now_shanghai, yyyymmdd
class FakeEastmoney(EastmoneyAdapter): class FakeEastmoney(EastmoneyAdapter):
@@ -98,5 +100,77 @@ class IntradayLkgTests(unittest.TestCase):
self.assertIn("intraday unavailable", str(ctx.exception)) self.assertIn("intraday unavailable", str(ctx.exception))
class ServingIntradayDateTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.db = HubDB(Path(self.tmp.name) / "hub.db")
self.api = V1API(self.db, pipeline=None, settings=None)
def tearDown(self) -> None:
self.tmp.cleanup()
def _assert_usable_intraday(self, payload: dict) -> None:
data = payload["data"]
points = [point for point in data.get("points") or [] if float(point.get("close") or 0) > 0]
self.assertGreaterEqual(len(points), 1)
self.assertTrue(str(data.get("trade_date") or ""))
self.assertFalse((payload.get("meta") or {}).get("stale"))
def test_serving_omitted_or_empty_date_uses_today_and_returns_points(self) -> None:
today = yyyymmdd(now_shanghai())
omitted = self.api.handle("/v1/intraday/points", {"code": ["601318"]})
empty = self.api.handle("/v1/intraday/points", {"code": ["601318"], "date": [""]})
explicit = self.api.handle("/v1/intraday/points", {"code": ["601318"], "date": [today]})
self._assert_usable_intraday(omitted)
self._assert_usable_intraday(empty)
self._assert_usable_intraday(explicit)
self.assertEqual(omitted["data"]["trade_date"], empty["data"]["trade_date"])
self.assertEqual(explicit["data"]["trade_date"], omitted["data"]["trade_date"])
def test_serving_normalizes_empty_date_to_today_and_keeps_history(self) -> None:
today = yyyymmdd(now_shanghai())
captured: list[str] = []
def fake_fetch(db, code, date=""):
captured.append(date)
return {
"schema_version": 1,
"data": {
"trade_date": f"{date[:4]}-{date[4:6]}-{date[6:8]}",
"points": [{"date": f"{date[:4]}-{date[4:6]}-{date[6:8]}", "time": "09:30", "close": 55.9}],
},
"meta": {"stale": False, "trade_date": date},
}
with patch("datahub.realtime_serve.fetch_intraday", side_effect=fake_fetch):
omitted = self.api.handle("/v1/intraday/points", {"code": ["601318"]})
empty = self.api.handle("/v1/intraday/points", {"code": ["601318"], "date": [" "]})
history = self.api.handle("/v1/intraday/points", {"code": ["601318"], "date": ["20260907"]})
self.assertEqual(captured, [today, today, "20260907"])
self.assertEqual(omitted["data"]["trade_date"], f"{today[:4]}-{today[4:6]}-{today[6:8]}")
self.assertEqual(empty["data"]["trade_date"], omitted["data"]["trade_date"])
self.assertEqual(history["data"]["trade_date"], "2026-09-07")
def test_serving_invalid_date_is_invalid_argument(self) -> None:
with self.assertRaises(ApiError) as ctx:
self.api.handle("/v1/intraday/points", {"code": ["601318"], "date": ["not-a-date"]})
self.assertEqual(ctx.exception.code, "INVALID_ARGUMENT")
self.assertIn("invalid trade_date", ctx.exception.message)
def test_serving_missing_code_is_invalid_argument(self) -> None:
with self.assertRaises(ApiError) as ctx:
self.api.handle("/v1/intraday/points", {"date": [yyyymmdd(now_shanghai())]})
self.assertEqual(ctx.exception.code, "INVALID_ARGUMENT")
self.assertIn("code is required", ctx.exception.message)
def test_serving_no_data_keeps_source_unavailable(self) -> None:
with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked:
mocked.return_value.fetch_intraday.side_effect = AdapterError("No intraday chart data returned")
with self.assertRaises(ApiError) as ctx:
self.api.handle("/v1/intraday/points", {"code": ["000001"]})
self.assertEqual(ctx.exception.code, "SOURCE_UNAVAILABLE")
self.assertIn("intraday unavailable", ctx.exception.message)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()