Cai
9 days ago 2fbc2b9ee0dfcf211f57b04769b7694d539d7312
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
#!/usr/bin/env python3
"""Evaluate current agriculture/new-energy research gaps and rebuild coverage.
 
This is a task coordinator, not a second valuation engine.  It reuses the
registered V2 evidence collectors, the frozen V1 calculation core and the
accepted MySQL daily ledger.  Agriculture and new-energy memberships are read
from their stable current research artifacts at runtime.
"""
 
from __future__ import annotations
 
import argparse
import csv
import hashlib
import importlib.util
import json
import os
import re
import subprocess
import sys
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
 
import pymysql
 
 
ROOT = Path(__file__).resolve().parents[2]
BASE_SCRIPT = ROOT / "ai-valuation-analyst/tools/generate_industry_research_gap_valuations_20260814.py"
SPEC = importlib.util.spec_from_file_location("current_research_base", BASE_SCRIPT)
if SPEC is None or SPEC.loader is None:
    raise RuntimeError(f"cannot import {BASE_SCRIPT}")
gen = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = gen
SPEC.loader.exec_module(gen)
 
RESULT_ROOT = ROOT / "ana-data/result/股票估值"
SUMMARY_DIR = RESULT_ROOT / "行业调研标的估值覆盖"
V2_ROOT = ROOT / "ai-valuation-analyst/tmp/valuation_current_research_20260820"
V2_CACHE = ROOT / "ai-valuation-analyst/tmp/v2_cache"
BATCH_ID = "BATCH-STOCK-VALUATION-CURRENT-RESEARCH-20260820-001"
CASE_ROOT = ROOT / "ana-data/cases/股票估值" / BATCH_ID
 
 
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 ticker_for(code: str) -> str:
    if code.startswith("6"):
        return f"{code}.SH"
    if code.startswith(("0", "3")):
        return f"{code}.SZ"
    return f"{code}.BJ"
 
 
def research_targets() -> tuple[dict[str, Any], dict[str, str]]:
    membership: dict[str, dict[str, Any]] = {}
    source_notes: dict[str, str] = {}
 
    def add(ticker: str, name: str, industry: str, layer: str) -> None:
        item = membership.setdefault(ticker, {"names": [], "industries": set(), "layers": set()})
        item["names"].append(name.strip())
        item["industries"].add(industry)
        item["layers"].add(layer)
 
    fertilizer_path = ROOT / "ana-data/cases/农业案例/extracted/fertilizer_export_primary_disposition_2025.csv"
    with fertilizer_path.open(encoding="utf-8-sig", newline="") as handle:
        for row in csv.DictReader(handle):
            add(
                ticker_for(row["security_code"].strip()),
                row["security_name_at_cutoff"].strip(),
                "农业",
                f"FERTILIZER:{row['primary_status'].strip()}",
            )
    source_notes["农业-化肥"] = f"{fertilizer_path.relative_to(ROOT).as_posix()}:固定50家公司"
 
    selection_path = ROOT / "ana-data/cases/农业案例/extracted/candidate_selection.csv"
    with selection_path.open(encoding="utf-8-sig", newline="") as handle:
        for row in csv.DictReader(handle):
            add(
                ticker_for(row["security_code"].strip()),
                row["security_name"].strip(),
                "农业",
                row["chain_code"].strip(),
            )
    source_notes["农业-首批"] = f"{selection_path.relative_to(ROOT).as_posix()}:农药/化肥16家公司"
 
    company_root = ROOT / "ana-data/cases/新能源案例/核心文档/公司"
    track_patterns = {
        "SOLAR": r"光伏|太阳能|硅料|硅片|电池片|组件|逆变器",
        "BATTERY": r"锂电|动力电池|电池材料",
        "STORAGE": r"储能",
        "WIND": r"风电|风机|风塔|叶片",
        "NUCLEAR": r"核电|核能",
        "POWER_SYSTEM": r"电力系统|电网|配电|输电",
    }
    card_count = 0
    for path in sorted(company_root.glob("*.md")):
        text = path.read_text(encoding="utf-8")
        first_line = text.splitlines()[0] if text else ""
        match = re.match(r"^#\s+(.+?)((\d{6}))", first_line)
        if not match:
            continue
        name, code = match.groups()
        tracks = [track for track, pattern in track_patterns.items() if re.search(pattern, text)] or ["NEWENERGY_OTHER"]
        for track in tracks:
            add(ticker_for(code), name, "新能源", track)
        card_count += 1
    source_notes["新能源"] = f"{company_root.relative_to(ROOT).as_posix()}:{card_count}张稳定公司卡"
 
    targets = {
        ticker: gen.Target(
            ticker=ticker,
            research_name=next((name for name in item["names"] if name), ticker),
            industries=tuple(sorted(item["industries"])),
            research_layers=tuple(sorted(item["layers"])),
        )
        for ticker, item in membership.items()
    }
    return targets, source_notes
 
 
