fix(HEL-494): 数据中枢独占调度,主网站不再回退旧接口
主网站只向中枢要业务数据;来源选择、切源、补数全部在中枢内部完成,失败不再走东财/腾讯/Tushare 保底。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
ef13d6feb5
commit
0b8419abca
@@ -202,7 +202,7 @@ class DatahubChartFallbackTests(unittest.TestCase):
|
||||
self.assertEqual(hub.calls, ["601318"])
|
||||
self.assertEqual(fallback.requests, [])
|
||||
|
||||
def test_datahub_timeout_or_empty_falls_back_to_eastmoney(self):
|
||||
def test_datahub_timeout_or_empty_does_not_use_old_channel(self):
|
||||
fallback = LookbackChartClient()
|
||||
for hub in (
|
||||
FakeHub(chart=None),
|
||||
@@ -213,10 +213,9 @@ class DatahubChartFallbackTests(unittest.TestCase):
|
||||
EastmoneyChartClient._cache.clear()
|
||||
fallback.requests.clear()
|
||||
client = MarketChartClient(IfindHttpClient(), fallback, hub)
|
||||
payload = client.stock_intraday("000001")
|
||||
self.assertEqual(payload["trade_date"], "2026-09-07")
|
||||
self.assertGreaterEqual(len(payload["points"]), 1)
|
||||
self.assertTrue(fallback.requests)
|
||||
with self.assertRaises(ChartDataError):
|
||||
client.stock_intraday("000001")
|
||||
self.assertEqual(fallback.requests, [])
|
||||
|
||||
def test_datahub_daily_skips_ifind(self):
|
||||
hub = FakeHub(
|
||||
|
||||
@@ -13,6 +13,7 @@ from backend.data.datahub.compare import compare_rows
|
||||
from backend.data.datahub.errors import DatahubError
|
||||
from backend.data.datahub.native import to_canonical_row, to_native_row
|
||||
from backend.data.datahub.route_state import LEDGER
|
||||
from backend.data.providers.tushare_transport import TushareError
|
||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -68,9 +69,16 @@ class FakeClient(DatahubClient):
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
||||
return self._record(path, params)
|
||||
|
||||
def post(self, path: str, body: dict[str, Any] | None = None) -> DatahubResponse:
|
||||
return self._record(path, body)
|
||||
|
||||
def _record(self, path: str, payload: dict[str, Any] | 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:
|
||||
self.calls.append((path, {key: value for key, value in (payload or {}).items()}))
|
||||
packed = json.dumps(payload or {})
|
||||
if TOKEN in packed or TOKEN in path:
|
||||
raise AssertionError("token leaked into url")
|
||||
if self.error:
|
||||
raise self.error
|
||||
@@ -131,16 +139,22 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
self.assertEqual(legacy.calls, [])
|
||||
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||
calendar_legacy = FakeLegacy([{"cal_date": "20240902", "is_open": 1}])
|
||||
calendar_client = FakeClient(error=DatahubError("INTERNAL", "nope"))
|
||||
calendar_client = FakeClient(
|
||||
response=DatahubResponse(
|
||||
data=[{"cal_date": "20240902", "is_open": 1, "pretrade_date": "20240830"}],
|
||||
meta={"source": "datahub", "stale": False, "staleness_seconds": 0},
|
||||
)
|
||||
)
|
||||
calendar_wrapped = DatahubAwareTushareClient(
|
||||
calendar_legacy,
|
||||
DatahubBridge(flags(daily=(True, False)), calendar_client),
|
||||
)
|
||||
calendar = calendar_wrapped.query("trade_cal", {"start_date": "20240902", "end_date": "20240902"}, "")
|
||||
self.assertEqual(calendar[0]["is_open"], 1)
|
||||
self.assertEqual(calendar_client.paths, [])
|
||||
self.assertEqual(calendar_legacy.calls, [])
|
||||
self.assertEqual(calendar_client.paths, ["/v1/query"])
|
||||
|
||||
def test_fallback_on_down_401_timeout_empty_unpublished_stale_and_incomplete(self) -> None:
|
||||
def test_hub_failure_does_not_call_website_legacy(self) -> None:
|
||||
cases = [
|
||||
DatahubError("UNAVAILABLE", "down"),
|
||||
DatahubError("UNAUTHORIZED", "401"),
|
||||
@@ -152,34 +166,21 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
]
|
||||
for error in cases:
|
||||
with self.subTest(error=error.code):
|
||||
if error.code == "EMPTY":
|
||||
client = FakeClient(response=DatahubResponse(data=[], meta={"stale": False, "staleness_seconds": 0}))
|
||||
elif error.code == "STALE":
|
||||
client = FakeClient(response=DatahubResponse(
|
||||
data=[dict(HUB_DAILY)],
|
||||
meta={"stale": True, "staleness_seconds": 999999},
|
||||
))
|
||||
elif error.code == "INCOMPLETE":
|
||||
client = FakeClient(response=DatahubResponse(
|
||||
data=[dict(HUB_DAILY)],
|
||||
meta={
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"incomplete": True,
|
||||
"coverage": {"complete": False, "missing_count": 80},
|
||||
},
|
||||
))
|
||||
else:
|
||||
client = FakeClient(error=error)
|
||||
client = FakeClient(error=error)
|
||||
legacy = FakeLegacy([LEGACY_DAILY])
|
||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||
self.assertEqual(len(legacy.calls), 1)
|
||||
with self.assertRaises(TushareError):
|
||||
wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||
self.assertEqual(legacy.calls, [])
|
||||
|
||||
def test_shadow_compares_without_replacing_and_survives_hub_failure(self) -> None:
|
||||
def test_shadow_mode_no_longer_calls_website_tushare(self) -> None:
|
||||
reports: list[dict[str, Any]] = []
|
||||
client = FakeClient()
|
||||
client = FakeClient(
|
||||
response=DatahubResponse(
|
||||
data=[dict(LEGACY_DAILY)],
|
||||
meta={"source": "tushare", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
|
||||
)
|
||||
)
|
||||
legacy = FakeLegacy([LEGACY_DAILY])
|
||||
wrapped = DatahubAwareTushareClient(
|
||||
legacy,
|
||||
@@ -187,21 +188,19 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
)
|
||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount,vol")
|
||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||
self.assertEqual(len(legacy.calls), 1)
|
||||
self.assertEqual(reports[0]["equal"], True)
|
||||
self.assertEqual(reports[0]["matched"], 1)
|
||||
self.assertEqual(legacy.calls, [])
|
||||
self.assertEqual(client.paths, ["/v1/query"])
|
||||
|
||||
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
|
||||
fail_reports: list[dict[str, Any]] = []
|
||||
fail_legacy = FakeLegacy([LEGACY_DAILY])
|
||||
fail_wrapped = DatahubAwareTushareClient(
|
||||
fail_legacy,
|
||||
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=fail_reports.append),
|
||||
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=reports.append),
|
||||
)
|
||||
again = fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
||||
self.assertEqual(again[0]["amount"], 2000.0)
|
||||
self.assertTrue(fail_reports[0]["hub_error"])
|
||||
self.assertNotIn(TOKEN, json.dumps(fail_reports[0]))
|
||||
with self.assertRaises(TushareError):
|
||||
fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
||||
self.assertEqual(fail_legacy.calls, [])
|
||||
self.assertNotIn(TOKEN, str(failed.calls))
|
||||
|
||||
def test_compare_classifies_unit_conversion_missing_row_and_value_diff(self) -> None:
|
||||
equal = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 0})
|
||||
@@ -288,13 +287,12 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
)
|
||||
wrapped = DatahubAwareTushareClient(
|
||||
FakeLegacy([legacy_close_only]),
|
||||
DatahubBridge(flags(daily=(False, True)), client, shadow_sink=reports.append),
|
||||
DatahubBridge(flags(daily=(True, False)), client, shadow_sink=reports.append),
|
||||
)
|
||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,trade_date,close,vol,amount")
|
||||
self.assertEqual(rows[0]["close"], 10.20)
|
||||
self.assertEqual(rows[0]["vol"], 1000.0)
|
||||
self.assertTrue(reports[0]["equal"])
|
||||
self.assertEqual(reports[0]["matched"], 1)
|
||||
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||
|
||||
def test_native_roundtrip_matches_known_scales(self) -> None:
|
||||
native = to_native_row("daily", HUB_DAILY)
|
||||
@@ -347,21 +345,17 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
self.assertIn('"daily"', source)
|
||||
self.assertIn("start_date", source)
|
||||
self.assertIn("end_date", source)
|
||||
client = FakeClient(
|
||||
response=DatahubResponse(
|
||||
data=[dict(HUB_DAILY)],
|
||||
meta={"stale": False, "staleness_seconds": 0, "incomplete": True, "coverage": {"complete": False, "missing_count": 89}},
|
||||
)
|
||||
)
|
||||
client = FakeClient(error=DatahubError("INCOMPLETE", "truncated"))
|
||||
legacy = FakeLegacy([LEGACY_DAILY])
|
||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
||||
rows = wrapped.query(
|
||||
"daily",
|
||||
{"ts_code": "600000.SH", "start_date": "20240301", "end_date": "20240902"},
|
||||
"ts_code,amount",
|
||||
)
|
||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||
self.assertEqual(len(legacy.calls), 1)
|
||||
with self.assertRaises(TushareError):
|
||||
wrapped.query(
|
||||
"daily",
|
||||
{"ts_code": "600000.SH", "start_date": "20240301", "end_date": "20240902"},
|
||||
"ts_code,amount",
|
||||
)
|
||||
self.assertEqual(legacy.calls, [])
|
||||
self.assertIn("/v1/query", client.paths)
|
||||
|
||||
def test_try_intraday_respects_switch_and_falls_back_on_bad_payload(self) -> None:
|
||||
closed = DatahubBridge(flags(), FakeClient(error=DatahubError("INTERNAL", "should not run")))
|
||||
@@ -462,17 +456,15 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
FakeClient(error=DatahubError("UNAVAILABLE", "down")),
|
||||
)
|
||||
self.assertIsNone(failed.try_market_quotes("20240902"))
|
||||
failed.record_legacy("quotes", "tencent_qt", "down")
|
||||
snap = next(item for item in LEDGER.snapshot() if item["dataset"] == "quotes")
|
||||
self.assertEqual(snap["route"], "legacy")
|
||||
self.assertEqual(snap["source"], "tencent_qt")
|
||||
self.assertIn("备用", "备用")
|
||||
self.assertEqual(snap["route"], "datahub")
|
||||
self.assertEqual(snap["source"], "unavailable")
|
||||
|
||||
gateway = build_data_gateway({}, datahub_settings=flags(quotes=(True, False)))
|
||||
status = gateway.datahub_status()
|
||||
self.assertEqual(status["enabled_reads"], 1)
|
||||
self.assertEqual(status["total_reads"], len(DATASETS))
|
||||
self.assertGreaterEqual(status["fallback_count"], 1)
|
||||
self.assertEqual(status["fallback_count"], 0)
|
||||
|
||||
def test_try_daily_chart_converts_hub_bars(self) -> None:
|
||||
rows = [
|
||||
|
||||
@@ -87,11 +87,10 @@ class ShenwanRealtimeSourceTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(TushareError, "rt_sw_k is disabled"):
|
||||
client.query("rt_sw_k", {"ts_code": "801074.SI"})
|
||||
|
||||
def test_outer_realtime_uses_eastmoney_shenwan_not_rt_sw_k(self) -> None:
|
||||
def test_outer_realtime_uses_hub_sector_quote_not_rt_sw_k(self) -> None:
|
||||
client = TushareClient(token="demo")
|
||||
client.query = MagicMock(side_effect=AssertionError("should not call tushare"))
|
||||
client.realtime_aggregator = MagicMock()
|
||||
client.realtime_aggregator.eastmoney_shenwan_quote.return_value = {
|
||||
client.try_sector_quote = MagicMock(return_value={
|
||||
"code": "801074.SI",
|
||||
"name": "工业金属",
|
||||
"close": 1234.5,
|
||||
@@ -101,7 +100,7 @@ class ShenwanRealtimeSourceTests(unittest.TestCase):
|
||||
"quote_date": "20260908",
|
||||
"quote_time": "2026-09-08T14:50:00+08:00",
|
||||
"source": "eastmoney_sw",
|
||||
}
|
||||
})
|
||||
row, source, error = client._sw_outer_realtime("801074.SI", "工业金属", "20260908")
|
||||
self.assertEqual(source, "eastmoney_sw")
|
||||
self.assertEqual(error, "")
|
||||
@@ -216,23 +215,21 @@ class MemberQuoteCoverageTests(unittest.TestCase):
|
||||
self.assertEqual(source, "datahub")
|
||||
client.try_quotes.assert_not_called()
|
||||
|
||||
def test_eastmoney_failure_uses_tencent_member_quotes(self) -> None:
|
||||
def test_hub_named_quotes_cover_members_when_market_missing(self) -> None:
|
||||
client = TushareClient(token="demo")
|
||||
wanted = ["000737.SZ", "000630.SZ"]
|
||||
client.try_market_quotes = MagicMock(return_value=None)
|
||||
client.try_quotes = MagicMock(return_value=None)
|
||||
aggregator = MagicMock()
|
||||
aggregator.eastmoney_stock_quotes.side_effect = RuntimeError("HTTP 503")
|
||||
aggregator.tencent_stock_quotes.return_value = [
|
||||
client.try_quotes = MagicMock(return_value=[
|
||||
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
|
||||
{"ts_code": "000630.SZ", "close": 4.5, "pre_close": 4.4},
|
||||
]
|
||||
client.realtime_aggregator = aggregator
|
||||
client._free_realtime_quotes = MagicMock(side_effect=AssertionError("tencent already won"))
|
||||
])
|
||||
client.realtime_aggregator = MagicMock()
|
||||
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(source, "tencent_qt")
|
||||
aggregator.tencent_stock_quotes.assert_called_once()
|
||||
self.assertEqual(source, "datahub")
|
||||
client.try_quotes.assert_called()
|
||||
client.realtime_aggregator.eastmoney_stock_quotes.assert_not_called()
|
||||
client.realtime_aggregator.tencent_stock_quotes.assert_not_called()
|
||||
|
||||
def test_delayed_hub_quotes_are_kept_not_zeroed(self) -> None:
|
||||
client = TushareClient(token="demo")
|
||||
@@ -323,8 +320,7 @@ class MemberQuoteCoverageTests(unittest.TestCase):
|
||||
client._sw_realtime_sector_snapshot = MagicMock(
|
||||
side_effect=AssertionError("daily inner should be kept")
|
||||
)
|
||||
client.realtime_aggregator = MagicMock()
|
||||
client.realtime_aggregator.eastmoney_shenwan_quote.return_value = {
|
||||
client.try_sector_quote = MagicMock(return_value={
|
||||
"code": "801074.SI",
|
||||
"name": "工业金属",
|
||||
"change": 1.5,
|
||||
@@ -332,7 +328,7 @@ class MemberQuoteCoverageTests(unittest.TestCase):
|
||||
"quote_date": "20260908",
|
||||
"quote_time": "2026-09-08T15:00:00+08:00",
|
||||
"source": "eastmoney_sw",
|
||||
}
|
||||
})
|
||||
snapshot = client.sw_sector_snapshot(
|
||||
"000737.SZ", "20260908", allow_realtime_close=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.data.datahub.bridge import DatahubAwareTushareClient, DatahubBridge
|
||||
from backend.data.datahub.client import DatahubClient
|
||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||
from backend.data.providers.tushare_transport import TushareError
|
||||
from tests.test_datahub_bridge import FakeClient, FakeLegacy, flags
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BLOCKED_HOSTS = (
|
||||
"api.tushare.pro",
|
||||
"push2.eastmoney.com",
|
||||
"push2delay.eastmoney.com",
|
||||
"push2his.eastmoney.com",
|
||||
"push2ex.eastmoney.com",
|
||||
"qt.gtimg.cn",
|
||||
"hq.sinajs.cn",
|
||||
"10jqka.com.cn",
|
||||
"xuangubao.cn",
|
||||
)
|
||||
|
||||
|
||||
class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
def test_query_never_calls_website_tushare_transport(self) -> None:
|
||||
client = FakeClient()
|
||||
legacy = FakeLegacy(TushareError("website tushare must stay dark"))
|
||||
wrapped = DatahubAwareTushareClient(
|
||||
legacy,
|
||||
DatahubBridge(flags(daily=(True, False)), client),
|
||||
)
|
||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||
self.assertEqual(legacy.calls, [])
|
||||
|
||||
def test_blocked_external_hosts_still_read_hub(self) -> None:
|
||||
settings = DatahubSettings(
|
||||
base_url="http://127.0.0.1:8766",
|
||||
token="hub-token",
|
||||
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
|
||||
)
|
||||
|
||||
def blocked_urlopen(request, timeout=None):
|
||||
url = str(getattr(request, "full_url", None) or request)
|
||||
if any(host in url for host in BLOCKED_HOSTS):
|
||||
raise AssertionError(f"website opened blocked host: {url}")
|
||||
if "127.0.0.1:8766" in url or "v1/bars/daily" in url:
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def read(self):
|
||||
return (
|
||||
b'{"schema_version":1,"data":[{"ts_code":"600000.SH","trade_date":"20240902",'
|
||||
b'"close":10.2,"volume":100000,"amount":2000000}],'
|
||||
b'"meta":{"stale":false,"staleness_seconds":0,"source":"datahub"}}'
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
return _Resp()
|
||||
raise AssertionError(f"unexpected url: {url}")
|
||||
|
||||
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||
legacy = FakeLegacy(TushareError("blocked"))
|
||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(settings, hub_client))
|
||||
with patch("urllib.request.urlopen", blocked_urlopen):
|
||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,close,amount")
|
||||
self.assertEqual(rows[0]["close"], 10.2)
|
||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||
self.assertEqual(legacy.calls, [])
|
||||
|
||||
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None:
|
||||
gateway_src = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
|
||||
self.assertIn("legacy.realtime_aggregator = None", gateway_src)
|
||||
self.assertIn("DatahubAwareTushareClient", gateway_src)
|
||||
|
||||
def test_bridge_query_has_no_legacy_call(self) -> None:
|
||||
source = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
query_fn = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "DatahubBridge"
|
||||
for item in node.body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == "query"
|
||||
)
|
||||
called = [
|
||||
ast.unparse(item.func) if hasattr(ast, "unparse") else ""
|
||||
for item in ast.walk(query_fn)
|
||||
if isinstance(item, ast.Call)
|
||||
]
|
||||
self.assertTrue(any("query_api" in text for text in called))
|
||||
self.assertFalse(any("legacy_query" in text for text in called))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,6 +12,20 @@ from backend.features.market.insights import MarketInsightsService
|
||||
from server import DashboardService
|
||||
|
||||
|
||||
class _FakeDailyHub:
|
||||
def __init__(self, rows: list) -> None:
|
||||
self.rows = rows
|
||||
|
||||
def try_daily_chart(self, code, end_date, limit, dataset="daily"):
|
||||
return list(self.rows)
|
||||
|
||||
def try_quotes(self, codes):
|
||||
return None
|
||||
|
||||
def try_index_quotes(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeIfind:
|
||||
configured = True
|
||||
|
||||
@@ -128,13 +142,50 @@ class IfindFeatureTests(unittest.TestCase):
|
||||
self.assertEqual(database.list_wencai_saved_queries(second["id"]), [])
|
||||
|
||||
def test_ifind_daily_chart_normalizes_change(self):
|
||||
client = MarketChartClient(FakeIfind(), EastmoneyChartClient())
|
||||
hub = _FakeDailyHub(
|
||||
[
|
||||
{
|
||||
"trade_date": "2026-07-27",
|
||||
"open": 10,
|
||||
"high": 10.5,
|
||||
"low": 9.8,
|
||||
"close": 10.2,
|
||||
"volume": 100,
|
||||
"amount_billion": 0.01,
|
||||
"change": 0,
|
||||
},
|
||||
{
|
||||
"trade_date": "2026-07-28",
|
||||
"open": 10.2,
|
||||
"high": 10.8,
|
||||
"low": 10.1,
|
||||
"close": 10.5,
|
||||
"volume": 120,
|
||||
"amount_billion": 0.012,
|
||||
"change": 2.9412,
|
||||
},
|
||||
]
|
||||
)
|
||||
client = MarketChartClient(FakeIfind(), EastmoneyChartClient(), hub)
|
||||
rows = client.stock_daily("000001", "20260728")
|
||||
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
|
||||
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
|
||||
|
||||
def test_ifind_daily_chart_keeps_last_traded_bar_before_market_open(self):
|
||||
client = MarketChartClient(FakeIfindStalePreopen(), EastmoneyChartClient())
|
||||
hub = _FakeDailyHub(
|
||||
[
|
||||
{
|
||||
"trade_date": "2026-07-28",
|
||||
"open": 10.2,
|
||||
"high": 10.8,
|
||||
"low": 10.1,
|
||||
"close": 10.5,
|
||||
"volume": 120,
|
||||
"amount_billion": 0.012,
|
||||
}
|
||||
]
|
||||
)
|
||||
client = MarketChartClient(FakeIfindStalePreopen(), EastmoneyChartClient(), hub)
|
||||
with patch("backend.features.market.charts.datetime", FixedPreopenDatetime):
|
||||
rows = client.stock_daily("000001", "20260729")
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
||||
self.assertEqual(dashboard["overview"]["limit_up_count"], 0)
|
||||
|
||||
def test_rt_k_permission_error_falls_back_to_free_quotes(self):
|
||||
def test_hub_quotes_used_when_rt_k_denied(self):
|
||||
original_query = self.client.query
|
||||
|
||||
def query(api_name, params=None, fields=""):
|
||||
@@ -265,21 +265,20 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
return original_query(api_name, params, fields)
|
||||
|
||||
self.client.query = query
|
||||
self.client.realtime_aggregator = FakeFreeAggregator()
|
||||
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
|
||||
TushareClient._realtime_reference_cache.clear()
|
||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||
|
||||
self.assertTrue(dashboard["meta"]["realtime"])
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "eastmoney_clist")
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||
self.assertEqual(dashboard["meta"]["trade_date"], "2026-07-20")
|
||||
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
||||
self.assertEqual(dashboard["overview"]["limit_up_count"], 1)
|
||||
self.assertEqual(dashboard["overview"]["limit_down_count"], 1)
|
||||
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
||||
self.assertIn("东财免费实时", dashboard["meta"]["notice"])
|
||||
self.assertEqual(dashboard["meta"]["indices"][0]["price"], 3800.12)
|
||||
self.assertIn("数据中枢", dashboard["meta"]["notice"])
|
||||
|
||||
def test_rt_k_empty_result_falls_back_to_free_quotes(self):
|
||||
def test_hub_quotes_used_when_rt_k_empty(self):
|
||||
original_query = self.client.query
|
||||
|
||||
def query(api_name, params=None, fields=""):
|
||||
@@ -288,29 +287,27 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
return original_query(api_name, params, fields)
|
||||
|
||||
self.client.query = query
|
||||
self.client.realtime_aggregator = FakeFreeAggregator()
|
||||
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
|
||||
TushareClient._realtime_reference_cache.clear()
|
||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "eastmoney_clist")
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
|
||||
|
||||
def test_rt_k_and_free_source_failure_keeps_today_error(self):
|
||||
def test_hub_failure_keeps_today_error(self):
|
||||
original_query = self.client.query
|
||||
|
||||
def query(api_name, params=None, fields=""):
|
||||
if api_name == "rt_k":
|
||||
raise TushareError("没有接口访问权限")
|
||||
raise TushareError("数据中枢行情暂不可用")
|
||||
return original_query(api_name, params, fields)
|
||||
|
||||
self.client.query = query
|
||||
self.client.realtime_aggregator = FakeFreeAggregator(fail=True)
|
||||
TushareClient._realtime_reference_cache.clear()
|
||||
with self.assertRaises(TushareError) as ctx:
|
||||
self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||
self.assertIn("当天盘中实时行情不可用", str(ctx.exception))
|
||||
self.assertIn("没有接口访问权限", str(ctx.exception))
|
||||
|
||||
def test_rt_k_and_eastmoney_failure_falls_back_to_tencent(self):
|
||||
def test_hub_failover_is_invisible_to_website(self):
|
||||
original_query = self.client.query
|
||||
|
||||
def query(api_name, params=None, fields=""):
|
||||
@@ -318,23 +315,13 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
raise TushareError("没有接口访问权限")
|
||||
return original_query(api_name, params, fields)
|
||||
|
||||
class TencentOnlyAggregator(FakeFreeAggregator):
|
||||
def eastmoney_market_quotes(self, expected_date=""):
|
||||
raise RealtimeAggregateError("eastmoney blocked")
|
||||
|
||||
def tencent_market_quotes(self, codes, expected_date=""):
|
||||
return list(FREE_QUOTES)
|
||||
|
||||
def tencent_stock_quotes(self, codes, expected_date="", minimum=None):
|
||||
return list(FREE_QUOTES)
|
||||
|
||||
self.client.query = query
|
||||
self.client.realtime_aggregator = TencentOnlyAggregator()
|
||||
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
|
||||
TushareClient._realtime_reference_cache.clear()
|
||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "tencent_qt")
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
|
||||
self.assertIn("腾讯免费实时", dashboard["meta"]["notice"])
|
||||
self.assertIn("数据中枢", dashboard["meta"]["notice"])
|
||||
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
||||
|
||||
def test_normalize_eastmoney_quote_maps_units_and_exchange(self):
|
||||
@@ -433,6 +420,13 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def query_api(self, api_name, params=None, fields=""):
|
||||
rows = FakeRealtimeClient("tok").query(api_name, params or {}, fields)
|
||||
return DatahubResponse(
|
||||
data=rows,
|
||||
meta={"source": "datahub", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
|
||||
)
|
||||
|
||||
def quotes_latest(self, **params):
|
||||
return self.get("/v1/quotes/latest", params)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user