Cai
2026-08-21 b5cd4db7bf43d92dcd6f1a58c7be0f2f46ef3ea4
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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
#!/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()