#!/usr/bin/env python3
|
"""Mid-year point-in-time refresh for every active stock valuation.
|
|
This is a valuation-analyst task utility, not a second production service. It
|
uses the accepted MySQL ledger as the universe, Eastmoney's registered
|
structured financial mirror for report facts, and dated institution details
|
plus business identity from 10jqka. Raw responses remain in the valuation
|
temporary area; formal outputs are written by the later revalue/publish phase.
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
import csv
|
import hashlib
|
import json
|
import re
|
import subprocess
|
import sys
|
import threading
|
import time
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from datetime import datetime
|
from pathlib import Path
|
from typing import Any
|
|
import requests
|
from bs4 import BeautifulSoup
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
BATCH_ID = "BATCH-STOCK-VALUATION-MIDYEAR-FULL-REFRESH-20260826-001"
|
TMP_ROOT = ROOT / "ana-data/tmp/股票估值" / BATCH_ID
|
RAW_ROOT = TMP_ROOT / "raw"
|
CASE_ROOT = ROOT / "ana-data/cases/股票估值" / BATCH_ID
|
AS_OF = "2026-08-26"
|
PRICE_DATE = "2026-08-25"
|
FINANCE_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get"
|
THS_BASE = "https://basic.10jqka.com.cn"
|
FINANCE_REPORTS = {
|
"main": "RPT_F10_FINANCE_MAINFINADATA",
|
"income": "RPT_DMSK_FN_INCOME",
|
"balance": "RPT_F10_FINANCE_GBALANCE",
|
"cashflow": "RPT_DMSK_FN_CASHFLOW",
|
}
|
THREAD_STATE = threading.local()
|
|
|
def mysql_rows(sql: str) -> list[dict[str, str]]:
|
proc = subprocess.run(
|
[
|
"mysql",
|
"--login-path=ana_semi_admin_preflight",
|
"--default-character-set=utf8mb4",
|
"--batch",
|
"--raw",
|
"-e",
|
sql,
|
],
|
cwd=ROOT,
|
capture_output=True,
|
text=True,
|
encoding="utf-8",
|
errors="strict",
|
check=True,
|
)
|
lines = proc.stdout.splitlines()
|
if not lines:
|
return []
|
reader = csv.DictReader(lines, delimiter="\t")
|
return list(reader)
|
|
|
def universe() -> list[dict[str, Any]]:
|
sql = f"""
|
SELECT s.ticker,s.company,s.market,s.currency,
|
v.valuation_id,v.valuation_date,v.method,v.base_low,v.base_high,
|
v.optimistic_low,v.optimistic_high,v.normalized_profit,
|
v.normalized_pe,v.pb,v.ps,v.consensus_year,v.consensus_profit,
|
v.consensus_count,v.report_path,v.snapshot_path,v.source_hash,
|
p.close,p.trade_date,
|
JSON_UNQUOTE(JSON_EXTRACT(i.raw_json,'$.TotalVolume')) AS static_total_shares
|
FROM stock_valuation.security s
|
JOIN stock_valuation.valuation_version v
|
ON v.ticker=s.ticker AND v.active_to IS NULL
|
LEFT JOIN stock_valuation.daily_price p
|
ON p.ticker=s.ticker AND p.trade_date='{PRICE_DATE}'
|
LEFT JOIN trading_xuntou.cn_stock_instrument_static i
|
ON BINARY i.symbol=BINARY s.ticker
|
WHERE s.active=1
|
ORDER BY s.ticker
|
"""
|
rows = mysql_rows(sql)
|
for row in rows:
|
for key in (
|
"base_low", "base_high", "optimistic_low", "optimistic_high",
|
"normalized_profit", "normalized_pe", "pb", "ps",
|
"consensus_profit", "close", "static_total_shares",
|
):
|
value = row.get(key)
|
row[key] = float(value) if value not in (None, "", "NULL") else None
|
row["consensus_count"] = int(row["consensus_count"]) if row.get("consensus_count") not in (None, "", "NULL") else 0
|
return rows
|
|
|
def session() -> requests.Session:
|
value = getattr(THREAD_STATE, "session", None)
|
if value is None:
|
value = requests.Session()
|
value.headers.update(
|
{
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36",
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6",
|
}
|
)
|
THREAD_STATE.session = value
|
return value
|
|
|
def get(url: str, *, params: dict[str, str] | None = None, referer: str | None = None) -> bytes:
|
headers = {"Referer": referer} if referer else None
|
last: Exception | None = None
|
for attempt in range(3):
|
try:
|
response = session().get(url, params=params, headers=headers, timeout=(8, 25))
|
response.raise_for_status()
|
if not response.content:
|
raise RuntimeError("empty response")
|
return response.content
|
except Exception as exc:
|
last = exc
|
time.sleep(0.5 * (attempt + 1))
|
raise RuntimeError(f"GET failed: {url}: {last}")
|
|
|
def finance_payload(ticker: str, report_name: str) -> tuple[dict[str, Any], str]:
|
params = {
|
"reportName": report_name,
|
"columns": "ALL",
|
"filter": f'(SECUCODE="{ticker}")',
|
"pageNumber": "1",
|
"pageSize": "20",
|
"sortTypes": "-1",
|
"sortColumns": "REPORT_DATE",
|
}
|
body = get(FINANCE_URL, params=params)
|
payload = json.loads(body.decode("utf-8-sig"))
|
return payload, hashlib.sha256(body).hexdigest().upper()
|
|
|
def records(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
return list(((payload.get("result") or {}).get("data") or payload.get("data") or []))
|
|
|
def eligible(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
result = []
|
for row in rows:
|
report_date = str(row.get("REPORT_DATE") or "")[:10]
|
notice_date = str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE") or "")[:10]
|
if report_date and notice_date and report_date <= AS_OF and notice_date <= AS_OF:
|
result.append(row)
|
return result
|
|
|
def select_periods(income_rows: list[dict[str, Any]]) -> tuple[str, str, str]:
|
periods = sorted({str(row["REPORT_DATE"])[:10] for row in eligible(income_rows)})
|
annuals = [period for period in periods if period.endswith("-12-31")]
|
if not annuals:
|
raise RuntimeError("no annual report")
|
annual = annuals[-1]
|
current_candidates = [period for period in periods if period > annual and not period.endswith("-12-31")]
|
if not current_candidates:
|
raise RuntimeError("no current cumulative report")
|
current = current_candidates[-1]
|
prior = f"{int(current[:4]) - 1}{current[4:]}"
|
if prior not in periods:
|
raise RuntimeError(f"no comparative period {prior}")
|
return annual, current, prior
|
|
|
def pick(rows: list[dict[str, Any]], period: str) -> dict[str, Any]:
|
matches = [row for row in eligible(rows) if str(row["REPORT_DATE"])[:10] == period]
|
if not matches:
|
raise RuntimeError(f"missing {period}")
|
return max(matches, key=lambda row: str(row.get("NOTICE_DATE") or row.get("UPDATE_DATE") or ""))
|
|
|
def compact(row: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
return {key: row.get(key) for key in keys if key in row}
|
|
|
INCOME_KEYS = (
|
"REPORT_DATE", "NOTICE_DATE", "UPDATE_DATE", "SECURITY_CODE", "SECURITY_NAME_ABBR",
|
"TOTAL_OPERATE_INCOME", "TOTALOPERATEREVE", "PARENT_NETPROFIT", "PARENTNETPROFIT",
|
"DEDUCT_PARENT_NETPROFIT", "KCFJCXSYJLR", "BASIC_EPS", "DILUTED_EPS",
|
)
|
CASHFLOW_KEYS = ("REPORT_DATE", "NOTICE_DATE", "UPDATE_DATE", "NETCASH_OPERATE", "CONSTRUCT_LONG_ASSET")
|
BALANCE_KEYS = (
|
"REPORT_DATE", "NOTICE_DATE", "UPDATE_DATE", "TOTAL_EQUITY", "TOTAL_EQUITY_PARENT",
|
"MONETARYFUNDS", "MINORITY_EQUITY", "TOTAL_ASSETS", "TOTAL_LIABILITIES",
|
"SHORT_LOAN", "NONCURRENT_LIAB_1YEAR", "LONG_LOAN", "BOND_PAYABLE", "LEASE_LIAB",
|
"SHORT_BOND_PAYABLE", "TRADE_FINASSET_NOTFVTPL", "TRADE_FINASSET", "FVTPL_FINASSET",
|
"APPOINT_FVTPL_FINASSET", "AVAILABLE_SALE_FINASSET", "DERIVE_FINASSET", "BUY_RESALE_FINASSET",
|
)
|
MAIN_KEYS = (
|
"REPORT_DATE", "NOTICE_DATE", "UPDATE_DATE", "TOTAL_SHARE", "TOTAL_EQUITY_PARENT",
|
"ROEJQ", "XSMLL", "ZCFZL",
|
)
|
|
|
def parse_money(text: str) -> float | None:
|
cleaned = text.replace(",", "").strip()
|
if cleaned in {"", "--", "-"}:
|
return None
|
match = re.search(r"(-?\d+(?:\.\d+)?)\s*([万亿]?)", cleaned)
|
if not match:
|
return None
|
value = float(match.group(1))
|
return value * ({"亿": 1e8, "万": 1e4}.get(match.group(2), 1.0))
|
|
|
def parse_forecast_page(body: bytes) -> tuple[list[dict[str, Any]], str]:
|
text = body.decode("gb18030", errors="replace")
|
soup = BeautifulSoup(text, "lxml")
|
root = soup.find(id="forecastdetail")
|
rows: list[dict[str, Any]] = []
|
if root:
|
table = root.find("table")
|
if table:
|
for tr in table.select("tbody tr"):
|
cells = [cell.get_text(" ", strip=True) for cell in tr.find_all(["th", "td"])]
|
if len(cells) < 9 or not re.fullmatch(r"20\d{2}-\d{2}-\d{2}", cells[-1]):
|
continue
|
rows.append(
|
{
|
"institution": cells[0],
|
"analyst": cells[1],
|
"eps_2026": parse_money(cells[2]),
|
"eps_2027": parse_money(cells[3]),
|
"eps_2028": parse_money(cells[4]),
|
"profit_2026": parse_money(cells[5]),
|
"profit_2027": parse_money(cells[6]),
|
"profit_2028": parse_money(cells[7]),
|
"report_date": cells[8],
|
}
|
)
|
return rows, hashlib.sha256(body).hexdigest().upper()
|
|
|
def extract_after(text: str, label: str, next_labels: tuple[str, ...]) -> str:
|
start = text.find(label)
|
if start < 0:
|
return ""
|
value = text[start + len(label):]
|
stops = [value.find(item) for item in next_labels if value.find(item) >= 0]
|
return value[: min(stops) if stops else len(value)].strip()
|
|
|
def parse_business_page(body: bytes) -> tuple[dict[str, str], str]:
|
text = body.decode("gb18030", errors="replace")
|
soup = BeautifulSoup(text, "lxml")
|
visible = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
|
main = extract_after(visible, "主营业务:", ("产品类型:", "产品名称:", "经营范围:"))
|
product_types = extract_after(visible, "产品类型:", ("产品名称:", "经营范围:"))
|
product_names = extract_after(visible, "产品名称:", ("经营范围:", "主营构成分析"))
|
return {
|
"main_business": main,
|
"product_types": product_types,
|
"product_names": product_names,
|
}, hashlib.sha256(body).hexdigest().upper()
|
|
|
def fetch_one(item: dict[str, Any]) -> dict[str, Any]:
|
ticker = item["ticker"]
|
code = ticker.split(".")[0]
|
if ticker.endswith(".HK"):
|
return {"ticker": ticker, "status": "UNSUPPORTED_HK", "errors": ["A股公开源不覆盖港股"]}
|
errors: list[str] = []
|
payloads: dict[str, Any] = {}
|
hashes: dict[str, str] = {}
|
try:
|
for kind, report_name in FINANCE_REPORTS.items():
|
payloads[kind], hashes[f"finance_{kind}"] = finance_payload(ticker, report_name)
|
income_rows = records(payloads["income"])
|
annual, current, prior = select_periods(income_rows)
|
selected = {
|
"annual_income": compact(pick(income_rows, annual), INCOME_KEYS),
|
"current_income": compact(pick(income_rows, current), INCOME_KEYS),
|
"prior_income": compact(pick(income_rows, prior), INCOME_KEYS),
|
"annual_cashflow": compact(pick(records(payloads["cashflow"]), annual), CASHFLOW_KEYS),
|
"current_cashflow": compact(pick(records(payloads["cashflow"]), current), CASHFLOW_KEYS),
|
"prior_cashflow": compact(pick(records(payloads["cashflow"]), prior), CASHFLOW_KEYS),
|
"current_balance": compact(pick(records(payloads["balance"]), current), BALANCE_KEYS),
|
"current_main": compact(pick(records(payloads["main"]), current), MAIN_KEYS),
|
}
|
except Exception as exc:
|
errors.append(f"finance:{type(exc).__name__}:{exc}")
|
selected = {}
|
annual = current = prior = ""
|
# Banks and a few financial issuers can omit the industrial cash-flow
|
# comparative row. Preserve income/balance evidence so their PB model
|
# can still be refreshed without inventing cash flow.
|
try:
|
income_rows = records(payloads.get("income") or {})
|
annual, current, prior = select_periods(income_rows)
|
selected = {
|
"annual_income": compact(pick(income_rows, annual), INCOME_KEYS),
|
"current_income": compact(pick(income_rows, current), INCOME_KEYS),
|
"prior_income": compact(pick(income_rows, prior), INCOME_KEYS),
|
"current_main": compact(pick(records(payloads.get("main") or {}), current), MAIN_KEYS),
|
}
|
balance_rows = records(payloads.get("balance") or {})
|
if balance_rows:
|
selected["current_balance"] = compact(pick(balance_rows, current), BALANCE_KEYS)
|
cash_rows = records(payloads.get("cashflow") or {})
|
if cash_rows:
|
selected.update(
|
{
|
"annual_cashflow": compact(pick(cash_rows, annual), CASHFLOW_KEYS),
|
"current_cashflow": compact(pick(cash_rows, current), CASHFLOW_KEYS),
|
"prior_cashflow": compact(pick(cash_rows, prior), CASHFLOW_KEYS),
|
}
|
)
|
except Exception as partial_exc:
|
errors.append(f"finance_partial:{type(partial_exc).__name__}:{partial_exc}")
|
try:
|
worth = get(f"{THS_BASE}/{code}/worth.html", referer=f"{THS_BASE}/{code}/")
|
forecasts, hashes["institution_detail"] = parse_forecast_page(worth)
|
except Exception as exc:
|
forecasts = []
|
errors.append(f"forecast:{type(exc).__name__}:{exc}")
|
try:
|
operate = get(f"{THS_BASE}/{code}/operate.html", referer=f"{THS_BASE}/{code}/")
|
business, hashes["business_identity"] = parse_business_page(operate)
|
except Exception as exc:
|
business = {"main_business": "", "product_types": "", "product_names": ""}
|
errors.append(f"business:{type(exc).__name__}:{exc}")
|
result = {
|
"ticker": ticker,
|
"company": item["company"],
|
"status": "OK" if not errors else ("PARTIAL" if selected else "FAILED"),
|
"as_of": AS_OF,
|
"periods": {"annual": annual, "current": current, "prior": prior},
|
"selected": selected,
|
"forecasts": [row for row in forecasts if row["report_date"] <= AS_OF],
|
"business": business,
|
"raw_sha256": hashes,
|
"errors": errors,
|
}
|
return result
|
|
|
def write_json(path: Path, value: Any) -> None:
|
path.parent.mkdir(parents=True, exist_ok=True)
|
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
def action_inventory() -> None:
|
rows = universe()
|
TMP_ROOT.mkdir(parents=True, exist_ok=True)
|
columns = list(rows[0])
|
with (TMP_ROOT / "universe.csv").open("w", encoding="utf-8", newline="") as handle:
|
writer = csv.DictWriter(handle, fieldnames=columns)
|
writer.writeheader()
|
writer.writerows(rows)
|
write_json(
|
TMP_ROOT / "inventory.json",
|
{
|
"batch_id": BATCH_ID,
|
"as_of": AS_OF,
|
"price_date": PRICE_DATE,
|
"created_at": datetime.now().astimezone().isoformat(),
|
"universe_count": len(rows),
|
"a_share_count": sum(not row["ticker"].endswith(".HK") for row in rows),
|
"price_covered": sum(row["close"] is not None for row in rows),
|
},
|
)
|
print(json.dumps({"universe": len(rows), "price_covered": sum(row["close"] is not None for row in rows)}, ensure_ascii=False))
|
|
|
def action_fetch(workers: int) -> None:
|
rows = universe()
|
RAW_ROOT.mkdir(parents=True, exist_ok=True)
|
completed: list[dict[str, Any]] = []
|
started = time.monotonic()
|
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="midyear-refresh") as pool:
|
futures = {pool.submit(fetch_one, item): item for item in rows}
|
for index, future in enumerate(as_completed(futures), 1):
|
item = futures[future]
|
try:
|
result = future.result()
|
except Exception as exc:
|
result = {"ticker": item["ticker"], "company": item["company"], "status": "FAILED", "errors": [repr(exc)]}
|
write_json(RAW_ROOT / f"{result['ticker'].replace('.', '_')}.json", result)
|
completed.append(result)
|
if index % 50 == 0 or index == len(rows):
|
print(f"[{index}/{len(rows)}] elapsed={time.monotonic()-started:.1f}s", flush=True)
|
counts: dict[str, int] = {}
|
for row in completed:
|
counts[row["status"]] = counts.get(row["status"], 0) + 1
|
write_json(
|
TMP_ROOT / "fetch_manifest.json",
|
{
|
"batch_id": BATCH_ID,
|
"as_of": AS_OF,
|
"price_date": PRICE_DATE,
|
"finished_at": datetime.now().astimezone().isoformat(),
|
"wall_seconds": round(time.monotonic() - started, 3),
|
"counts": counts,
|
"files": len(completed),
|
},
|
)
|
print(json.dumps(counts, ensure_ascii=False))
|
|
|
def main() -> None:
|
parser = argparse.ArgumentParser()
|
parser.add_argument("action", choices=("inventory", "fetch"))
|
parser.add_argument("--workers", type=int, default=16)
|
args = parser.parse_args()
|
if hasattr(sys.stdout, "reconfigure"):
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
if args.action == "inventory":
|
action_inventory()
|
else:
|
action_fetch(args.workers)
|
|
|
if __name__ == "__main__":
|
main()
|