Add OKX-style funds bar with USDT/USDC convert and transfer.
SIM uses multi-wallet balances; LIVE hits OKX asset/account APIs and spot USDC-USDT swap. Funds strip hides sim labeling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
getUsername,
|
||||
startAuthTokenAutoRefresh,
|
||||
} from "./api/client";
|
||||
import FundsBar from "./components/FundsBar";
|
||||
import LoginPage from "./pages/Login";
|
||||
import PlanPage from "./pages/Plan";
|
||||
import TradesPage from "./pages/Trades";
|
||||
@@ -63,6 +64,7 @@ function Shell({ children }: { children: ReactNode }) {
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<FundsBar />
|
||||
<main className="page">{children}</main>
|
||||
<nav className="bottom-nav" aria-label="手机导航">
|
||||
{NAV.map((item) => (
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiFetch } from "../api/client";
|
||||
|
||||
export type FundsSummary = {
|
||||
ok: boolean;
|
||||
exchange: string;
|
||||
trading_day: string;
|
||||
total_trades: number;
|
||||
win_rate: number;
|
||||
profit_loss_ratio: number | null;
|
||||
total_funds: number | null;
|
||||
funding_usdt: number | null;
|
||||
trading_usdt: number | null;
|
||||
options_funding_label: string;
|
||||
options_trading_label: string;
|
||||
realtime_pnl: number | null;
|
||||
usdc_usdt_rate?: number;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
function fmtU(n: number | null | undefined) {
|
||||
if (n == null || Number.isNaN(n)) return "—";
|
||||
return `${n.toFixed(2)}U`;
|
||||
}
|
||||
|
||||
function pnlClass(n: number | null | undefined) {
|
||||
if (n == null || Number.isNaN(n) || n === 0) return "";
|
||||
return n > 0 ? "pos-pnl-profit" : "pos-pnl-loss";
|
||||
}
|
||||
|
||||
export default function FundsBar() {
|
||||
const [s, setS] = useState<FundsSummary | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiFetch<FundsSummary>("/api/funds/summary")
|
||||
.then(setS)
|
||||
.catch(() => {
|
||||
/* 顶栏静默失败,避免刷屏 */
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = window.setInterval(load, 5000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (!s?.ok) return null;
|
||||
|
||||
return (
|
||||
<div className="funds-bar" aria-label="资金与统计">
|
||||
<div className="funds-item funds-item--primary">
|
||||
<div className="funds-label">交易所</div>
|
||||
<div className="funds-value">{s.exchange || "—"}</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">交易日</div>
|
||||
<div className="funds-value mono">{s.trading_day}</div>
|
||||
</div>
|
||||
<div className="funds-item funds-item--primary">
|
||||
<div className="funds-label">总交易</div>
|
||||
<div className="funds-value mono">{s.total_trades}</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">胜率</div>
|
||||
<div className="funds-value mono">
|
||||
{(s.win_rate * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="funds-item" title="平均盈利 ÷ 平均亏损">
|
||||
<div className="funds-label">盈亏比</div>
|
||||
<div className="funds-value mono">
|
||||
{s.profit_loss_ratio != null ? s.profit_loss_ratio.toFixed(2) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">总资金</div>
|
||||
<div className="funds-value mono">{fmtU(s.total_funds)}</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">资金账户</div>
|
||||
<div className="funds-value mono">{fmtU(s.funding_usdt)}</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">交易账户</div>
|
||||
<div className="funds-value mono">{fmtU(s.trading_usdt)}</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">期权资金账户</div>
|
||||
<div className="funds-value mono">{s.options_funding_label}</div>
|
||||
</div>
|
||||
<div className="funds-item">
|
||||
<div className="funds-label">期权交易账户</div>
|
||||
<div className="funds-value mono">{s.options_trading_label}</div>
|
||||
</div>
|
||||
<div className="funds-item funds-item--pnl">
|
||||
<div className="funds-label">实时盈亏</div>
|
||||
<div className={`funds-value mono ${pnlClass(s.realtime_pnl)}`}>
|
||||
{s.realtime_pnl == null ? "—" : fmtU(s.realtime_pnl)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -168,8 +168,7 @@ export default function PlanPage() {
|
||||
<div className="plan-desktop-head">
|
||||
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
||||
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
||||
{plan?.mode === "LIVE" ? "LIVE 实盘下单" : "SIM 本地撮合"} · 行情{" "}
|
||||
{(snap?.exchange || "okx").toUpperCase()}
|
||||
行情 {(snap?.exchange || "okx").toUpperCase()}
|
||||
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
|
||||
目标平仓(双腿/远虚只平永续) · 未达标则到期结算
|
||||
{plan?.mode === "LIVE" && plan.live_ready === false
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
uploadRestoreBackup,
|
||||
} from "../api/client";
|
||||
|
||||
type Tab = "strategy" | "runtime" | "account" | "backup";
|
||||
type Tab = "strategy" | "runtime" | "funds" | "account" | "backup";
|
||||
type StratSub =
|
||||
| "position"
|
||||
| "select"
|
||||
@@ -104,6 +104,18 @@ export default function SettingsPage() {
|
||||
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);
|
||||
|
||||
function loadRuntime() {
|
||||
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
||||
.then((r) => {
|
||||
@@ -426,6 +438,20 @@ export default function SettingsPage() {
|
||||
>
|
||||
运行模式
|
||||
</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"}
|
||||
@@ -1031,6 +1057,169 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "funds" ? (
|
||||
<div className="card settings-card">
|
||||
{fundsErr ? <div className="err">{fundsErr}</div> : null}
|
||||
{fundsOk ? <div className="settings-ok">{fundsOk}</div> : null}
|
||||
<section className="settings-section">
|
||||
<h3>币种兑换(USDT ↔ USDC)</h3>
|
||||
<p className="meta" style={{ marginTop: 0 }}>
|
||||
对齐 OKX 现货市价 USDC-USDT。参考价{" "}
|
||||
{usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}
|
||||
。兑换发生在资金账户侧(USDT↔期权资金 USDC)。
|
||||
</p>
|
||||
<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("兑换成功,顶部资金条将刷新");
|
||||
} catch (e) {
|
||||
setFundsErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
兑换
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>账户划转</h3>
|
||||
<p className="meta" style={{ marginTop: 0 }}>
|
||||
资金账户 ↔ 交易账户(USDT/USDC)。期权侧可用 options_funding /
|
||||
options_trading。
|
||||
</p>
|
||||
<div className="settings-fields">
|
||||
<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) => setXferFrom(e.target.value)}
|
||||
>
|
||||
<option value="funding">资金账户</option>
|
||||
<option value="trading">交易账户</option>
|
||||
<option value="options_funding">期权资金</option>
|
||||
<option value="options_trading">期权交易</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="xferTo">到</label>
|
||||
<select
|
||||
id="xferTo"
|
||||
value={xferTo}
|
||||
onChange={(e) => setXferTo(e.target.value)}
|
||||
>
|
||||
<option value="funding">资金账户</option>
|
||||
<option value="trading">交易账户</option>
|
||||
<option value="options_funding">期权资金</option>
|
||||
<option value="options_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>
|
||||
<div className="settings-actions">
|
||||
<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("划转成功,顶部资金条将刷新");
|
||||
} catch (e) {
|
||||
setFundsErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
划转
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "account" ? (
|
||||
<div className="card settings-card settings-card-narrow">
|
||||
{err ? <div className="err">{err}</div> : null}
|
||||
|
||||
@@ -80,7 +80,52 @@ input {
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.funds-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(18, 22, 28, 0.96);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.funds-item {
|
||||
flex: 1 1 auto;
|
||||
min-width: 88px;
|
||||
padding: 4px 10px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.funds-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.funds-item--primary .funds-value {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.funds-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.funds-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.funds-item--pnl .funds-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
|
||||
Reference in New Issue
Block a user