# -*- coding: utf-8 -*-
"""生成单页离线 HTML 市场情绪日报（暗色盘面复盘风，全内联）"""
import json
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime
from html import escape
from pathlib import Path

TRADE_DATE = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
MODE = sys.argv[2] if len(sys.argv) > 2 else "full"   # full | intraday
SHOW_LHB = MODE != "intraday"
BASE = Path(__file__).parent
DATA = BASE / (sys.argv[3] if len(sys.argv) > 3 else "data")
OUTFILE = BASE / (f"A股实时情绪_盘中_{TRADE_DATE}.html"
                  if MODE == "intraday" else f"A股市场情绪日报_{TRADE_DATE}.html")

UP = "#f6465d"      # 涨 红
DOWN = "#0ecb81"    # 跌 绿
FLAT = "#8b949e"

# ---------------- 载入数据 ----------------
lhb_raw = json.loads((DATA / "lhb.json").read_text(encoding="utf-8")) if SHOW_LHB else []
hot = json.loads((DATA / "ths_hot.json").read_text(encoding="utf-8"))
ind_raw = json.loads((DATA / "industry_raw.json").read_text(encoding="utf-8"))
idx = json.loads((DATA / "index.json").read_text(encoding="utf-8"))
try:
    ind_detail = json.loads((DATA / "industry_detail.json").read_text(encoding="utf-8"))
except Exception:
    ind_detail = {}

# ---------------- 龙虎榜聚合（按个股去重）----------------
grp = defaultdict(list)
for r in lhb_raw:
    grp[r["code"]].append(r)

lhb = []
for code, rows in grp.items():
    main = max(rows, key=lambda x: abs(x["net_buy"]))
    reasons = []
    for x in rows:
        if x["reason"] and x["reason"] not in reasons:
            reasons.append(x["reason"])
    lhb.append({
        "code": code, "name": main["name"], "close": main["close"],
        "change_pct": main["change_pct"], "net_buy": main["net_buy"],
        "buy_amt": max(x["buy_amt"] for x in rows),
        "sell_amt": max(x["sell_amt"] for x in rows),
        "turnover_pct": max(x["turnover_pct"] for x in rows),
        "accum_amount": main["accum_amount"],
        "free_mcap": main["free_mcap"],
        "reasons": reasons, "n_rec": len(rows),
    })
lhb.sort(key=lambda x: x["net_buy"], reverse=True)

net_total = sum(x["net_buy"] for x in lhb)
buy_total = sum(x["buy_amt"] for x in lhb)
sell_total = sum(x["sell_amt"] for x in lhb)
n_netbuy = sum(1 for x in lhb if x["net_buy"] > 0)
n_netsell = sum(1 for x in lhb if x["net_buy"] < 0)
n_up = sum(1 for x in lhb if x["change_pct"] > 0)
n_down = sum(1 for x in lhb if x["change_pct"] < 0)
n_limitup = sum(1 for x in lhb if x["change_pct"] >= 9.8)

# ---------------- 题材词频 ----------------
tag_stocks = defaultdict(list)
for h in hot:
    for t in re.split(r"[+＋]", str(h.get("reason") or "")):
        t = t.strip()
        if t:
            tag_stocks[t].append(h)
tag_cnt = Counter({k: len(v) for k, v in tag_stocks.items()})
top_tags = tag_cnt.most_common(15)
multi_tags = [(t, c) for t, c in tag_cnt.items() if c >= 2]
multi_tags.sort(key=lambda x: -x[1])
hot_up = [float(h["change_pct"]) for h in hot if h.get("change_pct") not in (None, "")]

# ---------------- 行业 ----------------
inds = [i for i in ind_raw if i.get("kline")]
for i in inds:
    i["chg"] = i["kline"]["change_pct"]
    i["amount"] = i["kline"]["amount"]
    i["turnover"] = i["kline"]["turnover"]
    d = ind_detail.get(i["code"]) or {}
    i["up"] = d.get("up")
    i["down"] = d.get("down")
    i["flat"] = d.get("flat")
    i["members"] = d.get("sampled")
    i["leader"] = d.get("leader")
    i["laggard"] = d.get("laggard")
inds.sort(key=lambda x: x["chg"], reverse=True)
ind_top = inds[:15]
ind_bottom = list(reversed(inds[-15:]))
n_ind_up = sum(1 for i in inds if i["chg"] > 0)
n_ind_down = sum(1 for i in inds if i["chg"] < 0)


# ---------------- 工具 ----------------
def money(v, unit="元"):
    """元 -> ¥x.xx亿 / ¥x万"""
    a = abs(v)
    s = "-" if v < 0 else ""
    if a >= 1e8:
        return f"{s}¥{a/1e8:.2f}亿"
    if a >= 1e4:
        return f"{s}¥{a/1e4:.0f}万"
    return f"{s}¥{a:.0f}"


def pct(v, plus=True):
    return f"{'+' if (plus and v > 0) else ''}{v:.2f}%"


def color(v):
    return UP if v > 0 else (DOWN if v < 0 else FLAT)


def esc(s):
    return escape(str(s), quote=True)


# ---------------- SVG 1: 题材热度 ----------------
def svg_themes():
    rows = top_tags
    if not rows:
        return "<p>无数据</p>"
    W, rowh, pad_l, pad_r, top = 1000, 30, 210, 70, 34
    H = top + rowh * len(rows) + 18
    maxv = max(c for _, c in rows)
    bw = W - pad_l - pad_r
    p = [f'<svg viewBox="0 0 {W} {H}" width="100%" xmlns="http://www.w3.org/2000/svg" '
         f'font-family="Consolas,Menlo,Microsoft YaHei,sans-serif">']
    p.append('<defs><linearGradient id="gt" x1="0" y1="0" x2="1" y2="0">'
             '<stop offset="0" stop-color="#f6465d"/><stop offset="1" stop-color="#ff9f43"/>'
             '</linearGradient></defs>')
    p.append(f'<text x="0" y="18" fill="#c9d1d9" font-size="14">题材出现只数（同花顺当日强势股 reason 标签按「+」拆分词频）</text>')
    for gi in range(0, maxv + 1):
        x = pad_l + bw * gi / maxv
        p.append(f'<line x1="{x:.1f}" y1="{top-8}" x2="{x:.1f}" y2="{H-14}" stroke="#21262d" stroke-width="1"/>')
        p.append(f'<text x="{x:.1f}" y="{H-2}" fill="#6e7681" font-size="11" text-anchor="middle">{gi}</text>')
    for i, (tag, c) in enumerate(rows):
        y = top + i * rowh
        w = bw * c / maxv
        p.append(f'<text x="{pad_l-10}" y="{y+15}" fill="#c9d1d9" font-size="13" text-anchor="end">{esc(tag)}</text>')
        p.append(f'<rect x="{pad_l}" y="{y+3}" width="{w:.1f}" height="18" rx="3" fill="url(#gt)" opacity="{0.55+0.45*c/maxv:.2f}"/>')
        p.append(f'<text x="{pad_l+w+8:.1f}" y="{y+17}" fill="#f0b90b" font-size="12.5">{c} 只</text>')
    p.append("</svg>")
    return "".join(p)


