Cai
2026-08-10 dcbb7e56299807c54800f7b1a7d76e39df5e9df0
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
#!/usr/bin/env python3
"""Build a read-only semiconductor MA60 rebound ranking from local MySQL."""
 
from __future__ import annotations
 
import argparse
import csv
import hashlib
import json
import os
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
 
import pymysql
 
 
SCOPE_VERSION = "semiconductor-scope-2026-08-07-v1"
 
# 口径:主营或重要业务直接位于芯片设计、制造、封测、EDA,或直接服务晶圆制造/封测的设备材料。
# relation=核心:芯片/晶圆/封测/EDA;上游:半导体专用设备、材料与封装基板;邻接:主题相关但半导体并非主要收入来源。
SCOPE = {
    "000021.SZ": ("核心(混合业务)", "存储半导体封测"),
    "001270.SZ": ("核心", "射频芯片"),
    "002049.SZ": ("核心", "特种集成电路"),
    "002151.SZ": ("核心(混合业务)", "导航芯片"),
    "002156.SZ": ("核心", "集成电路封测"),
    "002185.SZ": ("核心", "集成电路封测"),
    "002371.SZ": ("上游", "刻蚀/薄膜/清洗设备"),
    "002402.SZ": ("核心(混合业务)", "相控阵T/R芯片"),
    "002405.SZ": ("核心(混合业务)", "汽车芯片"),
    "002409.SZ": ("上游", "半导体材料/电子特气"),
    "002436.SZ": ("上游", "半导体测试板/封装基板"),
    "002916.SZ": ("上游", "封装基板"),
    "300053.SZ": ("核心(混合业务)", "宇航芯片/模块"),
    "300054.SZ": ("上游", "CMP抛光材料"),
    "300101.SZ": ("核心(混合业务)", "高端集成电路"),
    "300139.SZ": ("核心(混合业务)", "集成电路设计"),
    "300260.SZ": ("上游", "高纯管路/阀门/真空部件"),
    "300327.SZ": ("核心", "MCU/驱动芯片"),
    "300346.SZ": ("上游", "前驱体/电子特气/光刻胶"),
    "300373.SZ": ("核心", "功率半导体"),
    "300395.SZ": ("上游(混合业务)", "半导体石英材料"),
    "300456.SZ": ("核心", "MEMS晶圆制造"),
    "300474.SZ": ("核心(混合业务)", "图形显控芯片"),
    "300567.SZ": ("上游", "半导体检测设备"),
    "300576.SZ": ("上游", "光刻胶"),
    "300604.SZ": ("上游", "半导体测试设备"),
    "300613.SZ": ("核心", "视频处理芯片"),
    "300666.SZ": ("上游", "靶材/精密部件"),
    "300671.SZ": ("核心", "模拟集成电路"),
    "300672.SZ": ("核心", "多媒体/存储芯片"),
    "300757.SZ": ("上游(混合业务)", "泛半导体自动化设备"),
    "301269.SZ": ("核心", "EDA"),
    "301308.SZ": ("核心", "存储芯片/先进封测"),
    "301583.SZ": ("上游", "半导体设备精密零部件"),
    "301611.SZ": ("上游", "半导体陶瓷零部件"),
    "600206.SH": ("上游(混合业务)", "高纯金属靶材"),
    "600360.SH": ("核心", "功率半导体IDM"),
    "600584.SH": ("核心", "集成电路封测"),
    "600641.SH": ("上游", "离子注入设备"),
    "600877.SH": ("核心", "特种集成电路"),
    "603061.SH": ("上游", "测试分选设备"),
    "603068.SH": ("核心", "无线通信芯片"),
    "603078.SH": ("上游", "湿电子化学品"),
    "603650.SH": ("上游", "光刻胶/电子化学品"),
    "603893.SH": ("核心", "智能应用处理器"),
    "603936.SH": ("上游", "封装载板"),
    "605358.SH": ("核心", "硅片/功率器件/射频芯片"),
    "688002.SH": ("核心", "红外探测器芯片"),
    "688012.SH": ("上游", "刻蚀/薄膜沉积设备"),
    "688019.SH": ("上游", "CMP材料"),
    "688037.SH": ("上游", "涂胶显影/清洗设备"),
    "688049.SH": ("核心", "智能音频SoC"),
    "688072.SH": ("上游", "薄膜沉积设备"),
    "688107.SH": ("核心", "FPGA/EDA"),
    "688110.SH": ("核心", "存储芯片"),
    "688120.SH": ("上游", "CMP/减薄设备"),
    "688126.SH": ("上游", "半导体硅片"),
    "688135.SH": ("核心", "晶圆/成品测试"),
    "688153.SH": ("核心", "射频前端芯片"),
    "688172.SH": ("核心", "半导体制造/IDM"),
    "688199.SH": ("上游(混合业务)", "半导体化学材料"),
    "688213.SH": ("核心", "CMOS图像传感器"),
    "688233.SH": ("上游", "大直径硅材料/硅零部件"),
    "688262.SH": ("核心", "嵌入式CPU"),
    "688268.SH": ("上游", "电子特种气体"),
    "688270.SH": ("核心", "射频芯片/微系统"),
    "688313.SH": ("核心", "光通信芯片"),
    "688343.SH": ("核心(混合业务)", "AI推理芯片"),
    "688347.SH": ("核心", "晶圆代工"),
    "688352.SH": ("核心", "先进封装与测试"),
    "688361.SH": ("上游", "量检测设备"),
    "688362.SH": ("核心", "封装与测试"),
    "688368.SH": ("核心", "电源管理/控制驱动芯片"),
    "688372.SH": ("核心", "集成电路测试"),
    "688375.SH": ("核心", "射频集成电路"),
    "688380.SH": ("核心", "MCU/控制芯片"),
    "688385.SH": ("核心", "集成电路设计"),
    "688401.SH": ("上游", "掩膜版"),
    "688409.SH": ("上游", "半导体设备精密零部件"),
    "688416.SH": ("核心", "存储芯片/MCU"),
    "688419.SH": ("上游", "半导体封装设备"),
    "688439.SH": ("核心", "高可靠集成电路"),
    "688458.SH": ("核心", "模拟及数模混合芯片"),
    "688486.SH": ("核心", "高速混合信号芯片"),
    "688498.SH": ("核心", "光通信激光器芯片"),
    "688515.SH": ("核心", "高速有线通信芯片"),
    "688521.SH": ("核心", "芯片IP/定制"),
    "688525.SH": ("核心", "存储芯片/先进封测"),
    "688535.SH": ("上游", "电子封装材料"),
    "688536.SH": ("核心", "模拟及数模混合芯片"),
    "688591.SH": ("核心", "低功耗无线物联网芯片"),
    "688595.SH": ("核心", "ADC/MCU"),
    "688596.SH": ("上游", "高纯工艺系统/电子气体"),
    "688627.SH": ("上游", "半导体检测设备"),
    "688630.SH": ("上游", "直写光刻设备"),
    "688652.SH": ("上游", "工艺温控/尾气处理设备"),
    "688702.SH": ("核心", "以太网交换芯片"),
    "688709.SH": ("核心", "特种集成电路"),
    "688711.SH": ("核心", "功率半导体"),
    "688825.SH": ("核心", "DRAM存储芯片制造"),
    "688981.SH": ("核心", "晶圆代工"),
}
 
