Cai
2026-08-20 61cf007883ae7d6e8d98f7a0a34f94cabaa79da1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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())