#!/usr/bin/env python3
"""Polymarket BTC 5m 市场 —— 逐笔成交聚合 OHLC 蜡烛图，输出自包含 HTML。

数据全部来自官方公开接口（无需 key）：
  gamma-api.polymarket.com/events?slug=btc-updown-5m-<ts>   市场元数据
  data-api.polymarket.com/v2/trades?condition=0x...          逐笔成交
  data-api.polymarket.com/v2/prices-history?token_id=...     分钟级价格（无成交时兜底）

用法:
  python chart_btc5m.py                      # 最近 2 小时 -> charts/btc5m_2h.html
  python chart_btc5m.py --hours 6            # 最近 6 小时
  python chart_btc5m.py --no-proxy           # 直连（境外机器）
  python chart_btc5m.py --asset eth          # ETH 5m 市场
"""
from __future__ import annotations

import argparse
import json
import os
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone

ROOT = os.path.dirname(os.path.abspath(__file__))
GAMMA = "https://gamma-api.polymarket.com"
DATA_API = "https://data-api.polymarket.com"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
STEP = 300  # 5 分钟一个市场

_opener = None


def set_proxy(proxy: str | None) -> None:
    global _opener
    if proxy:
        h = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
        _opener = urllib.request.build_opener(h)
    else:
        _opener = urllib.request.build_opener()


def get_json(url: str, tries: int = 4, timeout: int = 30):
    last = None
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
            with _opener.open(req, timeout=timeout) as r:
                return json.loads(r.read().decode("utf-8"))
        except Exception as e:  # noqa: BLE001 - 网络抖动一律重试
            last = e
            if i < tries - 1:
                time.sleep(1.2 * (i + 1))
    raise RuntimeError(f"net fail: {last} :: {url[:140]}")


def fetch_window(ts: int, asset: str) -> dict | None:
    """抓一个 5 分钟窗口：元数据 + 逐笔成交。"""
    slug = f"{asset}-updown-5m-{ts}"
    try:
        ev = get_json(f"{GAMMA}/events?slug={slug}")
    except RuntimeError as e:
        print("  skip", slug, e)
        return None
    if not ev:
        return None
    e = ev[0]
    m = e["markets"][0]
    cond = m["conditionId"]
    outcomes = json.loads(m["outcomes"]) if isinstance(m["outcomes"], str) else m["outcomes"]
    tokens = json.loads(m["clobTokenIds"]) if isinstance(m["clobTokenIds"], str) else m["clobTokenIds"]

    trades = []
    cursor = None
    for _ in range(6):  # 一个 5 分钟窗口极少超过 6 页
        url = (f"{DATA_API}/v2/trades?condition={cond}&start={ts}&end={ts + STEP}"
               f"&limit=1000&taker_only=true")
        if cursor:
            url += f"&cursor={cursor}"
        try:
            d = get_json(url)
        except RuntimeError as err:
            print("  trades fail", slug, err)
            break
        trades.extend(d.get("data") or [])
        pag = d.get("pagination") or {}
        cursor = pag.get("next_cursor")
        if not cursor:
            break

    by_out: dict[str, list] = {o: [] for o in outcomes}
    for t in trades:
        o = t.get("outcome")
        if o in by_out:
            by_out[o].append({
                "t": int(t["timestamp"]),
                "p": round(float(t["price"]), 4),
                "s": round(float(t["size"]), 2),
                "side": t.get("side"),
            })
    for v in by_out.values():
        v.sort(key=lambda x: x["t"])

    closed = bool(m.get("closed")) or ts + STEP <= int(time.time())
    return {
        "ts": ts,
        "end": ts + STEP,
        "slug": slug,
        "title": m.get("question") or e.get("title"),
        "conditionId": cond,
        "outcomes": outcomes,
        "tokens": tokens,
        "closed": closed,
        "trades": by_out,
        "n_trades": len(trades),
        "volume": round(sum(float(t.get("size") or 0) for t in trades), 2),
    }


def fetch_prices(token: str, ts: int, now: int) -> list:
    """兜底：分钟级价格点（无成交的窗口用来画参考线）。"""
    end = min(ts + STEP, now)
    if end <= ts:
        return []
    try:
        d = get_json(f"{DATA_API}/v2/prices-history?token_id={token}&start={ts}&end={end}&bucket_seconds=60")
        return [[int(p["timestamp"]), float(p["price"])] for p in d.get("data") or []]
    except RuntimeError:
        return []