ADJACENT = {
    "002008.SZ": ("邻接", "通用激光/部分半导体设备应用"),
    "002129.SZ": ("邻接", "光伏硅片,非集成电路硅片"),
    "002222.SZ": ("邻接", "光学晶体/激光器件"),
    "002338.SZ": ("邻接", "精密光电仪器"),
    "301021.SZ": ("邻接", "精密激光加工设备"),
    "301307.SZ": ("邻接", "精密压铸件"),
    "301392.SZ": ("邻接", "真空镀膜设备"),
    "301421.SZ": ("邻接", "精密光学"),
    "688371.SH": ("邻接", "纳米薄膜防护涂层"),
    "688392.SH": ("邻接", "超声设备/部分半导体应用"),
    "688502.SH": ("邻接", "高端精密光学"),
    "688610.SH": ("邻接", "机器视觉核心部件"),
}
 
 
def connect(database: str):
    required = ["STOCK_MA60_DB_HOST", "STOCK_MA60_DB_PORT", "STOCK_MA60_DB_USER", "STOCK_MA60_DB_PASS"]
    missing = [name for name in required if not os.getenv(name)]
    if missing:
        raise RuntimeError(f"missing database environment variables: {', '.join(missing)}")
    return pymysql.connect(
        host=os.environ["STOCK_MA60_DB_HOST"],
        port=int(os.environ["STOCK_MA60_DB_PORT"]),
        user=os.environ["STOCK_MA60_DB_USER"],
        password=os.environ["STOCK_MA60_DB_PASS"],
        database=database,
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
        autocommit=True,
    )
 
 
