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; } export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } 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"); } 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 | null = null; /** * 用当前 Bearer 换发新 HMAC token(非 Flask session)。 * 登录态下由定时器/页面可见时静默调用。 */ export async function refreshAuthToken(): Promise { 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 { 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( path: string, options: RequestInit = {}, ): Promise { if ( !path.startsWith("/api/auth/login") && !path.startsWith("/api/auth/fleet-exchange") ) { await ensureFreshToken(); } 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) { const res = await apiFetch("/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: { current_password: string; new_username: string; new_password: string; }) { const res = await apiFetch("/api/auth/change-credentials", { method: "POST", body: JSON.stringify(input), }); setSession(res.token, res.username, res.expires_in); return res; } export type MarketSnapshot = { connected: boolean; updated_at_ms: number | null; index_px: number | null; exchange?: string; perp_inst_id?: string; 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; }; export type PlanState = { running: boolean; phase: string; rounds_done: number; window_key: string | null; rest_left_sec: number; rest_seconds: number; skip_weekends?: boolean; one_expiry_per_day?: boolean; exit_move_pct?: number; exit_mode: "fixed_usdt" | "premium_multiple"; net_profit_target: number; premium_exit_multiple: number; exit_target_usdt: number; leverage: number; perp_margin_mode?: "cross" | "isolated"; perp_qty_eth?: number; option_qty_eth?: number; min_option_hours: number; min_option_leverage: number; atm_open_offset_enabled?: boolean; max_atm_open_offset?: number; fixed_direction_enabled?: boolean; fixed_perp_side?: "long" | "short"; can_open: boolean; open_capacity?: { leverage?: number; perp_can_open?: boolean | null; option_can_open?: boolean | null; perp_label?: string; option_label?: string; funds_ok?: boolean; perp_need_usdt?: number | null; option_need_usdc?: number | null; perp_have_usdt?: number | null; option_have_usdc?: number | null; }; last_error: string | null; show_manual_trade_buttons?: boolean; position: { has_position: boolean; group_id?: string; open_at_ms?: number | null; perp_side?: string; option_side?: string; perp_inst_id?: string; perp_entry_px?: number; perp_qty_eth?: number; perp_mark_px?: number | null; perp_notional?: number; perp_margin?: number | null; leverage?: number; option_inst_id?: string; option_entry_px?: number; option_qty_eth?: number; option_qty_contracts?: number; option_mark_px?: number | null; option_bid_sz?: number | null; option_leverage?: number | null; strike?: number | null; expiry_ymd?: string | null; expiry_ms?: number | null; perp_upl?: number; option_upl?: number; fees_paid?: number; funding_usdt?: number; est_close_fees?: number; net_pnl?: number; pnl_source?: string; index_px?: number | null; entry_index_px?: number; move_points?: number; move_pct?: number; initial_premium?: number; premium_gap?: number; }; residuals?: { group_id: string; option_inst_id: string; option_side: string; strike: number | null; expiry_ymd: string | null; status: string; }[]; ledger: { equity: number; available: number; reserved: number }; mode?: "SIM" | "LIVE"; sim?: boolean; live_ready?: boolean; live_ready_reason?: string; }; export type StrategySettings = { fee_rate: number; exit_mode: "fixed_usdt" | "premium_multiple"; net_profit_target: number; premium_exit_multiple: number; rest_seconds: number; live_order_interval_sec?: number; skip_weekends?: boolean; one_expiry_per_day?: boolean; initial_equity?: number; leverage?: number; perp_margin_mode?: "cross" | "isolated"; min_option_hours?: number; min_option_leverage?: number; atm_open_offset_enabled?: boolean; max_atm_open_offset?: number; fixed_direction_enabled?: boolean; fixed_perp_side?: "long" | "short"; close_bid_mark_max_pct?: number; perp_qty_eth?: number; option_qty_eth?: number; show_manual_trade_buttons?: boolean; sizing_mode?: "manual" | "risk_based"; risk_leverage_basis?: "actual" | "selection"; risk_loss_mode?: "percent" | "absolute"; risk_loss_pct?: number; risk_loss_usdt?: number; risk_capital_source?: "trading_account" | "manual"; risk_manual_capital_usdt?: number; risk_perp_unit?: number; risk_option_unit?: number; risk_exit_unit?: number; risk_sizing_preview?: Record; exchange?: string; }; export type RuntimeSettings = { mode: "SIM" | "LIVE"; exchange: string; okx_configured: boolean; binance_configured: boolean; okx_api_key_masked: string | null; okx_api_secret_masked: string | null; okx_api_passphrase_masked: string | null; binance_api_key_masked: string | null; binance_api_secret_masked: string | null; live_ready: boolean; live_ready_reason: string; sim: boolean; }; export type NotifySettings = { enabled: boolean; webhook_configured: boolean; webhook_url_masked: string | null; venue_label: string; machine_name?: string; }; export async function downloadBackup(name: string): Promise { await ensureFreshToken(); const token = getToken(); const res = await fetch( `${getApiBase()}/api/backup/download/${encodeURIComponent(name)}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }, ); if (res.status === 401) { clearSession(); throw new Error("unauthorized"); } if (!res.ok) { const text = await res.text(); let detail = res.statusText; try { const j = JSON.parse(text) as { detail?: string }; if (j.detail) detail = String(j.detail); } catch { if (text) detail = text; } throw new Error(detail || `HTTP ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } export async function uploadRestoreBackup( file: File, confirmPhrase: string, ): Promise<{ detail?: string; restart_required?: boolean }> { await ensureFreshToken(); const token = getToken(); const res = await fetch(`${getApiBase()}/api/backup/restore`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "X-Confirm-Phrase": confirmPhrase, "Content-Type": "application/zip", }, body: file, }); 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 { detail?: string; restart_required?: boolean }; } export type BackupStatus = { auto_enabled: boolean; interval_hours: number; keep_count: number; last_at_ms: number | null; backup_dir: string; items: { name: string; path: string; size_bytes: number; mtime_ms: number; }[]; };