def build(args) -> dict:
    now = int(time.time())
    end = now - now % STEP
    start = end - int(args.hours * 3600)
    stamps = list(range(start, end + STEP, STEP))  # 含当前进行中的窗口
    print(f"window range: {datetime.fromtimestamp(start, timezone.utc):%Y-%m-%d %H:%M}Z .. "
          f"{datetime.fromtimestamp(end, timezone.utc):%H:%M}Z  ({len(stamps)} windows)")

    out = []
    with ThreadPoolExecutor(max_workers=args.workers) as pool:
        for i, w in enumerate(pool.map(lambda s: fetch_window(s, args.asset), stamps), 1):
            if w:
                out.append(w)
                print(f"  [{i}/{len(stamps)}] {w['slug']} trades={w['n_trades']} vol={w['volume']}")

    # 无成交窗口补价格点
    missing = 0
    for w in out:
        if w["n_trades"] == 0:
            idx = w["outcomes"].index("Up") if "Up" in w["outcomes"] else 0
            w["ph"] = fetch_prices(w["tokens"][idx], w["ts"], now)
            missing += 1
    print(f"done: {len(out)} windows, {missing} empty -> fallback price points")

    return {
        "generated": now,
        "hours": args.hours,
        "asset": args.asset,
        "step": STEP,
        "windows": out,
    }


def render(data: dict, out_path: str) -> None:
    payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
    html = HTML.replace("__DATA__", payload)
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    with open(out_path, "w", encoding="utf-8", newline="\n") as f:
        f.write(html)
    print("wrote", out_path, f"({os.path.getsize(out_path)/1024:.1f} KB)")


