Add control residual options table with liquidity-only manual close.

Fleet status exposes enriched residuals; manual close skips the premium recovery gate while auto mid-close remains unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 15:13:29 +08:00
parent 92c2e89b0e
commit a7aee8f425
8 changed files with 407 additions and 46 deletions
+127
View File
@@ -76,6 +76,54 @@ function leveragePair(strat: Record<string, unknown>): string {
return `${a}/${b}`;
}
type ResidualRow = {
key: string;
nodeId: number;
nodeName: string;
group_id: string;
option_inst_id: string;
option_qty_eth: number | null;
initial_premium: number | null;
bid_px: number | null;
current_premium: number | null;
recovery_pct: number | null;
liquidity_ok: boolean;
};
function collectResiduals(nodes: NodeCard[]): ResidualRow[] {
const out: ResidualRow[] = [];
for (const n of nodes) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
const list = fleet.residuals;
if (!Array.isArray(list)) continue;
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const r = raw as Record<string, unknown>;
const gid = String(r.group_id || "");
if (!gid) continue;
const numOrNull = (v: unknown) => {
if (v == null || v === "") return null;
const x = Number(v);
return Number.isFinite(x) ? x : null;
};
out.push({
key: `${n.id}:${gid}`,
nodeId: n.id,
nodeName: n.name,
group_id: gid,
option_inst_id: String(r.option_inst_id || "—"),
option_qty_eth: numOrNull(r.option_qty_eth),
initial_premium: numOrNull(r.initial_premium),
bid_px: numOrNull(r.bid_px),
current_premium: numOrNull(r.current_premium),
recovery_pct: numOrNull(r.recovery_pct),
liquidity_ok: r.liquidity_ok === true,
});
}
}
return out;
}
type NodeStats = {
mode?: string;
show_slip?: boolean;
@@ -249,6 +297,7 @@ export default function MonitorPage() {
const [detailStatsErr, setDetailStatsErr] = useState("");
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
const [equityPage, setEquityPage] = useState(0);
const [residualBusy, setResidualBusy] = useState<string>("");
const EQUITY_PAGE_SIZE = 5;
const applyNodes = useCallback((list: NodeCard[]) => {
@@ -504,6 +553,26 @@ export default function MonitorPage() {
})
.map((n) => n.id);
async function closeResidual(row: ResidualRow) {
const ok = window.confirm(
`确认平掉残留期权?\n\n机器:${row.nodeName}\n组:${row.group_id}\n合约:${row.option_inst_id}\n\n仅校验买一流动性,不要求权利金回收比例。`,
);
if (!ok) return;
setResidualBusy(row.key);
setErr("");
try {
await apiFetch(`/api/nodes/${row.nodeId}/residual/close`, {
method: "POST",
body: JSON.stringify({ group_id: row.group_id }),
});
await refresh();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setResidualBusy("");
}
}
async function startAll() {
if (!startableIds.length) {
setErr("没有可启动的策略机(需已配对且当前未运行)");
@@ -564,6 +633,7 @@ export default function MonitorPage() {
equityPageSafe * EQUITY_PAGE_SIZE,
equityPageSafe * EQUITY_PAGE_SIZE + EQUITY_PAGE_SIZE,
);
const residualRows = collectResiduals(nodes);
return (
<div>
@@ -751,6 +821,63 @@ export default function MonitorPage() {
<p className="meta"> Token</p>
)}
{residualRows.length ? (
<>
<div className="toolbar residual-toolbar">
<h2></h2>
<span className="meta">
</span>
</div>
<div className="monitor-table-wrap">
<table className="monitor-table residual-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>%</th>
<th></th>
</tr>
</thead>
<tbody>
{residualRows.map((r) => (
<tr key={r.key}>
<td>{r.nodeName}</td>
<td className="mono">{r.group_id}</td>
<td className="mono">{r.option_inst_id}</td>
<td className="mono">{fmt(r.option_qty_eth, 4)}</td>
<td className="mono">{fmt(r.initial_premium, 2)}</td>
<td className="mono">{fmt(r.current_premium, 2)}</td>
<td className="mono">
{r.recovery_pct == null ? "—" : fmt(r.recovery_pct, 1)}
</td>
<td className="col-actions">
<button
type="button"
className="btn btn-sm"
disabled={!!residualBusy || !r.liquidity_ok}
title={
r.liquidity_ok
? "按最新买一 IOC 平仓(不验权利金比例)"
: "买一流动性不足或盘口不可用"
}
onClick={() => void closeResidual(r)}
>
{residualBusy === r.key ? "平仓中…" : "平仓"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
{detailNode && detail ? (
<div
className="modal-backdrop"
+12
View File
@@ -320,6 +320,18 @@ input {
white-space: normal;
}
.residual-toolbar {
margin-top: 1.5rem;
}
.monitor-table.residual-table {
min-width: 900px;
}
.monitor-table.residual-table tbody tr {
cursor: default;
}
.status-dots {
display: inline-flex;
align-items: center;