Add strategy residual table with manual close at panel bottom.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+28
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user