#!/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()
|