from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
import os
|
import tempfile
|
from collections import Counter
|
from pathlib import Path
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
COVERAGE_PATH = PROJECT_ROOT / "ana-data/result/股票估值/行业调研标的估值覆盖/全部行业调研标的估值覆盖清单.csv"
|
LATEST_PATH = PROJECT_ROOT / "ana-data/result/股票估值/估值台账/latest.csv"
|
OUTPUT_DIR = PROJECT_ROOT / "ana-data/result/股票估值/行业调研标的估值覆盖"
|
RESULT_ROOT = PROJECT_ROOT / "ana-data/result/股票估值"
|
OUTPUT_CSV = OUTPUT_DIR / "半导体估值排序.csv"
|
OUTPUT_MD = OUTPUT_DIR / "半导体估值排序.md"
|
OUTPUT_MANIFEST = OUTPUT_DIR / "全部行业调研标的估值覆盖manifest.json"
|
|
FIELDS = [
|
"代码",
|
"公司",
|
"所属行业",
|
"收盘价",
|
"基准区间",
|
"低于基准下沿",
|
"原判定",
|
"泡沫判定",
|
"高于基准上沿",
|
"主要泡沫原因",
|
"原因说明",
|
"置信度",
|
"60日涨幅",
|
"相对MA60",
|
"正式报告",
|
]
|
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 sha256(path: Path) -> str:
|
return hashlib.sha256(path.read_bytes()).hexdigest().upper()
|
|
|
def as_float(value: str) -> float | None:
|
try:
|
return float(value)
|
except (TypeError, ValueError):
|
return None
|
|
|
def signed_pct(value: str) -> str:
|
number = as_float(value)
|
return "—" if number is None else f"{number:+.2f}%"
|
|
|
def below_base_low_pct(row: dict[str, str]) -> float | None:
|
close = as_float(row.get("close", ""))
|
low = as_float(row.get("base_low", ""))
|
if close is None or low is None or low <= 0:
|
return None
|
return (low - close) / low * 100.0
|
|
|
def display_industries(value: str) -> str:
|
return value.replace(";", "、")
|
|
|
def resolve_report_path(source: dict[str, str]) -> str:
|
if source.get("report_path"):
|
return source["report_path"]
|
code = source["ticker"].split(".", 1)[0]
|
candidates = sorted(RESULT_ROOT.glob(f"*_{code}_valuation/*价格合理性评估*.md"))
|
if len(candidates) != 1:
|
raise RuntimeError(f"Expected one formal report for {source['ticker']}, got {len(candidates)}")
|
return candidates[0].relative_to(PROJECT_ROOT).as_posix()
|
|
|
def escape_markdown(value: str) -> str:
|
return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
|
|
|
def atomic_write_text(path: Path, text: str) -> None:
|
path.parent.mkdir(parents=True, exist_ok=True)
|
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
try:
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
handle.write(text)
|
os.replace(temp_name, path)
|
except BaseException:
|
try:
|
os.unlink(temp_name)
|
except FileNotFoundError:
|
pass
|
raise
|
|
|
def atomic_write_csv(path: Path, rows: list[dict[str, str]]) -> None:
|
path.parent.mkdir(parents=True, exist_ok=True)
|
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
try:
|
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
writer = csv.DictWriter(handle, fieldnames=FIELDS, lineterminator="\n")
|
writer.writeheader()
|
writer.writerows(rows)
|
os.replace(temp_name, path)
|
except BaseException:
|
try:
|
os.unlink(temp_name)
|
except FileNotFoundError:
|
pass
|
raise
|
|
|
def main() -> int:
|
coverage = [row for row in read_csv(COVERAGE_PATH) if "半导体" in row["industries"].split(";")]
|
latest = {row["ticker"]: row for row in read_csv(LATEST_PATH)}
|
ranked: list[tuple[tuple[int, float, str], dict[str, str]]] = []
|
|
for source in coverage:
|
current = latest.get(source["ticker"])
|
report_path = resolve_report_path(source)
|
if current is None:
|
output = {
|
"代码": source["ticker"],
|
"公司": source["company"],
|
"所属行业": display_industries(source["industries"]),
|
"收盘价": "—",
|
"基准区间": "—",
|
"低于基准下沿": "—",
|
"原判定": "特殊结论",
|
"泡沫判定": "不适用",
|
"高于基准上沿": "—",
|
"主要泡沫原因": "常规PE/PB失效",
|
"原因说明": source["coverage_status"],
|
"置信度": "—",
|
"60日涨幅": "—",
|
"相对MA60": "—",
|
"正式报告": report_path,
|
}
|
ranked.append(((4, float("inf"), source["ticker"]), output))
|
continue
|
|
close = as_float(current["close"])
|
low = as_float(current["base_low"])
|
high = as_float(current["base_high"])
|
premium = as_float(current["bubble_premium_pct"])
|
discount = below_base_low_pct(current)
|
output = {
|
"代码": source["ticker"],
|
"公司": source["company"],
|
"所属行业": display_industries(source["industries"]),
|
"收盘价": "—" if close is None else f"{close:.2f}",
|
"基准区间": "—" if low is None or high is None else f"{low:.2f}—{high:.2f}",
|
"低于基准下沿": "—" if discount is None else f"{discount:.2f}%",
|
"原判定": current["label"],
|
"泡沫判定": current["bubble_status"],
|
"高于基准上沿": "—" if premium is None else f"{max(0.0, premium):.2f}%",
|
"主要泡沫原因": current["bubble_primary_cause"],
|
"原因说明": current["bubble_reason"],
|
"置信度": current["bubble_confidence"],
|
"60日涨幅": signed_pct(current["return_60d_pct"]),
|
"相对MA60": signed_pct(current["distance_to_ma60_pct"]),
|
"正式报告": report_path,
|
}
|
key = (0, float("inf") if discount is None else -discount, source["ticker"])
|
ranked.append((key, output))
|
|
ranked.sort(key=lambda item: item[0])
|
rows = [item[1] for item in ranked]
|
if len(rows) != 179 or len({row["代码"] for row in rows}) != 179:
|
raise RuntimeError(f"Expected 179 unique semiconductor securities, got {len(rows)}")
|
|
atomic_write_csv(OUTPUT_CSV, rows)
|
label_counts = Counter(row["原判定"] for row in rows)
|
trade_dates = sorted({latest[row["代码"]]["trade_date"] for row in rows if row["代码"] in latest})
|
lines = [
|
"# 半导体股票估值排序",
|
"",
|
f"- 价格日:{'、'.join(trade_dates)};半导体研究证券共{len(rows)}只。",
|
"- 排序:按 `低于基准下沿 = (基准下沿-收盘价)/基准下沿` 从高到低排列,数值越大表示相对基准下沿越便宜;无法计算的特殊结论置于末尾。",
|
f"- 分布:偏低{label_counts['偏低']}只、基本合理{label_counts['基本合理']}只、偏贵{label_counts['偏贵']}只、明显偏贵{label_counts['明显偏贵']}只、特殊结论{label_counts['特殊结论']}只。",
|
"- “低于基准下沿”为正表示收盘价低于下沿,为负表示收盘价高于下沿;该字段是价格位置比较,不代表经营风险已经消除。",
|
"- “高于基准上沿”为0表示没有超过基准合理区间上沿;泡沫原因除价格位置外属于最可能解释,不是已经证实的资金因果。",
|
f"- 输入:`全部行业调研标的估值覆盖清单.csv` SHA-256 `{sha256(COVERAGE_PATH)}`;`../估值台账/latest.csv` SHA-256 `{sha256(LATEST_PATH)}`。",
|
"- 合理区间是条件化估值,不是目标价、交易指令或收益承诺。",
|
"",
|
"| " + " | ".join(FIELDS) + " |",
|
"|" + "|".join(["---"] * len(FIELDS)) + "|",
|
]
|
for row in rows:
|
values = []
|
for field in FIELDS:
|
value = row[field]
|
if field == "正式报告" and value:
|
value = f"[报告](../../../../{value})"
|
values.append(escape_markdown(value))
|
lines.append("| " + " | ".join(values) + " |")
|
lines.append("")
|
atomic_write_text(OUTPUT_MD, "\n".join(lines))
|
|
manifest = json.loads(OUTPUT_MANIFEST.read_text(encoding="utf-8"))
|
manifest["semiconductor_ranking_trade_date"] = "、".join(trade_dates)
|
manifest["semiconductor_ranking_md_sha256"] = sha256(OUTPUT_MD).lower()
|
manifest["semiconductor_ranking_csv_sha256"] = sha256(OUTPUT_CSV).lower()
|
atomic_write_text(
|
OUTPUT_MANIFEST,
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
)
|
|
print(json.dumps({
|
"rows": len(rows),
|
"label_counts": dict(label_counts),
|
"trade_dates": trade_dates,
|
"csv": str(OUTPUT_CSV.relative_to(PROJECT_ROOT)),
|
"csv_sha256": sha256(OUTPUT_CSV),
|
"markdown": str(OUTPUT_MD.relative_to(PROJECT_ROOT)),
|
"markdown_sha256": sha256(OUTPUT_MD),
|
"manifest": str(OUTPUT_MANIFEST.relative_to(PROJECT_ROOT)),
|
"manifest_sha256": sha256(OUTPUT_MANIFEST),
|
}, ensure_ascii=False))
|
return 0
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|