from __future__ import annotations from typing import Any from .cache import canonical_bytes, sha256_bytes class CoreInputError(ValueError): pass FINANCIAL_LABELS = ("annual", "current_cumulative", "prior_year_same_period") FINANCIAL_VALUES = ("revenue", "attributable_profit", "deduct_profit", "cfo", "capex") BALANCE_VALUES = ( "equity", "cash_available", "non_operating_financial_assets", "interest_bearing_debt", "minority_interest", ) CORE_VALUE_PATHS = ( "market.price", "market.diluted_shares", "market.platform_market_cap", *(f"financials.{label}.{field}" for label in FINANCIAL_LABELS for field in FINANCIAL_VALUES), *(f"balance_sheet.{field}" for field in BALANCE_VALUES), ) def protected_projection(snapshot: dict[str, Any]) -> dict[str, Any]: return { "meta": snapshot.get("meta"), "market": snapshot.get("market"), "financials": snapshot.get("financials"), "balance_sheet": snapshot.get("balance_sheet"), "sources": snapshot.get("sources"), } def protected_hash(snapshot: dict[str, Any]) -> str: return sha256_bytes(canonical_bytes(protected_projection(snapshot))) def _a1_for_period( sources: list[dict[str, Any]], period: str, support: str ) -> dict[str, Any] | None: candidates = [ source for source in sources if source.get("source_type") in {"cninfo", "szse", "sse", "bse", "archived_primary"} and source.get("period_end") == period and support in source.get("supports", []) and source.get("publish_date") and source.get("id") ] return max(candidates, key=lambda item: (item["publish_date"], item["id"])) if candidates else None def _require_lineage( path: str, lineage: dict[str, Any], source_by_id: dict[str, dict[str, Any]], all_raw: set[str], as_of: str, ) -> None: missing = [ key for key in ("source_id", "raw_hash", "publish_date", "data_date", "provider", "raw_field", "unit") if not lineage.get(key) ] if missing: raise CoreInputError(f"字段血缘缺项 {path}: {','.join(missing)}") if lineage["source_id"] not in source_by_id: raise CoreInputError(f"字段血缘 source_id 不存在 {path}: {lineage['source_id']}") structured = source_by_id[lineage["source_id"]] if structured.get("period_end") != lineage["data_date"]: raise CoreInputError(f"字段血缘 data_date/source period 不一致:{path}") if structured.get("publish_date") != lineage["publish_date"]: raise CoreInputError(f"字段血缘 publish_date/source 不一致:{path}") if not any( path == support or path.startswith(f"{support}.") for support in structured.get("supports", []) ): raise CoreInputError(f"字段血缘结构化来源不支持字段:{path}") if lineage["raw_hash"] not in all_raw or len(lineage["raw_hash"]) != 64: raise CoreInputError(f"字段血缘 raw hash 不存在 {path}") if lineage["publish_date"] > as_of or lineage["data_date"] > as_of: raise CoreInputError(f"E_ASOF_VIOLATION:{path}") def build_data_snapshot( ticker: str, as_of: str, providers: dict[str, dict[str, Any]] ) -> tuple[dict[str, Any], dict[str, Any]]: gaps = [gap for result in providers.values() for gap in result.get("gaps", [])] blocking = [gap for gap in gaps if gap.get("blocking")] if blocking: raise CoreInputError(";".join(gap["reason"] for gap in blocking)) for required_provider in ("announcements", "market", "finance", "forecast"): if required_provider not in providers: raise CoreInputError(f"缺少 provider:{required_provider}") announcements = providers["announcements"] market = providers["market"] finance = providers["finance"] forecast = providers["forecast"] for key, container in ( ("identity", announcements), ("market", market), ("financials", finance), ("balance_sheet", finance), ): if key not in container: raise CoreInputError(f"缺少核心字段组:{key}") sources: list[dict[str, Any]] = [] seen: set[str] = set() for result in providers.values(): for source in result.get("sources", []): source_id = source.get("id") if not source_id: raise CoreInputError("来源缺少 source_id") if not source.get("publish_date"): raise CoreInputError(f"来源缺少 publish_date:{source_id}") if source["publish_date"] > as_of: raise CoreInputError(f"E_ASOF_VIOLATION:{source_id}") if source.get("period_end") and source["period_end"] > as_of: raise CoreInputError(f"E_ASOF_VIOLATION:{source_id}") if source_id not in seen: seen.add(source_id) sources.append(source) source_by_id = {source["id"]: source for source in sources} all_raw = { digest for result in providers.values() for digest in result.get("raw_artifact_hashes", []) } financials = finance["financials"] current_period = financials["current_cumulative"]["period_end"] annual_period = financials["annual"]["period_end"] annual_a1 = _a1_for_period(sources, annual_period, "financials.annual") current_a1 = _a1_for_period(sources, current_period, "financials.current_cumulative") comparative_a1 = _a1_for_period( sources, current_period, "financials.prior_year_same_period_comparative" ) balance_a1 = _a1_for_period(sources, current_period, "balance_sheet") if annual_a1 is None or current_a1 is None or comparative_a1 is None or balance_a1 is None: missing = [] if annual_a1 is None: missing.append(annual_period) if current_a1 is None: missing.append(current_period) if comparative_a1 is None: missing.append(f"{current_period}:comparative") if balance_a1 is None: missing.append(f"{current_period}:balance") raise CoreInputError(f"核心财务缺少 A1 报告期回链:{missing}") announcement_hashes = announcements.get("raw_artifact_hashes", []) if not announcement_hashes: raise CoreInputError("A1 公告索引缺少 raw hash") a1_raw_hash = announcement_hashes[-1] field_lineage: dict[str, dict[str, Any]] = {} for result in providers.values(): for path, item in result.get("field_lineage", {}).items(): if path in field_lineage: raise CoreInputError(f"字段血缘重复:{path}") field_lineage[path] = dict(item) for path in CORE_VALUE_PATHS: if path not in field_lineage: raise CoreInputError(f"核心字段缺少血缘:{path}") _require_lineage(path, field_lineage[path], source_by_id, all_raw, as_of) if path.startswith("financials.") or path.startswith("balance_sheet."): if path.startswith("financials.annual."): a1 = annual_a1 a1_support = "financials.annual" elif path.startswith("financials.prior_year_same_period."): a1 = comparative_a1 a1_support = "financials.prior_year_same_period_comparative" relation = field_lineage[path].get("comparison_relation") expected_relation = { "type": "same_response_comparative_row", "current_period_end": current_period, "comparison_period_end": financials["prior_year_same_period"]["period_end"], "shared_publish_date": a1["publish_date"], } if relation != expected_relation: raise CoreInputError(f"上年同期比较关系无效:{path}") if field_lineage[path]["publish_date"] != a1["publish_date"]: raise CoreInputError(f"上年同期不是同一 A1 报告比较列:{path}") elif path.startswith("balance_sheet."): a1 = balance_a1 a1_support = "balance_sheet" else: a1 = current_a1 a1_support = "financials.current_cumulative" if a1_support not in a1.get("supports", []): raise CoreInputError(f"A1 未声明字段支持:{path}") field_lineage[path]["a1_source_id"] = a1["id"] field_lineage[path]["a1_publish_date"] = a1["publish_date"] field_lineage[path]["a1_period_end"] = a1["period_end"] field_lineage[path]["a1_raw_hash"] = a1_raw_hash field_lineage[path]["a1_support"] = a1_support if a1_raw_hash not in all_raw or a1["publish_date"] > as_of: raise CoreInputError(f"核心字段 A1 血缘无效:{path}") identity = announcements["identity"] snapshot = { "snapshot_id": f"SNAPSHOT-{ticker.split('.')[0]}-{as_of.replace('-', '')}-V2", "meta": { "company": identity["company"], "code": ticker, "market": identity["market"], "as_of_date": as_of, "currency": "CNY", "report_period_end": current_period, "latest_operating_info_date": max(source["publish_date"] for source in sources), }, "market": market["market"], "financials": financials, "balance_sheet": finance["balance_sheet"], "institutions": forecast.get("institutions", {"coverage_status": "gap", "forecasts": []}), "sources": sources, } report = { "schema_version": 2, "ticker": ticker, "as_of": as_of, "field_lineage": field_lineage, "raw_hashes": sorted(all_raw), "gaps": gaps, "as_of_safe": True, "mechanical_protected_sha256": protected_hash(snapshot), } return snapshot, report