2b348854fe
Backups land under /root/eth_hedge_backups; restore accepts zip body for new-server migration and restarts the process. Co-authored-by: Cursor <cursoragent@cursor.com>
1133 lines
40 KiB
TypeScript
1133 lines
40 KiB
TypeScript
import { FormEvent, ReactNode, useEffect, useState } from "react";
|
||
import {
|
||
changeCredentials,
|
||
getUsername,
|
||
setSession,
|
||
apiFetch,
|
||
StrategySettings,
|
||
RuntimeSettings,
|
||
BackupStatus,
|
||
downloadBackup,
|
||
uploadRestoreBackup,
|
||
} from "../api/client";
|
||
|
||
type Tab = "strategy" | "runtime" | "account" | "backup";
|
||
type StratSub =
|
||
| "position"
|
||
| "select"
|
||
| "exit"
|
||
| "pace";
|
||
|
||
function RulesFold({
|
||
open,
|
||
onToggle,
|
||
children,
|
||
}: {
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className={`settings-rules ${open ? "open" : ""}`}>
|
||
<button
|
||
type="button"
|
||
className="settings-rules-toggle"
|
||
aria-expanded={open}
|
||
onClick={onToggle}
|
||
>
|
||
<span>规则说明</span>
|
||
<span className="settings-rules-chevron" aria-hidden>
|
||
{open ? "▾" : "▸"}
|
||
</span>
|
||
</button>
|
||
{open ? <div className="settings-rules-body">{children}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
const [tab, setTab] = useState<Tab>("strategy");
|
||
const [stratSub, setStratSub] = useState<StratSub>("position");
|
||
const [stratRulesOpen, setStratRulesOpen] = useState(false);
|
||
const [runtimeRulesOpen, setRuntimeRulesOpen] = useState(false);
|
||
|
||
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
||
const [currentPassword, setCurrentPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [confirmPassword, setConfirmPassword] = useState("");
|
||
const [err, setErr] = useState("");
|
||
const [ok, setOk] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const [fee, setFee] = useState(0.0005);
|
||
const [exitMode, setExitMode] = useState<"fixed_usdt" | "premium_multiple">(
|
||
"fixed_usdt",
|
||
);
|
||
const [netTarget, setNetTarget] = useState(15);
|
||
const [premMult, setPremMult] = useState(1);
|
||
const [rest, setRest] = useState(300);
|
||
const [orderInterval, setOrderInterval] = useState(1);
|
||
const [skipWeekends, setSkipWeekends] = useState(true);
|
||
const [leverage, setLeverage] = useState(3);
|
||
const [minHours, setMinHours] = useState(12);
|
||
const [minOptLev, setMinOptLev] = useState(100);
|
||
const [atmOffOn, setAtmOffOn] = useState(false);
|
||
const [maxAtmOff, setMaxAtmOff] = useState(3);
|
||
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("");
|
||
|
||
const [runtime, setRuntime] = useState<RuntimeSettings | null>(null);
|
||
const [mode, setMode] = useState<"SIM" | "LIVE">("SIM");
|
||
const [okxKey, setOkxKey] = useState("");
|
||
const [okxSecret, setOkxSecret] = useState("");
|
||
const [okxPass, setOkxPass] = useState("");
|
||
const [bnKey, setBnKey] = useState("");
|
||
const [bnSecret, setBnSecret] = useState("");
|
||
const [runtimeOk, setRuntimeOk] = useState("");
|
||
|
||
const [backup, setBackup] = useState<BackupStatus | null>(null);
|
||
const [bakAuto, setBakAuto] = useState(true);
|
||
const [bakInterval, setBakInterval] = useState(24);
|
||
const [bakKeep, setBakKeep] = useState(14);
|
||
const [bakOk, setBakOk] = useState("");
|
||
const [bakBusy, setBakBusy] = useState("");
|
||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||
const [restorePhrase, setRestorePhrase] = useState("");
|
||
|
||
function loadRuntime() {
|
||
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
||
.then((r) => {
|
||
setRuntime(r);
|
||
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
|
||
})
|
||
.catch(() => undefined);
|
||
}
|
||
|
||
function loadBackup() {
|
||
apiFetch<BackupStatus>("/api/backup/status")
|
||
.then((s) => {
|
||
setBackup(s);
|
||
setBakAuto(s.auto_enabled !== false);
|
||
setBakInterval(s.interval_hours ?? 24);
|
||
setBakKeep(s.keep_count ?? 14);
|
||
})
|
||
.catch(() => undefined);
|
||
}
|
||
|
||
useEffect(() => {
|
||
apiFetch<StrategySettings>("/api/settings/strategy")
|
||
.then((s) => {
|
||
setFee(s.fee_rate);
|
||
setExitMode(s.exit_mode === "premium_multiple" ? "premium_multiple" : "fixed_usdt");
|
||
setNetTarget(s.net_profit_target ?? 15);
|
||
setPremMult(s.premium_exit_multiple ?? 1);
|
||
setRest(s.rest_seconds);
|
||
setOrderInterval(s.live_order_interval_sec ?? 1);
|
||
setSkipWeekends(s.skip_weekends !== false);
|
||
setLeverage(s.leverage ?? 3);
|
||
setMinHours(s.min_option_hours ?? 12);
|
||
setMinOptLev(s.min_option_leverage ?? 100);
|
||
setAtmOffOn(s.atm_open_offset_enabled === true);
|
||
setMaxAtmOff(s.max_atm_open_offset ?? 3);
|
||
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");
|
||
})
|
||
.catch(() => undefined);
|
||
loadRuntime();
|
||
}, []);
|
||
|
||
async function onSaveBackupSettings(e: FormEvent) {
|
||
e.preventDefault();
|
||
setErr("");
|
||
setBakOk("");
|
||
try {
|
||
const s = await apiFetch<BackupStatus>("/api/backup/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
auto_enabled: bakAuto,
|
||
interval_hours: bakInterval,
|
||
keep_count: bakKeep,
|
||
}),
|
||
});
|
||
setBackup(s);
|
||
setBakOk("备份设置已保存");
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
}
|
||
}
|
||
|
||
async function onBackupNow() {
|
||
setErr("");
|
||
setBakOk("");
|
||
setBakBusy("备份中");
|
||
try {
|
||
const r = await apiFetch<{ name: string; backup_dir: string }>(
|
||
"/api/backup/now",
|
||
{ method: "POST", body: "{}" },
|
||
);
|
||
setBakOk(`已备份 ${r.name} → ${r.backup_dir}`);
|
||
loadBackup();
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
} finally {
|
||
setBakBusy("");
|
||
}
|
||
}
|
||
|
||
async function onDownload(name: string) {
|
||
setErr("");
|
||
setBakBusy("下载中");
|
||
try {
|
||
await downloadBackup(name);
|
||
setBakOk(`已下载 ${name}`);
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
} finally {
|
||
setBakBusy("");
|
||
}
|
||
}
|
||
|
||
async function onRestore() {
|
||
setErr("");
|
||
setBakOk("");
|
||
if (!restoreFile) {
|
||
setErr("请先选择备份 zip");
|
||
return;
|
||
}
|
||
if (restorePhrase.trim() !== "RESTORE") {
|
||
setErr("请输入确认串 RESTORE(大写)");
|
||
return;
|
||
}
|
||
if (
|
||
!window.confirm(
|
||
"将用备份覆盖本机数据库与 .env,服务会自动重启。新服务器迁移请确认包来自可信来源。继续?",
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
setBakBusy("恢复中");
|
||
try {
|
||
const r = await uploadRestoreBackup(restoreFile, restorePhrase.trim());
|
||
setBakOk(r.detail || "恢复成功,请等待重启后刷新");
|
||
window.setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2500);
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
} finally {
|
||
setBakBusy("");
|
||
}
|
||
}
|
||
|
||
async function onSaveCreds(e: FormEvent) {
|
||
e.preventDefault();
|
||
setErr("");
|
||
setOk("");
|
||
if (newPassword !== confirmPassword) {
|
||
setErr("两次输入的新密码不一致");
|
||
return;
|
||
}
|
||
if (newPassword.length < 4) {
|
||
setErr("新密码至少 4 位");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const res = await changeCredentials({
|
||
current_password: currentPassword,
|
||
new_username: newUsername.trim(),
|
||
new_password: newPassword,
|
||
});
|
||
setSession(res.token, res.username, res.expires_in);
|
||
setCurrentPassword("");
|
||
setNewPassword("");
|
||
setConfirmPassword("");
|
||
setOk("用户名/密码已更新");
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function onSaveStrategy(e: FormEvent) {
|
||
e.preventDefault();
|
||
setStratOk("");
|
||
setErr("");
|
||
const isLive = (runtime?.mode ?? mode) === "LIVE";
|
||
try {
|
||
const body: Record<string, unknown> = {
|
||
fee_rate: fee,
|
||
exit_mode: exitMode,
|
||
net_profit_target: netTarget,
|
||
premium_exit_multiple: premMult,
|
||
rest_seconds: rest,
|
||
live_order_interval_sec: orderInterval,
|
||
skip_weekends: skipWeekends,
|
||
leverage,
|
||
min_option_hours: minHours,
|
||
min_option_leverage: minOptLev,
|
||
atm_open_offset_enabled: atmOffOn,
|
||
max_atm_open_offset: maxAtmOff,
|
||
close_bid_mark_max_pct: closeDevPct,
|
||
perp_qty_eth: perpQty,
|
||
option_qty_eth: optQty,
|
||
show_manual_trade_buttons: showManualTrade,
|
||
exchange,
|
||
};
|
||
// LIVE 不改模拟资金,避免误重置本地账本
|
||
if (!isLive) {
|
||
body.initial_equity = initialEquity;
|
||
}
|
||
await apiFetch("/api/settings/strategy", {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
});
|
||
setStratOk(
|
||
isLive
|
||
? "策略参数已保存(实盘模式不改模拟资金)"
|
||
: "策略参数已保存(切换交易所/改模拟资金需无持仓;切换后会重连行情)",
|
||
);
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
}
|
||
}
|
||
|
||
async function onSaveRuntime(e: FormEvent) {
|
||
e.preventDefault();
|
||
setErr("");
|
||
setRuntimeOk("");
|
||
const goingLive = mode === "LIVE" && runtime?.mode !== "LIVE";
|
||
if (goingLive) {
|
||
const typed = window.prompt('切换到 LIVE 实盘:请输入 LIVE 确认(将真实下单)');
|
||
if (typed !== "LIVE") {
|
||
setErr("已取消:须输入 LIVE 才能切换到实盘");
|
||
return;
|
||
}
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const body: Record<string, unknown> = {
|
||
mode,
|
||
confirm_live: goingLive,
|
||
confirm_live_phrase: goingLive ? "LIVE" : undefined,
|
||
};
|
||
if (okxKey.trim()) body.okx_api_key = okxKey.trim();
|
||
if (okxSecret.trim()) body.okx_api_secret = okxSecret.trim();
|
||
if (okxPass.trim()) body.okx_api_passphrase = okxPass.trim();
|
||
if (bnKey.trim()) body.binance_api_key = bnKey.trim();
|
||
if (bnSecret.trim()) body.binance_api_secret = bnSecret.trim();
|
||
const r = await apiFetch<RuntimeSettings>("/api/settings/runtime", {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
});
|
||
setRuntime(r);
|
||
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
|
||
setOkxKey("");
|
||
setOkxSecret("");
|
||
setOkxPass("");
|
||
setBnKey("");
|
||
setBnSecret("");
|
||
setRuntimeOk(
|
||
r.mode === "LIVE"
|
||
? r.live_ready
|
||
? "已切换 LIVE,密钥已写入 .env"
|
||
: `已切 LIVE,但未就绪:${r.live_ready_reason}`
|
||
: "已切换 SIM,配置已写入 .env",
|
||
);
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="settings-page">
|
||
<h2 style={{ marginTop: 0 }}>系统设置</h2>
|
||
<div className="tabs">
|
||
<button
|
||
type="button"
|
||
className={tab === "strategy" ? "tab active" : "tab"}
|
||
onClick={() => {
|
||
setTab("strategy");
|
||
loadRuntime();
|
||
}}
|
||
>
|
||
策略设置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tab === "runtime" ? "tab active" : "tab"}
|
||
onClick={() => {
|
||
setTab("runtime");
|
||
loadRuntime();
|
||
}}
|
||
>
|
||
运行模式
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tab === "account" ? "tab active" : "tab"}
|
||
onClick={() => setTab("account")}
|
||
>
|
||
登录账户
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tab === "backup" ? "tab active" : "tab"}
|
||
onClick={() => {
|
||
setTab("backup");
|
||
loadBackup();
|
||
}}
|
||
>
|
||
备份恢复
|
||
</button>
|
||
</div>
|
||
|
||
{tab === "strategy" ? (
|
||
<div className="card settings-card">
|
||
{stratOk ? <div className="settings-ok">{stratOk}</div> : null}
|
||
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
|
||
|
||
<div className="settings-subtabs" role="tablist" aria-label="策略子分类">
|
||
{(
|
||
[
|
||
["position", "仓位"],
|
||
["select", "选约"],
|
||
["exit", "出场"],
|
||
["pace", "节奏"],
|
||
] as const
|
||
).map(([id, label]) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={stratSub === id}
|
||
className={
|
||
stratSub === id ? "settings-subtab active" : "settings-subtab"
|
||
}
|
||
onClick={() => setStratSub(id)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<form onSubmit={onSaveStrategy}>
|
||
{stratSub === "position" ? (
|
||
<section className="settings-section">
|
||
<h3>交易所与仓位</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="exch">交易所</label>
|
||
<select
|
||
id="exch"
|
||
className="mono"
|
||
value={exchange}
|
||
onChange={(e) =>
|
||
setExchange(
|
||
e.target.value === "binance" ? "binance" : "okx",
|
||
)
|
||
}
|
||
>
|
||
<option value="okx">OKX(USDC 期权族)</option>
|
||
<option value="binance">
|
||
币安(USDT 永续 + 欧洲期权)
|
||
</option>
|
||
</select>
|
||
</div>
|
||
{(runtime?.mode ?? mode) !== "LIVE" ? (
|
||
<div className="field">
|
||
<label htmlFor="equity">模拟资金(USDT)</label>
|
||
<input
|
||
id="equity"
|
||
className="mono"
|
||
type="number"
|
||
step="100"
|
||
min="1000"
|
||
value={initialEquity}
|
||
onChange={(e) =>
|
||
setInitialEquity(Number(e.target.value))
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="field">
|
||
<label htmlFor="lev">永续杠杆倍数</label>
|
||
<input
|
||
id="lev"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={leverage}
|
||
onChange={(e) => setLeverage(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="perp">永续 ETH 数量</label>
|
||
<input
|
||
id="perp"
|
||
className="mono"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
value={perpQty}
|
||
onChange={(e) => setPerpQty(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="opt">期权 ETH 数量</label>
|
||
<input
|
||
id="opt"
|
||
className="mono"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
value={optQty}
|
||
onChange={(e) => setOptQty(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{stratSub === "select" ? (
|
||
<section className="settings-section">
|
||
<h3>选约条件</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="hours">期权最小剩余小时</label>
|
||
<input
|
||
id="hours"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={minHours}
|
||
onChange={(e) => setMinHours(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="olev">
|
||
期权杠杆下限(现价÷卖一权利金)
|
||
</label>
|
||
<input
|
||
id="olev"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={minOptLev}
|
||
onChange={(e) => setMinOptLev(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="atmoffOn">开仓 ATM 偏差限制</label>
|
||
<select
|
||
id="atmoffOn"
|
||
className="mono"
|
||
value={atmOffOn ? "1" : "0"}
|
||
onChange={(e) => setAtmOffOn(e.target.value === "1")}
|
||
>
|
||
<option value="0">关闭(默认)</option>
|
||
<option value="1">开启</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="atmoff">开仓 ATM 最大偏差(点)</label>
|
||
<input
|
||
id="atmoff"
|
||
className="mono"
|
||
type="number"
|
||
step="0.5"
|
||
min="0"
|
||
disabled={!atmOffOn}
|
||
value={maxAtmOff}
|
||
onChange={(e) => setMaxAtmOff(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{stratSub === "exit" ? (
|
||
<section className="settings-section">
|
||
<h3>出场与平仓</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="exitMode">出场规则</label>
|
||
<select
|
||
id="exitMode"
|
||
className="mono"
|
||
value={exitMode}
|
||
onChange={(e) =>
|
||
setExitMode(
|
||
e.target.value === "premium_multiple"
|
||
? "premium_multiple"
|
||
: "fixed_usdt",
|
||
)
|
||
}
|
||
>
|
||
<option value="fixed_usdt">固定净盈利(USDT)</option>
|
||
<option value="premium_multiple">权利金倍数</option>
|
||
</select>
|
||
</div>
|
||
{exitMode === "fixed_usdt" ? (
|
||
<div className="field">
|
||
<label htmlFor="netTarget">净盈利出场目标(USDT)</label>
|
||
<input
|
||
id="netTarget"
|
||
className="mono"
|
||
type="number"
|
||
step="0.1"
|
||
min="0.1"
|
||
value={netTarget}
|
||
onChange={(e) => setNetTarget(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="field">
|
||
<label htmlFor="premMult">
|
||
权利金倍数(1 = 一倍权利金)
|
||
</label>
|
||
<input
|
||
id="premMult"
|
||
className="mono"
|
||
type="number"
|
||
step="0.1"
|
||
min="0.1"
|
||
value={premMult}
|
||
onChange={(e) => setPremMult(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label htmlFor="closeDev">平仓买一/标记最大偏差(%)</label>
|
||
<input
|
||
id="closeDev"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={closeDevPct}
|
||
onChange={(e) => setCloseDevPct(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{stratSub === "pace" ? (
|
||
<section className="settings-section">
|
||
<h3>节奏与费用</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="rest">轮间休息秒数</label>
|
||
<input
|
||
id="rest"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
value={rest}
|
||
onChange={(e) => setRest(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="orderInterval">
|
||
实盘下单最小间隔(秒)
|
||
</label>
|
||
<input
|
||
id="orderInterval"
|
||
className="mono"
|
||
type="number"
|
||
step="0.1"
|
||
min="0.2"
|
||
max="30"
|
||
value={orderInterval}
|
||
onChange={(e) =>
|
||
setOrderInterval(Number(e.target.value))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="skipWe">周六日跳过开仓(上海时区)</label>
|
||
<select
|
||
id="skipWe"
|
||
className="mono"
|
||
value={skipWeekends ? "1" : "0"}
|
||
onChange={(e) => setSkipWeekends(e.target.value === "1")}
|
||
>
|
||
<option value="1">开启(推荐)</option>
|
||
<option value="0">关闭</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="fee">手续费率</label>
|
||
<input
|
||
id="fee"
|
||
className="mono"
|
||
type="number"
|
||
step="0.0001"
|
||
value={fee}
|
||
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}
|
||
|
||
<RulesFold
|
||
open={stratRulesOpen}
|
||
onToggle={() => setStratRulesOpen((v) => !v)}
|
||
>
|
||
<ul className="settings-rules-list">
|
||
<li>
|
||
{(runtime?.mode ?? mode) === "LIVE"
|
||
? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。"
|
||
: "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
|
||
</li>
|
||
{stratSub === "position" ? (
|
||
<>
|
||
<li>交易所:有持仓时不可切换。切换后套用该所合约并重连行情。</li>
|
||
{(runtime?.mode ?? mode) !== "LIVE" ? (
|
||
<li>
|
||
模拟资金:仅 SIM 显示。保存后重置权益(须无持仓)。默认
|
||
10000。
|
||
</li>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
{stratSub === "select" ? (
|
||
<>
|
||
<li>
|
||
开仓 ATM 偏差限制:关闭时不限制 |ATM −
|
||
现价|;开启后超过下方点数则不开仓。
|
||
</li>
|
||
<li>
|
||
开仓 ATM 最大偏差:仅开关开启时生效。币安行权价较粗时可能长时间无开仓机会。
|
||
</li>
|
||
</>
|
||
) : null}
|
||
{stratSub === "exit" ? (
|
||
<li>
|
||
平仓买一/标记最大偏差:主要用于 SIM
|
||
流动性闸门;LIVE 以交易所能否成交为准。
|
||
</li>
|
||
) : null}
|
||
{stratSub === "pace" ? (
|
||
<>
|
||
<li>
|
||
实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围
|
||
0.2–30。
|
||
</li>
|
||
<li>
|
||
显示手动开仓:默认关闭。开启后计划页才出现「手动开一组 /
|
||
手动全平」。策略自动运行中禁止手动开仓(须先暂停);紧急全平始终可用。
|
||
</li>
|
||
</>
|
||
) : null}
|
||
</ul>
|
||
</RulesFold>
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn" type="submit">
|
||
保存策略
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
) : null}
|
||
|
||
{tab === "runtime" ? (
|
||
<div className="card settings-card">
|
||
{runtimeOk ? <div className="settings-ok">{runtimeOk}</div> : null}
|
||
{err && tab === "runtime" ? <div className="err">{err}</div> : null}
|
||
<form onSubmit={onSaveRuntime}>
|
||
<section className="settings-section">
|
||
<h3>模式</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="mode">运行模式</label>
|
||
<select
|
||
id="mode"
|
||
className="mono"
|
||
value={mode}
|
||
onChange={(e) =>
|
||
setMode(e.target.value === "LIVE" ? "LIVE" : "SIM")
|
||
}
|
||
>
|
||
<option value="SIM">SIM 本地模拟</option>
|
||
<option value="LIVE">LIVE 实盘</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<h3>OKX API</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="okxKey">API Key</label>
|
||
<input
|
||
id="okxKey"
|
||
className="mono"
|
||
type="password"
|
||
autoComplete="off"
|
||
placeholder={
|
||
runtime?.okx_api_key_masked
|
||
? `已配置 ${runtime.okx_api_key_masked}`
|
||
: "未配置"
|
||
}
|
||
value={okxKey}
|
||
onChange={(e) => setOkxKey(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="okxSecret">Secret</label>
|
||
<input
|
||
id="okxSecret"
|
||
className="mono"
|
||
type="password"
|
||
autoComplete="off"
|
||
placeholder={
|
||
runtime?.okx_api_secret_masked
|
||
? `已配置 ${runtime.okx_api_secret_masked}`
|
||
: "未配置"
|
||
}
|
||
value={okxSecret}
|
||
onChange={(e) => setOkxSecret(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="okxPass">Passphrase</label>
|
||
<input
|
||
id="okxPass"
|
||
className="mono"
|
||
type="password"
|
||
autoComplete="off"
|
||
placeholder={
|
||
runtime?.okx_api_passphrase_masked
|
||
? `已配置 ${runtime.okx_api_passphrase_masked}`
|
||
: "未配置"
|
||
}
|
||
value={okxPass}
|
||
onChange={(e) => setOkxPass(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<h3>币安 API</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="bnKey">API Key</label>
|
||
<input
|
||
id="bnKey"
|
||
className="mono"
|
||
type="password"
|
||
autoComplete="off"
|
||
placeholder={
|
||
runtime?.binance_api_key_masked
|
||
? `已配置 ${runtime.binance_api_key_masked}`
|
||
: "未配置"
|
||
}
|
||
value={bnKey}
|
||
onChange={(e) => setBnKey(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="bnSecret">Secret</label>
|
||
<input
|
||
id="bnSecret"
|
||
className="mono"
|
||
type="password"
|
||
autoComplete="off"
|
||
placeholder={
|
||
runtime?.binance_api_secret_masked
|
||
? `已配置 ${runtime.binance_api_secret_masked}`
|
||
: "未配置"
|
||
}
|
||
value={bnSecret}
|
||
onChange={(e) => setBnSecret(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<RulesFold
|
||
open={runtimeRulesOpen}
|
||
onToggle={() => setRuntimeRulesOpen((v) => !v)}
|
||
>
|
||
<ul className="settings-rules-list">
|
||
<li>
|
||
SIM = 本地撮合;LIVE =
|
||
当前所选交易所真下单(OKX / 币安均已接入)。密钥只写入服务器{" "}
|
||
<span className="mono">.env</span>
|
||
,接口不回传明文。OKX 期权常需账户有 USDC;币安多为 USDT。
|
||
</li>
|
||
<li>
|
||
当前:{runtime?.mode || "—"} · 交易所{" "}
|
||
{runtime?.exchange || "—"} ·{" "}
|
||
{runtime?.mode === "LIVE"
|
||
? runtime.live_ready
|
||
? "LIVE 就绪"
|
||
: `未就绪(${runtime.live_ready_reason})`
|
||
: "SIM"}
|
||
。有持仓时不可切换模式。
|
||
</li>
|
||
<li>
|
||
密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE
|
||
时用币安密钥真下单(无 Passphrase)。
|
||
</li>
|
||
</ul>
|
||
</RulesFold>
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn" type="submit" disabled={loading}>
|
||
{loading ? "保存中…" : "保存模式与密钥"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
) : null}
|
||
|
||
{tab === "account" ? (
|
||
<div className="card settings-card settings-card-narrow">
|
||
{err ? <div className="err">{err}</div> : null}
|
||
{ok ? <div className="settings-ok">{ok}</div> : null}
|
||
<form onSubmit={onSaveCreds} className="settings-account-form">
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="user">新用户名</label>
|
||
<input
|
||
id="user"
|
||
value={newUsername}
|
||
onChange={(e) => setNewUsername(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="cur">当前密码</label>
|
||
<input
|
||
id="cur"
|
||
type="password"
|
||
value={currentPassword}
|
||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="np">新密码</label>
|
||
<input
|
||
id="np"
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="cp">确认新密码</label>
|
||
<input
|
||
id="cp"
|
||
type="password"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn" type="submit" disabled={loading}>
|
||
{loading ? "保存中…" : "保存账号"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
) : null}
|
||
|
||
{tab === "backup" ? (
|
||
<div className="card settings-card">
|
||
{bakOk ? <div className="settings-ok">{bakOk}</div> : null}
|
||
{err && tab === "backup" ? <div className="err">{err}</div> : null}
|
||
{bakBusy ? <div className="meta">{bakBusy}…</div> : null}
|
||
|
||
<form onSubmit={onSaveBackupSettings}>
|
||
<section className="settings-section">
|
||
<h3>自动备份</h3>
|
||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||
备份内容:SQLite 数据库 +{" "}
|
||
<span className="mono">.env</span>(含登录与交易所密钥)。服务器目录:
|
||
<span className="mono">
|
||
{" "}
|
||
{backup?.backup_dir || "/root/eth_hedge_backups"}
|
||
</span>
|
||
</p>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="bakAuto">自动备份</label>
|
||
<select
|
||
id="bakAuto"
|
||
className="mono"
|
||
value={bakAuto ? "1" : "0"}
|
||
onChange={(e) => setBakAuto(e.target.value === "1")}
|
||
>
|
||
<option value="1">开启(默认)</option>
|
||
<option value="0">关闭</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="bakInt">间隔(小时)</label>
|
||
<input
|
||
id="bakInt"
|
||
className="mono"
|
||
type="number"
|
||
min={1}
|
||
max={168}
|
||
value={bakInterval}
|
||
onChange={(e) => setBakInterval(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="bakKeep">保留份数</label>
|
||
<input
|
||
id="bakKeep"
|
||
className="mono"
|
||
type="number"
|
||
min={1}
|
||
max={90}
|
||
value={bakKeep}
|
||
onChange={(e) => setBakKeep(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions" style={{ gap: 8 }}>
|
||
<button className="btn" type="submit">
|
||
保存备份设置
|
||
</button>
|
||
<button
|
||
className="btn ghost"
|
||
type="button"
|
||
disabled={!!bakBusy}
|
||
onClick={() => void onBackupNow()}
|
||
>
|
||
立即备份
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</form>
|
||
|
||
<section className="settings-section">
|
||
<h3>服务器备份列表</h3>
|
||
{backup?.items && backup.items.length > 0 ? (
|
||
<div className="settings-fields">
|
||
{backup.items.map((it) => (
|
||
<div
|
||
key={it.name}
|
||
className="field"
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 8,
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<span className="mono" style={{ flex: "1 1 220px" }}>
|
||
{it.name}
|
||
<span style={{ color: "var(--muted)" }}>
|
||
{" "}
|
||
· {(it.size_bytes / 1024).toFixed(1)} KB ·{" "}
|
||
{new Date(it.mtime_ms).toLocaleString()}
|
||
</span>
|
||
</span>
|
||
<button
|
||
className="btn ghost"
|
||
type="button"
|
||
disabled={!!bakBusy}
|
||
onClick={() => void onDownload(it.name)}
|
||
>
|
||
下载
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p style={{ color: "var(--muted)" }}>暂无备份,可点「立即备份」。</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<h3>上传恢复(新服务器迁移)</h3>
|
||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||
在新机器部署后,上传旧机下载的 zip,将覆盖数据库与{" "}
|
||
<span className="mono">.env</span>
|
||
。须无持仓;确认串填写{" "}
|
||
<span className="mono">RESTORE</span>
|
||
。恢复后服务自动重启。
|
||
</p>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="bakFile">备份 zip</label>
|
||
<input
|
||
id="bakFile"
|
||
type="file"
|
||
accept=".zip,application/zip"
|
||
onChange={(e) =>
|
||
setRestoreFile(e.target.files?.[0] ?? null)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="bakPhrase">确认串</label>
|
||
<input
|
||
id="bakPhrase"
|
||
className="mono"
|
||
value={restorePhrase}
|
||
placeholder="RESTORE"
|
||
onChange={(e) => setRestorePhrase(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button
|
||
className="btn danger"
|
||
type="button"
|
||
disabled={!!bakBusy}
|
||
onClick={() => void onRestore()}
|
||
>
|
||
上传并恢复
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|