Hide manual trade by default, block open while running, auto-refresh auth token.

Also fix flat-side reconcile to check both long and short residuals; document in 更新说明.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 22:43:38 +08:00
parent 0cf3756b09
commit 16efa44ffb
13 changed files with 304 additions and 38 deletions
+11 -2
View File
@@ -1,6 +1,11 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { clearSession, getToken, getUsername } from "./api/client";
import {
clearSession,
getToken,
getUsername,
startAuthTokenAutoRefresh,
} from "./api/client";
import LoginPage from "./pages/Login";
import PlanPage from "./pages/Plan";
import TradesPage from "./pages/Trades";
@@ -58,6 +63,10 @@ function Shell({ children }: { children: ReactNode }) {
}
function RequireAuth({ children }: { children: ReactNode }) {
useEffect(() => {
if (!getToken()) return;
return startAuthTokenAutoRefresh();
}, []);
if (!getToken()) return <Navigate to="/login" replace />;
return <Shell>{children}</Shell>;
}
+104 -3
View File
@@ -1,5 +1,6 @@
const TOKEN_KEY = "eth_hedge_token";
const USER_KEY = "eth_hedge_user";
const TOKEN_EXP_KEY = "eth_hedge_token_exp";
export function getApiBase(): string {
return window.location.origin;
@@ -9,14 +10,21 @@ export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setSession(token: string, username: string) {
export function setSession(token: string, username: string, expiresInSec?: number) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, username);
if (expiresInSec != null && expiresInSec > 0) {
localStorage.setItem(
TOKEN_EXP_KEY,
String(Date.now() + expiresInSec * 1000),
);
}
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_EXP_KEY);
localStorage.removeItem("eth_hedge_api_base");
}
@@ -24,10 +32,97 @@ export function getUsername(): string | null {
return localStorage.getItem(USER_KEY);
}
function tokenExpiresAtMs(): number | null {
const raw = localStorage.getItem(TOKEN_EXP_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
/** 距过期不足该毫秒则主动换发(默认 12h)。 */
const REFRESH_BEFORE_MS = 12 * 60 * 60 * 1000;
let _refreshInFlight: Promise<boolean> | null = null;
/**
* 用当前 Bearer 换发新 HMAC token(非 Flask session)。
* 登录态下由定时器/页面可见时静默调用。
*/
export async function refreshAuthToken(): Promise<boolean> {
const token = getToken();
if (!token) return false;
if (_refreshInFlight) return _refreshInFlight;
_refreshInFlight = (async () => {
try {
const res = await fetch(`${getApiBase()}/api/auth/refresh`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: "{}",
});
if (!res.ok) return false;
const data = (await res.json()) as LoginResult;
if (!data?.token) return false;
setSession(data.token, data.username || getUsername() || "", data.expires_in);
return true;
} catch {
return false;
} finally {
_refreshInFlight = null;
}
})();
return _refreshInFlight;
}
let _lastRefreshAttemptMs = 0;
/** 临近过期则刷新;返回是否仍持有有效 token。 */
export async function ensureFreshToken(): Promise<boolean> {
if (!getToken()) return false;
const exp = tokenExpiresAtMs();
const need =
exp == null || exp - Date.now() < REFRESH_BEFORE_MS;
if (!need) return true;
const now = Date.now();
// 无过期戳的旧会话:最多每 60s 尝试一次,避免轮询打爆 /refresh
if (exp == null && now - _lastRefreshAttemptMs < 60_000) {
return true;
}
_lastRefreshAttemptMs = now;
const ok = await refreshAuthToken();
if (!ok && exp != null && exp <= now) {
clearSession();
return false;
}
return !!getToken();
}
/** 在已登录壳内启动:定时换发 + 页面回到前台时检查。 */
export function startAuthTokenAutoRefresh(): () => void {
const tick = () => {
void ensureFreshToken();
};
tick();
const id = window.setInterval(tick, 30 * 60 * 1000);
const onVis = () => {
if (document.visibilityState === "visible") tick();
};
document.addEventListener("visibilitychange", onVis);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", onVis);
};
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
if (!path.startsWith("/api/auth/login")) {
await ensureFreshToken();
}
const base = getApiBase();
const headers = new Headers(options.headers || {});
if (!headers.has("Content-Type") && options.body) {
@@ -67,10 +162,12 @@ export type LoginResult = {
};
export async function login(username: string, password: string) {
return apiFetch<LoginResult>("/api/auth/login", {
const res = await apiFetch<LoginResult>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setSession(res.token, res.username, res.expires_in);
return res;
}
export async function changeCredentials(input: {
@@ -78,10 +175,12 @@ export async function changeCredentials(input: {
new_username: string;
new_password: string;
}) {
return apiFetch<LoginResult>("/api/auth/change-credentials", {
const res = await apiFetch<LoginResult>("/api/auth/change-credentials", {
method: "POST",
body: JSON.stringify(input),
});
setSession(res.token, res.username, res.expires_in);
return res;
}
export type MarketSnapshot = {
@@ -135,6 +234,7 @@ export type PlanState = {
max_atm_open_offset?: number;
can_open: boolean;
last_error: string | null;
show_manual_trade_buttons?: boolean;
position: {
has_position: boolean;
group_id?: string;
@@ -217,6 +317,7 @@ export type StrategySettings = {
close_bid_mark_max_pct: number;
perp_qty_eth: number;
option_qty_eth: number;
show_manual_trade_buttons?: boolean;
exchange: "okx" | "binance";
perp_inst_id?: string;
option_inst_family?: string;
+1 -1
View File
@@ -15,7 +15,7 @@ export default function LoginPage() {
setLoading(true);
try {
const res = await login(username.trim(), password);
setSession(res.token, res.username);
setSession(res.token, res.username, res.expires_in);
nav("/plan", { replace: true });
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
+35 -22
View File
@@ -144,11 +144,11 @@ export default function PlanPage() {
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
<button
className="btn"
className={plan?.running ? "btn btn-running" : "btn"}
type="button"
disabled={
!!busy ||
plan?.running ||
!!plan?.running ||
(plan?.mode === "LIVE" && plan.live_ready === false)
}
title={
@@ -158,7 +158,7 @@ export default function PlanPage() {
}
onClick={() => act("/api/plan/start", "start")}
>
{plan?.running ? "启动中" : "启动策略"}
</button>
<button
className="btn ghost"
@@ -168,26 +168,39 @@ export default function PlanPage() {
>
</button>
{plan?.show_manual_trade_buttons ? (
<>
<button
className="btn ghost"
type="button"
disabled={
!!busy ||
!!plan?.running ||
(plan?.mode === "LIVE" && plan.live_ready === false)
}
title={
plan?.running
? "策略自动运行中,禁止手动开仓"
: plan?.mode === "LIVE" && plan.live_ready === false
? plan.live_ready_reason || "LIVE 未就绪"
: undefined
}
onClick={() => act("/api/sim/open-group", "open")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy}
onClick={() => act("/api/sim/close-group", "close")}
>
</button>
</>
) : null}
<button
className="btn ghost"
type="button"
disabled={
!!busy || (plan?.mode === "LIVE" && plan.live_ready === false)
}
onClick={() => act("/api/sim/open-group", "open")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy}
onClick={() => act("/api/sim/close-group", "close")}
>
</button>
<button
className="btn ghost"
className="btn danger"
type="button"
disabled={!!busy}
onClick={() => act("/api/plan/emergency-close", "emg")}
+28 -5
View File
@@ -73,6 +73,7 @@ export default function SettingsPage() {
const [closeDevPct, setCloseDevPct] = useState(30);
const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2);
const [showManualTrade, setShowManualTrade] = useState(false);
const [initialEquity, setInitialEquity] = useState(10000);
const [exchange, setExchange] = useState<"okx" | "binance">("okx");
const [stratOk, setStratOk] = useState("");
@@ -113,6 +114,7 @@ export default function SettingsPage() {
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2);
setShowManualTrade(s.show_manual_trade_buttons === true);
setInitialEquity(s.initial_equity ?? 10000);
setExchange(s.exchange === "binance" ? "binance" : "okx");
})
@@ -139,7 +141,7 @@ export default function SettingsPage() {
new_username: newUsername.trim(),
new_password: newPassword,
});
setSession(res.token, res.username);
setSession(res.token, res.username, res.expires_in);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
@@ -173,6 +175,7 @@ export default function SettingsPage() {
close_bid_mark_max_pct: closeDevPct,
perp_qty_eth: perpQty,
option_qty_eth: optQty,
show_manual_trade_buttons: showManualTrade,
exchange,
};
// LIVE 不改模拟资金,避免误重置本地账本
@@ -564,6 +567,20 @@ export default function SettingsPage() {
onChange={(e) => setFee(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="showManual"></label>
<select
id="showManual"
className="mono"
value={showManualTrade ? "1" : "0"}
onChange={(e) =>
setShowManualTrade(e.target.value === "1")
}
>
<option value="0"></option>
<option value="1"> / </option>
</select>
</div>
</div>
</section>
) : null}
@@ -607,10 +624,16 @@ export default function SettingsPage() {
</li>
) : null}
{stratSub === "pace" ? (
<li>
LIVE / 1s
0.230
</li>
<>
<li>
LIVE / 1s
0.230
</li>
<li>
/
</li>
</>
) : null}
</ul>
</RulesFold>
+19
View File
@@ -613,6 +613,25 @@ input {
border: 1px solid var(--line);
}
.btn.danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
.btn.danger:hover:not(:disabled) {
background: rgba(246, 70, 93, 0.12);
}
.btn.btn-running,
.btn.btn-running:disabled {
background: rgba(14, 203, 129, 0.16);
color: #0ecb81;
border: 1px solid #0ecb81;
opacity: 1;
cursor: default;
}
.btn.block {
width: 100%;
}