def existing_security() -> dict[str, str]:
    with mysql_connection("stock_valuation") as connection, connection.cursor() as cursor:
        cursor.execute("SELECT ticker,company FROM security WHERE active=1")
        result = {row["ticker"]: row["company"] for row in cursor.fetchall()}
    manifest_path = CASE_ROOT / "batch_manifest.json"
    if manifest_path.exists():
        prior = gen.base.read_json(manifest_path)
        for row in prior.get("results", []):
            if row.get("special_valuation_status"):
                result[row["ticker"]] = row["company"]
    return result
 
 
def package_dir(ticker: str) -> Path:
    stem = f"v2_{ticker.replace('.', '_')}"
    candidates = [path for path in V2_ROOT.glob(f"{stem}.failed-*") if (path / "provider_results.json").exists()]
    exact = V2_ROOT / stem
    if (exact / "provider_results.json").exists():
        candidates.append(exact)
    if not candidates:
        raise FileNotFoundError(f"V2 package missing for {ticker}")
    return max(candidates, key=lambda path: path.stat().st_mtime)
 
 
def load_provider(ticker: str) -> tuple[dict[str, Any], dict[str, Any], Path]:
    directory = package_dir(ticker)
    failure_path = directory / "failure.json"
    failure = gen.base.read_json(failure_path) if failure_path.exists() else {"status": "COMPLETE"}
    return gen.base.read_json(directory / "provider_results.json"), failure, directory
 
 
def fetch_one(ticker: str, as_of: str) -> dict[str, Any]:
    try:
        directory = package_dir(ticker)
        return {"ticker": ticker, "returncode": 0, "status": "REUSED", "package": directory.name}
    except FileNotFoundError:
        pass
    target = V2_ROOT / f"v2_{ticker.replace('.', '_')}"
    env = os.environ.copy()
    env["PYTHONPATH"] = str(ROOT / "dev/project-dev")
    proc = subprocess.run(
        [
            sys.executable,
            "-m",
            "stock_valuation_pipeline_v2",
            "--ticker",
            ticker,
            "--as-of",
            as_of,
            "--cache-dir",
            str(V2_CACHE),
            "--output-dir",
            str(target),
        ],
        cwd=ROOT,
        env=env,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        timeout=120,
    )
    return {
        "ticker": ticker,
        "returncode": proc.returncode,
        "status": "FETCHED",
        "stdout_tail": proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else "",
        "stderr_tail": proc.stderr.strip()[-500:],
    }
 
 
def fetch_missing(as_of: str, workers: int) -> dict[str, Any]:
    targets, _ = research_targets()
    covered = existing_security()
    missing = sorted(set(targets) - set(covered))
    V2_ROOT.mkdir(parents=True, exist_ok=True)
    rows: list[dict[str, Any]] = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        pending = {pool.submit(fetch_one, ticker, as_of): ticker for ticker in missing}
        for future in as_completed(pending):
            ticker = pending[future]
            try:
                row = future.result()
            except Exception as exc:
                row = {"ticker": ticker, "returncode": 99, "status": "ERROR", "error": repr(exc)}
            rows.append(row)
            print(f"[{len(rows):03d}/{len(missing):03d}] {ticker} {row['status']} rc={row['returncode']}", flush=True)
    rows.sort(key=lambda item: item["ticker"])
    manifest = {"batch_id": BATCH_ID, "as_of": as_of, "missing_before": len(missing), "items": rows}
    write_json(V2_ROOT / "fetch_manifest.json", manifest)
    return manifest
 
 