# ---------------- SVG 2: 行业涨跌 ----------------
def svg_industry():
    if not inds:
        return ('<svg viewBox="0 0 1000 80" width="100%" xmlns="http://www.w3.org/2000/svg" '
                'font-family="Microsoft YaHei,sans-serif">'
                '<text x="20" y="46" fill="#8b949e" font-size="14">行业板块数据暂未取到，请稍后刷新。</text></svg>')
    rows = ind_top[:12] + ind_bottom[:12]
    rows = sorted({r["code"]: r for r in rows}.values(), key=lambda r: r["chg"], reverse=True)
    # 列：0-150 行业名 | 155-245 涨跌家数 | 轴心 cx=420，正负各 160px 条 | 640+ 领涨股
    W, rowh, top = 1000, 28, 46
    cx, half = 420, 150
    H = top + rowh * len(rows) + 12
    maxa = max(abs(r["chg"]) for r in rows) or 1
    p = [f'<svg viewBox="0 0 {W} {H}" width="100%" xmlns="http://www.w3.org/2000/svg" '
         f'font-family="Consolas,Menlo,Microsoft YaHei,sans-serif">']
    p.append('<text x="0" y="14" fill="#c9d1d9" font-size="13.5">东财行业板块当日涨跌幅　'
             f'<tspan fill="{UP}">红=涨</tspan> <tspan fill="{DOWN}">绿=跌</tspan></text>')
    p.append(f'<text x="148" y="{top-8}" fill="#6e7681" font-size="11">涨/跌家数</text>')
    p.append(f'<text x="{cx}" y="{top-8}" fill="#6e7681" font-size="11" text-anchor="middle">涨跌幅</text>')
    p.append(f'<text x="640" y="{top-8}" fill="#6e7681" font-size="11">板块领涨股</text>')
    p.append(f'<line x1="{cx}" y1="{top-4}" x2="{cx}" y2="{H-6}" stroke="#3a434f" stroke-width="1"/>')
    for i, r in enumerate(rows):
        y = top + i * rowh
        v = r["chg"]
        w = max(half * abs(v) / maxa, 1.5)
        col = color(v)
        x = cx if v >= 0 else cx - w
        if i % 2 == 0:
            p.append(f'<rect x="0" y="{y}" width="{W}" height="{rowh-2}" fill="#ffffff" opacity="0.02"/>')
        p.append(f'<text x="0" y="{y+16}" fill="#c9d1d9" font-size="12.5">{esc(r["name"][:9])}</text>')
        if r.get("up") is not None:
            p.append(f'<text x="148" y="{y+16}" font-size="11.5">'
                     f'<tspan fill="{UP}">{r["up"]}</tspan><tspan fill="#4d5560"> / </tspan>'
                     f'<tspan fill="{DOWN}">{r["down"]}</tspan></text>')
        p.append(f'<rect x="{x:.1f}" y="{y+4}" width="{w:.1f}" height="15" rx="2" fill="{col}" opacity="0.85"/>')
        lx = cx + w + 7 if v >= 0 else cx - w - 7
        anchor = "start" if v >= 0 else "end"
        p.append(f'<text x="{lx:.1f}" y="{y+16}" fill="{col}" font-size="11.5" text-anchor="{anchor}">{v:+.2f}%</text>')
        ld = r.get("leader")
        if ld:
            p.append(f'<text x="640" y="{y+16}" fill="#8b949e" font-size="11.5">{esc(ld["name"][:8])} '
                     f'<tspan fill="{color(ld["change_pct"])}">{ld["change_pct"]:+.2f}%</tspan></text>')
    p.append("</svg>")
    return "".join(p)


# ---------------- SVG 3: 龙虎榜净买入排名 ----------------
def svg_lhb():
    if not lhb:
        return ('<svg viewBox="0 0 1000 80" width="100%" xmlns="http://www.w3.org/2000/svg" '
                'font-family="Microsoft YaHei,sans-serif">'
                '<text x="20" y="46" fill="#8b949e" font-size="14">'
                '龙虎榜尚未公布（交易所盘后 16:00 后发布，届时盘后日报自动补充）</text></svg>')
    tops = lhb[:15]
    bots = [x for x in lhb if x["net_buy"] < 0][-10:]
    rows = tops + list(reversed(bots))
    # 列：0-150 名称 | 155-215 代码 | 225-275 涨跌幅 | 轴心 cx=460，正 430 / 负 180
    W, rowh, top = 1000, 28, 46
    cx, halfp, halfn = 460, 430, 150
    H = top + rowh * len(rows) + 12
    maxa = max(abs(r["net_buy"]) for r in rows) or 1
    p = [f'<svg viewBox="0 0 {W} {H}" width="100%" xmlns="http://www.w3.org/2000/svg" '
         f'font-family="Consolas,Menlo,Microsoft YaHei,sans-serif">']
    p.append('<text x="0" y="14" fill="#c9d1d9" font-size="13.5">龙虎榜净买入 TOP15 / 净卖出 TOP10　'
             f'<tspan fill="{UP}">红=净买入</tspan> <tspan fill="{DOWN}">绿=净卖出</tspan></text>')
    p.append(f'<text x="265" y="{top-8}" fill="#6e7681" font-size="11" text-anchor="end">当日涨跌幅</text>')
    p.append(f'<text x="{cx+8}" y="{top-8}" fill="#6e7681" font-size="11">席位净买入额（亿元）</text>')
    p.append(f'<line x1="{cx}" y1="{top-4}" x2="{cx}" y2="{H-6}" stroke="#3a434f"/>')
    for i, r in enumerate(rows):
        y = top + i * rowh
        v = r["net_buy"]
        half = halfp if v >= 0 else halfn
        w = max(half * abs(v) / maxa, 1.5)
        col = UP if v >= 0 else DOWN
        x = cx if v >= 0 else cx - w
        if i % 2 == 0:
            p.append(f'<rect x="0" y="{y}" width="{W}" height="{rowh-2}" fill="#ffffff" opacity="0.02"/>')
        p.append(f'<text x="0" y="{y+16}" fill="#c9d1d9" font-size="12.5">{esc(r["name"][:6])}</text>')
        p.append(f'<text x="155" y="{y+16}" fill="#6e7681" font-size="11.5">{esc(r["code"])}</text>')
        p.append(f'<text x="265" y="{y+16}" fill="{color(r["change_pct"])}" font-size="11.5" text-anchor="end">{r["change_pct"]:+.2f}%</text>')
        p.append(f'<rect x="{x:.1f}" y="{y+4}" width="{w:.1f}" height="15" rx="2" fill="{col}" opacity="0.85"/>')
        lx = cx + w + 7 if v >= 0 else cx - w - 7
        anchor = "start" if v >= 0 else "end"
        p.append(f'<text x="{lx:.1f}" y="{y+16}" fill="{col}" font-size="11.5" text-anchor="{anchor}">{v/1e8:+.2f}</text>')
    p.append("</svg>")
    return "".join(p)


