Skill: bwgao/ashare-analyzer (version 1.0.1)

--- name: ashare-analyzer description: 生成A股综合分析报告（深交所/上交所/北交所），包含K线技术指标图表、同行业对比、基本面分析、主营业务构成、技术面评分、催化剂与风险、短线/中线买卖建议。当用户提供A股名称（如康盛股份）或代码（如002418、002418.SZ、sz002418）并要求分析、出报告、看技术面，或简单输入"002418"、"看看康盛股份"时触发。仅对A股触发，不对美股、港股、加密货币或无具体标的的大盘评论触发。 --- # A股综合分析器 根据A股名称或代码，生成包含两张图表的综合分析报告，严格遵循 `references/report_template.md` 中的锁定模板。 ## 触发条件 - 股票名称：`康盛股份`、`贵州茅台`、`宁德时代` - 任意格式代码：`002418`、`600519`、`002418.SZ`、`sz002418`、`SH600519` - 口语化表达：`分析一下 002418`、`看看康盛股份`、`002418 怎么样`、`贵州茅台技术面` 如果名称有歧义，先询问用户确认后再继续。 ## 数据架构 本 skill 使用多个专业数据源，每个领域选择最可靠的来源： | 领域 | 主要来源 | 说明 | |---|---|---| | 价量K线（股票 + 沪深300 + 对比股） | 腾讯财经 | `web.ifzq.gtimg.cn` + `qt.gtimg.cn` — 无需认证，实时 | | 同行业/同概念/题材 | 东方财富F10 `ssbk` + 板块成员 | 直连 `push2delay` API，规避 akshare 限流 | | 基本面 | Fallback链：tushare → akshare → baostock → eastmoney | 来源透明，永不做静默回退 | | 主营业务构成 | 东方财富 `RPT_F10_FN_MAINOP` | 按产品/行业/地区拆分 | ## 前置条件 - Python 依赖：`tushare`、`akshare`、`baostock`、`pandas`、`numpy`、`matplotlib`。安装： ```bash pip install tushare akshare baostock pandas numpy matplotlib -q ``` - **可选：** `TUSHARE_TOKEN` 环境变量 — 启用 tushare pro 作为基本面第一优先来源。未配置时自动降级到 akshare/baostock/eastmoney。 - 中文字体：Windows 自带 SimHei 和 Microsoft YaHei，图表脚本会自动注册可用的中文字体。 ## 工作流（单路径，无分支） ### 第1步 — 解析输入 ```bash python scripts/resolve_code.py "康盛股份" # 名称 python scripts/resolve_code.py 002418 # 代码 python scripts/resolve_code.py 002418.SZ # 带后缀代码 python scripts/resolve_code.py sz002418 # 带前缀代码 ``` 返回 JSON，包含 `code6`、`ts_code`、`tencent_code`、`name`、`industry`。如果匹配到多个结果：`{"matches": [...]}` — 停下来让用户选择。 ### 第2步 — 运行数据管线 ```bash python scripts/fetch_and_analyze.py "康盛股份" --outdir ./output # （接受名称或代码 — 与 resolve_code.py 参数相同） ``` 这一条命令完成全部数据采集： 1. 解析输入 2. 通过腾讯财经获取252天K线（股票 + 沪深300 + 4只对比股） 3. 获取实时行情（价格 / PE_TTM / PB / 总市值 / 换手率） 4. 从东方财富F10 `ssbk` 分类中选取3-4只对比股： - 1-2只来自行业板块（以Ⅰ/Ⅱ/Ⅲ结尾的） - 1-2只来自概念板块（液冷/算力/储能/新能源等或以"概念"结尾） - 补充自题材板块 5. 运行基本面 fallback 链（tushare → akshare → baostock → eastmoney） 6. 获取主营业务构成 7. 渲染K线图 → `kline_<code>.png` 8. 渲染对比图 → `compare_<code>.png` 9. 写入数据包 → `data_<code>.json` **如果 `fundamental_used` 为 null 且 `needs_websearch` 为 true**，说明4层 fallback 全部失败；Claude 必须 `web_search` 补充缺失的基本面数据（ROE、利润率、最新财报），再填写报告。 ### 第3步 — 生成叙述性分析 JSON **⚠️ 执行前必读规则（不可跳过）：** 1. **先读模板理解字段含义** — 在生成 narrative JSON 之前，先 `Read` `references/report_template.md`，确认每个字段的名称和含义。 2. **逐字段对照** — 下方 JSON structure 是严格契约，字段名不得自创、不得遗漏、不得改名。输出时逐字段核对，确保与 JSON schema 完全一致。 3. **不跳步** — 严格按 workflow 顺序执行，不得省略任何步骤（特别是下方的 `web_search` 要求）。 读取 `data_<code>.json`，基于 Claude 判断生成叙述性分析 JSON 文件（`narrative_<code>.json`）。**不得编造数字** — 所有数字来自数据包，Claude 只负责叙述和判断类字段。**不得编造催化剂/风险** — 催化剂和风险必须基于 `web_search` 结果，不得凭通用行业知识编造。 **写 narrative 前必须 `web_search`（不可省略）：** - 搜索 `<名称> 公告`、`<名称> 澄清`、`<名称> 减持`、`<名称> 利好`，覆盖最近30天 - 包含3-5条催化剂和3-5条风险，每条标注 高/中高/中/低 影响级别 - 技术面评分标准：参见 `references/technical_scoring.md` **narrative JSON 结构**（保存为 `narrative_<code>.json`，放在 output 目录）： ```json { "concepts": "3-5个最相关概念板块", "company_comment": "1-2句公司定性描述", "price_position": "当前价格位置描述", "business_comment": "业务结构评价", "region_summary": "按地区一句话概述", "tech_score": 3, "tech_verdict": "中性偏多", "tech_brief": "简评", "ma_verdict": "均线排列判断", "rsi_verdict": "RSI判断", "macd_verdict": "MACD判断", "bb_verdict": "布林带判断", "kdj_verdict": "KDJ判断", "vol_verdict": "成交量判断", "market_comment": "vs沪深300一句话点评", "peer_findings": ["发现1", "发现2", "发现3", "发现4"], "catalysts": [{"title":"标题", "impact":"高/中高/中/低", "desc":"描述"}], "risks": [{"title":"标题", "impact":"高/中高/中/低", "desc":"描述"}], "overall_judgment": "一句话整体判断", "short_term": {"tech":"...", "support":"支撑位", "resist":"阻力位", "advice":"操作建议", "stop_loss":"止损价"}, "mid_term": {"drivers":"驱动力", "valuation":"估值判断", "support":"中期支撑", "advice":"建议", "target":"目标价", "scenarios":"乐观X/中性X/悲观X"}, "stars": {"fundamental": 1-5, "concept": 1-5, "tech": 1-5, "timing": 1-5}, "stars_comment": {"fundamental":"评价", "concept":"评价", "tech":"评价", "timing":"评价"}, "one_liner": "一句话投资结论" } ``` ### 第4步 — 生成 HTML 报告 ```bash python scripts/generate_report.py --data ./output/data_<code>.json --narrative ./output/narrative_<code>.json --outdir ./output ``` 生成独立的 `report_<code>.html`，包含： - K线图和对比图以 Base64 内嵌 - 所有数据驱动的板块自动格式化（表格、KPI、数字） - 内联 CSS 暗色主题，与图表风格一致 - 无外部依赖 ### 第5步 — 呈现给用户 告知用户 HTML 报告已生成，路径为 `./output/report_<code>.html`，并询问是否在浏览器中打开。 ## 边界范围 - **仅支持A股。** 港股/美股代码 → 拒绝并说明。 - **如果基本面 fallback 链返回 `needs_websearch: true`**，必须先 `web_search` 获取该股票最近财报数据，再填写财务板块。不得留空或使用占位文字。 - **如果股票停牌**（实时行情无近期数据）：报告停牌状态，跳过技术面评分，仅输出基本面报告。 - **如果对比股不足3只**，使用已有数据继续，并在对比板块标注"同行业数据有限"。 ## 参考文件 - `references/report_template.md` — 报告模板（叙述内容的填写指南） - `references/technical_scoring.md` — 10分制技术面评分标准 - `references/peer_selection_rules.md` — 对比股板块分类规则 ## 脚本文件 ``` scripts/ ├── resolve_code.py — 名称/代码 → ts_code / tencent_code（东方财富搜索 + 快照） ├── fetch_and_analyze.py — 主管线（一条命令完成全部采集） ├── generate_report.py — 数据JSON + 叙述JSON → 独立HTML报告 ├── peer_selector.py — 行业/概念/题材分类 + 代表性选股 ├── fundamental_chain.py — tushare → akshare → baostock → eastmoney fallback ├── plot_kline.py — K线渲染（MA / BB / 成交量 / MACD / RSI） ├── plot_compare.py — 归一化1年对比图 └── sources/ ├── tencent.py — K线 + 实时行情（无需认证） ├── eastmoney.py — F10 ssbk、主营业务、板块成员、数据中心指标 ├── akshare_src.py — 个股信息、行业/概念查询、基本面 ├── tushare_src.py — tushare pro 基本面（优先级1） └── baostock_src.py — 免费季报基本面（优先级3） ```

