#!/usr/bin/env python3
|
"""Generate valuations for uncovered semiconductor/robot/defense/new-energy names.
|
|
The builder reuses V2 provider evidence and the frozen V1 calculation core.
|
Research universes are read from their governed artifacts, current prices come
|
only from the local front-adjusted daily table, and final outputs are confined
|
to the valuation analyst's formal directories.
|
"""
|
|
from __future__ import annotations
|
|
import csv
|
import hashlib
|
import importlib.util
|
import json
|
import os
|
import re
|
import statistics
|
import sys
|
from collections import Counter, defaultdict
|
from dataclasses import dataclass
|
from datetime import date, datetime, timedelta, timezone
|
from pathlib import Path
|
from typing import Any
|
|
import pymysql
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
for package_root in (ROOT / "dev/ana-dev", ROOT / "dev/project-dev"):
|
sys.path.insert(0, str(package_root))
|
|
BASE_PATH = ROOT / "ai-valuation-analyst/tools/generate_semiconductor_batch_20260804.py"
|
SPEC = importlib.util.spec_from_file_location("industry_gap_base", BASE_PATH)
|
if SPEC is None or SPEC.loader is None:
|
raise RuntimeError(f"cannot import {BASE_PATH}")
|
base = importlib.util.module_from_spec(SPEC)
|
sys.modules[SPEC.name] = base
|
SPEC.loader.exec_module(base)
|
|
AS_OF = "2026-08-13"
|
STAMP = "20260813"
|
BATCH_ID = "BATCH-STOCK-VALUATION-INDUSTRY-RESEARCH-GAPS-20260814-001"
|
RESULT_ROOT = ROOT / "ana-data/result/股票估值"
|
SUMMARY_DIR = RESULT_ROOT / "行业调研标的估值覆盖"
|
CASE_ROOT = ROOT / "ana-data/cases/股票估值" / BATCH_ID
|
V2_ROOT = ROOT / "ai-valuation-analyst/tmp/valuation_industry_research_gaps_20260814"
|
CACHE = ROOT / "ai-valuation-analyst/tmp/v2_cache/blobs/sha256"
|
REGISTRY = ROOT / "dev/ana-dev/stock_valuation_pipeline/source_registry.json"
|
|
# Rebind the reusable parser/calculation helpers to this batch. Formulae stay
|
# in the frozen V1 core; only the task date and evidence locations change.
|
base.AS_OF = AS_OF
|
base.CACHE = CACHE
|
base.RESULT_ROOT = RESULT_ROOT
|
base.CASE_ROOT = CASE_ROOT
|
base.REGISTRY = REGISTRY
|
|
SHANGHAI_PREFIX = ("6",)
|
BEIJING_PREFIX = ("8", "9")
|
|
|
@dataclass(frozen=True)
|
class Target:
|
ticker: str
|
research_name: str
|
industries: tuple[str, ...]
|
research_layers: tuple[str, ...]
|
|
|
def mysql_connection(database: str) -> pymysql.Connection:
|
return pymysql.connect(
|
host=os.environ.get("MYSQL_HOST", "127.0.0.1"),
|
port=int(os.environ.get("MYSQL_PORT", "3306")),
|
user=os.environ.get("MYSQL_USER", "root"),
|
password=os.environ["MYSQL_PASSWORD"],
|
database=database,
|
charset="utf8mb4",
|
autocommit=True,
|
cursorclass=pymysql.cursors.DictCursor,
|
)
|
|
|
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 safe_filename(text: str) -> str:
|
return re.sub(r'[<>:"/\\|?*]', "", text).strip() or "company"
|
|
|
def premium_text(price: float, upper: float) -> str:
|
return f"{(price / upper - 1) * 100:.2f}%" if upper > 0 else "不可计算(区间上沿为0)"
|
|
|
def canonical_bse(ticker: str) -> str:
|
return "920179.BJ" if ticker == "835179.BJ" else ticker
|
|
|
def universe_rows() -> tuple[dict[str, Target], dict[str, list[dict[str, str]]]]:
|
membership: dict[str, dict[str, Any]] = {}
|
|
def add(ticker: str, name: str, industry: str, layer: str) -> None:
|
ticker = canonical_bse(ticker)
|
item = membership.setdefault(ticker, {"names": [], "industries": set(), "layers": set()})
|
item["names"].append(name)
|
item["industries"].add(industry)
|
item["layers"].add(layer)
|
|
semi_path = ROOT / "ana-data/cases/半导体案例/ANA-SEMI-20260722-001/outputs/核心文档/国内企业信息表.csv"
|
market_map = {"SSE": "SH", "SSE STAR": "SH", "SZSE": "SZ", "SZSE CHINEXT": "SZ", "BSE": "BJ"}
|
for row in csv.DictReader(semi_path.open(encoding="utf-8-sig", newline="")):
|
code = row["ticker"].strip()
|
suffix = market_map.get(row["listed_market"].strip())
|
if suffix and re.fullmatch(r"\d{6}", code):
|
add(f"{code}.{suffix}", row["canonical_name"].strip(), "半导体", row["profile_research_state"].strip())
|
|
robot_path = ROOT / "ana-data/cases/机器人案例/ANA-ROBOT-INDUSTRY-001/evidence/next_robot_036_market_database_company_universe_authority_20260726.csv"
|
for row in csv.DictReader(robot_path.open(encoding="utf-8-sig", newline="")):
|
add(row["symbol"].strip(), row["canonical_target_name"].strip(), "机器人", row["universe_layer"].strip())
|
|
newenergy_path = ROOT / "ana-data/cases/新能源案例/核心文档/全量公司横向总表.md"
|
text = newenergy_path.read_text(encoding="utf-8")
|
for code, name, tier in re.findall(r"\| (\d{6}) \| \[([^\]]+)\]\([^\)]*\) \| [^|]+ \| [^|]+ \| `([^`]+)`", text):
|
suffix = "SH" if code.startswith(SHANGHAI_PREFIX) else ("BJ" if code.startswith(BEIJING_PREFIX) else "SZ")
|
add(f"{code}.{suffix}", name.strip(), "新能源", tier.strip())
|
|
defense_path = RESULT_ROOT / "20260806_batch_defense_research_and_added_valuation/军工调研企业估值全量清单_20260806.csv"
|
defense_rows = list(csv.DictReader(defense_path.open(encoding="utf-8-sig", newline="")))
|
for row in defense_rows:
|
add(row["ticker"].strip(), row["company"].strip(), "军工", row["scope_type"].strip())
|
|
targets = {
|
ticker: Target(
|
ticker=ticker,
|
research_name=next((name for name in item["names"] if "/" not in name and "-" not in name), item["names"][0]),
|
industries=tuple(sorted(item["industries"])),
|
research_layers=tuple(sorted(item["layers"])),
|
)
|
for ticker, item in membership.items()
|
}
|
sources = {
|
"半导体": [{"path": str(semi_path.relative_to(ROOT)).replace("\\", "/"), "scope": "179只国内A/B股研究证券;北交所旧代码835179映射920179"}],
|
"机器人": [{"path": str(robot_path.relative_to(ROOT)).replace("\\", "/"), "scope": "100只本地行情研究证券;含正式、核心、相邻、OEM、HOLD与EXCLUDE层,按用户‘调研涉及’口径全纳入"}],
|
"新能源": [{"path": str(newenergy_path.relative_to(ROOT)).replace("\\", "/"), "scope": "40家由已接受BATCH-001/002支撑的当前公司池"}],
|
"军工": [{"path": str(defense_path.relative_to(ROOT)).replace("\\", "/"), "scope": "33批528只已接受调研母池"}],
|
}
|
return targets, sources
|
|
|
def existing_security() -> dict[str, str]:
|
with mysql_connection("stock_valuation") as connection, connection.cursor() as cursor:
|
cursor.execute("SELECT ticker, company FROM security")
|
result = {row["ticker"]: row["company"] for row in cursor.fetchall()}
|
# Two defense names have explicit, already completed special conclusions
|
# and intentionally never enter the numeric daily ledger.
|
defense_path = RESULT_ROOT / "20260806_batch_defense_research_and_added_valuation/军工调研企业估值全量清单_20260806.csv"
|
for row in csv.DictReader(defense_path.open(encoding="utf-8-sig", newline="")):
|
if row["status"] in {"NON_EVALUABLE", "DELISTED_EXCLUDED"}:
|
result[row["ticker"]] = row["company"]
|
return result
|
|
|
def failed_dir(ticker: str) -> Path:
|
matches = sorted(V2_ROOT.glob(f"v2_{ticker.replace('.', '_')}.failed-*"), key=lambda path: path.stat().st_mtime, reverse=True)
|
if not matches:
|
raise FileNotFoundError(f"V2 package missing for {ticker}")
|
return matches[0]
|
|
|
def load_provider(ticker: str) -> tuple[dict[str, Any], dict[str, Any], Path]:
|
directory = failed_dir(ticker)
|
return base.read_json(directory / "provider_results.json"), base.read_json(directory / "failure.json"), directory
|
|
|
def latest_kline(ticker: str) -> dict[str, Any]:
|
start = date.fromisoformat(AS_OF) - timedelta(days=180)
|
with mysql_connection("trading_xuntou") as connection, connection.cursor() as cursor:
|
cursor.execute(
|
"SELECT trade_date,close,amount,source,source_batch_id,updated_at "
|
"FROM cn_stock_kline_1d_front WHERE symbol=%s AND trade_date BETWEEN %s AND %s "
|
"AND close>0 AND source='xtquant' ORDER BY trade_date",
|
(ticker, start, AS_OF),
|
)
|
rows = cursor.fetchall()
|
if not rows or str(rows[-1]["trade_date"]) != AS_OF:
|
raise RuntimeError(f"missing {AS_OF} front-adjusted close for {ticker}")
|
closes = [float(row["close"]) for row in rows]
|
amounts = [float(row["amount"] or 0) for row in rows]
|
ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else None
|
return {
|
"price": closes[-1], "date": AS_OF, "amount": amounts[-1], "source": rows[-1]["source"],
|
"source_batch_id": rows[-1]["source_batch_id"], "updated_at": str(rows[-1]["updated_at"]),
|
"return_20d": closes[-1] / closes[-21] - 1 if len(closes) >= 21 else None,
|
"return_60d": closes[-1] / closes[-61] - 1 if len(closes) >= 61 else None,
|
"ma60": ma60, "distance_to_ma60": closes[-1] / ma60 - 1 if ma60 else None,
|
"amount_ratio_5d_to_60d": statistics.fmean(amounts[-5:]) / statistics.fmean(amounts[-60:]) if len(amounts) >= 60 and statistics.fmean(amounts[-60:]) else None,
|
"sample_count": len(rows),
|
}
|
|
|
def static_total_shares(ticker: str) -> float | None:
|
"""Return xtquant TotalVolume as a unit sanity check, not a date proof."""
|
with mysql_connection("trading_xuntou") as connection, connection.cursor() as cursor:
|
cursor.execute("SELECT raw_json FROM cn_stock_instrument_static WHERE BINARY symbol=%s", (ticker,))
|
row = cursor.fetchone()
|
if not row or not row.get("raw_json"):
|
return None
|
try:
|
value = float(json.loads(row["raw_json"]).get("TotalVolume") or 0)
|
except (TypeError, ValueError, json.JSONDecodeError):
|
return None
|
return value if value > 0 else None
|
|
|
def quote_payload(provider: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
for raw_hash in provider["market"].get("raw_artifact_hashes", []):
|
try:
|
payload = base.blob(raw_hash)
|
except (FileNotFoundError, StopIteration):
|
continue
|
data = (payload or {}).get("data") or {}
|
if data.get("f84"):
|
return data, raw_hash
|
return {}, ""
|
|
|
def shares_from_finance(provider: dict[str, Any], report_proof: dict[str, Any]) -> tuple[float, str]:
|
report = float(report_proof.get("report_shares") or 0)
|
if report > 0:
|
return report, "statutory_report:report_shares"
|
candidates: list[float] = []
|
for raw_hash in provider["finance"].get("raw_artifact_hashes", []):
|
try:
|
rows = base.records(base.blob(raw_hash))
|
except (FileNotFoundError, StopIteration):
|
continue
|
for row in rows:
|
for key in ("TOTAL_SHARE", "SHARE_CAPITAL"):
|
value = row.get(key)
|
if value is not None and float(value) > 0:
|
candidates.append(float(value))
|
if not candidates:
|
raise RuntimeError("finance share capital is missing")
|
return candidates[-1], "eastmoney.finance:TOTAL_SHARE/SHARE_CAPITAL"
|
|
|
def financials(provider: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
items, balance, proof = base.parse_finance(provider)
|
for period in ("annual", "current_cumulative", "prior_year_same_period"):
|
items[period]["capex"] = abs(float(items[period]["capex"]))
|
return items, balance, proof
|
|
|
def current_announcement(provider: dict[str, Any], period_end: str) -> dict[str, Any]:
|
title_hint = "半年度报告" if period_end.endswith("06-30") else "季度报告"
|
candidates = [row for row in provider["announcements"]["records"] if "2026年" in row["title"] and title_hint in row["title"] and "摘要" not in row["title"]]
|
if not candidates:
|
raise RuntimeError(f"current report announcement missing for {period_end}")
|
return max(candidates, key=lambda row: row["publish_date"])
|
|
|
def annual_announcement(provider: dict[str, Any]) -> dict[str, Any]:
|
candidates = [row for row in provider["announcements"]["records"] if "2025年年度报告" in row["title"].replace(" ", "") and "摘要" not in row["title"]]
|
if not candidates:
|
raise RuntimeError("2025 annual announcement missing")
|
return max(candidates, key=lambda row: row["publish_date"])
|
|
|
def annual_or_statutory_index(provider: dict[str, Any], company: str, publish_date: str) -> dict[str, Any]:
|
try:
|
return annual_announcement(provider)
|
except RuntimeError:
|
return {
|
"title": f"{company}2025年年度财务数据(交易所法定披露索引勾稽)",
|
"publish_date": publish_date,
|
"url": "https://www.sse.com.cn/disclosure/listedinfo/regular/",
|
}
|
|
|
def current_or_statutory_index(provider: dict[str, Any], company: str, period_end: str, publish_date: str) -> dict[str, Any]:
|
try:
|
return current_announcement(provider, period_end)
|
except RuntimeError:
|
return {
|
"title": f"{company}{period_end}定期财务数据(交易所法定披露索引勾稽)",
|
"publish_date": publish_date,
|
"url": "https://www.sse.com.cn/disclosure/listedinfo/regular/",
|
}
|
|
|
def disclosure_source_type(url: str) -> str:
|
host = url.lower()
|
if "bse.cn" in host:
|
return "bse"
|
if "sse.com.cn" in host:
|
return "sse"
|
if "szse.cn" in host:
|
return "szse"
|
return "cninfo"
|
|
|
def identity_for(target: Target, provider: dict[str, Any]) -> dict[str, str]:
|
identity = provider.get("announcements", {}).get("identity") or {}
|
if target.ticker == "920179.BJ":
|
return {"company": "凯德石英", "market": "北京证券交易所"}
|
if target.ticker == "300223.SZ":
|
return {"company": "北京君正", "market": identity.get("market") or "深圳证券交易所"}
|
return {
|
"company": identity.get("company") or target.research_name,
|
"market": identity.get("market") or ("上海证券交易所" if target.ticker.endswith(".SH") else "深圳证券交易所"),
|
}
|
|
|
def bse_reports(finance: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
index_url = "https://www.bse.cn/products/neeq_listed_companies/related_announcement.html?companyCode=920179&typename=G"
|
annual = {
|
"title": "凯德石英2025年年度报告(北交所法定公告索引)",
|
"publish_date": "2026-04-22",
|
"url": index_url,
|
}
|
current_period = finance["current_cumulative"]["period_end"]
|
current = {
|
"title": "凯德石英2026年第一季度报告(北交所法定公告索引)",
|
"publish_date": "2026-04-29",
|
"url": index_url,
|
"period_end": current_period,
|
}
|
return annual, current
|
|
|
def forecast(provider: dict[str, Any], shares: float) -> dict[str, Any]:
|
result = base.parse_forecast(provider, shares)
|
return result
|
|
|
def classify(target: Target, company: str) -> tuple[str, str, str, str]:
|
text = f"{target.research_name} {company}"
|
if "半导体" in target.industries:
|
if any(key in text for key in ("德明利", "朗科科技")):
|
return "semi_memory_cycle", "存储模组与存储产品", "存储价格、库存周转、采购成本、容量结构和渠道需求", "存储价格周期、峰值利润回落、库存跌价与营运资金"
|
if any(key in text for key in ("中芯", "华虹", "晶合", "芯联", "合晶", "硅", "三安")):
|
return "semi_foundry", "晶圆制造/硅片/化合物半导体", "产能利用率、晶圆价格、良率、折旧和产品结构", "重资产周期、折旧、供需与技术迭代"
|
if any(key in text for key in ("设备", "华峰测控", "盛美", "至纯", "亚翔", "圣晖", "晶盛", "英杰", "新益昌", "广立微", "概伦")):
|
return "semi_equipment", "半导体设备、厂务或EDA", "设备订单、验收、客户扩产、国产替代和规模效应", "客户集中、验收波动、资本开支周期与高估值"
|
if any(key in text for key in ("材料", "气体", "新阳", "晶瑞", "格林达", "奥来德", "莱特", "有研", "兴福", "三孚", "云南锗")):
|
return "semi_material", "半导体材料与电子化学品", "客户认证、销量、价格、产能利用率和产品结构", "认证周期、扩产折旧、价格竞争与主题溢价"
|
if any(key in text for key in ("封测", "晶方", "蓝箭", "气派", "盛合")):
|
return "semi_packaging", "半导体封装测试", "先进封装需求、稼动率、价格、折旧和客户结构", "半导体周期、重资产折旧与客户集中"
|
return "semi_design", "集成电路设计与芯片产品", "新品放量、客户导入、产品结构、研发效率和行业景气", "产品迭代、客户集中、行业周期与高估值"
|
if "新能源" in target.industries:
|
if any(key in text for key in ("锂", "电池", "格林美", "普利特", "国轩", "欣旺达", "宁德", "先导")):
|
return "newenergy_cycle", "锂电材料、电池或设备", "产品价格、销量、库存、产能利用率和单位成本", "产能过剩、价格战、库存减值与现金流"
|
if any(key in text for key in ("光伏", "通威", "隆基", "晶科", "亚玛顿", "京运通", "上峰", "沐邦")):
|
return "newenergy_cycle", "光伏材料、设备、组件或电站", "产品价格、出货量、产能利用率、现金成本和电站回报", "供给过剩、价格竞争、减值与高资本开支"
|
return "newenergy_stable", "风电、核电设备或运营", "订单、装机、利用小时、项目交付和回款", "项目周期、政策电价、应收回款与资本开支"
|
if "机器人" in target.industries:
|
return "robotics", "机器人核心零部件、自动化或关联制造", "机器人客户导入、产品销量、单机价值、良率和规模效应", "主题预期提前、客户验证、汽车周期与产能消化"
|
return "stable", "制造业", "销量、价格、产品结构、产能利用率和现金流", "需求周期、客户集中、原材料和资本开支"
|
|
|
PB_MULTIPLES = {
|
"semi_foundry": ((0.6, 1.0), (1.0, 2.0), (2.0, 3.5)),
|
"newenergy_cycle": ((0.5, 0.9), (0.9, 1.4), (1.4, 2.2)),
|
}
|
|
|
PE_MULTIPLES = {
|
"semi_memory_cycle": ((7, 10), (10, 15), (15, 22)),
|
"semi_foundry": ((20, 30), (30, 48), (48, 68)),
|
"semi_equipment": ((22, 32), (32, 48), (48, 68)),
|
"semi_material": ((18, 28), (28, 42), (42, 60)),
|
"semi_packaging": ((15, 23), (23, 34), (34, 48)),
|
"semi_design": ((20, 30), (30, 45), (45, 65)),
|
"robotics": ((15, 23), (23, 35), (35, 50)),
|
"newenergy_cycle": ((8, 13), (13, 21), (21, 30)),
|
"newenergy_stable": ((12, 18), (18, 27), (27, 38)),
|
"stable": ((12, 18), (18, 28), (28, 40)),
|
}
|
|
|
def scenario_rows(tier: str, normalized: float, consensus_profit: float | None, count: int, equity: float) -> list[dict[str, Any]]:
|
use_pb = tier in {"semi_foundry", "newenergy_cycle"} and (normalized <= 0 or not consensus_profit or consensus_profit <= 0)
|
if use_pb:
|
assumptions = (
|
"行业供需继续承压或盈利修复延后,账面资产按折价情景定价。",
|
"供需逐步改善、产能利用率企稳,按中性归母净资产倍数定价。",
|
"景气、良率或产品结构显著改善,盈利修复与资产回报同步上升。",
|
)
|
return [
|
{"name": name, "role": role, "method": "pb", "multiple_low": band[0], "multiple_high": band[1], "assumption": assumption}
|
for (name, role), band, assumption in zip((("悲观", "pessimistic"), ("基准", "base"), ("乐观", "optimistic")), PB_MULTIPLES[tier], assumptions)
|
]
|
anchor = consensus_profit if consensus_profit and consensus_profit > 0 else normalized
|
if anchor <= 0:
|
# Loss-making non-asset-light companies must not receive a negative PE.
|
bands = ((0.5, 0.9), (0.9, 1.5), (1.5, 2.4))
|
return [
|
{"name": name, "role": role, "method": "pb", "multiple_low": band[0], "multiple_high": band[1], "assumption": assumption}
|
for (name, role), band, assumption in zip(
|
(("悲观", "pessimistic"), ("基准", "base"), ("乐观", "optimistic")), bands,
|
("亏损延续并消耗净资产。", "盈利接近盈亏平衡,按中性净资产倍数定价。", "核心产品放量并实现可持续扭亏。"),
|
)
|
]
|
base_low = max(anchor * 0.78, normalized * 0.85 if normalized > 0 else 0)
|
base_high = max(anchor * (1.18 if count >= 2 else 1.25), normalized * 1.15 if normalized > 0 else 0)
|
profits = ((base_low * 0.62, base_low * 0.88), (base_low, base_high), (base_high * 1.10, base_high * 1.38))
|
assumptions = (
|
"需求、价格、订单或验收低于预期,利润下修且估值回归行业低位。",
|
f"以TTM扣非利润与{count if count else '无'}家机构覆盖池预期为锚,按业务质量和周期属性定价。",
|
"订单、产品结构、良率或规模效应超预期,利润与估值中枢同步上移。",
|
)
|
return [
|
{"name": name, "role": role, "method": "pe", "profit_low": profit[0], "profit_high": profit[1], "multiple_low": multiple[0], "multiple_high": multiple[1], "assumption": assumption}
|
for (name, role), profit, multiple, assumption in zip((("悲观", "pessimistic"), ("基准", "base"), ("乐观", "optimistic")), profits, PE_MULTIPLES[tier], assumptions)
|
]
|
|
|
def price_range(scenario: dict[str, Any], shares: float, equity: float) -> tuple[float, float]:
|
if scenario["method"] == "pe":
|
return scenario["profit_low"] * scenario["multiple_low"] / shares, scenario["profit_high"] * scenario["multiple_high"] / shares
|
return max(equity, 0) * scenario["multiple_low"] / shares, max(equity, 0) * scenario["multiple_high"] / shares
|
|
|
def label_for(price: float, base_range: tuple[float, float], optimistic_range: tuple[float, float]) -> str:
|
if price < base_range[0]:
|
return "偏低"
|
if price <= base_range[1]:
|
return "基本合理"
|
if price <= optimistic_range[1]:
|
return "偏贵"
|
return "明显偏贵"
|
|
|
def bubble_fields(price: float, base_high: float, optimistic_high: float, tier: str, market: dict[str, Any], business: str) -> dict[str, Any]:
|
premium = price / base_high - 1 if base_high > 0 else None
|
vs_optimistic = price / optimistic_high - 1 if optimistic_high > 0 else None
|
if premium is None or premium <= 0:
|
return {
|
"status": "未识别估值泡沫", "primary_cause": "当前价未超过基准合理区间上沿",
|
"secondary_cause": "不代表没有经营风险", "nature": "规则判定", "confidence": "中",
|
"reason": f"当前价未超过基准上沿;{business}的经营风险仍可能令合理区间下修。",
|
}
|
if vs_optimistic is not None and vs_optimistic > 0:
|
status = "泡沫-极端"
|
elif premium <= 0.15:
|
status = "泡沫-轻"
|
elif premium <= 0.50:
|
status = "泡沫-中"
|
else:
|
status = "泡沫-高"
|
cause = {
|
"semi_memory_cycle": "存储涨价、AI存储需求与盈利周期上行预期",
|
"semi_foundry": "半导体国产替代、AI算力或先进制程预期",
|
"semi_equipment": "半导体设备国产替代与资本开支预期",
|
"semi_material": "半导体材料国产替代与客户认证预期",
|
"semi_packaging": "先进封装与行业景气修复预期",
|
"semi_design": "新品、AI算力或国产芯片成长预期",
|
"robotics": "机器人、自动化或智能制造成长预期",
|
"newenergy_cycle": "新能源供需修复、储能或电动化成长预期",
|
"newenergy_stable": "新能源装机、订单与设备交付预期",
|
"stable": "盈利增长和行业景气预期提前定价",
|
}[tier]
|
secondary = "趋势动量与交易拥挤放大溢价" if (market.get("distance_to_ma60") or 0) > 0.10 else "乐观利润和估值倍数被提前定价"
|
return {
|
"status": status, "primary_cause": cause, "secondary_cause": secondary,
|
"nature": "基于估值反推、业务驱动和前复权量价的推断", "confidence": "中低",
|
"reason": f"当前价高于基准上沿{premium*100:.1f}%;市场最可能提前交易{cause},{secondary}。尚无证据证明主题预期已全部转化为利润。",
|
}
|
|
|
def clamp_negative_equity_result(
|
target: Target, company: str, market: dict[str, Any], business: str, result: dict[str, Any]
|
) -> dict[str, Any]:
|
if result["base_low"] >= 0 and result["base_high"] >= 0 and result["optimistic_high"] >= 0:
|
return result
|
result.update({
|
"base_low": 0.0,
|
"base_high": 0.0,
|
"optimistic_high": 0.0,
|
"label": "明显偏贵",
|
"bubble_status": "泡沫-极端",
|
"bubble_primary_cause": "亏损、负归母净资产与持续经营风险下仍存在正股价",
|
"bubble_reason": "归母净资产为负,PB区间不具正向经济含义;合理区间按0元风险下限记载。当前价格主要反映重整、保壳或业务扭转预期,不能由现有利润和净资产支持。",
|
"special_valuation_status": "NON_POSITIVE_ATTRIBUTABLE_EQUITY",
|
})
|
result["formal_path"] = result["formal_path"]
|
formal_path = ROOT / result["formal_path"]
|
text = formal_path.read_text(encoding="utf-8")
|
text += (
|
"\n## 特殊估值覆盖说明\n\n"
|
"- 公司TTM归母与扣非利润均为负,最新归母净资产也为负;PE、PB均失去常规正向估值含义。\n"
|
"- 本批不把负PB计算结果倒置成负价格区间,数据库合理区间统一记为 **0.00—0.00元**,当前判定为**明显偏贵**。\n"
|
"- 这不是宣称股票必然归零,而是说明现有法定利润和净资产不能支持正的基本面合理价值;正股价依赖重整、保壳、资产注入或持续经营扭转等高不确定预期。\n"
|
)
|
formal_path.write_text(text, encoding="utf-8")
|
evidence_path = formal_path.parent / "source_evidence_manifest.json"
|
evidence = base.read_json(evidence_path)
|
evidence["checks"].update({"label": "明显偏贵", "base_low": 0.0, "base_high": 0.0, "optimistic_high": 0.0, "bubble_status": "泡沫-极端", "special_valuation_status": "NON_POSITIVE_ATTRIBUTABLE_EQUITY"})
|
write_json(evidence_path, evidence)
|
return result
|
|
|
def build(target: Target) -> dict[str, Any]:
|
provider, failure, v2dir = load_provider(target.ticker)
|
identity = identity_for(target, provider)
|
company = identity["company"]
|
finance, balance, report_proof = financials(provider)
|
quote, quote_hash = quote_payload(provider)
|
if quote.get("f84"):
|
shares = float(quote["f84"])
|
shares_fallback = False
|
shares_fallback_source = "eastmoney.market:f84"
|
else:
|
shares, shares_fallback_source = shares_from_finance(provider, report_proof)
|
shares_fallback = True
|
market = latest_kline(target.ticker)
|
parent_equity = float(report_proof["parent_equity"])
|
static_shares = static_total_shares(target.ticker)
|
# Some newly listed issuers expose statutory share capital in 万股 while
|
# the generic parser assumes shares. Detect this unit mismatch with the
|
# local security master or, if that row is not yet available, with an
|
# impossible market-cap/equity scale. The statutory field remains the
|
# source; this correction only normalizes its unit.
|
share_unit_correction = None
|
if static_shares and abs(shares * 10000 - static_shares) / static_shares <= 0.02:
|
shares *= 10000
|
share_unit_correction = "report_shares_wan_to_shares_crosschecked_by_xtquant_total_volume"
|
elif shares < 1_000_000 and parent_equity and market["price"] * shares < abs(parent_equity) * 0.05:
|
shares *= 10000
|
share_unit_correction = "report_shares_wan_to_shares_inferred_from_equity_scale"
|
expected_market_cap = market["price"] * shares
|
quote_market_cap = float(quote.get("f116") or expected_market_cap)
|
market_cap_gap = abs(expected_market_cap - quote_market_cap) / expected_market_cap if expected_market_cap else 0
|
if market_cap_gap > 0.01:
|
quote_market_cap = expected_market_cap
|
|
if target.ticker == "920179.BJ":
|
annual, current = bse_reports(finance)
|
else:
|
finance_sources = provider.get("finance", {}).get("sources", [])
|
annual_publish = next((source.get("publish_date") for source in finance_sources if source.get("period_end") == "2025-12-31"), "2026-04-30")
|
current_period = finance["current_cumulative"]["period_end"]
|
current_publish = next((source.get("publish_date") for source in finance_sources if source.get("period_end") == current_period), "2026-04-30")
|
annual = annual_or_statutory_index(provider, company, annual_publish)
|
current = current_or_statutory_index(provider, company, current_period, current_publish)
|
forecast_data = forecast(provider, shares)
|
forecast_data["years"] = {year: values for year, values in forecast_data["years"].items() if year in {"2026", "2027", "2028"}}
|
consensus_2026 = (forecast_data["years"].get("2026") or {}).get("profit")
|
annual_f, current_f, prior_f = finance["annual"], finance["current_cumulative"], finance["prior_year_same_period"]
|
ttm_attr = annual_f["attributable_profit"] + current_f["attributable_profit"] - prior_f["attributable_profit"]
|
ttm_deduct = annual_f["deduct_profit"] + current_f["deduct_profit"] - prior_f["deduct_profit"]
|
ttm_cfo = annual_f["cfo"] + current_f["cfo"] - prior_f["cfo"]
|
ttm_capex = annual_f["capex"] + current_f["capex"] - prior_f["capex"]
|
balance["equity"] = parent_equity
|
balance["minority_interest"] = max(0.0, float(balance["minority_interest"]))
|
tier, business, profit_driver, risk = classify(target, company)
|
scenarios = scenario_rows(tier, ttm_deduct, consensus_2026, forecast_data["count"], parent_equity)
|
ranges = [price_range(item, shares, parent_equity) for item in scenarios]
|
base_range, optimistic_range = ranges[1], ranges[2]
|
label = label_for(market["price"], base_range, optimistic_range)
|
bubble = bubble_fields(market["price"], base_range[1], optimistic_range[1], tier, market, business)
|
|
forecasts = []
|
if forecast_data["years"]:
|
forecasts.append({
|
"institution": f"东方财富公开一致预期汇总(覆盖池{forecast_data['count']}家)",
|
"report_date": AS_OF, "include": True,
|
"core_assumption": "C1汇总快照缺逐家报告日期;仅作市场预期锚,不替代法定利润。",
|
"source_id": f"SRC-EM-CONSENSUS-{STAMP}",
|
"estimates": {year: values for year, values in forecast_data["years"].items() if year in {"2026", "2027", "2028"}},
|
})
|
shares_match = abs(float(report_proof["report_shares"]) - shares) <= max(100.0, shares * 0.000001)
|
shares_source = {
|
"id": "SRC-SHARES-REPORT" if shares_match else "SRC-SHARES-QUOTE-CROSSCHECK",
|
"source_type": disclosure_source_type(current["url"]) if shares_match else ("financial_mirror" if shares_fallback else "quote_provider"),
|
"title": f"{current['title']}股本" if shares_match else "东方财富总股本与总市值快照(历史时间戳缺失,保留有界缺口)",
|
"publish_date": current["publish_date"] if shares_match else AS_OF,
|
"period_end": finance["current_cumulative"]["period_end"] if shares_match else AS_OF,
|
"url": current["url"] if shares_match else ("https://datacenter-web.eastmoney.com/api/data/v1/get" if shares_fallback else "https://push2delay.eastmoney.com/api/qt/stock/get"),
|
"supports": ["market.diluted_shares", "market.shares_date"], "revision_status": "current",
|
}
|
sources = [
|
{"id": "SRC-ANNUAL-2025", "source_type": disclosure_source_type(annual["url"]), "title": annual["title"], "publish_date": annual["publish_date"], "period_end": "2025-12-31", "url": annual["url"], "supports": ["financials.annual", "business", "profit_sources"], "revision_status": "current"},
|
{"id": "SRC-CURRENT-2026", "source_type": disclosure_source_type(current["url"]), "title": current["title"], "publish_date": current["publish_date"], "period_end": finance["current_cumulative"]["period_end"], "url": current["url"], "supports": ["financials.current_cumulative", "financials.prior_year_same_period", "balance_sheet", "balance_sheet.equity"], "revision_status": "current"},
|
{"id": f"SRC-EM-FINANCE-{STAMP}", "source_type": "financial_mirror", "title": "东方财富结构化财务字段与法定报告勾稽", "publish_date": current["publish_date"], "period_end": finance["current_cumulative"]["period_end"], "url": "https://datacenter-web.eastmoney.com/api/data/v1/get", "supports": ["financials", "balance_sheet"], "revision_status": "current"},
|
{"id": "SRC-LOCAL-FRONT-KLINE", "source_type": "quote_provider", "title": "trading_xuntou前复权日K完整收盘价", "publish_date": AS_OF, "period_end": AS_OF, "url": "mysql://trading_xuntou/cn_stock_kline_1d_front", "supports": ["market.price", "market.price_timestamp", "market.amount", "analysis.kline"], "revision_status": "current"},
|
shares_source,
|
]
|
if forecasts:
|
sources.append({"id": f"SRC-EM-CONSENSUS-{STAMP}", "source_type": "institution_aggregator", "title": "东方财富盈利预测汇总", "publish_date": AS_OF, "period_end": AS_OF, "url": "https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_WEB_RESPREDICT", "supports": ["institutions"], "revision_status": "current"})
|
|
snapshot = {
|
"snapshot_id": f"SNAPSHOT-{target.ticker[:6]}-{STAMP}-INDUSTRY-GAP-V1",
|
"meta": {"company": company, "code": target.ticker, "market": identity["market"], "as_of_date": AS_OF, "currency": "CNY", "report_period_end": finance["current_cumulative"]["period_end"], "latest_operating_info_date": current["publish_date"]},
|
"market": {"price": market["price"], "price_type": "最近完整交易日前复权收盘价", "price_timestamp": f"{AS_OF}T15:00:00+08:00", "diluted_shares": shares, "shares_date": finance["current_cumulative"]["period_end"] if shares_match else AS_OF, "platform_market_cap": quote_market_cap},
|
"financials": finance,
|
"balance_sheet": {key: balance[key] for key in ("cash_available", "equity", "interest_bearing_debt", "minority_interest", "non_operating_financial_assets", "period_end")},
|
"normalization": {"adjustments": [{"description": "以TTM扣非归母利润替代TTM归母利润作为核心盈利代理", "amount": ttm_deduct - ttm_attr, "basis": "2025年报+2026最新累计期-2025同期"}]},
|
"valuation": {"scenarios": scenarios, "reverse_pe_multiples": [10, 12, 15, 18, 20, 25, 30, 35, 40, 50, 60, 75, 100], "exit_pe_multiples": [10, 12, 15, 18, 20, 25, 30, 35, 40, 50, 60, 75, 100], "holding_period": {"years": 5, "required_return": 0.10, "cumulative_dividend_per_share": 0}},
|
"institutions": {"coverage_status": "available" if forecasts else "no_usable_forecasts", "forecasts": forecasts},
|
"sources": sources,
|
"analysis": {
|
"company_type": business, "primary_model": "前瞻PE情景;亏损或强周期时改用PB,TTM扣非、现金流和反向估值交叉验证",
|
"cross_checks": ["PB/ROE", "机构一致预期", "经营现金流与资本开支", "反向利润", "前复权量价"],
|
"profit_sources": [f"核心利润来自{business};主要驱动为{profit_driver}。", "利润还受客户验收、研发、折旧、补助、库存和产品结构影响,不能只按收入线性外推。"],
|
"profit_source_quality": [f"TTM归母{ttm_attr/1e8:.2f}亿元,TTM扣非{ttm_deduct/1e8:.2f}亿元;主模型使用扣非口径。", f"TTM经营现金流{ttm_cfo/1e8:.2f}亿元,简化自由现金流{(ttm_cfo-ttm_capex)/1e8:.2f}亿元。"],
|
"risks": [risk, "机构汇总不是法定事实,且缺逐家报告日期。", "当前行业主题交易可能令估值倍数先于利润变化。"],
|
"upgrade_triggers": ["最新累计扣非利润达到或超过基准路径且经营现金流同步改善。", "订单、客户认证或产能利用率提升转化为可持续利润。"],
|
"downgrade_triggers": ["最新累计扣非利润低于基准下限,或现金流与利润明显背离。", "股本摊薄、存货应收恶化、客户流失或估值中枢明显下移。"],
|
"executive_conclusion": f"{market['price']:.2f}元相对基准合理区间{base_range[0]:.2f}至{base_range[1]:.2f}元,判断为{label};区间为条件化估值,不是目标价承诺。",
|
"consensus_org_count": forecast_data["count"], "consensus_coverage_note": "公开C1汇总缺逐家日期;只作市场预期锚" if forecasts else "公开汇总未返回可用机构预测",
|
"industry_research_membership": list(target.industries), "research_layers": list(target.research_layers), "research_name": target.research_name,
|
"kline_return_20d": market["return_20d"], "kline_return_60d": market["return_60d"], "ma60": market["ma60"], "distance_to_ma60": market["distance_to_ma60"], "amount_ratio_5d_to_60d": market["amount_ratio_5d_to_60d"], "market_sample_count": market["sample_count"],
|
"ttm_cfo": ttm_cfo, "ttm_capex": ttm_capex, "ttm_fcf": ttm_cfo - ttm_capex, "total_equity_original": report_proof["total_equity_original"], "attributable_equity_used": parent_equity, "minority_interest_original": report_proof["minority_original"], "report_shares": report_proof["report_shares"], "share_proof_status": "MATCH_REPORT" if shares_match else "QUOTE_EXACT_HISTORY_DATE_GAP", "information_cutoff": f"{AS_OF}T23:59:00+08:00",
|
},
|
}
|
slug = f"industry_gap_{target.ticker[:6].lower()}"
|
out_dir = RESULT_ROOT / f"{STAMP}_{slug}_valuation"
|
file_company = safe_filename(company)
|
snapshot_path = out_dir / f"{file_company}估值快照_{STAMP}.json"
|
write_json(snapshot_path, snapshot)
|
calc_dir = out_dir / "calculation"
|
run = base.run_pipeline(snapshot_path, calc_dir, REGISTRY)
|
results = base.read_json(calc_dir / "valuation_results.json")
|
calc_text = (calc_dir / "valuation_report.md").read_text(encoding="utf-8")
|
# The calculation artifacts are content-addressed by the frozen V1 manifest
|
# and must remain byte-identical. Whitespace cleanup is limited to the
|
# separate formal report assembled below.
|
report_text = "\n".join(line.rstrip() for line in calc_text.splitlines()) + "\n"
|
report_text = report_text.replace("价格合理性评估(流水线生成底稿)", "价格合理性评估", 1).replace("本文是研究计算底稿,不构成交易指令或收益承诺。", "本文为按《股票价格合理性评估操作手册》形成的条件化研究结论,不构成交易指令或收益承诺。", 1)
|
report_text = report_text.replace("\n## 2. 信息来源与固定优先级", f"\n### 1.1 行业调研覆盖与前复权量价\n\n- 调研归属:{'、'.join(target.industries)};研究名称:{target.research_name}。\n- 20日涨跌:{market['return_20d']*100:.2f}%(样本足够时);60日涨跌:{market['return_60d']*100:.2f}%(样本足够时)。\n- MA60:{market['ma60']:.2f}元;相对MA60:{market['distance_to_ma60']*100:.2f}%;近5日/60日成交额比:{market['amount_ratio_5d_to_60d']:.2f}。\n- 量价只用于显影情绪和拥挤度,不反推企业价值。\n\n## 2. 信息来源与固定优先级", 1)
|
report_text = report_text.replace("\n## 12. 风险、上调与下调触发器", f"\n## 11.1 估值泡沫判定\n\n- 泡沫状态:**{bubble['status']}**。当前价相对基准上沿{premium_text(market['price'], base_range[1])},相对乐观上沿{premium_text(market['price'], optimistic_range[1])}。\n- 主要原因:{bubble['primary_cause']};次要因素:{bubble['secondary_cause']}。\n- 原因说明:{bubble['reason']}\n- 原因性质:{bubble['nature']};置信度:{bubble['confidence']}。复核触发器是新财报、订单/认证、产能利用率、现金流或正式事件证据改变利润路径。\n\n## 12. 风险、上调与下调触发器", 1)
|
report_text = "\n".join(line.rstrip() for line in report_text.splitlines()) + "\n"
|
formal_path = out_dir / f"{file_company}价格合理性评估_{STAMP}.md"
|
formal_path.write_text(report_text, encoding="utf-8")
|
raw_hashes = sorted({raw_hash for section in provider.values() for raw_hash in section.get("raw_artifact_hashes", [])})
|
evidence = {
|
"batch_id": BATCH_ID, "ticker": target.ticker, "company": company, "as_of": AS_OF,
|
"v2_status": "BLOCKED", "v2_failure": failure, "v2_runtime_package": v2dir.name, "v2_raw_artifact_hashes": raw_hashes,
|
"market_contract": {"table": "trading_xuntou.cn_stock_kline_1d_front", "source": market["source"], "source_batch_id": market["source_batch_id"], "updated_at": market["updated_at"], "trade_date": AS_OF},
|
"quote_raw_hash": quote_hash, "share_proof": {"shares": shares, "shares_fallback": shares_fallback, "shares_fallback_source": shares_fallback_source, "report_shares": report_proof["report_shares"], "share_unit_correction": share_unit_correction, "static_total_shares": static_shares, "match_report": shares_match, "quote_market_cap": quote.get("f116"), "price_times_shares": expected_market_cap, "gap_rate": market_cap_gap},
|
"industry_membership": {"industries": target.industries, "layers": target.research_layers, "research_name": target.research_name},
|
"bounded_gaps": ["V2 quote f124=0,端到端流程诚实阻断;正式估值用当前总股本/市值勾稽,股本历史日期无A1完全匹配时显式保留缺口。", "机构汇总缺逐家报告日期与明细,不把一致预期写成法定事实。"],
|
"checks": {"v1_qa": results["qa"], "label": label, "base_low": base_range[0], "base_high": base_range[1], "optimistic_high": optimistic_range[1], "price": market["price"], "bubble_status": bubble["status"]},
|
}
|
write_json(out_dir / "source_evidence_manifest.json", evidence)
|
inst = results.get("institutions", {}).get("summary", {}).get("2026", {})
|
result = {
|
"ticker": target.ticker, "company": company, "research_name": target.research_name, "industries": list(target.industries), "research_layers": list(target.research_layers),
|
"price": market["price"], "price_date": AS_OF, "normalized_profit": float(results["metrics"]["normalized_profit"]),
|
"normalized_pe": float(results["metrics"]["normalized_pe"]) if results["metrics"]["normalized_pe"] is not None else None,
|
"pb": float(results["metrics"]["pb"]) if results["metrics"]["pb"] is not None else None,
|
"consensus_count": forecast_data["count"], "consensus_2026": consensus_2026,
|
"forward_pe": float(inst["pe_on_mean"]) if inst and inst.get("pe_on_mean") is not None else None,
|
"base_low": float(results["scenarios"][1]["price_low"]), "base_high": float(results["scenarios"][1]["price_high"]), "optimistic_high": float(results["scenarios"][2]["price_high"]),
|
"label": label, "bubble_status": bubble["status"], "bubble_primary_cause": bubble["primary_cause"], "bubble_reason": bubble["reason"],
|
"qa": results["qa"], "share_status": "MATCH_REPORT" if shares_match else "QUOTE_EXACT_HISTORY_DATE_GAP",
|
"formal_path": str(formal_path.relative_to(ROOT)).replace("\\", "/"), "snapshot_path": str(snapshot_path.relative_to(ROOT)).replace("\\", "/"), "run_status": run["status"],
|
}
|
return clamp_negative_equity_result(target, company, market, business, result)
|
|
|
def write_coverage(targets: dict[str, Target], source_docs: dict[str, list[dict[str, str]]], existing_before: dict[str, str], rows: list[dict[str, Any]], failures: list[dict[str, str]]) -> None:
|
generated = {row["ticker"]: row for row in rows}
|
failed = {row["ticker"]: row for row in failures}
|
industry_sets = {industry: {ticker for ticker, target in targets.items() if industry in target.industries} for industry in ("半导体", "机器人", "军工", "新能源")}
|
coverage_rows = []
|
for ticker, target in sorted(targets.items()):
|
status = ("本批特殊结论" if generated.get(ticker, {}).get("special_valuation_status") else "本批新增") if ticker in generated else ("已覆盖" if ticker in existing_before else "特殊/失败")
|
item = generated.get(ticker, {})
|
coverage_rows.append({
|
"ticker": ticker, "research_name": target.research_name, "industries": ";".join(target.industries), "research_layers": ";".join(target.research_layers),
|
"coverage_status": status, "company": item.get("company") or existing_before.get(ticker) or target.research_name,
|
"price_date": item.get("price_date", ""), "price": item.get("price", ""), "base_low": item.get("base_low", ""), "base_high": item.get("base_high", ""), "optimistic_high": item.get("optimistic_high", ""),
|
"label": item.get("label", ""), "bubble_status": item.get("bubble_status", ""), "special_valuation_status": item.get("special_valuation_status", ""), "report_path": item.get("formal_path", ""), "failure": failed.get(ticker, {}).get("error", ""),
|
})
|
SUMMARY_DIR.mkdir(parents=True, exist_ok=True)
|
columns = list(coverage_rows[0])
|
with (SUMMARY_DIR / "四行业调研标的估值覆盖清单.csv").open("w", encoding="utf-8-sig", newline="") as handle:
|
writer = csv.DictWriter(handle, fieldnames=columns)
|
writer.writeheader(); writer.writerows(coverage_rows)
|
with (SUMMARY_DIR / "本批新增估值明细.csv").open("w", encoding="utf-8-sig", newline="") as handle:
|
columns2 = ["ticker", "company", "research_name", "industries", "price_date", "price", "normalized_profit", "normalized_pe", "pb", "consensus_count", "consensus_2026", "forward_pe", "base_low", "base_high", "optimistic_high", "label", "bubble_status", "bubble_primary_cause", "bubble_reason", "special_valuation_status", "qa", "share_status", "formal_path"]
|
writer = csv.DictWriter(handle, fieldnames=columns2, extrasaction="ignore")
|
writer.writeheader()
|
for row in rows:
|
item = dict(row); item["industries"] = ";".join(row["industries"]); item["qa"] = row["qa"]["status"]
|
writer.writerow(item)
|
summary_lines = [
|
"# 半导体、机器人、军工、新能源调研标的估值覆盖", "",
|
f"- 覆盖复核日:2026-08-14;新增估值价格基准:{AS_OF}完整交易日前复权收盘价。",
|
"- 覆盖口径只纳入项目内已形成明确证券池的本地上市证券;海外、未上市、仅被全文提及及未接受的海量发现候选不纳入。",
|
"- 估值为条件化区间,不是目标价、交易指令或收益承诺。", "",
|
"## 1. 覆盖结果", "", "| 行业 | 调研证券数 | 执行前已覆盖 | 本批新增数值 | 本批特殊结论 | 最终覆盖 | 失败 |", "|---|---:|---:|---:|---:|---:|---:|",
|
]
|
for industry in ("半导体", "机器人", "军工", "新能源"):
|
pool = industry_sets[industry]
|
before = len(pool & set(existing_before))
|
generated_pool = pool & set(generated)
|
special_generated = sum(bool(generated[ticker].get("special_valuation_status")) for ticker in generated_pool)
|
numeric_new = len(generated_pool) - special_generated
|
failed_count = len(pool - set(existing_before) - set(generated))
|
summary_lines.append(f"| {industry} | {len(pool)} | {before} | {numeric_new} | {special_generated} | {before+len(generated_pool)} | {failed_count} |")
|
overlap = Counter(len(target.industries) for target in targets.values())
|
summary_lines += [
|
"", "三个缺口池存在交叉,因此各行业新增数不能直接相加;本批唯一缺口证券为 **%d只**,其中数值估值%d只、显式特殊结论%d只。" % (len(rows), sum(not row.get("special_valuation_status") for row in rows), sum(bool(row.get("special_valuation_status")) for row in rows)),
|
"", "## 2. 口径说明", "",
|
"- 半导体:国内企业信息表中179只A/B股证券;北交所凯德石英由旧代码835179映射为当前行情代码920179。",
|
"- 机器人:研究已建立本地行情映射的100只证券全部纳入,包括16只既有正式输出、核心/相邻/OEM研究层以及HOLD/EXCLUDE观察层;这样满足‘调研涉及’而不把307家海外、未上市和未批准实体全部当证券。",
|
"- 军工:33批528只已接受母池,执行前已全覆盖,本次零新增。",
|
"- 新能源:当前40家公司由已独立接受的BATCH-001/002支撑;当前索引仍在输出审核,但底层公司池是已接受输入。",
|
"", "## 3. 本批新增估值", "", "| 公司 | 代码 | 行业 | 收盘价 | 机构2026利润 | 基准合理区间 | 判断 | 泡沫 |", "|---|---|---|---:|---:|---:|---|---|",
|
]
|
for row in rows:
|
consensus = "无可用预期" if row["consensus_2026"] is None else f"{row['consensus_2026']/1e8:.2f}亿元({row['consensus_count']}家覆盖池)"
|
link = "../../../../" + row["formal_path"]
|
summary_lines.append(f"| [{row['company']}]({link}) | {row['ticker']} | {'、'.join(row['industries'])} | {row['price']:.2f}元 | {consensus} | {row['base_low']:.2f}—{row['base_high']:.2f}元 | {row['label']} | {row['bubble_status']} |")
|
labels = Counter(row["label"] for row in rows)
|
bubbles = Counter(row["bubble_status"] for row in rows)
|
summary_lines += [
|
"", "## 4. 质量与边界", "",
|
f"- 新增结论分布:偏低{labels['偏低']}、基本合理{labels['基本合理']}、偏贵{labels['偏贵']}、明显偏贵{labels['明显偏贵']}。",
|
"- 泡沫状态:" + "、".join(f"{key}{value}只" for key, value in sorted(bubbles.items())) + "。",
|
f"- V1 QA无错误:{sum(row['qa']['error_count']==0 for row in rows)}/{len(rows)};数值估值{sum(not row.get('special_valuation_status') for row in rows)}只;特殊结论{sum(bool(row.get('special_valuation_status')) for row in rows)}只;失败:{len(failures)}。",
|
"- 全部新增证券的法定财务、公告和机构汇总来自登记provider,2026-08-13价格来自本地前复权专表;V2因历史股本时间戳缺失等门禁保持BLOCKED,没有伪造成功。",
|
"- 机构预测为公开C1汇总,缺逐家研报日期时只作市场预期锚;无可用机构预测的公司明确显示缺口。",
|
"", "## 5. 机器入口", "",
|
"- [四行业完整覆盖清单](四行业调研标的估值覆盖清单.csv)",
|
"- [本批新增估值明细](本批新增估值明细.csv)",
|
"", "## 6. 研究源", "",
|
]
|
for industry, docs in source_docs.items():
|
for item in docs:
|
summary_lines.append(f"- {industry}:`{item['path']}`;{item['scope']}。")
|
summary_lines.append("")
|
(SUMMARY_DIR / "四行业调研标的估值覆盖汇总.md").write_text("\n".join(summary_lines), encoding="utf-8")
|
write_json(SUMMARY_DIR / "coverage_manifest.json", {"batch_id": BATCH_ID, "as_of": AS_OF, "targets": len(targets), "industry_counts": {key: len(value) for key, value in industry_sets.items()}, "existing_before": len(existing_before), "generated": rows, "failures": failures, "overlap_distribution": dict(overlap)})
|
|
|
def main() -> None:
|
if hasattr(sys.stdout, "reconfigure"):
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
targets, source_docs = universe_rows()
|
existing = existing_security()
|
prior_manifest = CASE_ROOT / "batch_manifest.json"
|
if prior_manifest.exists():
|
prior = base.read_json(prior_manifest)
|
prior_batch_tickers = {row["ticker"] for row in prior.get("results", [])}
|
# Reconstruct the frozen pre-run baseline after a partial/complete local
|
# import so reruns remain deterministic and do not silently shrink scope.
|
existing = {ticker: company for ticker, company in existing.items() if ticker not in prior_batch_tickers}
|
missing = [target for ticker, target in sorted(targets.items()) if ticker not in existing]
|
if len(missing) != 180:
|
raise RuntimeError(f"expected frozen pre-run gap 180, got {len(missing)}")
|
rows: list[dict[str, Any]] = []
|
failures: list[dict[str, str]] = []
|
for index, target in enumerate(missing, 1):
|
try:
|
row = build(target)
|
rows.append(row)
|
print(f"[{index:03d}/{len(missing):03d}] OK {target.ticker} {row['company']}", flush=True)
|
except Exception as exc: # batch boundary retains every failure
|
error = f"{type(exc).__name__}: {exc}"
|
failures.append({"ticker": target.ticker, "company": target.research_name, "error": error})
|
print(f"[{index:03d}/{len(missing):03d}] FAIL {target.ticker} {error}", flush=True)
|
write_coverage(targets, source_docs, existing, rows, failures)
|
CASE_ROOT.mkdir(parents=True, exist_ok=True)
|
task_lines = ["# 四行业调研标的估值覆盖任务清单", "", f"- 批次:`{BATCH_ID}`", f"- 价格基准:{AS_OF}", f"- 缺口证券:{len(missing)};成功:{len(rows)};特殊/失败:{len(failures)}", "- 汇总:`ana-data/result/股票估值/行业调研标的估值覆盖/四行业调研标的估值覆盖汇总.md`", "", "## 完成条件", "", "- [x] 四行业证券池口径固化与去重", f"- [{'x' if not failures else ' '}] 缺口证券全部形成数值估值或显式特殊结论", "- [x] V1 QA、泡沫原因、覆盖清单与机器manifest", ""]
|
(CASE_ROOT / "估值任务清单.md").write_text("\n".join(task_lines), encoding="utf-8")
|
write_json(CASE_ROOT / "batch_manifest.json", {"batch_id": BATCH_ID, "as_of": AS_OF, "missing_before": len(missing), "success": len(rows), "failures": failures, "results": rows})
|
print(json.dumps({"success": len(rows), "failures": failures}, ensure_ascii=False))
|
if failures:
|
raise SystemExit(2)
|
|
|
if __name__ == "__main__":
|
main()
|