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:
@@ -442,6 +442,35 @@ async def node_stats(
|
||||
return data
|
||||
|
||||
|
||||
class ResidualCloseBody(BaseModel):
|
||||
group_id: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
@router.post("/{node_id}/residual/close")
|
||||
async def node_residual_close(
|
||||
node_id: int,
|
||||
body: ResidualCloseBody,
|
||||
_user: Annotated[str, Depends(require_control_user)],
|
||||
) -> dict:
|
||||
"""代理策略机手动平单条残留(只验流动性)。"""
|
||||
db = get_control_db()
|
||||
node = db.get_node(node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
if not node.get("token_sealed"):
|
||||
raise HTTPException(status_code=400, detail="未生成 Token")
|
||||
code, data = await call_node(
|
||||
node,
|
||||
"POST",
|
||||
"/api/fleet/residual/close",
|
||||
json_body={"group_id": body.group_id},
|
||||
timeout=30.0,
|
||||
)
|
||||
if code >= 400:
|
||||
_raise_node_error(code, data)
|
||||
return {"ok": True, "result": data}
|
||||
|
||||
|
||||
@router.post("/{node_id}/start")
|
||||
async def node_start(
|
||||
node_id: int,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user