path: scripts/fetch_and_analyze.py
content: #!/usr/bin/env python3
"""
End-to-end pipeline for A-share stock analysis (v2).

Data sources:
  - 价量 (OHLCV): Tencent Finance (股票 + 沪深300 + 同行业对比股)
  - 同行业/同概念/题材: Eastmoney F10 ssbk + board members
  - 基本面: tushare → akshare → baostock → eastmoney fallback chain
  - 主营业务: Eastmoney RPT_F10_FN_MAINOP

Input: stock code or name
Outputs (in --outdir): kline_<code>.png, compare_<code>.png, data_<code>.json
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime

import pandas as pd

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
sys.path.insert(0, os.path.join(SCRIPT_DIR, "..", "assets"))

from plot_kline import plot_kline, compute_indicator_values  # noqa: E402
from plot_compare import plot_comparison  # noqa: E402
from peer_selector import select_peers  # noqa: E402
from fundamental_chain import resolve_fundamentals  # noqa: E402
from resolve_code import resolve  # noqa: E402
from sources import tencent, eastmoney, tushare_src  # noqa: E402


def _retry(fn, *args, tries=3, delay=0.6, **kwargs):
    last = None
    for i in range(tries):
        try:
            return fn(*args, **kwargs)
        except Exception as e:
            last = e
            time.sleep(delay * (i + 1))
    raise last


CSI300_TENCENT_CODE = "sh000300"
DISPLAY_DAYS = 252          # days shown in charts / report
WARMUP_DAYS = 120           # extra days for indicator warmup (MA60, MACD etc.)
FETCH_DAYS = DISPLAY_DAYS + WARMUP_DAYS + 48  # 420, with buffer for holidays


def _get_daily_kline(code, days=FETCH_DAYS):
    """tushare pro 优先获取K线，失败后 fallback 到腾讯财经。"""
    try:
        result = tushare_src.get_daily_kline(code, days)
        if result is not None and len(result) > 0:
            return result, "tushare"
    except Exception:
        pass
    return tencent.get_daily_kline(code, days), "tencent"


def fetch_all(raw_input: str, outdir: str, verbose: bool = True) -> dict:
    os.makedirs(outdir, exist_ok=True)
    log = (lambda *a: print("[pipeline]", *a, file=sys.stderr)) if verbose else (lambda *a: None)

    # 1. Resolve
    log("Resolving:", raw_input)
    resolved = resolve(raw_input)
    if "error" in resolved:
        raise RuntimeError(resolved["error"])
    if "matches" in resolved:
        raise RuntimeError(
            f"Ambiguous input — {len(resolved['matches'])} matches: "
            + ", ".join(f"{m['name']}({m['code6']})" for m in resolved["matches"])
        )

    code6 = resolved["code6"]
    tencent_code = resolved["tencent_code"]
    name = resolved["name"]
    ts_code = resolved["ts_code"]
    industry = resolved.get("industry", "")
    log(f"Resolved: {name} ({code6}) industry={industry!r}")

    # 2. Target price (fetch extra for warmup, display only last DISPLAY_DAYS)
    log("Fetching K-line (tushare → tencent fallback)...")
    daily_full, price_source = _retry(_get_daily_kline, tencent_code, FETCH_DAYS)
    last_close = float(daily_full["close"].iloc[-1])
    log(f"K-line source: {price_source}, rows (full): {len(daily_full)}, last close: {last_close}")

    log("Fetching Tencent realtime quote...")
    try:
        rt = tencent.get_realtime_quote(tencent_code)
    except Exception as e:
        log(f"Realtime fetch failed: {e}")
        rt = {}

    # 3. CSI 300
    log("Fetching CSI 300...")
    try:
        csi_full = _retry(_get_daily_kline, CSI300_TENCENT_CODE, FETCH_DAYS)[0]
        csi_full = csi_full[csi_full["trade_date"] >= daily_full["trade_date"].iloc[0]].reset_index(drop=True)
    except Exception as e:
        log(f"CSI 300 fetch failed: {e}")
        csi_full = pd.DataFrame()

    # 4. Peers
    log("Selecting peers...")
    peers = select_peers(code6, max_peers=4, verbose=False)
    log(f"Peers: {[(p['name'], p['source']) for p in peers]}")

    peer_data = []
    for p in peers:
        try:
            pdf, peer_price_src = _retry(_get_daily_kline, p["code6"], FETCH_DAYS)
            pdf = pdf[pdf["trade_date"] >= daily_full["trade_date"].iloc[0]].reset_index(drop=True)
            if len(pdf) == 0:
                log(f"  [{p['name']}] empty kline, skipping")
                continue

            # --- 行情数据：腾讯实时 → 东方财富快照 → K线收盘 ---
            try:
                peer_rt = tencent.get_realtime_quote(p["code6"])
            except Exception:
                peer_rt = {}
            snap = eastmoney.get_stock_snapshot(p["code6"]) or {}

            peer_close = peer_rt.get("price") or snap.get("price") or float(pdf["close"].iloc[-1])
            peer_mv = peer_rt.get("total_mv_亿") or ((snap.get("total_mv") or 0) / 1e8)
            peer_pe = peer_rt.get("pe_ttm") or snap.get("pe_dynamic")

            rt_src = "腾讯财经（实时行情）" if peer_rt.get("price") else (
                "东方财富（push2delay快照）" if snap.get("name") else "K线收盘价")

            # --- 基本面数据：tushare pro → 东方财富数据中心 ---
            try:
                pf_ts = tushare_src.get_fundamental_indicators(p["code6"]) or {}
            except Exception:
                pf_ts = {}
            try:
                pf_em = eastmoney.get_fundamental_indicators(p["code6"]) or {}
            except Exception:
                pf_em = {}

            # 合并：tushare 优先，eastmoney 填充
            pf = {}
            for key in ("roe", "gross_margin", "net_margin", "revenue_yoy", "net_profit_yoy"):
                pf[key] = pf_ts.get(key) if pf_ts.get(key) is not None else pf_em.get(key)

            fund_src = "tushare" if any(pf_ts.get(k) is not None for k in ("roe",)) else "东方财富数据中心"

            if len(pdf) >= 2:
                first_close = float(pdf["close"].iloc[0])
                pct_1y = (float(pdf["close"].iloc[-1]) / first_close - 1) * 100
            else:
                pct_1y = None

            peer_data.append({
                "_source": f"行情:{rt_src}｜基本面:{fund_src}｜价量K线:{peer_price_src}",
                "code6": p["code6"],
                "name": p["name"],
                "source": p["source"],
                "df": pdf,
                "close": peer_close,
                "total_mv_yi": peer_mv,
                "pe_ttm": peer_pe,
                "roe": pf.get("roe"),
                "gross_margin": pf.get("gross_margin"),
                "net_margin": pf.get("net_margin"),
                "revenue_yoy": pf.get("revenue_yoy"),
                "net_profit_yoy": pf.get("net_profit_yoy"),
                "pct_1y": pct_1y,
            })
        except Exception as e:
            log(f"  [{p['name']}] skip: {e}")

    # 5. Fundamental chain
    log("Resolving fundamentals...")
    fundamentals = resolve_fundamentals(code6, verbose=verbose)
    log(f"Fundamentals source: {fundamentals.get('source_used')}")

    # 6. Main business
    log("Fetching main business...")
    try:
        main_biz = eastmoney.get_latest_main_business(code6)
    except Exception as e:
        log(f"Main business failed: {e}")
        main_biz = {}

    # 7. Indicators (computed on full data for warmup, values from tail)
    ind = compute_indicator_values(daily_full)

    # 8. Slice to display window for charts — but pass full data to plot_kline
    #    so that MA60/MACD/etc. have warmup and don't start empty
    daily = daily_full.tail(DISPLAY_DAYS).reset_index(drop=True)
    csi = csi_full.tail(DISPLAY_DAYS + 30).reset_index(drop=True) if len(csi_full) else pd.DataFrame()
    csi = csi[csi["trade_date"] >= daily["trade_date"].iloc[0]].reset_index(drop=True) if len(csi) else csi
    for p in peer_data:
        p["df"] = p["df"][p["df"]["trade_date"] >= daily["trade_date"].iloc[0]].reset_index(drop=True)

    log(f"Display window: {len(daily)} rows")

    # 9. Charts
    log("Rendering K-line chart...")
    kline_path = os.path.join(outdir, f"kline_{code6}.png")
    plot_kline(daily, ts_code, kline_path, last_close=last_close, warmup_df=daily_full)

    log("Rendering comparison chart...")
    compare_path = os.path.join(outdir, f"compare_{code6}.png")
    plot_comparison(
        target_df=daily[["trade_date", "close"]].copy(),
        target_name=name,
        target_code=ts_code,
        index_df=csi if len(csi) else pd.DataFrame(),
        peers=[{"code6": p["code6"], "name": p["name"], "df": p["df"]} for p in peer_data],
        out_path=compare_path,
    )

    # 10. Index movements
    csi_pct_1m = csi_pct_1y = None
    if len(csi) > 20:
        csi_pct_1m = (csi["close"].iloc[-1] / csi["close"].iloc[-21] - 1) * 100
    if len(csi) > 240:
        csi_pct_1y = (csi["close"].iloc[-1] / csi["close"].iloc[0] - 1) * 100

    # 11. Bundle
    fund_data = fundamentals.get("data") or {}

    bundle = {
        "meta": {
            "code6": code6,
            "ts_code": ts_code,
            "tencent_code": tencent_code,
            "name": name,
            "industry": industry,
            "analysis_date": datetime.now().strftime("%Y-%m-%d"),
            "data_sources": {
                "price": price_source,
                "peers": "eastmoney_f10",
                "fundamental_used": fundamentals.get("source_used"),
                "fundamental_attempts": fundamentals.get("attempts", []),
                "needs_websearch": fundamentals.get("needs_websearch"),
                "main_business": "eastmoney",
            },
        },
        "price": {
            "_source": f"价量K线：{price_source}（tushare优先→腾讯财经兜底）｜实时行情：腾讯财经（qt.gtimg.cn）",
            "close": rt.get("price") or last_close,
            "prev_close": rt.get("pre_close"),
            "pct_today": rt.get("change_pct"),
            "change_today": rt.get("change"),
            "today_high": rt.get("high"),
            "today_low": rt.get("low"),
            "today_open": rt.get("today_open"),
            "turnover_rate": rt.get("turnover_rate"),
            "total_mv_yi": rt.get("total_mv_亿"),
            "float_mv_yi": rt.get("float_mv_亿"),
            "pe_ttm": rt.get("pe_ttm"),
            "pb": rt.get("pb"),
            "amount_yi": (rt.get("amount_万元") or 0) / 1e4,
        },
        "financials": {
            "_source": f"基本面数据来源：{fund_data.get('source') or '未知'}（fallback chain: tushare → akshare → baostock → eastmoney）",
            "end_date": fund_data.get("end_date"),
            "report_name": fund_data.get("report_name"),
            "revenue_yi": (fund_data.get("revenue") or 0) / 1e8 if fund_data.get("revenue") else None,
            "net_profit_wan": (fund_data.get("net_profit") or 0) / 1e4 if fund_data.get("net_profit") else None,
            "revenue_yoy_pct": fund_data.get("revenue_yoy"),
            "net_profit_yoy_pct": fund_data.get("net_profit_yoy"),
            "roe": fund_data.get("roe"),
            "roa": fund_data.get("roa"),
            "gross_margin": fund_data.get("gross_margin"),
            "net_margin": fund_data.get("net_margin"),
            "debt_to_assets": fund_data.get("debt_to_assets"),
            "eps": fund_data.get("eps"),
            "bps": fund_data.get("bps"),
            "source": fund_data.get("source"),
        },
        "main_business": {**main_biz, "_source": "东方财富（RPT_F10_FN_MAINOP）"},
        "technicals": {**ind, "_source": f"由本地脚本基于{price_source}价量数据计算"},
        "market_compare": {
            "_source": f"{price_source}（沪深300指数）",
            "csi300_pct_1m": csi_pct_1m,
            "csi300_pct_1y": csi_pct_1y,
        },
        "peers": [
            {k: v for k, v in p.items() if k != "df"}
            for p in peer_data
        ],
        "outputs": {
            "kline_path": kline_path,
            "compare_path": compare_path,
        },
    }

    def _clean(obj):
        if isinstance(obj, dict):
            return {k: _clean(v) for k, v in obj.items()}
        if isinstance(obj, list):
            return [_clean(x) for x in obj]
        if isinstance(obj, pd.Timestamp):
            return str(obj)
        if hasattr(obj, "item"):
            try:
                return obj.item()
            except Exception:
                return str(obj)
        return obj

    bundle = _clean(bundle)

    data_path = os.path.join(outdir, f"data_{code6}.json")
    with open(data_path, "w", encoding="utf-8") as f:
        json.dump(bundle, f, ensure_ascii=False, indent=2, default=str)
    bundle["outputs"]["data_path"] = data_path

    log("Done.")
    return bundle


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("stock", help="code or name: 002418, 康盛股份, 002418.SZ, sz002418")
    ap.add_argument("--outdir", default="./output")
    ap.add_argument("--quiet", action="store_true")
    args = ap.parse_args()
    try:
        b = fetch_all(args.stock, args.outdir, verbose=not args.quiet)
        print(json.dumps({
            "ok": True,
            "code6": b["meta"]["code6"],
            "name": b["meta"]["name"],
            "outputs": b["outputs"],
            "fundamental_source": b["meta"]["data_sources"]["fundamental_used"],
        }, ensure_ascii=False, indent=2))
    except Exception as e:
        import traceback
        traceback.print_exc()
        print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
        sys.exit(1)


path: scripts/fundamental_chain.py
content: """
Fundamental data resolver with cascading fallback.

Priority order (per user spec):
  1. tushare pro
  2. akshare
  3. baostock
  4. eastmoney datacenter
  5. (web_search — handled by Claude layer, not this script)

Each layer must return a dict in the harmonized shape or None.
The chain records which source succeeded so the report can show provenance.
"""
from __future__ import annotations
import sys
import os
import traceback
from typing import Optional

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
    sys.path.insert(0, SCRIPT_DIR)

from sources import tushare_src, akshare_src, baostock_src, eastmoney  # noqa: E402


LAYERS = [
    ("tushare", tushare_src.get_fundamental_indicators),
    ("akshare", akshare_src.get_fundamental_indicators),
    ("baostock", baostock_src.get_fundamental_indicators),
    ("eastmoney", eastmoney.get_fundamental_indicators),
]


def resolve_fundamentals(code: str, verbose: bool = False) -> dict:
    """
    Try each data source in priority order. Return merged result:
    {
      "data": { ...harmonized dict... } or None,
      "source_used": "tushare"|"akshare"|"baostock"|"eastmoney"|None,
      "attempts": [ {name, ok, error?} ],
      "needs_websearch": bool,   # True if all 4 layers returned None
    }
    """
    attempts = []
    result = None
    source_used = None

    for name, fn in LAYERS:
        try:
            out = fn(code)
            if out:
                attempts.append({"name": name, "ok": True})
                result = out
                source_used = name
                if verbose:
                    print(f"[fundamentals] success via {name}", file=sys.stderr)
                break
            else:
                attempts.append({"name": name, "ok": False, "error": "no data returned"})
                if verbose:
                    print(f"[fundamentals] {name} returned None", file=sys.stderr)
        except Exception as e:
            attempts.append({
                "name": name,
                "ok": False,
                "error": f"{type(e).__name__}: {e}",
            })
            if verbose:
                traceback.print_exc(file=sys.stderr)

    # Merge with eastmoney data even when tushare wins, to fill any gaps
    # (eastmoney gives revenue/profit numbers that tushare pro basic plan might miss)
    if result and source_used != "eastmoney":
        try:
            em = eastmoney.get_fundamental_indicators(code)
            if em:
                for k in ("revenue", "net_profit", "revenue_yoy", "net_profit_yoy",
                          "gross_margin", "roe"):
                    if (result.get(k) is None) and (em.get(k) is not None):
                        result[k] = em[k]
                        result.setdefault("_merged_from", []).append(f"{k}:eastmoney")
        except Exception:
            pass

    return {
        "data": result,
        "source_used": source_used,
        "attempts": attempts,
        "needs_websearch": (result is None),
    }


if __name__ == "__main__":
    import json
    if len(sys.argv) < 2:
        print("usage: fundamental_chain.py <code>")
        sys.exit(1)
    out = resolve_fundamentals(sys.argv[1], verbose=True)
    print(json.dumps(out, ensure_ascii=False, indent=2, default=str))


path: scripts/generate_report.py
content: #!/usr/bin/env python3
"""
Generate a self-contained HTML analysis report from data bundle + narrative JSON.

Usage:
  python generate_report.py --data data_002418.json --narrative narrative_002418.json --outdir ./output
