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
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
+44
-34
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user