Cai
2026-08-16 2992aee3f9bb2eaa5dd4da28a598be3d67ea0ec0
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
#!/usr/bin/env python3
"""Generate the 2026-08-13 formal valuations for Deye and Sieyuan.
 
This is a task-specific analysis builder.  It reuses the frozen V1 arithmetic
core, the V2 evidence package and the local front-adjusted market contract; it
does not duplicate V1 valuation formulas or write external databases.
"""
 
from __future__ import annotations
 
import hashlib
import json
import os
import statistics
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Any
 
import pymysql
 
from stock_valuation_pipeline.core import run_pipeline
 
 
ROOT = Path(__file__).resolve().parents[2]
AS_OF = "2026-08-13"
AS_OF_STAMP = "20260813"
RESULT_ROOT = ROOT / "ana-data" / "result" / "股票估值"
CASE_ROOT = ROOT / "ana-data" / "cases" / "股票估值" / "ANA-STOCK-VALUATION-DEYE-SIEYUAN-20260813-001"
V2_ROOT = ROOT / "ai-valuation-analyst" / "tmp" / "valuation_20260813_deye_sieyuan_v2"
CACHE_ROOT = ROOT / "ai-valuation-analyst" / "tmp" / "v2_cache" / "blobs" / "sha256"
REGISTRY = ROOT / "dev" / "ana-dev" / "stock_valuation_pipeline" / "source_registry.json"
 
 
@dataclass(frozen=True)
class Company:
    ticker: str
    slug: str
    company: str
    market: str
    report_shares: float
    current_shares: float
    share_date: str
    share_title: str
    share_url: str
    share_publish_date: str
    business: str
    profit_sources: list[str]
    quality: list[str]
    risks: list[str]
    upgrade_triggers: list[str]
    downgrade_triggers: list[str]
    base_profit: tuple[float, float]
    base_multiple: tuple[float, float]
    pessimistic_profit: tuple[float, float]
    pessimistic_multiple: tuple[float, float]
    optimistic_profit: tuple[float, float]
    optimistic_multiple: tuple[float, float]
    latest_info_date: str
    latest_info_title: str
    latest_info_url: str
    latest_info_note: str
 
 
