diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index a6e42cb..092f834 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -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() diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 689efbc..7c0964a 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -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( diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 690cc41..264e85e 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -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( diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 194814d..dcc75d1 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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]]: diff --git a/backend/tests/test_residual_premium_close.py b/backend/tests/test_residual_premium_close.py index 67c00d7..fabccc1 100644 --- a/backend/tests/test_residual_premium_close.py +++ b/backend/tests/test_residual_premium_close.py @@ -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 diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py index 36cefd8..007ed31 100644 --- a/control/backend/app/api/nodes.py +++ b/control/backend/app/api/nodes.py @@ -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, diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index c25cdbf..ada3837 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -76,6 +76,54 @@ function leveragePair(strat: Record): 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; + 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; + 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(""); 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 (
@@ -751,6 +821,63 @@ export default function MonitorPage() {

暂无策略机。请到「系统设置」添加并生成 Token。

)} + {residualRows.length ? ( + <> +
+

残留期权

+ + 自动平仓仍要求权利金≥设定比例;下方「平仓」仅验流动性 + +
+
+ + + + + + + + + + + + + + + {residualRows.map((r) => ( + + + + + + + + + + + ))} + +
机器名组名期权名数量开仓权利金当前买一权利金回收占比%操作
{r.nodeName}{r.group_id}{r.option_inst_id}{fmt(r.option_qty_eth, 4)}{fmt(r.initial_premium, 2)}{fmt(r.current_premium, 2)} + {r.recovery_pct == null ? "—" : fmt(r.recovery_pct, 1)} + + +
+
+ + ) : null} + {detailNode && detail ? (