# ---------------- 表格 ----------------
def table_lhb():
    if not lhb:
        return '<tr><td colspan="11" style="text-align:center;padding:18px;color:#8b949e">龙虎榜尚未公布（盘后 16:00 后自动补充）</td></tr>'
    tr = []
    for i, r in enumerate(lhb, 1):
        tr.append(
            f'<tr><td class="num">{i}</td><td class="mono">{esc(r["code"])}</td>'
            f'<td class="nm">{esc(r["name"])}</td>'
            f'<td class="num mono">{r["close"]:.2f}</td>'
            f'<td class="num mono" style="color:{color(r["change_pct"])}">{r["change_pct"]:+.2f}%</td>'
            f'<td class="num mono" style="color:{UP if r["net_buy"]>=0 else DOWN}">{money(r["net_buy"])}</td>'
            f'<td class="num mono dim">{money(r["buy_amt"])}</td>'
            f'<td class="num mono dim">{money(r["sell_amt"])}</td>'
            f'<td class="num mono">{r["turnover_pct"]:.2f}%</td>'
            f'<td class="num mono dim">{money(r["accum_amount"])}</td>'
            f'<td class="rsn">{esc(" / ".join(r["reasons"]))}</td></tr>')
    return "".join(tr)




def card_lhb():
    """龙虎榜明细：横滑卡片行（每个股一张卡），移动端友好。"""
    if not lhb:
        return '<div style="padding:18px;text-align:center;color:#8b949e">龙虎榜尚未公布（交易所盘后 16:00 后发布，届时自动补充）</div>'
    cards = []
    for r in lhb:
        nc = UP if r["net_buy"] >= 0 else DOWN
        cards.append(
            '<div class="lhb-card">'
            '<div class="lc-top"><span class="lc-name">' + esc(r["name"]) + '</span>'
            '<span class="lc-code mono">' + esc(r["code"]) + '</span></div>'
            '<div class="lc-chg mono" style="color:' + color(r["change_pct"]) + '">' + format(r["change_pct"], "+.2f") + '%</div>'
            '<div class="lc-row"><span class="lc-k">净买入</span>'
            '<span class="lc-v mono" style="color:' + nc + '">' + money(r["net_buy"]) + '</span></div>'
            '<div class="lc-row dim"><span>买 ' + money(r["buy_amt"]) + '</span>'
            '<span>卖 ' + money(r["sell_amt"]) + '</span></div>'
            '<div class="lc-row dim">换手 ' + format(r["turnover_pct"], ".2f") + '%</div>'
            '<div class="lc-rsn">' + esc(" / ".join(r["reasons"])) + '</div>'
            '</div>'
        )
    return "".join(cards)

def table_industry(rows):
    tr = []
    for i, r in enumerate(rows, 1):
        ld = r.get("leader")
        lead = (f'{esc(ld["name"])} <span style="color:{color(ld["change_pct"])}">{ld["change_pct"]:+.2f}%</span>'
                if ld else '<span class="dim">—</span>')
        ud = (f'<span style="color:{UP}">{r["up"]}</span> / <span style="color:{DOWN}">{r["down"]}</span>'
              f' <span class="dim">/ {r["flat"]}</span>' if r.get("up") is not None else '<span class="dim">—</span>')
        tr.append(
            f'<tr><td class="num">{i}</td><td class="nm">{esc(r["name"])}</td>'
            f'<td class="num mono" style="color:{color(r["chg"])}">{r["chg"]:+.2f}%</td>'
            f'<td class="num mono">{ud}</td>'
            f'<td class="num mono dim">{money(r["amount"])}</td>'
            f'<td class="num mono dim">{r["turnover"]:.2f}%</td>'
            f'<td>{lead}</td></tr>')
    return "".join(tr)


def fchg(s):
    try:
        return float(s)
    except (TypeError, ValueError):
        return 0.0


def table_themes():
    tr = []
    for i, (tag, c) in enumerate(multi_tags[:20], 1):
        sts = tag_stocks[tag]
        chips = " ".join(
            f'<span class="chip" style="border-color:{color(fchg(s["change_pct"]))}44">'
            f'{esc(s["name"])}<em style="color:{color(fchg(s["change_pct"]))}">{fchg(s["change_pct"]):+.1f}%</em></span>'
            for s in sorted(sts, key=lambda x: -fchg(x["change_pct"])))
        tr.append(f'<tr><td class="num">{i}</td><td class="nm">{esc(tag)}</td>'
                  f'<td class="num mono" style="color:{UP}">{c}</td><td>{chips}</td></tr>')
    return "".join(tr)


def index_cards():
    out = []
    for nm, d in idx.items():
        out.append(f'<div class="ix"><span class="ixn">{esc(nm)}</span>'
                   f'<span class="ixv mono" style="color:{color(d["change_pct"])}">{d["close"]:.2f}</span>'
                   f'<span class="ixc mono" style="color:{color(d["change_pct"])}">{d["change_pct"]:+.2f}%</span></div>')
    return "".join(out)