PE_ADDITIONS = {
    "agri_resource": ((6, 10), (10, 16), (16, 24)),
    "agri_fertilizer": ((7, 11), (11, 18), (18, 26)),
    "agri_pesticide": ((10, 15), (15, 24), (24, 34)),
    "agri_distribution": ((7, 11), (11, 17), (17, 24)),
}
 
 
def classify(target: Any, company: str) -> tuple[str, str, str, str]:
    layers = " ".join(target.research_layers)
    if "农业" in target.industries:
        if "PESTICIDE" in layers or company in {"海利尔", "美邦股份"}:
            return "agri_pesticide", "农药原药、制剂或农化服务", "产品价格、销量、登记证、渠道、原料成本和出口需求", "农药周期、环保安监、库存渠道、出口和价格波动"
        if company in {"辉隆股份", "浙农股份", "天禾股份", "中农立华", "富邦科技", "农发种业"}:
            return "agri_distribution", "农资流通、助剂或综合农业服务", "销量、周转、渠道效率、费用率和营运资金", "低毛利、应收存货、渠道信用和业务纯度"
        if company in {"藏格矿业", "盐湖股份", "亚钾国际", "川发龙蟒", "川恒股份", "洛阳钼业", "甘肃能化", "兰花科创", "兖矿能源", "中煤能源"}:
            return "agri_resource", "资源型化肥原料或跨业务资源品", "资源价格、产销量、现金成本、权益产能和资本开支", "商品周期、业务纯度、资源税费、扩产和峰值利润外推"
        return "agri_fertilizer", "氮磷钾肥、复合肥或化工联产", "肥价、原料价差、产销量、开工率、出口和产品结构", "化肥周期、煤气磷硫成本、出口政策、库存和现金流"
    if "新能源" in target.industries:
        if any(track in layers for track in ("SOLAR", "BATTERY")):
            return "newenergy_cycle", "光伏或锂电材料、设备、组件与系统", "产品价格、出货、产能利用率、单位成本和库存", "产能过剩、价格战、减值、技术迭代和现金流"
        return "newenergy_stable", "储能、风电、核电或新型电力系统", "订单、装机、交付、利用小时、项目验收和回款", "项目周期、政策、电价、应收回款和资本开支"
    return gen._original_classify(target, company)
 
 