def decimal_float(value):
    if value is None:
        return None
    if isinstance(value, Decimal):
        return float(value)
    return float(value)
 
 
def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest().upper()
 
 
def fmt_num(value, digits=2):
    return "—" if value is None else f"{value:.{digits}f}"
 
 
def fmt_pct(value):
    return "—" if value is None else f"{value * 100:.2f}%"
 
 
def load_security_and_valuation(as_of: date):
    with connect("stock_valuation") as conn, conn.cursor() as cursor:
        cursor.execute(
            """
            SELECT s.ticker, s.company, v.valuation_id, v.base_low, v.base_high,
                   j.close AS ledger_close, j.label
              FROM security s
              JOIN valuation_version v
                ON v.ticker = s.ticker
               AND v.active_from <= %s
               AND (v.active_to IS NULL OR v.active_to >= %s)
              LEFT JOIN daily_judgement j
                ON j.ticker = s.ticker AND j.trade_date = %s
             WHERE s.active = 1 AND s.currency = 'CNY'
            """,
            (as_of, as_of, as_of),
        )
        rows = cursor.fetchall()
    return {row["ticker"]: row for row in rows}
 
 
def load_calendar_and_bars(as_of: date, tickers: list[str]):
    with connect("trading_xuntou") as conn, conn.cursor() as cursor:
        cursor.execute(
            """
            SELECT asset_version, market, calendar_source_kind, calendar_source_version,
                   window_start, window_end, generated_at, status
              FROM formal_trading_calendar_assets
             WHERE is_current = 1 AND market = 'SH'
             ORDER BY generated_at DESC LIMIT 1
            """
        )
        calendar = cursor.fetchone()
        if not calendar or calendar["status"] != "current" or calendar["window_end"] < as_of:
            raise RuntimeError("authoritative trading calendar does not cover as-of date")
        cursor.execute(
            "SELECT trade_date, is_open FROM formal_trading_calendar_days WHERE market='SH' AND trade_date=%s AND asset_version=%s",
            (as_of, calendar["asset_version"]),
        )
        day = cursor.fetchone()
        if not day or int(day["is_open"]) != 1:
            raise RuntimeError("as-of date is not a proven open trading day")
 
        placeholders = ",".join(["%s"] * len(tickers))
        query = f"""
            WITH dedup AS (
                SELECT symbol, trade_date, close, amount, source, source_batch_id, updated_at,
                       ROW_NUMBER() OVER (PARTITION BY symbol, trade_date ORDER BY id DESC) AS duplicate_rank
                  FROM cn_stock_kline_1d_front
                 WHERE symbol IN ({placeholders}) AND trade_date <= %s
                   AND close IS NOT NULL AND close > 0
            ), ranked AS (
                SELECT symbol, trade_date, close, amount, source, source_batch_id, updated_at,
                       ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY trade_date DESC) AS recent_rank
                  FROM dedup WHERE duplicate_rank = 1
            )
            SELECT symbol, trade_date, close, amount, source, source_batch_id, updated_at
              FROM ranked WHERE recent_rank <= 60
             ORDER BY symbol, trade_date
        """
        cursor.execute(query, (*tickers, as_of))
        rows = cursor.fetchall()
    grouped = defaultdict(list)
    for row in rows:
        grouped[row["symbol"]].append(row)
    return calendar, grouped
 
 