top_theme = top_tags[0] if top_tags else ("—", 0)
lead_ind = inds[0] if inds else None
lag_ind = inds[-1] if inds else None
gen_time = datetime.now().strftime("%Y-%m-%d %H:%M")

# 安全预处理行业领涨/领跌展示，避免 lead_ind/lag_ind 为 None 时 f-string 强制求值崩溃
lead_name = esc(lead_ind["name"]) if lead_ind else "—"
lead_chg = f'{lead_ind["chg"]:+.2f}%' if lead_ind else "—"
lead_col = color(lead_ind["chg"]) if lead_ind else FLAT
lag_name = esc(lag_ind["name"]) if lag_ind else "—"
lag_chg = f'{lag_ind["chg"]:+.2f}%' if lag_ind else "—"
lag_col = color(lag_ind["chg"]) if lag_ind else FLAT

# ---------------- 模式相关：KPI 与龙虎榜区块 ----------------
if SHOW_LHB:
    kpi_html = f"""
<div class="kpi"><div class="k">龙虎榜上榜个股</div><div class="v">{len(lhb)} <span style="font-size:13px;color:var(--dim)">只</span></div>
<div class="s">共 {len(lhb_raw)} 条上榜记录｜涨 <span class="up">{n_up}</span> / 跌 <span class="down">{n_down}</span>｜涨停约 {n_limitup} 只</div></div>
<div class="kpi"><div class="k">龙虎榜净买入合计</div><div class="v" style="color:{UP if net_total>=0 else DOWN}">{money(net_total)}</div>
<div class="s">买入 {money(buy_total)}｜卖出 {money(sell_total)}｜净买 <span class="up">{n_netbuy}</span> 家 / 净卖 <span class="down">{n_netsell}</span> 家</div></div>
<div class="kpi"><div class="k">当日最热题材</div><div class="v" style="font-size:18px;color:var(--acc)">{esc(top_theme[0])}</div>
<div class="s">出现 {top_theme[1]} 只｜同花顺强势股 {len(hot)} 只、题材标签 {sum(tag_cnt.values())} 个（去重 {len(tag_cnt)}）</div></div>
<div class="kpi"><div class="k">领涨行业 / 领跌行业</div>
<div class="v" style="font-size:17px">{lead_name} <span style="color:{lead_col}">{lead_chg}</span></div>
<div class="s">领跌 {lag_name} <span class="down">{lag_chg}</span>｜{len(inds)} 个行业中涨 <span class="up">{n_ind_up}</span> / 跌 <span class="down">{n_ind_down}</span></div></div>
"""
else:
    n_ind_red = round(n_ind_up / len(inds) * 100, 1) if inds else 0
    kpi_html = f"""
<div class="kpi"><div class="k">同花顺强势股 / 题材数</div><div class="v">{len(hot)} <span style="font-size:13px;color:var(--dim)">只</span></div>
<div class="s">题材标签 {sum(tag_cnt.values())} 个（去重 {len(tag_cnt)}）｜出现≥2只题材 {len(multi_tags)} 个</div></div>
<div class="kpi"><div class="k">当日最热题材</div><div class="v" style="font-size:18px;color:var(--acc)">{esc(top_theme[0])}</div>
<div class="s">出现 {top_theme[1]} 只｜题材高度分散，缺乏主线共识</div></div>
<div class="kpi"><div class="k">领涨行业 / 领跌行业</div>
<div class="v" style="font-size:17px">{lead_name} <span style="color:{lead_col}">{lead_chg}</span></div>
<div class="s">领跌 {lag_name} <span class="down">{lag_chg}</span>｜{len(inds)} 个行业中涨 <span class="up">{n_ind_up}</span> / 跌 <span class="down">{n_ind_down}</span></div></div>
<div class="kpi"><div class="k">红盘行业占比</div><div class="v" style="color:{UP if n_ind_up >= n_ind_down else DOWN}">{n_ind_red}%</div>
<div class="s">{len(inds)} 个一级行业中 涨 <span class="up">{n_ind_up}</span> / 跌 <span class="down">{n_ind_down}</span> / 平 {len(inds) - n_ind_up - n_ind_down}</div></div>
"""

if SHOW_LHB:
    lhb_svg_section = f"""
<section>
<div class="hd"><h2>龙虎榜净买入排名</h2><div class="src">来源：东方财富每日龙虎榜详情（营业部席位买卖净额）</div></div>
<div class="bd">{svg_lhb()}</div>
</section>
"""
    lhb_table_section = f"""
<section>
<div class="hd"><h2>全市场龙虎榜明细（{len(lhb)} 只，按净买入降序）</h2><div class="src">来源：东方财富 RPT_DAILYBILLBOARD_DETAILSNEW｜{TRADE_DATE}</div></div>
<div class="scroll"><table><thead><tr><th>#</th><th>代码</th><th>名称</th><th class="num">收盘价</th><th class="num">涨跌幅</th>
<th class="num">龙虎榜净买入</th><th class="num">买入额</th><th class="num">卖出额</th><th class="num">换手率</th><th class="num">当日成交额</th><th>上榜原因</th></tr></thead>
<tbody>{table_lhb()}</tbody></table></div>
<div class="note">同一个股可能因多条规则重复上榜（当日共 {len(lhb_raw)} 条记录 / {len(lhb)} 只个股）；此处按个股去重，净买入取该股绝对值最大的一条记录，上榜原因合并展示，避免重复计入合计。</div>
</section>
"""
    lhb_note = ""
else:
    lhb_svg_section = ""
    lhb_table_section = ""
    lhb_note = f"""
<section>
<div class="hd"><h2>龙虎榜（盘后更新）</h2><div class="src">交易所盘后数据</div></div>
<div class="bd"><div class="note">龙虎榜为交易所<b>盘后</b>数据，交易日 <b>16:00</b> 后公布。当前为<b>盘中实时版</b>，仅含<b>题材热点 + 行业轮动</b>；盘后完整日报将自动补充龙虎榜净买入排名与全明细。</div></div>
</section>
"""

