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
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,
}
+1 -1
View File
@@ -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,
+14 -5
View File
@@ -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";
+104 -21
View File
@@ -94,11 +94,22 @@ const PHASE_ZH: Record<string, string> = {
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<MarketSnapshot | null>(null);
const [plan, setPlan] = useState<PlanState | null>(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() {
</span>
</span>
</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">
<span></span>
<span className="mono">{biasTag}</span>
@@ -559,6 +569,79 @@ export default function PlanPage() {
</div>
) : null}
</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 className="plan-positions">
+44 -34
View File
@@ -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 {