from __future__ import annotations import html import json import re import urllib.parse from datetime import date, datetime, time as dt_time, timedelta, timezone from typing import Any from zoneinfo import ZoneInfo from .cache import BlobIntegrityError from .http_client import HttpClient, HttpRequest def _decode_json(response: dict[str, Any]) -> dict[str, Any]: return json.loads(response["body"].decode("utf-8-sig")) def _secid(ticker: str) -> str: code, market = ticker.split(".") return f"{1 if market == 'SH' else 0}.{code}" def _market_name(ticker: str) -> str: return {"SZ": "深圳证券交易所", "SH": "上海证券交易所", "BJ": "北京证券交易所"}[ticker[-2:]] def _result(provider_id: str, as_of: str, status: str = "OK") -> dict[str, Any]: return { "provider_id": provider_id, "adapter_version": "1.0.0", "status": status, "fetched_at": datetime.now().astimezone().isoformat(), "as_of_date": as_of, "records": [], "sources": [], "gaps": [], "warnings": [], "raw_artifact_hashes": [], "request_telemetry": [], "field_lineage": {}, "data_kinds": {}, "baseline_data_kinds": [], "cache_integrity_failure": False, } def _capture(result: dict[str, Any], response: dict[str, Any]) -> None: meta = response["meta"] result["raw_artifact_hashes"].append(meta["blob_hash"]) if response.get("from_baseline"): result["baseline_data_kinds"].append(meta["data_kind"]) return result["request_telemetry"].append( { "fingerprint": response["fingerprint"], "raw_hash": meta["blob_hash"], "bytes": meta["bytes"], "http_status": meta["http_status"], "from_cache": response["from_cache"], "started_at": meta.get("started_at"), "finished_at": meta.get("finished_at"), "data_kind": meta.get("data_kind"), "remaining_before": meta.get("remaining_before"), "remaining_after": meta.get("remaining_after"), "attempts": meta.get("attempts", []), } ) def _kind_entry( *, request: HttpRequest, response: dict[str, Any], ticker: str, as_of: str, data_date: str, publish_date: str | None, watermark: Any, requires_publish_date: bool = False, **extra: Any, ) -> dict[str, Any]: meta = response["meta"] return { "data_kind": request.data_kind, "provider_id": request.provider_id, "request_fingerprint": response["fingerprint"], "ticker": ticker, "requested_as_of": as_of, "baseline_as_of": as_of, "data_date": data_date, "publish_date": publish_date, "requires_publish_date": requires_publish_date, "fetched_at": meta["fetched_at"], "expires_at": meta["expires_at"], "adapter_version": request.adapter_version, "raw_hash": meta["blob_hash"], "schema_status": "PASS", "as_of_status": "PASS", "semantic_status": "OK", "watermark": watermark, **extra, } def _lineage( source_id: str, raw_hash: str, publish_date: str, data_date: str, provider: str, raw_field: str, unit: str, ) -> dict[str, Any]: return { "source_id": source_id, "raw_hash": raw_hash, "publish_date": publish_date, "data_date": data_date, "provider": provider, "raw_field": raw_field, "unit": unit, } def acquire_announcements(client: HttpClient, ticker: str, as_of: str) -> dict[str, Any]: result = _result("cninfo.announcement_index", as_of) code, market = ticker.split(".") if market == "BJ": raise ValueError("CNInfo 首期 registry 不支持 BJ 公告索引") recent_as_of = (client.process_start.date() - date.fromisoformat(as_of)).days <= 7 stock_request = HttpRequest( "cninfo.announcement_index", "1.0.0", "stock_identity", "GET", "https://www.cninfo.com.cn/new/data/szse_stock.json", ticker, as_of, "cninfo_stock_list", ttl_seconds=7 * 86400, ) stock = client.fetch(stock_request) _capture(result, stock) stock_payload = _decode_json(stock) rows = stock_payload.get("stockList") or stock_payload.get("data") or [] identity = next( ( row for row in rows if str(row.get("code") or row.get("secCode") or row.get("dm")) == code ), None, ) if not identity: raise ValueError(f"CNInfo 无证券记录:{ticker}") client.confirm_reusable(stock_request, stock) org_id = str(identity.get("orgId") or identity.get("orgid") or identity.get("id")) company = str(identity.get("zwjc") or identity.get("name") or identity.get("secName")) plate = "sz" if market == "SZ" else "sh" form = { "pageNum": "1", "pageSize": "30", "column": "szse" if market == "SZ" else "sse", "tabName": "fulltext", "plate": plate, "stock": f"{code},{org_id}", "searchkey": "", "secid": "", "category": "category_ndbg_szsh;category_yjdbg_szsh;category_bndbg_szsh;category_sjdbg_szsh", "trade": "", "seDate": f"2020-01-01~{as_of}", "sortName": "", "sortType": "", "isHLtitle": "true", } body = urllib.parse.urlencode(form).encode("utf-8") announcement_request = HttpRequest( "cninfo.announcement_index", "1.0.0", "announcement_index", "POST", "https://www.cninfo.com.cn/new/hisAnnouncement/query", ticker, as_of, "cninfo_announcements", body=body, content_type="application/x-www-form-urlencoded", ttl_seconds=6 * 3600 if recent_as_of else 30 * 86400, ) announcements = client.fetch(announcement_request) _capture(result, announcements) payload = _decode_json(announcements) result["identity"] = { "ticker": ticker, "company": company, "market": _market_name(ticker), "org_id": org_id, } fixture_sources = payload.get("v2_sources") if fixture_sources is not None: if not fixture_sources: raise ValueError("CNInfo fixture A1 索引为空") for source in fixture_sources: if not source.get("publish_date") or source["publish_date"] > as_of: raise ValueError("E_ASOF_VIOLATION:CNInfo fixture 发布日缺失或未来") result["sources"] = fixture_sources result["records"] = payload.get("announcements", []) client.confirm_reusable(announcement_request, announcements) max_publish = max(source["publish_date"] for source in fixture_sources) result["data_kinds"] = { "stock_identity": _kind_entry( request=stock_request, response=stock, ticker=ticker, as_of=as_of, data_date=as_of, publish_date=None, watermark={"code": code, "market": market, "org_id": org_id}, ), "announcement_index": _kind_entry( request=announcement_request, response=announcements, ticker=ticker, as_of=as_of, data_date=max_publish, publish_date=max_publish, watermark=max_publish, requires_publish_date=True, ), } return result cutoff = datetime.combine( date.fromisoformat(as_of), dt_time.max, tzinfo=ZoneInfo("Asia/Shanghai") ) for row in payload.get("announcements", []): raw_time = row.get("announcementTime") if isinstance(raw_time, (int, float)): published = datetime.fromtimestamp(raw_time / 1000, timezone.utc).astimezone( ZoneInfo("Asia/Shanghai") ) else: published = datetime.fromisoformat(str(raw_time).replace("Z", "+00:00")) if published.tzinfo is None: published = published.replace(tzinfo=ZoneInfo("Asia/Shanghai")) else: published = published.astimezone(ZoneInfo("Asia/Shanghai")) if published > cutoff: continue url = "https://static.cninfo.com.cn/" + str(row.get("adjunctUrl", "")).lstrip("/") source_id = f"CNINFO-{row.get('announcementId')}" record = { "source_id": source_id, "announcement_id": str(row.get("announcementId")), "title": html.unescape(re.sub("<[^>]+>", "", str(row.get("announcementTitle", "")))), "publish_date": published.date().isoformat(), "url": url, } result["records"].append(record) period_end = None supports = ["statutory_disclosure"] normalized_title = record["title"].replace(" ", "") annual_match = re.search(r"(\d{4})年年度报告", normalized_title) q1_match = re.search(r"(\d{4})年(?:第一|一)季度报告", normalized_title) if annual_match: period_end = f"{annual_match.group(1)}-12-31" supports = ["financials.annual", "financials.prior_year_same_period"] elif q1_match: period_end = f"{q1_match.group(1)}-03-31" supports = [ "financials.current_cumulative", "financials.prior_year_same_period_comparative", "balance_sheet", ] result["sources"].append( { "id": source_id, "source_type": "cninfo", "title": record["title"], "publish_date": record["publish_date"], "period_end": period_end, "url": url, "supports": supports, "revision_status": "current", } ) if not result["records"]: raise ValueError("CNInfo 公告索引为空") client.confirm_reusable(announcement_request, announcements) max_publish = max(source["publish_date"] for source in result["sources"]) result["data_kinds"] = { "stock_identity": _kind_entry( request=stock_request, response=stock, ticker=ticker, as_of=as_of, data_date=as_of, publish_date=None, watermark={"code": code, "market": market, "org_id": org_id}, ), "announcement_index": _kind_entry( request=announcement_request, response=announcements, ticker=ticker, as_of=as_of, data_date=max_publish, publish_date=max_publish, watermark=max_publish, requires_publish_date=True, ), } return result def acquire_market(client: HttpClient, ticker: str, as_of: str) -> dict[str, Any]: result = _result("eastmoney.market", as_of) quote_request = HttpRequest( "eastmoney.market", "1.0.0", "shares_market_cap", "GET", "https://push2.eastmoney.com/api/qt/stock/get", ticker, as_of, "eastmoney_market_quote", query={"secid": _secid(ticker), "fields": "f57,f58,f84,f116,f124"}, ttl_seconds=900, ) quote = client.fetch(quote_request) kline_request = HttpRequest( "eastmoney.market", "1.0.0", "market_close", "GET", "https://push2his.eastmoney.com/api/qt/stock/kline/get", ticker, as_of, "eastmoney_market_kline", query={ "secid": _secid(ticker), "klt": "101", "fqt": "1", "beg": (date.fromisoformat(as_of) - timedelta(days=14)).strftime("%Y%m%d"), "end": as_of.replace("-", ""), "fields1": "f1,f2,f3,f4,f5,f6", "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61", }, ttl_seconds=30 * 86400, ) kline = client.fetch(kline_request) _capture(result, quote) _capture(result, kline) qdata = _decode_json(quote).get("data") or {} kdata = _decode_json(kline).get("data") or {} klines = kdata.get("klines") or [] if not klines: raise ValueError("无 as-of 历史收盘") eligible = [item for item in klines if str(item).split(",", 1)[0] <= as_of] if not eligible: raise ValueError("无不晚于 as-of 的历史收盘") fields = str(max(eligible, key=lambda item: str(item).split(",", 1)[0])).split(",") trade_date, close = fields[0], float(fields[2]) quote_epoch = int(qdata.get("f124") or 0) if quote_epoch <= 0: raise ValueError("E_HISTORICAL_SHARES_UNPROVEN:quote f124 缺失或为 0") quote_time = datetime.fromtimestamp( quote_epoch, ZoneInfo("Asia/Shanghai") ) quote_date = quote_time.date().isoformat() process_local_date = client.process_start.astimezone(ZoneInfo("Asia/Shanghai")).date().isoformat() if quote_date != trade_date: raise ValueError("E_HISTORICAL_SHARES_UNPROVEN:股本时间与收盘交易日不一致") if client.fixture_dir is None and as_of != process_local_date: raise ValueError("E_HISTORICAL_SHARES_UNPROVEN:历史 as-of 无同日可信 baseline") shares = int(qdata["f84"]) market_cap = float(qdata["f116"]) if shares <= 0 or close <= 0 or market_cap <= 0: raise ValueError("行情核心字段非正数") if abs(close * shares - market_cap) > max(1.0, market_cap * 0.005): raise ValueError("price×shares 与平台市值不一致") client.confirm_reusable(quote_request, quote) client.confirm_reusable(kline_request, kline) result["market"] = { "price": close, "price_type": "收盘价", "price_timestamp": f"{trade_date}T15:00:00+08:00", "diluted_shares": shares, "shares_date": quote_date, "platform_market_cap": market_cap, } price_source_id = f"SRC-MARKET-CLOSE-{trade_date.replace('-', '')}" shares_source_id = f"SRC-SHARES-{quote_date.replace('-', '')}" result["sources"] = [ { "id": price_source_id, "source_type": "quote_provider", "title": "东方财富历史行情接口", "publish_date": trade_date, "period_end": trade_date, "url": f"https://push2his.eastmoney.com/api/qt/stock/kline/get?secid={_secid(ticker)}", "supports": ["market.price"], "revision_status": "current", }, { "id": shares_source_id, "source_type": "quote_provider", "title": "东方财富股本与总市值接口", "publish_date": quote_date, "period_end": quote_date, "url": f"https://push2.eastmoney.com/api/qt/stock/get?secid={_secid(ticker)}", "supports": ["market.diluted_shares", "market.platform_market_cap"], "revision_status": "current", }, ] result["field_lineage"] = { "market.price": _lineage( price_source_id, kline["meta"]["blob_hash"], trade_date, trade_date, "eastmoney.market", "data.klines[].f53", "CNY/share", ), "market.diluted_shares": _lineage( shares_source_id, quote["meta"]["blob_hash"], quote_date, quote_date, "eastmoney.market", "data.f84", "share", ), "market.platform_market_cap": _lineage( shares_source_id, quote["meta"]["blob_hash"], quote_date, quote_date, "eastmoney.market", "data.f116", "CNY", ), } result["data_kinds"] = { "market_close": _kind_entry( request=kline_request, response=kline, ticker=ticker, as_of=as_of, data_date=trade_date, publish_date=trade_date, watermark=trade_date, requires_publish_date=True, is_latest_eligible_trade_date=True, ), "shares_market_cap": _kind_entry( request=quote_request, response=quote, ticker=ticker, as_of=as_of, data_date=quote_date, publish_date=quote_date, watermark=quote_time.isoformat(), requires_publish_date=True, historical_capture_valid=(client.fixture_dir is not None or as_of == process_local_date), ), } return result FINANCE_REPORTS = { "main": "RPT_F10_FINANCE_MAINFINADATA", "income": "RPT_DMSK_FN_INCOME", "balance": "RPT_F10_FINANCE_GBALANCE", "cashflow": "RPT_DMSK_FN_CASHFLOW", } def _finance_query(ticker: str, report_name: str) -> dict[str, str]: return { "reportName": report_name, "columns": "ALL", "filter": f'(SECUCODE="{ticker}")', "pageNumber": "1", "pageSize": "20", "sortTypes": "-1", "sortColumns": "REPORT_DATE", } def _records(payload: dict[str, Any]) -> list[dict[str, Any]]: return list(((payload.get("result") or {}).get("data") or payload.get("data") or [])) def _pick(rows: list[dict[str, Any]], period: str, as_of: str) -> dict[str, Any]: candidates = [ row for row in rows if str(row.get("REPORT_DATE", ""))[:10] == period and bool(row.get("NOTICE_DATE") or row.get("UPDATE_DATE")) and str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE"))[:10] <= as_of ] if not candidates: raise ValueError(f"缺少财务期间 {period}") return max(candidates, key=lambda row: str(row.get("NOTICE_DATE") or "")) def _select_ttm_periods(rows: list[dict[str, Any]], as_of: str) -> tuple[str, str, str]: available = sorted( { str(row.get("REPORT_DATE", ""))[:10] for row in rows if str(row.get("REPORT_DATE", ""))[:10] <= as_of and bool(row.get("NOTICE_DATE") or row.get("UPDATE_DATE")) and str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE"))[:10] <= as_of } ) annuals = [period for period in available if period.endswith("-12-31")] if not annuals: raise ValueError("缺少 as-of 前完整年度财务") annual = max(annuals) cumulative = [period for period in available if period > annual and not period.endswith("-12-31")] if not cumulative: raise ValueError("缺少完整年度之后的最新累计期") current = max(cumulative) current_date = date.fromisoformat(current) prior = current_date.replace(year=current_date.year - 1).isoformat() if prior not in available: raise ValueError(f"缺少上年同期 {prior}") return annual, current, prior def _number(row: dict[str, Any], *keys: str) -> float: for key in keys: value = row.get(key) if value is not None and value != "": return float(value) raise ValueError(f"缺少字段 {'/'.join(keys)}") LIQUID_FV_ALIAS_KEYS = ( "TRADE_FINASSET_NOTFVTPL", "TRADE_FINASSET", "FVTPL_FINASSET", "APPOINT_FVTPL_FINASSET", "AVAILABLE_SALE_FINASSET", ) FINANCIAL_SINGLE_KEYS = ("DERIVE_FINASSET", "BUY_RESALE_FINASSET") DEBT_KEYS = ( "SHORT_LOAN", "NONCURRENT_LIAB_1YEAR", "LONG_LOAN", "BOND_PAYABLE", "LEASE_LIAB", "SHORT_BOND_PAYABLE", ) def _require_keys(row: dict[str, Any], keys: tuple[str, ...]) -> None: missing = [key for key in keys if key not in row] if missing: raise ValueError(f"E_BALANCE_SCHEMA_DRIFT:缺少键 {','.join(missing)}") def _nonnegative(value: Any, key: str) -> float: if value is None or value == "": return 0.0 number = float(value) if number < 0: raise ValueError(f"E_BALANCE_SCHEMA_DRIFT:{key} 为负") return number def parse_balance_record(row: dict[str, Any]) -> dict[str, float]: _require_keys(row, LIQUID_FV_ALIAS_KEYS + FINANCIAL_SINGLE_KEYS + DEBT_KEYS + ("MINORITY_EQUITY",)) alias_values = [ _nonnegative(row[key], key) for key in LIQUID_FV_ALIAS_KEYS if row[key] is not None and row[key] != "" ] distinct = set(alias_values) if len(distinct) > 1: raise ValueError("E_BALANCE_ALIAS_CONFLICT:流动公允价值金融资产 alias 数值冲突") liquid_fv = alias_values[0] if alias_values else 0.0 financial_assets = liquid_fv + sum( _nonnegative(row[key], key) for key in FINANCIAL_SINGLE_KEYS ) debt = sum(_nonnegative(row[key], key) for key in DEBT_KEYS) minority = _nonnegative(row["MINORITY_EQUITY"], "MINORITY_EQUITY") return { "non_operating_financial_assets": financial_assets, "interest_bearing_debt": debt, "minority_interest": minority, } def acquire_finance(client: HttpClient, ticker: str, as_of: str) -> dict[str, Any]: result = _result("eastmoney.finance", as_of) recent_as_of = (client.process_start.date() - date.fromisoformat(as_of)).days <= 7 payloads: dict[str, dict[str, Any]] = {} responses: dict[str, tuple[HttpRequest, dict[str, Any]]] = {} for kind, report in FINANCE_REPORTS.items(): request = HttpRequest( "eastmoney.finance", "1.0.0", f"finance_{kind}", "GET", "https://datacenter-web.eastmoney.com/api/data/v1/get", ticker, as_of, f"eastmoney_finance_{kind}", query=_finance_query(ticker, report), ttl_seconds=6 * 3600 if recent_as_of else 30 * 86400, ) response = client.fetch(request) _capture(result, response) payloads[kind] = _decode_json(response) responses[kind] = (request, response) income = _records(payloads["income"]) cashflow = _records(payloads["cashflow"]) balance = _records(payloads["balance"]) main = _records(payloads["main"]) periods = _select_ttm_periods(income or main, as_of) labels = ("annual", "current_cumulative", "prior_year_same_period") financials: dict[str, Any] = {} selected: dict[str, dict[str, dict[str, Any]]] = {} for period, label in zip(periods, labels): inc = _pick(income or main, period, as_of) cash = _pick(cashflow, period, as_of) selected[label] = {"income": inc, "cashflow": cash} financials[label] = { "period_end": period, "basis": { "annual": "audited", "current_cumulative": "quarterly_report_unaudited", "prior_year_same_period": "reported_comparative", }[label], "revenue": _number(inc, "TOTAL_OPERATE_INCOME", "TOTALOPERATEREVE"), "attributable_profit": _number(inc, "PARENT_NETPROFIT", "PARENTNETPROFIT"), "deduct_profit": _number(inc, "DEDUCT_PARENT_NETPROFIT", "KCFJCXSYJLR"), "cfo": _number(cash, "NETCASH_OPERATE"), "capex": _number(cash, "CONSTRUCT_LONG_ASSET"), } bal = _pick(balance, periods[1], as_of) parsed_balance = parse_balance_record(bal) result["financials"] = financials result["balance_sheet"] = { "period_end": periods[1], "equity": _number(bal, "TOTAL_EQUITY", "TOTAL_EQUITY_PARENT"), "cash_available": _number(bal, "MONETARYFUNDS"), **parsed_balance, } selected["balance_sheet"] = {"balance": bal} raw_by_kind = { kind: response["meta"]["blob_hash"] for kind, (_, response) in responses.items() } source_ids: dict[tuple[str, str], str] = {} for label, tables in selected.items(): for table, row in tables.items(): period = str(row["REPORT_DATE"])[:10] publish = str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE"))[:10] source_id = f"SRC-EM-FINANCE-{table.upper()}-{period.replace('-', '')}" source_ids[(label, table)] = source_id result["sources"].append( { "id": source_id, "source_type": "financial_mirror", "title": f"东方财富结构化财务 {table} {period}", "publish_date": publish, "period_end": period, "url": "https://datacenter-web.eastmoney.com/api/data/v1/get", "supports": [f"financials.{label}" if label != "balance_sheet" else "balance_sheet"], "revision_status": "current", } ) for label in labels: period = financials[label]["period_end"] inc = selected[label]["income"] cash = selected[label]["cashflow"] inc_publish = str(inc.get("NOTICE_DATE") or inc.get("UPDATE_DATE"))[:10] cash_publish = str(cash.get("NOTICE_DATE") or cash.get("UPDATE_DATE"))[:10] for field, raw_field, unit in ( ("revenue", "TOTAL_OPERATE_INCOME|TOTALOPERATEREVE", "CNY"), ("attributable_profit", "PARENT_NETPROFIT|PARENTNETPROFIT", "CNY"), ("deduct_profit", "DEDUCT_PARENT_NETPROFIT|KCFJCXSYJLR", "CNY"), ): result["field_lineage"][f"financials.{label}.{field}"] = _lineage( source_ids[(label, "income")], raw_by_kind["income"], inc_publish, period, "eastmoney.finance", raw_field, unit, ) for field, raw_field in (("cfo", "NETCASH_OPERATE"), ("capex", "CONSTRUCT_LONG_ASSET")): result["field_lineage"][f"financials.{label}.{field}"] = _lineage( source_ids[(label, "cashflow")], raw_by_kind["cashflow"], cash_publish, period, "eastmoney.finance", raw_field, "CNY", ) if label == "prior_year_same_period": current_period = financials["current_cumulative"]["period_end"] current_publish = str( selected["current_cumulative"]["income"].get("NOTICE_DATE") or selected["current_cumulative"]["income"].get("UPDATE_DATE") )[:10] if inc_publish != current_publish or cash_publish != current_publish: raise ValueError( "E_COMPARATIVE_LINEAGE:上年同期行未与本期报告共享发布日期" ) relation = { "type": "same_response_comparative_row", "current_period_end": current_period, "comparison_period_end": period, "shared_publish_date": current_publish, } for field in ("revenue", "attributable_profit", "deduct_profit", "cfo", "capex"): result["field_lineage"][f"financials.{label}.{field}"][ "comparison_relation" ] = relation bal_publish = str(bal.get("NOTICE_DATE") or bal.get("UPDATE_DATE"))[:10] balance_fields = { "equity": "TOTAL_EQUITY|TOTAL_EQUITY_PARENT", "cash_available": "MONETARYFUNDS", "non_operating_financial_assets": "+".join(LIQUID_FV_ALIAS_KEYS + FINANCIAL_SINGLE_KEYS), "interest_bearing_debt": "+".join(DEBT_KEYS), "minority_interest": "MINORITY_EQUITY", } for field, raw_field in balance_fields.items(): result["field_lineage"][f"balance_sheet.{field}"] = _lineage( source_ids[("balance_sheet", "balance")], raw_by_kind["balance"], bal_publish, periods[1], "eastmoney.finance", raw_field, "CNY", ) ttl_suffix = "recent" if recent_as_of else "historical" required_periods = set(periods) for kind, rows_for_kind in (("main", main), ("income", income), ("balance", balance), ("cashflow", cashflow)): request, response = responses[kind] eligible_rows = [ row for row in rows_for_kind if row.get("REPORT_DATE") and (row.get("NOTICE_DATE") or row.get("UPDATE_DATE")) and str(row["REPORT_DATE"])[:10] <= as_of and str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE"))[:10] <= as_of ] max_row = max( eligible_rows, key=lambda row: ( str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE"))[:10], str(row["REPORT_DATE"])[:10], ), ) present_periods = {str(row["REPORT_DATE"])[:10] for row in eligible_rows} complete = ( bool(present_periods & {periods[0]}) if kind == "main" else required_periods.issubset(present_periods) if kind in {"income", "cashflow"} else periods[1] in present_periods ) entry = _kind_entry( request=request, response=response, ticker=ticker, as_of=as_of, data_date=str(max_row["REPORT_DATE"])[:10], publish_date=str(max_row.get("NOTICE_DATE") or max_row.get("UPDATE_DATE"))[:10], watermark=[ str(max_row.get("NOTICE_DATE") or max_row.get("UPDATE_DATE"))[:10], str(max_row["REPORT_DATE"])[:10], ], requires_publish_date=True, required_periods_complete=complete, ttl_class=f"finance_{kind}_{ttl_suffix}", ) result["data_kinds"][f"finance_{kind}"] = entry for request, response in responses.values(): client.confirm_reusable(request, response) return result def acquire_forecast(client: HttpClient, ticker: str, as_of: str) -> dict[str, Any]: result = _result("eastmoney.forecast", as_of) code, market = ticker.split(".") summary_request = HttpRequest( "eastmoney.forecast_summary", "1.0.0", "forecast_summary", "GET", "https://datacenter-web.eastmoney.com/api/data/v1/get", ticker, as_of, "eastmoney_forecast_summary", query={ "reportName": "RPT_WEB_RESPREDICT", "columns": "ALL", "filter": f'(SECURITY_CODE="{code}")', "pageNumber": "1", "pageSize": "50", }, ttl_seconds=6 * 3600, ) summary = client.fetch(summary_request) _capture(result, summary) summary_payload = _decode_json(summary) rows = _records(summary_payload) eligible_summary = [] for row in rows: raw_date = row.get("REPORT_DATE") or row.get("UPDATE_DATE") if not raw_date: continue record_date = str(raw_date)[:10] if record_date <= as_of: eligible_summary.append(row) summary_count = int( (eligible_summary[0] if eligible_summary else {}).get("RATING_ORG_NUM") or summary_payload.get("summary_count") or 0 ) summary_date = max( (str(row.get("REPORT_DATE") or row.get("UPDATE_DATE"))[:10] for row in eligible_summary), default=None, ) forecasts: list[dict[str, Any]] = [] detail_error: str | None = None detail_pair: tuple[HttpRequest, dict[str, Any]] | None = None try: detail_request = HttpRequest( "eastmoney.forecast_detail", "1.0.0", "forecast_detail", "GET", "https://emweb.eastmoney.com/PC_HSF10/ProfitForecast/Index", ticker, as_of, "eastmoney_forecast_detail", query={"code": f"{market}{code}", "type": "web"}, content_type="text/html", ttl_seconds=6 * 3600, ) detail = client.fetch(detail_request) detail_pair = (detail_request, detail) _capture(result, detail) text = detail["body"].decode("utf-8", errors="replace") match = re.search(r'', text, re.S) if match: parsed = json.loads(html.unescape(match.group(1)))["forecasts"] invalid = [ item for item in parsed if not item.get("report_date") or str(item["report_date"])[:10] > as_of ] forecasts = [item for item in parsed if item not in invalid] if invalid: detail_error = "预测明细缺少真实报告日期或包含 as-of 之后记录" else: detail_error = "固定预测明细表未找到或 schema 漂移" except Exception as exc: # forecast is explicitly non-core detail_error = str(exc) result["cache_integrity_failure"] = isinstance(exc, BlobIntegrityError) result["institutions"] = {"coverage_status": "available" if forecasts else "gap", "forecasts": forecasts} if not summary_date or summary_count != len(forecasts) or detail_error: result["gaps"].append( { "gap_id": "W_FORECAST_COVERAGE", "provider": "eastmoney.forecast", "field": "institutions.forecasts", "reason": detail_error or f"汇总 {summary_count} 家、可见明细 {len(forecasts)} 家", "impact": "机构覆盖明细不完整,不影响法定财务计算", "blocking": False, "budget_used_seconds": None, "manual_action": "如需逐家核对,人工补充缺失机构原报告", } ) result["sources"] = [] if forecasts: detail_date = max(str(item["report_date"])[:10] for item in forecasts) result["sources"].append({ "id": "SRC-INSTITUTION-DETAIL", "source_type": "institution_aggregator", "title": "东方财富盈利预测明细", "publish_date": detail_date, "period_end": detail_date, "url": f"https://emweb.eastmoney.com/PC_HSF10/ProfitForecast/Index?code={market}{code}&type=web", "supports": ["institutions"], "revision_status": "current", }) else: detail_date = None if summary_date: result["sources"].append({ "id": "SRC-INSTITUTION-CONSENSUS", "source_type": "institution_aggregator", "title": "东方财富机构盈利预测汇总", "publish_date": summary_date, "period_end": summary_date, "url": "https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_WEB_RESPREDICT", "supports": ["institutions"], "revision_status": "current", }) if summary_count > 0: client.confirm_reusable(summary_request, summary) if forecasts and detail_pair: client.confirm_reusable(*detail_pair) result["data_kinds"]["forecast_summary"] = _kind_entry( request=summary_request, response=summary, ticker=ticker, as_of=as_of, data_date=summary_date or "9999-12-31", publish_date=summary_date, watermark=summary_date or "", requires_publish_date=True, summary_complete=bool(summary_date and summary_count > 0), ) if detail_pair: result["data_kinds"]["forecast_detail"] = _kind_entry( request=detail_pair[0], response=detail_pair[1], ticker=ticker, as_of=as_of, data_date=detail_date or "9999-12-31", publish_date=detail_date, watermark=detail_date or "", requires_publish_date=True, detail_complete=bool(forecasts and detail_date), ) return result