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 (
{open ?
{children}
: null}
); } export default function SettingsPage() { const [tab, setTab] = useState("strategy"); const [stratSub, setStratSub] = useState("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 | null>( null, ); const [initialEquity, setInitialEquity] = useState(10000); const [exchange, setExchange] = useState<"okx" | "binance">("okx"); const [stratOk, setStratOk] = useState(""); const [runtime, setRuntime] = useState(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(null); const [wecomOk, setWecomOk] = useState(""); const [backup, setBackup] = useState(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(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(null); const [fundsRulesOpen, setFundsRulesOpen] = useState(false); function loadRuntime() { apiFetch("/api/settings/runtime") .then((r) => { setRuntime(r); setMode(r.mode === "LIVE" ? "LIVE" : "SIM"); }) .catch(() => undefined); apiFetch("/api/settings/notify") .then((n) => { setWecomMeta(n); setWecomEnabled(n.enabled === true); setWecomMachineName(n.machine_name || ""); }) .catch(() => undefined); } function loadBackup() { apiFetch("/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("/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("/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 = { 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("/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 = { 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("/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 = { enabled: wecomEnabled, machine_name: wecomMachineName.trim(), }; if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim(); const n = await apiFetch("/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( "/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 (

系统设置

{tab === "strategy" ? (
{stratOk ?
{stratOk}
: null} {err && tab === "strategy" ?
{err}
: null}
{( [ ["position", "仓位"], ["select", "选约"], ["exit", "出场"], ["pace", "节奏"], ] as const ).map(([id, label]) => ( ))}
{stratSub === "position" ? (

交易所与仓位

{mode !== "LIVE" ? (
setInitialEquity(Number(e.target.value)) } />
) : null} {!isOo ? ( <>
setLeverage(Number(e.target.value))} />
) : null}

二选一。期期:近 N 小时振幅高低匹配虚值 Call/Put;达标只平盈利腿。

{isOo ? (
setOoRewardRatio(Number(e.target.value)) } />

目标盈利 = 以损预算 × 盈亏比(例预算 100U、比 2 → 目标 200U)。两腿 1:1 平分预算。振幅/选约参数见「选约」页。

) : null} {!isOo ? (
) : (
以损定仓(期期固定 · Call/Put 1:1)
)} {sizingMode === "risk_based" || isOo ? ( <> {!isOo ? (

选约杠杆:按「指数÷期权杠杆≥」估权利金算 k,权利金再便宜也不放大仓位。 实际杠杆:按盘口卖一定仓,便宜时 k 会变大。

) : null} {!isOo ? (
) : null} {isOo || riskLossMode === "percent" ? ( <>
{riskCapitalSource === "manual" ? (
setRiskManualCapital(Number(e.target.value)) } />
) : null}
{ const v = Number(e.target.value); setRiskLossPct(v); if (v > 3) setMartingaleOn(false); }} />

{riskLossPct > 3 ? "亏损幅度超过 3% 时不可启用倍投。" : "连续亏损日达阈值后翻倍。到期结算无论盈亏(含小盈利)都按亏损日计;达标平仓盈利才打断连亏。"}

{martingaleOn && riskLossPct <= 3 ? ( <>
setMartingaleStartAfter( Number(e.target.value), ) } />
setMartingaleMaxDoubles( Number(e.target.value), ) } />

例:幅度 {riskLossPct}%、连亏{" "} {martingaleStartAfter} 天起翻、最多{" "} {martingaleMaxDoubles} 次 →{" "} {[0, 1, 2, 3] .filter((d) => d <= martingaleMaxDoubles) .map( (d) => `${Number( ( riskLossPct * 2 ** d ).toPrecision(6), )}%`, ) .join(" → ")}

) : null} ) : (
setRiskLossUsdt(Number(e.target.value)) } />
)}
{(() => { 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}`; })()}
{!isOo ? ( <>
{ 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))); } }} />
{ 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))); } }} />
{ 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))); } }} />
{ 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); } }} />
) : null} ) : null} {!isOo ? ( <>
0 ? Number( (riskPerpUnit * Number(riskPreview?.k)).toFixed(4), ) : perpQty } onChange={(e) => setPerpQty(Number(e.target.value))} />
0 ? Number( (riskOptUnit * Number(riskPreview?.k)).toFixed(4), ) : optQty } onChange={(e) => setOptQty(Number(e.target.value))} />
) : null}
) : null} {stratSub === "select" ? (

选约条件

{isOo ? ( <>
setOoAmpPct(Number(e.target.value))} />
setOoAmpHours(Number(e.target.value)) } />
setOoMinHours(Number(e.target.value)) } />
setOoMinLev(Number(e.target.value))} />

期期:同到期虚值 Call 贴高点、Put 贴低点;不使用 ATM/固定方向。

) : ( <>
setMinHours(Number(e.target.value))} />
setMinOptLev(Number(e.target.value))} />
setMaxAtmOff(Number(e.target.value))} />
)}
) : null} {stratSub === "exit" ? (

出场与平仓

{isOo ? (
setOoRewardRatio(Number(e.target.value)) } />

净盈 ≥ 预算×盈亏比时只平盈利腿;亏损腿进残留,按下方回收比例或到期结算。

) : ( <>
{sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
{ 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); } }} />
) : (
setPremMult(Number(e.target.value))} />
)} )}
setCloseDevPct(Number(e.target.value))} />
setResidualMinPremPct(Number(e.target.value)) } />
) : null} {stratSub === "pace" ? (

节奏与费用

setRest(Number(e.target.value))} />
setOrderInterval(Number(e.target.value)) } />

开启后:历史上已开过某到期日(如 0803)后,不论今天还是明天都不能再开同到期,只能监控/开下一到期(如 0804)。

setFee(Number(e.target.value))} />
) : null} setStratRulesOpen((v) => !v)} >
  • {mode === "LIVE" ? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。" : "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
  • {stratSub === "position" ? ( <>
  • 交易所:有持仓时不可切换。切换后套用该所合约并重连行情。
  • {mode !== "LIVE" ? (
  • 模拟资金:仅 SIM 显示。保存且金额变更后重置权益,并清空交易记录(须无持仓)。默认 10000。
  • ) : null} {isOo ? (
  • 期期对冲:强制以损定仓 + 亏损幅度%;两腿 1:1 平分预算;出场用盈亏比,不使用永续名义/比例/k。
  • ) : (
  • 永续保证金模式:仅作用于永续(全仓/逐仓,默认全仓)。OKX 期权始终为现金/逐仓(cash),不受此选项影响。
  • )} ) : null} {stratSub === "select" ? ( isOo ? (
  • 期期选约:回看窗内真实高低点振幅达标后,同到期虚值 Call 贴高、Put 贴低;剩余到期与单腿杠杆门限见本页。
  • ) : ( <>
  • 固定方向:默认关。开启后固定永续多→买 Put,或永续空→买 Call;期权只选实值或平值,不买虚值。关闭时仍按 ATM/卖一比价规则。
  • 开仓 ATM 偏差限制:固定方向开启时不适用。关闭时不限制 |ATM − 现价|;开启后超过下方点数则不开仓。
  • 开仓 ATM 最大偏差:仅开关开启且未开固定方向时生效。币安行权价较粗时可能长时间无开仓机会。
  • ) ) : null} {stratSub === "exit" ? ( <>
  • 平仓买一/标记最大偏差:主要用于 SIM 流动性闸门;LIVE 以交易所能否成交为准。
  • {isOo ? "残留回收:达标只平盈利腿后,亏损腿权利金回升到初始比例及以上时可尝试平仓;否则到期结算。" : "残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,按最新买一 IOC 限价卖掉归档期权(不扫市价);默认 20%。未达标或未完全成交则等到下次巡检或到期结算。"}
  • ) : null} {stratSub === "pace" ? ( <>
  • 实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围 0.2–30。
  • 显示手动开仓:默认关闭。开启后计划页才出现「手动开一组 / 手动全平」。策略自动运行中禁止手动开仓(须先暂停);紧急全平始终可用。
  • ) : null}
) : null} {tab === "runtime" ? (
{runtimeOk ?
{runtimeOk}
: null} {err && tab === "runtime" ?
{err}
: null}

模式

OKX API

setOkxKey(e.target.value)} />
setOkxSecret(e.target.value)} />
setOkxPass(e.target.value)} />

币安 API

setBnKey(e.target.value)} />
setBnSecret(e.target.value)} />

企业微信通知

setWecomMachineName(e.target.value)} />
setWecomWebhook(e.target.value)} />
{wecomOk ?
{wecomOk}
: null}
setRuntimeRulesOpen((v) => !v)} >
  • SIM = 本地撮合;LIVE = 当前所选交易所真下单(OKX / 币安均已接入)。密钥只写入服务器{" "} .env ,接口不回传明文。OKX 期权常需账户有 USDC;币安多为 USDT。
  • 当前:{runtime?.mode || "—"} · 交易所{" "} {runtime?.exchange || "—"} ·{" "} {runtime?.mode === "LIVE" ? runtime.live_ready ? "LIVE 就绪" : `未就绪(${runtime.live_ready_reason})` : "SIM"} 。有持仓时不可切换模式。
  • 密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE 时用币安密钥真下单(无 Passphrase)。
  • 企业微信:群机器人 Webhook,推送 Markdown,带标识(OPEN/CLOSE/START/PAUSE/FAULT)。 可填「机器名称」区分多机,标题形如【云A】。【实盘·OKX】仅 LIVE 显示。 当前机器: {wecomMachineName || wecomMeta?.machine_name || "未命名"} ;盘口标签: {wecomMeta?.venue_label || (runtime?.mode === "LIVE" ? `实盘·${(runtime.exchange || "okx").toUpperCase()}` : "模拟盘")} 。开仓/平仓/启动/暂停/故障均推送。
  • 企业微信:在群里添加「自定义机器人」复制 Webhook。留空保存不覆盖已有 URL。故障同类消息 5 分钟内去重。
) : null} {tab === "funds" ? (
{fundsErr ?
{fundsErr}
: null} {fundsOk ?
{fundsOk}
: null} setFundsRulesOpen((v) => !v)} >

币种兑换:对齐 OKX 现货市价 USDC-USDT,在交易账户 内完成(不走资金账户)。参考价{" "} {usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}。

账户划转:仅资金账户 ↔ 交易账户 (USDT/USDC)。OKX 永续与期权均在交易账户,无单独期权资金/期权交易账户。

自动兑 USDC(仅 OKX SIM/LIVE):开仓前若交易账户 USDC 不够当前期权所需,则在交易账户内市价 USDT→USDC,目标约「所需×2」;已够开仓则不兑;并预留永续保证金。币安不生效。

币种兑换(交易账户 · USDT ↔ USDC)

setConvertAmt(Number(e.target.value))} />

账户划转

setXferAmt(Number(e.target.value))} />
) : null} {tab === "account" ? (
{err ?
{err}
: null} {ok ?
{ok}
: null}

中控 API Token

{fleetHint || "在中控生成 Token 后粘贴保存。用于远程启停、更新与免密登录。"}

状态:{fleetConfigured ? "已配置" : "未配置"}

setFleetTokenInput(e.target.value)} placeholder="粘贴中控生成的 Token" autoComplete="off" />

登录账号

setNewUsername(e.target.value)} required />
setCurrentPassword(e.target.value)} required />
setNewPassword(e.target.value)} required />
setConfirmPassword(e.target.value)} required />
) : null} {tab === "backup" ? (
{bakOk ?
{bakOk}
: null} {err && tab === "backup" ?
{err}
: null} {bakBusy ?
{bakBusy}…
: null}

自动备份

setBakInterval(Number(e.target.value))} />
setBakKeep(Number(e.target.value))} />

服务器备份列表

{backup?.items && backup.items.length > 0 ? (
{backup.items.map((it) => (
{it.name} {" "} · {(it.size_bytes / 1024).toFixed(1)} KB ·{" "} {new Date(it.mtime_ms).toLocaleString()}
))}
) : (

暂无备份,可点「立即备份」。

)}

上传恢复(新服务器迁移)

setRestoreFile(e.target.files?.[0] ?? null) } />
setRestorePhrase(e.target.value)} />
setBackupRulesOpen((v) => !v)} >
  • 备份内容:SQLite 数据库 +{" "} .env (含登录与交易所密钥)。服务器目录: {backup?.backup_dir || "/root/eth_hedge_backups"}
  • 上传恢复:在新机器部署后,上传旧机下载的 zip,将覆盖数据库与{" "} .env 。须无持仓;确认串填写{" "} RESTORE 。恢复后服务自动重启。
) : null}
); }