78fd046fb6
Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { FormEvent, useEffect, useState } from "react";
|
||
import {
|
||
changeCredentials,
|
||
getUsername,
|
||
setSession,
|
||
apiFetch,
|
||
StrategySettings,
|
||
} from "../api/client";
|
||
|
||
type Tab = "strategy" | "account";
|
||
|
||
export default function SettingsPage() {
|
||
const [tab, setTab] = useState<Tab>("strategy");
|
||
|
||
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
||
const [currentPassword, setCurrentPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [confirmPassword, setConfirmPassword] = useState("");
|
||
const [err, setErr] = useState("");
|
||
const [ok, setOk] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const [fee, setFee] = useState(0.0005);
|
||
const [exitMode, setExitMode] = useState<"fixed_usdt" | "premium_multiple">(
|
||
"fixed_usdt",
|
||
);
|
||
const [netTarget, setNetTarget] = useState(15);
|
||
const [premMult, setPremMult] = useState(1);
|
||
const [rest, setRest] = useState(300);
|
||
const [skipWeekends, setSkipWeekends] = useState(true);
|
||
const [leverage, setLeverage] = useState(3);
|
||
const [minHours, setMinHours] = useState(12);
|
||
const [minOptLev, setMinOptLev] = useState(100);
|
||
const [closeDevPct, setCloseDevPct] = useState(30);
|
||
const [perpQty, setPerpQty] = useState(1);
|
||
const [optQty, setOptQty] = useState(2);
|
||
const [initialEquity, setInitialEquity] = useState(10000);
|
||
const [exchange, setExchange] = useState<"okx" | "binance">("okx");
|
||
const [stratOk, setStratOk] = useState("");
|
||
|
||
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);
|
||
setSkipWeekends(s.skip_weekends !== false);
|
||
setLeverage(s.leverage ?? 3);
|
||
setMinHours(s.min_option_hours ?? 12);
|
||
setMinOptLev(s.min_option_leverage ?? 100);
|
||
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
|
||
setPerpQty(s.perp_qty_eth ?? 1);
|
||
setOptQty(s.option_qty_eth ?? 2);
|
||
setInitialEquity(s.initial_equity ?? 10000);
|
||
setExchange(s.exchange === "binance" ? "binance" : "okx");
|
||
})
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
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);
|
||
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("");
|
||
try {
|
||
await apiFetch("/api/settings/strategy", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
fee_rate: fee,
|
||
exit_mode: exitMode,
|
||
net_profit_target: netTarget,
|
||
premium_exit_multiple: premMult,
|
||
rest_seconds: rest,
|
||
skip_weekends: skipWeekends,
|
||
leverage,
|
||
min_option_hours: minHours,
|
||
min_option_leverage: minOptLev,
|
||
close_bid_mark_max_pct: closeDevPct,
|
||
perp_qty_eth: perpQty,
|
||
option_qty_eth: optQty,
|
||
initial_equity: initialEquity,
|
||
exchange,
|
||
}),
|
||
});
|
||
setStratOk(
|
||
"策略参数已保存(切换交易所/改模拟资金需无持仓;切换后会重连行情)",
|
||
);
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
}
|
||
}
|
||
|
||
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")}
|
||
>
|
||
策略设置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tab === "account" ? "tab active" : "tab"}
|
||
onClick={() => setTab("account")}
|
||
>
|
||
登录账户
|
||
</button>
|
||
</div>
|
||
|
||
{tab === "strategy" ? (
|
||
<div className="card settings-card">
|
||
<p className="settings-lead">
|
||
无开仓时间窗;可切换 OKX / 币安行情(SIM 本地撮合);周六日可跳过开仓;出场按净盈利或到期全平。
|
||
</p>
|
||
{stratOk ? <div className="settings-ok">{stratOk}</div> : null}
|
||
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
|
||
<form onSubmit={onSaveStrategy}>
|
||
<section className="settings-section">
|
||
<h3>交易所</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="exch">行情交易所(SIM 只读)</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>
|
||
<p className="settings-hint">
|
||
有持仓时不可切换。切换后自动套用该所合约并重连公共行情。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<h3>资金</h3>
|
||
<div className="settings-fields">
|
||
<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))}
|
||
/>
|
||
<p className="settings-hint">
|
||
保存后将权益重置为该金额(须无持仓)。默认 10000。
|
||
</p>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="lev">永续杠杆倍数</label>
|
||
<input
|
||
id="lev"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={leverage}
|
||
onChange={(e) => setLeverage(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="perp">永续 ETH 数量</label>
|
||
<input
|
||
id="perp"
|
||
className="mono"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
value={perpQty}
|
||
onChange={(e) => setPerpQty(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="opt">期权 ETH 数量</label>
|
||
<input
|
||
id="opt"
|
||
className="mono"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
value={optQty}
|
||
onChange={(e) => setOptQty(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<h3>选约</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="hours">期权最小剩余小时</label>
|
||
<input
|
||
id="hours"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={minHours}
|
||
onChange={(e) => setMinHours(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="olev">期权杠杆下限(现价÷卖一权利金)</label>
|
||
<input
|
||
id="olev"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={minOptLev}
|
||
onChange={(e) => setMinOptLev(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<h3>出场与风控</h3>
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="exitMode">出场规则</label>
|
||
<select
|
||
id="exitMode"
|
||
className="mono"
|
||
value={exitMode}
|
||
onChange={(e) =>
|
||
setExitMode(
|
||
e.target.value === "premium_multiple"
|
||
? "premium_multiple"
|
||
: "fixed_usdt",
|
||
)
|
||
}
|
||
>
|
||
<option value="fixed_usdt">固定净盈利(USDT)</option>
|
||
<option value="premium_multiple">权利金倍数</option>
|
||
</select>
|
||
</div>
|
||
{exitMode === "fixed_usdt" ? (
|
||
<div className="field">
|
||
<label htmlFor="netTarget">净盈利出场目标(USDT)</label>
|
||
<input
|
||
id="netTarget"
|
||
className="mono"
|
||
type="number"
|
||
step="0.1"
|
||
min="0.1"
|
||
value={netTarget}
|
||
onChange={(e) => setNetTarget(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="field">
|
||
<label htmlFor="premMult">权利金倍数(1 = 一倍权利金)</label>
|
||
<input
|
||
id="premMult"
|
||
className="mono"
|
||
type="number"
|
||
step="0.1"
|
||
min="0.1"
|
||
value={premMult}
|
||
onChange={(e) => setPremMult(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label htmlFor="closeDev">平仓买一/标记最大偏差(%)</label>
|
||
<input
|
||
id="closeDev"
|
||
className="mono"
|
||
type="number"
|
||
step="1"
|
||
min="1"
|
||
value={closeDevPct}
|
||
onChange={(e) => setCloseDevPct(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div 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="skipWe">周六日跳过开仓(上海时区)</label>
|
||
<select
|
||
id="skipWe"
|
||
className="mono"
|
||
value={skipWeekends ? "1" : "0"}
|
||
onChange={(e) => setSkipWeekends(e.target.value === "1")}
|
||
>
|
||
<option value="1">开启(推荐)</option>
|
||
<option value="0">关闭</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="fee">手续费率</label>
|
||
<input
|
||
id="fee"
|
||
className="mono"
|
||
type="number"
|
||
step="0.0001"
|
||
value={fee}
|
||
onChange={(e) => setFee(Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn" type="submit">
|
||
保存策略
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
) : (
|
||
<div className="card settings-card settings-card-narrow">
|
||
{err ? <div className="err">{err}</div> : null}
|
||
{ok ? <div className="settings-ok">{ok}</div> : null}
|
||
<form onSubmit={onSaveCreds} className="settings-account-form">
|
||
<div className="settings-fields">
|
||
<div className="field">
|
||
<label htmlFor="user">新用户名</label>
|
||
<input
|
||
id="user"
|
||
value={newUsername}
|
||
onChange={(e) => setNewUsername(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="cur">当前密码</label>
|
||
<input
|
||
id="cur"
|
||
type="password"
|
||
value={currentPassword}
|
||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="np">新密码</label>
|
||
<input
|
||
id="np"
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="cp">确认新密码</label>
|
||
<input
|
||
id="cp"
|
||
type="password"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="settings-actions">
|
||
<button className="btn" type="submit" disabled={loading}>
|
||
{loading ? "保存中…" : "保存账号"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|