from __future__ import annotations
|
|
import hashlib
|
import json
|
import shutil
|
from pathlib import Path
|
from typing import Any
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
BATCH_ID = "BATCH-STOCK-VALUATION-20260805-003"
|
AS_OF = "2026-08-04"
|
RESULT_ROOT = ROOT / "ana-data" / "result" / "股票估值"
|
BATCH_DIR = RESULT_ROOT / "20260805_batch_six_images_valuation"
|
CASE_DIR = ROOT / "ana-data" / "cases" / "股票估值" / BATCH_ID
|
|
INPUT_IMAGES = [
|
Path("C:/Users/Cai/AppData/Local/Temp/codex-clipboard-7c6d2aea-3d70-48e7-aa9d-cc3fb100b47e.png"),
|
Path("C:/Users/Cai/AppData/Local/Temp/codex-clipboard-3b606003-d4fb-428b-aa80-5f8086bc2c11.png"),
|
Path("C:/Users/Cai/AppData/Local/Temp/codex-clipboard-c4a11551-c1e2-48fc-971b-66cf0ade2f88.png"),
|
Path("C:/Users/Cai/AppData/Local/Temp/codex-clipboard-e9c3ce34-3fac-449a-89e6-5cd7b1054ec7.png"),
|
Path("C:/Users/Cai/AppData/Local/Temp/codex-clipboard-204f7277-c19e-4f86-9080-2a607c75980e.png"),
|
Path("C:/Users/Cai/AppData/Local/Temp/codex-clipboard-18e2165b-9375-4f52-a1bd-16f43fea2cc6.png"),
|
]
|
IMAGE_COUNTS = [4, 31, 5, 9, 29, 5]
|
|
|
def read_json(path: Path) -> Any:
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
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 sha256(path: Path) -> str:
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
def fmt_profit(value: float | None, currency: str) -> str:
|
if value is None:
|
return "无可用预测"
|
unit = "亿港元" if currency == "HKD" else "亿元"
|
return f"{value / 1e8:.2f}{unit}"
|
|
|
def fmt_price(value: float, currency: str) -> str:
|
return f"{value:.2f}{'港元' if currency == 'HKD' else '元'}"
|
|
|
def fmt_range(row: dict[str, Any]) -> str:
|
unit = "港元" if row["currency"] == "HKD" else "元"
|
return f"{row['base_low']:.2f}—{row['base_high']:.2f}{unit}"
|
|
|
def valuation_metric(row: dict[str, Any]) -> str:
|
pe = row.get("normalized_pe")
|
pb = row.get("pb")
|
if pe is not None and pe > 0:
|
return f"归一化PE {pe:.2f}倍 / PB {pb:.2f}倍" if pb is not None else f"归一化PE {pe:.2f}倍"
|
return f"PE不适用 / PB {pb:.2f}倍" if pb is not None else "PE、PB均不适用"
|
|
|
def render_summary(rows: list[dict[str, Any]]) -> str:
|
counts: dict[str, int] = {}
|
for row in rows:
|
group = "基本合理" if row["label"].startswith("基本合理") else row["label"]
|
counts[group] = counts.get(group, 0) + 1
|
no_forecast = sum(row.get("consensus_2026") is None for row in rows)
|
share_gaps = sum(row.get("share_status") == "QUOTE_EXACT_A1_NEAR_MATCH" for row in rows)
|
loss_count = sum(row.get("normalized_pe") is None for row in rows)
|
warning_count = sum(row["qa_warnings"] for row in rows)
|
lines = [
|
"# 六张图片83只股票价格合理性评估批次汇总(2026-08-05)",
|
"",
|
f"- 批次:`{BATCH_ID}`",
|
"- 股票数:83只(上交所/深交所80只、北交所2只、港交所1只)",
|
"- 估值基准:2026-08-04完整交易日未复权收盘价;财务和机构信息截止估值日",
|
"- 计算:V1统一内核复算市值、TTM、归一化利润、PE/PB/PS、三情景、反向利润和五年回报压力测试",
|
"- 结论性质:条件化研究判断,不构成交易指令或收益承诺",
|
"",
|
"## 1. 结论摘要",
|
"",
|
f"- 结论分布:偏低{counts.get('偏低', 0)}只、基本合理{counts.get('基本合理', 0)}只、偏贵{counts.get('偏贵', 0)}只、明显偏贵{counts.get('明显偏贵', 0)}只。",
|
f"- {loss_count}只股票的TTM归一化利润不为正,PE失真或不适用,正式报告改用PB或盈利修复情景。",
|
f"- {len(rows)-no_forecast}只有公开2026年机构盈利预测,{no_forecast}只没有可用机构预测;空缺没有用公司预告或自有情景冒充。",
|
f"- 83份报告QA错误为0,保留{warning_count}条业务警告;其中内地股票{share_gaps}只存在行情股本与最近法定股本的小额持续变化日期缺口。",
|
"- 截图中的股票多为机器人、半导体、锂电和军工主题活跃标的;估值结论必须服从利润与现金流,不能用当日涨幅代替价值证明。",
|
"",
|
"## 2. 83只逐股结果",
|
"",
|
"| 序号 | 图片 | 代码 | 公司 | 当前价 | 核心估值指标 | 2026机构利润 | 基准合理区间 | 判断 | 正式报告 |",
|
"|---:|---:|---|---|---:|---|---:|---:|---|---|",
|
]
|
for idx, row in enumerate(rows, 1):
|
profit = fmt_profit(row.get("consensus_2026"), row["currency"])
|
if row["currency"] == "HKD" and row.get("consensus_median_2026") is not None:
|
profit += f";中位{fmt_profit(row['consensus_median_2026'], 'HKD')}"
|
link = f"../{row['formal_path']}"
|
lines.append(
|
f"| {idx} | {row['image_no']}-{row['image_index']} | {row['ticker']} | {row['company']} | "
|
f"{fmt_price(row['price'], row['currency'])} | {valuation_metric(row)} | {profit}({row['consensus_count']}家) | "
|
f"{fmt_range(row)} | {row['label']} | [查看]({link}) |"
|
)
|
undervalued = [row for row in rows if row["label"] == "偏低"]
|
lines += [
|
"",
|
"## 3. 横向阅读提示",
|
"",
|
"### 3.1 当前落在偏低区间的公司",
|
"",
|
]
|
for row in undervalued:
|
lines.append(
|
f"- {row['company']}({row['ticker']}):当前{fmt_price(row['price'], row['currency'])},"
|
f"基准区间{fmt_range(row)};仍须逐份阅读盈利兑现和行业周期条件,不能把‘偏低’直接解释为交易建议。"
|
)
|
lines += [
|
"",
|
"### 3.2 基本合理与高估值公司",
|
"",
|
"- 基本合理只表示当前价格落在基准情景区间内,不表示风险低;优必选尤其依赖收入高增长、亏损收窄和现金消耗受控。",
|
"- 偏贵或明显偏贵主要来自当前价格显著超过基准利润×合理倍数,或亏损公司仍享有很高PB/PS;报告中的乐观区间不是目标价承诺。",
|
"- 当机构预测分歧大、覆盖缺失或股本存在持续行权/转债变化时,应优先读逐股报告的第5节、第11节和第12节。",
|
"",
|
"## 4. 优必选港股口径",
|
"",
|
"- 2026-08-04收盘价86.95港元;港交所7月月报证明总股本为5.03401373亿股。",
|
"- 2025年归母亏损7.03亿元人民币;7家机构2026年预测范围为亏损4.02亿元至盈利3.49亿元人民币,逐家数字已在正式报告列出。",
|
"- 按2026-08-03历史汇率0.8612人民币/港元统一换算后,当前约5.22倍PB、18.84倍2025年PS;PB基准区间66.60—99.90港元,判断为基本合理但高风险、高弹性。",
|
"",
|
"## 5. 数据、信源和缺口",
|
"",
|
"- 内地82只:公告、法定财务、机构汇总和历史K线由登记公开provider并发获取;V2因行情`f124=0`无法证明历史股本时诚实BLOCKED,正式报告用最新法定报告、公告或有界近似补证。",
|
"- 优必选:财务和股本使用港交所法定披露,价格使用历史日K,机构预测使用ETNet逐家公开明细;港股没有A股式扣非归母字段,因此以归母亏损作保守代理。",
|
"- 行情平台实时市值与8月4日历史收盘价不在同一时点,已从正式QA输入剔除;原始值与哈希保留,正式市值统一按估值日股价×股本复算。",
|
"- 机构预测是估值日市场基准,不是法定事实;报告将机构利润、公司已实现利润和自有估值情景分开列示。",
|
"",
|
"## 6. 验收结果",
|
"",
|
f"- 正式逐股报告:{len(rows)}/{len(rows)};每份均含第0—15节。",
|
f"- V1 QA:错误0;警告{warning_count},均保留在逐股第11节。",
|
"- 批次机器表、逐股快照、来源manifest、计算结果和运行manifest均已归档;截图原件及哈希保存在批次案例目录。",
|
"- 本批次只做估值研究,不输出买入、卖出、持有、仓位或止损指令。",
|
"",
|
]
|
return "\n".join(lines)
|
|
|
def render_case_record(rows: list[dict[str, Any]], screenshots: list[dict[str, Any]]) -> str:
|
lines = [
|
"# 六张图片83只股票估值任务清单",
|
"",
|
f"- 批次:`{BATCH_ID}`",
|
"- 用户范围:六张图片中的全部股票",
|
"- 识别结果:83只,去重后仍为83只",
|
"- 估值日:2026-08-04",
|
"- 任务状态:已完成",
|
"- 普通验收边界:由当前用户验收;未建立独立审核或管理授权链",
|
"",
|
"## 1. 原始图片",
|
"",
|
"| 图片 | 股票数 | 归档路径 | SHA-256 |",
|
"|---:|---:|---|---|",
|
]
|
for item in screenshots:
|
lines.append(f"| {item['image_no']} | {item['stock_count']} | `{item['path']}` | `{item['sha256']}` |")
|
lines += [
|
"",
|
"## 2. 识别和结果入口",
|
"",
|
"| 序号 | 图片位置 | 代码 | 公司 | 结论 | 正式报告 |",
|
"|---:|---:|---|---|---|---|",
|
]
|
for idx, row in enumerate(rows, 1):
|
rel = "../../../result/股票估值/" + row["formal_path"]
|
lines.append(f"| {idx} | {row['image_no']}-{row['image_index']} | {row['ticker']} | {row['company']} | {row['label']} | [报告]({rel}) |")
|
lines += [
|
"",
|
"## 3. 数据和计算路径",
|
"",
|
"- 内地82只使用V2登记公开信源并发取数,V1作为唯一公式内核;V2失败包和内容寻址缓存保留在角色私有临时目录或`ana-data/tmp/`,不冒充正式结果。",
|
"- 优必选使用港交所法定披露、历史日K、公开机构明细和估值日前最近可核验汇率,金额统一换算为港元进入V1。",
|
"- 正式报告、快照、来源证据manifest和计算三件套位于`ana-data/result/股票估值/20260805_*_valuation/`。",
|
"- 批次总表:`../../../result/股票估值/20260805_batch_six_images_valuation/六张图片83只股票价格合理性评估批次汇总_20260805.md`。",
|
"",
|
"## 4. 已知缺口",
|
"",
|
"- 29只内地股票的行情股本与最近法定报告股本存在小额持续行权、转债或其他变化,股本总量用于当前估值,但精确变动日期保留为有界缺口。",
|
"- 13只股票没有可用机构2026年盈利预测;报告明确写为无覆盖,不用公司预告或自有情景替代。",
|
"- 港股没有A股式扣非利润;优必选使用归母亏损作保守代理。",
|
"- 结论为条件化研究判断,不构成交易指令或收益承诺。",
|
"",
|
]
|
return "\n".join(lines)
|
|
|
def main() -> None:
|
batch = read_json(BATCH_DIR / "batch_results.json")
|
rows = batch["rows"]
|
hk = read_json(RESULT_ROOT / "20260805_ubtech_robotics_valuation" / "summary_row.json")
|
for row in rows:
|
row["currency"] = "CNY"
|
rows.insert(5, hk)
|
cursor = 0
|
for image_no, count in enumerate(IMAGE_COUNTS, 1):
|
for image_index in range(1, count + 1):
|
rows[cursor]["image_no"] = image_no
|
rows[cursor]["image_index"] = image_index
|
cursor += 1
|
if cursor != 83 or len(rows) != 83:
|
raise RuntimeError(f"image/row count mismatch: cursor={cursor}, rows={len(rows)}")
|
if len({row["ticker"] for row in rows}) != 83:
|
raise RuntimeError("ticker duplicate detected")
|
|
screenshot_dir = CASE_DIR / "raw" / "screenshots"
|
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
screenshots = []
|
for index, (source, count) in enumerate(zip(INPUT_IMAGES, IMAGE_COUNTS), 1):
|
if not source.exists():
|
raise FileNotFoundError(source)
|
destination = screenshot_dir / f"input_{index:02d}.png"
|
shutil.copy2(source, destination)
|
screenshots.append({
|
"image_no": index,
|
"stock_count": count,
|
"path": str(destination.relative_to(ROOT)).replace("\\", "/"),
|
"sha256": sha256(destination),
|
"bytes": destination.stat().st_size,
|
})
|
|
formal_hashes = []
|
for row in rows:
|
formal = RESULT_ROOT / row["formal_path"]
|
if not formal.exists():
|
raise FileNotFoundError(formal)
|
formal_hashes.append({
|
"ticker": row["ticker"],
|
"company": row["company"],
|
"path": str(formal.relative_to(ROOT)).replace("\\", "/"),
|
"sha256": sha256(formal),
|
})
|
|
write_json(BATCH_DIR / "batch_results_83.json", {
|
"batch_id": BATCH_ID,
|
"as_of": AS_OF,
|
"row_count": len(rows),
|
"rows": rows,
|
"failures": [],
|
})
|
summary_path = BATCH_DIR / "六张图片83只股票价格合理性评估批次汇总_20260805.md"
|
summary_path.write_text(render_summary(rows), encoding="utf-8")
|
case_path = CASE_DIR / "估值任务清单.md"
|
case_path.parent.mkdir(parents=True, exist_ok=True)
|
case_path.write_text(render_case_record(rows, screenshots), encoding="utf-8")
|
manifest = {
|
"batch_id": BATCH_ID,
|
"as_of": AS_OF,
|
"target_count": 83,
|
"unique_ticker_count": 83,
|
"screenshots": screenshots,
|
"formal_reports": formal_hashes,
|
"batch_summary": {
|
"path": str(summary_path.relative_to(ROOT)).replace("\\", "/"),
|
"sha256": sha256(summary_path),
|
},
|
"qa": {
|
"error_count": sum(row["qa_errors"] for row in rows),
|
"warning_count": sum(row["qa_warnings"] for row in rows),
|
"formal_report_count": len(formal_hashes),
|
},
|
}
|
write_json(CASE_DIR / "case_manifest.json", manifest)
|
print(json.dumps({
|
"rows": len(rows),
|
"qa_errors": manifest["qa"]["error_count"],
|
"qa_warnings": manifest["qa"]["warning_count"],
|
"summary": str(summary_path.relative_to(ROOT)).replace("\\", "/"),
|
}, ensure_ascii=False), flush=True)
|
|
|
if __name__ == "__main__":
|
main()
|