def calculate(ticker, relation, segment, security, bars, as_of):
    if not security:
        return None, {"ticker": ticker, "reason": "不在当前估值台账有效A股范围"}
    if len(bars) != 60:
        return None, {"ticker": ticker, "company": security["company"], "reason": f"有效日K不足60条({len(bars)}条)"}
    if bars[-1]["trade_date"] != as_of:
        return None, {"ticker": ticker, "company": security["company"], "reason": f"最新有效日K为{bars[-1]['trade_date']},不是{as_of}"}
    closes = [decimal_float(row["close"]) for row in bars]
    amounts = [decimal_float(row["amount"]) for row in bars]
    close = closes[-1]
    ma60 = sum(closes) / 60
    below = (ma60 - close) / ma60
    rebound = ma60 / close - 1
    amount5 = None if any(v is None for v in amounts[-5:]) else sum(amounts[-5:]) / 5
    amount20 = None if any(v is None for v in amounts[-20:]) else sum(amounts[-20:]) / 20
    amount_ratio = amount5 / amount20 if amount5 is not None and amount20 not in (None, 0) else None
    if amount_ratio is None:
        amount_state = "未知"
    elif amount_ratio >= 1.2:
        amount_state = "放量"
    elif amount_ratio <= 0.8:
        amount_state = "缩量"
    else:
        amount_state = "平量"
    ledger_close = decimal_float(security["ledger_close"])
    price_check = ledger_close is None or abs(ledger_close - close) < 0.005
    return {
        "ticker": ticker,
        "company": security["company"],
        "relation": relation,
        "segment": segment,
        "trade_date": str(as_of),
        "close": close,
        "ma60": ma60,
        "below_ma60_pct": below,
        "rebound_to_ma60_pct": rebound,
        "position": "低于MA60" if close < ma60 else "不低于MA60",
        "amount_5d_20d_ratio": amount_ratio,
        "amount_state": amount_state,
        "valuation_label": security["label"] or "未形成当日台账判断",
        "base_low": decimal_float(security["base_low"]),
        "base_high": decimal_float(security["base_high"]),
        "valuation_id": security["valuation_id"],
        "ledger_price_match": price_check,
        "kline_source": bars[-1]["source"],
        "kline_batch": bars[-1]["source_batch_id"],
        "kline_updated_at": str(bars[-1]["updated_at"]),
    }, None
 
 
def write_csv(path: Path, rows: list[dict]):
    fields = [
        "rank", "ticker", "company", "relation", "segment", "trade_date", "close", "ma60",
        "below_ma60_pct", "rebound_to_ma60_pct", "position", "amount_5d_20d_ratio", "amount_state",
        "valuation_label", "base_low", "base_high", "valuation_id", "ledger_price_match",
        "kline_source", "kline_batch", "kline_updated_at",
    ]
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for index, row in enumerate(rows, 1):
            item = dict(row)
            item["rank"] = index if row["position"] == "低于MA60" else ""
            writer.writerow(item)
 
 
