first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user