1252f45aac
Co-authored-by: Cursor <cursoragent@cursor.com>
477 lines
13 KiB
TypeScript
477 lines
13 KiB
TypeScript
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);
|
|
}
|
|
|
|
/** 与企微通知同一机器名(公开接口,登录前可用)。 */
|
|
export async function fetchMachineName(): Promise<string> {
|
|
try {
|
|
const res = await fetch(`${getApiBase()}/api/auth/branding`);
|
|
if (!res.ok) return "";
|
|
const data = (await res.json()) as { machine_name?: string };
|
|
return String(data.machine_name || "").trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
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") &&
|
|
!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<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: {
|
|
current_password: string;
|
|
new_username: string;
|
|
new_password: string;
|
|
}) {
|
|
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 = {
|
|
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;
|
|
option_qty_eth?: number | null;
|
|
strike?: number | null;
|
|
expiry_ymd?: string | null;
|
|
status?: string;
|
|
initial_premium?: number | null;
|
|
bid_px?: number | null;
|
|
bid_sz?: number | null;
|
|
bid_sz_eth?: number | null;
|
|
current_premium?: number | null;
|
|
recovery_pct?: number | null;
|
|
liquidity_ok?: boolean;
|
|
liquidity_detail?: string | null;
|
|
}[];
|
|
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;
|
|
residual_min_premium_pct?: number;
|
|
residual_close_check_sec?: 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<string, unknown>;
|
|
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<void> {
|
|
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;
|
|
}[];
|
|
};
|