Files
eth_hedge_sim/frontend/src/pages/Settings.tsx
T
dekun cd88fa5d83 Hide perp-option settings when option-option hedge is selected.
Show OO selection and reward-ratio fields in their tabs instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:21:36 +08:00

2384 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FormEvent, ReactNode, useEffect, useState } from "react";
import {
changeCredentials,
getUsername,
setSession,
apiFetch,
StrategySettings,
RuntimeSettings,
NotifySettings,
BackupStatus,
downloadBackup,
uploadRestoreBackup,
} from "../api/client";
type Tab = "strategy" | "runtime" | "funds" | "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 [backupRulesOpen, setBackupRulesOpen] = useState(false);
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [fleetConfigured, setFleetConfigured] = useState(false);
const [fleetTokenInput, setFleetTokenInput] = useState("");
const [fleetHint, setFleetHint] = 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 [oneExpiryPerDay, setOneExpiryPerDay] = useState(true);
const [leverage, setLeverage] = useState(3);
const [perpMarginMode, setPerpMarginMode] = useState<"cross" | "isolated">(
"cross",
);
const [minHours, setMinHours] = useState(12);
const [minOptLev, setMinOptLev] = useState(100);
const [atmOffOn, setAtmOffOn] = useState(false);
const [maxAtmOff, setMaxAtmOff] = useState(3);
const [fixedDirOn, setFixedDirOn] = useState(false);
const [fixedPerpSide, setFixedPerpSide] = useState<"long" | "short">("long");
const [closeDevPct, setCloseDevPct] = useState(30);
const [residualMinPremPct, setResidualMinPremPct] = useState(20);
const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2);
const [showManualTrade, setShowManualTrade] = useState(false);
const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual");
const [riskLeverageBasis, setRiskLeverageBasis] = useState<
"actual" | "selection"
>("selection");
const [riskLossMode, setRiskLossMode] = useState<"percent" | "absolute">(
"percent",
);
const [riskLossPct, setRiskLossPct] = useState(1);
const [riskLossUsdt, setRiskLossUsdt] = useState(15);
const [riskCapitalSource, setRiskCapitalSource] = useState<
"trading_account" | "manual"
>("trading_account");
const [riskManualCapital, setRiskManualCapital] = useState(10000);
const [riskPerpUnit, setRiskPerpUnit] = useState(1);
const [riskOptUnit, setRiskOptUnit] = useState(2);
const [riskExitUnit, setRiskExitUnit] = useState(15);
const [martingaleOn, setMartingaleOn] = useState(false);
const [martingaleStartAfter, setMartingaleStartAfter] = useState(2);
const [martingaleMaxDoubles, setMartingaleMaxDoubles] = useState(3);
const [hedgeMode, setHedgeMode] = useState<"perp_option" | "option_option">(
"perp_option",
);
const [ooAmpPct, setOoAmpPct] = useState(1.5);
const [ooAmpHours, setOoAmpHours] = useState(12);
const [ooMinHours, setOoMinHours] = useState(24);
const [ooMinLev, setOoMinLev] = useState(200);
const [ooRewardRatio, setOoRewardRatio] = useState(2);
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
null,
);
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 [wecomEnabled, setWecomEnabled] = useState(false);
const [wecomWebhook, setWecomWebhook] = useState("");
const [wecomMachineName, setWecomMachineName] = useState("");
const [wecomMeta, setWecomMeta] = useState<NotifySettings | null>(null);
const [wecomOk, setWecomOk] = 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("");
const [fundsOk, setFundsOk] = useState("");
const [fundsErr, setFundsErr] = useState("");
const [convertDir, setConvertDir] = useState<"usdt_to_usdc" | "usdc_to_usdt">(
"usdt_to_usdc",
);
const [convertAmt, setConvertAmt] = useState(100);
const [xferCcy, setXferCcy] = useState<"USDT" | "USDC">("USDC");
const [xferAmt, setXferAmt] = useState(50);
const [xferFrom, setXferFrom] = useState("funding");
const [xferTo, setXferTo] = useState("trading");
const [usdcRate, setUsdcRate] = useState<number | null>(null);
const [fundsRulesOpen, setFundsRulesOpen] = useState(false);
function loadRuntime() {
apiFetch<RuntimeSettings>("/api/settings/runtime")
.then((r) => {
setRuntime(r);
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
})
.catch(() => undefined);
apiFetch<NotifySettings>("/api/settings/notify")
.then((n) => {
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
setWecomMachineName(n.machine_name || "");
})
.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);
setOneExpiryPerDay(s.one_expiry_per_day !== false);
setLeverage(s.leverage ?? 3);
setPerpMarginMode(
s.perp_margin_mode === "isolated" ? "isolated" : "cross",
);
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);
setFixedDirOn(s.fixed_direction_enabled === true);
setFixedPerpSide(s.fixed_perp_side === "short" ? "short" : "long");
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
setResidualMinPremPct(s.residual_min_premium_pct ?? 20);
setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2);
setShowManualTrade(s.show_manual_trade_buttons === true);
setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual");
setRiskLeverageBasis(
s.risk_leverage_basis === "actual" ? "actual" : "selection",
);
setRiskLossMode(s.risk_loss_mode === "absolute" ? "absolute" : "percent");
setRiskLossPct(s.risk_loss_pct ?? 1);
setRiskLossUsdt(s.risk_loss_usdt ?? 15);
setRiskCapitalSource(
s.risk_capital_source === "manual" ? "manual" : "trading_account",
);
setRiskManualCapital(s.risk_manual_capital_usdt ?? 10000);
setRiskPerpUnit(s.risk_perp_unit ?? 1);
setRiskOptUnit(s.risk_option_unit ?? 2);
setRiskExitUnit(s.risk_exit_unit ?? 15);
setMartingaleOn(s.martingale_enabled === true);
setMartingaleStartAfter(s.martingale_start_after_loss_days ?? 2);
setMartingaleMaxDoubles(s.martingale_max_doubles ?? 3);
setHedgeMode(
s.hedge_mode === "option_option" ? "option_option" : "perp_option",
);
setOoAmpPct(s.oo_amplitude_pct ?? 1.5);
setOoAmpHours(s.oo_amplitude_hours ?? 12);
setOoMinHours(s.oo_min_option_hours ?? 24);
setOoMinLev(s.oo_min_leverage ?? 200);
setOoRewardRatio(s.oo_reward_ratio ?? 2);
setRiskPreview(
s.risk_sizing_preview && typeof s.risk_sizing_preview === "object"
? s.risk_sizing_preview
: null,
);
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 = mode === "LIVE";
const oo = hedgeMode === "option_option";
try {
const body: Record<string, unknown> = {
fee_rate: fee,
exit_mode:
oo || sizingMode === "risk_based" ? "fixed_usdt" : exitMode,
premium_exit_multiple: premMult,
rest_seconds: rest,
live_order_interval_sec: orderInterval,
skip_weekends: skipWeekends,
one_expiry_per_day: oneExpiryPerDay,
leverage,
perp_margin_mode: perpMarginMode,
min_option_hours: minHours,
min_option_leverage: minOptLev,
atm_open_offset_enabled: oo ? false : atmOffOn,
max_atm_open_offset: maxAtmOff,
fixed_direction_enabled: oo ? false : fixedDirOn,
fixed_perp_side: fixedPerpSide,
close_bid_mark_max_pct: closeDevPct,
residual_min_premium_pct: residualMinPremPct,
show_manual_trade_buttons: showManualTrade,
sizing_mode: oo ? "risk_based" : sizingMode,
risk_leverage_basis: riskLeverageBasis,
risk_loss_mode: oo ? "percent" : riskLossMode,
risk_loss_pct: riskLossPct,
risk_loss_usdt: riskLossUsdt,
risk_capital_source: riskCapitalSource,
risk_manual_capital_usdt: riskManualCapital,
risk_perp_unit: riskPerpUnit,
risk_option_unit: riskOptUnit,
risk_exit_unit: riskExitUnit,
martingale_enabled:
(oo || sizingMode === "risk_based") &&
(oo || riskLossMode === "percent") &&
riskLossPct <= 3 &&
martingaleOn,
martingale_start_after_loss_days: martingaleStartAfter,
martingale_max_doubles: martingaleMaxDoubles,
hedge_mode: hedgeMode,
oo_amplitude_pct: ooAmpPct,
oo_amplitude_hours: ooAmpHours,
oo_min_option_hours: ooMinHours,
oo_min_leverage: ooMinLev,
oo_reward_ratio: ooRewardRatio,
exchange,
};
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
if (sizingMode === "manual") {
body.net_profit_target = netTarget;
body.perp_qty_eth = perpQty;
body.option_qty_eth = optQty;
}
// LIVE 不改模拟资金,避免误重置本地账本
if (!isLive) {
body.initial_equity = initialEquity;
}
const saved = await apiFetch<StrategySettings>("/api/settings/strategy", {
method: "PUT",
body: JSON.stringify(body),
});
if (saved?.risk_sizing_preview) {
setRiskPreview(saved.risk_sizing_preview);
}
if (saved?.perp_qty_eth != null) setPerpQty(saved.perp_qty_eth);
if (saved?.option_qty_eth != null) setOptQty(saved.option_qty_eth);
if (saved?.net_profit_target != null) setNetTarget(saved.net_profit_target);
if (saved?.exit_mode) {
setExitMode(
saved.exit_mode === "premium_multiple" ? "premium_multiple" : "fixed_usdt",
);
}
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",
);
window.dispatchEvent(new Event("funds-refresh"));
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
async function onSaveWecom() {
setErr("");
setWecomOk("");
try {
const body: Record<string, unknown> = {
enabled: wecomEnabled,
machine_name: wecomMachineName.trim(),
};
if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim();
const n = await apiFetch<NotifySettings>("/api/settings/notify", {
method: "PUT",
body: JSON.stringify(body),
});
setWecomMeta(n);
setWecomEnabled(n.enabled === true);
setWecomMachineName(n.machine_name || "");
setWecomWebhook("");
setWecomOk(
n.enabled
? n.webhook_configured
? "企业微信通知已保存并开启"
: "已开启,但尚未配置 Webhook"
: "企业微信通知已关闭",
);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function onTestWecom() {
setErr("");
setWecomOk("");
try {
const n = await apiFetch<NotifySettings & { detail?: string }>(
"/api/settings/notify/test",
{ method: "POST", body: "{}" },
);
setWecomMeta(n);
setWecomOk(n.detail || "测试消息已发送,请查看企业微信群");
if (n.machine_name != null) setWecomMachineName(n.machine_name || "");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
const isOo = hedgeMode === "option_option";
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 === "funds" ? "tab active" : "tab"}
onClick={() => {
setTab("funds");
setFundsErr("");
setFundsOk("");
apiFetch<{ usdc_usdt_rate?: number }>("/api/funds/summary")
.then((r) => setUsdcRate(r.usdc_usdt_rate ?? null))
.catch(() => undefined);
}}
>
</button>
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
onClick={() => {
setTab("account");
apiFetch<{ configured: boolean; hint?: string }>("/api/fleet/meta")
.then((r) => {
setFleetConfigured(!!r.configured);
setFleetHint(r.hint || "");
})
.catch(() => undefined);
}}
>
</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">OKXUSDC </option>
<option value="binance">
USDT +
</option>
</select>
</div>
{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}
{!isOo ? (
<>
<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="perpMm"></label>
<select
id="perpMm"
className="mono"
value={perpMarginMode}
onChange={(e) =>
setPerpMarginMode(
e.target.value === "isolated"
? "isolated"
: "cross",
)
}
>
<option value="cross"></option>
<option value="isolated"></option>
</select>
</div>
</>
) : null}
<div className="field">
<label htmlFor="hedgeMode"></label>
<select
id="hedgeMode"
className="mono"
value={hedgeMode}
onChange={(e) => {
const v =
e.target.value === "option_option"
? "option_option"
: "perp_option";
setHedgeMode(v);
if (v === "option_option") {
setSizingMode("risk_based");
setRiskLossMode("percent");
setFixedDirOn(false);
}
}}
>
<option value="perp_option">+</option>
<option value="option_option"></option>
</select>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
N Call/Put
</p>
</div>
{isOo ? (
<div className="field">
<label htmlFor="ooRR"></label>
<input
id="ooRR"
className="mono"
type="number"
step="0.1"
min="0.5"
value={ooRewardRatio}
onChange={(e) =>
setOoRewardRatio(Number(e.target.value))
}
/>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
= × 100U 2
200U 1:1 /
</p>
</div>
) : null}
{!isOo ? (
<div className="field">
<label htmlFor="sizingMode"></label>
<select
id="sizingMode"
className="mono"
value={sizingMode}
onChange={(e) =>
setSizingMode(
e.target.value === "risk_based"
? "risk_based"
: "manual",
)
}
>
<option value="manual"> ETH </option>
<option value="risk_based">
</option>
</select>
</div>
) : (
<div className="field">
<label></label>
<div className="mono" style={{ fontSize: 13 }}>
· Call/Put 1:1
</div>
</div>
)}
{sizingMode === "risk_based" || isOo ? (
<>
{!isOo ? (
<div className="field">
<label htmlFor="riskLevBasis">
</label>
<select
id="riskLevBasis"
className="mono"
value={riskLeverageBasis}
onChange={(e) =>
setRiskLeverageBasis(
e.target.value === "actual"
? "actual"
: "selection",
)
}
>
<option value="selection">
</option>
<option value="actual"></option>
</select>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
÷
k便
便 k
</p>
</div>
) : null}
{!isOo ? (
<div className="field">
<label htmlFor="riskLossMode"></label>
<select
id="riskLossMode"
className="mono"
value={riskLossMode}
onChange={(e) => {
const mode =
e.target.value === "absolute"
? "absolute"
: "percent";
setRiskLossMode(mode);
if (mode !== "percent") setMartingaleOn(false);
}}
>
<option value="percent">
%
</option>
<option value="absolute">USDT</option>
</select>
</div>
) : null}
{isOo || riskLossMode === "percent" ? (
<>
<div className="field">
<label htmlFor="riskCapSrc"></label>
<select
id="riskCapSrc"
className="mono"
value={riskCapitalSource}
onChange={(e) =>
setRiskCapitalSource(
e.target.value === "manual"
? "manual"
: "trading_account",
)
}
>
<option value="trading_account">
</option>
<option value="manual"></option>
</select>
</div>
{riskCapitalSource === "manual" ? (
<div className="field">
<label htmlFor="riskManCap">USDT</label>
<input
id="riskManCap"
className="mono"
type="number"
step="100"
min="1"
value={riskManualCapital}
onChange={(e) =>
setRiskManualCapital(Number(e.target.value))
}
/>
</div>
) : null}
<div className="field">
<label htmlFor="riskPct">%</label>
<input
id="riskPct"
className="mono"
type="number"
step="0.01"
min="0.01"
value={riskLossPct}
onChange={(e) => {
const v = Number(e.target.value);
setRiskLossPct(v);
if (v > 3) setMartingaleOn(false);
}}
/>
</div>
<div className="field">
<label htmlFor="mgOn"></label>
<select
id="mgOn"
className="mono"
value={martingaleOn ? "on" : "off"}
disabled={riskLossPct > 3}
onChange={(e) =>
setMartingaleOn(e.target.value === "on")
}
>
<option value="off"></option>
<option value="on"></option>
</select>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
{riskLossPct > 3
? "亏损幅度超过 3% 时不可启用倍投。"
: "连续亏损日达阈值后翻倍。到期结算无论盈亏(含小盈利)都按亏损日计;达标平仓盈利才打断连亏。"}
</p>
</div>
{martingaleOn && riskLossPct <= 3 ? (
<>
<div className="field">
<label htmlFor="mgStart">
</label>
<input
id="mgStart"
className="mono"
type="number"
step="1"
min="1"
max="30"
value={martingaleStartAfter}
onChange={(e) =>
setMartingaleStartAfter(
Number(e.target.value),
)
}
/>
</div>
<div className="field">
<label htmlFor="mgMax"></label>
<input
id="mgMax"
className="mono"
type="number"
step="1"
min="1"
max="10"
value={martingaleMaxDoubles}
onChange={(e) =>
setMartingaleMaxDoubles(
Number(e.target.value),
)
}
/>
<p
className="hint"
style={{ margin: "0.35rem 0 0" }}
>
{riskLossPct}%{" "}
{martingaleStartAfter} {" "}
{martingaleMaxDoubles} {" "}
{[0, 1, 2, 3]
.filter((d) => d <= martingaleMaxDoubles)
.map(
(d) =>
`${Number(
(
riskLossPct *
2 ** d
).toPrecision(6),
)}%`,
)
.join(" → ")}
</p>
</div>
</>
) : null}
</>
) : (
<div className="field">
<label htmlFor="riskUsdt">USDT</label>
<input
id="riskUsdt"
className="mono"
type="number"
step="0.1"
min="0.1"
value={riskLossUsdt}
onChange={(e) =>
setRiskLossUsdt(Number(e.target.value))
}
/>
</div>
)}
<div className="field" style={{ gridColumn: "1 / -1" }}>
<label></label>
<div className="mono" style={{ fontSize: 12, opacity: 0.9 }}>
{(() => {
if (riskPreview == null) return "—";
if (riskPreview.ok === false) {
return String(riskPreview.detail || "预览不可用");
}
const k = Number(riskPreview.k);
const bud = Number(riskPreview.budget);
const mx = Number(riskPreview.max_loss);
const pk = Number.isFinite(k) ? k : null;
const perp =
pk != null
? Number((riskPerpUnit * pk).toFixed(4))
: riskPreview.perp_qty_eth;
const opt =
pk != null
? Number((riskOptUnit * pk).toFixed(4))
: riskPreview.option_qty_eth;
const exit =
pk != null
? Number((riskExitUnit * pk).toFixed(2))
: riskPreview.net_profit_target;
const budS = Number.isFinite(bud)
? bud.toFixed(2)
: "—";
const mxS = Number.isFinite(mx) ? mx.toFixed(2) : "—";
const basis =
riskPreview.leverage_basis === "actual"
? "实际杠杆"
: riskPreview.leverage_basis === "selection"
? "选约杠杆"
: "";
const basisS = basis ? ` · ${basis}` : "";
const mg =
riskPreview.martingale &&
typeof riskPreview.martingale === "object"
? (riskPreview.martingale as Record<
string,
unknown
>)
: null;
const mgD = Number(mg?.doubles);
const mgS =
mg != null &&
mg.enabled === true &&
Number.isFinite(mgD) &&
mgD > 0
? ` · 倍投×${2 ** mgD}(连亏${Number(mg.loss_days) || 0}天·有效${Number(mg.effective_pct)}%)`
: mg != null && mg.enabled === true
? ` · 倍投待命(连亏${Number(mg.loss_days) || 0}天)`
: "";
return isOo
? `预算=${budS}U · 估亏=${mxS}U · 出场=预算×${ooRewardRatio}${
riskPreview.option_qty_eth != null
? ` · 单腿≈${riskPreview.option_qty_eth}ETH`
: ""
}${mgS}`
: `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}${basisS}${mgS}`;
})()}
</div>
</div>
{!isOo ? (
<>
<div className="field">
<label htmlFor="riskPerpU">k=1</label>
<input
id="riskPerpU"
className="mono"
type="number"
step="0.01"
min="0.01"
value={riskPerpUnit}
onChange={(e) => {
const v = Number(e.target.value);
setRiskPerpUnit(v);
const k = Number(riskPreview?.k);
if (Number.isFinite(k) && k > 0) {
setPerpQty(Number((v * k).toFixed(4)));
}
}}
/>
</div>
<div className="field">
<label htmlFor="riskOptU">k=1</label>
<input
id="riskOptU"
className="mono"
type="number"
step="0.01"
min="0.01"
value={riskOptUnit}
onChange={(e) => {
const v = Number(e.target.value);
setRiskOptUnit(v);
const k = Number(riskPreview?.k);
if (Number.isFinite(k) && k > 0) {
setOptQty(Number((v * k).toFixed(4)));
}
}}
/>
</div>
<div className="field">
<label htmlFor="riskExitU">
k=1
</label>
<input
id="riskExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
value={riskExitUnit}
onChange={(e) => {
const v = Number(e.target.value);
setRiskExitUnit(v);
const k = Number(riskPreview?.k);
if (Number.isFinite(k) && k > 0) {
setNetTarget(Number((v * k).toFixed(2)));
setPerpQty(Number((riskPerpUnit * k).toFixed(4)));
setOptQty(Number((riskOptUnit * k).toFixed(4)));
}
}}
/>
</div>
<div className="field">
<label htmlFor="riskExitAbs">
= ×k
</label>
<input
id="riskExitAbs"
className="mono"
type="number"
step="0.01"
min="0.1"
value={
(() => {
const k = Number(riskPreview?.k);
if (Number.isFinite(k) && k > 0) {
return Number((riskExitUnit * k).toFixed(2));
}
return Number(riskExitUnit.toFixed(2));
})()
}
onChange={(e) => {
const abs = Number(e.target.value);
const k = Number(riskPreview?.k);
if (Number.isFinite(k) && k > 0) {
const unit = Number((abs / k).toFixed(4));
setRiskExitUnit(unit);
setNetTarget(Number(abs.toFixed(2)));
setPerpQty(Number((riskPerpUnit * k).toFixed(4)));
setOptQty(Number((riskOptUnit * k).toFixed(4)));
} else {
setRiskExitUnit(abs);
setNetTarget(abs);
}
}}
/>
</div>
</>
) : null}
</>
) : null}
{!isOo ? (
<>
<div className="field">
<label htmlFor="perp">
ETH
{sizingMode === "risk_based" ? "(随比例×k" : ""}
</label>
<input
id="perp"
className="mono"
type="number"
step="0.01"
min="0.01"
disabled={sizingMode === "risk_based"}
value={
sizingMode === "risk_based" &&
Number(riskPreview?.k) > 0
? Number(
(riskPerpUnit * Number(riskPreview?.k)).toFixed(4),
)
: perpQty
}
onChange={(e) => setPerpQty(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="opt">
ETH
{sizingMode === "risk_based" ? "(随比例×k" : ""}
</label>
<input
id="opt"
className="mono"
type="number"
step="0.01"
min="0.01"
disabled={sizingMode === "risk_based"}
value={
sizingMode === "risk_based" &&
Number(riskPreview?.k) > 0
? Number(
(riskOptUnit * Number(riskPreview?.k)).toFixed(4),
)
: optQty
}
onChange={(e) => setOptQty(Number(e.target.value))}
/>
</div>
</>
) : null}
</div>
</section>
) : null}
{stratSub === "select" ? (
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
{isOo ? (
<>
<div className="field">
<label htmlFor="ooAmp">%</label>
<input
id="ooAmp"
className="mono"
type="number"
step="0.1"
min="0.1"
value={ooAmpPct}
onChange={(e) => setOoAmpPct(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="ooAmpH"></label>
<input
id="ooAmpH"
className="mono"
type="number"
step="1"
min="1"
value={ooAmpHours}
onChange={(e) =>
setOoAmpHours(Number(e.target.value))
}
/>
</div>
<div className="field">
<label htmlFor="ooMinH"></label>
<input
id="ooMinH"
className="mono"
type="number"
step="1"
min="1"
value={ooMinHours}
onChange={(e) =>
setOoMinHours(Number(e.target.value))
}
/>
</div>
<div className="field">
<label htmlFor="ooLev"></label>
<input
id="ooLev"
className="mono"
type="number"
step="1"
min="1"
value={ooMinLev}
onChange={(e) => setOoMinLev(Number(e.target.value))}
/>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
Call Put 使 ATM/
</p>
</div>
</>
) : (
<>
<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="fixedDirOn"></label>
<select
id="fixedDirOn"
className="mono"
value={fixedDirOn ? "1" : "0"}
onChange={(e) => setFixedDirOn(e.target.value === "1")}
>
<option value="0"></option>
<option value="1"></option>
</select>
</div>
<div className="field">
<label htmlFor="fixedPerp"></label>
<select
id="fixedPerp"
className="mono"
disabled={!fixedDirOn}
value={fixedPerpSide}
onChange={(e) =>
setFixedPerpSide(
e.target.value === "short" ? "short" : "long",
)
}
>
<option value="long"> · Put/</option>
<option value="short"> · Call/</option>
</select>
</div>
<div className="field">
<label htmlFor="atmoffOn"> ATM </label>
<select
id="atmoffOn"
className="mono"
disabled={fixedDirOn}
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 || fixedDirOn}
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">
{isOo ? (
<div className="field">
<label htmlFor="ooRRExit"></label>
<input
id="ooRRExit"
className="mono"
type="number"
step="0.1"
min="0.5"
value={ooRewardRatio}
onChange={(e) =>
setOoRewardRatio(Number(e.target.value))
}
/>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
×
</p>
</div>
) : (
<>
<div className="field">
<label htmlFor="exitMode"></label>
<select
id="exitMode"
className="mono"
disabled={sizingMode === "risk_based"}
value={
sizingMode === "risk_based" ? "fixed_usdt" : 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>
{sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
<div className="field">
<label htmlFor="netTarget">
{sizingMode === "risk_based"
? "出场资金(k=1 基数)"
: "净盈利出场目标(USDT"}
</label>
<input
id="netTarget"
className="mono"
type="number"
step="0.1"
min="0.1"
value={
sizingMode === "risk_based" ? riskExitUnit : netTarget
}
onChange={(e) => {
const v = Number(e.target.value);
if (sizingMode === "risk_based") {
setRiskExitUnit(v);
const k = Number(riskPreview?.k);
if (Number.isFinite(k) && k > 0) {
setNetTarget(Number((v * k).toFixed(2)));
setPerpQty(
Number((riskPerpUnit * k).toFixed(4)),
);
setOptQty(Number((riskOptUnit * k).toFixed(4)));
}
} else {
setNetTarget(v);
}
}}
/>
</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 className="field">
<label htmlFor="residualPrem">
{isOo
? "亏损腿残留回收:当前权利金 / 初始权利金 ≥(%)"
: "残留期权回收:当前权利金 / 初始权利金 ≥(%)"}
</label>
<input
id="residualPrem"
className="mono"
type="number"
step="1"
min="1"
max="100"
value={residualMinPremPct}
onChange={(e) =>
setResidualMinPremPct(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="oneExpDay">
</label>
<select
id="oneExpDay"
className="mono"
value={oneExpiryPerDay ? "1" : "0"}
onChange={(e) =>
setOneExpiryPerDay(e.target.value === "1")
}
>
<option value="1"></option>
<option value="0"></option>
</select>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
0803/ 0804
</p>
</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>
{mode === "LIVE"
? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。"
: "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
</li>
{stratSub === "position" ? (
<>
<li></li>
{mode !== "LIVE" ? (
<li>
SIM
10000
</li>
) : null}
{isOo ? (
<li>
+ % 1:1 使//k
</li>
) : (
<li>
/OKX
/cash
</li>
)}
</>
) : null}
{stratSub === "select" ? (
isOo ? (
<li>
Call
Put
</li>
) : (
<>
<li>
Put
Call ATM/
</li>
<li>
ATM |ATM
|
</li>
<li>
ATM
</li>
</>
)
) : null}
{stratSub === "exit" ? (
<>
<li>
/ SIM
LIVE
</li>
<li>
{isOo
? "残留回收:达标只平盈利腿后,亏损腿权利金回升到初始比例及以上时可尝试平仓;否则到期结算。"
: "残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,按最新买一 IOC 限价卖掉归档期权(不扫市价);默认 20%。未达标或未完全成交则等到下次巡检或到期结算。"}
</li>
</>
) : null}
{stratSub === "pace" ? (
<>
<li>
LIVE / 1s
0.230
</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>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="wecomMachine"></label>
<input
id="wecomMachine"
className="mono"
autoComplete="off"
maxLength={64}
placeholder="例如 云A / 云B(推送标题前缀)"
value={wecomMachineName}
onChange={(e) => setWecomMachineName(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="wecomEn"></label>
<select
id="wecomEn"
className="mono"
value={wecomEnabled ? "1" : "0"}
onChange={(e) => setWecomEnabled(e.target.value === "1")}
>
<option value="0"></option>
<option value="1"></option>
</select>
</div>
<div className="field">
<label htmlFor="wecomUrl">Webhook URL</label>
<input
id="wecomUrl"
className="mono"
type="password"
autoComplete="off"
placeholder={
wecomMeta?.webhook_configured
? `已配置 ${wecomMeta.webhook_url_masked || "********"}`
: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
}
value={wecomWebhook}
onChange={(e) => setWecomWebhook(e.target.value)}
/>
</div>
</div>
{wecomOk ? <div className="settings-ok">{wecomOk}</div> : null}
<div className="settings-actions" style={{ gap: 8 }}>
<button className="btn ghost" type="button" onClick={() => void onSaveWecom()}>
</button>
<button
className="btn ghost"
type="button"
onClick={() => void onTestWecom()}
>
</button>
</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>
<li>
Webhook MarkdownOPEN/CLOSE/START/PAUSE/FAULT
A·OKX LIVE
<span className="mono">
{wecomMachineName || wecomMeta?.machine_name || "未命名"}
</span>
<span className="mono">
{wecomMeta?.venue_label ||
(runtime?.mode === "LIVE"
? `实盘·${(runtime.exchange || "okx").toUpperCase()}`
: "模拟盘")}
</span>
////
</li>
<li>
Webhook
URL 5
</li>
</ul>
</RulesFold>
<div className="settings-actions">
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存模式与密钥"}
</button>
</div>
</form>
</div>
) : null}
{tab === "funds" ? (
<div className="card settings-card">
{fundsErr ? <div className="err">{fundsErr}</div> : null}
{fundsOk ? <div className="settings-ok">{fundsOk}</div> : null}
<RulesFold
open={fundsRulesOpen}
onToggle={() => setFundsRulesOpen((v) => !v)}
>
<p>
OKX USDC-USDT<strong></strong>
{" "}
{usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}
</p>
<p>
<strong> </strong>
USDT/USDCOKX /
</p>
<p>
USDC OKX SIM/LIVE<strong></strong> USDC
USDTUSDC×2
</p>
</RulesFold>
<section className="settings-section">
<h3> · USDT USDC</h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="convertDir"></label>
<select
id="convertDir"
value={convertDir}
onChange={(e) =>
setConvertDir(
e.target.value === "usdc_to_usdt"
? "usdc_to_usdt"
: "usdt_to_usdc",
)
}
>
<option value="usdt_to_usdc">USDT USDC</option>
<option value="usdc_to_usdt">USDC USDT</option>
</select>
</div>
<div className="field">
<label htmlFor="convertAmt"></label>
<input
id="convertAmt"
type="number"
min={0}
step="0.01"
value={convertAmt}
onChange={(e) => setConvertAmt(Number(e.target.value))}
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading}
onClick={async () => {
setFundsErr("");
setFundsOk("");
setLoading(true);
try {
await apiFetch("/api/funds/convert", {
method: "POST",
body: JSON.stringify({
direction: convertDir,
amount: convertAmt,
}),
});
setFundsOk("兑换成功,顶部资金条将刷新");
window.dispatchEvent(new Event("funds-refresh"));
} catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
<section className="settings-section">
<h3></h3>
<div className="settings-xfer-row">
<div className="field">
<label htmlFor="xferCcy"></label>
<select
id="xferCcy"
value={xferCcy}
onChange={(e) =>
setXferCcy(e.target.value === "USDT" ? "USDT" : "USDC")
}
>
<option value="USDC">USDC</option>
<option value="USDT">USDT</option>
</select>
</div>
<div className="field">
<label htmlFor="xferFrom"></label>
<select
id="xferFrom"
value={xferFrom}
onChange={(e) => {
const v = e.target.value === "trading" ? "trading" : "funding";
setXferFrom(v);
setXferTo(v === "funding" ? "trading" : "funding");
}}
>
<option value="funding"></option>
<option value="trading"></option>
</select>
</div>
<div className="field">
<label htmlFor="xferTo"></label>
<select
id="xferTo"
value={xferTo}
onChange={(e) => {
const v = e.target.value === "trading" ? "trading" : "funding";
setXferTo(v);
setXferFrom(v === "funding" ? "trading" : "funding");
}}
>
<option value="funding"></option>
<option value="trading"></option>
</select>
</div>
<div className="field">
<label htmlFor="xferAmt"></label>
<input
id="xferAmt"
type="number"
min={0}
step="0.01"
value={xferAmt}
onChange={(e) => setXferAmt(Number(e.target.value))}
/>
</div>
<div className="settings-xfer-action">
<button
className="btn"
type="button"
disabled={loading}
onClick={async () => {
setFundsErr("");
setFundsOk("");
setLoading(true);
try {
await apiFetch("/api/funds/transfer", {
method: "POST",
body: JSON.stringify({
ccy: xferCcy,
amount: xferAmt,
from_account: xferFrom,
to_account: xferTo,
}),
});
setFundsOk("划转成功,顶部资金条将刷新");
window.dispatchEvent(new Event("funds-refresh"));
} catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</div>
</section>
</div>
) : null}
{tab === "account" ? (
<div className="card settings-card">
{err ? <div className="err">{err}</div> : null}
{ok ? <div className="settings-ok">{ok}</div> : null}
<section className="settings-section">
<h3> API Token</h3>
<p className="meta">
{fleetHint ||
"在中控生成 Token 后粘贴保存。用于远程启停、更新与免密登录。"}
</p>
<p className="meta">
{fleetConfigured ? "已配置" : "未配置"}
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="fleetTok">Token</label>
<input
id="fleetTok"
type="password"
value={fleetTokenInput}
onChange={(e) => setFleetTokenInput(e.target.value)}
placeholder="粘贴中控生成的 Token"
autoComplete="off"
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading || !fleetTokenInput.trim()}
onClick={async () => {
setErr("");
setOk("");
setLoading(true);
try {
const r = await apiFetch<{ configured: boolean }>(
"/api/fleet/token",
{
method: "PUT",
body: JSON.stringify({ token: fleetTokenInput.trim() }),
},
);
setFleetConfigured(!!r.configured);
setFleetTokenInput("");
setOk("中控 Token 已保存");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}}
>
Token
</button>
<button
className="btn ghost"
type="button"
disabled={loading || !fleetConfigured}
onClick={async () => {
setErr("");
setOk("");
setLoading(true);
try {
await apiFetch("/api/fleet/token", { method: "DELETE" });
setFleetConfigured(false);
setOk("已清除中控 Token");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
<form onSubmit={onSaveCreds} className="settings-account-form">
<section className="settings-section">
<h3></h3>
<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>
</section>
</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>
<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="backup-list" role="list">
{backup.items.map((it) => (
<div key={it.name} className="backup-list-row" role="listitem">
<span className="mono backup-list-name">
{it.name}
<span className="backup-list-meta">
{" "}
· {(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>
<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>
<RulesFold
open={backupRulesOpen}
onToggle={() => setBackupRulesOpen((v) => !v)}
>
<ul className="settings-rules-list">
<li>
SQLite +{" "}
<span className="mono">.env</span>
<span className="mono">
{backup?.backup_dir || "/root/eth_hedge_backups"}
</span>
</li>
<li>
zip{" "}
<span className="mono">.env</span>
{" "}
<span className="mono">RESTORE</span>
</li>
</ul>
</RulesFold>
</div>
) : null}
</div>
);
}