diff --git a/backend/data/providers/tushare_daily.py b/backend/data/providers/tushare_daily.py index bb09d32..83d558d 100644 --- a/backend/data/providers/tushare_daily.py +++ b/backend/data/providers/tushare_daily.py @@ -3,7 +3,11 @@ from __future__ import annotations from typing import Any 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: @@ -17,7 +21,11 @@ class DailyMarketMixin: trade_date = requested else: 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( "trade_cal", diff --git a/backend/data/providers/tushare_dashboard.py b/backend/data/providers/tushare_dashboard.py index c2fb4b4..2b1404a 100644 --- a/backend/data/providers/tushare_dashboard.py +++ b/backend/data/providers/tushare_dashboard.py @@ -16,6 +16,12 @@ from backend.data.providers.tushare_transport import TushareError 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]: trade_date, previous_trade_date = self.resolve_trade_context(requested_date) if self.should_use_realtime(requested_date, trade_date): @@ -26,11 +32,12 @@ class DashboardMixin: ) daily = self._load_daily(trade_date) + now = self._now() if ( 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 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( requested_date, @@ -98,15 +105,14 @@ class DashboardMixin: } return apply_sentiment_to_dashboard(dashboard) - @staticmethod - def should_use_realtime(requested_date: str, trade_date: str) -> bool: - """Use rt_k for today's open market until end-of-day datasets settle.""" - now = datetime.now().astimezone() + def should_use_realtime(self, requested_date: str, trade_date: str) -> bool: + """Use live quotes for today's open session until official daily settles.""" + now = self._now() today = now.strftime("%Y%m%d") return ( requested_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( @@ -178,7 +184,7 @@ class DashboardMixin: ) sectors = _build_sectors(limits) previous_sectors = _build_sectors(previous_limits) - now = datetime.now().astimezone() + now = self._now() market_status = _realtime_market_status(now.time().replace(tzinfo=None)) dashboard = { "meta": { @@ -234,7 +240,7 @@ class DashboardMixin: {"trade_date": previous_trade_date}, "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}") result = { "basic_rows": basic_rows, diff --git a/backend/data/providers/tushare_helpers.py b/backend/data/providers/tushare_helpers.py index 6260e9e..1a13a80 100644 --- a/backend/data/providers/tushare_helpers.py +++ b/backend/data/providers/tushare_helpers.py @@ -6,6 +6,17 @@ from typing import Any 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: if isinstance(value, (list, tuple, set)): return "、".join(str(item).strip() for item in value if str(item).strip()) diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 6680d68..09756c2 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -65,9 +65,31 @@ class MarketServiceMixin: # Compatibility for isolated legacy unit-test service stubs. 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]: normalized_date = normalize_date(trade_date) - now = datetime.now().astimezone() + now = self._now() if ( normalized_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time() @@ -174,14 +196,14 @@ class MarketServiceMixin: def _should_retry_incomplete_snapshot( self, snapshot: dict[str, Any], requested_date: str ) -> bool: - if requested_date != date.today().strftime("%Y%m%d"): + if requested_date != self._now().strftime("%Y%m%d"): return False meta = snapshot.get("meta") or {} - incomplete = ( - meta.get("limit_data_source") == "derived" - or bool(meta.get("carried_forward")) - or str(meta.get("trade_date") or "").replace("-", "") != requested_date - ) + actual = str(meta.get("trade_date") or "").replace("-", "") + stale_carry = bool(meta.get("carried_forward") or actual != requested_date) + if stale_carry and self._is_requested_open_session(requested_date): + return True + incomplete = meta.get("limit_data_source") == "derived" or stale_carry return incomplete and self._snapshot_age_seconds(meta) >= 60 def _annotate_data_status(self, dashboard: dict[str, Any]) -> dict[str, Any]: @@ -199,6 +221,9 @@ class MarketServiceMixin: else: meta["data_status"] = "preparing" meta["display_notice"] = self._preparing_display_notice(actual, requested) + elif meta.get("realtime"): + meta["data_status"] = "intraday" + meta.setdefault("display_notice", "") else: meta["data_status"] = "official" meta.setdefault("display_notice", "") @@ -225,9 +250,9 @@ class MarketServiceMixin: normalized_date: str, snapshot: dict[str, Any], ) -> 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 - now = datetime.now().astimezone() + now = self._now() local_time = now.time().replace(tzinfo=None) realtime_start = datetime.strptime("09:15", "%H:%M").time() morning_end = datetime.strptime("11:35", "%H:%M").time() @@ -276,6 +301,12 @@ class MarketServiceMixin: actual_date = normalize_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) if actual_date != normalized_date: dashboard.setdefault("meta", {}).update( @@ -297,6 +328,30 @@ class MarketServiceMixin: ) return self._apply_reason_overrides(self._with_storage(dashboard, cached=False)) 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) if fallback: actual = str((fallback.get("meta") or {}).get("trade_date") or "") diff --git a/backend/jobs/refresh.py b/backend/jobs/refresh.py index 279d9f8..b674d08 100644 --- a/backend/jobs/refresh.py +++ b/backend/jobs/refresh.py @@ -41,6 +41,8 @@ def official_catchup_due(today: str, snapshot: dict[str, object]) -> bool: actual == today and meta.get("limit_data_source") != "derived" and not meta.get("carried_forward") + and not meta.get("realtime") + and meta.get("mode") != "realtime" ): return False return True diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 80a93e2..e05e616 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -508,8 +508,8 @@ }, { "path": "backend/data/providers/tushare_dashboard.py", - "bytes": 28234, - "lines": 648 + "bytes": 28327, + "lines": 654 }, { "path": "backend/data/providers/tushare_industries.py", @@ -551,6 +551,11 @@ "bytes": 15311, "lines": 387 }, + { + "path": "frontend/shared/dashboard.js", + "bytes": 15063, + "lines": 321 + }, { "path": "frontend/pages/pools/page.html", "bytes": 14942, @@ -561,11 +566,6 @@ "bytes": 14743, "lines": 342 }, - { - "path": "frontend/shared/dashboard.js", - "bytes": 14740, - "lines": 316 - }, { "path": "frontend/shared/admin.js", "bytes": 14410, @@ -638,8 +638,8 @@ }, { "path": "backend/data/providers/tushare_daily.py", - "bytes": 6837, - "lines": 160 + "bytes": 6949, + "lines": 168 }, { "path": "backend/application.py", @@ -786,6 +786,11 @@ "bytes": 2514, "lines": 63 }, + { + "path": "backend/data/providers/tushare_helpers.py", + "bytes": 2360, + "lines": 75 + }, { "path": "backend/jobs/service.py", "bytes": 2337, @@ -811,11 +816,6 @@ "bytes": 2165, "lines": 35 }, - { - "path": "backend/data/providers/tushare_helpers.py", - "bytes": 2083, - "lines": 64 - }, { "path": "frontend/pages/market/breadth.js", "bytes": 2071, @@ -827,13 +827,13 @@ "lines": 45 }, { - "path": "backend/features/system/routes.py", - "bytes": 1791, - "lines": 46 + "path": "backend/jobs/refresh.py", + "bytes": 1808, + "lines": 48 }, { - "path": "backend/jobs/refresh.py", - "bytes": 1728, + "path": "backend/features/system/routes.py", + "bytes": 1791, "lines": 46 }, { diff --git a/frontend/shared/dashboard.js b/frontend/shared/dashboard.js index 37c2d83..f32e3b1 100644 --- a/frontend/shared/dashboard.js +++ b/frontend/shared/dashboard.js @@ -67,6 +67,11 @@ async function startAdminRefresh() { const actualCompact = actualDate.replaceAll("-", ""); const updated = formatTimestamp(meta.updated_at); 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") { setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert"); setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据"); diff --git a/tests/test_admin_refresh_status.py b/tests/test_admin_refresh_status.py index ce65157..3c96987 100644 --- a/tests/test_admin_refresh_status.py +++ b/tests/test_admin_refresh_status.py @@ -3,7 +3,8 @@ from __future__ import annotations import copy import threading 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 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: + def __init__(self, open_today: bool = True): + self.open_today = open_today + def dashboard(self, trade_date: str): 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): - def __init__(self, client, latest=None): + def __init__(self, client, latest=None, clock=None): self.configured = True self.sync_lock = threading.Lock() self.database = FakeSyncDatabase(latest) self._client = client self.current_user_id = 1 + self.clock = clock def _tushare_client(self): return self._client @@ -142,23 +183,139 @@ class DashboardFreshnessTests(unittest.TestCase): self.assertEqual(harness.database.finished[0][0][1], "success") self.assertEqual(verified_dashboard_result(payload), payload) - def test_missing_official_data_keeps_previous_day_with_preparing_notice(self): - today = date.today() - previous = (today - timedelta(days=1)).strftime("%Y-%m-%d") + def test_intraday_refresh_keeps_today_and_does_not_fall_back_to_yesterday(self): + today = TRADE_DAY.strftime("%Y%m%d") latest = { - "meta": {"trade_date": previous, "source": "tushare"}, + "meta": {"trade_date": "2026-09-07", "source": "tushare"}, "overview": {"limit_up_count": 20}, } - harness = SyncHarness(FakeMissingDailyClient(), latest) - payload = harness.sync_dashboard(today.strftime("%Y%m%d")) + harness = SyncHarness( + FakeRealtimeTodayClient(), + latest, + clock=lambda: at_clock(10, 5), + ) + payload = harness.sync_dashboard(today) meta = payload["meta"] - self.assertTrue(meta["carried_forward"]) - self.assertEqual(meta["data_status"], "preparing") - self.assertIn("今日数据正在准备,当前展示", meta["display_notice"]) - self.assertIn("月", meta["display_notice"]) - self.assertNotIn("No daily data", meta["display_notice"]) - self.assertNotEqual(verified_dashboard_result(payload).get("status"), "failed") + self.assertFalse(meta.get("carried_forward")) + self.assertTrue(meta["realtime"]) + self.assertEqual(meta["data_status"], "intraday") + self.assertEqual(str(meta["trade_date"]).replace("-", ""), today) + self.assertNotIn("今日数据正在准备", meta.get("display_notice") or "") + 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): snapshot = { @@ -200,19 +357,43 @@ class DashboardFreshnessTests(unittest.TestCase): {"meta": {"trade_date": iso, "limit_data_source": "derived"}}, ) 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.assertTrue(derived_due) else: self.assertFalse(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): def test_dashboard_script_distinguishes_partial_from_failure(self): 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("meta.realtime && actualCompact === requestedCompact", script) self.assertIn('job.status === "failed"', script) failed_block = script.split("if (job.status === \"failed\")", 1)[1].split("const query", 1)[0] self.assertIn("后台刷新失败", failed_block) diff --git a/tests/test_realtime_dashboard.py b/tests/test_realtime_dashboard.py index 6977a4f..8943df6 100644 --- a/tests/test_realtime_dashboard.py +++ b/tests/test_realtime_dashboard.py @@ -1,8 +1,10 @@ from __future__ import annotations import unittest +from datetime import datetime, timedelta, timezone from backend.data.providers.tushare_client import TushareClient +from backend.data.providers.tushare_helpers import calendar_is_open class FakeRealtimeClient(TushareClient): @@ -130,6 +132,58 @@ class RealtimeDashboardTests(unittest.TestCase): self.assertEqual(dashboard["meta"]["limit_data_source"], "derived") 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__": unittest.main()