COMPANIES = (
    Company(
        ticker="605117.SH",
        slug="deye",
        company="德业股份",
        market="上海证券交易所",
        report_shares=909_332_542.0,
        current_shares=1_273_231_036.0,
        share_date="2026-07-02",
        share_title="关于2022年股票期权激励计划2026年第二季度自主行权结果暨股份变动的公告",
        share_url="https://static.sse.com.cn/disclosure/listedinfo/announcement/c/new/2026-07-02/605117_20260702_MIX1.pdf",
        share_publish_date="2026-07-02",
        business="户用及工商业储能逆变器、储能电池包与环境电器",
        profit_sources=[
            "2025年储能逆变器收入52.17亿元、毛利率51.10%,是毛利贡献的第一来源;储能电池包收入38.32亿元、毛利率31.81%,是增速最快的第二增长极。",
            "光伏逆变器收入10.54亿元,环境电器和热交换器合计约20.05亿元;2025年外销收入97.47亿元,占主营收入约80.0%,利润高度依赖海外储能需求和渠道。",
            "2026年上半年业绩预告归母净利润26.68至27.28亿元、扣非24.93至25.53亿元,增长主要来自欧洲、中东、东南亚户储及工商业储能销售放量。",
        ],
        quality=[
            "2025年经营现金流39.86亿元,高于归母净利润31.71亿元;TTM简化自由现金流约40.60亿元,现金转化较好。",
            "2025年储能电池包毛利率同比下降9.49个百分点,且逆变器、电池包期末库存分别同比增加52.55%和49.18%,高增长同时带来库存与价格压力。",
            "2025年合同负债4.80亿元、同比增长81.43%;2026Q1升至6.05亿元,预收增长对后续收入有一定支撑。",
        ],
        risks=[
            "外销约占主营收入80%,欧盟、美国及其他市场的关税、贸易限制、认证和补贴规则变化会直接影响订单和利润率。",
            "储能景气和能源价格具有周期性;机构2026年利润预期较2025年大幅增长,若下半年需求或毛利率回落,年度利润容易低于一致预期。",
            "储能电池包毛利率已显著下降,锂电池、IGBT、铜铝及其他元器件价格波动会侵蚀利润;库存高增长增加跌价与渠道去库存风险。",
            "汇率、海外应收与渠道回款、股权激励持续行权会影响归母利润和每股收益。",
        ],
        upgrade_triggers=[
            "2026年正式半年报达到预告中枢且经营现金流、库存周转同步改善。",
            "欧洲、中东、东南亚之外的新市场持续放量,储能电池包毛利率止跌回升。",
        ],
        downgrade_triggers=[
            "2026年归母净利润低于60亿元或扣非利润与现金流明显背离。",
            "贸易限制、补贴退坡、渠道去库存或产品降价使储能业务毛利率继续显著下滑。",
        ],
        base_profit=(6.0e9, 7.4e9),
        base_multiple=(18.0, 24.0),
        pessimistic_profit=(4.5e9, 5.5e9),
        pessimistic_multiple=(12.0, 17.0),
        optimistic_profit=(7.8e9, 9.0e9),
        optimistic_multiple=(24.0, 30.0),
        latest_info_date="2026-07-09",
        latest_info_title="2026年半年度业绩预增公告",
        latest_info_url="https://static.sse.com.cn/disclosure/listedinfo/announcement/c/new/2026-07-09/605117_20260709_IHEV.pdf",
        latest_info_note="预告口径:归母26.68至27.28亿元,扣非24.93至25.53亿元。",
    ),
    Company(
        ticker="002028.SZ",
        slug="sieyuan_electric",
        company="思源电气",
        market="深圳证券交易所",
        report_shares=782_451_182.0,
        current_shares=782_573_282.0,
        share_date="2026-06-30",
        share_title="2025年度权益分派实施公告(现有总股本证明)",
        share_url="https://disc.static.szse.cn/download/disc/disk03/finalpage/2026-07-01/d515dfa9-944e-45e0-adce-b7b4d6f678e0.PDF",
        share_publish_date="2026-07-01",
        business="输配电设备、保护自动化、电力电子、海外EPC与储能系统",
        profit_sources=[
            "2025年开关类收入89.35亿元、变压器类46.33亿元、保护及自动化25.42亿元,是利润的主体;三类毛利率分别为36.88%、36.39%和35.11%。",
            "电力电子、EPC和储能系统及元件收入分别15.25亿元、23.32亿元和15.31亿元;后两类增长快但毛利率仅约11.8%,对收入增长的贡献高于对利润的贡献。",
            "2025年海外收入58.03亿元、同比增长85.84%,占收入26.94%;新增订单288.91亿元、同比增长34.64%,订单与海外扩张是未来利润的主要驱动。",
        ],
        quality=[
            "2025年经营现金流22.34亿元、低于归母净利润31.50亿元;TTM经营现金流23.11亿元,简化自由现金流15.41亿元,现金流为正但转化率不算高。",
            "2025年末应收账款81.83亿元,占总资产27.42%;合同负债29.77亿元,2026Q1升至31.50亿元。较高预收支撑订单,但高应收和海外EPC拉长回款周期。",
            "2026年半年业绩快报归母14.87亿元、同比增长15.03%,明显慢于收入27.14%;汇兑损失及思源东芝持股比例变更压低归母利润增速。",
        ],
        risks=[
            "2026H1归母利润仅增长15.03%,低于当前一致预期隐含的全年约37%增速,下半年需要明显加速才能兑现43亿元左右的机构预测。",
            "应收账款规模高、海外EPC周期1至3年,存在回款、汇率、税务、施工、合同与分包风险。",
            "铜铝等大宗原料、半导体器件和国际物流价格上涨,以及低毛利EPC/储能占比提高,可能压制整体利润率。",
            "海外业务扩张带来汇兑与当地运营风险;2026H1已出现美元应收等外币资产的较大汇兑损失。",
        ],
        upgrade_triggers=[
            "新增订单持续接近公司2026年375亿元经营目标,合同负债与现金回款同步增长。",
            "下半年归母利润增速回升至25%以上,开关、变压器等高毛利业务占比稳定,汇兑损失收窄。",
        ],
        downgrade_triggers=[
            "2026年归母净利润低于38亿元,或收入增长继续快于利润而毛利率显著下行。",
            "应收账款和海外EPC风险上升、经营现金流转弱,或思源东芝少数股权变化持续稀释归母利润。",
        ],
        base_profit=(3.8e9, 4.5e9),
        base_multiple=(26.0, 32.0),
        pessimistic_profit=(3.3e9, 3.8e9),
        pessimistic_multiple=(20.0, 25.0),
        optimistic_profit=(4.6e9, 5.2e9),
        optimistic_multiple=(32.0, 38.0),
        latest_info_date="2026-07-11",
        latest_info_title="2026年半年度业绩快报",
        latest_info_url="https://file.finance.sina.com.cn/211.154.219.97%3A9494/MRGG/CNSESZ_STOCK/2026/2026-7/2026-07-11/12441232.PDF",
        latest_info_note="快报口径:收入108.03亿元、归母14.87亿元、扣非14.19亿元、经营现金流2.25亿元。",
    ),
)
 
 
def load_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8-sig"))
 
 
def failed_dir(ticker: str) -> Path:
    matches = sorted(V2_ROOT.glob(f"{ticker.replace('.', '_')}.failed-*"))
    if len(matches) != 1:
        raise RuntimeError(f"Expected one V2 failure package for {ticker}, got {len(matches)}")
    return matches[0]
 
 
