From 1252f45aac428c350fcdd0d13af5f3371d6771e3 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 3 Aug 2026 07:47:22 +0800 Subject: [PATCH] Add strategy residual table with manual close at panel bottom. Co-authored-by: Cursor --- backend/app/api/plan.py | 29 +++++++- backend/app/strategy/engine.py | 2 +- frontend/src/api/client.ts | 19 +++-- frontend/src/pages/Plan.tsx | 125 +++++++++++++++++++++++++++------ frontend/src/styles/app.css | 78 +++++++++++--------- 5 files changed, 191 insertions(+), 62 deletions(-) diff --git a/backend/app/api/plan.py b/backend/app/api/plan.py index 5b47afd..2272021 100644 --- a/backend/app/api/plan.py +++ b/backend/app/api/plan.py @@ -1,8 +1,10 @@ from __future__ import annotations +import asyncio from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field from ..strategy import get_engine from .auth import require_user @@ -28,3 +30,28 @@ async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict: @router.post("/emergency-close") async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict: return await get_engine().emergency_close() + + +class ResidualCloseBody(BaseModel): + group_id: str = Field(min_length=1, max_length=128) + + +@router.post("/residual/close") +async def plan_residual_close( + body: ResidualCloseBody, + _user: Annotated[str, Depends(require_user)], +) -> dict: + """手动平单条残留:只验流动性,不验权利金回收比例。""" + matcher = get_engine().matcher + result = await asyncio.to_thread(matcher.close_residual_manual, body.group_id) + if not result.ok: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=result.detail or "平残留失败", + ) + return { + "ok": True, + "detail": result.detail, + "data": result.data, + "liquidity_wait": result.liquidity_wait, + } diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 8f398d8..eee404c 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -245,7 +245,7 @@ class StrategyEngine: "open_capacity": open_cap, "last_error": last_error, "position": upl, - "residuals": self.matcher.list_residual_options(pending_only=True), + "residuals": self.matcher.list_residual_options_enriched(), "ledger": self.ledger.snapshot(), "mode": "SIM" if s.is_sim else "LIVE", "sim": s.is_sim, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cad1f16..6fc7811 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -307,11 +307,20 @@ export type PlanState = { }; residuals?: { group_id: string; - option_inst_id: string; - option_side: string; - strike: number | null; - expiry_ymd: string | null; - status: string; + option_inst_id?: string; + option_side?: string; + option_qty_eth?: number | null; + strike?: number | null; + expiry_ymd?: string | null; + status?: string; + initial_premium?: number | null; + bid_px?: number | null; + bid_sz?: number | null; + bid_sz_eth?: number | null; + current_premium?: number | null; + recovery_pct?: number | null; + liquidity_ok?: boolean; + liquidity_detail?: string | null; }[]; ledger: { equity: number; available: number; reserved: number }; mode?: "SIM" | "LIVE"; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 9fc47ab..8afdcb3 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -94,11 +94,22 @@ const PHASE_ZH: Record = { wait_funds: "资金不足", }; +function fmtBidLiquidity( + bidPx: number | null | undefined, + bidSzEth: number | null | undefined, +): string { + if (bidPx == null && bidSzEth == null) return "—"; + const px = bidPx == null ? "—" : fmtExPx("option", bidPx); + const sz = bidSzEth == null ? "—" : fmt(bidSzEth, 2); + return `${px} / ${sz}`; +} + export default function PlanPage() { const [snap, setSnap] = useState(null); const [plan, setPlan] = useState(null); const [err, setErr] = useState(""); const [busy, setBusy] = useState(""); + const [residualBusy, setResidualBusy] = useState(""); const [nowMs, setNowMs] = useState(() => Date.now()); async function refresh() { @@ -139,6 +150,26 @@ export default function PlanPage() { } } + async function closeResidual(groupId: string, optionInstId: string) { + const ok = window.confirm( + `确认平掉残留期权?\n\n组:${groupId}\n合约:${optionInstId}\n\n仅校验买一流动性,不要求权利金回收比例。`, + ); + if (!ok) return; + setResidualBusy(groupId); + setErr(""); + try { + await apiFetch("/api/plan/residual/close", { + method: "POST", + body: JSON.stringify({ group_id: groupId }), + }); + await refresh(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setResidualBusy(""); + } + } + const bias = snap?.ask_compare?.bias; const biasTag = bias === "strike_below_spot" || @@ -485,27 +516,6 @@ export default function PlanPage() { -
- 当前组 - {pos?.group_id || "—"} -
-
- 残留期权(待到期) - {plan?.residuals && plan.residuals.length > 0 ? ( -
- {plan.residuals.map((r) => ( -
- {r.group_id}:{r.option_inst_id || "?"}@{r.expiry_ymd || "?"} -
- ))} -
- ) : ( - - )} -
信号方向 {biasTag} @@ -559,6 +569,79 @@ export default function PlanPage() {
) : null} + {plan?.residuals && plan.residuals.length > 0 ? ( +
+
+

残留期权

+ + 自动平仓仍要求权利金≥设定比例;「平仓」仅验流动性 + +
+
+ + + + + + + + + + + + + + + {plan.residuals.map((r) => { + const gid = String(r.group_id || ""); + const inst = String(r.option_inst_id || "—"); + const liqOk = r.liquidity_ok === true; + return ( + + + + + + + + + + + ); + })} + +
组名期权名数量开仓权利金买一流动性买一权利金占比操作
{gid || "—"}{inst}{fmt(r.option_qty_eth, 4)}{fmt(r.initial_premium, 2)} + {fmtBidLiquidity(r.bid_px, r.bid_sz_eth)} + {fmt(r.current_premium, 2)} + {r.recovery_pct == null + ? "—" + : `${fmt(r.recovery_pct, 1)}%`} + + +
+
+
+ ) : null}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 37fb80c..944f16e 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -725,49 +725,59 @@ input { min-width: 0; } -.kv-residuals { - align-items: flex-start; +.plan-residual-block { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--line); } -.kv-residuals > span:first-child { - padding-top: 2px; -} - -.residual-list { +.plan-residual-head { display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; - min-width: 0; - max-width: min(100%, 28em); + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 8px; } -.residual-item { - text-align: right; - line-height: 1.35; - word-break: break-word; - overflow-wrap: anywhere; +.plan-residual-head h4 { + margin: 0; + font-size: 0.95rem; + color: var(--accent); + letter-spacing: 0.06em; } -@media (max-width: 900px) { - .kv-residuals { - flex-direction: column; - align-items: stretch; - gap: 6px; - } +.plan-residual-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); +} - .residual-list { - align-items: stretch; - max-width: none; - } +.plan-residual-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + font-size: 0.85rem; +} - .residual-item { - text-align: left; - padding: 6px 8px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--line); - } +.plan-residual-table th, +.plan-residual-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(0, 229, 255, 0.1); + text-align: center; + white-space: nowrap; + vertical-align: middle; +} + +.plan-residual-table th { + color: var(--accent); + font-weight: 650; + background: rgba(0, 40, 60, 0.45); +} + +.plan-residual-table tbody tr:last-child td { + border-bottom: 0; } .trade-list {