from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
import math
|
import statistics
|
from collections import Counter
|
from datetime import datetime
|
from pathlib import Path
|
from typing import Any
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
RESULT_ROOT = ROOT / "ana-data/result/股票估值"
|
MASTER_ROOT = RESULT_ROOT / "全量中报重估"
|
CURRENT_ROOT = RESULT_ROOT / "当前估值"
|
LEDGER_ROOT = RESULT_ROOT / "估值台账"
|
BATCH_ROOT = ROOT / "ana-data/tmp/股票估值/BATCH-STOCK-VALUATION-H1-GAP-CLOSURE-20260903-001"
|
CASE_ROOT = ROOT / "ana-data/cases/股票估值/BATCH-STOCK-VALUATION-H1-GAP-CLOSURE-20260903-001"
|
OLD_MASTER = MASTER_ROOT / "全部已评估公司最新估值.csv"
|
INCREMENTAL = BATCH_ROOT / "revaluation_output/全部已评估公司最新估值.csv"
|
UNIVERSE = BATCH_ROOT / "universe.csv"
|
LATEST = LEDGER_ROOT / "latest.csv"
|
LATEST_GAPS = LEDGER_ROOT / "latest_gaps.csv"
|
WEEKLY = RESULT_ROOT / "周度增量复评/latest.csv"
|
|
MODEL_GAPS = {
|
"600892.SH": ("大晟文化", "最新归母净资产不为正且TTM扣非亏损,PE/PB均无有效锚点"),
|
"603398.SH": ("*ST沐邦", "最新归母净资产不为正且TTM扣非亏损,PE/PB均无有效锚点"),
|
}
|
HK_GAPS = {
|
"03888.HK": "金山软件",
|
"09880.HK": "优必选",
|
}
|
SUSPENDED = {"600929.SH", "688432.SH"}
|
CURRENT_OVERRIDES = {
|
"002158.SZ": "VAL-be8591b5946a328183125299",
|
"300223.SZ": "VAL-f43dd629fde5197ab61e2f1f",
|
"300623.SZ": "VAL-e5fea1c78750cdafffb8d7e8",
|
"600745.SH": "VAL-948f52a9cc02658f919e1b0a",
|
"603163.SH": "VAL-c2739ad9efcd0971aa6ba773",
|
"688018.SH": "VAL-76f429dabd366d04b0d8720f",
|
"688019.SH": "VAL-18f847d08045478ed367f5a1",
|
"688138.SH": "VAL-3cde42baa99030febd11c676",
|
"688187.SH": "VAL-409ffcd54b4fae5ba10826d5",
|
"688213.SH": "VAL-9ef621439025dbdc5199b80e",
|
"688401.SH": "VAL-0d030950a6ae9ed8bc573c94",
|
}
|
|
|
def read_csv(path: Path) -> list[dict[str, str]]:
|
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
return list(csv.DictReader(handle))
|
|
|
def read_json(path: Path) -> dict[str, Any]:
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
def sha256(path: Path) -> str:
|
return hashlib.sha256(path.read_bytes()).hexdigest().upper()
|
|
|
def f(value: Any) -> float | None:
|
if value in (None, "", "None"):
|
return None
|
return float(value)
|
|
|
def valuation_position(price: float, low: float, high: float) -> float:
|
if price < low:
|
return price / low - 1
|
if price <= high:
|
return price / ((low + high) / 2) - 1
|
return price / high - 1
|
|
|
def classify(price: float, base_low: float, base_high: float, optimistic_high: float) -> tuple[str, str, str]:
|
if price < base_low:
|
label = "偏低"
|
elif price <= base_high:
|
label = "基本合理"
|
elif price <= optimistic_high:
|
label = "偏贵"
|
else:
|
label = "明显偏贵"
|
premium = price / base_high - 1
|
vs_optimistic = price / optimistic_high - 1
|
if premium <= 0:
|
return label, "未识别估值泡沫", "当前价格未高于基准合理区间上沿,按统一规则不认定估值泡沫。"
|
if vs_optimistic > 0:
|
status = "泡沫-极端"
|
elif premium <= 0.15:
|
status = "泡沫-轻"
|
elif premium <= 0.50:
|
status = "泡沫-中"
|
else:
|
status = "泡沫-高"
|
reason = (
|
f"价格高于基准上沿{premium * 100:.1f}%,市场已提前交易行业增长、盈利修复或份额提升预期"
|
+ (f";同时高于乐观上沿{vs_optimistic * 100:.1f}%,包含模型外叙事溢价" if vs_optimistic > 0 else "")
|
+ "。除价格位置外,原因属于基于估值状态的解释性推断。"
|
)
|
return label, status, reason
|
|
|
def current_file(ticker: str, suffix: str) -> Path:
|
candidates = sorted((CURRENT_ROOT / ticker).glob(f"*{suffix}_当前.*"))
|
if candidates:
|
return candidates[0]
|
candidates = sorted((CURRENT_ROOT / ticker).glob(f"*{suffix}.*"))
|
if len(candidates) != 1:
|
raise RuntimeError(f"expected one {suffix} for {ticker}, got {len(candidates)}")
|
return candidates[0]
|
|
|
def snapshot_row(ticker: str, price_row: dict[str, str], valuation_id: str) -> dict[str, Any]:
|
snapshot_path = current_file(ticker, "估值快照")
|
report_path = current_file(ticker, "价格合理性评估")
|
snapshot = read_json(snapshot_path)
|
results = read_json(snapshot_path.parent / "calculation/valuation_results.json")
|
scenarios = {row["role"]: row for row in results["scenarios"]}
|
metrics = results["metrics"]
|
meta = snapshot["meta"]
|
market = snapshot["market"]
|
analysis = snapshot.get("analysis", {})
|
forecasts = snapshot.get("institutions", {}).get("forecasts", [])
|
profits = []
|
for forecast in forecasts:
|
estimate = forecast.get("estimates", {}).get("2026") or {}
|
if isinstance(estimate, dict) and f(estimate.get("profit")) is not None:
|
profits.append(float(estimate["profit"]))
|
price_date = price_row.get("trade_date") or price_row.get("price_date")
|
close = price_row["close"]
|
return {
|
"ticker": ticker,
|
"company": meta["company"],
|
"market": meta["market"],
|
"currency": meta["currency"],
|
"status": "COMPLETE",
|
"valuation_id": valuation_id,
|
"valuation_date": meta["as_of_date"],
|
"price_date": price_date,
|
"close": close,
|
"shares": metrics["diluted_shares"],
|
"market_cap": float(close) * float(metrics["diluted_shares"]),
|
"latest_period": meta["report_period_end"],
|
"latest_notice_date": meta["latest_operating_info_date"],
|
"old_valuation_date": analysis.get("old_valuation_date", ""),
|
"financial_update": analysis.get("financial_update", ""),
|
"model_reclassified": analysis.get("model_reclassified", ""),
|
"old_company_type": analysis.get("old_company_type", ""),
|
"company_type": analysis.get("company_type", ""),
|
"method": scenarios["base"]["method"].upper(),
|
"normalized_profit": metrics.get("normalized_profit"),
|
"normalized_pe": metrics.get("normalized_pe"),
|
"pb": metrics.get("pb"),
|
"ps": metrics.get("ps"),
|
"consensus_year": 2026 if profits else "",
|
"consensus_profit": statistics.median(profits) if profits else "",
|
"consensus_count": len(profits),
|
"excluded_forecast_count": len(analysis.get("excluded_institution_forecasts", [])),
|
"pessimistic_low": scenarios["pessimistic"]["price_low"],
|
"pessimistic_high": scenarios["pessimistic"]["price_high"],
|
"base_low": scenarios["base"]["price_low"],
|
"base_high": scenarios["base"]["price_high"],
|
"optimistic_low": scenarios["optimistic"]["price_low"],
|
"optimistic_high": scenarios["optimistic"]["price_high"],
|
"confidence": analysis.get("classification_confidence", "中"),
|
"qa_status": results["qa"]["status"],
|
"qa_warning_count": results["qa"]["warning_count"],
|
"report_path": report_path.relative_to(ROOT).as_posix(),
|
"snapshot_path": snapshot_path.relative_to(ROOT).as_posix(),
|
"source_hash": sha256(snapshot_path),
|
}
|
|
|
def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str]) -> None:
|
path.parent.mkdir(parents=True, exist_ok=True)
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
|
writer.writeheader()
|
writer.writerows(rows)
|
|
|
def main() -> None:
|
old_rows = read_csv(OLD_MASTER)
|
incremental_rows = read_csv(INCREMENTAL)
|
universe_rows = read_csv(UNIVERSE)
|
latest_rows = read_csv(LATEST)
|
gap_rows = read_csv(LATEST_GAPS)
|
weekly_rows = read_csv(WEEKLY)
|
latest_by_ticker = {row["ticker"]: row for row in latest_rows}
|
gap_by_ticker = {row["ticker"]: row for row in gap_rows}
|
weekly_by_ticker = {row["ticker"]: row for row in weekly_rows}
|
old_by_ticker = {row["ticker"]: row for row in old_rows}
|
universe_by_ticker = {row["ticker"]: row for row in universe_rows}
|
|
combined = {row["ticker"]: dict(row) for row in old_rows}
|
for row in incremental_rows:
|
combined[row["ticker"]] = dict(row)
|
for ticker, valuation_id in CURRENT_OVERRIDES.items():
|
price_row = latest_by_ticker.get(ticker) or gap_by_ticker.get(ticker)
|
if not price_row:
|
raise RuntimeError(f"missing price row for current override {ticker}")
|
combined[ticker] = snapshot_row(ticker, price_row, valuation_id)
|
for ticker in MODEL_GAPS:
|
combined.pop(ticker, None)
|
for ticker in SUSPENDED:
|
combined[ticker] = snapshot_row(ticker, gap_by_ticker[ticker], weekly_by_ticker[ticker]["valuation_id"])
|
|
if len(combined) != 1225:
|
raise RuntimeError(f"expected 1225 numeric valuations, got {len(combined)}")
|
|
final_rows: list[dict[str, Any]] = []
|
for ticker, row in combined.items():
|
price_row = latest_by_ticker.get(ticker) or gap_by_ticker.get(ticker)
|
if not price_row or not price_row.get("close"):
|
raise RuntimeError(f"missing latest available price for {ticker}")
|
price = float(price_row["close"])
|
base_low = float(row["base_low"])
|
base_high = float(row["base_high"])
|
optimistic_high = float(row["optimistic_high"])
|
label, bubble, reason = classify(price, base_low, base_high, optimistic_high)
|
row.update({
|
"close": price,
|
"price_date": price_row.get("trade_date") or price_row.get("price_date"),
|
"market_cap": price * float(row["shares"]),
|
"label": label,
|
"bubble_status": bubble,
|
"bubble_reason": reason,
|
"valuation_position_pct": valuation_position(price, base_low, base_high),
|
"premium_to_base_high": price / base_high - 1,
|
"vs_optimistic_high": price / optimistic_high - 1,
|
"industry": row.get("company_type", ""),
|
})
|
if row.get("latest_period") != "2026-06-30":
|
raise RuntimeError(f"non-H1 numeric valuation remains: {ticker} {row.get('latest_period')}")
|
final_rows.append(row)
|
final_rows.sort(key=lambda row: (float(row["valuation_position_pct"]), row["ticker"]))
|
|
fields = list(final_rows[0])
|
if "industry" not in fields:
|
fields.insert(fields.index("company_type") + 1, "industry")
|
csv_path = MASTER_ROOT / "全部已评估公司最新估值.csv"
|
write_csv(csv_path, final_rows, fields)
|
|
formal_gaps = []
|
for ticker, (company, reason) in MODEL_GAPS.items():
|
report_path = current_file(ticker, "价格合理性评估")
|
snapshot_path = current_file(ticker, "估值快照")
|
formal_gaps.append({
|
"ticker": ticker,
|
"company": company,
|
"status": "VALUATION_MODEL_GAP",
|
"latest_period": "2026-06-30",
|
"price_date": latest_by_ticker[ticker]["trade_date"],
|
"close": latest_by_ticker[ticker]["close"],
|
"reason": reason,
|
"report_path": report_path.relative_to(ROOT).as_posix(),
|
"snapshot_path": snapshot_path.relative_to(ROOT).as_posix(),
|
})
|
for ticker, company in HK_GAPS.items():
|
formal_gaps.append({
|
"ticker": ticker,
|
"company": company,
|
"status": "UNSUPPORTED_HK",
|
"latest_period": "",
|
"price_date": "",
|
"close": "",
|
"reason": "现有登记行情与中报批处理合同仅覆盖A股,未伪造港股数据或回退未审核来源",
|
"report_path": weekly_by_ticker[ticker].get("valuation_id", ""),
|
"snapshot_path": "",
|
})
|
formal_gaps.sort(key=lambda row: row["ticker"])
|
gaps_path = MASTER_ROOT / "全部已评估公司最新估值_缺口.csv"
|
write_csv(gaps_path, formal_gaps, list(formal_gaps[0]))
|
|
share_audit = []
|
valuation_change_audit = []
|
for row in incremental_rows:
|
ticker = row["ticker"]
|
static_shares = f(universe_by_ticker[ticker].get("static_total_shares"))
|
h1_shares = f(row.get("shares"))
|
share_delta = h1_shares / static_shares - 1 if static_shares and h1_shares else None
|
if share_delta is not None and abs(share_delta) > 0.02:
|
share_audit.append({
|
"ticker": ticker,
|
"company": row["company"],
|
"static_shares": static_shares,
|
"h1_reported_shares": h1_shares,
|
"difference_pct": share_delta,
|
"selected_basis": "2026H1法定报告TOTAL_SHARE",
|
})
|
old = old_by_ticker.get(ticker)
|
if old:
|
old_mid = (float(old["base_low"]) + float(old["base_high"])) / 2
|
new_mid = (float(row["base_low"]) + float(row["base_high"])) / 2
|
valuation_change_audit.append({
|
"ticker": ticker,
|
"company": row["company"],
|
"old_period": old.get("latest_period", ""),
|
"new_period": row["latest_period"],
|
"old_base_low": old["base_low"],
|
"old_base_high": old["base_high"],
|
"new_base_low": row["base_low"],
|
"new_base_high": row["base_high"],
|
"midpoint_change_pct": new_mid / old_mid - 1 if old_mid else "",
|
"method_changed": str(old.get("method")) != str(row.get("method")),
|
})
|
share_audit.sort(key=lambda row: (-abs(float(row["difference_pct"])), row["ticker"]))
|
valuation_change_audit.sort(
|
key=lambda row: (-abs(float(row["midpoint_change_pct"])), row["ticker"])
|
)
|
share_audit_path = CASE_ROOT / "share_basis_audit.csv"
|
change_audit_path = CASE_ROOT / "valuation_change_audit.csv"
|
write_csv(share_audit_path, share_audit, list(share_audit[0]))
|
write_csv(change_audit_path, valuation_change_audit, list(valuation_change_audit[0]))
|
|
labels = Counter(row["label"] for row in final_rows)
|
bubbles = Counter(row["bubble_status"] for row in final_rows)
|
price_dates = Counter(row["price_date"] for row in final_rows)
|
lines = [
|
"# 全部已评估公司中报期最新估值",
|
"",
|
f"> 估值信息截止:2026-09-03;统一价格日:2026-09-02({price_dates['2026-09-02']}家);因停牌无9月2日日K而使用最新可得2026-08-28收盘:{price_dates['2026-08-28']}家。",
|
"> 合理价值区间是条件化估值,不等同目标价;本表不构成交易指令或收益承诺。",
|
"",
|
"## 覆盖结论",
|
"",
|
"- 已评估证券总数:1,229家。",
|
"- A股中报已逐家处理:1,227/1,227(100%)。其中1,225家形成数值合理区间,2家转为显式估值模型缺口,旧区间已停止作为当前判断依据。",
|
"- 港股显式缺口:2家;现有登记行情和批处理合同不支持,未使用未经审核来源补数。",
|
f"- 数值估值标签:偏低{labels['偏低']}、基本合理{labels['基本合理']}、偏贵{labels['偏贵']}、明显偏贵{labels['明显偏贵']}。",
|
f"- 泡沫标签:未识别{bubbles['未识别估值泡沫']}、轻{bubbles['泡沫-轻']}、中{bubbles['泡沫-中']}、高{bubbles['泡沫-高']}、极端{bubbles['泡沫-极端']}。",
|
"",
|
"## 估值最低的前30家公司",
|
"",
|
"| 排名 | 代码 | 公司 | 所属行业 | 收盘价 | 价格日 | 基准区间 | 估值位置 | 判定 | 泡沫 | 正式报告 |",
|
"|---:|---|---|---|---:|---|---:|---:|---|---|---|",
|
]
|
for rank, row in enumerate(final_rows[:30], 1):
|
link = "../" + row["report_path"].split("股票估值/", 1)[-1].replace(" ", "%20")
|
lines.append(
|
f"| {rank} | {row['ticker']} | {row['company']} | {row['industry']} | {float(row['close']):.2f} | {row['price_date']} | "
|
f"{float(row['base_low']):.2f}—{float(row['base_high']):.2f} | {float(row['valuation_position_pct'])*100:.2f}% | "
|
f"{row['label']} | {row['bubble_status']} | [查看]({link}) |"
|
)
|
lines += [
|
"",
|
"## 全量结果",
|
"",
|
"排序口径:低于下沿时计算相对下沿折价;区间内计算相对区间中枢偏离;高于上沿时计算相对上沿溢价。数值越低,估值位置越靠前。",
|
"",
|
"| 排名 | 代码 | 公司 | 所属行业 | 收盘价 | 价格日 | 基准区间 | 乐观上沿 | 估值位置 | 判定 | 泡沫 | 置信度 | 正式报告 |",
|
"|---:|---|---|---|---:|---|---:|---:|---:|---|---|---|---|",
|
]
|
for rank, row in enumerate(final_rows, 1):
|
link = "../" + row["report_path"].split("股票估值/", 1)[-1].replace(" ", "%20")
|
lines.append(
|
f"| {rank} | {row['ticker']} | {row['company']} | {row['industry']} | {float(row['close']):.2f} | {row['price_date']} | "
|
f"{float(row['base_low']):.2f}—{float(row['base_high']):.2f} | {float(row['optimistic_high']):.2f} | "
|
f"{float(row['valuation_position_pct'])*100:.2f}% | {row['label']} | {row['bubble_status']} | {row['confidence']} | [查看]({link}) |"
|
)
|
lines += [
|
"",
|
"## 显式缺口",
|
"",
|
"| 代码 | 公司 | 状态 | 最新经营期 | 最新价格 | 原因 |",
|
"|---|---|---|---|---:|---|",
|
]
|
for row in formal_gaps:
|
price = f"{row['close']}({row['price_date']})" if row["close"] else "—"
|
lines.append(f"| {row['ticker']} | {row['company']} | {row['status']} | {row['latest_period'] or '—'} | {price} | {row['reason']} |")
|
md_path = MASTER_ROOT / "全部已评估公司最新估值.md"
|
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
evidence_path = MASTER_ROOT / "evidence.json"
|
generated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
if evidence_path.exists():
|
try:
|
previous_evidence = read_json(evidence_path)
|
if previous_evidence.get("schema") == "stock_valuation_all_h1_refresh_v1":
|
generated_at = previous_evidence.get("generated_at") or generated_at
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
pass
|
evidence = {
|
"schema": "stock_valuation_all_h1_refresh_v1",
|
"generated_at": generated_at,
|
"information_cutoff": "2026-09-03",
|
"latest_complete_trade_date": "2026-09-02",
|
"scope": {"total": 1229, "a_share": 1227, "numeric": 1225, "model_gaps": 2, "unsupported_hk": 2},
|
"h1_coverage": {"a_share_processed": 1227, "a_share_total": 1227, "numeric_h1": 1225, "model_gap_h1": 2},
|
"price_dates": dict(price_dates),
|
"labels": dict(labels),
|
"bubble_status": dict(bubbles),
|
"new_h1_fetch": {"requested": 500, "a_share_ok": 498, "unsupported_hk": 2},
|
"new_valuations": {"numeric": 496, "model_gap": 2, "mysql_new_versions": 496, "effective_from": "2026-09-04"},
|
"database_model_gap_disposition": {
|
"tickers": ["600892.SH", "603398.SH"],
|
"security_active": 0,
|
"reason": "防止每日任务继续引用已经失效的旧数值区间;历史版本和历史判断均保留",
|
},
|
"quality_audits": {
|
"h1_share_difference_over_2pct": len(share_audit),
|
"valuation_change_rows": len(valuation_change_audit),
|
"share_basis_rule": "2026H1法定报告期末总股本优先于旧证券静态股本",
|
},
|
"historical_policy": "2026-09-02 daily_price/daily_judgement preserved; no historical daily rerun or rewrite",
|
"inputs": {
|
str(INCREMENTAL.relative_to(ROOT)): sha256(INCREMENTAL),
|
str(LATEST.relative_to(ROOT)): sha256(LATEST),
|
str(LATEST_GAPS.relative_to(ROOT)): sha256(LATEST_GAPS),
|
},
|
"outputs": {},
|
}
|
for path in (csv_path, md_path, gaps_path, share_audit_path, change_audit_path):
|
evidence["outputs"][str(path.relative_to(ROOT))] = {"bytes": path.stat().st_size, "sha256": sha256(path)}
|
evidence_path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
print(json.dumps({"status": "PASS", "rows": len(final_rows), "gaps": len(formal_gaps), "labels": dict(labels), "prices": dict(price_dates), "outputs": evidence["outputs"]}, ensure_ascii=False, indent=2))
|
|
|
if __name__ == "__main__":
|
main()
|