def blob(raw_hash: str) -> bytes:
    path = CACHE_ROOT / raw_hash[:2] / f"{raw_hash}.bin"
    data = path.read_bytes()
    if hashlib.sha256(data).hexdigest() != raw_hash:
        raise RuntimeError(f"Cache hash mismatch: {raw_hash}")
    return data
 
 
def consensus(provider: dict[str, Any], shares: float) -> tuple[int, dict[str, dict[str, float]], dict[str, float]]:
    summary_row: dict[str, Any] | None = None
    summary_hash = ""
    for raw_hash in provider["forecast"]["raw_artifact_hashes"]:
        try:
            value = json.loads(blob(raw_hash).decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError):
            continue
        rows = ((value.get("result") or {}).get("data") or [])
        if rows and "RATING_ORG_NUM" in rows[0]:
            summary_row = rows[0]
            summary_hash = raw_hash
            break
    if not summary_row:
        raise RuntimeError("No consensus summary")
    years: dict[str, dict[str, float]] = {}
    for idx in range(1, 5):
        year = summary_row.get(f"YEAR{idx}")
        eps = summary_row.get(f"EPS{idx}")
        if year and eps is not None and str(summary_row.get(f"YEAR_MARK{idx}")) == "E":
            years[str(year)] = {"eps": float(eps), "profit": float(eps) * shares}
    targets = {
        "min": float(summary_row["DEC_AIMPRICEMIN"]) if summary_row.get("DEC_AIMPRICEMIN") is not None else 0.0,
        "max": float(summary_row["DEC_AIMPRICEMAX"]) if summary_row.get("DEC_AIMPRICEMAX") is not None else 0.0,
        "raw_hash": summary_hash,
    }
    return int(summary_row.get("RATING_ORG_NUM") or 0), years, targets
 
 
def mysql_connection() -> 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="trading_xuntou",
        charset="utf8mb4",
        autocommit=True,
        cursorclass=pymysql.cursors.DictCursor,
    )
 
 
def local_market(ticker: str) -> dict[str, Any]:
    start = date.fromisoformat(AS_OF) - timedelta(days=150)
    with mysql_connection() 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"No complete {AS_OF} front-adjusted row for {ticker}")
    closes = [float(row["close"]) for row in rows]
    amounts = [float(row["amount"]) for row in rows]
    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,
        "return_60d": closes[-1] / closes[-61] - 1,
        "ma60": statistics.fmean(closes[-60:]),
        "distance_to_ma60": closes[-1] / statistics.fmean(closes[-60:]) - 1,
        "amount_ratio_5d_to_60d": statistics.fmean(amounts[-5:]) / statistics.fmean(amounts[-60:]),
        "sample_count": len(rows),
    }
 
 
