first commit
This commit is contained in:
Vendored
+343
@@ -0,0 +1,343 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>比特骆驼行情采集分析</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "market-intel-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
const TOKEN_KEY = "mi_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const t = getToken();
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = {
|
||||
...(init?.headers || {}),
|
||||
...authHeaders(),
|
||||
};
|
||||
const r = await fetch(input, { ...init, headers, credentials: "include" });
|
||||
if (r.status === 401) {
|
||||
setToken(null);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export type AuthStatus = { auth_required: boolean };
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const r = await fetch("/api/auth/status", { credentials: "include" });
|
||||
if (!r.ok) throw new Error("auth status failed");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<{ ok: boolean; token?: string | null }> {
|
||||
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);
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
setToken(null);
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
|
||||
export type Health = {
|
||||
ok: boolean;
|
||||
collector_lag_ms: number | null;
|
||||
consecutive_failures: number;
|
||||
option_quotes: number;
|
||||
};
|
||||
|
||||
export type Latest = {
|
||||
call?: { leverage?: number; ask?: number; inst_id?: string; index_px?: number };
|
||||
put?: { leverage?: number; ask?: number; inst_id?: string; index_px?: number };
|
||||
heartbeat?: { meta?: { index_px?: number; expiry_ymd?: string; strike?: number } };
|
||||
};
|
||||
|
||||
export type LeverageBucket = {
|
||||
bucket_start_min: number;
|
||||
label: string;
|
||||
n: number;
|
||||
mean: number | null;
|
||||
median: number | null;
|
||||
p25: number | null;
|
||||
p75: number | null;
|
||||
pct_ge_min: number | null;
|
||||
};
|
||||
|
||||
export type LeverageStats = {
|
||||
status: string;
|
||||
range: string;
|
||||
date: string;
|
||||
start_ymd: string;
|
||||
end_ymd: string;
|
||||
side: string;
|
||||
sample_count: number;
|
||||
min_leverage: number;
|
||||
buckets: LeverageBucket[];
|
||||
};
|
||||
|
||||
export type MoveBucket = {
|
||||
bucket_start_min: number;
|
||||
label: string;
|
||||
n: number;
|
||||
mean_abs: number | null;
|
||||
median_abs: number | null;
|
||||
mean_signed: number | null;
|
||||
median_signed: number | null;
|
||||
};
|
||||
|
||||
export type MovePointsStats = {
|
||||
status: string;
|
||||
pending_expiry?: boolean;
|
||||
pending_count?: number;
|
||||
settled_count?: number;
|
||||
sample_count?: number;
|
||||
message?: string | null;
|
||||
buckets: MoveBucket[];
|
||||
};
|
||||
|
||||
export type OpsMap = {
|
||||
leverage: LeverageStats;
|
||||
move_points: MovePointsStats;
|
||||
};
|
||||
|
||||
export async function fetchHealth(): Promise<Health> {
|
||||
const r = await fetch("/health");
|
||||
if (!r.ok) throw new Error("health failed");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchLatest(): Promise<Latest> {
|
||||
const r = await apiFetch("/api/meta/latest");
|
||||
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "latest failed");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchOpsMap(params: {
|
||||
range: string;
|
||||
date?: string;
|
||||
side?: string;
|
||||
bucket_minutes?: number;
|
||||
}): Promise<OpsMap> {
|
||||
const q = new URLSearchParams();
|
||||
q.set("range", params.range);
|
||||
if (params.date) q.set("date", params.date);
|
||||
if (params.side) q.set("side", params.side);
|
||||
if (params.bucket_minutes) q.set("bucket_minutes", String(params.bucket_minutes));
|
||||
const r = await apiFetch(`/api/stats/ops-map?${q}`);
|
||||
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "ops-map failed");
|
||||
return r.json();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { LeverageBucket } from "../api/client";
|
||||
|
||||
type Props = {
|
||||
buckets: LeverageBucket[];
|
||||
minLeverage: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export default function LeverageChart({ buckets, minLeverage, title }: Props) {
|
||||
const width = 880;
|
||||
const height = 260;
|
||||
const padL = 44;
|
||||
const padR = 12;
|
||||
const padT = 24;
|
||||
const padB = 36;
|
||||
const innerW = width - padL - padR;
|
||||
const innerH = height - padT - padB;
|
||||
|
||||
const vals = buckets.map((b) => b.mean ?? 0);
|
||||
const maxV = Math.max(minLeverage * 1.2, ...vals, 1);
|
||||
const barW = innerW / Math.max(buckets.length, 1);
|
||||
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
<div className="chart-title">{title}</div>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="chart-svg" role="img">
|
||||
<line
|
||||
x1={padL}
|
||||
y1={padT + innerH * (1 - minLeverage / maxV)}
|
||||
x2={width - padR}
|
||||
y2={padT + innerH * (1 - minLeverage / maxV)}
|
||||
stroke="#e6a23c"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={width - padR - 4}
|
||||
y={padT + innerH * (1 - minLeverage / maxV) - 4}
|
||||
fill="#e6a23c"
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
>
|
||||
min {minLeverage}
|
||||
</text>
|
||||
{buckets.map((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 >= minLeverage ? "#3ecf8e" : "#3d8fd1";
|
||||
return (
|
||||
<g key={b.bucket_start_min}>
|
||||
<rect x={x} y={y} width={w} height={Math.max(h, b.n > 0 ? 2 : 0)} fill={fill} rx="2">
|
||||
<title>
|
||||
{b.label}: mean={b.mean?.toFixed(1) ?? "—"} n={b.n} median=
|
||||
{b.median?.toFixed(1) ?? "—"}
|
||||
</title>
|
||||
</rect>
|
||||
{i % 2 === 0 && (
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={height - 10}
|
||||
fill="#8b9aab"
|
||||
fontSize="9"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{b.label.replace(":00", "")}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={padL} y1={padT} x2={padL} y2={padT + innerH} stroke="#243041" strokeWidth="1" />
|
||||
<line
|
||||
x1={padL}
|
||||
y1={padT + innerH}
|
||||
x2={width - padR}
|
||||
y2={padT + innerH}
|
||||
stroke="#243041"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text x={4} y={padT + 8} fill="#8b9aab" fontSize="10">
|
||||
{maxV.toFixed(0)}
|
||||
</text>
|
||||
<text x={4} y={padT + innerH} fill="#8b9aab" fontSize="10">
|
||||
0
|
||||
</text>
|
||||
</svg>
|
||||
<div className="chart-legend">
|
||||
<span className="dot ok" /> ≥达标线均值
|
||||
<span className="dot mid" /> <达标线
|
||||
<span className="dot empty" /> 无样本
|
||||
<span className="dot warn" /> 达标线
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { login } from "../api/client";
|
||||
|
||||
type Props = {
|
||||
onOk: () => void;
|
||||
};
|
||||
|
||||
export default function LoginGate({ onOk }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await login(password);
|
||||
onOk();
|
||||
} catch (ex) {
|
||||
setErr(String(ex));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">需要登录后查看看板</div>
|
||||
<form className="tile" onSubmit={submit} style={{ maxWidth: 360 }}>
|
||||
<div className="label">管理员密码</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "0.5rem",
|
||||
padding: "0.55rem 0.65rem",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #243041",
|
||||
background: "#0c1117",
|
||||
color: "#e8eef5",
|
||||
}}
|
||||
/>
|
||||
{err && <p style={{ color: "#e85d5d", fontSize: "0.85rem" }}>{err}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
style={{
|
||||
marginTop: "0.85rem",
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: 6,
|
||||
border: 0,
|
||||
background: "#3d8fd1",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{busy ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { MoveBucket } from "../api/client";
|
||||
|
||||
type Props = {
|
||||
buckets: MoveBucket[];
|
||||
title: string;
|
||||
mode?: "abs" | "signed";
|
||||
};
|
||||
|
||||
export default function MovePointsChart({ buckets, title, mode = "abs" }: Props) {
|
||||
const width = 880;
|
||||
const height = 260;
|
||||
const padL = 44;
|
||||
const padR = 12;
|
||||
const padT = 24;
|
||||
const padB = 36;
|
||||
const innerW = width - padL - padR;
|
||||
const innerH = height - 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);
|
||||
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
<div className="chart-title">{title}</div>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="chart-svg" role="img">
|
||||
{mode === "signed" && (
|
||||
<line
|
||||
x1={padL}
|
||||
y1={y0}
|
||||
x2={width - padR}
|
||||
y2={y0}
|
||||
stroke="#243041"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
{buckets.map((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";
|
||||
return (
|
||||
<g key={b.bucket_start_min}>
|
||||
<rect x={x} y={y} width={w} height={Math.max(h, b.n > 0 ? 2 : 0)} fill={fill} rx="2">
|
||||
<title>
|
||||
{b.label}: abs={b.mean_abs?.toFixed(1) ?? "—"} signed=
|
||||
{b.mean_signed?.toFixed(1) ?? "—"} n={b.n}
|
||||
</title>
|
||||
</rect>
|
||||
{i % 2 === 0 && (
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={height - 10}
|
||||
fill="#8b9aab"
|
||||
fontSize="9"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{b.label.replace(":00", "")}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={padL} y1={padT} x2={padL} y2={padT + innerH} stroke="#243041" strokeWidth="1" />
|
||||
<line
|
||||
x1={padL}
|
||||
y1={padT + innerH}
|
||||
x2={width - padR}
|
||||
y2={padT + innerH}
|
||||
stroke="#243041"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text x={4} y={padT + 8} fill="#8b9aab" fontSize="10">
|
||||
{mode === "signed" ? maxAbs.toFixed(0) : maxAbs.toFixed(0)}
|
||||
</text>
|
||||
<text x={4} y={padT + innerH} fill="#8b9aab" fontSize="10">
|
||||
{mode === "signed" ? `-${maxAbs.toFixed(0)}` : "0"}
|
||||
</text>
|
||||
</svg>
|
||||
<div className="chart-legend">
|
||||
{mode === "abs" ? (
|
||||
<>
|
||||
<span className="dot" style={{ background: "#9b7bff" }} /> 绝对波动均值
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="dot ok" /> 上涨
|
||||
<span className="dot" style={{ background: "#e85d5d" }} /> 下跌
|
||||
</>
|
||||
)}
|
||||
<span className="dot empty" /> 无样本/未到期
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import OpsMap from "./pages/OpsMap";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/ops-map" element={<OpsMap />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchAuthStatus,
|
||||
fetchHealth,
|
||||
fetchLatest,
|
||||
Health,
|
||||
Latest,
|
||||
logout,
|
||||
} from "../api/client";
|
||||
import LoginGate from "../components/LoginGate";
|
||||
|
||||
function fmt(n?: number | null, d = 1) {
|
||||
if (n == null || Number.isNaN(n)) return "—";
|
||||
return n.toFixed(d);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [needLogin, setNeedLogin] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [latest, setLatest] = useState<Latest | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const bootstrap = async () => {
|
||||
const st = await fetchAuthStatus();
|
||||
if (!st.auth_required) {
|
||||
setNeedLogin(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchLatest();
|
||||
setNeedLogin(false);
|
||||
} catch {
|
||||
setNeedLogin(true);
|
||||
}
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap().catch((e) => setErr(String(e)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || needLogin) return;
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [h, m] = await Promise.all([fetchHealth(), fetchLatest()]);
|
||||
if (!alive) return;
|
||||
setHealth(h);
|
||||
setLatest(m);
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
const msg = String(e);
|
||||
if (msg.includes("unauthorized")) setNeedLogin(true);
|
||||
else setErr(msg);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 10000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [ready, needLogin]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="sub">加载中…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (needLogin) {
|
||||
return (
|
||||
<LoginGate
|
||||
onOk={() => {
|
||||
setNeedLogin(false);
|
||||
setErr(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const lag = health?.collector_lag_ms;
|
||||
const ok =
|
||||
!!health?.ok && (lag == null || lag < 120_000) && (health.consecutive_failures || 0) < 5;
|
||||
const meta = latest?.heartbeat?.meta;
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">只读采集 · 杠杆 = 指数 ÷ 卖一 · Asia/Shanghai</div>
|
||||
<div className="nav">
|
||||
<Link to="/">总览</Link>
|
||||
<Link to="/ops-map">作战地图</Link>
|
||||
<a
|
||||
href="#logout"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
logout().finally(() => setNeedLogin(true));
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</a>
|
||||
</div>
|
||||
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
||||
<div className="row">
|
||||
<div className="tile">
|
||||
<div className="label">采集状态</div>
|
||||
<div className="value" style={{ color: ok ? "var(--ok)" : "var(--warn)" }}>
|
||||
{health ? (ok ? "正常" : "异常/等待") : "…"}
|
||||
</div>
|
||||
<div className="hint">
|
||||
{lag == null
|
||||
? "尚无采样"
|
||||
: `延迟 ${Math.round(lag / 1000)}s · 样本 ${health?.option_quotes ?? 0}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<div className="label">ATM Call 杠杆</div>
|
||||
<div className="value">{fmt(latest?.call?.leverage)}</div>
|
||||
<div className="hint">{latest?.call?.inst_id ?? "—"}</div>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<div className="label">ATM Put 杠杆</div>
|
||||
<div className="value">{fmt(latest?.put?.leverage)}</div>
|
||||
<div className="hint">{latest?.put?.inst_id ?? "—"}</div>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<div className="label">指数</div>
|
||||
<div className="value">{fmt(meta?.index_px ?? latest?.call?.index_px, 2)}</div>
|
||||
<div className="hint">
|
||||
{meta?.expiry_ymd ? `到期 ${meta.expiry_ymd} · 行权 ${meta.strike ?? "—"}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
fetchAuthStatus,
|
||||
fetchOpsMap,
|
||||
LeverageStats,
|
||||
logout,
|
||||
MovePointsStats,
|
||||
OpsMap,
|
||||
} from "../api/client";
|
||||
import LeverageChart from "../components/LeverageChart";
|
||||
import LoginGate from "../components/LoginGate";
|
||||
import MovePointsChart from "../components/MovePointsChart";
|
||||
|
||||
type RangeKey = "day" | "week" | "month";
|
||||
type SideKey = "both" | "C" | "P";
|
||||
|
||||
function todayYmd() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default function OpsMapPage() {
|
||||
const [needLogin, setNeedLogin] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [range, setRange] = useState<RangeKey>("day");
|
||||
const [side, setSide] = useState<SideKey>("both");
|
||||
const [date, setDate] = useState(todayYmd());
|
||||
const [moveMode, setMoveMode] = useState<"abs" | "signed">("abs");
|
||||
const [data, setData] = useState<OpsMap | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthStatus()
|
||||
.then(async (st) => {
|
||||
if (!st.auth_required) {
|
||||
setNeedLogin(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchOpsMap({ range: "day", bucket_minutes: 60 });
|
||||
setNeedLogin(false);
|
||||
} catch {
|
||||
setNeedLogin(true);
|
||||
}
|
||||
setReady(true);
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || needLogin) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetchOpsMap({ range, date, side, bucket_minutes: 60 })
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setData(d);
|
||||
setErr(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
const msg = String(e);
|
||||
if (msg.includes("unauthorized")) setNeedLogin(true);
|
||||
else setErr(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range, side, date, ready, needLogin]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="sub">加载中…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (needLogin) {
|
||||
return <LoginGate onOk={() => setNeedLogin(false)} />;
|
||||
}
|
||||
|
||||
const lev: LeverageStats | undefined = data?.leverage;
|
||||
const mov: MovePointsStats | undefined = data?.move_points;
|
||||
const rangeLabel = range === "day" ? "日" : range === "week" ? "近7日" : "近30日";
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="brand">比特骆驼行情采集分析</div>
|
||||
<div className="sub">作战地图 · 杠杆 × 时段 → 到期波动</div>
|
||||
<div className="nav">
|
||||
<Link to="/">总览</Link>
|
||||
<Link to="/ops-map">作战地图</Link>
|
||||
<a
|
||||
href="#logout"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
logout().finally(() => setNeedLogin(true));
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="seg">
|
||||
{(["day", "week", "month"] as RangeKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className={range === k ? "active" : ""}
|
||||
onClick={() => setRange(k)}
|
||||
>
|
||||
{k === "day" ? "日" : k === "week" ? "周" : "月"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="seg">
|
||||
{(["both", "C", "P"] as SideKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className={side === k ? "active" : ""}
|
||||
onClick={() => setSide(k)}
|
||||
>
|
||||
{k === "both" ? "双边" : k === "C" ? "Call" : "Put"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="date-field">
|
||||
锚点日
|
||||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
||||
{loading && !data && <p style={{ color: "var(--muted)" }}>加载中…</p>}
|
||||
|
||||
{lev && (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: "1rem" }}>
|
||||
<div className="tile">
|
||||
<div className="label">范围</div>
|
||||
<div className="value" style={{ fontSize: "1.1rem" }}>
|
||||
{lev.start_ymd} → {lev.end_ymd}
|
||||
</div>
|
||||
<div className="hint">杠杆样本 {lev.sample_count}</div>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<div className="label">达标线</div>
|
||||
<div className="value">{lev.min_leverage}</div>
|
||||
<div className="hint">杠杆 = 指数 ÷ 卖一</div>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<div className="label">波动样本</div>
|
||||
<div className="value">{mov?.settled_count ?? 0}</div>
|
||||
<div className="hint">
|
||||
{mov?.pending_expiry
|
||||
? `pending ${mov.pending_count ?? 0}(未到期已排除)`
|
||||
: "全部已结算"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LeverageChart
|
||||
buckets={lev.buckets}
|
||||
minLeverage={lev.min_leverage}
|
||||
title={`上图 · 时段杠杆均值(${rangeLabel})`}
|
||||
/>
|
||||
|
||||
<div className="toolbar" style={{ marginTop: "1.25rem" }}>
|
||||
<div className="seg">
|
||||
<button
|
||||
type="button"
|
||||
className={moveMode === "abs" ? "active" : ""}
|
||||
onClick={() => setMoveMode("abs")}
|
||||
>
|
||||
绝对波动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={moveMode === "signed" ? "active" : ""}
|
||||
onClick={() => setMoveMode("signed")}
|
||||
>
|
||||
带符号
|
||||
</button>
|
||||
</div>
|
||||
{mov?.pending_expiry && (
|
||||
<span style={{ color: "var(--warn)", fontSize: "0.85rem" }}>
|
||||
pending_expiry=true
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MovePointsChart
|
||||
buckets={mov?.buckets ?? []}
|
||||
mode={moveMode}
|
||||
title={`下图 · 时段→到期波动(${rangeLabel} · ${
|
||||
moveMode === "abs" ? "abs" : "signed"
|
||||
})`}
|
||||
/>
|
||||
{mov?.message && (
|
||||
<p style={{ color: "var(--muted)", fontSize: "0.85rem", marginTop: "0.75rem" }}>
|
||||
{mov.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
:root {
|
||||
--bg: #0c1117;
|
||||
--panel: #151b24;
|
||||
--text: #e8eef5;
|
||||
--muted: #8b9aab;
|
||||
--accent: #3d8fd1;
|
||||
--ok: #3ecf8e;
|
||||
--warn: #e6a23c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, #1a2a3d 0%, transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
.layout { max-width: 980px; margin: 0 auto; padding: 1.5rem; }
|
||||
.brand { font-size: 1.35rem; font-weight: 700; }
|
||||
.sub { color: var(--muted); margin: 0.35rem 0 1.25rem; }
|
||||
.nav { display: flex; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
.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;
|
||||
}
|
||||
.label { color: var(--muted); font-size: 0.8rem; }
|
||||
.value { margin-top: 0.35rem; font-size: 1.5rem; font-weight: 650; }
|
||||
.hint { margin-top: 0.3rem; color: var(--muted); font-size: 0.78rem; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.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;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.seg button.active {
|
||||
background: #1e2a3a;
|
||||
color: var(--text);
|
||||
}
|
||||
.date-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.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 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; }
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
padding: 0.25rem 0.75rem 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.mid { background: var(--accent); }
|
||||
.dot.empty { background: #243041; }
|
||||
.dot.warn { background: var(--warn); }
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:5170",
|
||||
"/health": "http://127.0.0.1:5170",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user