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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
@@ -233,6 +234,12 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
|
|||||||
except Exception:
|
except Exception:
|
||||||
latest_funds = 0.0
|
latest_funds = 0.0
|
||||||
|
|
||||||
|
residuals: list[dict] = []
|
||||||
|
try:
|
||||||
|
residuals = get_engine().matcher.list_residual_options_enriched()
|
||||||
|
except Exception:
|
||||||
|
residuals = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"mode": settings.mode,
|
"mode": settings.mode,
|
||||||
@@ -240,6 +247,7 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
|
|||||||
"exchange": exchange_name,
|
"exchange": exchange_name,
|
||||||
"sim": settings.is_sim,
|
"sim": settings.is_sim,
|
||||||
"latest_funds": latest_funds,
|
"latest_funds": latest_funds,
|
||||||
|
"residuals": residuals,
|
||||||
"market_connected": bool(snap.connected) if snap else False,
|
"market_connected": bool(snap.connected) if snap else False,
|
||||||
"pair": snap.pair.to_dict() if snap and snap.pair else None,
|
"pair": snap.pair.to_dict() if snap and snap.pair else None,
|
||||||
"index_px": (
|
"index_px": (
|
||||||
@@ -315,6 +323,31 @@ async def fleet_pause(_tok: Annotated[str, Depends(require_fleet_token)]) -> dic
|
|||||||
return await get_engine().pause()
|
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")
|
@router.post("/issue-login")
|
||||||
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
|
||||||
username, _ = get_credentials()
|
username, _ = get_credentials()
|
||||||
|
|||||||
@@ -1205,21 +1205,26 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
}
|
}
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def try_close_one_residual(self, row: dict) -> dict | None:
|
def try_close_one_residual(
|
||||||
"""LIVE-BN:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
|
self, row: dict, *, skip_premium_ratio: bool = False
|
||||||
|
) -> dict | None:
|
||||||
|
"""LIVE-BN:流动性通过后按最新买一 IOC 限价卖;自动路径另要求权利金比例。"""
|
||||||
err = self._guard_live()
|
err = self._guard_live()
|
||||||
if err:
|
if err:
|
||||||
logger.warning("residual premium close blocked: %s", err)
|
logger.warning("residual close blocked: %s", err)
|
||||||
return None
|
return None
|
||||||
synced = self._sync_residual_contracts_with_exchange(row)
|
synced = self._sync_residual_contracts_with_exchange(row)
|
||||||
if synced is None:
|
if synced is None:
|
||||||
return None
|
return None
|
||||||
row = synced
|
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 or close_bid is None:
|
||||||
if skip:
|
if skip:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"residual premium close skip %s: %s",
|
"residual close skip %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
skip,
|
skip,
|
||||||
)
|
)
|
||||||
@@ -1232,17 +1237,19 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
|
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
|
||||||
if opt_contracts <= 0:
|
if opt_contracts <= 0:
|
||||||
logger.warning(
|
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
|
return None
|
||||||
oq2 = self._quote_held_option(option_inst_id)
|
oq2 = self._quote_held_option(option_inst_id)
|
||||||
if oq2 is None or oq2.bid is None:
|
if oq2 is None or oq2.bid is None:
|
||||||
return None
|
return None
|
||||||
bid_px = float(oq2.bid)
|
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:
|
if skip2:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"residual premium close recheck skip %s: %s",
|
"residual close recheck skip %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
skip2,
|
skip2,
|
||||||
)
|
)
|
||||||
@@ -1258,7 +1265,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"residual premium close bid-ioc sell failed %s: %s",
|
"residual close bid-ioc sell failed %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
@@ -1276,6 +1283,7 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
|
tag = "manual" if skip_premium_ratio else "mid"
|
||||||
return self._book_residual_market_close(
|
return self._book_residual_market_close(
|
||||||
row,
|
row,
|
||||||
fill_px=of_px,
|
fill_px=of_px,
|
||||||
@@ -1283,11 +1291,15 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
notional=of_px * fill_eth,
|
notional=of_px * fill_eth,
|
||||||
slip=0.0,
|
slip=0.0,
|
||||||
now_ms=now_ms,
|
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",
|
exec_mode="LIVE",
|
||||||
filled_contracts=filled_c,
|
filled_contracts=filled_c,
|
||||||
remaining_contracts=remaining,
|
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(
|
def _try_exchange_flatten_residual(
|
||||||
|
|||||||
@@ -1250,21 +1250,26 @@ class OkxLiveExecutor(Matcher):
|
|||||||
}
|
}
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def try_close_one_residual(self, row: dict) -> dict | None:
|
def try_close_one_residual(
|
||||||
"""LIVE:权利金达标后按最新买一 IOC 限价卖出归档期权(不扫市价)。"""
|
self, row: dict, *, skip_premium_ratio: bool = False
|
||||||
|
) -> dict | None:
|
||||||
|
"""LIVE:流动性通过后按最新买一 IOC 限价卖;自动路径另要求权利金比例。"""
|
||||||
err = self._guard_live()
|
err = self._guard_live()
|
||||||
if err:
|
if err:
|
||||||
logger.warning("residual premium close blocked: %s", err)
|
logger.warning("residual close blocked: %s", err)
|
||||||
return None
|
return None
|
||||||
synced = self._sync_residual_contracts_with_exchange(row)
|
synced = self._sync_residual_contracts_with_exchange(row)
|
||||||
if synced is None:
|
if synced is None:
|
||||||
return None
|
return None
|
||||||
row = synced
|
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 or close_bid is None:
|
||||||
if skip:
|
if skip:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"residual premium close skip %s: %s",
|
"residual close skip %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
skip,
|
skip,
|
||||||
)
|
)
|
||||||
@@ -1277,17 +1282,19 @@ class OkxLiveExecutor(Matcher):
|
|||||||
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
|
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
|
||||||
if opt_contracts <= 0:
|
if opt_contracts <= 0:
|
||||||
logger.warning(
|
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
|
return None
|
||||||
oq2 = self._quote_held_option(option_inst_id)
|
oq2 = self._quote_held_option(option_inst_id)
|
||||||
if oq2 is None or oq2.bid is None:
|
if oq2 is None or oq2.bid is None:
|
||||||
return None
|
return None
|
||||||
bid_px = float(oq2.bid)
|
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:
|
if skip2:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"residual premium close recheck skip %s: %s",
|
"residual close recheck skip %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
skip2,
|
skip2,
|
||||||
)
|
)
|
||||||
@@ -1304,7 +1311,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"residual premium close bid-ioc sell failed %s: %s",
|
"residual close bid-ioc sell failed %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
@@ -1321,6 +1328,7 @@ class OkxLiveExecutor(Matcher):
|
|||||||
remaining = max(0.0, opt_contracts - filled_c)
|
remaining = max(0.0, opt_contracts - filled_c)
|
||||||
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
fill_eth = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
|
tag = "manual" if skip_premium_ratio else "mid"
|
||||||
return self._book_residual_market_close(
|
return self._book_residual_market_close(
|
||||||
row,
|
row,
|
||||||
fill_px=of_px,
|
fill_px=of_px,
|
||||||
@@ -1328,11 +1336,15 @@ class OkxLiveExecutor(Matcher):
|
|||||||
notional=of_px * fill_eth,
|
notional=of_px * fill_eth,
|
||||||
slip=0.0,
|
slip=0.0,
|
||||||
now_ms=now_ms,
|
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",
|
exec_mode="LIVE",
|
||||||
filled_contracts=filled_c,
|
filled_contracts=filled_c,
|
||||||
remaining_contracts=remaining,
|
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(
|
def _try_exchange_flatten_residual(
|
||||||
|
|||||||
+101
-16
@@ -838,16 +838,24 @@ class Matcher:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _residual_bid_gate(
|
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:
|
) -> str | None:
|
||||||
"""权利金比例 + 深度 + 买一/标记偏差。通过返回 None。"""
|
"""深度 + 买一/标记偏差;可选权利金比例。通过返回 None。"""
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
initial_premium = float(row.get("initial_premium") or 0)
|
initial_premium = float(row.get("initial_premium") or 0)
|
||||||
opt_qty = float(row.get("option_qty_eth") or 0)
|
opt_qty = float(row.get("option_qty_eth") or 0)
|
||||||
if initial_premium <= 0 or opt_qty <= 0:
|
if opt_qty <= 0:
|
||||||
return "invalid_initial_premium_or_qty"
|
return "invalid_qty"
|
||||||
if bid <= 0:
|
if bid <= 0:
|
||||||
return "option_bid_unavailable"
|
return "option_bid_unavailable"
|
||||||
|
if require_premium_ratio:
|
||||||
|
if initial_premium <= 0:
|
||||||
|
return "invalid_initial_premium_or_qty"
|
||||||
current_premium = float(bid) * opt_qty
|
current_premium = float(bid) * opt_qty
|
||||||
min_pct = self._residual_min_premium_pct()
|
min_pct = self._residual_min_premium_pct()
|
||||||
threshold = initial_premium * (min_pct / 100.0)
|
threshold = initial_premium * (min_pct / 100.0)
|
||||||
@@ -877,12 +885,11 @@ class Matcher:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _evaluate_residual_premium_close(
|
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]:
|
) -> tuple[str | None, float | None, Any]:
|
||||||
"""
|
"""
|
||||||
残留中途平前置:权利金比例 + 买一流动性。
|
残留平前置:买一流动性;自动路径另加权利金比例。
|
||||||
返回 (skip_reason, close_bid, option_quote);skip_reason 非空则本轮不卖。
|
返回 (skip_reason, close_bid, option_quote);skip_reason 非空则本轮不卖。
|
||||||
成交价口径:最新买一(不再抬到内在价值)。
|
|
||||||
"""
|
"""
|
||||||
option_inst_id = str(row.get("option_inst_id") or "")
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
if not option_inst_id:
|
if not option_inst_id:
|
||||||
@@ -891,11 +898,73 @@ class Matcher:
|
|||||||
if oq is None or oq.bid is None:
|
if oq is None or oq.bid is None:
|
||||||
return ("option_bid_unavailable", None, None)
|
return ("option_bid_unavailable", None, None)
|
||||||
close_bid = float(oq.bid)
|
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:
|
if skip:
|
||||||
return (skip, None, None)
|
return (skip, None, None)
|
||||||
return (None, close_bid, oq)
|
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(
|
def _book_residual_market_close(
|
||||||
self,
|
self,
|
||||||
row: dict[str, Any],
|
row: dict[str, Any],
|
||||||
@@ -1094,25 +1163,31 @@ class Matcher:
|
|||||||
"fully_done": fully_done,
|
"fully_done": fully_done,
|
||||||
}
|
}
|
||||||
|
|
||||||
def try_close_one_residual(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
def try_close_one_residual(
|
||||||
"""SIM:权利金达标且流动性通过则本地吃买一平残留。"""
|
self, row: dict[str, Any], *, skip_premium_ratio: bool = False
|
||||||
skip, close_bid, oq = self._evaluate_residual_premium_close(row)
|
) -> 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 or close_bid is None or oq is None:
|
||||||
if skip:
|
if skip:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"residual premium close skip %s: %s",
|
"residual close skip %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
skip,
|
skip,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
# 下单前再刷买一并重跑门槛
|
|
||||||
option_inst_id = str(row.get("option_inst_id") or "")
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
oq2 = self._quote_held_option(option_inst_id) or oq
|
oq2 = self._quote_held_option(option_inst_id) or oq
|
||||||
bid2 = float(oq2.bid) if oq2.bid is not None else float(close_bid)
|
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:
|
if skip2:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"residual premium close recheck skip %s: %s",
|
"residual close recheck skip %s: %s",
|
||||||
row.get("group_id"),
|
row.get("group_id"),
|
||||||
skip2,
|
skip2,
|
||||||
)
|
)
|
||||||
@@ -1125,6 +1200,11 @@ class Matcher:
|
|||||||
fee_rate=self._fee_rate(),
|
fee_rate=self._fee_rate(),
|
||||||
)
|
)
|
||||||
now_ms = int(time.time() * 1000)
|
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(
|
return self._book_residual_market_close(
|
||||||
row,
|
row,
|
||||||
fill_px=of.fill_px,
|
fill_px=of.fill_px,
|
||||||
@@ -1132,9 +1212,14 @@ class Matcher:
|
|||||||
notional=of.notional,
|
notional=of.notional,
|
||||||
slip=of.slip,
|
slip=of.slip,
|
||||||
now_ms=now_ms,
|
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,
|
filled_contracts=float(row.get("option_qty_contracts") or 0) or None,
|
||||||
remaining_contracts=0.0,
|
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]]:
|
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()
|
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:
|
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setenv("MODE", "SIM")
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
from app.api import settings as settings_api
|
from app.api import settings as settings_api
|
||||||
|
|||||||
@@ -442,6 +442,35 @@ async def node_stats(
|
|||||||
return data
|
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")
|
@router.post("/{node_id}/start")
|
||||||
async def node_start(
|
async def node_start(
|
||||||
node_id: int,
|
node_id: int,
|
||||||
|
|||||||
@@ -76,6 +76,54 @@ function leveragePair(strat: Record<string, unknown>): string {
|
|||||||
return `${a}/${b}`;
|
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 = {
|
type NodeStats = {
|
||||||
mode?: string;
|
mode?: string;
|
||||||
show_slip?: boolean;
|
show_slip?: boolean;
|
||||||
@@ -249,6 +297,7 @@ export default function MonitorPage() {
|
|||||||
const [detailStatsErr, setDetailStatsErr] = useState("");
|
const [detailStatsErr, setDetailStatsErr] = useState("");
|
||||||
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
|
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
|
||||||
const [equityPage, setEquityPage] = useState(0);
|
const [equityPage, setEquityPage] = useState(0);
|
||||||
|
const [residualBusy, setResidualBusy] = useState<string>("");
|
||||||
const EQUITY_PAGE_SIZE = 5;
|
const EQUITY_PAGE_SIZE = 5;
|
||||||
|
|
||||||
const applyNodes = useCallback((list: NodeCard[]) => {
|
const applyNodes = useCallback((list: NodeCard[]) => {
|
||||||
@@ -504,6 +553,26 @@ export default function MonitorPage() {
|
|||||||
})
|
})
|
||||||
.map((n) => n.id);
|
.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() {
|
async function startAll() {
|
||||||
if (!startableIds.length) {
|
if (!startableIds.length) {
|
||||||
setErr("没有可启动的策略机(需已配对且当前未运行)");
|
setErr("没有可启动的策略机(需已配对且当前未运行)");
|
||||||
@@ -564,6 +633,7 @@ export default function MonitorPage() {
|
|||||||
equityPageSafe * EQUITY_PAGE_SIZE,
|
equityPageSafe * EQUITY_PAGE_SIZE,
|
||||||
equityPageSafe * EQUITY_PAGE_SIZE + EQUITY_PAGE_SIZE,
|
equityPageSafe * EQUITY_PAGE_SIZE + EQUITY_PAGE_SIZE,
|
||||||
);
|
);
|
||||||
|
const residualRows = collectResiduals(nodes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -751,6 +821,63 @@ export default function MonitorPage() {
|
|||||||
<p className="meta">暂无策略机。请到「系统设置」添加并生成 Token。</p>
|
<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 ? (
|
{detailNode && detail ? (
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal-backdrop"
|
||||||
|
|||||||
@@ -320,6 +320,18 @@ input {
|
|||||||
white-space: normal;
|
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 {
|
.status-dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user