Add strategy residual table with manual close at panel bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-03 07:47:22 +08:00
parent 09abb8d35b
commit 1252f45aac
5 changed files with 191 additions and 62 deletions
+28 -1
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated 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 ..strategy import get_engine
from .auth import require_user from .auth import require_user
@@ -28,3 +30,28 @@ async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict:
@router.post("/emergency-close") @router.post("/emergency-close")
async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict: async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict:
return await get_engine().emergency_close() 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,
}
+1 -1
View File
@@ -245,7 +245,7 @@ class StrategyEngine:
"open_capacity": open_cap, "open_capacity": open_cap,
"last_error": last_error, "last_error": last_error,
"position": upl, "position": upl,
"residuals": self.matcher.list_residual_options(pending_only=True), "residuals": self.matcher.list_residual_options_enriched(),
"ledger": self.ledger.snapshot(), "ledger": self.ledger.snapshot(),
"mode": "SIM" if s.is_sim else "LIVE", "mode": "SIM" if s.is_sim else "LIVE",
"sim": s.is_sim, "sim": s.is_sim,
+14 -5
View File
@@ -307,11 +307,20 @@ export type PlanState = {
}; };
residuals?: { residuals?: {
group_id: string; group_id: string;
option_inst_id: string; option_inst_id?: string;
option_side: string; option_side?: string;
strike: number | null; option_qty_eth?: number | null;
expiry_ymd: string | null; strike?: number | null;
status: string; 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 }; ledger: { equity: number; available: number; reserved: number };
mode?: "SIM" | "LIVE"; mode?: "SIM" | "LIVE";
+104 -21
View File
@@ -94,11 +94,22 @@ const PHASE_ZH: Record<string, string> = {
wait_funds: "资金不足", 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() { export default function PlanPage() {
const [snap, setSnap] = useState<MarketSnapshot | null>(null); const [snap, setSnap] = useState<MarketSnapshot | null>(null);
const [plan, setPlan] = useState<PlanState | null>(null); const [plan, setPlan] = useState<PlanState | null>(null);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [residualBusy, setResidualBusy] = useState("");
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
async function refresh() { 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 bias = snap?.ask_compare?.bias;
const biasTag = const biasTag =
bias === "strike_below_spot" || bias === "strike_below_spot" ||
@@ -485,27 +516,6 @@ export default function PlanPage() {
</span> </span>
</span> </span>
</div> </div>
<div className="kv">
<span></span>
<span className="mono">{pos?.group_id || "—"}</span>
</div>
<div className="kv kv-residuals">
<span>()</span>
{plan?.residuals && plan.residuals.length > 0 ? (
<div className="residual-list mono">
{plan.residuals.map((r) => (
<div
key={`${r.group_id}:${r.option_inst_id || ""}`}
className="residual-item"
>
{r.group_id}:{r.option_inst_id || "?"}@{r.expiry_ymd || "?"}
</div>
))}
</div>
) : (
<span className="mono"></span>
)}
</div>
<div className="kv"> <div className="kv">
<span></span> <span></span>
<span className="mono">{biasTag}</span> <span className="mono">{biasTag}</span>
@@ -559,6 +569,79 @@ export default function PlanPage() {
</div> </div>
) : null} ) : null}
</div> </div>
{plan?.residuals && plan.residuals.length > 0 ? (
<div className="plan-residual-block">
<div className="plan-residual-head">
<h4></h4>
<span className="meta">
</span>
</div>
<div className="plan-residual-wrap">
<table className="plan-residual-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th title="最新买一价格 / 买一数量(ETH)"></th>
<th title="买一价 × 持仓数量"></th>
<th title="买一权利金 ÷ 开仓权利金"></th>
<th></th>
</tr>
</thead>
<tbody>
{plan.residuals.map((r) => {
const gid = String(r.group_id || "");
const inst = String(r.option_inst_id || "—");
const liqOk = r.liquidity_ok === true;
return (
<tr key={`${gid}:${inst}`}>
<td className="mono">{gid || "—"}</td>
<td className="mono">{inst}</td>
<td className="mono">{fmt(r.option_qty_eth, 4)}</td>
<td className="mono">{fmt(r.initial_premium, 2)}</td>
<td
className="mono"
title={
liqOk
? "买一深度可覆盖持仓"
: "买一不足或盘口不可用"
}
>
{fmtBidLiquidity(r.bid_px, r.bid_sz_eth)}
</td>
<td className="mono">{fmt(r.current_premium, 2)}</td>
<td className="mono">
{r.recovery_pct == null
? "—"
: `${fmt(r.recovery_pct, 1)}%`}
</td>
<td>
<button
type="button"
className="btn ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
disabled={!!residualBusy || !!busy || !liqOk}
title={
liqOk
? "按最新买一 IOC 平仓(不验权利金比例)"
: "买一流动性不足或盘口不可用"
}
onClick={() => void closeResidual(gid, inst)}
>
{residualBusy === gid ? "平仓中…" : "平仓"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : null}
</div> </div>
<div className="plan-positions"> <div className="plan-positions">
+45 -35
View File
@@ -725,49 +725,59 @@ input {
min-width: 0; min-width: 0;
} }
.kv-residuals { .plan-residual-block {
align-items: flex-start; margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
} }
.kv-residuals > span:first-child { .plan-residual-head {
padding-top: 2px;
}
.residual-list {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
align-items: flex-end; justify-content: space-between;
gap: 6px; gap: 12px;
min-width: 0; flex-wrap: wrap;
max-width: min(100%, 28em); margin-bottom: 8px;
} }
.residual-item { .plan-residual-head h4 {
text-align: right; margin: 0;
line-height: 1.35; font-size: 0.95rem;
word-break: break-word; color: var(--accent);
overflow-wrap: anywhere; letter-spacing: 0.06em;
} }
@media (max-width: 900px) { .plan-residual-wrap {
.kv-residuals { overflow: auto;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.residual-list {
align-items: stretch;
max-width: none;
}
.residual-item {
text-align: left;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line); border: 1px solid var(--line);
} border-radius: 8px;
background: rgba(0, 0, 0, 0.18);
}
.plan-residual-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
font-size: 0.85rem;
}
.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 { .trade-list {