# ---------------- 页脚来源（按模式） ----------------
if SHOW_LHB:
    src_html = (f"① 龙虎榜 — 东方财富数据中心 每日龙虎榜详情（datacenter-web.eastmoney.com，reportName=RPT_DAILYBILLBOARD_DETAILSNEW）；<br>"
               f"② 题材热点 — 同花顺当日强势股题材归因（zx.10jqka.com.cn/event/api/getharden，reason 字段按「+」拆分词频）；<br>"
               f"③ 行业板块 — 东方财富行业板块列表（push2 clist，m:90+t:2）+ 各板块 {TRADE_DATE} 历史日K（push2his kline）；<br>"
               f"④ 行业成分股涨跌家数与领涨股 — 东方财富板块成分（clist b:BKxxxx）+ 腾讯财经历史日K（不复权收盘价计算当日涨跌幅）；<br>"
               f"⑤ 指数行情 — 腾讯财经历史日K。")
    gen_note = "数据为该日盘后最终值"
    caveat = (f"龙虎榜金额为交易所公布的营业部席位买卖金额，非个股全天资金流；行业涨跌家数基于东财板块成分股名单与腾讯历史收盘价计算，"
              f"成分股名单为最新快照，可能与 {TRADE_DATE} 当日成分略有出入；")
else:
    src_html = ("① 龙虎榜 — 交易所盘后数据，本实时版不含（盘后完整日报自动补充）；<br>"
               "② 题材热点 — 同花顺当日强势股题材归因（zx.10jqka.com.cn/event/api/getharden，reason 字段按「+」拆词频）；<br>"
               "③ 行业板块 — 东方财富行业板块实时行情（push2 clist 实时，m:90+t:1 一级行业），直接含涨跌幅 / 涨跌家数 / 领涨股；<br>"
               "⑤ 指数行情 — 东方财富实时行情（push2 stock/get）。")
    gen_note = "数据为盘中实时快照"
    caveat = ("行业涨跌家数与领涨股来自东财板块实时接口（f104/f105/f128），盘中随行情变动；本版不含龙虎榜（盘后数据）；")

# 自动刷新 JS（每60s 强制 reload + 保持滚动位置）。meta refresh 作兜底，JS 主导更可靠。


n_ind_red = round(n_ind_up / len(inds) * 100, 1) if inds else 0
theme_chips = " ".join(f'<span class="chip2">{esc(t)} <b>{c}</b></span>' for t, c in top_tags[:8])

TAB_JS = """
var TABS = ['overview','theme','industry','lhb','watch'];
window.currentTab = 'overview';
function switchTab(t){
  window.currentTab = t;
  document.querySelectorAll('.tab').forEach(function(x){ x.classList.toggle('active', x.getAttribute('data-tab')===t); });
  document.querySelectorAll('.tabpane').forEach(function(p){ p.classList.toggle('active', p.getAttribute('data-tab')===t); });
  var sc = document.getElementById('screen'); if (sc) sc.scrollTop = 0;
  try{ localStorage.setItem('sl_tab', t); }catch(e){}
}
document.querySelectorAll('.tab').forEach(function(b){
  b.addEventListener('click', function(){ switchTab(b.getAttribute('data-tab')); });
});
// 左右滑动切换 Tab
var _sx=0,_sy=0,_st=null;
var _screen=document.getElementById('screen');
if(_screen){
  _screen.addEventListener('touchstart',function(e){var t=e.changedTouches[0];_sx=t.clientX;_sy=t.clientY;_st=Date.now();},{passive:true});
  _screen.addEventListener('touchend',function(e){
    var t=e.changedTouches[0];var dx=t.clientX-_sx,dy=t.clientY-_sy;
    if(Date.now()-_st<600 && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)*1.5){
      var cur=TABS.findIndex(function(x){return document.querySelector('.tabpane.active')&&document.querySelector('.tabpane.active').getAttribute('data-tab')===x;});
      if(cur<0)cur=0;
      var nx = dx<0 ? Math.min(cur+1,TABS.length-1) : Math.max(cur-1,0);
      switchTab(TABS[nx]);
    }
  },{passive:true});
}
// 恢复上次停留的 Tab（避免 60s 整页刷新把用户弹回概览）
try{
  var _t = localStorage.getItem('sl_tab');
  if(_t && TABS.indexOf(_t) >= 0){ switchTab(_t); }
}catch(e){}
"""
WATCH_JS = """
function initWatch(){
  var listEl=document.getElementById('wlList');
  var input=document.getElementById('wlInput');
  var addBtn=document.getElementById('wlAddBtn');
  if(!listEl) return;
  // 轻量设备令牌：首次访问自动生成 64-hex 身份并存入 localStorage，后续所有请求携带
  var UID=localStorage.getItem('sl_uid');
  if(!UID||!/^[0-9a-f]{32,64}$/.test(UID)){
    var _b=new Uint8Array(32); (window.crypto||window.msCrypto).getRandomValues(_b);
    UID=Array.prototype.map.call(_b,function(x){return x.toString(16).padStart(2,'0');}).join('');
    localStorage.setItem('sl_uid',UID);
  }
  function api(path,opts){
    opts=opts||{}; opts.headers=opts.headers||{}; opts.headers['Authorization']='Bearer '+UID;
    return fetch(path,opts).then(function(r){ if(r.status===401){alert('身份无效，请刷新页面重新获取');throw new Error('AUTH');} return r; });
  }
  var wlCodes=[];
  function esc(s){return (s==null?'':String(s)).replace(/[&<>]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c];});}
  function fmt(n){return (n==null||isNaN(n))?'':Number(n).toFixed(2);}
  function col(v){return v>0?'var(--up)':(v<0?'var(--down)':'var(--dim)');}
  function load(){
    api('/api/watchlist').then(function(r){return r.json();}).then(function(wl){
      var codes=wl.codes||[]; wlCodes=codes;
      if(!codes.length){listEl.innerHTML='<div class="wl-empty">暂无自选股，添加一只开始跟踪</div>';return;}
      fetch('/api/quote?codes='+codes.join(',')).then(function(r){return r.json();}).then(function(qs){
        var map={}; qs.forEach(function(q){map[q.code]=q;});
        var html='';
        codes.forEach(function(code){
          var q=map[code]||{code:code,name:'—',price:0,change_pct:0};
          var cp=q.change_pct||0;
          html+='<div class="wl-row" data-code="'+code+'">'
            +'<div class="wl-head" data-code="'+code+'">'
            +'<div><span class="wl-name">'+esc(q.name||code)+'</span><span class="wl-code">'+code+'</span></div>'
            +'<div class="wl-right">'
            +'<span class="wl-px" style="color:'+col(cp)+'">'+fmt(q.price)+'</span>'
            +'<span class="wl-px" style="font-size:12px;color:'+col(cp)+'">'+(cp>0?'+':'')+fmt(cp)+'%</span>'
            +'<button class="wl-up" data-up="'+code+'" title="上移">▲</button>'
            +'<button class="wl-down" data-down="'+code+'" title="下移">▼</button>'
            +'<button class="wl-del" data-del="'+code+'">删除</button>'
            +'</div></div>'
            +'</div>';
        });
        listEl.innerHTML=html;
      }).catch(function(){listEl.innerHTML='<div class="wl-empty">行情获取失败</div>';});
    }).catch(function(){listEl.innerHTML='<div class="wl-empty">自选列表获取失败</div>';});
  }
  function reorder(code,dir){
    var arr=wlCodes.slice();
    var i=arr.indexOf(code); if(i<0) return;
    var j = dir==='up'? i-1 : i+1; if(j<0||j>=arr.length) return;
    arr[i]=arr[j]; arr[j]=code; wlCodes=arr;
    api('/api/watchlist/order',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({codes:arr})})
      .then(function(){ load(); }).catch(function(){alert('排序失败');});
  }
  listEl.addEventListener('click',function(e){
    var del=e.target.getAttribute('data-del');
    if(del){
      if(confirm('删除 '+del+' ？')){
        api('/api/watchlist?code='+encodeURIComponent(del),{method:'DELETE'})
          .then(function(){ load(); })
          .catch(function(){alert('删除失败');});
      }
      return;
    }
    var up=e.target.getAttribute('data-up');
    if(up){ reorder(up,'up'); return; }
    var down=e.target.getAttribute('data-down');
    if(down){ reorder(down,'down'); return; }
  });
  addBtn.addEventListener('click',function(){
    var v=input.value.trim(); if(!v) return;
    api('/api/watchlist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:v})})
      .then(function(r){ if(!r.ok){r.json().then(function(x){alert(x.error||'添加失败');}); return null;} input.value=''; load(); })
      .catch(function(){alert('添加失败，请检查网络');});
  });
  input.addEventListener('keydown',function(e){ if(e.key==='Enter') addBtn.click(); });
  setInterval(function(){
    if(window.currentTab!=='watch') return;
    api('/api/watchlist').then(function(r){return r.json();}).then(function(wl){
      fetch('/api/quote?codes='+(wl.codes||[]).join(',')).then(function(r){return r.json();}).then(function(qs){
        var map={}; qs.forEach(function(q){map[q.code]=q;});
        (wl.codes||[]).forEach(function(code){
          var q=map[code]; if(!q) return;
          var row=listEl.querySelector('.wl-row[data-code=\"'+code+'\"]'); if(!row) return;
          var cp=q.change_pct||0;
          var ps=row.querySelectorAll('.wl-px');
          if(ps[0]){ps[0].textContent=fmt(q.price); ps[0].style.color=col(cp);}
          if(ps[1]){ps[1].textContent=(cp>0?'+':'')+fmt(cp)+'%'; ps[1].style.color=col(cp);}
        });
      });
    });
  }, 10000);
  load();
}
initWatch();
"""
SCROLL_JS = """
<script>
(function(){
  var KEY='scroll_'+location.pathname;
  try{var y=sessionStorage.getItem(KEY); if(y){window.scrollTo(0,parseInt(y));}}catch(e){}
  window.addEventListener('beforeunload',function(){try{sessionStorage.setItem(KEY,window.scrollY);}catch(e){}});
  var sec=60, el=document.getElementById('rft');
  function tick(){
    if(window.currentTab==='watch'){ if(el) el.textContent='自选实时'; return; }
    if(el && sec>0){sec--; el.textContent=sec+'s';}
  }
  if(el){el.textContent=sec+'s';}
  setInterval(tick,1000);
  setTimeout(function(){
    // 自选股页为客户端实时拉取，跳过整页刷新；其余页整页刷新以更新烘焙数据
    if(window.currentTab!=='watch'){ location.reload(true); }
  }, 60000);
})();
</script>
"""