def bubble_fields(price: float, base_high: float, optimistic_high: float, tier: str, market: dict[str, Any], business: str) -> dict[str, Any]:
    if not tier.startswith("agri_"):
        return gen._original_bubble_fields(price, base_high, optimistic_high, tier, market, business)
    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 = "泡沫-高"
    causes = {
        "agri_resource": "资源品稀缺性、价格上行或高景气持续预期",
        "agri_fertilizer": "肥价修复、出口与成本价差改善预期",
        "agri_pesticide": "农药周期修复、出口和库存去化预期",
        "agri_distribution": "渠道整合、周转改善与农业服务成长预期",
    }
    cause = causes[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 bounded_financials(provider: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
    """Allow one disclosed cash-flow schema omission without inventing profit.
 
    *ST雅博's registered cash-flow payload omits CONSTRUCT_LONG_ASSET.  Capex is
    therefore set to zero only for the free-cash-flow cross-check; the formal
    report/evidence manifest records that FCF is an upper bound.  Profit,
    equity, debt and valuation inputs keep their ordinary parser contracts.
    """
    company = (provider.get("announcements", {}).get("identity") or {}).get("company", "")
    if company not in {"*ST雅博", "ST雅博"}:
        return gen._original_financials(provider)
    original_number = gen.base._number
 
    def optional_capex(row: dict[str, Any], *keys: str) -> float:
        if keys == ("CONSTRUCT_LONG_ASSET",) and not any(row.get(key) is not None for key in keys):
            return 0.0
        return original_number(row, *keys)
 
    gen.base._number = optional_capex
    try:
        return gen._original_financials(provider)
    finally:
        gen.base._number = original_number
 
 
def disclose_bounded_capex(row: dict[str, Any]) -> None:
    if row["ticker"] != "002323.SZ":
        return
    report_path = ROOT / row["formal_path"]
    report_path.write_text(
        report_path.read_text(encoding="utf-8")
        + "\n## 有界数据缺口\n\n"
        + "- 注册现金流负载未提供 `CONSTRUCT_LONG_ASSET`(购建长期资产现金支出)字段;本报告仅在现金流交叉验证中按0处理,因此自由现金流是上限,不作为抬高合理区间的主锚。\n"
        + "- 利润、归母净资产、股本和三情景估值未使用该缺失字段;后续取得法定现金流明细时复核自由现金流质量。\n",
        encoding="utf-8",
    )
    evidence_path = report_path.parent / "source_evidence_manifest.json"
    evidence = gen.base.read_json(evidence_path)
    evidence.setdefault("bounded_gaps", []).append(
        "*ST雅博现金流镜像缺CONSTRUCT_LONG_ASSET;capex=0仅用于FCF上限交叉验证,不进入主估值锚。"
    )
    write_json(evidence_path, evidence)
 
 
def configure_generator(as_of: str) -> None:
    stamp = as_of.replace("-", "")
    gen.AS_OF = as_of
    gen.STAMP = stamp
    gen.BATCH_ID = BATCH_ID
    gen.RESULT_ROOT = RESULT_ROOT
    gen.SUMMARY_DIR = SUMMARY_DIR
    gen.CASE_ROOT = CASE_ROOT
    gen.V2_ROOT = V2_ROOT
    gen.CACHE = V2_CACHE / "blobs/sha256"
    gen.base.AS_OF = as_of
    gen.base.RESULT_ROOT = RESULT_ROOT
    gen.base.CASE_ROOT = CASE_ROOT
    gen.base.CACHE = gen.CACHE
    gen.PE_MULTIPLES.update(PE_ADDITIONS)
    if not hasattr(gen, "_original_classify"):
        gen._original_classify = gen.classify
    if not hasattr(gen, "_original_bubble_fields"):
        gen._original_bubble_fields = gen.bubble_fields
    if not hasattr(gen, "_original_financials"):
        gen._original_financials = gen.financials
    gen.classify = classify
    gen.bubble_fields = bubble_fields
    gen.financials = bounded_financials
    gen.failed_dir = package_dir
    gen.load_provider = load_provider
 
 
def generate_missing(as_of: str) -> dict[str, Any]:
    configure_generator(as_of)
    targets, source_notes = research_targets()
    before = existing_security()
    missing = [targets[ticker] for ticker in sorted(set(targets) - set(before))]
    prior_manifest_path = CASE_ROOT / "batch_manifest.json"
    prior_manifest = gen.base.read_json(prior_manifest_path) if prior_manifest_path.exists() else {}
    prior_results = {row["ticker"]: row for row in prior_manifest.get("results", [])}
    rows: list[dict[str, Any]] = []
    failures: list[dict[str, str]] = []
    for index, target in enumerate(missing, 1):
        try:
            row = gen.build(target)
            disclose_bounded_capex(row)
            rows.append(row)
            print(f"[{index:03d}/{len(missing):03d}] OK {target.ticker} {row['company']}", flush=True)
        except Exception as exc:
            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)
 
    CASE_ROOT.mkdir(parents=True, exist_ok=True)
    all_results = dict(prior_results)
    all_results.update({row["ticker"]: row for row in rows})
    result_rows = [all_results[ticker] for ticker in sorted(all_results)]
    manifest = {
        "batch_id": BATCH_ID,
        "as_of": as_of,
        "research_target_count": len(targets),
        "existing_before": prior_manifest.get("existing_before", len(set(targets) & set(before))),
        "missing_before": prior_manifest.get("missing_before", len(missing)),
        "success": len(result_rows),
        "failures": failures,
        "sources": source_notes,
        "results": result_rows,
    }
    write_json(CASE_ROOT / "batch_manifest.json", manifest)
 
    SUMMARY_DIR.mkdir(parents=True, exist_ok=True)
    detail_path = SUMMARY_DIR / "农业新能源新增估值明细.csv"
    columns = [
        "ticker", "company", "research_name", "industries", "research_layers", "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",
    ]
    with detail_path.open("w", encoding="utf-8-sig", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=columns, extrasaction="ignore")
        writer.writeheader()
        for row in result_rows:
            item = dict(row)
            item["industries"] = ";".join(row["industries"])
            item["research_layers"] = ";".join(row["research_layers"])
            item["qa"] = row["qa"]["status"]
            writer.writerow(item)
    labels = Counter(row["label"] for row in result_rows)
    lines = [
        "# 农业与新能源研究标的新增基础估值",
        "",
        f"- 估值日:{as_of};初始范围缺口:{manifest['missing_before']}只;已形成结论:{len(result_rows)}只;当前失败:{len(failures)}只。",
        f"- 标签:偏低{labels['偏低']}、基本合理{labels['基本合理']}、偏贵{labels['偏贵']}、明显偏贵{labels['明显偏贵']}。",
        "- 估值区间是条件化研究结论,不是目标价、交易指令或收益承诺。",
        "",
        "| 公司 | 代码 | 行业 | 收盘价 | 基准合理区间 | 判断 | 泡沫 |",
        "|---|---|---|---:|---:|---|---|",
    ]
    for row in result_rows:
        link = "../../../../" + row["formal_path"]
        lines.append(
            f"| [{row['company']}]({link}) | {row['ticker']} | {'、'.join(row['industries'])} | {row['price']:.2f}元 | "
            f"{row['base_low']:.2f}—{row['base_high']:.2f}元 | {row['label']} | {row['bubble_status']} |"
        )
    if failures:
        lines += ["", "## 明确失败", ""] + [f"- {item['ticker']} {item['company']}:{item['error']}" for item in failures]
    lines += ["", f"机器明细:`{detail_path.name}`。", ""]
    (SUMMARY_DIR / "农业与新能源研究标的新增估值汇总.md").write_text("\n".join(lines), encoding="utf-8")
    return manifest
 
 
def merged_research_membership() -> dict[str, dict[str, Any]]:
    merged: dict[str, dict[str, Any]] = {}
 
    def add(ticker: str, name: str, industries: list[str], layers: list[str]) -> None:
        item = merged.setdefault(ticker, {"name": name, "industries": set(), "layers": set()})
        if name and not item["name"]:
            item["name"] = name
        item["industries"].update(value for value in industries if value)
        item["layers"].update(value for value in layers if value)
 
    old_path = SUMMARY_DIR / "四行业调研标的估值覆盖清单.csv"
    with old_path.open(encoding="utf-8-sig", newline="") as handle:
        for row in csv.DictReader(handle):
            add(row["ticker"], row.get("research_name") or row.get("company") or "", row.get("industries", "").split(";"), row.get("research_layers", "").split(";"))
    targets, _ = research_targets()
    for ticker, target in targets.items():
        add(ticker, target.research_name, list(target.industries), list(target.research_layers))
    return merged
 
 
def rebuild_coverage(trade_date: str) -> dict[str, Any]:
    membership = merged_research_membership()
    with mysql_connection("stock_valuation") as connection, connection.cursor() as cursor:
        cursor.execute("SELECT ticker,company FROM security WHERE active=1")
        securities = {row["ticker"]: row["company"] for row in cursor.fetchall()}
        cursor.execute(
            "SELECT j.ticker,s.company,j.trade_date,j.close,j.base_low,j.base_high,j.optimistic_high,j.label,"
            "v.valuation_date,v.report_path FROM daily_judgement j JOIN security s ON s.ticker=j.ticker "
            "JOIN valuation_version v ON v.valuation_id=j.valuation_id WHERE j.trade_date=%s",
            (trade_date,),
        )
        judgements = {row["ticker"]: row for row in cursor.fetchall()}
 
    rows: list[dict[str, Any]] = []
    for ticker, item in sorted(membership.items()):
        current = judgements.get(ticker, {})
        if current:
            status = "已按最新交易日判定"
        elif ticker in securities:
            status = "已有基础估值但当日无有效价格"
        else:
            status = "尚无数值基础估值或属于特殊结论"
        rows.append({
            "ticker": ticker,
            "company": current.get("company") or securities.get(ticker) or item["name"],
            "industries": ";".join(sorted(item["industries"])),
            "research_layers": ";".join(sorted(item["layers"])),
            "coverage_status": status,
            "trade_date": str(current.get("trade_date") or ""),
            "close": current.get("close", ""),
            "base_low": current.get("base_low", ""),
            "base_high": current.get("base_high", ""),
            "optimistic_high": current.get("optimistic_high", ""),
            "label": current.get("label", ""),
            "valuation_date": str(current.get("valuation_date") or ""),
            "report_path": current.get("report_path", ""),
        })
 
    SUMMARY_DIR.mkdir(parents=True, exist_ok=True)
    csv_path = SUMMARY_DIR / "全部行业调研标的估值覆盖清单.csv"
    with csv_path.open("w", encoding="utf-8-sig", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)
 
    industries = sorted({industry for item in membership.values() for industry in item["industries"]})
    lines = [
        "# 全部行业调研标的估值覆盖",
        "",
        f"- 当前价格日:{trade_date};研究证券去重后{len(rows)}只。",
        "- 证券池来自半导体、机器人、军工既有正式覆盖清单,以及农业、新能源当前稳定研究成果;全A股底表和待核验发现候选不纳入。",
        "- 当前价格判定来自 MySQL `stock_valuation`;合理价值区间为条件化估值,不是目标价或交易指令。",
        "",
        "## 覆盖统计",
        "",
        "| 行业 | 调研证券 | 最新交易日已判定 | 已有估值但缺当日价 | 尚无数值估值/特殊结论 |",
        "|---|---:|---:|---:|---:|",
    ]
    for industry in industries:
        pool = [row for row in rows if industry in row["industries"].split(";")]
        counts = Counter(row["coverage_status"] for row in pool)
        lines.append(
            f"| {industry} | {len(pool)} | {counts['已按最新交易日判定']} | "
            f"{counts['已有基础估值但当日无有效价格']} | {counts['尚无数值基础估值或属于特殊结论']} |"
        )
    labels = Counter(row["label"] for row in rows if row["label"])
    lines += [
        "",
        "## 最新价格判断分布(研究池去重)",
        "",
        f"- 偏低{labels['偏低']}只、基本合理{labels['基本合理']}只、偏贵{labels['偏贵']}只、明显偏贵{labels['明显偏贵']}只。",
        f"- 逐股完整清单:[{csv_path.name}]({csv_path.name})。",
        "- 半导体179只按估值由便宜到贵的完整结果:[Markdown](半导体估值排序.md);[CSV](半导体估值排序.csv)。",
        "- 全部已评估证券(包括用户单独点名、但不属于上述行业研究池的公司)的唯一当前入口仍是 `../估值台账/latest.md` 与 `../估值台账/latest.csv`。",
        "",
    ]
    md_path = SUMMARY_DIR / "全部行业调研标的估值覆盖汇总.md"
    md_path.write_text("\n".join(lines), encoding="utf-8")
 
    focus_rows = [row for row in rows if set(row["industries"].split(";")) & {"农业", "新能源"}]
    label_order = {"偏低": 0, "基本合理": 1, "偏贵": 2, "明显偏贵": 3, "": 4}
    focus_rows.sort(key=lambda row: (label_order.get(row["label"], 5), row["ticker"]))
    focus_lines = [
        "# 农业与新能源研究标的当前估值列表",
        "",
        f"- 当前完整价格日:{trade_date};农业56只,新能源115只,合计去重{len(focus_rows)}只。",
        "- 顺序为偏低、基本合理、偏贵、明显偏贵、特殊/缺口;同档按证券代码排列。",
        "- 合理区间是条件化估值,不是目标价、交易指令或收益承诺。",
        "",
    ]
    for industry in ("农业", "新能源"):
        pool = [row for row in focus_rows if industry in row["industries"].split(";")]
        counts = Counter(row["label"] or "特殊/缺口" for row in pool)
        focus_lines.append(
            f"- {industry}:偏低{counts['偏低']}、基本合理{counts['基本合理']}、偏贵{counts['偏贵']}、"
            f"明显偏贵{counts['明显偏贵']}、特殊/缺口{counts['特殊/缺口']}。"
        )
    focus_lines += [
        "",
        "| 公司 | 代码 | 行业 | 价格日 | 收盘价 | 基准合理区间 | 判断 | 状态 | 正式报告 |",
        "|---|---|---|---|---:|---:|---|---|---|",
    ]
    for row in focus_rows:
        close = "—" if row["close"] == "" else f"{float(row['close']):.2f}元"
        base = "—" if row["base_low"] == "" else f"{float(row['base_low']):.2f}—{float(row['base_high']):.2f}元"
        report = "—" if not row["report_path"] else f"[报告](../../../../{row['report_path']})"
        focus_lines.append(
            f"| {row['company']} | {row['ticker']} | {row['industries'].replace(';', '、')} | "
            f"{row['trade_date'] or '—'} | {close} | {base} | {row['label'] or '特殊/缺口'} | "
            f"{row['coverage_status']} | {report} |"
        )
    focus_path = SUMMARY_DIR / "农业与新能源研究标的当前估值列表.md"
    focus_path.write_text("\n".join(focus_lines) + "\n", encoding="utf-8")
    manifest = {
        "batch_id": BATCH_ID,
        "trade_date": trade_date,
        "target_count": len(rows),
        "current_judgement_count": sum(bool(row["trade_date"]) for row in rows),
        "industry_counts": {industry: sum(industry in row["industries"].split(";") for row in rows) for industry in industries},
        "label_counts": dict(labels),
        "csv_sha256": hashlib.sha256(csv_path.read_bytes()).hexdigest(),
        "md_sha256": hashlib.sha256(md_path.read_bytes()).hexdigest(),
        "agriculture_newenergy_md_sha256": hashlib.sha256(focus_path.read_bytes()).hexdigest(),
    }
    ranking_md = SUMMARY_DIR / "半导体估值排序.md"
    ranking_csv = SUMMARY_DIR / "半导体估值排序.csv"
    if ranking_md.is_file() and ranking_csv.is_file():
        manifest["semiconductor_ranking_md_sha256"] = hashlib.sha256(ranking_md.read_bytes()).hexdigest()
        manifest["semiconductor_ranking_csv_sha256"] = hashlib.sha256(ranking_csv.read_bytes()).hexdigest()
    write_json(SUMMARY_DIR / "全部行业调研标的估值覆盖manifest.json", manifest)
    return manifest
 
 
def repair_generated_labels(as_of: str) -> dict[str, Any]:
    """Align the human conclusion with the frozen V1/ledger interval rule."""
    configure_generator(as_of)
    manifest_path = CASE_ROOT / "batch_manifest.json"
    manifest = gen.base.read_json(manifest_path)
    changed: list[dict[str, str]] = []
    for row in manifest.get("results", []):
        expected = gen.label_for(
            float(row["price"]),
            (float(row["base_low"]), float(row["base_high"])),
            (float(row["base_high"]), float(row["optimistic_high"])),
        )
        old = row["label"]
        if expected == old:
            continue
        report_path = ROOT / row["formal_path"]
        text = report_path.read_text(encoding="utf-8")
        text = text.replace(f",判断为{old};", f",判断为{expected};", 1)
        report_path.write_text(text, encoding="utf-8")
        evidence_path = report_path.parent / "source_evidence_manifest.json"
        evidence = gen.base.read_json(evidence_path)
        evidence["checks"]["label"] = expected
        write_json(evidence_path, evidence)
        row["label"] = expected
        changed.append({"ticker": row["ticker"], "old": old, "new": expected})
    write_json(manifest_path, manifest)
    generate_missing(as_of)
    return {"changed_count": len(changed), "changed": changed}
 
 
def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=("inventory", "fetch", "generate", "coverage", "repair"))
    parser.add_argument("--as-of", default="2026-08-19")
    parser.add_argument("--workers", type=int, default=12)
    args = parser.parse_args()
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    if args.action == "inventory":
        targets, notes = research_targets()
        covered = existing_security()
        result = {"targets": len(targets), "covered": len(set(targets) & set(covered)), "missing": len(set(targets) - set(covered)), "sources": notes}
    elif args.action == "fetch":
        result = fetch_missing(args.as_of, args.workers)
    elif args.action == "generate":
        result = generate_missing(args.as_of)
    elif args.action == "repair":
        result = repair_generated_labels(args.as_of)
    else:
        result = rebuild_coverage(args.as_of)
    print(json.dumps(result, ensure_ascii=False, default=str))
 
 
if __name__ == "__main__":
    main()