HTML = r"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Polymarket · BTC 5m 成交蜡烛</title>
<style>
  :root{
    --bg:#0b1220; --panel:#111a2e; --line:#1e293b; --grid:#1b2740;
    --fg:#e2e8f0; --dim:#7c8aa5; --up:#22c55e; --dn:#ef4444; --acc:#38bdf8;
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--fg);
       font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}
  .wrap{max-width:1280px;margin:0 auto;padding:18px 20px 40px}
  h1{font-size:17px;font-weight:600;margin:0 0 4px}
  .sub{color:var(--dim);font-size:12px}
  .sub code{background:#0e1728;border:1px solid var(--line);border-radius:4px;padding:1px 5px}
  .cards{display:flex;gap:10px;flex-wrap:wrap;margin:14px 0 12px}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
        padding:8px 12px;min-width:104px}
  .card .k{color:var(--dim);font-size:11px}
  .card .v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
  .bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
  .seg{display:flex;background:var(--panel);border:1px solid var(--line);border-radius:7px;overflow:hidden}
  .seg button{background:none;border:0;color:var(--dim);padding:6px 12px;cursor:pointer;font:inherit}
  .seg button.on{background:#1d2b47;color:var(--fg)}
  .seg button+button{border-left:1px solid var(--line)}
  .legend{margin-left:auto;display:flex;gap:14px;color:var(--dim);font-size:12px}
  .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}
  .chartbox{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:6px}
  canvas{display:block}
  .tip{position:absolute;pointer-events:none;opacity:0;transition:opacity .08s;
       background:#0a1526f2;border:1px solid #2b3c5c;border-radius:7px;padding:7px 10px;
       font-size:12px;white-space:nowrap;z-index:5;box-shadow:0 8px 24px #0008}
  .tip b{color:#fff}
  .tip .r{display:flex;justify-content:space-between;gap:14px;color:var(--dim)}
  .tip .r span:last-child{color:var(--fg);font-variant-numeric:tabular-nums}
  footer{margin-top:14px;color:var(--dim);font-size:11px;line-height:1.7}
</style>
</head>
<body>
<div class="wrap">
  <h1>Polymarket · BTC 5m 市场成交蜡烛</h1>
  <div class="sub">逐笔成交聚合 OHLC · 数据源 <code>data-api.polymarket.com/v2/trades</code> · 无需 API Key</div>

  <div class="cards" id="cards"></div>

  <div class="bar">
    <div class="seg" id="rangeSeg">
      <button data-r="15">15m</button>
      <button data-r="30" class="on">30m</button>
      <button data-r="60">1h</button>
      <button data-r="120">2h</button>
    </div>
    <div class="seg" id="viewSeg">
      <button data-v="5" class="on">5s</button>
      <button data-v="10">10s</button>
      <button data-v="30">30s</button>
      <button data-v="60">1m</button>
      <button data-v="300">5m</button>
    </div>
    <div class="seg" id="sideSeg">
      <button data-s="Up" class="on">Up</button>
      <button data-s="Down">Down</button>
    </div>
    <div class="legend">
      <span><i style="background:var(--up)"></i>收 ≥ 开</span>
      <span><i style="background:var(--dn)"></i>收 &lt; 开</span>
      <span><i style="background:#475569"></i>无成交</span>
    </div>
  </div>

  <div class="chartbox">
    <canvas id="cv"></canvas>
    <div class="tip" id="tip"></div>
  </div>

  <footer id="foot"></footer>
</div>

<script>
const DATA = __DATA__;
const STEP = DATA.step || 300;
const CV = document.getElementById('cv');
const CTX = CV.getContext('2d');
const TIP = document.getElementById('tip');
const state = { bucket: 5, rangeMin: 30, side: 'Up', hover: -1 };

/* ---------- 聚合 ---------- */
function agg(list, bucketStart, bucketSec) {
  if (!list || !list.length) return null;
  const o = list[0].p, c = list[list.length - 1].p;
  let hi = o, lo = o, v = 0;
  for (const t of list) { if (t.p > hi) hi = t.p; if (t.p < lo) lo = t.p; v += t.s; }
  return { t: bucketStart, o, h: hi, l: lo, c, v, n: list.length };
}

function buildCandles() {
  const B = state.bucket;
  const perWin = Math.max(1, Math.round(STEP / B));
  const cut = DATA.generated - state.rangeMin * 60;
  const rows = [];
  let win = 0;
  for (const w of DATA.windows) {
    if (w.end <= cut) continue;
    const list = (w.trades && w.trades[state.side]) || [];
    for (let i = 0; i < perWin; i++) {
      const a = w.ts + i * B, b = a + B;
      const part = list.filter(x => x.t >= a && x.t < b);
      rows.push({
        k: agg(part, a, B), w: w, label: a, empty: !part.length,
        ph: w.ph, win: win, first: i === 0,
      });
    }
    win++;
  }
  return rows;
}

/* ---------- 绘制 ---------- */
const PAD = { l: 52, r: 16, t: 14, b: 56 };
let W = 0, H = 0, ROWS = [], GEO = {};

function resize() {
  const box = CV.parentElement.getBoundingClientRect();
  W = Math.max(560, Math.floor(box.width - 12));
  H = 520;
  const dpr = window.devicePixelRatio || 1;
  CV.width = W * dpr; CV.height = H * dpr;
  CV.style.width = W + 'px'; CV.style.height = H + 'px';
  CTX.setTransform(dpr, 0, 0, dpr, 0, 0);
  draw();
}

function yOf(p) { return GEO.plotBot - p * GEO.plotH; }

function draw() {
  ROWS = buildCandles();
  const volH = 84;
  GEO = {
    plotTop: PAD.t,
    plotH: H - PAD.t - PAD.b - volH,
    plotBot: H - PAD.b - volH,
    volTop: H - PAD.b - volH + 12,
    volBot: H - PAD.b - 8,
  };
  const slotW = (W - PAD.l - PAD.r) / Math.max(1, ROWS.length);
  GEO.slotW = slotW;

  CTX.clearRect(0, 0, W, H);
  CTX.fillStyle = '#111a2e'; CTX.fillRect(0, 0, W, H);

  // 水平网格 + y 轴
  CTX.font = '11px ui-monospace,Consolas,monospace';
  CTX.textBaseline = 'middle';
  for (let i = 0; i <= 4; i++) {
    const p = i / 4, y = yOf(p);
    CTX.strokeStyle = '#1b2740'; CTX.lineWidth = 1;
    CTX.beginPath(); CTX.moveTo(PAD.l, y); CTX.lineTo(W - PAD.r, y); CTX.stroke();
    CTX.fillStyle = '#7c8aa5'; CTX.textAlign = 'right';
    CTX.fillText(p.toFixed(2), PAD.l - 8, y);
  }

  // 5 分钟市场背景交替 + 分隔线
  const perWin = Math.max(1, Math.round(STEP / state.bucket));
  for (let i = 0; i < ROWS.length; i += perWin) {
    const x0 = PAD.l + i * slotW;
    const wi = Math.floor(i / perWin);
    if (wi % 2 === 0) {
      CTX.fillStyle = 'rgba(255,255,255,0.022)';
      CTX.fillRect(x0, GEO.plotTop, slotW * perWin, GEO.plotBot - GEO.plotTop);
    }
    CTX.strokeStyle = 'rgba(56,189,248,0.20)';
    CTX.beginPath(); CTX.moveTo(x0, GEO.plotTop); CTX.lineTo(x0, GEO.volBot); CTX.stroke();
  }

  // 成交量
  let vMax = 0;
  for (const r of ROWS) if (r.k && r.k.v > vMax) vMax = r.k.v;
  vMax = vMax || 1;

  // 蜡烛
  const bw = Math.max(1.5, Math.min(16, slotW * 0.66));
  ROWS.forEach((r, i) => {
    const x = PAD.l + (i + 0.5) * slotW;
    if (state.hover === i) {
      CTX.fillStyle = 'rgba(56,189,248,0.10)';
      CTX.fillRect(x - slotW / 2, GEO.plotTop, slotW, GEO.plotBot - GEO.plotTop);
    }
    if (!r.k) {
      // 无成交：用价格点兜底画灰点
      if (r.ph && r.ph.length) {
        CTX.fillStyle = '#475569';
        for (const [t, p] of r.ph) {
          if (t < r.w.ts || t >= r.w.ts + STEP) continue;
          CTX.beginPath(); CTX.arc(x, yOf(p), 1.6, 0, Math.PI * 2); CTX.fill();
        }
      } else {
        CTX.fillStyle = '#475569';
        CTX.fillRect(x - bw / 2, yOf(0.5) - 1, bw, 2);
      }
      return;
    }
    const k = r.k;
    const up = k.c >= k.o;
    const col = up ? '#22c55e' : '#ef4444';
    CTX.strokeStyle = col; CTX.fillStyle = col; CTX.lineWidth = 1;
    // 影线
    CTX.beginPath();
    CTX.moveTo(x, yOf(k.h)); CTX.lineTo(x, yOf(k.l)); CTX.stroke();
    // 实体
    const yO = yOf(k.o), yC = yOf(k.c);
    const top = Math.min(yO, yC), hh = Math.max(1.4, Math.abs(yC - yO));
    CTX.fillRect(x - bw / 2, top, bw, hh);
    // 成交量
    if (k.v > 0) {
      const vh = (k.v / vMax) * (GEO.volBot - GEO.volTop);
      CTX.fillStyle = up ? 'rgba(34,197,94,0.42)' : 'rgba(239,68,68,0.42)';
      CTX.fillRect(x - bw / 2, GEO.volBot - vh, bw, vh);
    }
  });

  // x 轴标签
  CTX.fillStyle = '#7c8aa5'; CTX.textAlign = 'center'; CTX.textBaseline = 'top';
  const nWins = Math.max(1, Math.ceil(ROWS.length / perWin));
  const stride = Math.max(1, Math.ceil(nWins / 14));
  for (let w = 0; w < nWins; w += stride) {
    const i = w * perWin;
    if (i >= ROWS.length) break;
    const x = PAD.l + (i + 0.5) * slotW;
    const d = new Date(ROWS[i].label * 1000);
    CTX.fillText(d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }),
      x, GEO.volBot + 8);
  }
  CTX.textAlign = 'left';
  CTX.fillStyle = '#4b5c78';
  CTX.fillText(`每根 = ${state.bucket}s · 每 ${perWin} 根 = 1 个 5 分钟市场 · 共 ${ROWS.length} 根`,
    PAD.l, GEO.volBot + 26);
  if (slotW < 2) {
    CTX.fillStyle = '#f59e0b';
    CTX.fillText('⚠ 蜡烛过密，缩短时间范围或加大周期', PAD.l + 330, GEO.volBot + 26);
  }
  CTX.textAlign = 'right';
  CTX.fillStyle = '#4b5c78';
  CTX.fillText('volume (shares)', W - PAD.r, GEO.volTop - 10);
}

