MB-X Bilibili Pipeline
7 days ago 8cdab3c14c30a7bfa0ec6c2e7fff8c5d4da7555f
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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
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 valuation_position(row: dict[str, str]) -> tuple[int, float, str] | None:
    close = as_float(row.get("close", ""))
    low = as_float(row.get("base_low", ""))
    high = as_float(row.get("base_high", ""))
    if close is None or low is None or high is None or low <= 0 or high <= low:
        return None
    if close < low:
        discount = (low - close) / low * 100.0
        return 0, discount, f"低于下沿 {discount:.2f}%"
    if close <= high:
        midpoint = (low + high) / 2.0
        deviation = (close - midpoint) / midpoint * 100.0
        if deviation < -0.005:
            display = f"低于中枢 {abs(deviation):.2f}%"
        elif deviation > 0.005:
            display = f"高于中枢 {deviation:.2f}%"
        else:
            display = "位于中枢 0.00%"
        return 1, deviation, display
    premium = (close - high) / high * 100.0
    return 2, premium, f"高于上沿 {premium:.2f}%"
 
 
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"])
        position = valuation_position(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 position is None else position[2],
            "原判定": 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,
        }
        if position is None:
            key = (3, float("inf"), source["ticker"])
        else:
            zone, metric, _ = position
            key = (zone, -metric if zone == 0 else metric, 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())