def provider_data(company: Company) -> tuple[dict[str, Any], dict[str, Any]]:
    directory = failed_dir(company.ticker)
    provider = load_json(directory / "provider_results.json")
    failure = load_json(directory / "failure.json")
    if provider["announcements"]["identity"]["ticker"] != company.ticker:
        raise RuntimeError("Ticker mismatch")
    return provider, failure
 
 
def announcement(provider: dict[str, Any], title: str) -> dict[str, Any]:
    def normalized(value: str) -> str:
        return value.replace("第一季度", "一季度").replace(" ", "")
 
    rows = [row for row in provider["announcements"]["records"] if normalized(row["title"]) == normalized(title)]
    if len(rows) != 1:
        raise RuntimeError(f"Expected one announcement {title}, got {len(rows)}")
    return rows[0]
 
 
def scenario(name: str, role: str, profit: tuple[float, float], multiple: tuple[float, float], assumption: str) -> dict[str, Any]:
    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,
    }
 
 
def build(company: Company) -> dict[str, Any]:
    provider, failure = provider_data(company)
    financials = provider["finance"]["financials"]
    balance = dict(provider["finance"]["balance_sheet"])
    market = local_market(company.ticker)
    quote_obj = None
    quote_hash = ""
    for raw_hash in provider["market"]["raw_artifact_hashes"]:
        try:
            obj = json.loads(blob(raw_hash).decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError):
            continue
        if (obj.get("data") or {}).get("f84"):
            quote_obj = obj["data"]
            quote_hash = raw_hash
            break
    if not quote_obj or float(quote_obj["f84"]) != company.current_shares:
        raise RuntimeError(f"Quote/share proof mismatch for {company.ticker}")
    if abs(market["price"] * company.current_shares - float(quote_obj["f116"])) > 1.0:
        raise RuntimeError(f"Market-cap cross-check failed for {company.ticker}")
    count, forecasts, targets = consensus(provider, company.current_shares)
    annual = announcement(provider, "2025年年度报告")
    current = announcement(provider, "2026年第一季度报告")
 
    annual_f = financials["annual"]
    current_f = financials["current_cumulative"]
    prior_f = financials["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"]
    # V2 finance equity is total equity.  V1 PB uses attributable equity.
    parent_equity = float(balance["equity"]) - float(balance["minority_interest"])
    balance["equity"] = parent_equity
 
    scenarios = [
        scenario("悲观", "pessimistic", company.pessimistic_profit, company.pessimistic_multiple, "需求、订单或毛利率低于预期,利润下修且估值回归行业低位。"),
        scenario("基准", "base", company.base_profit, company.base_multiple, "结合2026年最新业绩预告/快报、机构一致预期、订单与现金流,给予与增长持续性和风险相匹配的PE区间。"),
        scenario("乐观", "optimistic", company.optimistic_profit, company.optimistic_multiple, "订单、海外扩张、产品结构和利润率超预期,利润与估值中枢同步上移。"),
    ]
    base_low = company.base_profit[0] * company.base_multiple[0] / company.current_shares
    base_high = company.base_profit[1] * company.base_multiple[1] / company.current_shares
    optimistic_high = company.optimistic_profit[1] * company.optimistic_multiple[1] / company.current_shares
    if market["price"] < base_low:
        label = "偏低"
    elif market["price"] <= base_high:
        label = "基本合理"
    elif market["price"] <= optimistic_high:
        label = "偏贵"
    else:
        label = "明显偏贵"
 
    forecast_rows = [{
        "institution": f"东方财富公开一致预期汇总(覆盖池{count}家)",
        "report_date": AS_OF,
        "include": True,
        "core_assumption": "汇总快照不替代逐家研报;覆盖池数量不等于每个年度的有效样本数。",
        "source_id": "SRC-EM-CONSENSUS-20260813",
        "estimates": forecasts,
    }]
    sources = [
        {
            "id": "SRC-ANNUAL-2025", "source_type": "cninfo", "title": annual["title"],
            "publish_date": annual["publish_date"], "period_end": "2025-12-31", "url": annual["url"],
            "supports": ["financials.annual", "business", "profit_sources", "risks"], "revision_status": "current",
        },
        {
            "id": "SRC-CURRENT-2026Q1", "source_type": "cninfo", "title": current["title"],
            "publish_date": current["publish_date"], "period_end": "2026-03-31", "url": current["url"],
            "supports": ["financials.current_cumulative", "financials.prior_year_same_period", "balance_sheet", "balance_sheet.equity"], "revision_status": "current",
        },
        {
            "id": "SRC-LATEST-OPERATING", "source_type": "sse" if company.ticker.endswith(".SH") else "financial_mirror",
            "title": company.latest_info_title, "publish_date": company.latest_info_date, "period_end": "2026-06-30",
            "url": company.latest_info_url, "supports": ["latest_operating_information", "valuation.scenarios"], "revision_status": "current",
        },
        {
            "id": "SRC-SHARES-A1", "source_type": "sse" if company.ticker.endswith(".SH") else "szse",
            "title": company.share_title, "publish_date": company.share_publish_date, "period_end": company.share_date,
            "url": company.share_url, "supports": ["market.diluted_shares", "market.shares_date"], "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",
        },
        {
            "id": "SRC-EM-QUOTE-SHARES", "source_type": "quote_provider", "title": "东方财富当前总股本与总市值快照交叉核验",
            "publish_date": AS_OF, "period_end": AS_OF, "url": "https://push2delay.eastmoney.com/api/qt/stock/get",
            "supports": ["market.diluted_shares", "market.platform_market_cap"], "revision_status": "current",
        },
        {
            "id": "SRC-EM-CONSENSUS-20260813", "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-{company.ticker[:6]}-{AS_OF_STAMP}-V1",
        "meta": {
            "company": company.company, "code": company.ticker, "market": company.market,
            "as_of_date": AS_OF, "currency": "CNY", "report_period_end": "2026-03-31",
            "latest_operating_info_date": company.latest_info_date,
        },
        "market": {
            "price": market["price"], "price_type": "最近完整交易日前复权收盘价",
            "price_timestamp": f"{AS_OF}T15:00:00+08:00", "diluted_shares": company.current_shares,
            "shares_date": company.share_date, "platform_market_cap": float(quote_obj["f116"]),
        },
        "financials": financials,
        "balance_sheet": {
            "cash_available": balance["cash_available"], "equity": balance["equity"],
            "interest_bearing_debt": balance["interest_bearing_debt"], "minority_interest": balance["minority_interest"],
            "non_operating_financial_assets": balance["non_operating_financial_assets"], "period_end": balance["period_end"],
        },
        "normalization": {
            "adjustments": [{
                "description": "以TTM扣非归母利润替代TTM归母利润作为核心盈利代理",
                "amount": ttm_deduct - ttm_attr,
                "basis": "2025年报加2026Q1减2025Q1;最新半年预告/快报仅用于情景利润,不混入TTM法定口径",
            }]
        },
        "valuation": {
            "scenarios": scenarios,
            "reverse_pe_multiples": [12, 15, 18, 20, 22, 25, 28, 30, 32, 35, 40],
            "exit_pe_multiples": [12, 15, 18, 20, 22, 25, 28, 30, 32, 35, 40],
            "holding_period": {"years": 5, "required_return": 0.10, "cumulative_dividend_per_share": 0},
        },
        "institutions": {"coverage_status": "available", "forecasts": forecast_rows},
        "sources": sources,
        "analysis": {
            "company_type": company.business,
            "primary_model": "2026年前瞻PE情景;TTM扣非、PB、现金流、最新半年预告/快报与反向利润交叉验证",
            "cross_checks": ["最新半年预告/快报", "机构一致预期", "PB/ROE", "经营现金流与资本开支", "订单/合同负债/应收", "前复权量价"],
            "profit_sources": company.profit_sources,
            "profit_source_quality": company.quality + [
                f"法定TTM归母{ttm_attr/1e8:.2f}亿元、TTM扣非{ttm_deduct/1e8:.2f}亿元;主模型以最新半年信息和机构预期构造2026全年情景。",
                f"TTM经营现金流{ttm_cfo/1e8:.2f}亿元、资本开支{ttm_capex/1e8:.2f}亿元、简化自由现金流{(ttm_cfo-ttm_capex)/1e8:.2f}亿元。",
            ],
            "risks": company.risks + ["机构汇总口径不是法定事实,且覆盖池数量不等于每个年度的有效样本数。"],
            "upgrade_triggers": company.upgrade_triggers,
            "downgrade_triggers": company.downgrade_triggers,
            "executive_conclusion": f"{market['price']:.2f}元位于基准合理区间{base_low:.2f}至{base_high:.2f}元内,判断为{label}。当前结论依赖2026年利润兑现,不构成目标价或交易指令。",
            "consensus_org_count": count,
            "consensus_coverage_note": f"东方财富公开汇总覆盖池{count}家;2026E EPS {forecasts['2026']['eps']:.4f}元,对应利润{forecasts['2026']['profit']/1e8:.2f}亿元。",
            "latest_operating_info_note": company.latest_info_note,
            "consensus_target_range": f"{targets['min']:.2f}至{targets['max']:.2f}元(仅作市场预期参照,不替代本报告合理区间)",
            "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"],
            "market_source_batch_id": market["source_batch_id"], "market_updated_at": market["updated_at"],
            "ttm_cfo": ttm_cfo, "ttm_capex": ttm_capex, "ttm_fcf": ttm_cfo - ttm_capex,
            "total_equity_original": provider["finance"]["balance_sheet"]["equity"],
            "attributable_equity_used": parent_equity, "minority_interest_original": balance["minority_interest"],
            "report_shares": company.report_shares, "share_proof_status": "MATCH_A1_CHANGE",
            "information_cutoff": f"{AS_OF}T23:59:00+08:00",
        },
    }
 
    out_dir = RESULT_ROOT / f"{AS_OF_STAMP}_{company.slug}_valuation"
    out_dir.mkdir(parents=True, exist_ok=True)
    snapshot_path = out_dir / f"{company.company}估值快照_{AS_OF_STAMP}.json"
    snapshot_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    calc_dir = out_dir / "calculation"
    run = run_pipeline(snapshot_path, calc_dir, REGISTRY)
    if run["status"] not in {"GENERATED", "REUSED"}:
        raise RuntimeError(f"Unexpected V1 status {run}")
    results = load_json(calc_dir / "valuation_results.json")
    calc_report_path = calc_dir / "valuation_report.md"
    calc_report_text = calc_report_path.read_text(encoding="utf-8")
    calc_report_text = "\n".join(line.rstrip() for line in calc_report_text.splitlines()) + "\n"
    calc_report_path.write_text(calc_report_text, encoding="utf-8")
    report_text = calc_report_text
    report_text = report_text.replace("价格合理性评估(流水线生成底稿)", "价格合理性评估", 1)
    report_text = report_text.replace(
        "本文是研究计算底稿,不构成交易指令或收益承诺。",
        "本文为按《股票价格合理性评估操作手册》形成的条件化研究结论,不构成交易指令或收益承诺。",
        1,
    )
    report_text = report_text.replace(
        "覆盖状态:`available`。早于最新经营信息日、或低于已实现累计利润的预测自动排除出当前一致预期。",
        f"覆盖状态:`available`。东方财富公开一致预期覆盖池为 **{count}家**;下表按一条汇总观测进入V1,因此“机构数”列显示1,不代表仅有1家机构。早于最新经营信息日、或低于已实现累计利润的预测自动排除出当前一致预期。",
        1,
    )
    market_note = (
        "\n\n### 1.1 前复权量价显影\n\n"
        f"- 20日涨跌:{market['return_20d']*100:.2f}%;60日涨跌:{market['return_60d']*100:.2f}%。\n"
        f"- 60日均线:{market['ma60']:.2f}元;当前价相对MA60:{market['distance_to_ma60']*100:.2f}%。\n"
        f"- 近5日/60日平均成交额比:{market['amount_ratio_5d_to_60d']:.2f}。量价用于显影情绪,不改变合理价值。\n"
    )
    report_text = report_text.replace("\n## 2. 信息来源与固定优先级", market_note + "\n## 2. 信息来源与固定优先级", 1)
    report_text = report_text.replace(
        "\n## 6. 利润质量、现金流与资产负债",
        f"\n### 5.1 最新半年经营信息\n\n- {company.latest_info_note}\n- 该信息用于调整2026全年情景利润,不与法定TTM公式重复相加。\n\n## 6. 利润质量、现金流与资产负债",
        1,
    )
    bubble_text = (
        "\n## 11.1 估值泡沫判定\n\n"
        f"- 当前价{market['price']:.2f}元未高于基准合理区间上沿{base_high:.2f}元,按手册规则**未识别估值泡沫**。\n"
        f"- 当前价相对基准上沿为{(market['price']/base_high-1)*100:.2f}%,相对乐观上沿为{(market['price']/optimistic_high-1)*100:.2f}%。\n"
        "- 这不等于没有经营风险;若利润情景下修,合理区间会同步下移,需要按下调触发器复评。\n"
    )
    report_text = report_text.replace("\n## 12. 风险、上调与下调触发器", bubble_text + "\n## 12. 风险、上调与下调触发器", 1)
    # V1 uses Markdown hard-break spaces in its header; formal project files keep
    # clean line endings and do not rely on trailing whitespace for rendering.
    report_text = "\n".join(line.rstrip() for line in report_text.splitlines()) + "\n"
    formal_path = out_dir / f"{company.company}价格合理性评估_{AS_OF_STAMP}.md"
    formal_path.write_text(report_text, encoding="utf-8")
 
    evidence = {
        "case_id": "ANA-STOCK-VALUATION-DEYE-SIEYUAN-20260813-001",
        "ticker": company.ticker, "company": company.company, "as_of": AS_OF,
        "v2_status": "BLOCKED", "v2_failure": failure,
        "v2_failure_package": str(failed_dir(company.ticker).relative_to(ROOT)).replace("\\", "/"),
        "v2_raw_artifact_hashes": sorted({h for section in provider.values() for h in section.get("raw_artifact_hashes", [])}),
        "quote_raw_hash": quote_hash, "consensus_raw_hash": targets["raw_hash"],
        "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,
        },
        "share_proof": {
            "current_shares": company.current_shares, "a1_title": company.share_title,
            "a1_publish_date": company.share_publish_date, "a1_url": company.share_url,
            "quote_shares": quote_obj["f84"], "quote_market_cap": quote_obj["f116"],
            "price_times_shares": market["price"] * company.current_shares,
        },
        "bounded_gaps": [
            "V2 quote f124=0,故端到端V2按历史股本时间戳门禁诚实阻断;正式估值由后续A1股本公告和估值日总股本/总市值快照补证。",
            "机构汇总覆盖池不是逐家研报原文;本报告披露机构数、EPS和推算利润,不将其写成法定事实。",
            "最新半年预告/快报提供利润锚,但资产负债表、现金流明细仍以2026Q1法定报告为最新完整结构口径。",
        ],
        "checks": {
            "v1_qa": results["qa"], "label": label, "base_low": base_low, "base_high": base_high,
            "optimistic_high": optimistic_high, "price": market["price"],
        },
    }
    (out_dir / "source_evidence_manifest.json").write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return {
        "ticker": company.ticker, "company": company.company, "price": market["price"],
        "base_low": base_low, "base_high": base_high, "optimistic_high": optimistic_high,
        "label": label, "normalized_profit": float(results["metrics"]["normalized_profit"]),
        "normalized_pe": float(results["metrics"]["normalized_pe"]), "pb": float(results["metrics"]["pb"]),
        "consensus_count": count, "consensus_2026": forecasts["2026"]["profit"],
        "forward_pe": market["price"] / forecasts["2026"]["eps"],
        "market": market, "report_path": str(formal_path.relative_to(ROOT)).replace("\\", "/"),
        "snapshot_path": str(snapshot_path.relative_to(ROOT)).replace("\\", "/"),
        "qa": results["qa"],
    }
 
 