HTML = f"""<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="75">
<title>{'A股实时情绪' if MODE == 'intraday' else 'A股市场情绪日报'} {TRADE_DATE}｜{'盘中实时·题材+行业' if MODE == 'intraday' else '龙虎榜·题材热点·行业轮动'}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
:root{{--bg:#0b0e13;--panel:#12161d;--panel2:#161b22;--bd:#232a34;--tx:#c9d1d9;--dim:#7d8590;--up:{UP};--down:{DOWN};--acc:#f0b90b}}
body{{background:var(--bg);color:var(--tx);font-family:"Microsoft YaHei","PingFang SC",system-ui,sans-serif;font-size:13px;line-height:1.5;-webkit-text-size-adjust:100%}}
.mono{{font-family:Consolas,Menlo,monospace}}
.phone{{display:flex;flex-direction:column;width:430px;max-width:100%;height:94vh;max-height:920px;margin:14px auto;background:var(--bg);border:1px solid var(--bd);border-radius:30px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,.5)}}
@media(max-width:480px){{.phone{{width:100%;height:100vh;max-height:none;margin:0;border-radius:0;border:none}}}}
.appbar{{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;background:linear-gradient(180deg,#10151d,#0b0e13);border-bottom:1px solid var(--bd)}}
.app-title{{font-size:18px;font-weight:700;letter-spacing:.5px}}
.app-sub{{font-size:11px;color:var(--dim);margin-top:2px}}
.appbar-r{{display:flex;align-items:center;gap:10px}}
.mode-badge{{font-size:11px;padding:3px 9px;border-radius:20px;border:1px solid var(--bd)}}
.mode-badge.intra{{color:var(--acc);border-color:#4a3d12}}
.mode-badge.close{{color:#7ee0b8;border-color:#1d4a3a}}
.refresh{{width:34px;height:34px;border-radius:50%;border:1px solid var(--bd);background:var(--panel2);color:var(--tx);font-size:16px;cursor:pointer}}
.refresh:active{{background:var(--bd)}}
.screen{{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:10px}}
.tabpane{{display:none}}
.tabpane.active{{display:block}}
.ixbar{{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:10px}}
@media (max-width:380px){{.ixbar{{grid-template-columns:repeat(2,1fr)}}}}
.ix{{min-width:0;background:var(--panel);border:1px solid var(--bd);border-radius:12px;padding:9px 11px}}
.ixn{{display:block;color:var(--dim);font-size:11px}}
.ixv{{display:block;font-size:15px;font-weight:700;margin-top:3px}}
.ixc{{display:block;font-size:12px;margin-top:1px}}
.kpis{{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}}
.kpi{{background:var(--panel);border:1px solid var(--bd);border-left:3px solid var(--acc);border-radius:10px;padding:10px 12px}}
.kpi .k{{color:var(--dim);font-size:11px}}
.kpi .v{{font-size:19px;font-weight:700;margin-top:3px;font-family:Consolas,Menlo,monospace}}
.kpi .s{{color:var(--dim);font-size:10.5px;margin-top:2px;line-height:1.4}}
section{{background:var(--panel);border:1px solid var(--bd);border-radius:12px;margin-bottom:10px;overflow:hidden}}
.hd{{padding:9px 13px;border-bottom:1px solid var(--bd);background:var(--panel2);display:flex;align-items:center;justify-content:space-between;gap:8px}}
.hd h2{{font-size:13px;font-weight:600;display:flex;align-items:center;gap:7px}}
.hd h2::before{{content:"";width:3px;height:13px;background:var(--acc);display:inline-block;border-radius:2px}}
.hd .src{{color:var(--dim);font-size:10.5px}}
.bd{{padding:11px 13px}}
.mini{{display:flex;align-items:flex-start;gap:8px;padding:5px 0;font-size:12px}}
.mini-k{{flex:0 0 60px;color:var(--dim);font-size:11px;padding-top:1px}}
.chips{{display:flex;flex-wrap:wrap;gap:5px}}
.chip2{{display:inline-block;background:var(--panel2);border:1px solid var(--bd);border-radius:7px;padding:2px 8px;font-size:11px;color:#aab4c0}}
.chip2 b{{color:var(--acc);margin-left:3px;font-weight:700}}
.mv b,.mdim b{{font-weight:700}}
table{{width:100%;border-collapse:collapse;font-size:11.5px}}
th{{background:#0f141b;color:var(--dim);font-weight:600;text-align:left;padding:6px 7px;position:sticky;top:0;border-bottom:1px solid var(--bd);white-space:nowrap;font-size:11px}}
td{{padding:5px 7px;border-bottom:1px solid #1b212a;vertical-align:middle}}
tr:hover td{{background:#171d26}}
.num{{text-align:right}} .nm{{font-weight:600;white-space:nowrap}} .dim{{color:var(--dim)}}
.rsn{{color:#9aa4b1;font-size:10.8px;max-width:200px}}
.scroll{{max-height:520px;overflow:auto}}
.chip{{display:inline-block;border:1px solid #2b3542;border-radius:4px;padding:1px 5px;margin:1px 2px;font-size:10.5px;color:#aab4c0}}
.chip em{{font-style:normal;margin-left:4px;font-family:Consolas,monospace}}
.note{{color:var(--dim);font-size:10.8px;padding:8px 13px;border-top:1px dashed var(--bd);background:#0f141b;line-height:1.5}}
.cards-scroll{{display:flex;gap:10px;overflow-x:auto;padding-bottom:8px}}
.lhb-card{{flex:0 0 230px;background:var(--panel2);border:1px solid var(--bd);border-radius:12px;padding:11px}}
.lc-top{{display:flex;justify-content:space-between;align-items:baseline;gap:6px}}
.lc-name{{font-size:14px;font-weight:700}}
.lc-code{{font-size:11px;color:var(--dim)}}
.lc-chg{{font-size:16px;font-weight:700;margin:5px 0 8px}}
.lc-row{{display:flex;justify-content:space-between;font-size:11.5px;margin:3px 0}}
.lc-row.dim{{color:var(--dim)}}
.lc-k{{color:var(--dim)}}
.lc-v{{font-weight:700}}
.lc-rsn{{margin-top:8px;padding-top:7px;border-top:1px dashed var(--bd);color:#9aa4b1;font-size:10.5px;line-height:1.4}}
.tabbar{{flex:0 0 auto;display:flex;background:#0d1117;border-top:1px solid var(--bd)}}
.tab{{flex:1;background:none;border:none;color:var(--dim);font-size:12px;padding:11px 0 13px;cursor:pointer;font-family:inherit}}
.tab.active{{color:var(--acc);font-weight:600}}
.tab.active::after{{content:"";display:block;width:22px;height:3px;background:var(--acc);border-radius:2px;margin:5px auto 0}}
.up{{color:var(--up)}} .down{{color:var(--down)}}
footer.src-foot{{flex:0 0 auto;border-top:1px solid var(--bd);background:#0b0e13;color:var(--dim);font-size:10.5px;line-height:1.4}}
footer.src-foot summary{{list-style:none;cursor:pointer;padding:6px 14px;color:var(--dim);font-size:10.5px;user-select:none;-webkit-tap-highlight-color:transparent}}
footer.src-foot summary::-webkit-details-marker{{display:none}}
footer.src-foot summary::before{{content:"▸";color:var(--acc);margin-right:5px}}
footer.src-foot details[open] summary::before{{content:"▾"}}
footer.src-foot .src-body{{padding:0 14px 9px;line-height:1.7}}
footer.src-foot b{{color:#a8b3bf;font-weight:600}}
.wl-add{{display:flex;gap:8px;margin-bottom:10px}}
.wl-add input{{flex:1;min-width:0;background:var(--panel);border:1px solid var(--bd);border-radius:10px;padding:9px 11px;color:var(--tx);font-size:13px;font-family:inherit}}
.wl-add input:focus{{outline:none;border-color:var(--acc)}}
.wl-add button{{background:var(--acc);border:none;border-radius:10px;color:#1a1a1a;font-weight:500;padding:0 16px;cursor:pointer;font-size:13px;font-family:inherit}}
.wl-add button:active{{opacity:.8}}
.wl-empty{{color:var(--dim);font-size:12px;padding:16px 4px;text-align:center}}
.wl-row{{background:var(--panel);border:1px solid var(--bd);border-radius:12px;margin-bottom:8px;overflow:hidden}}
.wl-head{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;cursor:pointer}}
.wl-name{{font-size:14px;font-weight:500}}
.wl-code{{font-size:11px;color:var(--dim);margin-left:6px}}
.wl-right{{display:flex;align-items:center;gap:10px}}
.wl-px{{font-size:15px;font-weight:500;font-family:Consolas,monospace}}
.wl-del{{background:none;border:none;color:var(--dim);font-size:12px;cursor:pointer;padding:4px 6px;font-family:inherit}}
.wl-del:active{{color:var(--down)}}
.wl-up,.wl-down{{background:none;border:none;color:var(--dim);font-size:13px;cursor:pointer;padding:4px 6px;line-height:1;font-family:inherit}}
.wl-up:active,.wl-down:active{{color:var(--acc)}}
</style></head><body>
<div class="phone">
<header class="appbar">
  <div class="appbar-l">
    <div class="app-title">A股情绪</div>
    <div class="app-sub">更新于 {gen_time} · <span id="rft" class="mono">60s</span> 后自动刷新</div>
  </div>
  <div class="appbar-r">
    <span class="mode-badge {'intra' if MODE=='intraday' else 'close'}">{'盘中实时' if MODE=='intraday' else '盘后完整'}</span>
    <button class="refresh" id="refreshBtn" onclick="location.reload(true)" title="立即刷新">↻</button>
  </div>
</header>
<main class="screen" id="screen">

<section class="tabpane active" data-tab="overview">
  <div class="ixbar">{index_cards()}</div>
  <div class="kpis">{kpi_html}</div>
  <section>
    <div class="hd"><h2>今日速览</h2><div class="src">最热题材与行业强弱</div></div>
    <div class="bd">
      <div class="mini"><span class="mini-k">最热题材</span><div class="chips">{theme_chips}</div></div>
      <div class="mini"><span class="mini-k">领涨行业</span><span class="mv">{lead_name} <b style="color:{lead_col}">{lead_chg}</b></span><span class="mdim">｜领跌 {lag_name} <b class="down">{lag_chg}</b></span></div>
      <div class="mini"><span class="mini-k">行业强弱</span><span class="mdim">涨 <b class="up">{n_ind_up}</b> / 跌 <b class="down">{n_ind_down}</b> ｜红盘占比 {n_ind_red}%</span></div>
    </div>
  </section>
</section>

<section class="tabpane" data-tab="theme">
  <section>
    <div class="hd"><h2>题材热度 TOP15（词频）</h2><div class="src">同花顺当日强势股归因 reason 标签，按「+」拆分统计</div></div>
    <div class="bd">{svg_themes()}</div>
    <div class="note">当日同花顺强势股 {len(hot)} 只，共 {sum(tag_cnt.values())} 个题材标签、去重后 {len(tag_cnt)} 个；其中出现 ≥2 只的题材 {len(multi_tags)} 个——题材{'高度分散，缺乏主线共识' if len(multi_tags)<=3 else '有一定集中度'}。</div>
  </section>
  <section>
    <div class="hd"><h2>热门题材成分股（出现 ≥2 只）</h2><div class="src">同花顺当日强势股题材归因</div></div>
    <div class="scroll"><table><thead><tr><th>#</th><th>题材</th><th class="num">只数</th><th>涉及个股（当日涨幅）</th></tr></thead><tbody>{table_themes()}</tbody></table></div>
  </section>
</section>

<section class="tabpane" data-tab="industry">
  <section>
    <div class="hd"><h2>行业涨跌榜（前12 / 后12）</h2><div class="src">来源：东方财富行业板块{'实时行情' if MODE=='intraday' else TRADE_DATE+' 日K'}（共 {len(inds)} 个板块）</div></div>
    <div class="bd">{svg_industry()}</div>
  </section>
  <section>
    <div class="hd"><h2>行业涨幅 TOP15</h2><div class="src">东财行业板块</div></div>
    <div class="scroll"><table><thead><tr><th>#</th><th>行业</th><th class="num">涨跌幅</th><th class="num">涨/跌/平</th><th class="num">成交额</th><th class="num">换手</th><th>领涨股</th></tr></thead><tbody>{table_industry(ind_top)}</tbody></table></div>
  </section>
  <section>
    <div class="hd"><h2>行业跌幅 TOP15</h2><div class="src">东财行业板块</div></div>
    <div class="scroll"><table><thead><tr><th>#</th><th>行业</th><th class="num">涨跌幅</th><th class="num">涨/跌/平</th><th class="num">成交额</th><th class="num">换手</th><th>领涨股</th></tr></thead><tbody>{table_industry(ind_bottom)}</tbody></table></div>
  </section>
</section>

<section class="tabpane" data-tab="lhb">
  {lhb_svg_section or lhb_note}
  <section>
    <div class="hd"><h2>全市场龙虎榜明细（{len(lhb)} 只，按净买入降序）</h2><div class="src">来源：东方财富 RPT_DAILYBILLBOARD_DETAILSNEW｜{TRADE_DATE}</div></div>
    <div class="bd">
      <div class="cards-scroll">{card_lhb()}</div>
      <div class="note">同一个股可能因多条规则重复上榜（当日共 {len(lhb_raw)} 条记录 / {len(lhb)} 只个股）；此处按个股去重，净买入取该股绝对值最大的一条记录，上榜原因合并展示，避免重复计入合计。</div>
    </div>
  </section>
</section>

<section class="tabpane" data-tab="watch">
  <div class="wl-add">
    <input id="wlInput" placeholder="代码，如 600000 / sh600000" />
    <button id="wlAddBtn">添加</button>
  </div>
  <div id="wlList"></div>
</section>

</main>
<footer class="src-foot">
<details>
  <summary>数据来源与口径</summary>
  <div class="src-body">
    <b>数据来源</b>：{src_html}<br>
    <b>交易日</b>：{TRADE_DATE}（{gen_note}）　|　<b>生成</b>：{gen_time}<br>
    <b>口径</b>：{caveat}本报告仅作数据复盘，不构成投资建议。
  </div>
</details>
</footer>
<nav class="tabbar">
  <button class="tab active" data-tab="overview">概览</button>
  <button class="tab" data-tab="theme">题材</button>
  <button class="tab" data-tab="industry">行业</button>
  <button class="tab" data-tab="lhb">龙虎榜</button>
  <button class="tab" data-tab="watch">自选</button>
</nav>
</div>
<script>{TAB_JS}</script>
<script>{WATCH_JS}</script>
{SCROLL_JS}</body></html>
"""

OUTFILE.write_text(HTML, encoding="utf-8")
print(f"生成: {OUTFILE}  ({len(HTML)/1024:.0f} KB)")
print(f"龙虎榜 {len(lhb)} 只 / {len(lhb_raw)} 条, 净买入合计 {money(net_total)}")
print(f"最热题材 {top_theme}, 行业 {len(inds)} 个, 领涨 {lead_ind['name'] if lead_ind else '-'} {lead_ind['chg'] if lead_ind else ''}%")