"""
import argparse
import base64
import json
import os
import sys
from datetime import datetime

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))


# ============================================================
# Helpers
# ============================================================

def _f(v, fmt=".2f", suffix="", prefix="", default="-"):
    """Format a number safely."""
    if v is None:
        return default
    try:
        return f"{prefix}{float(v):{fmt}}{suffix}"
    except (TypeError, ValueError):
        return default


def _pct(v, fmt="+.2f", default="-"):
    """Format a percentage with sign."""
    if v is None:
        return default
    try:
        v = float(v)
        return f"{v:+{fmt.strip('+')}}%"
    except (TypeError, ValueError):
        return default


def _img_to_base64(path):
    """Read image file and return base64 data URI."""
    if not path or not os.path.isfile(path):
        return ""
    with open(path, "rb") as f:
        data = base64.b64encode(f.read()).decode("ascii")
    ext = os.path.splitext(path)[1].lower()
    mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg"}.get(ext.lstrip("."), "image/png")
    return f"data:{mime};base64,{data}"


def pct_today_label(pct):
    if pct is None:
        return "暂缺"
    if pct > 9.8:
        return "涨停"
    if pct < -9.8:
        return "跌停"
    if pct > 0:
        return "上涨"
    if pct < 0:
        return "下跌"
    return "平盘"


def pe_label(pe):
    if pe is None:
        return "暂缺"
    if pe < 0:
        return f"{pe:.2f}x <span class='warn'>⚠️亏损股，PE失真</span>"
    if pe > 200:
        return f"{pe:.2f}x <span class='warn'>（估值极高）</span>"
    return f"{pe:.2f}x"


def roe_label(roe):
    if roe is None:
        return "暂缺"
    if roe > 15:
        return f"{roe:.2f}% <span class='good'>（优秀）</span>"
    if roe > 10:
        return f"{roe:.2f}% <span class='good'>（良好）</span>"
    if roe > 5:
        return f"{roe:.2f}% （一般）"
    if roe > 1:
        return f"{roe:.2f}% <span class='warn'>（较差）</span>"
    if roe > 0:
        return f"{roe:.2f}% <span class='warn'>（极差）</span>"
    return f"{roe:.2f}% <span class='warn'>⚠️亏损</span>"


def fundamental_source_label(src):
    mapping = {
        "tushare": "tushare pro（基本面）",
        "akshare": "akshare（基本面）",
        "baostock": "baostock（基本面）",
        "eastmoney": "东方财富数据中心（基本面）",
        "eastmoney_datacenter": "东方财富数据中心（基本面）",
    }
    return mapping.get(src, "网络公开资料（基本面）")


def price_source_label(src):
    mapping = {
        "tushare": "tushare pro（价量）",
        "tencent": "腾讯财经（价量）",
    }
    return mapping.get(src, src or "未知")


def star_html(n, max_n=5):
    """Generate star rating HTML."""
    return "<span class='stars'>" + "★" * n + "<span class='star-dim'>" + "★" * (max_n - n) + "</span></span>"


def impact_badge(level):
    colors = {"高": "#e74c3c", "中高": "#e67e22", "中": "#f39c12", "低": "#3498db"}
    c = colors.get(level, "#8892a0")
    return f"<span class='badge' style='background:{c}'>{level}</span>"


# ============================================================
# HTML Template
# ============================================================

def build_html(data, narrative, kline_b64, compare_b64):
    meta = data["meta"]
    price = data["price"]
    fin = data.get("financials") or {}
    mbiz = data.get("main_business") or {}
    tech = data.get("technicals") or {}
    mkt = data.get("market_compare") or {}
    peers = data.get("peers") or []
    sources = meta.get("data_sources") or {}

    # Extract actual peer data sources from _source fields
    peer_rt_srcs = set()
    peer_fund_srcs = set()
    for p in peers:
        src = p.get("_source", "")
        for part in src.split("｜"):
            if part.startswith("行情:"):
                peer_rt_srcs.add(part.split(":", 1)[1])
            elif part.startswith("基本面:"):
                peer_fund_srcs.add(part.split(":", 1)[1])
    peer_rt_label = "、".join(sorted(peer_rt_srcs)) if peer_rt_srcs else "未知"
    peer_fund_label = "、".join(sorted(peer_fund_srcs)) if peer_fund_srcs else "未知"

    n = narrative  # shorthand
    name = meta["name"]
    code6 = meta["code6"]
    date = meta["analysis_date"]

    # Build peer comparison table rows
    peer_headers = "".join(f"<th>{p['name']}<br><span class='peer-src'>{p.get('source','').split(':')[0]}</span></th>" for p in peers)
    peer_row = lambda fn: "".join(f"<td>{fn(p)}</td>" for p in peers)

    # Main business table
    biz_rows = ""
    products = mbiz.get("by_product") or mbiz.get("by_industry") or []
    for item in products[:5]:
        ratio = (item.get("revenue_ratio") or 0) * 100
        if ratio < 1 and "其他" in item.get("item", ""):
            continue
        rev_yi = (item.get("revenue") or 0) / 1e8
        gm = (item.get("gross_margin") or 0) * 100
        biz_rows += f"<tr><td>{item['item']}</td><td>{rev_yi:.2f}</td><td>{ratio:.1f}%</td><td>{gm:.2f}%</td></tr>"

    # Catalysts & risks
    cat_html = ""
    for c in n.get("catalysts", []):
        cat_html += f"<li><strong>{c['title']}</strong> {impact_badge(c.get('impact','中'))}：{c['desc']}</li>"
    risk_html = ""
    for r in n.get("risks", []):
        risk_html += f"<li><strong>{r['title']}</strong> {impact_badge(r.get('impact','中'))}：{r['desc']}</li>"

    # Stars
    stars = n.get("stars", {})
    stars_comment = n.get("stars_comment", {})

    # Technical score color
    ts = n.get("tech_score", 0)
    if ts >= 5:
        ts_color = "#2ecc71"
        ts_word = "偏多"
    elif ts >= 2:
        ts_color = "#f5c518"
        ts_word = "中性偏多"
    elif ts >= -1:
        ts_color = "#8892a0"
        ts_word = "中性"
    elif ts >= -4:
        ts_color = "#e67e22"
        ts_word = "中性偏空"
    else:
        ts_color = "#e74c3c"
        ts_word = "偏空"

    html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name} ({code6}) 综合分析报告</title>
<style>
:root {{
  --bg: #1a2332;
  --card: #1e2a3a;
  --border: #2a3544;
  --text: #d0d5dc;
  --muted: #8892a0;
  --accent: #f5c518;
  --up: #e74c3c;
  --down: #2ecc71;
  --warn: #e67e22;
  --good: #2ecc71;
}}
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{
  font-family: -apple-system, "Microsoft YaHei", "SimHei", "Noto Sans CJK SC", sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.7;
  padding: 20px;
  max-width: 1100px;
  margin: 0 auto;
}}
h1 {{ font-size: 1.8em; color: var(--accent); margin-bottom: 4px; }}
h2 {{ font-size: 1.25em; color: var(--text); border-bottom: 2px solid var(--border); padding-bottom: 6px; margin: 28px 0 14px; }}
.subtitle {{ color: var(--muted); margin-bottom: 20px; }}
.card {{
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 20px;
  margin-bottom: 16px;
}}
.card-grid {{
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 12px;
  margin-bottom: 16px;
}}
.kpi {{ text-align: center; padding: 14px 8px; }}
.kpi .label {{ font-size: 0.82em; color: var(--muted); }}
.kpi .value {{ font-size: 1.45em; font-weight: 700; margin-top: 2px; }}
.kpi .value.up {{ color: var(--up); }}
.kpi .value.down {{ color: var(--down); }}
.kpi .value.accent {{ color: var(--accent); }}
.chart-img {{ width: 100%; border-radius: 8px; margin: 8px 0; }}
table {{ width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 0.92em; }}
th, td {{ padding: 8px 10px; text-align: right; border-bottom: 1px solid var(--border); }}
th {{ color: var(--muted); font-weight: 600; text-align: right; }}
th:first-child, td:first-child {{ text-align: left; }}
tr.target {{ background: rgba(245,197,24,0.08); font-weight: 600; }}
.warn {{ color: var(--warn); }}
.good {{ color: var(--good); }}
.badge {{
  display: inline-block;
  padding: 1px 8px;
  border-radius: 10px;
  font-size: 0.78em;
  color: #fff;
  margin-left: 4px;
}}
.peer-src {{ font-size: 0.75em; color: var(--muted); font-weight: normal; }}
.stars {{ color: var(--accent); letter-spacing: 2px; }}
.star-dim {{ color: var(--border); }}
.section-tag {{
  display: inline-block;
  background: var(--accent);
  color: var(--bg);
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 0.82em;
  font-weight: 700;
  margin-bottom: 8px;
}}
ul {{ padding-left: 20px; }}
li {{ margin: 6px 0; }}
.disclaimer {{
  margin-top: 32px;
  padding: 16px;
  background: rgba(142,146,160,0.08);
  border-radius: 8px;
  font-size: 0.85em;
  color: var(--muted);
  text-align: center;
}}
.score-big {{
  font-size: 2.2em;
  font-weight: 800;
  margin-right: 8px;
}}
.score-label {{
  display: inline-block;
  padding: 3px 12px;
  border-radius: 6px;
  font-weight: 600;
  font-size: 0.9em;
}}
.star-row {{
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 0;
  border-bottom: 1px solid var(--border);
}}
.star-row:last-child {{ border-bottom: none; }}
.star-label {{ flex: 0 0 80px; font-weight: 600; }}
.star-value {{ flex: 0 0 80px; }}
.star-comment {{ flex: 1; color: var(--muted); font-size: 0.9em; }}
.src-tag {{
  display: block;
  margin-top: 6px;
  font-size: 0.75em;
  color: #5a6577;
  font-style: italic;
}}
</style>
</head>
<body>

<h1>{name} ({code6}) 综合分析</h1>
<p class="subtitle">数据截止：{date}收盘</p>

<!-- ==================== 一、公司速览 ==================== -->
<h2>一、公司速览</h2>
<div class="card">
  <p><strong>行业：</strong>{meta.get('industry','-')}
     <strong>概念板块：</strong>{n.get('concepts','-')}</p>
</div>

<div class="card-grid">
  <div class="kpi">
    <div class="label">股价</div>
    <div class="value {'up' if (price.get('pct_today') or 0) > 0 else 'down'}">{_f(price.get('close'),'.2f','元')}</div>
    <div class="label">{_pct(price.get('pct_today'))} {pct_today_label(price.get('pct_today'))}</div>
  </div>
  <div class="kpi">
    <div class="label">总市值</div>
    <div class="value accent">{_f(price.get('total_mv_yi'),'.1f','亿')}</div>
  </div>
  <div class="kpi">
    <div class="label">PE(TTM)</div>
    <div class="value">{pe_label(price.get('pe_ttm'))}</div>
  </div>
  <div class="kpi">
    <div class="label">PB</div>
    <div class="value">{_f(price.get('pb'),'.2f','x')}</div>
  </div>
  <div class="kpi">
    <div class="label">ROE</div>
    <div class="value">{roe_label(fin.get('roe'))}</div>
  </div>
  <div class="kpi">
    <div class="label">毛利率</div>
    <div class="value">{_f(fin.get('gross_margin'),'.2f','%')}</div>
  </div>
  <div class="kpi">
    <div class="label">换手率</div>
    <div class="value">{_f(price.get('turnover_rate'),'.2f','%')}</div>
  </div>
  <div class="kpi">
    <div class="label">成交额</div>
    <div class="value">{_f(price.get('amount_yi'),'.2f','亿')}</div>
  </div>
</div>

<div class="card">
  <p><strong>最新财报（{fin.get('report_name','-')}）：</strong>
     营收 {_f(fin.get('revenue_yi'),'.2f','亿')}（{_pct(fin.get('revenue_yoy_pct'))}%），
     归母净利 {_f(fin.get('net_profit_wan'),'.0f','万')}（{_pct(fin.get('net_profit_yoy_pct'))}%）</p>
  <p style="margin-top:8px">
    <strong>近期涨跌：</strong>
    1周 {_pct(tech.get('pct_1w'))}% ｜
    1月 <span style="color:{'var(--up)' if (tech.get('pct_1m') or 0) > 0 else 'var(--down)'}">{_pct(tech.get('pct_1m'))}%</span> ｜
    1年 <span style="color:{'var(--up)' if (tech.get('pct_1y') or 0) > 0 else 'var(--down)'}">{_pct(tech.get('pct_1y'))}%</span>
  </p>
  <span class="src-tag">数据来源：实时行情 → 腾讯财经（qt.gtimg.cn）｜财报数据 → {fundamental_source_label(fin.get('source', sources.get('fundamental_used')))}</span>
</div>

<div class="card" style="border-left:3px solid var(--accent); background:rgba(245,197,24,0.05);">
  <p>{n.get('company_comment','')}</p>
</div>

<!-- ==================== 二、K线图 ==================== -->
<h2>二、K线图</h2>
<div class="card">
  {"<img class='chart-img' src='" + kline_b64 + "' alt='K线图'>" if kline_b64 else "<p>K线图暂缺</p>"}
  <p style="margin-top:10px">
    年内最高：<strong style="color:var(--up)">{_f(tech.get('year_high'),'.2f','元')}</strong>（{tech.get('year_high_date','-')}）
    年内最低：<strong style="color:var(--down)">{_f(tech.get('year_low'),'.2f','元')}</strong>（{tech.get('year_low_date','-')}）
  </p>
  <p style="margin-top:4px">{n.get('price_position','')}</p>
  <span class="src-tag">数据来源：K线价量 → {price_source_label(sources.get('price'))}</span>
</div>
<h2>三、主营业务构成</h2>
<div class="card">
  <p style="margin-bottom:8px"><span class="section-tag">{mbiz.get('report_name','-')}</span></p>
  <table>
    <tr><th>业务</th><th>收入（亿元）</th><th>占比</th><th>毛利率</th></tr>
    {biz_rows}
  </table>
  <p style="margin-top:8px;color:var(--muted)">按地区：{n.get('region_summary','-')}</p>
  <p style="margin-top:4px">{n.get('business_comment','')}</p>
  <span class="src-tag">数据来源：东方财富（RPT_F10_FN_MAINOP）</span>
</div>
<h2>四、技术面评分</h2>
<div class="card">
  <p style="margin-bottom:12px">
    <span class="score-big" style="color:{ts_color}">{ts:+d}/10</span>
    <span class="score-label" style="background:{ts_color}20; color:{ts_color}">{ts_word}</span>
    <span style="color:var(--muted); margin-left:8px">{n.get('tech_brief','')}</span>
  </p>
  <table>
    <tr><th>指标</th><th>数值</th><th>判断</th></tr>
    <tr><td>均线排列</td><td>MA5={_f(tech.get('ma5'))} &gt; MA10={_f(tech.get('ma10'))} &gt; MA20={_f(tech.get('ma20'))} &gt; MA60={_f(tech.get('ma60'))}</td><td>{n.get('ma_verdict','-')}</td></tr>
    <tr><td>RSI(14)</td><td>{_f(tech.get('rsi14'))}</td><td>{n.get('rsi_verdict','-')}</td></tr>
    <tr><td>MACD</td><td>DIF={_f(tech.get('macd_dif'))}, DEA={_f(tech.get('macd_dea'))}, 柱={_f(tech.get('macd_hist'))}</td><td>{n.get('macd_verdict','-')}</td></tr>
    <tr><td>布林带</td><td>上轨={_f(tech.get('bb_upper'))}, 下轨={_f(tech.get('bb_lower'))}, 偏离={_f(tech.get('bb_deviation_pct'),'.1f','%')}</td><td>{n.get('bb_verdict','-')}</td></tr>
    <tr><td>KDJ</td><td>K={_f(tech.get('kdj_k'),'.0f')}, D={_f(tech.get('kdj_d'),'.0f')}, J={_f(tech.get('kdj_j'),'.0f')}</td><td>{n.get('kdj_verdict','-')}</td></tr>
    <tr><td>成交量</td><td>换手率 {_f(price.get('turnover_rate'),'.2f','%')}</td><td>{n.get('vol_verdict','-')}</td></tr>
  </table>
  <p style="margin-top:10px">
    关键支撑：<strong>{_f(tech.get('ma5'))}（MA5）</strong> / {_f(tech.get('ma10'))}（MA10） / {_f(tech.get('ma20'))}（MA20）<br>
    关键阻力：<strong>{_f(tech.get('year_high'))}（52周新高）</strong>
  </p>
  <span class="src-tag">数据来源：基于{price_source_label(sources.get('price'))}OHLCV数据本地计算（含120日预热）</span>
</div>
<h2>五、大盘 &amp; 同行业对比</h2>
<div class="card">
  {"<img class='chart-img' src='" + compare_b64 + "' alt='对比图'>" if compare_b64 else ""}
</div>
<div class="card">
  <p><strong>vs 沪深300</strong></p>
  <table>
    <tr><th>周期</th><th>{name}</th><th>沪深300</th></tr>
    <tr><td>近1月</td><td style="color:{'var(--up)' if (tech.get('pct_1m') or 0) > 0 else 'var(--down)'}">{_pct(tech.get('pct_1m'))}%</td><td>{_pct(mkt.get('csi300_pct_1m'))}%</td></tr>
    <tr><td>近1年</td><td style="color:{'var(--up)' if (tech.get('pct_1y') or 0) > 0 else 'var(--down)'}">{_pct(tech.get('pct_1y'))}%</td><td>{_pct(mkt.get('csi300_pct_1y'))}%</td></tr>
  </table>
  <p style="margin-top:6px">{n.get('market_comment','')}</p>
  <span class="src-tag">数据来源：沪深300指数 → {price_source_label(sources.get('price'))}</span>
</div>
  <div style="overflow-x:auto">
  <table>
    <tr><th>指标</th><th><strong>{name}</strong></th>{peer_headers}</tr>
    <tr><td>来源</td><td>(目标)</td>{peer_row(lambda p: f"<span class='peer-src'>{p.get('source','').split(':')[0]}</span>")}</tr>
    <tr class="target"><td>股价</td><td>{_f(price.get('close'),'.2f')}</td>{peer_row(lambda p: _f(p.get('close'),'.2f'))}</tr>
    <tr><td>市值(亿)</td><td>{_f(price.get('total_mv_yi'),'.1f')}</td>{peer_row(lambda p: _f(p.get('total_mv_yi'),'.1f'))}</tr>
    <tr><td>PE(TTM)</td><td>{_f(price.get('pe_ttm'),'.2f')}</td>{peer_row(lambda p: _f(p.get('pe_ttm'),'.2f'))}</tr>
    <tr><td>ROE</td><td>{_f(fin.get('roe'),'.2f','%')}</td>{peer_row(lambda p: _f(p.get('roe'),'.2f','%'))}</tr>
    <tr><td>毛利率</td><td>{_f(fin.get('gross_margin'),'.2f','%')}</td>{peer_row(lambda p: _f(p.get('gross_margin'),'.2f','%'))}</tr>
    <tr><td>营收增速</td><td>{_pct(fin.get('revenue_yoy_pct'))}%</td>{peer_row(lambda p: _pct(p.get('revenue_yoy'))+"%")}</tr>
    <tr><td>利润增速</td><td>{_pct(fin.get('net_profit_yoy_pct'))}%</td>{peer_row(lambda p: _pct(p.get('net_profit_yoy'))+"%")}</tr>
    <tr><td>1年涨幅</td><td style="color:{'var(--up)' if (tech.get('pct_1y') or 0) > 0 else 'var(--down)'}">{_pct(tech.get('pct_1y'))}%</td>{peer_row(lambda p: f"<span style='color:var(--up)'>{_pct(p.get('pct_1y'))}%</span>")}</tr>
  </table>
  </div>
  <div style="margin-top:10px">
    <p><strong>📌 关键发现：</strong></p>
    <ul>
      {"".join(f"<li>{f}</li>" for f in n.get('peer_findings', []))}
    </ul>
  </div>
  <span class="src-tag">数据来源：同行业选股 → 东方财富F10板块（ssbk）｜实时行情 → {peer_rt_label}｜基本面 → {peer_fund_label}</span>
</div>
<h2>六、核心催化剂 &amp; 风险</h2>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
  <div class="card" style="border-left:3px solid var(--good)">
    <p style="font-weight:700; margin-bottom:8px; color:var(--good)">催化剂 🚀</p>
    <ul>{cat_html}</ul>
  </div>
  <div class="card" style="border-left:3px solid var(--warn)">
    <p style="font-weight:700; margin-bottom:8px; color:var(--warn)">风险 ⚠️</p>
    <ul>{risk_html}</ul>
  </div>
</div>

<!-- ==================== 七、买卖建议 ==================== -->
<h2>七、买卖建议</h2>
<div class="card" style="border-left:3px solid var(--accent)">
  <p style="font-size:1.1em; font-weight:700; margin-bottom:12px">{n.get('overall_judgment','')}</p>
</div>

<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
  <div class="card">
    <p style="font-weight:700; color:var(--accent); margin-bottom:8px">📌 短线（几天～2周）</p>
    <p>技术面：{n.get('short_term',{}).get('tech','-')}</p>
    <p>支撑位 <strong>{n.get('short_term',{}).get('support','-')}</strong> / 阻力位 <strong>{n.get('short_term',{}).get('resist','-')}</strong></p>
    <p>操作建议：<strong>{n.get('short_term',{}).get('advice','-')}</strong></p>
    <p>止损参考：<strong style="color:var(--down)">{n.get('short_term',{}).get('stop_loss','-')}</strong></p>
  </div>
  <div class="card">
    <p style="font-weight:700; color:var(--accent); margin-bottom:8px">📌 中线（1～3个月）</p>
    <p>核心驱动力：{n.get('mid_term',{}).get('drivers','-')}</p>
    <p>估值判断：{n.get('mid_term',{}).get('valuation','-')}</p>
    <p>中期支撑：<strong>{n.get('mid_term',{}).get('support','-')}</strong></p>
    <p>建议：<strong>{n.get('mid_term',{}).get('advice','-')}</strong>，目标 <strong>{n.get('mid_term',{}).get('target','-')}</strong></p>
    <p>情景分析：{n.get('mid_term',{}).get('scenarios','-')}</p>
  </div>
</div>

<div class="card">
  <div class="star-row"><span class="star-label">基本面</span><span class="star-value">{star_html(stars.get('fundamental',0))}</span><span class="star-comment">{stars_comment.get('fundamental','')}</span></div>
  <div class="star-row"><span class="star-label">题材概念</span><span class="star-value">{star_html(stars.get('concept',0))}</span><span class="star-comment">{stars_comment.get('concept','')}</span></div>
  <div class="star-row"><span class="star-label">技术面</span><span class="star-value">{star_html(stars.get('tech',0))}</span><span class="star-comment">{stars_comment.get('tech','')}</span></div>
  <div class="star-row"><span class="star-label">时机</span><span class="star-value">{star_html(stars.get('timing',0))}</span><span class="star-comment">{stars_comment.get('timing','')}</span></div>
</div>

<div class="card" style="border-left:3px solid var(--accent); background:rgba(245,197,24,0.05);">
  <p style="font-size:1.1em; font-weight:700">{n.get('one_liner','')}</p>
</div>

<!-- ==================== 免责声明 ==================== -->
<div class="disclaimer">
  ⚠️ 以上分析仅供参考，不构成投资建议。<br>
  数据来源：{price_source_label(sources.get('price'))}、东方财富（板块+主营业务）、{fundamental_source_label(sources.get('fundamental_used'))}。<br>
  报告生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M')}
</div>

</body>
</html>"""
    return html


