diff --git a/tests/test_chart_data_provider.py b/tests/test_chart_data_provider.py index a40c956..6d9314c 100644 --- a/tests/test_chart_data_provider.py +++ b/tests/test_chart_data_provider.py @@ -193,8 +193,14 @@ class DatahubChartFallbackTests(unittest.TestCase): def test_datahub_timeout_or_empty_falls_back_to_eastmoney(self): 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() + fallback.requests.clear() client = MarketChartClient(IfindHttpClient(), fallback, hub) payload = client.stock_intraday("000001") self.assertEqual(payload["trade_date"], "2026-09-07") diff --git a/tests/test_datahub_bridge.py b/tests/test_datahub_bridge.py index 9cb16e9..fe921d2 100644 --- a/tests/test_datahub_bridge.py +++ b/tests/test_datahub_bridge.py @@ -64,9 +64,11 @@ class FakeClient(DatahubClient): meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0}, ) self.paths: list[str] = [] + self.calls: list[tuple[str, dict[str, Any]]] = [] def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse: 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: raise AssertionError("token leaked into url") if self.error: @@ -397,6 +399,20 @@ class DatahubBridgeTests(unittest.TestCase): self.assertEqual(len(chart["points"]), 1) self.assertEqual(chart["points"][0]["average"], 56.4) 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: violations = [] diff --git a/xiaobai-datahub/datahub/serving.py b/xiaobai-datahub/datahub/serving.py index cd36cd0..d8c7585 100644 --- a/xiaobai-datahub/datahub/serving.py +++ b/xiaobai-datahub/datahub/serving.py @@ -269,8 +269,13 @@ class V1API: code = str(q.get("code") or "").strip() if not code: raise ApiError("INVALID_ARGUMENT", "code is required") + raw_date = str(q.get("date") or "").strip() 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: raise ApiError(exc.code, exc.message) from exc diff --git a/xiaobai-datahub/tests/test_realtime_intraday.py b/xiaobai-datahub/tests/test_realtime_intraday.py index 0537507..a52e264 100644 --- a/xiaobai-datahub/tests/test_realtime_intraday.py +++ b/xiaobai-datahub/tests/test_realtime_intraday.py @@ -9,6 +9,8 @@ from datahub.adapters.base import AdapterError from datahub.adapters.eastmoney import HIS_TRENDS_URL, TRENDS_URL, EastmoneyAdapter from datahub.db import HubDB from datahub.realtime_serve import fetch_intraday +from datahub.serving import ApiError, V1API +from datahub.timeutil import now_shanghai, yyyymmdd class FakeEastmoney(EastmoneyAdapter): @@ -98,5 +100,77 @@ class IntradayLkgTests(unittest.TestCase): 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__": unittest.main()