Files
market_intel/web/dist/index.html
T
2026-08-01 10:33:19 +08:00

344 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>比特骆驼行情采集分析</title>
<style>
:root {
--bg: #0c1117; --panel: #151b24; --text: #e8eef5; --muted: #8b9aab;
--accent: #3d8fd1; --ok: #3ecf8e; --warn: #e6a23c; --bad: #e85d5d;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, #1a2a3d 0%, transparent 55%), var(--bg);
}
header { padding: 1.5rem 2rem 0.75rem; border-bottom: 1px solid #243041; }
.brand { font-size: 1.35rem; font-weight: 700; }
.sub { color: var(--muted); margin-top: 0.35rem; font-size: 0.9rem; }
.nav { display: flex; gap: 1rem; padding: 0.75rem 2rem; }
.nav a { color: var(--muted); text-decoration: none; cursor: pointer; }
.nav a.active, .nav a:hover { color: var(--accent); }
main { padding: 1rem 2rem 3rem; max-width: 980px; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
.tile { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem 1.1rem; }
.label { color: var(--muted); font-size: 0.8rem; }
.value { margin-top: 0.4rem; font-size: 1.5rem; font-weight: 650; font-variant-numeric: tabular-nums; }
.hint { margin-top: 0.35rem; color: var(--muted); font-size: 0.78rem; }
.status-ok { color: var(--ok); } .status-bad { color: var(--bad); } .status-warn { color: var(--warn); }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin: 0.5rem 0 1rem; }
.seg { display: inline-flex; background: var(--panel); border: 1px solid #243041; border-radius: 8px; overflow: hidden; }
.seg button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.45rem 0.85rem; cursor: pointer; }
.seg button.active { background: #1e2a3a; color: var(--text); }
.date-field { color: var(--muted); font-size: 0.85rem; display: inline-flex; gap: 0.5rem; align-items: center; }
.date-field input { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; }
.chart-wrap { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 0.75rem 0.5rem; margin-top: 0.5rem; }
.chart-title { padding: 0 0.75rem 0.25rem; color: var(--muted); font-size: 0.85rem; }
.chart-svg { width: 100%; height: auto; display: block; }
.hidden { display: none; }
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; }
</style>
</head>
<body>
<header>
<div class="brand">比特骆驼行情采集分析</div>
<div class="sub">只读采集 · 杠杆 = 指数 ÷ 卖一 · Asia/Shanghai</div>
</header>
<div class="nav">
<a id="nav-dash" class="active" data-page="dash">总览</a>
<a id="nav-ops" data-page="ops">作战地图</a>
<a id="nav-logout" class="hidden">退出</a>
</div>
<main>
<section id="page-login" class="hidden">
<div class="tile" style="max-width:360px">
<div class="label">管理员密码</div>
<input id="loginPass" type="password" style="width:100%;margin-top:0.5rem;padding:0.55rem;border-radius:6px;border:1px solid #243041;background:#0c1117;color:#e8eef5" />
<p id="loginErr" style="color:var(--bad);font-size:0.85rem"></p>
<button id="loginBtn" type="button" style="margin-top:0.5rem;padding:0.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;cursor:pointer">登录</button>
</div>
</section>
<section id="page-dash">
<div class="row">
<div class="tile"><div class="label">采集状态</div><div class="value" id="status"></div><div class="hint" id="lag"></div></div>
<div class="tile"><div class="label">ATM Call 杠杆</div><div class="value" id="callLev"></div><div class="hint" id="callMeta"></div></div>
<div class="tile"><div class="label">ATM Put 杠杆</div><div class="value" id="putLev"></div><div class="hint" id="putMeta"></div></div>
<div class="tile"><div class="label">指数</div><div class="value" id="indexPx"></div><div class="hint" id="expiry"></div></div>
</div>
<pre id="raw" style="margin-top:1rem">加载中…</pre>
</section>
<section id="page-ops" class="hidden">
<div class="toolbar">
<div class="seg" id="rangeSeg">
<button type="button" data-range="day" class="active"></button>
<button type="button" data-range="week"></button>
<button type="button" data-range="month"></button>
</div>
<div class="seg" id="sideSeg">
<button type="button" data-side="both" class="active">双边</button>
<button type="button" data-side="C">Call</button>
<button type="button" data-side="P">Put</button>
</div>
<label class="date-field">锚点日 <input type="date" id="anchorDate" /></label>
</div>
<div class="row">
<div class="tile"><div class="label">范围</div><div class="value" id="opsRange" style="font-size:1.05rem"></div><div class="hint" id="opsSamples"></div></div>
<div class="tile"><div class="label">达标线</div><div class="value" id="opsMin"></div><div class="hint">杠杆 = 指数 ÷ 卖一</div></div>
<div class="tile"><div class="label">波动样本</div><div class="value" id="opsMoveN"></div><div class="hint" id="opsPending"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title" id="chartTitle">上图 · 时段杠杆均值</div>
<svg id="levChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div>
<div class="toolbar" style="margin-top:1rem">
<div class="seg" id="moveModeSeg">
<button type="button" data-mode="abs" class="active">绝对波动</button>
<button type="button" data-mode="signed">带符号</button>
</div>
<span id="pendingBadge" style="color:var(--warn);font-size:0.85rem"></span>
</div>
<div class="chart-wrap">
<div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div>
<svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div>
<p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p>
</section>
</main>
<script>
const TOKEN_KEY = "mi_token";
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", lastMov: null, authRequired: false };
function getToken() { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } }
function setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders() {
const t = getToken();
return t ? { Authorization: "Bearer " + t } : {};
}
async function apiFetch(url, init) {
const headers = Object.assign({}, (init && init.headers) || {}, authHeaders());
const r = await fetch(url, Object.assign({}, init || {}, { headers, credentials: "include" }));
if (r.status === 401) { setToken(null); showLogin(); }
return r;
}
function fmt(n, d=1) {
if (n == null || Number.isNaN(n)) return "—";
return Number(n).toFixed(d);
}
function todayYmd() {
const d = new Date();
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
}
document.getElementById("anchorDate").value = todayYmd();
function showLogin() {
document.getElementById("page-login").classList.remove("hidden");
document.getElementById("page-dash").classList.add("hidden");
document.getElementById("page-ops").classList.add("hidden");
document.getElementById("nav-logout").classList.add("hidden");
}
function hideLogin() {
document.getElementById("page-login").classList.add("hidden");
document.getElementById("nav-logout").classList.toggle("hidden", !state.authRequired);
}
function showPage(p) {
if (state.authRequired && !getToken() && !document.cookie.includes("mi_token=")) {
showLogin();
return;
}
state.page = p;
hideLogin();
document.getElementById("page-dash").classList.toggle("hidden", p !== "dash");
document.getElementById("page-ops").classList.toggle("hidden", p !== "ops");
document.getElementById("nav-dash").classList.toggle("active", p === "dash");
document.getElementById("nav-ops").classList.toggle("active", p === "ops");
location.hash = p === "ops" ? "ops-map" : "";
if (p === "ops") loadOps();
if (p === "dash") refreshDash();
}
if (location.pathname === "/ops-map" || location.hash === "#ops-map") state.page = "ops";
document.querySelectorAll(".nav a[data-page]").forEach(a => a.addEventListener("click", () => showPage(a.dataset.page)));
document.getElementById("nav-logout").addEventListener("click", async () => {
setToken(null);
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
showLogin();
});
document.getElementById("loginBtn").addEventListener("click", async () => {
const password = document.getElementById("loginPass").value;
document.getElementById("loginErr").textContent = "";
try {
const r = await fetch("/api/auth/login", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!r.ok) throw new Error("密码错误");
const body = await r.json();
if (body.token) setToken(body.token);
showPage(state.page || "dash");
} catch (e) {
document.getElementById("loginErr").textContent = String(e.message || e);
}
});
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
state.range = b.dataset.range;
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
loadOps();
}));
document.querySelectorAll("#sideSeg button").forEach(b => b.addEventListener("click", () => {
state.side = b.dataset.side;
document.querySelectorAll("#sideSeg button").forEach(x => x.classList.toggle("active", x === b));
loadOps();
}));
document.querySelectorAll("#moveModeSeg button").forEach(b => b.addEventListener("click", () => {
state.moveMode = b.dataset.mode;
document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b));
if (state.lastMov) drawMoveChart(state.lastMov.buckets || [], state.moveMode);
}));
document.getElementById("anchorDate").addEventListener("change", loadOps);
function drawChart(buckets, minLev) {
const svg = document.getElementById("levChart");
const W=880,H=260,padL=44,padR=12,padT=24,padB=36;
const innerW=W-padL-padR, innerH=H-padT-padB;
const vals = buckets.map(b => b.mean || 0);
const maxV = Math.max(minLev * 1.2, ...vals, 1);
const barW = innerW / Math.max(buckets.length, 1);
const yMin = padT + innerH * (1 - minLev / maxV);
let html = "";
html += `<line x1="${padL}" y1="${yMin}" x2="${W-padR}" y2="${yMin}" stroke="#e6a23c" stroke-dasharray="4 4"/>`;
html += `<text x="${W-padR-4}" y="${yMin-4}" fill="#e6a23c" font-size="10" text-anchor="end">min ${minLev}</text>`;
buckets.forEach((b,i) => {
const v = b.mean || 0;
const h = b.n > 0 ? (v / maxV) * innerH : 0;
const x = padL + i * barW + barW * 0.15;
const y = padT + innerH - h;
const w = barW * 0.7;
const fill = b.n === 0 ? "#243041" : (v >= minLev ? "#3ecf8e" : "#3d8fd1");
html += `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(h, b.n>0?2:0)}" fill="${fill}" rx="2"><title>${b.label}: mean=${fmt(b.mean)} n=${b.n}</title></rect>`;
if (i % 2 === 0) html += `<text x="${x+w/2}" y="${H-10}" fill="#8b9aab" font-size="9" text-anchor="middle">${String(b.label).replace(":00","")}</text>`;
});
html += `<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT+innerH}" stroke="#243041"/>`;
html += `<line x1="${padL}" y1="${padT+innerH}" x2="${W-padR}" y2="${padT+innerH}" stroke="#243041"/>`;
html += `<text x="4" y="${padT+8}" fill="#8b9aab" font-size="10">${maxV.toFixed(0)}</text>`;
html += `<text x="4" y="${padT+innerH}" fill="#8b9aab" font-size="10">0</text>`;
svg.innerHTML = html;
}
function drawMoveChart(buckets, mode) {
const svg = document.getElementById("moveChart");
const W=880,H=260,padL=44,padR=12,padT=24,padB=36;
const innerW=W-padL-padR, innerH=H-padT-padB;
const vals = buckets.map(b => mode === "abs" ? (b.mean_abs||0) : (b.mean_signed||0));
const maxAbs = Math.max(...vals.map(v => Math.abs(v)), 1);
const y0 = mode === "signed" ? padT + innerH/2 : padT + innerH;
const scale = mode === "signed" ? (innerH/2)/maxAbs : innerH/maxAbs;
const barW = innerW / Math.max(buckets.length, 1);
let html = "";
if (mode === "signed") html += `<line x1="${padL}" y1="${y0}" x2="${W-padR}" y2="${y0}" stroke="#243041"/>`;
buckets.forEach((b,i) => {
const v = mode === "abs" ? (b.mean_abs||0) : (b.mean_signed||0);
const h = b.n > 0 ? Math.abs(v)*scale : 0;
const x = padL + i*barW + barW*0.15;
const y = mode === "signed" ? (v >= 0 ? y0 - h : y0) : y0 - h;
const w = barW*0.7;
const fill = b.n === 0 ? "#243041" : (mode === "abs" ? "#9b7bff" : (v >= 0 ? "#3ecf8e" : "#e85d5d"));
html += `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(h, b.n>0?2:0)}" fill="${fill}" rx="2"><title>${b.label}: abs=${fmt(b.mean_abs)} signed=${fmt(b.mean_signed)} n=${b.n}</title></rect>`;
if (i % 2 === 0) html += `<text x="${x+w/2}" y="${H-10}" fill="#8b9aab" font-size="9" text-anchor="middle">${String(b.label).replace(":00","")}</text>`;
});
html += `<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT+innerH}" stroke="#243041"/>`;
html += `<line x1="${padL}" y1="${padT+innerH}" x2="${W-padR}" y2="${padT+innerH}" stroke="#243041"/>`;
html += `<text x="4" y="${padT+8}" fill="#8b9aab" font-size="10">${maxAbs.toFixed(0)}</text>`;
html += `<text x="4" y="${padT+innerH}" fill="#8b9aab" font-size="10">${mode==="signed"?"-"+maxAbs.toFixed(0):"0"}</text>`;
svg.innerHTML = html;
}
async function loadOps() {
const date = document.getElementById("anchorDate").value;
const q = new URLSearchParams({ range: state.range, side: state.side, bucket_minutes: "60", date });
try {
const d = await apiFetch("/api/stats/ops-map?" + q).then(r => {
if (!r.ok) throw new Error("ops-map " + r.status);
return r.json();
});
const lev = d.leverage || {};
document.getElementById("opsRange").textContent = (lev.start_ymd || "—") + " → " + (lev.end_ymd || "—");
document.getElementById("opsSamples").textContent = "杠杆样本 " + (lev.sample_count ?? 0);
document.getElementById("opsMin").textContent = lev.min_leverage ?? "—";
const titleMap = { day: "日", week: "近7日", month: "近30日" };
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + "";
drawChart(lev.buckets || [], lev.min_leverage || 100);
const mov = d.move_points || {};
state.lastMov = mov;
document.getElementById("opsMoveN").textContent = mov.settled_count ?? 0;
document.getElementById("opsPending").textContent = mov.pending_expiry
? ("pending " + (mov.pending_count ?? 0) + "(未到期已排除)")
: "全部已结算";
document.getElementById("pendingBadge").textContent = mov.pending_expiry ? "pending_expiry=true" : "";
document.getElementById("moveTitle").textContent =
"下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + "";
drawMoveChart(mov.buckets || [], state.moveMode);
document.getElementById("moveHint").textContent = mov.message || "";
} catch (e) {
document.getElementById("opsRange").textContent = "加载失败";
document.getElementById("moveHint").textContent = String(e);
}
}
async function refreshDash() {
try {
const [h, mRes] = await Promise.all([
fetch("/health").then(r => r.json()),
apiFetch("/api/meta/latest"),
]);
if (!mRes.ok) throw new Error("latest " + mRes.status);
const m = await mRes.json();
const lag = h.collector_lag_ms;
const ok = h.ok && (lag == null || lag < 120000) && (h.consecutive_failures || 0) < 5;
const st = document.getElementById("status");
st.textContent = ok ? "正常" : "异常/等待";
st.className = "value " + (ok ? "status-ok" : "status-warn");
document.getElementById("lag").textContent =
lag == null ? "尚无采样" : `延迟 ${Math.round(lag/1000)}s · 样本 ${h.option_quotes||0}`;
const call = m.call, put = m.put;
document.getElementById("callLev").textContent = fmt(call && call.leverage, 1);
document.getElementById("putLev").textContent = fmt(put && put.leverage, 1);
document.getElementById("callMeta").textContent = call ? `${call.inst_id} · ask ${fmt(call.ask, 4)}` : "—";
document.getElementById("putMeta").textContent = put ? `${put.inst_id} · ask ${fmt(put.ask, 4)}` : "—";
const meta = (m.heartbeat && m.heartbeat.meta) || {};
document.getElementById("indexPx").textContent = fmt(meta.index_px || (call && call.index_px), 2);
document.getElementById("expiry").textContent = meta.expiry_ymd
? `到期 ${meta.expiry_ymd} · 行权 ${meta.strike||"—"}` : "—";
document.getElementById("raw").textContent = JSON.stringify({ health: h, latest: m }, null, 2);
} catch (e) {
document.getElementById("status").textContent = "请求失败";
document.getElementById("status").className = "value status-bad";
document.getElementById("raw").textContent = String(e);
}
}
async function boot() {
try {
const st = await fetch("/api/auth/status").then(r => r.json());
state.authRequired = !!st.auth_required;
if (state.authRequired && !getToken()) {
showLogin();
return;
}
showPage(state.page || "dash");
} catch (e) {
document.getElementById("raw").textContent = String(e);
showPage("dash");
}
}
boot();
setInterval(() => {
if (state.page === "dash" && document.getElementById("page-login").classList.contains("hidden")) {
refreshDash();
}
}, 10000);
</script>
</body>
</html>