# ============================================================
# Main
# ============================================================

def main():
    ap = argparse.ArgumentParser(description="Generate HTML stock analysis report")
    ap.add_argument("--data", required=True, help="Path to data_<code>.json")
    ap.add_argument("--narrative", required=True, help="Path to narrative JSON (Claude-generated)")
    ap.add_argument("--outdir", default="./output", help="Output directory")
    args = ap.parse_args()

    # Load data
    with open(args.data, "r", encoding="utf-8") as f:
        data = json.load(f)

    # Load narrative
    with open(args.narrative, "r", encoding="utf-8") as f:
        narrative = json.load(f)

    code6 = data["meta"]["code6"]

    # Resolve chart paths
    data_dir = os.path.dirname(os.path.abspath(args.data))
    kline_path = os.path.join(data_dir, f"kline_{code6}.png")
    compare_path = os.path.join(data_dir, f"compare_{code6}.png")

    kline_b64 = _img_to_base64(kline_path)
    compare_b64 = _img_to_base64(compare_path)

    # Build HTML
    html = build_html(data, narrative, kline_b64, compare_b64)

    # Write output
    os.makedirs(args.outdir, exist_ok=True)
    out_path = os.path.join(args.outdir, f"report_{code6}.html")
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(html)

    print(json.dumps({
        "ok": True,
        "output": out_path,
        "code6": code6,
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/peer_selector.py
content: """
Peer selector (v3).

Strategy:
  1. Get target's board memberships via eastmoney F10 ssbk
  2. Classify boards into industry / concept / theme, filter out technical/region tags
  3. Pick 1-2 industry + 1-2 concept + fillers → 3-5 peers total
  4. For each board, fetch constituents DIRECTLY from eastmoney (bypassing akshare rate limits)
     and pick the liquid representative (highest trading amount excluding target/ST)
"""
from __future__ import annotations
import re
import sys
import os
from typing import Optional

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
    sys.path.insert(0, SCRIPT_DIR)

from sources import eastmoney  # noqa: E402


EXCLUDE_BOARD_PATTERNS = [
    r"昨日.*", r"最近.*", r"^\d{4}年.*", r".*板块$", r".*重仓$",
    r".*热股$", r".*龙头$", r".*一字$", r"融资融券", r"AH股",
    r"深股通", r"沪股通", r"MSCI.*", r"富时.*", r"证金持股",
    r"国企改革", r".*基金重仓$", r"^H股$", r"破净股",
    r"同花顺.*", r"次新股", r"小盘股", r"大盘股",
    r"预盈预增", r"预亏预减", r".*退市",
]

INDUSTRY_SUFFIX = re.compile(r"[Ⅰ-Ⅶ]$")

CONCEPT_KEYWORDS = [
    "新能源", "光伏", "锂电", "氢能", "储能", "5G", "半导体", "芯片",
    "机器人", "区块链", "元宇宙", "人工智能", "AI", "液冷", "算力",
    "数据中心", "服务器", "云计算", "军工", "航天", "航空",
    "特斯拉", "华为", "苹果", "宁德", "北斗", "光刻", "MCU", "GPU",
    "智能驾驶", "无人驾驶", "自动驾驶", "充电桩", "换电", "动力电池",
    "钠离子", "固态电池", "复合集流体", "一体化压铸", "CPO", "HBM",
    "电池", "汽车热管理", "石墨烯", "超级电容", "燃料电池",
]


def _should_exclude(name: str) -> bool:
    return any(re.match(pat, name) for pat in EXCLUDE_BOARD_PATTERNS)


def _is_industry_board(item: dict) -> bool:
    return bool(INDUSTRY_SUFFIX.search(item.get("board_name", "")))


def _is_concept_board(item: dict) -> bool:
    name = item.get("board_name", "")
    return any(k in name for k in CONCEPT_KEYWORDS) or name.endswith("概念")


def classify_boards(ssbk: list[dict]) -> dict:
    industry, concept, theme = [], [], []
    for item in ssbk:
        if _should_exclude(item.get("board_name", "")):
            continue
        if _is_industry_board(item):
            industry.append(item)
        elif _is_concept_board(item):
            concept.append(item)
        else:
            theme.append(item)
    for bucket in (industry, concept, theme):
        bucket.sort(key=lambda x: x.get("rank") or 999)
    return {"industry": industry, "concept": concept, "theme": theme}


def _pick_representative(board_code: str, target_code6: str,
                         exclude: set) -> Optional[dict]:
    df = eastmoney.get_board_members(board_code, limit=30)
    if df is None or len(df) == 0:
        return None

    def _ok(row):
        c = str(row["code"])
        n = str(row["name"])
        if c == target_code6 or c in exclude:
            return False
        if "ST" in n or "*" in n or "退" in n:
            return False
        if row.get("price") is None:
            return False
        return True

    filtered = df[df.apply(_ok, axis=1)]
    if len(filtered) == 0:
        return None

    # Rank by trading amount (元) — rewards liquidity + scale
    filtered = filtered.sort_values("amount", ascending=False, na_position="last")
    top = filtered.iloc[0]
    return {"code6": str(top["code"]), "name": str(top["name"])}


def select_peers(code6: str, max_peers: int = 4, verbose: bool = False) -> list[dict]:
    try:
        boards_info = eastmoney.get_boards_of(code6)
    except Exception as e:
        if verbose:
            print(f"[peer_selector] boards fetch failed: {e}", file=sys.stderr)
        return []

    classified = classify_boards(boards_info.get("ssbk", []))

    if verbose:
        print(f"[peer_selector] industry: {[b['board_name'] for b in classified['industry']]}", file=sys.stderr)
        print(f"[peer_selector] concept: {[b['board_name'] for b in classified['concept']]}", file=sys.stderr)
        print(f"[peer_selector] theme: {[b['board_name'] for b in classified['theme']]}", file=sys.stderr)

    picks, exclude = [], {code6}

    for board in classified["industry"][:3]:
        if sum(1 for p in picks if p["source"].startswith("industry")) >= 2:
            break
        rep = _pick_representative(board["board_code"], code6, exclude)
        if rep:
            picks.append({**rep, "source": f"industry:{board['board_name']}"})
            exclude.add(rep["code6"])

    for board in classified["concept"][:5]:
        if sum(1 for p in picks if p["source"].startswith("concept")) >= 2:
            break
        rep = _pick_representative(board["board_code"], code6, exclude)
        if rep:
            picks.append({**rep, "source": f"concept:{board['board_name']}"})
            exclude.add(rep["code6"])

    for board in classified["theme"][:6]:
        if len(picks) >= max_peers:
            break
        rep = _pick_representative(board["board_code"], code6, exclude)
        if rep:
            picks.append({**rep, "source": f"theme:{board['board_name']}"})
            exclude.add(rep["code6"])

    return picks[:max_peers]


if __name__ == "__main__":
    import json
    if len(sys.argv) < 2:
        print("usage: peer_selector.py <code6>")
        sys.exit(1)
    picks = select_peers(sys.argv[1], verbose=True)
    print(json.dumps(picks, ensure_ascii=False, indent=2))


path: scripts/plot_compare.py
content: """
Normalized price comparison chart.

Reference style:
 - Dark navy background
 - Target stock: thick yellow line
 - CSI 300: grey dashed
 - 3-5 peers: rotating teal/pink/purple/blue/orange palette
 - Right-edge labels with latest normalized value (starting value = 100)
 - Title: "<name> (<code>) vs 同行业 — 近1年归一化价格走势"
"""
import sys
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "assets"))
from chart_style import (  # noqa: E402
    BG_COLOR, TEXT_COLOR, FG_MUTED,
    TARGET_COLOR, INDEX_COLOR, PEER_PALETTE,
    setup_chinese_font, apply_dark_style, style_legend,
)


def plot_comparison(target_df: pd.DataFrame, target_name: str, target_code: str,
                    index_df: pd.DataFrame,
                    peers: list, out_path: str) -> None:
    """
    target_df / index_df: DataFrame with 'trade_date' and 'close'. Ascending.
    peers: list of dicts [{"ts_code", "name", "df": DataFrame}, ...]
    """
    setup_chinese_font()

    fig, ax = plt.subplots(figsize=(19, 10))
    apply_dark_style(fig, ax)

    # Common date range: use target_df's date range as master
    td = target_df.copy()
    td["trade_date"] = pd.to_datetime(td["trade_date"].astype(str), format="%Y%m%d",
                                      errors="coerce")
    td = td.dropna(subset=["trade_date"]).drop_duplicates(subset=["trade_date"])
    td = td.sort_values("trade_date").reset_index(drop=True)
    base_dates = pd.DatetimeIndex(td["trade_date"].values)

    def _normalize(df, base_idx):
        df = df.copy()
        # Handle both already-parsed datetimes and YYYYMMDD strings
        if pd.api.types.is_datetime64_any_dtype(df["trade_date"]):
            pass
        else:
            df["trade_date"] = pd.to_datetime(df["trade_date"].astype(str),
                                              format="%Y%m%d", errors="coerce")
        df = df.dropna(subset=["trade_date"]).drop_duplicates(subset=["trade_date"])
        df = df.sort_values("trade_date").set_index("trade_date")
        if len(df) == 0:
            return None
        df = df.reindex(base_idx, method="ffill").ffill().bfill()
        first_valid = df["close"].dropna()
        if len(first_valid) == 0:
            return None
        base = first_valid.iloc[0]
        return df["close"] / base * 100

    target_norm = _normalize(td, base_dates)
    if target_norm is None:
        raise RuntimeError("target has no valid close data")

    # Plot peers first (so target line sits on top)
    last_values = []  # [(value, label, color, linewidth)]

    # CSI 300
    if index_df is not None and len(index_df) > 0:
        idx_norm = _normalize(index_df, base_dates)
        if idx_norm is not None:
            ax.plot(base_dates, idx_norm, color=INDEX_COLOR, linewidth=1.3,
                    linestyle="--", label="沪深300", alpha=0.85)
            last_values.append((idx_norm.iloc[-1], "沪深300", INDEX_COLOR))

    # Peers
    for i, peer in enumerate(peers):
        color = PEER_PALETTE[i % len(PEER_PALETTE)]
        pnorm = _normalize(peer["df"], base_dates)
        if pnorm is None:
            continue
        ax.plot(base_dates, pnorm, color=color, linewidth=1.3,
                label=peer["name"], alpha=0.9)
        last_values.append((pnorm.iloc[-1], peer["name"], color))

    # Target (thick yellow on top)
    ax.plot(base_dates, target_norm, color=TARGET_COLOR, linewidth=2.8,
            label=f"{target_name}（目标）", alpha=1.0)
    last_values.append((target_norm.iloc[-1], target_name, TARGET_COLOR))

    # Right-side labels
    # Sort last_values so text doesn't overlap too badly (simple sort by value)
    last_values.sort(key=lambda t: t[0], reverse=True)
    xmax = base_dates[-1]
    x_offset = pd.Timedelta(days=3)
    for val, label, color in last_values:
        ax.text(xmax + x_offset, val, f"{val:.1f}",
                color=color, fontsize=11, fontweight="bold",
                va="center", ha="left")

    title = f"{target_name} ({target_code.split('.')[0]}) vs 同行业 — 近1年归一化价格走势"
    ax.set_title(title, color=TEXT_COLOR, fontsize=14, pad=12)
    ax.set_ylabel("归一化价格（起始=100）", color=TEXT_COLOR)

    leg = ax.legend(loc="upper left", frameon=True, fontsize=10)
    style_legend(leg)

    # X-axis ticks
    ax.set_xlim(base_dates[0], base_dates[-1] + pd.Timedelta(days=12))
    # Month ticks
    import matplotlib.dates as mdates
    ax.xaxis.set_major_locator(mdates.MonthLocator(interval=1))
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%m/%d"))
    plt.setp(ax.get_xticklabels(), rotation=0, color=TEXT_COLOR)

    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

    plt.tight_layout()
    fig.savefig(out_path, facecolor=BG_COLOR, dpi=120, bbox_inches="tight")
    plt.close(fig)


path: scripts/plot_kline.py
content: """
K-line chart renderer matching the reference style:
 - Dark navy background
 - Candles: red up, green down (A-share convention)
 - MA5 (yellow), MA10 (red), MA20 (teal), MA60 (purple)
 - Bollinger bands (dashed grey, shaded)
 - Volume bars colored by up/down day
 - MACD panel (DIF/DEA + histogram)
 - RSI panel with 70/30 reference lines
 - 252-day window, annotated year high/low
 - Title: "<close> (<code>) 日K线 — 近252个交易日"
"""
import sys
import os
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.patches import Rectangle
import numpy as np
import pandas as pd

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "assets"))
from chart_style import (  # noqa: E402
    BG_COLOR, GRID_COLOR, TEXT_COLOR, FG_MUTED,
    UP_COLOR, DOWN_COLOR,
    MA5_COLOR, MA10_COLOR, MA20_COLOR, MA60_COLOR,
    BB_COLOR, MACD_DIF_COLOR, MACD_DEA_COLOR,
    setup_chinese_font, apply_dark_style, style_legend,
)