def table_lines(rows: list[dict], ranked=True):
    if not rows:
        return ["无。"]
    lines = [
        "| 排名 | 公司 | 代码 | 产业链位置 | 收盘价 | MA60 | 低于MA60 | 回到MA60空间 | 量能 | 估值判断 | 基准合理区间 |",
        "|---:|---|---|---|---:|---:|---:|---:|---|---|---:|",
    ]
    for index, row in enumerate(rows, 1):
        rank = str(index) if ranked else "—"
        lines.append(
            f"| {rank} | {row['company']} | {row['ticker']} | {row['relation']}·{row['segment']} | "
            f"{row['close']:.2f} | {row['ma60']:.2f} | {fmt_pct(row['below_ma60_pct'])} | "
            f"{fmt_pct(row['rebound_to_ma60_pct'])} | {row['amount_state']}({fmt_num(row['amount_5d_20d_ratio'])}×) | "
            f"{row['valuation_label']} | {row['base_low']:.2f}—{row['base_high']:.2f} |"
        )
    return lines
 
 
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--as-of", default="2026-08-06")
    parser.add_argument("--output-dir", required=True)
    args = parser.parse_args()
    as_of = date.fromisoformat(args.as_of)
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
 
    securities = load_security_and_valuation(as_of)
    requested = sorted(set(SCOPE) | set(ADJACENT))
    calendar, grouped = load_calendar_and_bars(as_of, requested)
 
    main_rows, adjacent_rows, gaps = [], [], []
    for ticker, (relation, segment) in SCOPE.items():
        row, gap = calculate(ticker, relation, segment, securities.get(ticker), grouped.get(ticker, []), as_of)
        if row:
            main_rows.append(row)
        else:
            gaps.append(gap)
    for ticker, (relation, segment) in ADJACENT.items():
        row, gap = calculate(ticker, relation, segment, securities.get(ticker), grouped.get(ticker, []), as_of)
        if row:
            adjacent_rows.append(row)
        else:
            gaps.append(gap)
 
    below = sorted([r for r in main_rows if r["position"] == "低于MA60"], key=lambda r: r["rebound_to_ma60_pct"], reverse=True)
    above = sorted([r for r in main_rows if r["position"] != "低于MA60"], key=lambda r: r["rebound_to_ma60_pct"], reverse=True)
    adjacent_below = sorted([r for r in adjacent_rows if r["position"] == "低于MA60"], key=lambda r: r["rebound_to_ma60_pct"], reverse=True)
    adjacent_above = sorted([r for r in adjacent_rows if r["position"] != "低于MA60"], key=lambda r: r["rebound_to_ma60_pct"], reverse=True)
    value_intersection = [r for r in below if r["valuation_label"] in {"偏低", "基本合理"}]
 
    csv_path = output_dir / f"半导体相关股票低于60日均线排序_{as_of.strftime('%Y%m%d')}.csv"
    md_path = output_dir / f"半导体相关股票低于60日均线排序_{as_of.strftime('%Y%m%d')}.md"
    gaps_path = output_dir / f"半导体相关股票MA60计算缺口_{as_of.strftime('%Y%m%d')}.csv"
    write_csv(csv_path, below + above)
    with gaps_path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=["ticker", "company", "reason"])
        writer.writeheader()
        writer.writerows(gaps)
 
    high_space_expensive = [r for r in below if r["valuation_label"] in {"偏贵", "明显偏贵"}]
    lines = [
        f"# 半导体相关股票低于60日均线排序({as_of})",
        "",
        "> 本表是技术位置筛选,不是目标价或交易指令。MA60只描述过去60个有效交易日的平均成本;价格可能继续下跌,也可能长期不回到MA60。",
        "",
        "## 1. 结论摘要",
        "",
        f"- 当前估值台账有效A股范围:{len(securities)}只。",
        f"- 主口径识别半导体核心及直接上游:{len(main_rows)}只;其中低于MA60:{len(below)}只,不低于MA60:{len(above)}只。",
        f"- 弱相关邻接观察:{len(adjacent_rows)}只;其中低于MA60:{len(adjacent_below)}只。邻接标的不参与主排名。",
        f"- 计算缺口:{len(gaps)}只。",
        f"- 主排名中虽然低于MA60、但基本面估值仍为偏贵或明显偏贵:{len(high_space_expensive)}只。",
        f"- 同时满足低于MA60且估值为偏低/基本合理:{len(value_intersection)}只。",
        "",
        "排序按“回到MA60的理论涨幅”从大到小。该指标比“低于MA60的比例”略大,公式分别为:",
        "",
        "```text",
        "低于MA60幅度 = (MA60 - 当前收盘价) / MA60",
        "回到MA60理论空间 = MA60 / 当前收盘价 - 1",
        "MA60 = 截至基准日最近60个有效前复权收盘价的算术平均",
        "```",
        "",
        "## 2. 双重筛选:低于MA60且估值不贵",
        "",
        "该组同时满足技术位置低于MA60、估值台账判断为偏低或基本合理,仍按回到MA60理论空间排序;它比单看均线距离更有参考价值,但也不代表反弹必然发生。",
        "",
        *table_lines(value_intersection),
        "",
        "## 3. 主排序:低于MA60的半导体核心及直接上游",
        "",
        *table_lines(below),
        "",
        "## 4. 不低于MA60的半导体核心及直接上游",
        "",
        "这些股票在“反弹到MA60”假设下没有正空间,因此不进入排名。",
        "",
        *table_lines(above, ranked=False),
        "",
        "## 5. 弱相关邻接观察",
        "",
        "该组包括光伏硅片、通用光学、通用激光和真空设备等。它们可能跟随半导体主题波动,但半导体不是主要收入来源或产业归属较弱,故不混入主排名。",
        "",
        "### 5.1 低于MA60",
        "",
        *table_lines(adjacent_below),
        "",
        "### 5.2 不低于MA60",
        "",
        *table_lines(adjacent_above, ranked=False),
        "",
        "## 6. 使用方法与风险",
        "",
        "- “空间最大”只代表距离60日均线最远,不代表反弹概率最高。大幅低于MA60有时恰恰反映盈利下修、行业景气下降、解禁减持、监管或流动性风险。",
        "- 量能列为最近5日平均成交额/最近20日平均成交额:≥1.20为放量,≤0.80为缩量,其余为平量。缩量下跌后的反弹确认通常弱于放量企稳,但这一指标仍不能单独决策。",
        "- 估值判断与MA60是两套口径。优先观察“低于MA60且估值为偏低/基本合理”的交集;若估值仍明显偏贵,回到均线只是交易层假设,不代表价值修复。",
        "- 当前收盘和MA60均来自`trading_xuntou.cn_stock_kline_1d_front`,只读、前复权;基准日由权威交易日历证明为开市日。",
        "- 行业范围采用固定清单版本,避免每日按关键词漂移。主营变化或新增估值标的时才更新范围。",
        "",
        "## 7. 数据与质量信息",
        "",
        f"- 行情基准日:{as_of}",
        f"- 交易日历资产:`{calendar['asset_version']}`,覆盖至{calendar['window_end']},状态`{calendar['status']}`。",
        "- 行情表:`trading_xuntou.cn_stock_kline_1d_front`;价格口径:前复权日K收盘价。",
        f"- 行业范围版本:`{SCOPE_VERSION}`。",
        f"- 价格与当日估值台账不一致:{sum(not r['ledger_price_match'] for r in main_rows + adjacent_rows)}只。",
        f"- 缺口明细:`{gaps_path.name}`。",
    ]
    md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
 
    manifest = {
        "generated_at": datetime.now().astimezone().isoformat(),
        "as_of": str(as_of),
        "scope_version": SCOPE_VERSION,
        "universe_a_share_count": len(securities),
        "main_scope_requested": len(SCOPE),
        "main_scope_calculated": len(main_rows),
        "main_below_ma60": len(below),
        "main_at_or_above_ma60": len(above),
        "adjacent_scope_requested": len(ADJACENT),
        "adjacent_scope_calculated": len(adjacent_rows),
        "adjacent_below_ma60": len(adjacent_below),
        "gaps": gaps,
        "calendar": {key: str(value) for key, value in calendar.items()},
        "sources": {
            "market": "trading_xuntou.cn_stock_kline_1d_front",
            "valuation": "stock_valuation.valuation_version + daily_judgement",
        },
        "outputs": {
            md_path.name: {"bytes": md_path.stat().st_size, "sha256": sha256(md_path)},
            csv_path.name: {"bytes": csv_path.stat().st_size, "sha256": sha256(csv_path)},
            gaps_path.name: {"bytes": gaps_path.stat().st_size, "sha256": sha256(gaps_path)},
        },
    }
    manifest_path = output_dir / "manifest.json"
    manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps(manifest, ensure_ascii=False, indent=2))
 
 
if __name__ == "__main__":
    main()