refactor: enforce market data quality contracts
This commit is contained in:
@@ -1,4 +1,14 @@
|
|||||||
from .gateway import DataGateway, build_data_gateway
|
from .gateway import DataGateway, build_data_gateway
|
||||||
from .policy import DataPolicyError, DataSourcePolicy
|
from .policy import DataPolicyError, DataSourcePolicy
|
||||||
|
from .quality import DataQualityError, DataQualityGate, QualityEvidence, QualityReport
|
||||||
|
|
||||||
__all__ = ["DataGateway", "DataPolicyError", "DataSourcePolicy", "build_data_gateway"]
|
__all__ = [
|
||||||
|
"DataGateway",
|
||||||
|
"DataPolicyError",
|
||||||
|
"DataQualityError",
|
||||||
|
"DataQualityGate",
|
||||||
|
"DataSourcePolicy",
|
||||||
|
"QualityEvidence",
|
||||||
|
"QualityReport",
|
||||||
|
"build_data_gateway",
|
||||||
|
]
|
||||||
|
|||||||
+27
-1
@@ -2,10 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from backend.data.contracts import DataUsage
|
from backend.data.contracts import DataUsage
|
||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers import IfindProvider, TushareProvider
|
from backend.data.providers import IfindProvider, TushareProvider
|
||||||
|
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
||||||
from chart_data_provider import EastmoneyChartClient, MarketChartClient
|
from chart_data_provider import EastmoneyChartClient, MarketChartClient
|
||||||
from ifind_client import IfindHttpClient
|
from ifind_client import IfindHttpClient
|
||||||
from realtime_aggregator import WebRealtimeAggregator
|
from realtime_aggregator import WebRealtimeAggregator
|
||||||
@@ -15,6 +17,7 @@ from tushare_client import TushareClient
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DataGateway:
|
class DataGateway:
|
||||||
policy: DataSourcePolicy
|
policy: DataSourcePolicy
|
||||||
|
quality: DataQualityGate
|
||||||
tushare_provider: TushareProvider
|
tushare_provider: TushareProvider
|
||||||
ifind_provider: IfindProvider
|
ifind_provider: IfindProvider
|
||||||
chart_data: MarketChartClient
|
chart_data: MarketChartClient
|
||||||
@@ -36,6 +39,27 @@ class DataGateway:
|
|||||||
def assert_source(self, dataset_id: str, provider_id: str, usage: DataUsage) -> None:
|
def assert_source(self, dataset_id: str, provider_id: str, usage: DataUsage) -> None:
|
||||||
self.policy.assert_allowed(dataset_id, provider_id, usage)
|
self.policy.assert_allowed(dataset_id, provider_id, usage)
|
||||||
|
|
||||||
|
def provider_chain(self, dataset_id: str, usage: DataUsage) -> tuple[str, ...]:
|
||||||
|
dataset = self.policy.dataset(dataset_id)
|
||||||
|
allowed = []
|
||||||
|
for provider_id in dataset.providers:
|
||||||
|
try:
|
||||||
|
self.policy.assert_allowed(dataset_id, provider_id, usage)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
allowed.append(provider_id)
|
||||||
|
if not allowed:
|
||||||
|
raise RuntimeError(f"No permitted provider for {dataset_id} ({usage})")
|
||||||
|
return tuple(allowed)
|
||||||
|
|
||||||
|
def require_quality(
|
||||||
|
self,
|
||||||
|
evidence: QualityEvidence,
|
||||||
|
usage: DataUsage,
|
||||||
|
as_of: str | datetime | None = None,
|
||||||
|
) -> QualityReport:
|
||||||
|
return self.quality.require(evidence, usage, as_of)
|
||||||
|
|
||||||
|
|
||||||
def build_data_gateway(
|
def build_data_gateway(
|
||||||
credentials: dict[str, object],
|
credentials: dict[str, object],
|
||||||
@@ -48,8 +72,10 @@ def build_data_gateway(
|
|||||||
token_supplier = tushare_token_supplier or (
|
token_supplier = tushare_token_supplier or (
|
||||||
lambda: str(credentials.get("tushare_token") or "")
|
lambda: str(credentials.get("tushare_token") or "")
|
||||||
)
|
)
|
||||||
|
policy = DataSourcePolicy.load()
|
||||||
return DataGateway(
|
return DataGateway(
|
||||||
policy=DataSourcePolicy.load(),
|
policy=policy,
|
||||||
|
quality=DataQualityGate.load(policy),
|
||||||
tushare_provider=TushareProvider(token_supplier),
|
tushare_provider=TushareProvider(token_supplier),
|
||||||
ifind_provider=IfindProvider(ifind),
|
ifind_provider=IfindProvider(ifind),
|
||||||
chart_data=MarketChartClient(ifind, EastmoneyChartClient()),
|
chart_data=MarketChartClient(ifind, EastmoneyChartClient()),
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from app_config import APP_DIR
|
||||||
|
from backend.data.contracts import DataUsage
|
||||||
|
from backend.data.policy import DataPolicyError, DataSourcePolicy
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def market_timezone(name: str = "Asia/Shanghai"):
|
||||||
|
try:
|
||||||
|
return ZoneInfo(name)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
if name != "Asia/Shanghai":
|
||||||
|
raise
|
||||||
|
return timezone(timedelta(hours=8), name)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QualityEvidence:
|
||||||
|
dataset_id: str
|
||||||
|
provider_id: str
|
||||||
|
data_time: str | datetime
|
||||||
|
observed_at: str | datetime
|
||||||
|
actual_count: int | None = None
|
||||||
|
expected_count: int | None = None
|
||||||
|
units: dict[str, str] | None = None
|
||||||
|
adjustment: str = ""
|
||||||
|
available_at: str | datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QualityReport:
|
||||||
|
accepted: bool
|
||||||
|
dataset_id: str
|
||||||
|
provider_id: str
|
||||||
|
usage: DataUsage
|
||||||
|
coverage_ratio: float | None
|
||||||
|
age_seconds: float
|
||||||
|
issues: tuple[str, ...]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"accepted": self.accepted,
|
||||||
|
"dataset_id": self.dataset_id,
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"usage": self.usage,
|
||||||
|
"coverage_ratio": self.coverage_ratio,
|
||||||
|
"age_seconds": round(self.age_seconds, 3),
|
||||||
|
"issues": list(self.issues),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityGate:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source_policy: DataSourcePolicy,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
self.source_policy = source_policy
|
||||||
|
self.timezone = market_timezone(
|
||||||
|
str(payload.get("timezone") or "Asia/Shanghai")
|
||||||
|
)
|
||||||
|
self.defaults = dict(payload.get("defaults") or {})
|
||||||
|
self.unit_profiles = dict(payload.get("unit_profiles") or {})
|
||||||
|
self.rules = dict(payload.get("datasets") or {})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(
|
||||||
|
cls,
|
||||||
|
source_policy: DataSourcePolicy,
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> "DataQualityGate":
|
||||||
|
config_path = path or APP_DIR / "config" / "data-quality.config.json"
|
||||||
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
return cls(source_policy, payload)
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
evidence: QualityEvidence,
|
||||||
|
usage: DataUsage,
|
||||||
|
as_of: str | datetime | None = None,
|
||||||
|
) -> QualityReport:
|
||||||
|
issues: list[str] = []
|
||||||
|
try:
|
||||||
|
self.source_policy.assert_allowed(
|
||||||
|
evidence.dataset_id, evidence.provider_id, usage
|
||||||
|
)
|
||||||
|
except DataPolicyError as exc:
|
||||||
|
issues.append(str(exc))
|
||||||
|
|
||||||
|
rule = self.rules.get(evidence.dataset_id)
|
||||||
|
if rule is None:
|
||||||
|
issues.append(f"Missing quality rule: {evidence.dataset_id}")
|
||||||
|
rule = {}
|
||||||
|
if rule.get("blocked"):
|
||||||
|
issues.append(f"Dataset quality is blocked: {evidence.dataset_id}")
|
||||||
|
|
||||||
|
reference = self._datetime(as_of or datetime.now(self.timezone))
|
||||||
|
data_time = self._datetime(evidence.data_time)
|
||||||
|
observed_at = self._datetime(evidence.observed_at)
|
||||||
|
tolerance = float(
|
||||||
|
(self.defaults.get(usage) or {}).get("future_tolerance_seconds") or 0
|
||||||
|
)
|
||||||
|
if data_time > reference + timedelta(seconds=tolerance):
|
||||||
|
issues.append("Data time is later than the evaluation time")
|
||||||
|
if observed_at > reference + timedelta(seconds=tolerance):
|
||||||
|
issues.append("Observation time is later than the evaluation time")
|
||||||
|
if observed_at < data_time:
|
||||||
|
issues.append("Observation time precedes data time")
|
||||||
|
|
||||||
|
age_seconds = max(0.0, (reference - data_time).total_seconds())
|
||||||
|
freshness = rule.get("freshness_seconds")
|
||||||
|
if freshness is not None and age_seconds > float(freshness):
|
||||||
|
issues.append(
|
||||||
|
f"Data is stale: {age_seconds:.1f}s exceeds {float(freshness):.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
coverage_ratio: float | None = None
|
||||||
|
if evidence.expected_count is not None:
|
||||||
|
if evidence.expected_count <= 0:
|
||||||
|
issues.append("Expected count must be positive")
|
||||||
|
elif evidence.actual_count is None or evidence.actual_count < 0:
|
||||||
|
issues.append("Actual count is missing or invalid")
|
||||||
|
else:
|
||||||
|
coverage_ratio = min(1.0, evidence.actual_count / evidence.expected_count)
|
||||||
|
minimum = float(rule.get("min_coverage_ratio") or 0)
|
||||||
|
if coverage_ratio < minimum:
|
||||||
|
issues.append(
|
||||||
|
f"Coverage {coverage_ratio:.3f} is below {minimum:.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
required_adjustment = str(rule.get("adjustment") or "")
|
||||||
|
if required_adjustment and evidence.adjustment != required_adjustment:
|
||||||
|
issues.append(
|
||||||
|
f"Adjustment {evidence.adjustment or 'missing'} does not match {required_adjustment}"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_id = str(rule.get("unit_profile") or "none")
|
||||||
|
required_units = dict(self.unit_profiles.get(profile_id) or {})
|
||||||
|
supplied_units = evidence.units or {}
|
||||||
|
for field, expected_unit in required_units.items():
|
||||||
|
actual_unit = supplied_units.get(field)
|
||||||
|
if actual_unit != expected_unit:
|
||||||
|
issues.append(
|
||||||
|
f"Unit for {field} is {actual_unit or 'missing'}, expected {expected_unit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if rule.get("point_in_time") == "announcement_date" and usage == "calculation":
|
||||||
|
if evidence.available_at is None:
|
||||||
|
issues.append("Point-in-time availability is missing")
|
||||||
|
elif self._datetime(evidence.available_at) > reference:
|
||||||
|
issues.append("Point-in-time data was not available at evaluation time")
|
||||||
|
|
||||||
|
return QualityReport(
|
||||||
|
accepted=not issues,
|
||||||
|
dataset_id=evidence.dataset_id,
|
||||||
|
provider_id=evidence.provider_id,
|
||||||
|
usage=usage,
|
||||||
|
coverage_ratio=coverage_ratio,
|
||||||
|
age_seconds=age_seconds,
|
||||||
|
issues=tuple(issues),
|
||||||
|
)
|
||||||
|
|
||||||
|
def require(
|
||||||
|
self,
|
||||||
|
evidence: QualityEvidence,
|
||||||
|
usage: DataUsage,
|
||||||
|
as_of: str | datetime | None = None,
|
||||||
|
) -> QualityReport:
|
||||||
|
report = self.evaluate(evidence, usage, as_of)
|
||||||
|
if not report.accepted:
|
||||||
|
raise DataQualityError("; ".join(report.issues))
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _datetime(self, value: str | datetime) -> datetime:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
parsed = value
|
||||||
|
else:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise DataQualityError("Quality evidence timestamp is missing")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
day = date.fromisoformat(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise DataQualityError(f"Invalid quality timestamp: {text}") from exc
|
||||||
|
parsed = datetime.combine(day, time.min)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=self.timezone)
|
||||||
|
return parsed.astimezone(self.timezone)
|
||||||
@@ -9,6 +9,8 @@ These registries describe the approved product surface during architecture migra
|
|||||||
assigned to a feature owner.
|
assigned to a feature owner.
|
||||||
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
|
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
|
||||||
known blocked datasets.
|
known blocked datasets.
|
||||||
|
- `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and
|
||||||
|
fail-closed rules for every canonical data product.
|
||||||
|
|
||||||
During Stage 04 these files are contract inputs, not runtime replacements. Backend access in
|
During Stage 04 these files are contract inputs, not runtime replacements. Backend access in
|
||||||
`api_access.py` remains authoritative until the HTTP governance phase switches it atomically.
|
`api_access.py` remains authoritative until the HTTP governance phase switches it atomically.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"defaults": {
|
||||||
|
"calculation": {"missing_policy": "fail_closed", "provenance_required": true, "future_tolerance_seconds": 5},
|
||||||
|
"display": {"missing_policy": "unavailable", "provenance_required": true, "future_tolerance_seconds": 5}
|
||||||
|
},
|
||||||
|
"unit_profiles": {
|
||||||
|
"none": {},
|
||||||
|
"calendar": {"trade_date": "date", "is_open": "boolean", "previous_open_date": "date"},
|
||||||
|
"master": {"list_date": "date"},
|
||||||
|
"daily_ohlcv": {"open": "CNY/share", "high": "CNY/share", "low": "CNY/share", "close": "CNY/share", "pct_chg": "percent", "volume_shares": "share", "amount_yuan": "CNY"},
|
||||||
|
"valuation": {"turnover_rate_pct": "percent", "volume_ratio": "ratio", "total_mv_10k_yuan": "10k CNY", "circ_mv_10k_yuan": "10k CNY", "pe_ttm": "ratio", "pb": "ratio", "ps_ttm": "ratio", "dv_ttm_pct": "percent"},
|
||||||
|
"fundamental": {"roe_pct": "percent", "roa_pct": "percent", "roic_pct": "percent", "gross_margin_pct": "percent", "net_profit_yoy_pct": "percent", "revenue_yoy_pct": "percent", "operating_cashflow_quality": "ratio"},
|
||||||
|
"moneyflow": {"small_net_yuan": "CNY", "medium_net_yuan": "CNY", "large_net_yuan": "CNY", "extra_large_net_yuan": "CNY", "total_net_yuan": "CNY"},
|
||||||
|
"industry": {"pct_chg": "percent", "turnover_rate_pct": "percent"},
|
||||||
|
"limit_event": {"first_time": "datetime", "last_time": "datetime", "open_times": "count", "consecutive_boards": "count"},
|
||||||
|
"auction": {"price": "CNY/share", "volume_shares": "share", "amount_yuan": "CNY", "pre_close": "CNY/share", "turnover_rate_pct": "percent", "volume_ratio": "ratio", "float_share": "share"},
|
||||||
|
"popularity": {"ths_rank": "rank", "dc_rank": "rank", "rank_change": "rank", "dual_source": "boolean"},
|
||||||
|
"dragon_tiger": {"buy_yuan": "CNY", "sell_yuan": "CNY", "net_buy_yuan": "CNY"},
|
||||||
|
"intraday": {"quote_time": "datetime", "open": "CNY/share", "high": "CNY/share", "low": "CNY/share", "close": "CNY/share", "avg_price": "CNY/share", "volume_shares": "share", "amount_yuan": "CNY"},
|
||||||
|
"realtime_index": {"quote_time": "datetime", "price": "CNY", "pct_chg": "percent", "amount_yuan": "CNY"},
|
||||||
|
"sentiment": {"temperature": "score", "confidence": "percent"}
|
||||||
|
},
|
||||||
|
"datasets": {
|
||||||
|
"market.trade_calendar": {"unit_profile": "calendar", "min_coverage_ratio": 1.0},
|
||||||
|
"market.stock_master": {"unit_profile": "master", "min_coverage_ratio": 0.98},
|
||||||
|
"market.stock_daily": {"unit_profile": "daily_ohlcv", "min_coverage_ratio": 0.98, "adjustment": "current-unadjusted"},
|
||||||
|
"market.daily_valuation": {"unit_profile": "valuation", "min_coverage_ratio": 0.95},
|
||||||
|
"market.fundamentals": {"unit_profile": "fundamental", "min_coverage_ratio": 0.90, "point_in_time": "announcement_date"},
|
||||||
|
"market.moneyflow": {"unit_profile": "moneyflow", "min_coverage_ratio": 0.90},
|
||||||
|
"market.industry_sw": {"unit_profile": "industry", "min_coverage_ratio": 0.95},
|
||||||
|
"market.limit_events": {"unit_profile": "limit_event", "min_coverage_ratio": 1.0},
|
||||||
|
"market.auction_close": {"unit_profile": "auction", "min_coverage_ratio": 0.90},
|
||||||
|
"market.auction_dynamic": {"unit_profile": "auction", "min_coverage_ratio": 0.80, "freshness_seconds": 10},
|
||||||
|
"market.popularity": {"unit_profile": "popularity", "min_coverage_ratio": 0.95},
|
||||||
|
"market.dragon_tiger": {"unit_profile": "dragon_tiger", "min_coverage_ratio": 0.95},
|
||||||
|
"chart.stock_daily": {"unit_profile": "daily_ohlcv", "min_coverage_ratio": 1.0, "adjustment": "forward1"},
|
||||||
|
"chart.intraday": {"unit_profile": "intraday", "min_coverage_ratio": 1.0, "freshness_seconds": 30},
|
||||||
|
"observation.realtime_indices": {"unit_profile": "realtime_index", "min_coverage_ratio": 1.0, "freshness_seconds": 90},
|
||||||
|
"derived.sentiment": {"unit_profile": "sentiment", "min_coverage_ratio": 1.0},
|
||||||
|
"research.consensus": {"unit_profile": "none", "min_coverage_ratio": 0.0, "blocked": true},
|
||||||
|
"market.level2": {"unit_profile": "none", "min_coverage_ratio": 0.0, "blocked": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Stage 07: Data Quality and Provenance Gates
|
||||||
|
|
||||||
|
Date: 2026-07-29
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- Added a quality rule for every registered data product.
|
||||||
|
- Added canonical Asia/Shanghai timestamp handling.
|
||||||
|
- Added source and usage verification before quality acceptance.
|
||||||
|
- Added maximum-age checks for realtime auction, intraday charts, and realtime indices.
|
||||||
|
- Added minimum coverage thresholds for deterministic datasets.
|
||||||
|
- Added unit profiles for prices, shares, currency, percentages, ratios, ranks, and timestamps.
|
||||||
|
- Added adjustment checks that distinguish current unadjusted deterministic bars from iFinD
|
||||||
|
forward-adjusted display charts.
|
||||||
|
- Added announcement-date point-in-time checks for financial data.
|
||||||
|
- Added explicit provider chains; a display fallback cannot silently become a calculation
|
||||||
|
source.
|
||||||
|
- Analyst consensus and Level-2 remain blocked until a qualified provider is registered.
|
||||||
|
|
||||||
|
## Fail-Closed Contract
|
||||||
|
|
||||||
|
A calculation evidence envelope is rejected when its source is unauthorized, its dataset is
|
||||||
|
blocked, its timestamp is from the future, its realtime data is stale, its coverage is below
|
||||||
|
the registered threshold, its units or adjustment mode differ, or its financial record was not
|
||||||
|
available at the evaluation time.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Existing legacy provider response shapes remain unchanged in this stage. New governed feature
|
||||||
|
paths must submit quality evidence through `DataGateway.require_quality`. Existing feature
|
||||||
|
paths receive envelopes as they migrate behind domain services, avoiding a simultaneous
|
||||||
|
rewrite of calculations and provider routing.
|
||||||
+104
-1
@@ -1,8 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from backend.data import DataPolicyError, DataSourcePolicy, build_data_gateway
|
from backend.data import (
|
||||||
|
DataPolicyError,
|
||||||
|
DataQualityError,
|
||||||
|
DataSourcePolicy,
|
||||||
|
QualityEvidence,
|
||||||
|
build_data_gateway,
|
||||||
|
)
|
||||||
|
from backend.data.quality import market_timezone
|
||||||
|
|
||||||
|
|
||||||
class DataGatewayTests(unittest.TestCase):
|
class DataGatewayTests(unittest.TestCase):
|
||||||
@@ -43,6 +51,101 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
||||||
self.assertIn("return gateway.tushare()", source)
|
self.assertIn("return gateway.tushare()", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class GovernanceRegistryTests(unittest.TestCase):
|
|||||||
self.pages = load("pages.config.json")
|
self.pages = load("pages.config.json")
|
||||||
self.api = load("api.config.json")
|
self.api = load("api.config.json")
|
||||||
self.data = load("data-fields.config.json")
|
self.data = load("data-fields.config.json")
|
||||||
|
self.quality = load("data-quality.config.json")
|
||||||
|
|
||||||
def test_features_are_unique_and_use_declared_roles(self) -> None:
|
def test_features_are_unique_and_use_declared_roles(self) -> None:
|
||||||
roles = set(self.features["roles"])
|
roles = set(self.features["roles"])
|
||||||
@@ -82,6 +83,15 @@ class GovernanceRegistryTests(unittest.TestCase):
|
|||||||
self.assertEqual(dataset["usage"], "blocked")
|
self.assertEqual(dataset["usage"], "blocked")
|
||||||
self.assertEqual(len(ids), len(set(ids)))
|
self.assertEqual(len(ids), len(set(ids)))
|
||||||
|
|
||||||
|
def test_every_dataset_has_one_quality_rule_and_known_unit_profile(self) -> None:
|
||||||
|
dataset_ids = {item["id"] for item in self.data["datasets"]}
|
||||||
|
rules = self.quality["datasets"]
|
||||||
|
self.assertEqual(set(rules), dataset_ids)
|
||||||
|
profiles = set(self.quality["unit_profiles"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(rule.get("unit_profile") or "none") in profiles for rule in rules.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user