def plot_kline(df: pd.DataFrame, ts_code: str, out_path: str,
               last_close: float, show_code_only: str = None,
               warmup_df: pd.DataFrame = None) -> None:
    """
    df: DataFrame with columns ['trade_date' (str YYYYMMDD or Timestamp), 'open','high','low','close','vol']
        indexed or not, sorted ascending by date. This is the display window.
    warmup_df: Optional full DataFrame (warmup + display) used for computing indicators.
              If provided, indicators are computed on warmup_df and only the tail (matching df length) is plotted.
    ts_code: e.g. '002418.SZ'
    out_path: png path
    last_close: the most recent close to show in title
    """
    setup_chinese_font()

    df = df.copy().reset_index(drop=True)
    # Normalize trade_date to Timestamp regardless of input dtype (str, object, StringDtype, datetime)
    try:
        is_dt = pd.api.types.is_datetime64_any_dtype(df["trade_date"])
    except Exception:
        is_dt = False
    if not is_dt:
        df["trade_date"] = pd.to_datetime(df["trade_date"].astype(str), format="%Y%m%d",
                                          errors="coerce")
    df = df.sort_values("trade_date").reset_index(drop=True)

    # If warmup data is provided, compute indicators on the full dataset
    # then take only the display tail for plotting
    if warmup_df is not None and len(warmup_df) > len(df):
        full = warmup_df.copy().reset_index(drop=True)
        try:
            is_dt_full = pd.api.types.is_datetime64_any_dtype(full["trade_date"])
        except Exception:
            is_dt_full = False
        if not is_dt_full:
            full["trade_date"] = pd.to_datetime(full["trade_date"].astype(str), format="%Y%m%d",
                                                errors="coerce")
        full = full.sort_values("trade_date").reset_index(drop=True)
        display_len = len(df)
    else:
        full = df
        display_len = len(df)

    # Compute indicators on FULL data (warmup + display), then slice tail for plotting
    full_close = full["close"].astype(float)
    full_high = full["high"].astype(float)
    full_low = full["low"].astype(float)
    full_open = full["open"].astype(float)
    full_vol = full["vol"].astype(float) if "vol" in full.columns else full["volume"].astype(float)
    full_amount = full["amount"].astype(float) if "amount" in full.columns else None

    # Moving averages (on full data)
    _ma5 = full_close.rolling(5).mean()
    _ma10 = full_close.rolling(10).mean()
    _ma20 = full_close.rolling(20).mean()
    _ma60 = full_close.rolling(60).mean()

    # Bollinger bands (20, 2)
    _bb_mid = full_close.rolling(20).mean()
    _bb_std = full_close.rolling(20).std()
    _bb_upper = _bb_mid + 2 * _bb_std
    _bb_lower = _bb_mid - 2 * _bb_std

    # MACD
    _ema12 = full_close.ewm(span=12, adjust=False).mean()
    _ema26 = full_close.ewm(span=26, adjust=False).mean()
    _dif = _ema12 - _ema26
    _dea = _dif.ewm(span=9, adjust=False).mean()
    _macd_hist = 2 * (_dif - _dea)

    # RSI (14)
    _delta = full_close.diff()
    _gain = _delta.clip(lower=0).rolling(14).mean()
    _loss = (-_delta.clip(upper=0)).rolling(14).mean()
    _rs = _gain / _loss.replace(0, np.nan)
    _rsi = 100 - 100 / (1 + _rs)

    # Slice to display window (last display_len rows)
    n = len(full)
    start = n - display_len
    close = full_close.iloc[start:].reset_index(drop=True)
    high = full_high.iloc[start:].reset_index(drop=True)
    low = full_low.iloc[start:].reset_index(drop=True)
    open_ = full_open.iloc[start:].reset_index(drop=True)
    vol = full_vol.iloc[start:].reset_index(drop=True)
    amount = full_amount.iloc[start:].reset_index(drop=True) if full_amount is not None else None
    ma5 = _ma5.iloc[start:].reset_index(drop=True)
    ma10 = _ma10.iloc[start:].reset_index(drop=True)
    ma20 = _ma20.iloc[start:].reset_index(drop=True)
    ma60 = _ma60.iloc[start:].reset_index(drop=True)
    bb_upper = _bb_upper.iloc[start:].reset_index(drop=True)
    bb_lower = _bb_lower.iloc[start:].reset_index(drop=True)
    dif = _dif.iloc[start:].reset_index(drop=True)
    dea = _dea.iloc[start:].reset_index(drop=True)
    macd_hist = _macd_hist.iloc[start:].reset_index(drop=True)
    rsi = _rsi.iloc[start:].reset_index(drop=True)

    # X axis as ordinal integers (smoother for stock charts, no weekend gaps)
    x = np.arange(display_len)

    # === Layout ===
    fig = plt.figure(figsize=(19, 11))
    gs = fig.add_gridspec(
        4, 1,
        height_ratios=[5, 1.2, 1.2, 0.8],
        hspace=0.05,
    )
    ax_price = fig.add_subplot(gs[0])
    ax_vol = fig.add_subplot(gs[1], sharex=ax_price)
    ax_macd = fig.add_subplot(gs[2], sharex=ax_price)
    ax_rsi = fig.add_subplot(gs[3], sharex=ax_price)

    apply_dark_style(fig, [ax_price, ax_vol, ax_macd, ax_rsi])

    # === Price + candles ===
    width_body = 0.6
    width_wick = 0.08
    for i in range(len(df)):
        o, c, h, l = open_.iloc[i], close.iloc[i], high.iloc[i], low.iloc[i]
        color = UP_COLOR if c >= o else DOWN_COLOR
        # Wick
        ax_price.add_patch(Rectangle(
            (x[i] - width_wick / 2, l), width_wick, h - l,
            facecolor=color, edgecolor=color, linewidth=0,
        ))
        # Body
        body_h = abs(c - o)
        if body_h < 1e-9:
            body_h = (h - l) * 0.01 + 0.001
        ax_price.add_patch(Rectangle(
            (x[i] - width_body / 2, min(o, c)), width_body, body_h,
            facecolor=color, edgecolor=color, linewidth=0,
        ))

    # MAs
    ax_price.plot(x, ma5, color=MA5_COLOR, linewidth=1.3, label="MA5")
    ax_price.plot(x, ma10, color=MA10_COLOR, linewidth=1.1, label="MA10")
    ax_price.plot(x, ma20, color=MA20_COLOR, linewidth=1.1, label="MA20")
    ax_price.plot(x, ma60, color=MA60_COLOR, linewidth=1.1, label="MA60")

    # Bollinger bands
    ax_price.plot(x, bb_upper, color=BB_COLOR, linewidth=0.8, linestyle="--",
                  alpha=0.55, label="BB上轨")
    ax_price.plot(x, bb_lower, color=BB_COLOR, linewidth=0.8, linestyle="--",
                  alpha=0.55, label="BB下轨")
    ax_price.fill_between(x, bb_upper, bb_lower, color=BB_COLOR, alpha=0.06)

    # Year high/low annotation
    yh_idx = high.idxmax()
    yl_idx = low.idxmin()
    yh_val = high.iloc[yh_idx]
    yl_val = low.iloc[yl_idx]
    yh_date = df["trade_date"].iloc[yh_idx].strftime("%Y-%m-%d")
    yl_date = df["trade_date"].iloc[yl_idx].strftime("%Y-%m-%d")

    ax_price.annotate(
        f"最高 ¥{yh_val:.2f}\n{yh_date}",
        xy=(x[yh_idx], yh_val),
        xytext=(x[yh_idx] + 3, yh_val + (close.max() - close.min()) * 0.08),
        color=UP_COLOR, fontsize=9, ha="left",
        arrowprops=dict(arrowstyle="->", color=UP_COLOR, lw=1),
        bbox=dict(boxstyle="round,pad=0.3", facecolor=BG_COLOR, edgecolor=UP_COLOR, alpha=0.8),
    )
    ax_price.annotate(
        f"最低 ¥{yl_val:.2f}\n{yl_date}",
        xy=(x[yl_idx], yl_val),
        xytext=(x[yl_idx] + 3, yl_val - (close.max() - close.min()) * 0.08),
        color=DOWN_COLOR, fontsize=9, ha="left",
        arrowprops=dict(arrowstyle="->", color=DOWN_COLOR, lw=1),
        bbox=dict(boxstyle="round,pad=0.3", facecolor=BG_COLOR, edgecolor=DOWN_COLOR, alpha=0.8),
    )

    code_display = show_code_only or ts_code.split(".")[0]
    title = f"{last_close:.2f} ({code_display}) 日K线 — 近{len(df)}个交易日"
    ax_price.set_title(title, color=TEXT_COLOR, fontsize=14, pad=10)
    ax_price.set_ylabel("价格(元)", color=TEXT_COLOR)
    leg = ax_price.legend(loc="upper left", frameon=True, fontsize=9)
    style_legend(leg)
    ax_price.set_xlim(-1, len(df))

    # === Volume (in 亿元 using amount if available) ===
    # Tencent's 'amount' column is in 元; tushare's is in 千元.
    # The value is usually >= 1e8 when aggregated daily. Auto-detect.
    if amount is not None:
        # Auto-scale: find the divisor that puts values in a sensible range (1-1000)
        max_v = amount.max()
        if max_v > 1e12:
            vol_display = amount / 1e8  # 元 → 亿元
        elif max_v > 1e9:
            vol_display = amount / 1e8  # 元 → 亿元
        else:
            vol_display = amount / 1e5  # 千元 → 亿元 (tushare fallback)
        vol_label = "亿元"
    else:
        vol_display = vol / 1e6  # 手 → 亿股
        vol_label = "亿股"

    vol_colors = [UP_COLOR if close.iloc[i] >= open_.iloc[i] else DOWN_COLOR
                  for i in range(len(df))]
    ax_vol.bar(x, vol_display, color=vol_colors, width=0.75, edgecolor="none")
    ax_vol.set_ylabel(vol_label, color=TEXT_COLOR, fontsize=9)
    ax_vol.set_xlim(-1, len(df))

    # === MACD ===
    hist_colors = [UP_COLOR if v >= 0 else DOWN_COLOR for v in macd_hist]
    ax_macd.bar(x, macd_hist, color=hist_colors, width=0.75, edgecolor="none", alpha=0.75)
    ax_macd.plot(x, dif, color=MACD_DIF_COLOR, linewidth=1.1, label="DIF")
    ax_macd.plot(x, dea, color=MACD_DEA_COLOR, linewidth=1.1, label="DEA")
    ax_macd.axhline(0, color=FG_MUTED, linewidth=0.5, linestyle="-", alpha=0.6)
    ax_macd.set_ylabel("MACD", color=TEXT_COLOR, fontsize=9)
    leg2 = ax_macd.legend(loc="upper left", frameon=True, fontsize=8)
    style_legend(leg2)

    # === RSI ===
    ax_rsi.plot(x, rsi, color="#b49ee7", linewidth=1.1)
    ax_rsi.axhline(70, color=UP_COLOR, linewidth=0.6, linestyle="--", alpha=0.5)
    ax_rsi.axhline(30, color=DOWN_COLOR, linewidth=0.6, linestyle="--", alpha=0.5)
    ax_rsi.set_ylim(0, 100)
    ax_rsi.set_ylabel("RSI", color=TEXT_COLOR, fontsize=9)

    # X-axis: show month-day labels at ~sensible tick positions
    n_ticks = 12
    tick_idx = np.linspace(0, len(df) - 1, n_ticks).astype(int)
    tick_labels = [df["trade_date"].iloc[i].strftime("%m/%d") for i in tick_idx]
    ax_rsi.set_xticks(tick_idx)
    ax_rsi.set_xticklabels(tick_labels, rotation=0, color=TEXT_COLOR)
    for ax in (ax_price, ax_vol, ax_macd):
        plt.setp(ax.get_xticklabels(), visible=False)

    # Hide top/right spines cleanly
    for ax in (ax_price, ax_vol, ax_macd, ax_rsi):
        ax.spines["top"].set_visible(False)
        ax.spines["right"].set_visible(False)

    plt.tight_layout()
    plt.subplots_adjust(hspace=0.08)
    fig.savefig(out_path, facecolor=BG_COLOR, dpi=120, bbox_inches="tight")
    plt.close(fig)


