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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user