def render_summary(rows: list[dict[str, Any]]) -> str:
    lines = [
        "# 德业股份与思源电气价格合理性评估汇总(2026-08-13)", "",
        "- 价格基准:2026-08-13完整交易日前复权收盘价。",
        "- 信息上限:2026-08-13;最新经营信息分别为德业股份2026H1业绩预告、思源电气2026H1业绩快报。",
        "- 估值区间为条件化研究结论,不是目标价、交易指令或收益承诺。", "",
        "| 公司 | 收盘价 | TTM扣非利润 | TTM扣非PE | 2026机构利润 | 前瞻PE | 基准合理区间 | 乐观上沿 | 判定 |",
        "|---|---:|---:|---:|---:|---:|---:|---:|---|",
    ]
    for row in rows:
        lines.append(
            f"| [{row['company']}](../../../../{row['report_path']}) | {row['price']:.2f}元 | "
            f"{row['normalized_profit']/1e8:.2f}亿元 | {row['normalized_pe']:.2f}倍 | "
            f"{row['consensus_2026']/1e8:.2f}亿元({row['consensus_count']}家覆盖池) | {row['forward_pe']:.2f}倍 | "
            f"{row['base_low']:.2f}—{row['base_high']:.2f}元 | {row['optimistic_high']:.2f}元 | {row['label']} |"
        )
    lines += [
        "", "## 核心判断", "",
        "- 德业股份:当前价低于基准中枢,2026H1预告强、现金流好,估值看起来更便宜;但外销占比约80%,储能景气、贸易政策、汇率和电池包毛利率是主要折价来源。",
        "- 思源电气:当前价位于基准区间中上部,订单和海外扩张具备支撑;但2026H1利润增速明显慢于收入,应收与海外EPC风险使其不宜直接套用机构目标价。",
        "- 两家公司当前价均未高于各自基准合理区间上沿,因此按手册规则均未识别估值泡沫;若正式半年报或全年利润低于情景下限,必须下修合理区间。", "",
        "## 量价显影", "",
        "| 公司 | 20日涨跌 | 60日涨跌 | MA60 | 相对MA60 | 近5日/60日成交额比 |",
        "|---|---:|---:|---:|---:|---:|",
    ]
    for row in rows:
        market = row["market"]
        lines.append(
            f"| {row['company']} | {market['return_20d']*100:.2f}% | {market['return_60d']*100:.2f}% | "
            f"{market['ma60']:.2f}元 | {market['distance_to_ma60']*100:.2f}% | {market['amount_ratio_5d_to_60d']:.2f} |"
        )
    lines += ["", "量价只用于识别市场情绪和拥挤度,不反推企业价值。", ""]
    return "\n".join(lines)
 
 