def compute_indicator_values(df: pd.DataFrame) -> dict:
    """Return the latest technical indicator values for the report."""
    close = df["close"].astype(float)
    open_ = df["open"].astype(float)
    high = df["high"].astype(float)
    low = df["low"].astype(float)

    ma5 = close.rolling(5).mean().iloc[-1]
    ma10 = close.rolling(10).mean().iloc[-1]
    ma20 = close.rolling(20).mean().iloc[-1]
    ma60 = close.rolling(60).mean().iloc[-1]

    bb_mid = close.rolling(20).mean()
    bb_std = close.rolling(20).std()
    bb_upper = (bb_mid + 2 * bb_std).iloc[-1]
    bb_lower = (bb_mid - 2 * bb_std).iloc[-1]

    # MACD
    ema12 = close.ewm(span=12, adjust=False).mean()
    ema26 = close.ewm(span=26, adjust=False).mean()
    dif = (ema12 - ema26).iloc[-1]
    dea_series = (ema12 - ema26).ewm(span=9, adjust=False).mean()
    dea = dea_series.iloc[-1]
    macd_hist = 2 * (dif - dea)

    # RSI14
    delta = close.diff()
    gain = delta.clip(lower=0).rolling(14).mean()
    loss = (-delta.clip(upper=0)).rolling(14).mean()
    rs = gain / loss.replace(0, np.nan)
    rsi = (100 - 100 / (1 + rs)).iloc[-1]

    # KDJ (9,3,3)
    low_min = low.rolling(9).min()
    high_max = high.rolling(9).max()
    rsv = 100 * (close - low_min) / (high_max - low_min).replace(0, np.nan)
    k = rsv.ewm(com=2).mean()
    d = k.ewm(com=2).mean()
    j = 3 * k - 2 * d

    latest_close = close.iloc[-1]
    bb_deviation_pct = (latest_close - bb_upper) / bb_upper * 100 if bb_upper else 0

    # Returns
    def _pct_change(n):
        if len(close) <= n:
            return None
        return (close.iloc[-1] / close.iloc[-n - 1] - 1) * 100

    return {
        "close": float(latest_close),
        "ma5": float(ma5) if pd.notna(ma5) else None,
        "ma10": float(ma10) if pd.notna(ma10) else None,
        "ma20": float(ma20) if pd.notna(ma20) else None,
        "ma60": float(ma60) if pd.notna(ma60) else None,
        "bb_upper": float(bb_upper) if pd.notna(bb_upper) else None,
        "bb_lower": float(bb_lower) if pd.notna(bb_lower) else None,
        "bb_deviation_pct": float(bb_deviation_pct) if pd.notna(bb_deviation_pct) else None,
        "macd_dif": float(dif) if pd.notna(dif) else None,
        "macd_dea": float(dea) if pd.notna(dea) else None,
        "macd_hist": float(macd_hist) if pd.notna(macd_hist) else None,
        "rsi14": float(rsi) if pd.notna(rsi) else None,
        "kdj_k": float(k.iloc[-1]) if pd.notna(k.iloc[-1]) else None,
        "kdj_d": float(d.iloc[-1]) if pd.notna(d.iloc[-1]) else None,
        "kdj_j": float(j.iloc[-1]) if pd.notna(j.iloc[-1]) else None,
        "year_high": float(high.max()),
        "year_low": float(low.min()),
        "year_high_date": str(df["trade_date"].iloc[int(high.values.argmax())]),
        "year_low_date": str(df["trade_date"].iloc[int(low.values.argmin())]),
        "pct_1w": _pct_change(5),
        "pct_1m": _pct_change(20),
        "pct_1y": _pct_change(min(244, len(close) - 1)),
    }


path: scripts/resolve_code.py
content: #!/usr/bin/env python3
"""
Resolve user's raw stock input → canonical A-share identifiers.

Accepts: 6-digit code, code.SZ, sz002418, Chinese name (康盛股份), fuzzy partial name.

Output JSON:
  {"code6", "ts_code", "tencent_code", "name", "industry", "total_mv", "list_date"}
  or {"matches": [...]} for ambiguous, or {"error": "..."} for failure.
"""
import json
import os
import re
import sys
import warnings

