Add control residual options table with liquidity-only manual close.

Fleet status exposes enriched residuals; manual close skips the premium recovery gate while auto mid-close remains unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 15:13:29 +08:00
parent 92c2e89b0e
commit a7aee8f425
8 changed files with 407 additions and 46 deletions
+33
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import logging
@@ -233,6 +234,12 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
except Exception:
latest_funds = 0.0
residuals: list[dict] = []
try:
residuals = get_engine().matcher.list_residual_options_enriched()
except Exception:
residuals = []
return {
"ok": True,
"mode": settings.mode,
@@ -240,6 +247,7 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
"exchange": exchange_name,
"sim": settings.is_sim,
"latest_funds": latest_funds,
"residuals": residuals,
"market_connected": bool(snap.connected) if snap else False,
"pair": snap.pair.to_dict() if snap and snap.pair else None,
"index_px": (
@@ -315,6 +323,31 @@ async def fleet_pause(_tok: Annotated[str, Depends(require_fleet_token)]) -> dic
return await get_engine().pause()
class ResidualCloseBody(BaseModel):
group_id: str = Field(min_length=1, max_length=128)
@router.post("/residual/close")
async def fleet_residual_close(
body: ResidualCloseBody,
_tok: Annotated[str, Depends(require_fleet_token)],
) -> 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,
}
@router.post("/issue-login")
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
username, _ = get_credentials()
+23 -11
View File
@@ -1205,21 +1205,26 @@ class BinanceLiveExecutor(Matcher):
}
return row
def try_close_one_residual(self, row: dict) -> dict | None:
"""LIVE-BN:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
def try_close_one_residual(
self, row: dict, *, skip_premium_ratio: bool = False
) -> dict | None:
"""LIVE-BN:流动性通过后按最新买一 IOC 限价卖;自动路径另要求权利金比例。"""
err = self._guard_live()
if err:
logger.warning("residual premium close blocked: %s", err)
logger.warning("residual close blocked: %s", err)
return None
synced = self._sync_residual_contracts_with_exchange(row)
if synced is None:
return None
row = synced
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
require_ratio = not skip_premium_ratio
skip, close_bid, _oq = self._evaluate_residual_premium_close(
row, require_premium_ratio=require_ratio
)
if skip or close_bid is None:
if skip:
logger.debug(
"residual premium close skip %s: %s",
"residual close skip %s: %s",
row.get("group_id"),
skip,
)
@@ -1232,17 +1237,19 @@ class BinanceLiveExecutor(Matcher):
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
if opt_contracts <= 0:
logger.warning(
"residual premium close skip %s: bad contracts", row.get("group_id")
"residual close skip %s: bad contracts", row.get("group_id")
)
return None
oq2 = self._quote_held_option(option_inst_id)
if oq2 is None or oq2.bid is None:
return None
bid_px = float(oq2.bid)
skip2 = self._residual_bid_gate(row, bid=bid_px, oq=oq2)
skip2 = self._residual_bid_gate(
row, bid=bid_px, oq=oq2, require_premium_ratio=require_ratio
)
if skip2:
logger.debug(
"residual premium close recheck skip %s: %s",
"residual close recheck skip %s: %s",
row.get("group_id"),
skip2,
)
@@ -1258,7 +1265,7 @@ class BinanceLiveExecutor(Matcher):
)
except Exception as e:
logger.warning(
"residual premium close bid-ioc sell failed %s: %s",
"residual close bid-ioc sell failed %s: %s",
row.get("group_id"),
e,
)
@@ -1276,6 +1283,7 @@ class BinanceLiveExecutor(Matcher):
)
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
now_ms = int(time.time() * 1000)
tag = "manual" if skip_premium_ratio else "mid"
return self._book_residual_market_close(
row,
fill_px=of_px,
@@ -1283,11 +1291,15 @@ class BinanceLiveExecutor(Matcher):
notional=of_px * fill_eth,
slip=0.0,
now_ms=now_ms,
note=f"LIVE-BN residual mid-close at bid IOC px={bid_px}",
note=f"LIVE-BN residual {tag}-close at bid IOC px={bid_px}",
exec_mode="LIVE",
filled_contracts=filled_c,
remaining_contracts=remaining,
close_reason="residual_premium_close",
close_reason=(
"residual_manual_close"
if skip_premium_ratio
else "residual_premium_close"
),
)
def _try_exchange_flatten_residual(
+23 -11
View File
@@ -1250,21 +1250,26 @@ class OkxLiveExecutor(Matcher):
}
return row
def try_close_one_residual(self, row: dict) -> dict | None:
"""LIVE:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
def try_close_one_residual(
self, row: dict, *, skip_premium_ratio: bool = False
) -> dict | None:
"""LIVE:流动性通过后按最新买一 IOC 限价卖;自动路径另要求权利金比例。"""
err = self._guard_live()
if err:
logger.warning("residual premium close blocked: %s", err)
logger.warning("residual close blocked: %s", err)
return None
synced = self._sync_residual_contracts_with_exchange(row)
if synced is None:
return None
row = synced
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
require_ratio = not skip_premium_ratio
skip, close_bid, _oq = self._evaluate_residual_premium_close(
row, require_premium_ratio=require_ratio
)
if skip or close_bid is None:
if skip:
logger.debug(
"residual premium close skip %s: %s",
"residual close skip %s: %s",
row.get("group_id"),
skip,
)
@@ -1277,17 +1282,19 @@ class OkxLiveExecutor(Matcher):
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
if opt_contracts <= 0:
logger.warning(
"residual premium close skip %s: bad contracts", row.get("group_id")
"residual close skip %s: bad contracts", row.get("group_id")
)
return None
oq2 = self._quote_held_option(option_inst_id)
if oq2 is None or oq2.bid is None:
return None
bid_px = float(oq2.bid)
skip2 = self._residual_bid_gate(row, bid=bid_px, oq=oq2)
skip2 = self._residual_bid_gate(
row, bid=bid_px, oq=oq2, require_premium_ratio=require_ratio
)
if skip2:
logger.debug(
"residual premium close recheck skip %s: %s",
"residual close recheck skip %s: %s",
row.get("group_id"),
skip2,
)
@@ -1304,7 +1311,7 @@ class OkxLiveExecutor(Matcher):
)
except Exception as e:
logger.warning(
"residual premium close bid-ioc sell failed %s: %s",
"residual close bid-ioc sell failed %s: %s",
row.get("group_id"),
e,
)
@@ -1321,6 +1328,7 @@ class OkxLiveExecutor(Matcher):
remaining = max(0.0, opt_contracts - filled_c)
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
now_ms = int(time.time() * 1000)
tag = "manual" if skip_premium_ratio else "mid"
return self._book_residual_market_close(
row,
fill_px=of_px,
@@ -1328,11 +1336,15 @@ class OkxLiveExecutor(Matcher):
notional=of_px * fill_eth,
slip=0.0,
now_ms=now_ms,
note=f"LIVE residual mid-close at bid IOC px={bid_px}",
note=f"LIVE residual {tag}-close at bid IOC px={bid_px}",
exec_mode="LIVE",
filled_contracts=filled_c,
remaining_contracts=remaining,
close_reason="residual_premium_close",
close_reason=(
"residual_manual_close"
if skip_premium_ratio
else "residual_premium_close"
),
)
def _try_exchange_flatten_residual(
+109 -24
View File
@@ -838,24 +838,32 @@ class Matcher:
)
def _residual_bid_gate(
self, row: dict[str, Any], *, bid: float, oq: Any
self,
row: dict[str, Any],
*,
bid: float,
oq: Any,
require_premium_ratio: bool = True,
) -> str | None:
"""权利金比例 + 深度 + 买一/标记偏差。通过返回 None。"""
"""深度 + 买一/标记偏差;可选权利金比例。通过返回 None。"""
s = get_settings()
initial_premium = float(row.get("initial_premium") or 0)
opt_qty = float(row.get("option_qty_eth") or 0)
if initial_premium <= 0 or opt_qty <= 0:
return "invalid_initial_premium_or_qty"
if opt_qty <= 0:
return "invalid_qty"
if bid <= 0:
return "option_bid_unavailable"
current_premium = float(bid) * opt_qty
min_pct = self._residual_min_premium_pct()
threshold = initial_premium * (min_pct / 100.0)
if current_premium + 1e-12 < threshold:
return (
f"premium_below_threshold curr={current_premium:.4f} "
f"need>={threshold:.4f} ({min_pct:g}%)"
)
if require_premium_ratio:
if initial_premium <= 0:
return "invalid_initial_premium_or_qty"
current_premium = float(bid) * opt_qty
min_pct = self._residual_min_premium_pct()
threshold = initial_premium * (min_pct / 100.0)
if current_premium + 1e-12 < threshold:
return (
f"premium_below_threshold curr={current_premium:.4f} "
f"need>={threshold:.4f} ({min_pct:g}%)"
)
option_inst_id = str(row.get("option_inst_id") or "")
ct_mult = self._ct_mult(option_inst_id)
if not bid_covers_eth(
@@ -877,12 +885,11 @@ class Matcher:
return None
def _evaluate_residual_premium_close(
self, row: dict[str, Any]
self, row: dict[str, Any], *, require_premium_ratio: bool = True
) -> tuple[str | None, float | None, Any]:
"""
残留中途平前置:权利金比例 + 买一流动性。
残留平前置:买一流动性;自动路径另加权利金比例
返回 (skip_reason, close_bid, option_quote)skip_reason 非空则本轮不卖。
成交价口径:最新买一(不再抬到内在价值)。
"""
option_inst_id = str(row.get("option_inst_id") or "")
if not option_inst_id:
@@ -891,11 +898,73 @@ class Matcher:
if oq is None or oq.bid is None:
return ("option_bid_unavailable", None, None)
close_bid = float(oq.bid)
skip = self._residual_bid_gate(row, bid=close_bid, oq=oq)
skip = self._residual_bid_gate(
row,
bid=close_bid,
oq=oq,
require_premium_ratio=require_premium_ratio,
)
if skip:
return (skip, None, None)
return (None, close_bid, oq)
def list_residual_options_enriched(self) -> list[dict[str, Any]]:
"""pending 残留 + 买一权利金/回收占比/流动性是否可手动平。"""
out: list[dict[str, Any]] = []
for row in self.list_residual_options(pending_only=True):
d = dict(row)
option_inst_id = str(d.get("option_inst_id") or "")
opt_qty = float(d.get("option_qty_eth") or 0)
init = float(d.get("initial_premium") or 0)
oq = self._quote_held_option(option_inst_id) if option_inst_id else None
bid = float(oq.bid) if oq is not None and oq.bid is not None else None
cur = float(bid) * opt_qty if bid is not None else None
ratio = (cur / init * 100.0) if cur is not None and init > 1e-12 else None
liq_detail: str | None
if bid is None or oq is None:
liq_detail = "option_bid_unavailable"
else:
liq_detail = self._residual_bid_gate(
d, bid=bid, oq=oq, require_premium_ratio=False
)
d.update(
{
"bid_px": bid,
"current_premium": cur,
"recovery_pct": ratio,
"liquidity_ok": liq_detail is None,
"liquidity_detail": liq_detail,
}
)
out.append(d)
return out
def close_residual_manual(self, group_id: str) -> CloseResult:
"""中控手动平单条残留:只验流动性,不验权利金比例。"""
gid = str(group_id or "").strip()
if not gid:
return CloseResult(ok=False, detail="缺少 group_id")
row = self.db.fetchone(
"SELECT * FROM residual_options WHERE group_id=? AND status='pending'",
(gid,),
)
if row is None:
return CloseResult(ok=False, detail="无该组 pending 残留")
d = dict(row)
skip, _bid, _oq = self._evaluate_residual_premium_close(
d, require_premium_ratio=False
)
if skip:
return CloseResult(ok=False, detail=skip, liquidity_wait=True)
booked = self.try_close_one_residual(d, skip_premium_ratio=True)
if not booked:
return CloseResult(
ok=False,
detail="平残留失败(流动性变化或下单未成交)",
liquidity_wait=True,
)
return CloseResult(ok=True, detail="residual_manual_closed", data=booked)
def _book_residual_market_close(
self,
row: dict[str, Any],
@@ -1094,25 +1163,31 @@ class Matcher:
"fully_done": fully_done,
}
def try_close_one_residual(self, row: dict[str, Any]) -> dict[str, Any] | None:
"""SIM:权利金达标且流动性通过则本地吃买一平残留。"""
skip, close_bid, oq = self._evaluate_residual_premium_close(row)
def try_close_one_residual(
self, row: dict[str, Any], *, skip_premium_ratio: bool = False
) -> dict[str, Any] | None:
"""SIM:流动性通过则吃买一平残留;自动路径另要求权利金比例。"""
require_ratio = not skip_premium_ratio
skip, close_bid, oq = self._evaluate_residual_premium_close(
row, require_premium_ratio=require_ratio
)
if skip or close_bid is None or oq is None:
if skip:
logger.debug(
"residual premium close skip %s: %s",
"residual close skip %s: %s",
row.get("group_id"),
skip,
)
return None
# 下单前再刷买一并重跑门槛
option_inst_id = str(row.get("option_inst_id") or "")
oq2 = self._quote_held_option(option_inst_id) or oq
bid2 = float(oq2.bid) if oq2.bid is not None else float(close_bid)
skip2 = self._residual_bid_gate(row, bid=bid2, oq=oq2)
skip2 = self._residual_bid_gate(
row, bid=bid2, oq=oq2, require_premium_ratio=require_ratio
)
if skip2:
logger.debug(
"residual premium close recheck skip %s: %s",
"residual close recheck skip %s: %s",
row.get("group_id"),
skip2,
)
@@ -1125,6 +1200,11 @@ class Matcher:
fee_rate=self._fee_rate(),
)
now_ms = int(time.time() * 1000)
note = (
f"residual manual close at bid px={bid2}"
if skip_premium_ratio
else f"residual mid-close at bid px={bid2}"
)
return self._book_residual_market_close(
row,
fill_px=of.fill_px,
@@ -1132,9 +1212,14 @@ class Matcher:
notional=of.notional,
slip=of.slip,
now_ms=now_ms,
note=f"residual mid-close at bid px={bid2}",
note=note,
filled_contracts=float(row.get("option_qty_contracts") or 0) or None,
remaining_contracts=0.0,
close_reason=(
"residual_manual_close"
if skip_premium_ratio
else "residual_premium_close"
),
)
def try_close_pending_residuals(self) -> list[dict[str, Any]]:
@@ -189,6 +189,57 @@ def test_residual_book_pending_guard(tmp_path, monkeypatch) -> None:
db.close()
def test_manual_close_skips_premium_ratio(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "manual.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0)
m = Matcher(db)
# bid=5 → premium=10 < 20%,自动路径应跳过,手动可平
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
enriched = m.list_residual_options_enriched()
assert len(enriched) == 1
assert enriched[0]["liquidity_ok"] is True
assert enriched[0]["recovery_pct"] is not None
assert float(enriched[0]["recovery_pct"]) < 20.0
r = m.close_residual_manual("G-res")
assert r.ok is True
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "settled"
db.close()
def test_manual_close_liquidity_still_required(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "manual_liq.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0)
m = Matcher(db)
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=1.0, mark_px=5.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
r = m.close_residual_manual("G-res")
assert r.ok is False
assert "liquidity" in (r.detail or "")
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "pending"
db.close()
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.api import settings as settings_api
+29
View File
@@ -442,6 +442,35 @@ async def node_stats(
return data
class ResidualCloseBody(BaseModel):
group_id: str = Field(min_length=1, max_length=128)
@router.post("/{node_id}/residual/close")
async def node_residual_close(
node_id: int,
body: ResidualCloseBody,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""代理策略机手动平单条残留(只验流动性)。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
if not node.get("token_sealed"):
raise HTTPException(status_code=400, detail="未生成 Token")
code, data = await call_node(
node,
"POST",
"/api/fleet/residual/close",
json_body={"group_id": body.group_id},
timeout=30.0,
)
if code >= 400:
_raise_node_error(code, data)
return {"ok": True, "result": data}
@router.post("/{node_id}/start")
async def node_start(
node_id: int,
+127
View File
@@ -76,6 +76,54 @@ function leveragePair(strat: Record<string, unknown>): string {
return `${a}/${b}`;
}
type ResidualRow = {
key: string;
nodeId: number;
nodeName: string;
group_id: string;
option_inst_id: string;
option_qty_eth: number | null;
initial_premium: number | null;
bid_px: number | null;
current_premium: number | null;
recovery_pct: number | null;
liquidity_ok: boolean;
};
function collectResiduals(nodes: NodeCard[]): ResidualRow[] {
const out: ResidualRow[] = [];
for (const n of nodes) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
const list = fleet.residuals;
if (!Array.isArray(list)) continue;
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const r = raw as Record<string, unknown>;
const gid = String(r.group_id || "");
if (!gid) continue;
const numOrNull = (v: unknown) => {
if (v == null || v === "") return null;
const x = Number(v);
return Number.isFinite(x) ? x : null;
};
out.push({
key: `${n.id}:${gid}`,
nodeId: n.id,
nodeName: n.name,
group_id: gid,
option_inst_id: String(r.option_inst_id || "—"),
option_qty_eth: numOrNull(r.option_qty_eth),
initial_premium: numOrNull(r.initial_premium),
bid_px: numOrNull(r.bid_px),
current_premium: numOrNull(r.current_premium),
recovery_pct: numOrNull(r.recovery_pct),
liquidity_ok: r.liquidity_ok === true,
});
}
}
return out;
}
type NodeStats = {
mode?: string;
show_slip?: boolean;
@@ -249,6 +297,7 @@ export default function MonitorPage() {
const [detailStatsErr, setDetailStatsErr] = useState("");
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
const [equityPage, setEquityPage] = useState(0);
const [residualBusy, setResidualBusy] = useState<string>("");
const EQUITY_PAGE_SIZE = 5;
const applyNodes = useCallback((list: NodeCard[]) => {
@@ -504,6 +553,26 @@ export default function MonitorPage() {
})
.map((n) => n.id);
async function closeResidual(row: ResidualRow) {
const ok = window.confirm(
`确认平掉残留期权?\n\n机器:${row.nodeName}\n组:${row.group_id}\n合约:${row.option_inst_id}\n\n仅校验买一流动性,不要求权利金回收比例。`,
);
if (!ok) return;
setResidualBusy(row.key);
setErr("");
try {
await apiFetch(`/api/nodes/${row.nodeId}/residual/close`, {
method: "POST",
body: JSON.stringify({ group_id: row.group_id }),
});
await refresh();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setResidualBusy("");
}
}
async function startAll() {
if (!startableIds.length) {
setErr("没有可启动的策略机(需已配对且当前未运行)");
@@ -564,6 +633,7 @@ export default function MonitorPage() {
equityPageSafe * EQUITY_PAGE_SIZE,
equityPageSafe * EQUITY_PAGE_SIZE + EQUITY_PAGE_SIZE,
);
const residualRows = collectResiduals(nodes);
return (
<div>
@@ -751,6 +821,63 @@ export default function MonitorPage() {
<p className="meta"> Token</p>
)}
{residualRows.length ? (
<>
<div className="toolbar residual-toolbar">
<h2></h2>
<span className="meta">
</span>
</div>
<div className="monitor-table-wrap">
<table className="monitor-table residual-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>%</th>
<th></th>
</tr>
</thead>
<tbody>
{residualRows.map((r) => (
<tr key={r.key}>
<td>{r.nodeName}</td>
<td className="mono">{r.group_id}</td>
<td className="mono">{r.option_inst_id}</td>
<td className="mono">{fmt(r.option_qty_eth, 4)}</td>
<td className="mono">{fmt(r.initial_premium, 2)}</td>
<td className="mono">{fmt(r.current_premium, 2)}</td>
<td className="mono">
{r.recovery_pct == null ? "—" : fmt(r.recovery_pct, 1)}
</td>
<td className="col-actions">
<button
type="button"
className="btn btn-sm"
disabled={!!residualBusy || !r.liquidity_ok}
title={
r.liquidity_ok
? "按最新买一 IOC 平仓(不验权利金比例)"
: "买一流动性不足或盘口不可用"
}
onClick={() => void closeResidual(r)}
>
{residualBusy === r.key ? "平仓中…" : "平仓"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
{detailNode && detail ? (
<div
className="modal-backdrop"
+12
View File
@@ -320,6 +320,18 @@ input {
white-space: normal;
}
.residual-toolbar {
margin-top: 1.5rem;
}
.monitor-table.residual-table {
min-width: 900px;
}
.monitor-table.residual-table tbody tr {
cursor: default;
}
.status-dots {
display: inline-flex;
align-items: center;