def main() -> None:
    rows = [build(company) for company in COMPANIES]
    CASE_ROOT.mkdir(parents=True, exist_ok=True)
    (CASE_ROOT / "评估任务清单.md").write_text(
        "# 德业股份与思源电气估值任务清单\n\n"
        "- 案例:`ANA-STOCK-VALUATION-DEYE-SIEYUAN-20260813-001`\n"
        "- 方法:`ana-doc/股票估值/股票价格合理性评估操作手册.md`\n"
        "- 基准日:2026-08-13\n\n"
        "| 代码 | 公司 | 状态 | 正式报告 |\n|---|---|---|---|\n" +
        "\n".join(f"| {row['ticker']} | {row['company']} | {row['qa']['status']} | `{row['report_path']}` |" for row in rows) + "\n",
        encoding="utf-8",
    )
    summary_dir = RESULT_ROOT / f"{AS_OF_STAMP}_deye_sieyuan_batch_valuation"
    summary_dir.mkdir(parents=True, exist_ok=True)
    (summary_dir / f"德业股份与思源电气估值汇总_{AS_OF_STAMP}.md").write_text(render_summary(rows), encoding="utf-8")
    (summary_dir / "batch_metrics.json").write_text(json.dumps({"as_of": AS_OF, "rows": rows}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"status": "PASS", "rows": rows}, ensure_ascii=False, indent=2))
 
 
if __name__ == "__main__":
    main()