warnings.filterwarnings("ignore")


def _market_suffix(code6: str) -> str:
    if code6.startswith(("60", "68", "90")) or code6[0] == "9":
        return ".SH"
    if code6.startswith(("43", "83", "87", "92", "4", "8")):
        return ".BJ"
    return ".SZ"


def _tencent_prefix(code6: str) -> str:
    if code6.startswith(("60", "68", "90")):
        return "sh"
    if code6.startswith(("43", "83", "87", "92", "4", "8")):
        return "bj"
    return "sz"


def _normalize(raw: str) -> str:
    s = raw.strip()
    m = re.match(r"^(sz|sh|bj)(\d{6})$", s, re.IGNORECASE)
    if m:
        return m.group(2)
    m = re.match(r"^(\d{6})\.(sz|sh|bj)$", s, re.IGNORECASE)
    if m:
        return m.group(1)
    if re.match(r"^\d{6}$", s):
        return s
    return s


def _resolve_by_code(code6: str) -> dict:
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from sources import eastmoney

    # Try eastmoney snapshot first (fast, no rate limits)
    snap = eastmoney.get_stock_snapshot(code6)
    name, industry, total_mv = "", "", None
    if snap and snap.get("name"):
        name = snap["name"]
        total_mv = snap.get("total_mv")
    else:
        # Fallback: akshare individual info
        try:
            from sources import akshare_src
            info = akshare_src.get_individual_info(code6)
            name = info.get("name", "")
            industry = info.get("industry", "")
            total_mv = info.get("total_mv")
        except Exception:
            pass

    if not name:
        return {"error": f"Code {code6} not found in A-share listings."}

    # Get industry from F10 ssbk (most authoritative)
    try:
        from sources import eastmoney as em
        boards = em.get_boards_of(code6)
        ssbk = boards.get("ssbk", [])
        for item in ssbk:
            n = item.get("board_name", "")
            if re.search(r"[Ⅰ-Ⅶ]$", n):  # 行业板块 end with 罗马数字
                industry = n
                break
    except Exception:
        pass

    return {
        "code6": code6,
        "ts_code": code6 + _market_suffix(code6),
        "tencent_code": _tencent_prefix(code6) + code6,
        "name": name,
        "industry": industry,
        "total_mv": total_mv,
    }


def _resolve_by_name(name: str) -> dict:
    """Use eastmoney's suggest endpoint first (reliable), fall back to akshare."""
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from sources import eastmoney

    try:
        results = eastmoney.search_stocks(name, limit=10)
    except Exception as e:
        results = []

    if len(results) == 0:
        # Fallback to akshare
        try:
            import akshare as ak
            df = ak.stock_info_a_code_name()
            hits = df[df["name"] == name]
            if len(hits) == 0:
                hits = df[df["name"].str.contains(name, na=False, regex=False)]
            results = [{"code6": str(r["code"]).zfill(6), "name": str(r["name"])}
                       for _, r in hits.head(10).iterrows()]
        except Exception:
            pass

    if len(results) == 0:
        return {"error": f"No A-share found matching '{name}'."}

    if len(results) == 1:
        return _resolve_by_code(results[0]["code6"])

    # Multiple matches — check if any is an exact match
    exact = [r for r in results if r["name"] == name]
    if len(exact) == 1:
        return _resolve_by_code(exact[0]["code6"])

    matches = []
    for r in results:
        c = r["code6"]
        matches.append({
            "code6": c,
            "ts_code": c + _market_suffix(c),
            "name": r["name"],
        })
    return {"matches": matches}


def resolve(raw: str) -> dict:
    norm = _normalize(raw)
    if re.match(r"^\d{6}$", norm):
        return _resolve_by_code(norm)
    return _resolve_by_name(norm)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(json.dumps({"error": "usage: resolve_code.py <name-or-code>"}, ensure_ascii=False))
        sys.exit(1)
    result = resolve(sys.argv[1])
    print(json.dumps(result, ensure_ascii=False, indent=2))
    sys.exit(0 if "error" not in result else 2)


path: scripts/sources/akshare_src.py
content: """
akshare data source for industry/concept/theme peer discovery.

Exposes:
 - get_industry_of(code) -> str (e.g. "家电零部件Ⅱ") via stock_individual_info_em
 - get_industry_peers(industry_name) -> DataFrame
 - get_concept_peers(concept_name) -> DataFrame
 - get_all_industries() -> DataFrame (name + board_code)
 - get_all_concepts() -> DataFrame
"""
from __future__ import annotations
import warnings
from typing import Optional

import pandas as pd

warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)


def _lazy_ak():
    import akshare as ak
    return ak


def get_individual_info(code6: str) -> dict:
    """
    Return a dict with individual info from akshare:
    {'name', 'industry', 'total_mv', 'float_mv', 'total_share', 'list_date'}

    Args: code6 — 6-digit code like '002418'
    """
    ak = _lazy_ak()
    df = ak.stock_individual_info_em(symbol=code6)
    # df has columns 'item' and 'value'
    d = dict(zip(df["item"], df["value"]))
    return {
        "name": str(d.get("股票简称", "")),
        "industry": str(d.get("行业", "")),
        "total_mv": float(d["总市值"]) if d.get("总市值") else None,
        "float_mv": float(d["流通市值"]) if d.get("流通市值") else None,
        "total_share": float(d["总股本"]) if d.get("总股本") else None,
        "float_share": float(d["流通股"]) if d.get("流通股") else None,
        "list_date": str(d.get("上市时间", "")),
        "price": float(d["最新"]) if d.get("最新") else None,
    }


def get_industry_peers(industry_name: str, top_n: int = 20) -> pd.DataFrame:
    """
    Return constituents of an Eastmoney industry board.
    Columns: 代码, 名称, 最新价, 涨跌幅, 换手率, 成交额, 市盈率-动态, 市净率
    """
    ak = _lazy_ak()
    df = ak.stock_board_industry_cons_em(symbol=industry_name)
    return df.head(top_n)


def get_concept_peers(concept_name: str, top_n: int = 30) -> pd.DataFrame:
    """
    Return constituents of an Eastmoney concept board.
    """
    ak = _lazy_ak()
    df = ak.stock_board_concept_cons_em(symbol=concept_name)
    return df.head(top_n)


def get_all_industries() -> pd.DataFrame:
    """List all Eastmoney industry boards."""
    ak = _lazy_ak()
    return ak.stock_board_industry_name_em()


def get_all_concepts() -> pd.DataFrame:
    """List all Eastmoney concept boards."""
    ak = _lazy_ak()
    return ak.stock_board_concept_name_em()


def get_financial_abstract(code6: str) -> Optional[pd.DataFrame]:
    """
    Fetch quarterly financial abstract (财务摘要) from Tonghuashun via akshare.
    Columns vary by indicator; typical: 报告期, 净利润, 净利润同比, 营业收入, 营业收入同比, ...

    Returns None on failure.
    """
    ak = _lazy_ak()
    try:
        df = ak.stock_financial_abstract_ths(symbol=code6, indicator="按报告期")
        return df
    except Exception:
        try:
            df = ak.stock_financial_abstract(symbol=code6)
            return df
        except Exception:
            return None


def get_financial_analysis_indicator(code6: str, start_year: str = "2023") -> Optional[pd.DataFrame]:
    """
    akshare financial analysis indicator (新浪/同花顺口径).
    Contains: 主营业务利润率(%), 总资产利润率(%), 净资产收益率(%), 净利率, 毛利率, ...
    """
    ak = _lazy_ak()
    try:
        df = ak.stock_financial_analysis_indicator(symbol=code6, start_year=start_year)
        return df
    except Exception:
        return None


def get_fundamental_indicators(code: str) -> Optional[dict]:
    """
    akshare fundamental source (layer 2 in fallback chain).
    Uses stock_financial_analysis_indicator (xueqiu/sina backend).
    Returns harmonized dict shape.
    """
    from datetime import datetime
    ak = _lazy_ak()
    code6 = _to_code6(code)

    try:
        df = ak.stock_financial_analysis_indicator(symbol=code6, start_year=str(datetime.now().year - 1))
    except Exception:
        return None
    if df is None or len(df) == 0:
        return None

    df = df.sort_values("日期").reset_index(drop=True)
    latest = df.iloc[-1].to_dict()

    def _f(key, scale: float = 1.0):
        v = latest.get(key)
        if v is None or (isinstance(v, float) and pd.isna(v)):
            return None
        try:
            return float(v) * scale
        except (TypeError, ValueError):
            return None

    end_date = str(latest.get("日期", ""))
    return {
        "end_date": end_date,
        "report_name": f"{end_date[:4]}年{_period_from_date(end_date)}" if len(end_date) >= 7 else "",
        "roe": _f("净资产收益率(%)"),
        "roa": _f("总资产利润率(%)"),
        "gross_margin": _f("销售毛利率(%)"),
        "net_margin": _f("销售净利率(%)"),
        "debt_to_assets": _f("资产负债率(%)"),
        "eps": _f("加权每股收益(元)") or _f("摊薄每股收益(元)"),
        "bps": _f("每股净资产_调整前(元)"),
        "revenue": None,
        "net_profit": None,
        "revenue_yoy": _f("主营业务收入增长率(%)"),
        "net_profit_yoy": _f("净利润增长率(%)"),
        "source": "akshare",
    }


def _to_code6(code: str) -> str:
    import re as _re
    s = code.strip().upper()
    m = _re.match(r"^(SZ|SH|BJ)(\d{6})$", s)
    if m:
        return m.group(2)
    m = _re.match(r"^(\d{6})\.(SZ|SH|BJ)$", s)
    if m:
        return m.group(1)
    m = _re.match(r"^\d{6}$", s)
    if m:
        return s
    return s


def _period_from_date(date_str: str) -> str:
    md = date_str[5:10].replace("-", "")
    return {"0331": "一季报", "0630": "中报", "0930": "三季报", "1231": "年报"}.get(md, "")


path: scripts/sources/baostock_src.py
content: """
Baostock fundamental source (layer 3 in fallback chain).
No auth needed. Returns quarterly data.
"""
from __future__ import annotations
import re
from typing import Optional


def _bs_code(code: str) -> str:
    """Normalize to 'sz.002418' / 'sh.600519' / 'bj.430047' format baostock uses."""
    s = code.strip().lower()
    m = re.match(r"^(sz|sh|bj)(\d{6})$", s)
    if m:
        return f"{m.group(1)}.{m.group(2)}"
    m = re.match(r"^(\d{6})\.(sz|sh|bj)$", s)
    if m:
        return f"{m.group(2)}.{m.group(1)}"
    m = re.match(r"^\d{6}$", s)
    if m:
        if s.startswith(("60", "68", "90")):
            return "sh." + s
        if s.startswith(("43", "83", "87", "92", "4", "8")):
            return "bj." + s
        return "sz." + s
    return s


def get_fundamental_indicators(code: str) -> Optional[dict]:
    """
    Fetch latest quarterly indicators from baostock.
    Uses: query_profit_data (ROE, netProfitMargin, ...), query_growth_data (YoY growth).
    """
    try:
        import baostock as bs
    except ImportError:
        return None

    bs_code = _bs_code(code)
    from datetime import datetime
    year = datetime.now().year

    login_rs = bs.login()
    if login_rs.error_code != "0":
        return None

    try:
        profit = _latest_quarter(bs, "query_profit_data", bs_code, year)
        growth = _latest_quarter(bs, "query_growth_data", bs_code, year)
        balance = _latest_quarter(bs, "query_balance_data", bs_code, year)

        if not (profit or growth):
            return None

        def _f(d, key):
            if not d:
                return None
            v = d.get(key)
            if v is None or v == "":
                return None
            try:
                return float(v)
            except (TypeError, ValueError):
                return None

        period = (profit or growth or {}).get("statDate") or ""
        # baostock's ROE fields are fractions (0.0097) not percentages (0.97); convert to %
        roe_raw = _f(profit, "roeAvg")
        gross_raw = _f(profit, "gpMargin")
        net_raw = _f(profit, "npMargin")

        return {
            "end_date": period,
            "report_name": f"{period[:4]}{_period_label(period[5:7] + period[8:10])}" if len(period) == 10 else "",
            "roe": roe_raw * 100 if roe_raw is not None else None,
            "gross_margin": gross_raw * 100 if gross_raw is not None else None,
            "net_margin": net_raw * 100 if net_raw is not None else None,
            "debt_to_assets": (_f(balance, "liabilityToAsset") or 0) * 100 if balance else None,
            "eps": _f(profit, "epsTTM"),
            "bps": None,
            "revenue": _f(profit, "MBRevenue"),
            "net_profit": _f(profit, "netProfit"),
            "revenue_yoy": (_f(growth, "YOYEquity") or 0) * 100 if growth else None,
            "net_profit_yoy": (_f(growth, "YOYNI") or 0) * 100 if growth else None,
            "source": "baostock",
        }
    finally:
        bs.logout()


def _latest_quarter(bs, method: str, code: str, year: int) -> Optional[dict]:
    """Query a baostock method for current & recent years, return most-recent non-empty quarter."""
    fn = getattr(bs, method)
    for y in (year, year - 1):
        for q in (3, 2, 1, 4):
            try:
                rs = fn(code=code, year=y, quarter=q)
                if rs.error_code != "0":
                    continue
                rows = []
                while rs.next():
                    rows.append(rs.get_row_data())
                if rows:
                    return dict(zip(rs.fields, rows[0]))
            except Exception:
                continue
    return None


