Initial eth_hedge_sim: P0 market, auth UI, one-click deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-24 16:33:25 +08:00
commit e51f357b48
49 changed files with 4783 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import type { ReactNode } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { clearSession, getToken, getUsername } from "./api/client";
import LoginPage from "./pages/Login";
import PlanPage from "./pages/Plan";
import TradesPage from "./pages/Trades";
import StatsPage from "./pages/Stats";
import SettingsPage from "./pages/Settings";
function Shell({ children }: { children: ReactNode }) {
const user = getUsername();
return (
<div className="app-shell">
<nav className="topnav">
<div className="brand">eth_hedge_sim</div>
<NavLink to="/plan" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/trades" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/settings" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<div className="spacer" />
<span className="meta mono">{user}</span>
<button
className="btn ghost"
type="button"
onClick={() => {
clearSession();
window.location.href = "/login";
}}
>
退
</button>
</nav>
<main className="page">{children}</main>
</div>
);
}
function RequireAuth({ children }: { children: ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <Shell>{children}</Shell>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/plan"
element={
<RequireAuth>
<PlanPage />
</RequireAuth>
}
/>
<Route
path="/trades"
element={
<RequireAuth>
<TradesPage />
</RequireAuth>
}
/>
<Route
path="/stats"
element={
<RequireAuth>
<StatsPage />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route path="/" element={<Navigate to="/plan" replace />} />
<Route path="*" element={<Navigate to="/plan" replace />} />
</Routes>
);
}
+110
View File
@@ -0,0 +1,110 @@
const API_KEY = "eth_hedge_api_base";
const TOKEN_KEY = "eth_hedge_token";
const USER_KEY = "eth_hedge_user";
export function getApiBase(): string {
const saved = localStorage.getItem(API_KEY);
if (saved && saved.trim()) return saved.trim().replace(/\/$/, "");
// same-origin default when UI is served by backend on :5155
return window.location.origin;
}
export function setApiBase(url: string) {
localStorage.setItem(API_KEY, url.trim().replace(/\/$/, ""));
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setSession(token: string, username: string) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, username);
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getUsername(): string | null {
return localStorage.getItem(USER_KEY);
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const base = getApiBase();
const headers = new Headers(options.headers || {});
if (!headers.has("Content-Type") && options.body) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${base}${path}`, { ...options, headers });
if (res.status === 401) {
clearSession();
throw new Error("unauthorized");
}
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { detail: text };
}
if (!res.ok) {
const detail =
typeof data === "object" && data && "detail" in data
? String((data as { detail: unknown }).detail)
: res.statusText;
throw new Error(detail || `HTTP ${res.status}`);
}
return data as T;
}
export type LoginResult = {
token: string;
username: string;
expires_in: number;
env_name: string;
mode: string;
};
export async function login(username: string, password: string) {
return apiFetch<LoginResult>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export type MarketSnapshot = {
connected: boolean;
updated_at_ms: number | null;
index_px: number | null;
pair: {
expiry_ymd: string;
strike: number;
call_inst_id: string;
put_inst_id: string;
} | null;
perp: Quote | null;
call: Quote | null;
put: Quote | null;
ask_compare: {
call_ask: number | null;
put_ask: number | null;
bias: string;
};
};
type Quote = {
inst_id: string;
bid: number | null;
ask: number | null;
bid_sz: number | null;
ask_sz: number | null;
mark_px: number | null;
};
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+79
View File
@@ -0,0 +1,79 @@
import { FormEvent, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getApiBase, login, setApiBase, setSession } from "../api/client";
export default function LoginPage() {
const nav = useNavigate();
const [apiBase, setApi] = useState(getApiBase());
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const hint = useMemo(
() => "默认同域 API。跨机访问时填写如 http://47.236.184.99:5155",
[],
);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setErr("");
setLoading(true);
try {
setApiBase(apiBase);
const res = await login(username.trim(), password);
setSession(res.token, res.username);
nav("/plan", { replace: true });
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<h1>eth_hedge_sim</h1>
<p> · API </p>
{err ? <div className="err">{err}</div> : null}
<div className="field">
<label htmlFor="api">API </label>
<input
id="api"
className="mono"
value={apiBase}
onChange={(e) => setApi(e.target.value)}
placeholder="http://host:5155"
autoComplete="url"
/>
</div>
<div className="field">
<label htmlFor="user"></label>
<input
id="user"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</div>
<div className="field">
<label htmlFor="pass"></label>
<input
id="pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<button className="btn block" type="submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
<div className="hint">{hint}</div>
</form>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { apiFetch, MarketSnapshot } from "../api/client";
function fmt(n: number | null | undefined, d = 2) {
if (n == null || Number.isNaN(n)) return "—";
return n.toFixed(d);
}
export default function PlanPage() {
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
const [err, setErr] = useState("");
useEffect(() => {
let alive = true;
const load = async () => {
try {
const data = await apiFetch<MarketSnapshot>("/api/market/snapshot");
if (alive) {
setSnap(data);
setErr("");
}
} catch (e) {
if (alive) setErr(e instanceof Error ? e.message : String(e));
}
};
load();
const t = window.setInterval(load, 2000);
return () => {
alive = false;
window.clearInterval(t);
};
}, []);
const bias = snap?.ask_compare?.bias;
const biasTag =
bias === "call_ask_gt_put" ? (
<span className="tag up">Call卖一 &gt; Put卖一 +</span>
) : bias === "put_ask_gt_call" ? (
<span className="tag down">Put卖一 &gt; Call卖一 +</span>
) : (
<span className="tag"> / </span>
);
return (
<div>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
P0 ·
</p>
{err ? <div className="err">{err}</div> : null}
<div className="card" style={{ marginBottom: 12 }}>
<div className="kv">
<span></span>
<span className="mono">SIM · </span>
</div>
<div className="kv">
<span></span>
<span className="mono">{snap?.connected ? "WS 已连接" : "REST/未连"}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.index_px)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{snap?.pair
? `${snap.pair.expiry_ymd} @ ${snap.pair.strike}`
: "—"}
</span>
</div>
<div className="kv">
<span></span>
{biasTag}
</div>
</div>
<div className="grid-2">
<div className="card">
<h3 style={{ marginTop: 0 }}> ETH-USDT-SWAP</h3>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.bid)} × {fmt(snap?.perp?.bid_sz, 2)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.ask)} × {fmt(snap?.perp?.ask_sz, 2)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.mark_px)}</span>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}> ATM</h3>
<div className="kv">
<span>Call </span>
<span className="mono">{fmt(snap?.call?.ask)} / {fmt(snap?.call?.bid)}</span>
</div>
<div className="kv">
<span>Put </span>
<span className="mono">{fmt(snap?.put?.ask)} / {fmt(snap?.put?.bid)}</span>
</div>
<div className="kv">
<span>Call</span>
<span className="mono" style={{ fontSize: 12 }}>
{snap?.pair?.call_inst_id || "—"}
</span>
</div>
<div className="kv">
<span>Put</span>
<span className="mono" style={{ fontSize: 12 }}>
{snap?.pair?.put_inst_id || "—"}
</span>
</div>
</div>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { FormEvent, useState } from "react";
import { getApiBase, setApiBase } from "../api/client";
export default function SettingsPage() {
const [apiBase, setApi] = useState(getApiBase());
const [saved, setSaved] = useState(false);
function onSave(e: FormEvent) {
e.preventDefault();
setApiBase(apiBase);
setSaved(true);
window.setTimeout(() => setSaved(false), 1500);
}
return (
<div className="card" style={{ maxWidth: 560 }}>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}>
API 5155
</p>
<form onSubmit={onSave}>
<div className="field">
<label htmlFor="api">API </label>
<input
id="api"
className="mono"
value={apiBase}
onChange={(e) => setApi(e.target.value)}
/>
</div>
<button className="btn" type="submit">
</button>
{saved ? <span style={{ marginLeft: 10, color: "var(--up)" }}></span> : null}
</form>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function StatsPage() {
return (
<div className="card">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}> / / 线</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function TradesPage() {
return (
<div className="card">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}> P1/P2 </p>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
:root {
--bg: #0b0e11;
--bg-elev: #12161c;
--bg-panel: #151a21;
--line: #1e2630;
--text: #eaecef;
--muted: #848e9c;
--accent: #f0b90b;
--up: #0ecb81;
--down: #f6465d;
--input: #0f141a;
--danger: #f6465d;
font-family: "IBM Plex Sans", sans-serif;
color: var(--text);
background: var(--bg);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
background:
radial-gradient(1200px 600px at 10% -10%, rgba(240, 185, 11, 0.08), transparent 55%),
radial-gradient(900px 500px at 100% 0%, rgba(14, 203, 129, 0.05), transparent 50%),
var(--bg);
}
button,
input {
font: inherit;
}
.mono {
font-family: "IBM Plex Mono", monospace;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
}
.topnav {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
background: rgba(11, 14, 17, 0.92);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
margin-right: 12px;
color: var(--accent);
}
.topnav a {
color: var(--muted);
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
}
.topnav a.active {
color: var(--text);
background: var(--bg-panel);
}
.topnav .spacer {
flex: 1;
}
.topnav .meta {
color: var(--muted);
font-size: 12px;
margin-right: 8px;
}
.page {
padding: 16px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 16px;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 800px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.kv {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
font-size: 14px;
}
.kv:last-child {
border-bottom: 0;
}
.kv span:first-child {
color: var(--muted);
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-box {
width: min(420px, 100%);
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: 14px;
padding: 28px 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.login-box h1 {
margin: 0 0 6px;
font-size: 22px;
}
.login-box p {
margin: 0 0 20px;
color: var(--muted);
font-size: 13px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.field label {
font-size: 12px;
color: var(--muted);
}
.field input {
background: var(--input);
border: 1px solid var(--line);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
outline: none;
}
.field input:focus {
border-color: rgba(240, 185, 11, 0.55);
}
.btn {
border: 0;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
background: var(--accent);
color: #111;
font-weight: 600;
}
.btn.ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
}
.btn.block {
width: 100%;
}
.err {
color: var(--danger);
font-size: 13px;
margin: 0 0 12px;
}
.hint {
margin-top: 12px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid var(--line);
color: var(--muted);
}
.tag.up {
color: var(--up);
border-color: rgba(14, 203, 129, 0.35);
}
.tag.down {
color: var(--down);
border-color: rgba(246, 70, 93, 0.35);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />