cd88fa5d83
Show OO selection and reward-ratio fields in their tabs instead. Co-authored-by: Cursor <cursoragent@cursor.com>
2384 lines
95 KiB
TypeScript
2384 lines
95 KiB
TypeScript
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">OKX(USDC 期权族)</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.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>
|
||
|
||
<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,推送 Markdown,带标识(OPEN/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/USDC)。OKX 永续与期权均在交易账户,无单独期权资金/期权交易账户。
|
||
</p>
|
||
<p>
|
||
自动兑 USDC(仅 OKX SIM/LIVE):开仓前若<strong>交易账户</strong> USDC
|
||
不够当前期权所需,则在交易账户内市价 USDT→USDC,目标约「所需×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>
|
||
);
|
||
}
|