/* ---------- 交互 ---------- */
function showTip(ev) {
  const rect = CV.getBoundingClientRect();
  const mx = ev.clientX - rect.left;
  const idx = Math.floor((mx - PAD.l) / GEO.slotW);
  if (idx < 0 || idx >= ROWS.length) { state.hover = -1; TIP.style.opacity = 0; draw(); return; }
  if (state.hover !== idx) { state.hover = idx; draw(); }
  const r = ROWS[idx], w = r.w;
  const et = new Date(w.ts * 1000).toLocaleTimeString('en-US',
    { timeZone: 'America/New_York', hour12: false, hour: '2-digit', minute: '2-digit' });
  const lt = new Date((r.k ? r.k.t : w.ts) * 1000).toLocaleTimeString('zh-CN',
    { hour12: false, hour: '2-digit', minute: '2-digit' });
  const span = state.bucket >= 60
    ? `${state.bucket / 60}m` : `${state.bucket}s`;
  let rows = `<div style="color:#8ba0c0;margin-bottom:4px">${lt} 本地 · ${et} ET · ${span} 桶</div>`;
  if (r.k) {
    const k = r.k;
    rows += `<div class="r"><span>开</span><span>${k.o.toFixed(3)}</span></div>
             <div class="r"><span>高</span><span>${k.h.toFixed(3)}</span></div>
             <div class="r"><span>低</span><span>${k.l.toFixed(3)}</span></div>
             <div class="r"><span>收</span><span>${k.c.toFixed(3)}</span></div>
             <div class="r"><span>量</span><span>${k.v.toFixed(0)}</span></div>
             <div class="r"><span>笔数</span><span>${k.n}</span></div>`;
  } else {
    rows += `<div class="r"><span>本档无成交</span><span>—</span></div>`;
  }
  rows += `<div style="color:#8ba0c0;margin-top:4px">${w.title}</div>`;
  TIP.innerHTML = rows;
  TIP.style.opacity = 1;
  const tw = TIP.offsetWidth;
  let left = mx + 16;
  if (left + tw > W - 8) left = mx - tw - 16;
  TIP.style.left = left + 'px';
  TIP.style.top = (ev.clientY - rect.top + 12) + 'px';
}