def _period_label(md: str) -> str:
    return {"0331": "一季报", "0630": "中报", "0930": "三季报", "1231": "年报"}.get(md, md)


path: scripts/sources/tencent.py
content: """
Tencent Finance data source.

Scope:
 - Daily K-line (OHLCV + turnover_rate + amount) for individual stocks
 - Daily K-line for indices (CSI 300 = sh000300, SSE = sh000001, SZSE = sz399001)
 - Realtime quote (current price, change %, total mv hint)
 - Code normalization helper (002418 → sz002418, 600519 → sh600519, etc)

No API key needed. Robust to 6-digit variations.
"""
from __future__ import annotations
import json
import re
import time
from datetime import datetime, timedelta
from typing import Optional

import pandas as pd
import urllib.request
import urllib.parse


KLINE_URL = "https://web.ifzq.gtimg.cn/appstock/app/newfqkline/get"
REALTIME_URL = "http://qt.gtimg.cn/q="

_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120"


def _http_get(url: str, timeout: int = 10, retries: int = 3) -> bytes:
    last_err = None
    for attempt in range(retries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": _UA})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return r.read()
        except Exception as e:
            last_err = e
            # exponential-ish backoff: 0.5s, 1.5s, 3.5s
            time.sleep(0.5 + attempt * 1.0 + attempt * attempt * 0.5)
    raise last_err if last_err else RuntimeError("http_get failed")


def to_tencent_code(code: str) -> str:
    """
    Convert any input into Tencent format: 'sz002418', 'sh600519', 'bj430047', etc.
    Accepts: 002418, 002418.SZ, sz002418, SH600519.
    Raises ValueError if can't determine market.
    """
    s = code.strip().lower()
    m = re.match(r"^(sz|sh|bj)(\d{6})$", s)
    if m:
        return m.group(1) + m.group(2)
    m = re.match(r"^(\d{6})\.(sz|sh|bj)$", s)
    if m:
        return m.group(2) + m.group(1)
    m = re.match(r"^\d{6}$", s)
    if m:
        prefix = s[:2]
        if prefix in ("60", "68", "51", "58") or s[0] == "9":
            return "sh" + s
        elif prefix in ("00", "30", "20") or prefix.startswith("3"):
            return "sz" + s
        elif s.startswith(("43", "83", "87", "92", "4", "8")):
            return "bj" + s
        else:
            # default to SZ
            return "sz" + s
    raise ValueError(f"Cannot parse Tencent code from: {code}")


def to_tushare_code(tcode: str) -> str:
    """Inverse: sz002418 -> 002418.SZ"""
    tcode = tcode.strip().lower()
    m = re.match(r"^(sz|sh|bj)(\d{6})$", tcode)
    if m:
        return m.group(2) + "." + m.group(1).upper()
    return tcode


# ---------- K-line ----------

def get_daily_kline(code: str, days: int = 320, adjust: str = "qfq") -> pd.DataFrame:
    """
    Fetch daily K-line from Tencent. Returns a DataFrame with:
        trade_date (YYYYMMDD str), open, high, low, close, vol (手), amount (元), turnover_rate (%)

    Args:
        code: any format accepted by to_tencent_code, or index code like 'sh000300'.
        days: how many trading days to return from Tencent's side (Tencent's limit).
        adjust: 'qfq' (前复权), 'hfq' (后复权), '' (不复权).
    """
    tcode = to_tencent_code(code) if not code.startswith(("sh00", "sz39", "sh39", "bj")) else code
    # Special-case indices: they use sh000300, sz399001, etc. which are already full
    if code.startswith(("sh", "sz", "bj")) and len(code) == 8:
        tcode = code.lower()

    # Build URL
    params = {
        "_var": "kline_day",
        "param": f"{tcode},day,,,{days},{adjust}",
        "r": str(time.time()),
    }
    url = KLINE_URL + "?" + urllib.parse.urlencode(params)
    raw = _http_get(url).decode("utf-8", errors="ignore")

    # Response is JSONP: kline_day={...};
    m = re.search(r"=\s*(\{.*\})\s*;?$", raw, re.DOTALL)
    if not m:
        raise RuntimeError(f"Unexpected Tencent response: {raw[:200]}")
    payload = json.loads(m.group(1))

    data = payload.get("data", {})
    stock_data = data.get(tcode, {})
    # Individual stocks: key is 'qfqday' / 'hfqday' / 'day'
    # Indices: key is 'day'
    candle_key = {"qfq": "qfqday", "hfq": "hfqday", "": "day"}.get(adjust, "qfqday")
    rows = stock_data.get(candle_key) or stock_data.get("day") or []
    if not rows:
        # Some indices return under 'day' even when qfq requested
        rows = stock_data.get("day", [])
    if not rows:
        raise RuntimeError(f"Tencent returned no candles for {tcode}")

    # Row format (newfqkline):
    # [date, open, close, high, low, vol, {}, turnover_rate, amount_wan, ""]
    # Index may have fewer fields: [date, open, close, high, low, vol]
    records = []
    for row in rows:
        if len(row) < 6:
            continue
        rec = {
            "trade_date": row[0].replace("-", ""),
            "open": float(row[1]),
            "close": float(row[2]),
            "high": float(row[3]),
            "low": float(row[4]),
            "vol": float(row[5]),  # 手
        }
        # Optional turnover & amount
        if len(row) >= 9:
            try:
                rec["turnover_rate"] = float(row[7])
            except (TypeError, ValueError):
                rec["turnover_rate"] = None
            try:
                # amount returned in 万元, convert to 元
                rec["amount"] = float(row[8]) * 1e4
            except (TypeError, ValueError):
                rec["amount"] = None
        else:
            rec["turnover_rate"] = None
            # Estimate amount from vol * close * 100 (vol is 手)
            rec["amount"] = rec["vol"] * rec["close"] * 100
        records.append(rec)

    df = pd.DataFrame(records)
    df = df.sort_values("trade_date").reset_index(drop=True)
    return df


# ---------- Realtime quote ----------

def get_realtime_quote(code: str) -> dict:
    """
    Fetch realtime quote from qt.gtimg.cn. Returns a dict with:
        name, code, price, pre_close, today_open, high, low, change, change_pct,
        vol_手, amount_万元, turnover_rate, pe_ttm, pb, total_mv_亿, float_mv_亿
    """
    tcode = to_tencent_code(code) if not code.startswith(("sh", "sz", "bj")) else code
    url = REALTIME_URL + tcode
    raw = _http_get(url)
    # Response is GBK encoded: v_sz002418="51~康盛股份~002418~6.81~...";
    try:
        txt = raw.decode("gbk")
    except UnicodeDecodeError:
        txt = raw.decode("utf-8", errors="ignore")

    m = re.search(r'"([^"]+)"', txt)
    if not m:
        raise RuntimeError(f"Unexpected Tencent realtime response: {txt[:200]}")
    parts = m.group(1).split("~")
    if len(parts) < 50:
        raise RuntimeError(f"Tencent realtime has too few fields: {len(parts)}")

    def _f(idx, default=None):
        try:
            v = parts[idx]
            return float(v) if v not in ("", "-", "~") else default
        except (ValueError, IndexError):
            return default

    quote = {
        "name": parts[1],
        "code": parts[2],
        "price": _f(3),
        "pre_close": _f(4),
        "today_open": _f(5),
        "volume_手": _f(6),
        "bid_vol": _f(7),
        "ask_vol": _f(8),
        "high": _f(33),
        "low": _f(34),
        "change": _f(31),
        "change_pct": _f(32),
        "amount_万元": _f(37),       # 总成交额（万元）
        "turnover_rate": _f(38),    # 换手率 %
        "pe_ttm": _f(39),
        "wavg_price": None,
        "pb": _f(46),
        "total_mv_亿": _f(45),       # 总市值（亿元）
        "float_mv_亿": _f(44),       # 流通市值（亿元）
        "timestamp": parts[30] if len(parts) > 30 else "",
    }
    return quote


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("usage: tencent.py <code>")
        sys.exit(1)
    code = sys.argv[1]
    print("--- realtime ---")
    print(json.dumps(get_realtime_quote(code), ensure_ascii=False, indent=2))
    print("--- kline last 5 ---")
    df = get_daily_kline(code, days=252)
    print(df.tail(5).to_string())


path: references/peer_selection_rules.md
content: # 同行业对比股选择规则 v2

## 目标

每次从 **东方财富 F10 所属板块**（`ssbk` 接口）反查，选出 **3-4 只对比股**，构成多维度参照组：
- **同行业 1-2 只** —— 共享主营业务
- **同概念 1-2 只** —— 共享热门题材
- **同题材 0-2 只** —— 补齐到 4 只

## 数据源

| 环节 | 接口 | 用途 |
|---|---|---|
| 板块归属反查 | Eastmoney [REDACTED_SECRET] | 返回股票所属的全部板块 |
| 板块成分股 | Eastmoney `push2delay/api/qt/clist/get?fs=b:BK{code}` | 取板块成分股并按成交额排序 |
| 板块分类 | 本地规则（板块名称+后缀） | 见下 |

**不再依赖 akshare 的 `stock_board_concept_cons_em`** —— 它受上游速率限制经常 503。直接打 eastmoney 的 push 接口更稳定。

## 板块分类规则

### 行业板块（industry）
板块名称以罗马数字结尾：`Ⅰ` / `Ⅱ` / `Ⅲ` / `Ⅳ` / `Ⅴ` / `Ⅵ` / `Ⅶ`

例如：`家电零部件Ⅱ`、`汽车零部件Ⅲ`、`电力设备Ⅰ`

### 概念板块（concept）
满足任一条件：
- 板块名称以 `概念` 结尾（如 `液冷概念`、`储能概念`）
- 板块名称包含关键词：`新能源`、`光伏`、`锂电`、`氢能`、`储能`、`5G`、`半导体`、`芯片`、`AI`、`液冷`、`算力`、`数据中心`、`服务器`、`云计算`、`军工`、`特斯拉`、`华为`、`苹果`、`宁德`、`GPU`、`MCU`、`HBM`、`CPO`、`智能驾驶`、`动力电池`、`燃料电池`、`钠离子`、`固态电池`、`复合集流体`、`一体化压铸` 等（完整列表见 `peer_selector.py`）

### 题材板块（theme）
剩下的非排除板块（如 `家用电器`、`电池技术`、`汽车热管理`、`超级电容`）

### 排除板块（不选）
- 技术性标签：`昨日高振幅`、`最近多板`、`昨日涨停`、`涨停一字`
- 持股标签：`机构重仓`、`QFII重仓`、`证金持股`、`基金重仓`
- 区域板块：`浙江板块`、`北京板块` 等
- 通道：`深股通`、`沪股通`、`AH股`、`MSCI`、`富时`
- 其他：`东方财富热股`、`龙头股`、`破净股`、`次新股`、`小盘股`、`大盘股`、`2025年报扭亏`、`预盈预增`、`预亏预减`、`退市警示` 等

## 代表股挑选

每个板块取成分股列表（最多 30 只），过滤后按**成交额（元）降序**排序，取第 1 只：

**过滤规则：**
- 排除目标股本身
- 排除已经选进 picks 的股票（去重）
- 排除 ST、*ST、退市股（名称含 `ST`、`*`、`退`）
- 排除无价格数据的（停牌）

**为什么按成交额排序？**
- 成交额同时反映**流动性**和**市场关注度**
- 相比换手率，成交额能避免小盘股因换手率高被误选
- 相比市值，避免只选龙头而忽略当下热点

## 选择流程

1. 调 `eastmoney.get_boards_of(code6)` 拿 `ssbk` 列表
2. 调 `classify_boards()` 分类
3. 从 **industry** 桶前 3 个里最多取 2 只
4. 从 **concept** 桶前 5 个里最多取 2 只
5. 从 **theme** 桶前 6 个里填补，直到 picks 达到 `max_peers`（默认 4）

## 降级

如果某个板块成分股拿不到（网络错误），跳过该板块继续下一个，不中断流程。

如果最终 picks < 3，返回现有结果，报告里加注 "同行业数据有限，对比仅供参考"。

## 实例

**目标：康盛股份 (002418)**
- F10 `ssbk` 返回 27 个板块
- 行业桶：`家电零部件Ⅱ`、`家电零部件Ⅲ`
- 概念桶：`液冷概念`、`算力概念`、`储能概念`、`氢能源`、`新能源车`、`无人驾驶`、`电池技术`、`超级电容`、`石墨烯`、`燃料电池概念`、`锂电池概念`、`汽车热管理`
- 题材桶：`家用电器`、`百日新高`、`近期新高`

**最终选出：**
1. **天银机电**（industry:家电零部件Ⅱ）
2. **三花智控**（industry:家电零部件Ⅲ）
3. **西部材料**（concept:电池技术）
4. **剑桥科技**（concept:液冷概念）

天银机电和三花智控是同行业龙头；西部材料和剑桥科技是目标股重点概念里的活跃股（成交额最大）。


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)