"""Build a same-day peer-valuation comparison for the current valuation universe. This is the analyst-side one-off/reference implementation. It does not change valuation ranges or MySQL facts. The project ledger implementation owns the long-lived database integration. """ from __future__ import annotations import argparse import csv import hashlib import json import math import os import statistics import tempfile from collections import Counter from pathlib import Path from typing import Iterable PEER_FIELDS = [ "industry", "peer_group_id", "peer_group_name", "peer_group_basis", "peer_tickers", "peer_metric", "target_multiple", "peer_count", "peer_mean", "peer_median", "peer_p25", "peer_p75", "peer_premium_pct", "peer_vs_mean_pct", "peer_raw_label", "peer_adjusted_label", "peer_adjustment_reason", "peer_confidence", "peer_as_of", "peer_gap_reason", ] GROUP_RULES: list[tuple[str, str, tuple[str, ...]]] = [ ("SOLAR", "光伏材料、设备、组件与电站", ("光伏", "光伏组件", "太阳能电池", "太阳能发电", "逆变器", "光伏电站")), ("LITHIUM_BATTERY", "锂电材料、电池与设备", ("锂电", "锂离子", "电池材料", "动力电池", "负极", "正极材料")), ("SEMICONDUCTOR_EQUIPMENT", "半导体设备、厂务与EDA", ("半导体设备", "半导体测试", "半导体及激光设备", "厂务", "eda")), ("SEMICONDUCTOR_MATERIAL", "半导体材料与电子化学品", ("半导体材料", "电子化学", "电子特种气体", "光刻胶", "硅片", "抛光材料")), ("WAFER_FOUNDRY", "晶圆制造、硅片与化合物半导体", ("晶圆制造", "化合物半导体", "晶圆代工", "硅片")), ("SEMICONDUCTOR_PACKAGING", "半导体封装测试", ("封装测试", "封测")), ("MEMORY", "存储模组与存储产品", ("存储模组", "存储控制器", "存储产品")), ("IC_DESIGN", "集成电路设计与芯片产品", ("集成电路设计", "芯片产品", "芯片设计", "soc")), ("DEFENSE", "军工电子、航空航天与特种装备", ("军工", "航空航天", "航空发动机", "航空器材", "航材", "军需", "特种装备", "雷达", "卫星通信", "高精度卫星导航", "武器系统", "民爆", "海洋防务", "光电防务", "防务装备", "军用", "军事", "导弹", "弹药", "红外", "低空经济", "固态微波", "高波段")), ("AI_OPTICAL", "AI算力、服务器、光通信与散热", ("ai算力", "服务器", "光通信", "cpo", "散热基础设施", "通信模组", "企业通信", "无线通信", "物联网", "光芯片", "光纤器件", "光纤环", "光测试仪器", "精密光学元组件", "光电元器件")), ("PCB_COMPONENT", "PCB、覆铜板与电子元件", ("pcb", "覆铜板", "电子铜箔", "电子元件", "被动元件", "印制电路")), ("ROBOT_AUTOMATION", "机器人、自动化与工业控制", ("机器人", "自动化", "工业控制", "运动控制", "伺服")), ("STORAGE_POWER", "储能与电力系统", ("储能", "新型电力系统", "电网", "电力设备")), ("WIND_NUCLEAR", "风电与核电", ("风电", "核电")), ("FERTILIZER", "氮磷钾肥、复合肥与化工联产", ("化肥", "复合肥", "磷肥", "钾肥", "氮磷钾", "肥料")), ("AGRICULTURE", "农业、农资、农药与养殖", ("农业", "农资", "农药", "养殖", "种业", "食品原料")), ("HEALTHCARE", "医药、医疗器械与生命科学服务", ("医药", "创新药", "疫苗", "医疗器械", "生命科学", "生物制品", "poct", "体外诊断", "诊断试剂", "过敏原", "脱敏治疗", "细胞培养")), ("SOFTWARE", "软件、数据服务、安全与信创", ("软件", "数据服务", "安全", "信创", "ai应用", "网络游戏", "军事仿真", "嵌入式系统测试", "cax", "cad", "cae", "cam")), ("TRADITIONAL_ENERGY", "煤炭、油气与传统能源", ("煤炭", "煤化工", "油气", "海洋石油", "传统能源")), ("METALS_RESOURCES", "有色金属、矿产与资源品", ("有色金属", "资源开采", "矿产", "锂资源", "镍钴", "稀土", "黄金", "铜矿", "铝加工", "铝合金", "稀有金属", "不锈钢", "合金管", "金属材料")), ("CHEMICAL", "化工、氟化工与化学材料", ("化工", "氟化工", "化学材料", "化学品", "原药", "制剂及中间体", "有机新材料", "催化剂", "分子筛", "高分子", "pvc", "烧碱")), ("AUTO", "汽车、零部件与智能驾驶", ("汽车", "汽车零部件", "智能驾驶", "车载", "客车", "轮胎", "摩托车", "全地形车", "车轮")), ("CONSTRUCTION", "地产、建筑与基础设施", ("地产", "建筑", "基础设施")), ("CONSUMER", "消费、商业服务与文旅", ("消费", "商业服务", "商务服务", "文旅", "旅游", "影视", "珠宝", "金银", "广告", "营销", "品牌传播")), ("TRANSPORT_UTILITY", "交通运输、物流与公用运营", ("交通运输", "物流", "公用运营", "港口", "机场")), ("FINANCIAL", "银行、保险与其他金融", ("银行", "保险", "证券", "金融")), ("INDUSTRIAL", "通用制造、机械设备与工业材料", ("通用制造", "机械设备", "工业材料", "仪器仪表", "检测", "矿用车", "高空作业平台", "工程机械", "高端装备", "重工装备", "轴承", "机械密封", "轨道交通", "刀具", "包装设备", "水泵", "减速机", "试验设备", "铸件", "管材", "密封")), ] ASSET_GROUPS = {"METALS_RESOURCES", "TRADITIONAL_ENERGY", "FERTILIZER", "FINANCIAL"} PS_FRIENDLY_GROUPS = { "SEMICONDUCTOR_EQUIPMENT", "SEMICONDUCTOR_MATERIAL", "WAFER_FOUNDRY", "SEMICONDUCTOR_PACKAGING", "MEMORY", "IC_DESIGN", "AI_OPTICAL", "SOFTWARE", "ROBOT_AUTOMATION", "HEALTHCARE", } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build peer valuation comparisons for all current targets.") parser.add_argument("--project-root", type=Path, default=Path(".")) parser.add_argument( "--valuation-csv", type=Path, default=Path("ana-data/result/股票估值/全量中报重估/全部已评估公司最新估值.csv"), ) parser.add_argument( "--latest-csv", type=Path, default=Path("ana-data/result/股票估值/估值台账/latest.csv"), ) parser.add_argument( "--gaps-csv", type=Path, default=Path("ana-data/result/股票估值/估值台账/latest_gaps.csv"), ) parser.add_argument( "--official-industry-csv", type=Path, default=Path("ana-data/cases/农业案例/extracted/candidate_disposition.csv"), ) parser.add_argument( "--semiconductor-map-csv", type=Path, default=Path("ana-data/cases/半导体案例/核心文档/企业子行业映射.csv"), ) parser.add_argument( "--output-dir", type=Path, default=Path("ana-data/result/股票估值/同行估值比较"), ) parser.add_argument("--generated-at", help="同行输入生成时间;默认使用价格日18:00+08:00") parser.add_argument("--available-at", help="同行输入可用时间;默认等于generated-at") parser.add_argument("--effective-from", help="同行组生效日;默认等于价格日") parser.add_argument("--write", action="store_true") return parser.parse_args() def under(root: Path, value: Path) -> Path: path = value if value.is_absolute() else root / value return path.resolve() def read_csv(path: Path) -> tuple[list[str], list[dict[str, str]]]: with path.open("r", encoding="utf-8-sig", newline="") as handle: reader = csv.DictReader(handle) return list(reader.fieldnames or []), list(reader) def number(value: object) -> float | None: try: result = float(str(value).strip()) except (TypeError, ValueError): return None return result if math.isfinite(result) else None def fmt(value: float | None, digits: int = 4) -> str: if value is None or not math.isfinite(value): return "" return f"{value:.{digits}f}" def quantile(values: list[float], q: float) -> float: ordered = sorted(values) if len(ordered) == 1: return ordered[0] index = (len(ordered) - 1) * q low = math.floor(index) high = math.ceil(index) if low == high: return ordered[low] return ordered[low] + (ordered[high] - ordered[low]) * (index - low) def classify_text(text: str) -> tuple[str, str] | None: lowered = text.lower() for group_id, name, keys in GROUP_RULES: if any(key.lower() in lowered for key in keys): return group_id, name return None def classify_group( row: dict[str, str], official_industry: str, semiconductor_override: tuple[str, str, str] | None, business_text: str, ) -> tuple[str, str, str]: if semiconductor_override is not None: return semiconductor_override current = (row.get("company_type") or "").strip() old = (row.get("old_company_type") or "").strip() official_direct = [ ("金融", "FINANCIAL", "银行、保险与其他金融"), ("建筑", "CONSTRUCTION", "地产、建筑与基础设施"), ("房地产", "CONSTRUCTION", "地产、建筑与基础设施"), ("交通运输", "TRANSPORT_UTILITY", "交通运输、物流与公用运营"), ] for key, group_id, name in official_direct: if key in official_industry: return group_id, name, f"交易所正式行业:{official_industry}" business_match = classify_text(business_text) if business_match: excerpt = business_text[:120] + ("…" if len(business_text) > 120 else "") return business_match[0], business_match[1], f"正式估值快照主营映射:{excerpt}" official_fallback = [ ("农、林、牧、渔", "AGRICULTURE", "农业、农资、农药与养殖"), ("信息传输、软件", "SOFTWARE", "软件、数据服务、安全与信创"), ("卫生和社会工作", "HEALTHCARE", "医药、医疗器械与生命科学服务"), ("采矿", "METALS_RESOURCES", "有色金属、矿产与资源品"), ("电力、热力", "TRANSPORT_UTILITY", "交通运输、物流与公用运营"), ("批发和零售", "CONSUMER", "消费、商业服务与文旅"), ("商务服务", "CONSUMER", "消费、商业服务与文旅"), ("住宿和餐饮", "CONSUMER", "消费、商业服务与文旅"), ("文化、体育", "CONSUMER", "消费、商业服务与文旅"), ] for key, group_id, name in official_fallback: if key in official_industry: return group_id, name, f"交易所正式行业兜底:{official_industry}" for source_name, text in (("原主营类型", old), ("当前主营类型", current)): match = classify_text(text) if match: return match[0], match[1], f"{source_name}映射:{text}" basis = old or current or official_industry or "主营类型缺失" return "OTHER_DIVERSIFIED", "其他或多元化公司", f"宽口径兜底:{basis}" def official_ticker(security_id: str) -> str | None: try: exchange, code = security_id.split(":", 1) except ValueError: return None suffix = {"SSE": "SH", "SZSE": "SZ", "BSE": "BJ"}.get(exchange) return f"{code}.{suffix}" if suffix else None def normalized_company_name(value: str) -> str: name = value.strip().replace("*", "") if name.upper().startswith("ST"): name = name[2:] for suffix in ("股份有限公司", "有限责任公司", "-U", "-W"): if name.endswith(suffix): name = name[: -len(suffix)] return name.strip() def semiconductor_group(subindustry_id: str) -> tuple[str, str] | None: if subindustry_id in {"01", "04", "06"}: return "SEMICONDUCTOR_EQUIPMENT", "半导体设备、厂务与EDA" if subindustry_id == "05": return "SEMICONDUCTOR_MATERIAL", "半导体材料与电子化学品" if subindustry_id in {"03", "14"}: return "WAFER_FOUNDRY", "晶圆制造、硅片与化合物半导体" if subindustry_id == "07": return "SEMICONDUCTOR_PACKAGING", "半导体封装测试" if subindustry_id == "08": return "MEMORY", "存储模组与存储产品" if subindustry_id in {"02", "09", "10", "11", "12", "13"}: return "IC_DESIGN", "集成电路设计与芯片产品" if subindustry_id == "A01": return "AI_OPTICAL", "AI算力、服务器、光通信与散热" return None def business_text_from_snapshot(root: Path, row: dict[str, str]) -> str: snapshot_value = (row.get("snapshot_path") or "").strip() if not snapshot_value: return "" snapshot_path = under(root, Path(snapshot_value)) if not snapshot_path.is_file(): return "" try: payload = json.loads(snapshot_path.read_text(encoding="utf-8-sig")) except (OSError, UnicodeError, json.JSONDecodeError): return "" analysis = payload.get("analysis") if isinstance(payload, dict) else None identity = analysis.get("business_identity") if isinstance(analysis, dict) else None if not isinstance(identity, dict): return "" value = identity.get("main_business") return str(value).strip() if value else "" def scaled_multiple(row: dict[str, str], field: str, current_close: float) -> float | None: old_close = number(row.get("close")) old_value = number(row.get(field)) if not old_close or old_close <= 0 or old_value is None or old_value <= 0: return None value = old_value * current_close / old_close return value if math.isfinite(value) else None def metric_value(row: dict[str, str], close: float, metric: str) -> float | None: shares = number(row.get("shares")) profit = number(row.get("normalized_profit")) if metric.startswith("FORWARD_PE_"): expected_year = metric.removeprefix("FORWARD_PE_") consensus_year = (row.get("consensus_year") or "").strip() consensus_count = number(row.get("consensus_count")) consensus_profit = number(row.get("consensus_profit")) if ( consensus_year != expected_year or consensus_count is None or consensus_count < 3 or not shares or shares <= 0 or not consensus_profit or consensus_profit <= 0 ): return None value = close * shares / consensus_profit elif metric == "NORMALIZED_PE": if not shares or shares <= 0 or not profit or profit <= 0: return None value = close * shares / profit elif metric == "PB": return scaled_multiple(row, "pb", close) elif metric == "PS": return scaled_multiple(row, "ps", close) else: return None return value if math.isfinite(value) and value > 0 else None def metric_denominator(row: dict[str, str], metric: str) -> float | None: """Return the major-currency denominator used by the MySQL peer ledger.""" shares = number(row.get("shares")) old_close = number(row.get("close")) if not shares or shares <= 0: return None if metric.startswith("FORWARD_PE_"): expected_year = metric.removeprefix("FORWARD_PE_") if (row.get("consensus_year") or "").strip() != expected_year: return None value = number(row.get("consensus_profit")) elif metric == "NORMALIZED_PE": value = number(row.get("normalized_profit")) elif metric == "PB": multiple = number(row.get("pb")) value = old_close * shares / multiple if old_close and multiple and multiple > 0 else None elif metric == "PS": multiple = number(row.get("ps")) value = old_close * shares / multiple if old_close and multiple and multiple > 0 else None else: value = None return value if value is not None and math.isfinite(value) and value > 0 else None def decimal_number(value: float) -> float | int: """Limit strict peer-input JSON numbers to at most six decimal places.""" rounded = round(value, 6) return int(rounded) if float(rounded).is_integer() else rounded def choose_metric(row: dict[str, str], close: float, group_id: str) -> tuple[str, float | None, str]: if group_id == "OTHER_DIVERSIFIED": return "NOT_APPLICABLE", None, "主营分类过宽或多元化,不能把兜底组伪装成可比行业" pe = metric_value(row, close, "NORMALIZED_PE") pb = metric_value(row, close, "PB") ps = metric_value(row, close, "PS") if group_id in ASSET_GROUPS and pb is not None and 0.1 <= pb <= 20: return "PB", pb, "资源/周期或资产型公司优先PB,避免峰值利润机械套PE" consensus_year = (row.get("consensus_year") or "").strip() if consensus_year: forward_metric = f"FORWARD_PE_{consensus_year}" forward_pe = metric_value(row, close, forward_metric) if forward_pe is not None and 2 <= forward_pe <= 200: return forward_metric, forward_pe, "至少3家机构且年度一致,优先使用同年度前瞻PE" if pe is not None and 2 <= pe <= 200: return "NORMALIZED_PE", pe, "正利润且正常化PE处于可解释范围" if group_id in PS_FRIENDLY_GROUPS and ps is not None and 0.05 <= ps <= 50: return "PS", ps, "亏损或PE极端敏感,切换PS" if pb is not None and 0.1 <= pb <= 20: return "PB", pb, "PE不可用,使用可复算PB交叉比较" if ps is not None and 0.05 <= ps <= 50: return "PS", ps, "PE/PB不可用,使用PS交叉比较" return "NOT_APPLICABLE", None, "没有可用且有限的同口径倍数" def valid_peer_metric(metric: str, value: float | None) -> bool: if value is None: return False if metric == "NORMALIZED_PE" or metric.startswith("FORWARD_PE_"): return 2 <= value <= 200 if metric == "PB": return 0.1 <= value <= 20 if metric == "PS": return 0.05 <= value <= 50 return False def label(premium: float) -> str: if premium <= -0.30: return "显著低于同行" if premium <= -0.10: return "低于同行" if premium < 0.10: return "接近同行" if premium < 0.30: return "高于同行" return "显著高于同行" def refresh_absolute_price_fields(row: dict[str, str], close: float) -> None: base_low = number(row.get("base_low")) base_high = number(row.get("base_high")) optimistic_high = number(row.get("optimistic_high")) if not base_low or not base_high or base_low <= 0 or base_high < base_low: return row["distance_to_base_low"] = fmt(close / base_low - 1, 8) row["distance_to_base_high"] = fmt(close / base_high - 1, 8) row["premium_to_base_high"] = fmt(close / base_high - 1, 8) if optimistic_high and optimistic_high > 0: row["vs_optimistic_high"] = fmt(close / optimistic_high - 1, 8) midpoint = (base_low + base_high) / 2 if close < base_low: row["label"] = "偏低" row["valuation_position_pct"] = fmt(close / base_low - 1, 8) elif close <= base_high: row["label"] = "基本合理" row["valuation_position_pct"] = fmt(close / midpoint - 1, 8) elif optimistic_high and close <= optimistic_high: row["label"] = "偏贵" row["valuation_position_pct"] = fmt(close / base_high - 1, 8) else: row["label"] = "明显偏贵" row["valuation_position_pct"] = fmt(close / base_high - 1, 8) premium = close / base_high - 1 if premium <= 0: row["bubble_status"] = "未识别估值泡沫" row["bubble_reason"] = "当前收盘价未高于基准合理区间上沿,按统一规则不认定估值泡沫。" elif optimistic_high and close > optimistic_high: row["bubble_status"] = "泡沫-极端(超过乐观上沿)" row["bubble_reason"] = f"当前收盘价高于基准上沿{premium:.1%}且超过乐观上沿,需复核额外叙事和基本面兑现条件。" elif premium <= 0.15: row["bubble_status"] = "泡沫-轻" row["bubble_reason"] = f"当前收盘价高于基准上沿{premium:.1%},处于估值误差与乐观预期交界。" elif premium <= 0.50: row["bubble_status"] = "泡沫-中" row["bubble_reason"] = f"当前收盘价高于基准上沿{premium:.1%},已明显提前支付乐观增长。" else: row["bubble_status"] = "泡沫-高" row["bubble_reason"] = f"当前收盘价高于基准上沿{premium:.1%},大幅提前资本化乐观情景。" def confidence(peer_count: int, group_id: str, basis: str, spread: float | None) -> str: if peer_count < 3: return "不可用" broad = basis.startswith("宽口径兜底") if group_id in {"OTHER_DIVERSIFIED", "INDUSTRIAL", "CONSUMER"}: return "低" if group_id in {"AI_OPTICAL", "DEFENSE", "ROBOT_AUTOMATION", "AUTO", "HEALTHCARE", "SOFTWARE"}: return "中" if peer_count >= 5 else "低" if peer_count >= 10 and not broad and spread is not None and spread <= 1.0: return "较高" if peer_count >= 5 and not broad: return "中" return "低" def quality_interpretation( raw_label: str, target: dict[str, object], peers: list[dict[str, object]], ) -> tuple[str, str]: if target["metric"] != "NORMALIZED_PE" and not str(target["metric"]).startswith("FORWARD_PE_"): return raw_label, "缺少统一的增长、ROE与现金流横截面,本轮不作机械质量调整" target_pb = target.get("pb") target_pe = target.get("value") if not isinstance(target_pb, float) or not isinstance(target_pe, float) or target_pe <= 0: return raw_label, "缺少可比ROE代理,本轮不作机械质量调整" target_roe = target_pb / target_pe peer_roes: list[float] = [] for peer in peers: peer_pb = peer.get("pb") peer_pe = peer.get("value") if isinstance(peer_pb, float) and isinstance(peer_pe, float) and peer_pe > 0: peer_roes.append(peer_pb / peer_pe) if len(peer_roes) < 3: return raw_label, "可比ROE代理少于3个,本轮不作机械质量调整" median_roe = statistics.median(peer_roes) if median_roe <= 0: return raw_label, "同行ROE代理不可解释,本轮不作机械质量调整" gap = target_roe / median_roe - 1 if "低于" in raw_label and gap <= -0.20: return "低倍数但质量偏弱", f"正常化ROE代理较同行中位数低{abs(gap):.1%},折价可能有基本面原因" if "低于" in raw_label and gap >= 0.20: return "低倍数且质量较强", f"正常化ROE代理较同行中位数高{gap:.1%},同行相对估值更有利" if "高于" in raw_label and gap >= 0.20: return "高倍数但部分有质量支撑", f"正常化ROE代理较同行中位数高{gap:.1%},部分溢价有质量支撑" if "高于" in raw_label and gap <= -0.20: return "高倍数且质量偏弱", f"正常化ROE代理较同行中位数低{abs(gap):.1%},溢价缺乏质量支撑" return raw_label, f"正常化ROE代理相对同行偏离{gap:.1%},不足以改变原始倍数位置解释" def atomic_write(path: Path, data: bytes) -> bool: path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and path.read_bytes() == data: return False fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temp = Path(temp_name) try: with os.fdopen(fd, "wb") as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) os.replace(temp, path) finally: if temp.exists(): temp.unlink() return True def csv_bytes(fieldnames: list[str], rows: Iterable[dict[str, str]]) -> bytes: import io stream = io.StringIO(newline="") writer = csv.DictWriter(stream, fieldnames=fieldnames, extrasaction="ignore", lineterminator="\n") writer.writeheader() writer.writerows(rows) return stream.getvalue().encode("utf-8") def md_escape(value: object) -> str: return str(value or "").replace("|", "\\|").replace("\n", " ") def render_summary(rows: list[dict[str, str]], gaps: list[dict[str, str]], as_of: str) -> bytes: counts = Counter(row["peer_raw_label"] or "不可用" for row in rows) metrics = Counter(row["peer_metric"] for row in rows) lines = [ "# 全部已评估公司同行估值比较", "", f"- 价格日期:`{as_of}`", f"- 已形成同行比较:`{sum(1 for row in rows if row['peer_raw_label'])}`只;不可用:`{sum(1 for row in rows if not row['peer_raw_label'])}`只;价格/口径缺口:`{len(gaps)}`只。", "- 主判定使用目标公司剔除自身后的同行中位数;算术均值仅辅助展示,不参与主分档。", "- 本表的合理区间来自全量中报重估当前基线,同行可比池限定为已完成正式估值且具有同日同口径倍数的证券,不冒充全A股行业总体。", "- MySQL每日台账仍对有效区间重叠证券失败关闭;本表的逐股直接复算不等于这些证券已经通过每日台账生效门禁。", "- 同行比较不替代三情景合理区间,不构成目标价或交易指令。", "", "## 分布", "", "| 项目 | 数量 |", "|---|---:|", ] for name in ["显著低于同行", "低于同行", "接近同行", "高于同行", "显著高于同行", "不可用"]: lines.append(f"| {name} | {counts.get(name, 0)} |") lines.extend(["", "## 指标使用", "", "| 指标 | 数量 |", "|---|---:|"]) for name, count in sorted(metrics.items()): lines.append(f"| {md_escape(name)} | {count} |") lines.extend( [ "", "## 全量排序", "", "同一原始标签内按相对同行中位数溢折价从低到高排列。同行分组为当前估值主营类型的规则化映射;宽口径兜底组和小样本已降低置信度。", "", "| 排名 | 代码 | 公司 | 所属可比行业 | 收盘价 | 指标 | 目标倍数 | 同行样本 | 同行均值 | 同行中位数 | 相对中位数 | 原始标签 | 调整后解释 | 置信度 | 基准区间 | 原价格判定 | 正式报告 |", "|---:|---|---|---|---:|---|---:|---:|---:|---:|---:|---|---|---|---|---|---|", ] ) for index, row in enumerate(rows, start=1): base = f"{row.get('base_low','')}—{row.get('base_high','')}" premium = f"{float(row['peer_premium_pct']):.1%}" if row.get("peer_premium_pct") else "—" report = row.get("report_path", "") report_link = f"`{report}`" if report else "—" values = [ index, row.get("ticker"), row.get("company"), row.get("industry"), row.get("close"), row.get("peer_metric"), row.get("target_multiple") or "—", row.get("peer_count") or "0", row.get("peer_mean") or "—", row.get("peer_median") or "—", premium, row.get("peer_raw_label") or "不可用", row.get("peer_adjusted_label") or "—", row.get("peer_confidence"), base, row.get("label"), report_link, ] lines.append("| " + " | ".join(md_escape(value) for value in values) + " |") if gaps: lines.extend(["", "## 价格或口径缺口", "", "详见同目录 `同行估值比较缺口.csv`。"]) return ("\n".join(lines) + "\n").encode("utf-8") def render_latest(rows: list[dict[str, str]], as_of: str) -> bytes: counts = Counter(row.get("label", "") for row in rows) peer_counts = Counter(row.get("peer_raw_label", "") or "不可用" for row in rows) lines = [ "# 股票估值最新判定", "", f"- 价格日期:`{as_of}`", f"- 数值判定:`{len(rows)}`只;四档分布:偏低{counts.get('偏低',0)}、基本合理{counts.get('基本合理',0)}、偏贵{counts.get('偏贵',0)}、明显偏贵{counts.get('明显偏贵',0)}。", f"- 同行分布:显著低于{peer_counts.get('显著低于同行',0)}、低于{peer_counts.get('低于同行',0)}、接近{peer_counts.get('接近同行',0)}、高于{peer_counts.get('高于同行',0)}、显著高于{peer_counts.get('显著高于同行',0)}、不可用{peer_counts.get('不可用',0)}。", "- 同行主统计量为剔除自身后的中位数;均值仅辅助。同行倍数位置不替代绝对合理区间。", "", "| 代码 | 公司 | 所属可比行业 | 收盘价 | 基准区间 | 原判定 | 泡沫判定 | 高于基准上沿 | 主要泡沫原因 | 原因说明 | 泡沫置信度 | 60日涨幅 | 相对MA60 | 同行指标 | 目标倍数 | 同行均值 | 同行中位数 | 相对中位数 | 同行原始标签 | 同行解释 | 同行置信度 | 正式报告 |", "|---|---|---|---:|---|---|---|---:|---|---|---|---:|---:|---|---:|---:|---:|---:|---|---|---|---|", ] for row in rows: premium = f"{float(row['peer_premium_pct']):.1%}" if row.get("peer_premium_pct") else "—" report = row.get("report_path", "") link = f"`{report}`" if report else "—" bubble_premium = row.get("bubble_premium_pct", "") return_60d = row.get("return_60d_pct", "") distance_ma60 = row.get("distance_to_ma60_pct", "") values = [ row.get("ticker"), row.get("company"), row.get("industry"), row.get("close"), f"{row.get('base_low','')}—{row.get('base_high','')}", row.get("label"), row.get("bubble_status", ""), f"{bubble_premium}%" if bubble_premium else "—", row.get("bubble_primary_cause", ""), row.get("bubble_reason", ""), row.get("bubble_confidence", ""), f"{return_60d}%" if return_60d else "—", f"{distance_ma60}%" if distance_ma60 else "—", row.get("peer_metric"), row.get("target_multiple") or "—", row.get("peer_mean") or "—", row.get("peer_median") or "—", premium, row.get("peer_raw_label") or "不可用", row.get("peer_adjusted_label") or "不可用", row.get("peer_confidence"), link, ] lines.append("| " + " | ".join(md_escape(value) for value in values) + " |") return ("\n".join(lines) + "\n").encode("utf-8") def main() -> int: args = parse_args() root = args.project_root.resolve() valuation_path = under(root, args.valuation_csv) latest_path = under(root, args.latest_csv) gaps_path = under(root, args.gaps_csv) official_industry_path = under(root, args.official_industry_csv) semiconductor_map_path = under(root, args.semiconductor_map_csv) output_dir = under(root, args.output_dir) valuation_fields, valuation_rows = read_csv(valuation_path) latest_fields, latest_rows = read_csv(latest_path) _, gap_rows = read_csv(gaps_path) _, official_rows = read_csv(official_industry_path) _, semiconductor_rows = read_csv(semiconductor_map_path) official_by_ticker: dict[str, str] = {} for official_row in official_rows: ticker = official_ticker(official_row.get("security_id", "")) industry = official_row.get("classification_text", "").strip() if not ticker or not industry: continue previous = official_by_ticker.setdefault(ticker, industry) if previous != industry: raise RuntimeError(f"Conflicting official industries for {ticker}: {previous!r} vs {industry!r}") semiconductor_candidates: dict[str, set[tuple[str, str]]] = {} for semiconductor_row in semiconductor_rows: name = normalized_company_name( semiconductor_row.get("canonical_name", "") or semiconductor_row.get("display_name", "") ) mapped = semiconductor_group(semiconductor_row.get("subindustry_id", "").strip()) if name and mapped: semiconductor_candidates.setdefault(name, set()).add(mapped) semiconductor_by_company = { name: (next(iter(values))[0], next(iter(values))[1], "半导体研究正式子行业映射") for name, values in semiconductor_candidates.items() if len(values) == 1 } valuation_by_ticker = {row["ticker"]: row for row in valuation_rows} if len(valuation_by_ticker) != len(valuation_rows): raise RuntimeError("Duplicate tickers in valuation CSV") trade_dates = {row.get("trade_date", "") for row in latest_rows if row.get("trade_date")} if len(trade_dates) != 1: raise RuntimeError(f"latest.csv must contain one trade date, got {sorted(trade_dates)}") as_of = next(iter(trade_dates)) generated_at = args.generated_at or f"{as_of}T18:00:00+08:00" available_at = args.available_at or generated_at effective_from = args.effective_from or as_of prices = {row["ticker"]: (number(row.get("close")), row.get("trade_date", "")) for row in latest_rows} for row in gap_rows: if row.get("price_date") == as_of and number(row.get("close")): prices.setdefault(row["ticker"], (number(row.get("close")), as_of)) prepared: list[dict[str, object]] = [] result_gaps: list[dict[str, str]] = [] for ticker, row in valuation_by_ticker.items(): close, price_date = prices.get(ticker, (None, "")) if close is None or close <= 0 or price_date != as_of: result_gaps.append({ "ticker": ticker, "company": row.get("company", ""), "reason": "缺少与全量基准一致的同日完整收盘价", "price_date": price_date, "close": fmt(close, 4), }) continue official_industry = official_by_ticker.get(ticker, "") semiconductor_override = semiconductor_by_company.get(normalized_company_name(row.get("company", ""))) business_text = business_text_from_snapshot(root, row) group_id, group_name, basis = classify_group(row, official_industry, semiconductor_override, business_text) metric, value, route_reason = choose_metric(row, close, group_id) pb = metric_value(row, close, "PB") prepared.append({ "ticker": ticker, "row": row, "close": close, "group_id": group_id, "group_name": group_name, "basis": basis, "metric": metric, "value": value, "pb": pb, "route_reason": route_reason, }) for latest_row in latest_rows: if latest_row["ticker"] not in valuation_by_ticker: result_gaps.append({ "ticker": latest_row["ticker"], "company": latest_row.get("company", ""), "reason": "当前台账有价格判定,但缺少本轮统一财务估值基线,同行倍数不可算", "price_date": latest_row.get("trade_date", ""), "close": latest_row.get("close", ""), }) for gap_row in gap_rows: if gap_row["ticker"] in valuation_by_ticker or gap_row.get("status") == "缺少有效估值版本": continue result_gaps.append({ "ticker": gap_row["ticker"], "company": gap_row.get("company", ""), "reason": gap_row.get("reason", "") or gap_row.get("status", ""), "price_date": gap_row.get("price_date", ""), "close": gap_row.get("close", ""), }) by_group: dict[str, list[dict[str, object]]] = {} for item in prepared: by_group.setdefault(str(item["group_id"]), []).append(item) comparisons: list[dict[str, str]] = [] comparison_by_ticker: dict[str, dict[str, str]] = {} peer_groups: list[dict[str, object]] = [] for item in prepared: row = dict(item["row"]) row["close"] = fmt(item["close"], 4) row["price_date"] = as_of refresh_absolute_price_fields(row, float(item["close"])) metric = str(item["metric"]) target_value = item.get("value") if isinstance(item.get("value"), float) else None route_reason = str(item["route_reason"]) def peers_for(selected_metric: str) -> list[dict[str, object]]: selected: list[dict[str, object]] = [] for peer in by_group.get(str(item["group_id"]), []): if peer["ticker"] == item["ticker"]: continue value = metric_value(dict(peer["row"]), float(peer["close"]), selected_metric) if valid_peer_metric(selected_metric, value): enriched_peer = dict(peer) enriched_peer["value"] = value selected.append(enriched_peer) return selected peers = peers_for(metric) if metric != "NOT_APPLICABLE" else [] if len(peers) < 3 and metric.startswith("FORWARD_PE_"): fallback_value = metric_value(dict(item["row"]), float(item["close"]), "NORMALIZED_PE") fallback_peers = peers_for("NORMALIZED_PE") if valid_peer_metric("NORMALIZED_PE", fallback_value) and len(fallback_peers) >= 3: metric = "NORMALIZED_PE" target_value = fallback_value peers = fallback_peers route_reason += ";同年度前瞻PE同行少于3只,降级为正常化PE" peer_values = [float(peer["value"]) for peer in peers if isinstance(peer.get("value"), float)] peer_gap = "" raw = "" adjusted = "" adjustment_reason = route_reason mean = median = p25 = p75 = premium = vs_mean = None if metric == "NOT_APPLICABLE" or not isinstance(target_value, float): peer_gap = route_reason elif len(peer_values) < 3: peer_gap = f"目标剔除自身后只有{len(peer_values)}个同口径有效同行,少于3个" else: mean = statistics.fmean(peer_values) median = statistics.median(peer_values) p25 = quantile(peer_values, 0.25) p75 = quantile(peer_values, 0.75) premium = target_value / median - 1 vs_mean = target_value / mean - 1 raw = label(premium) target_for_quality = dict(item) target_for_quality["metric"] = metric target_for_quality["value"] = target_value adjusted, quality_reason = quality_interpretation(raw, target_for_quality, peers) adjustment_reason = f"{route_reason};{quality_reason}" spread = (p75 - p25) / median if p75 is not None and p25 is not None and median else None peer_confidence = confidence(len(peer_values), str(item["group_id"]), str(item["basis"]), spread) peer_fields = { "industry": str(item["group_name"]), "peer_group_id": str(item["group_id"]), "peer_group_name": str(item["group_name"]), "peer_group_basis": str(item["basis"]), "peer_tickers": ";".join(sorted(str(peer["ticker"]) for peer in peers)), "peer_metric": metric, "target_multiple": fmt(target_value, 8), "peer_count": str(len(peer_values)), "peer_mean": fmt(mean, 8), "peer_median": fmt(median, 8), "peer_p25": fmt(p25, 8), "peer_p75": fmt(p75, 8), "peer_premium_pct": fmt(premium, 6), "peer_vs_mean_pct": fmt(vs_mean, 6), "peer_raw_label": raw, "peer_adjusted_label": adjusted, "peer_adjustment_reason": adjustment_reason, "peer_confidence": peer_confidence, "peer_as_of": as_of, "peer_gap_reason": peer_gap, } row.update(peer_fields) comparisons.append(row) comparison_by_ticker[str(item["ticker"])] = peer_fields if raw and len(peers) >= 3 and isinstance(target_value, float): member_items = [item, *peers] members: list[dict[str, object]] = [] for member_item in member_items: member_row = dict(member_item["row"]) denominator = metric_denominator(member_row, metric) shares = number(member_row.get("shares")) if denominator is None or shares is None or shares <= 0: raise RuntimeError( f"Missing strict peer denominator for {item['ticker']} member {member_item['ticker']} metric {metric}" ) members.append({ "ticker": str(member_item["ticker"]), "role": "TARGET" if member_item["ticker"] == item["ticker"] else "PEER", "equity_units": decimal_number(shares), "enterprise_value_adjustment": 0, "metric_denominator": decimal_number(denominator), "eligible": True, "exclusion_reason": None, "selection_reason": ( f"同属{item['group_name']},使用{metric}同日横向比较;目标股不进入自身同行样本" ), "source_id": "stock_valuation_peer_reference_v1", "evidence_ref": member_row.get("report_path", "") or member_row.get("snapshot_path", ""), }) configured_confidence = {"较高": "HIGH", "中": "MEDIUM"}.get(peer_confidence, "LOW") metric_basis_period = ( f"{metric.removeprefix('FORWARD_PE_')}E" if metric.startswith("FORWARD_PE_") else ("NORMALIZED_TTM" if metric == "NORMALIZED_PE" else (row.get("latest_period") or "LATEST_REPORTED")) ) peer_groups.append({ "peer_group_id": str(item["group_id"]), "target_ticker": str(item["ticker"]), "industry": str(item["group_name"]), "peer_group_name": str(item["group_name"]), "metric": "FORWARD_PE" if metric.startswith("FORWARD_PE_") else metric, "metric_basis_period": metric_basis_period, "currency": str(row.get("currency") or "CNY"), "monetary_unit": f"{str(row.get('currency') or 'CNY')}_MAJOR", "basis_as_of": str(row.get("valuation_date") or as_of), "effective_from": effective_from, "configured_confidence": configured_confidence, "quality_adjusted_label": adjusted or None, "adjustment_reason": adjustment_reason, "selection_basis": ( f"{item['basis']};剔除目标自身;仅使用当前正式估值池中同日、同币种、同指标且倍数有效的公司" ), "evidence_ref": "ana-data/result/股票估值/同行估值比较/全部已评估公司同行估值比较.csv", "members": members, }) label_order = {"显著低于同行": 0, "低于同行": 1, "接近同行": 2, "高于同行": 3, "显著高于同行": 4, "": 5} comparisons.sort(key=lambda row: (label_order.get(row.get("peer_raw_label", ""), 5), number(row.get("peer_premium_pct")) or 0, row["ticker"])) enriched_latest: list[dict[str, str]] = [] for row in latest_rows: enriched = dict(row) peer = comparison_by_ticker.get(row["ticker"]) if peer is None: peer = {field: "" for field in PEER_FIELDS} peer["peer_gap_reason"] = "当前正式估值基线缺失" enriched.update(peer) enriched_latest.append(enriched) output_fields = list(latest_fields) for field in PEER_FIELDS: if field not in output_fields: output_fields.append(field) comparison_fields = list(valuation_fields) if "trade_date" not in comparison_fields: comparison_fields.append("trade_date") for field in PEER_FIELDS: if field not in comparison_fields: comparison_fields.append(field) for row in comparisons: row["trade_date"] = as_of peer_input = { "schema_version": 1, "source_id": "stock_valuation_peer_reference_v1", "generated_at": generated_at, "available_at": available_at, "groups": sorted(peer_groups, key=lambda group: str(group["target_ticker"])), } files = { latest_path: csv_bytes(output_fields, enriched_latest), latest_path.with_suffix(".md"): render_latest(enriched_latest, as_of), output_dir / "全部已评估公司同行估值比较.csv": csv_bytes(comparison_fields, comparisons), output_dir / "全部已评估公司同行估值比较.md": render_summary(comparisons, result_gaps, as_of), output_dir / "同行估值比较缺口.csv": csv_bytes(["ticker", "company", "reason", "price_date", "close"], result_gaps), output_dir / "同行组输入.json": ( json.dumps(peer_input, ensure_ascii=False, indent=2, sort_keys=True) + "\n" ).encode("utf-8"), } base_latest_fields = [field for field in latest_fields if field not in PEER_FIELDS] manifest_payload = { "schema": "stock_valuation_peer_comparison_manifest_v1", "as_of": as_of, "scope": "current_formally_valued_securities_with_same-day-comparable-multiples", "universe": { "current_valuation": len(comparisons) + len(result_gaps), "same_day_priced_and_baselined": len(comparisons), "peer_comparable": sum(1 for row in comparisons if row["peer_raw_label"]), "peer_not_applicable": sum(1 for row in comparisons if not row["peer_raw_label"]), "gaps": len(result_gaps), "mysql_peer_groups": len(peer_groups), }, "contract": { "target_excluded": True, "minimum_peer_count": 3, "primary_statistic": "median", "auxiliary_statistics": ["mean", "p25", "p75"], "premium_formula": "target_multiple / peer_median - 1", "full_a_share_industry_claim": False, }, "inputs": { str(valuation_path.relative_to(root)): hashlib.sha256(valuation_path.read_bytes()).hexdigest().upper(), str(official_industry_path.relative_to(root)): hashlib.sha256(official_industry_path.read_bytes()).hexdigest().upper(), str(semiconductor_map_path.relative_to(root)): hashlib.sha256(semiconductor_map_path.read_bytes()).hexdigest().upper(), f"{latest_path.relative_to(root)}#without_peer_fields": hashlib.sha256( csv_bytes(base_latest_fields, latest_rows) ).hexdigest().upper(), }, "outputs": { str(path.relative_to(root)): hashlib.sha256(data).hexdigest().upper() for path, data in files.items() }, } files[output_dir / "manifest.json"] = ( json.dumps(manifest_payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" ).encode("utf-8") changed: dict[str, bool] = {} if args.write: for path, data in files.items(): changed[str(path.relative_to(root))] = atomic_write(path, data) summary = { "mode": "WRITE" if args.write else "DRY_RUN", "as_of": as_of, "valuation_universe": len(valuation_rows), "current_valuation_universe": len(comparisons) + len(result_gaps), "same_day_priced": len(prepared), "peer_comparable": sum(1 for row in comparisons if row["peer_raw_label"]), "peer_not_applicable": sum(1 for row in comparisons if not row["peer_raw_label"]), "price_or_baseline_gaps": len(result_gaps), "latest_rows": len(enriched_latest), "labels": dict(Counter(row["peer_raw_label"] or "不可用" for row in comparisons)), "metrics": dict(Counter(row["peer_metric"] for row in comparisons)), "files_changed": changed, "output_sha256": { str(path.relative_to(root)): hashlib.sha256(data).hexdigest().upper() for path, data in files.items() }, } print(json.dumps(summary, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())