CV.addEventListener('mousemove', showTip);
CV.addEventListener('mouseleave', () => { state.hover = -1; TIP.style.opacity = 0; draw(); });

function resetView(mut) {
  mut();
  state.hover = -1;
  TIP.style.opacity = 0;
  draw();
}
document.querySelectorAll('#viewSeg button').forEach(b => b.onclick = () => resetView(() => {
  document.querySelectorAll('#viewSeg button').forEach(x => x.classList.toggle('on', x === b));
  state.bucket = parseInt(b.dataset.v, 10);
}));
document.querySelectorAll('#rangeSeg button').forEach(b => b.onclick = () => resetView(() => {
  document.querySelectorAll('#rangeSeg button').forEach(x => x.classList.toggle('on', x === b));
  state.rangeMin = parseInt(b.dataset.r, 10);
}));
document.querySelectorAll('#sideSeg button').forEach(b => b.onclick = () => resetView(() => {
  document.querySelectorAll('#sideSeg button').forEach(x => x.classList.toggle('on', x === b));
  state.side = b.dataset.s;
}));
window.addEventListener('resize', resize);

/* ---------- 顶部卡片 ---------- */
(function cards() {
  const ws = DATA.windows;
  const nT = ws.reduce((a, w) => a + w.n_trades, 0);
  const nV = ws.reduce((a, w) => a + w.volume, 0);
  const lastT = ws.length ? ws[ws.length - 1].ts : 0;
  const upWin = ws.filter(w => {
    const l = (w.trades && w.trades.Up) || [];
    if (!l.length) return false;
    const first = DATA.windows[0] && DATA.windows[0].ts;
    return l[l.length - 1].p >= 0.5;
  }).length;
  const fmt = t => new Date(t * 1000).toLocaleString('zh-CN', { hour12: false });
  document.getElementById('cards').innerHTML = [
    ['窗口数', ws.length],
    ['成交笔数', nT],
    ['成交量(shares)', nV.toFixed(0)],
    ['Up 收 ≥0.5', upWin + ' / ' + ws.length],
    ['生成时间', fmt(DATA.generated)],
    ['起始', fmt(ws.length ? ws[0].ts : 0)],
  ].map(([k, v]) => `<div class="card"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
  document.getElementById('foot').innerHTML =
    `数据源：<code>gamma-api.polymarket.com/events</code> + <code>data-api.polymarket.com/v2/trades</code>（逐笔，taker_only）<br>` +
    `OHLC 由桶内成交价聚合；无成交的桶留空。<code>/v2/prices-history</code> 仅作兜底（灰点）。<br>` +
    `覆盖 ${DATA.asset.toUpperCase()} 5m 市场最近 ${DATA.hours} 小时；最后一档可能仍在进行中。`;
})();

resize();
</script>
</body>
</html>
"""


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--hours", type=float, default=2)
    ap.add_argument("--asset", default="btc", choices=["btc", "eth"])
    ap.add_argument("--workers", type=int, default=8)
    ap.add_argument("--out", default="")
    ap.add_argument("--proxy", default=os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or "http://127.0.0.1:10808")
    ap.add_argument("--no-proxy", action="store_true")
    args = ap.parse_args()

    set_proxy(None if args.no_proxy else args.proxy)
    out = args.out or os.path.join(ROOT, "charts", f"{args.asset}5m_{int(args.hours)}h.html")
    data = build(args)
    render(data, out)


if __name__ == "__main__":
    main()
