from __future__ import annotations import ast import unittest from datetime import datetime, timedelta from pathlib import Path from backend.data import ( DataPolicyError, DataQualityError, DataSourcePolicy, QualityEvidence, build_data_gateway, ) from backend.data.quality import market_timezone class DataGatewayTests(unittest.TestCase): def test_policy_allows_registered_calculation_source(self) -> None: policy = DataSourcePolicy.load() contract = policy.assert_allowed( "market.stock_daily", "tushare", "calculation" ) self.assertEqual(contract.primary, "tushare") def test_policy_rejects_public_web_source_for_calculation(self) -> None: policy = DataSourcePolicy.load() with self.assertRaises(DataPolicyError): policy.assert_allowed( "observation.realtime_indices", "eastmoney", "calculation" ) def test_policy_rejects_blocked_dataset(self) -> None: policy = DataSourcePolicy.load() with self.assertRaises(DataPolicyError): policy.assert_allowed("market.level2", "unresolved", "display") def test_gateway_uses_live_token_supplier_and_shared_ifind(self) -> None: token = {"value": "first"} gateway = build_data_gateway( {"ifind_refresh_token": "refresh", "ifind_access_token": "access"}, lambda: token["value"], ) self.assertEqual(gateway.tushare().token, "first") token["value"] = "second" self.assertEqual(gateway.tushare().token, "second") self.assertIs(gateway.chart_data.ifind, gateway.ifind) def test_server_has_no_direct_runtime_tushare_construction(self) -> None: source = ( Path(__file__).resolve().parents[1] / "backend" / "features" / "market" / "service.py" ).read_text(encoding="utf-8") self.assertEqual(source.count("TushareClient(self.token)"), 1) self.assertIn("return gateway.tushare()", source) def test_provider_construction_has_unique_declared_owners(self) -> None: root = Path(__file__).resolve().parents[1] owners = { "EastmoneyChartClient": {"backend/data/gateway.py"}, "IfindHttpClient": {"backend/data/gateway.py"}, "IfindProvider": {"backend/data/gateway.py"}, "MarketChartClient": {"backend/data/gateway.py"}, "TushareClient": {"backend/features/market/service.py"}, "TushareProvider": {"backend/data/gateway.py"}, "WebRealtimeAggregator": {"backend/data/gateway.py"}, } found = {name: set() for name in owners} for path in (root / "backend").rglob("*.py"): relative = path.relative_to(root).as_posix() tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in ast.walk(tree): if not isinstance(node, ast.Call): continue name = getattr(node.func, "id", None) or getattr(node.func, "attr", None) if name in found: found[name].add(relative) self.assertEqual(found, owners) provider_source = (root / "backend/data/providers/tushare.py").read_text( encoding="utf-8" ) self.assertIn("client_factory: Callable[[str], TushareClient] = TushareClient", provider_source) def test_quality_gate_accepts_matching_daily_evidence(self) -> None: timezone = market_timezone() now = datetime(2026, 7, 29, 16, 0, tzinfo=timezone) gateway = build_data_gateway({}) report = gateway.require_quality( QualityEvidence( dataset_id="market.stock_daily", provider_id="tushare", data_time="2026-07-29", observed_at=now, actual_count=5000, expected_count=5000, adjustment="current-unadjusted", units={ "open": "CNY/share", "high": "CNY/share", "low": "CNY/share", "close": "CNY/share", "pct_chg": "percent", "volume_shares": "share", "amount_yuan": "CNY", }, ), "calculation", now, ) self.assertTrue(report.accepted) self.assertEqual(report.coverage_ratio, 1.0) def test_quality_gate_rejects_stale_dynamic_auction(self) -> None: timezone = market_timezone() now = datetime(2026, 7, 29, 9, 24, tzinfo=timezone) gateway = build_data_gateway({}) with self.assertRaises(DataQualityError): gateway.require_quality( QualityEvidence( dataset_id="market.auction_dynamic", provider_id="ifind", data_time=now - timedelta(seconds=30), observed_at=now - timedelta(seconds=29), units={ "price": "CNY/share", "volume_shares": "share", "amount_yuan": "CNY", "pre_close": "CNY/share", "turnover_rate_pct": "percent", "volume_ratio": "ratio", "float_share": "share", }, ), "calculation", now, ) def test_quality_gate_rejects_low_coverage_and_wrong_adjustment(self) -> None: timezone = market_timezone() now = datetime(2026, 7, 29, 16, 0, tzinfo=timezone) gateway = build_data_gateway({}) report = gateway.quality.evaluate( QualityEvidence( dataset_id="market.stock_daily", provider_id="tushare", data_time="2026-07-29", observed_at=now, actual_count=4000, expected_count=5000, adjustment="forward1", ), "calculation", now, ) self.assertFalse(report.accepted) self.assertTrue(any("Coverage" in issue for issue in report.issues)) self.assertTrue(any("Adjustment" in issue for issue in report.issues)) def test_quality_gate_enforces_financial_point_in_time(self) -> None: timezone = market_timezone() now = datetime(2026, 7, 29, 16, 0, tzinfo=timezone) gateway = build_data_gateway({}) report = gateway.quality.evaluate( QualityEvidence( dataset_id="market.fundamentals", provider_id="tushare", data_time="2026-06-30", observed_at=now, available_at="2026-08-15", ), "calculation", now, ) self.assertFalse(report.accepted) self.assertTrue(any("not available" in issue for issue in report.issues)) def test_provider_chain_never_silently_promotes_display_fallback(self) -> None: gateway = build_data_gateway({}) self.assertEqual( gateway.provider_chain("chart.intraday", "display"), ("ifind", "eastmoney"), ) with self.assertRaises(RuntimeError): gateway.provider_chain("chart.intraday", "calculation") if __name__ == "__main__": unittest.main()