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