first commit

This commit is contained in:
dekun
2026-08-01 10:33:19 +08:00
commit d9a34d4f20
72 changed files with 5499 additions and 0 deletions
+150
View File
@@ -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();
}
+98
View File
@@ -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" /> &lt;线
<span className="dot empty" />
<span className="dot warn" /> 线
</div>
</div>
);
}
+67
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
+143
View File
@@ -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>
);
}
+219
View File
@@ -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>
);
}
+108
View File
@@ -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); }