Add depth-based option close flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,6 +50,68 @@ def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.0
|
||||
return float(quote_per_unit) * float(eth_amount)
|
||||
|
||||
|
||||
def estimate_close_by_bids(
|
||||
bids: list[dict[str, Any]] | None,
|
||||
sheets: int | float,
|
||||
*,
|
||||
ct_mult: float = 0.01,
|
||||
premium_paid: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""按买一到买N逐档估算限价卖出可收回金额."""
|
||||
target = max(0, int(float(sheets or 0)))
|
||||
remaining = target
|
||||
total_received = 0.0
|
||||
levels: list[dict[str, Any]] = []
|
||||
if target <= 0 or ct_mult <= 0:
|
||||
return {
|
||||
"levels": [],
|
||||
"covered_sheets": 0,
|
||||
"uncovered_sheets": target,
|
||||
"total_received": 0.0,
|
||||
"avg_px": None,
|
||||
"estimated_pnl": None,
|
||||
}
|
||||
for i, level in enumerate(bids or [], start=1):
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
px = float(level.get("px"))
|
||||
sz = int(float(level.get("sz")))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
if px <= 0 or sz <= 0:
|
||||
continue
|
||||
take = min(remaining, sz)
|
||||
eth_amount = eth_amount_from_sheets(take, ct_mult)
|
||||
received = total_premium(px, eth_amount)
|
||||
levels.append(
|
||||
{
|
||||
"level": i,
|
||||
"px": px,
|
||||
"available_sheets": sz,
|
||||
"sheets": take,
|
||||
"eth_amount": eth_amount,
|
||||
"received": round(received, 4),
|
||||
}
|
||||
)
|
||||
total_received += received
|
||||
remaining -= take
|
||||
covered = target - remaining
|
||||
avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None
|
||||
estimated_pnl = None
|
||||
if premium_paid is not None and covered > 0:
|
||||
paid_basis = float(premium_paid) * (covered / target)
|
||||
estimated_pnl = round(total_received - paid_basis, 4)
|
||||
return {
|
||||
"levels": levels,
|
||||
"covered_sheets": covered,
|
||||
"uncovered_sheets": remaining,
|
||||
"total_received": round(total_received, 4),
|
||||
"avg_px": round(avg_px, 4) if avg_px is not None else None,
|
||||
"estimated_pnl": estimated_pnl,
|
||||
}
|
||||
|
||||
|
||||
def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int:
|
||||
if eth_amount <= 0 or ct_mult <= 0:
|
||||
return 0
|
||||
|
||||
+208
-11
@@ -14,6 +14,7 @@ from lib.options.options_monitor_lib import options_monitor_loop
|
||||
from lib.options.options_pricing_lib import (
|
||||
calc_order_size,
|
||||
ct_mult_from_meta,
|
||||
estimate_close_by_bids,
|
||||
min_sz_from_meta,
|
||||
premium_per_sheet,
|
||||
total_premium,
|
||||
@@ -76,6 +77,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
build_option_chain,
|
||||
estimate_usdt_to_usdc,
|
||||
execute_convert,
|
||||
fetch_option_book_depth,
|
||||
fetch_option_positions,
|
||||
fetch_options_balances,
|
||||
format_position_row,
|
||||
@@ -109,6 +111,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
|
||||
"build_option_chain": build_option_chain,
|
||||
"quote_option_contract": quote_option_contract,
|
||||
"fetch_option_book_depth": fetch_option_book_depth,
|
||||
"place_option_limit_order": place_option_limit_order,
|
||||
"place_option_market_order": place_option_market_order,
|
||||
"fetch_option_positions": fetch_option_positions,
|
||||
@@ -143,6 +146,75 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
||||
return float(raw), ""
|
||||
|
||||
|
||||
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
rec = conn.execute(
|
||||
"""
|
||||
SELECT premium_paid FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if rec and rec["premium_paid"] is not None:
|
||||
return round(float(rec["premium_paid"]), 4)
|
||||
finally:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
def _position_avail_sheets(pos: dict[str, Any]) -> int:
|
||||
avail = _safe_float(pos.get("availPos"))
|
||||
if avail is None or avail <= 0:
|
||||
avail = abs(_safe_float(pos.get("pos")) or 0)
|
||||
return max(0, int(avail or 0))
|
||||
|
||||
|
||||
def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None:
|
||||
return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None)
|
||||
|
||||
|
||||
def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None:
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
|
||||
invalidate_option_positions_cache()
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return None
|
||||
pos = _find_position(raw, inst_id)
|
||||
if not pos:
|
||||
return 0
|
||||
return _position_avail_sheets(pos)
|
||||
|
||||
|
||||
def _attach_close_preview(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
sheets: int | None = None,
|
||||
premium_paid: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
inst_id = str(row.get("inst_id") or row.get("instId") or "").strip()
|
||||
if not inst_id:
|
||||
return row
|
||||
ct_mult = float(row.get("ct_mult") or 0.01)
|
||||
target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0))
|
||||
paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid"))
|
||||
book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
|
||||
row["bid_depth"] = book.get("bids") or []
|
||||
row["ask_depth"] = book.get("asks") or []
|
||||
row["close_preview"] = estimate_close_by_bids(
|
||||
row["bid_depth"],
|
||||
target_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=paid,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
_OPTIONS_SYNC_LOCK = threading.Lock()
|
||||
_OPTIONS_SYNC_LAST_AT = 0.0
|
||||
_OPTIONS_SYNC_INTERVAL_SEC = 15.0
|
||||
@@ -231,6 +303,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ct_mult = q.get("ct_mult") or 0.01
|
||||
min_sz = q.get("min_sz") or 1
|
||||
mode = (request.args.get("mode") or "budget_full").strip()
|
||||
sheet_count = None
|
||||
try:
|
||||
if request.args.get("sheets"):
|
||||
sheet_count = int(request.args.get("sheets"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if mode == "close_preview":
|
||||
paid = _open_premium_paid(cfg, inst_id)
|
||||
target = sheet_count if sheet_count is not None else 0
|
||||
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
|
||||
budget = cfg["trade_budget"]
|
||||
budget_cap = cfg["trade_budget"]
|
||||
available_usdc = None
|
||||
@@ -243,17 +325,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
|
||||
available_usdc = fetch_options_trading_usdc(ex)
|
||||
eth_amount = None
|
||||
sheet_count = None
|
||||
try:
|
||||
if request.args.get("eth_amount"):
|
||||
eth_amount = float(request.args.get("eth_amount"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
if request.args.get("sheets"):
|
||||
sheet_count = int(request.args.get("sheets"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if ask is None or ask <= 0:
|
||||
return jsonify({**q, "ok": False, "msg": "暂无卖一价"})
|
||||
sizing = calc_order_size(
|
||||
@@ -266,6 +342,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
sheets=sheet_count if mode == "sheets" else None,
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
)
|
||||
q = _attach_close_preview(
|
||||
cfg,
|
||||
ex,
|
||||
q,
|
||||
sheets=int(sizing.get("sheets") or sheet_count or 0),
|
||||
premium_paid=_open_premium_paid(cfg, inst_id),
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
**q,
|
||||
@@ -403,6 +486,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
).fetchone()
|
||||
if rec and rec["premium_paid"] is not None:
|
||||
row["premium_paid"] = round(float(rec["premium_paid"]), 4)
|
||||
_attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "positions": rows})
|
||||
@@ -416,23 +500,24 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
data = request.get_json(silent=True) or {}
|
||||
inst_id = (data.get("inst_id") or "").strip()
|
||||
use_market = bool(data.get("market")) and cfg["allow_market_close"]
|
||||
close_mode = (data.get("mode") or "").strip()
|
||||
depth_split = close_mode == "depth_split" and not use_market
|
||||
if not inst_id:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
sheets = data.get("sheets")
|
||||
q = cfg["quote_option_contract"](ex, inst_id)
|
||||
bid = q.get("bid")
|
||||
if not use_market and (bid is None or bid <= 0):
|
||||
if not use_market and not depth_split and (bid is None or bid <= 0):
|
||||
return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"})
|
||||
raw_positions = cfg["fetch_option_positions"](ex)
|
||||
if raw_positions is None:
|
||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
|
||||
pos = _find_position(raw_positions, inst_id)
|
||||
if not pos:
|
||||
return jsonify({"ok": False, "msg": "未找到持仓"})
|
||||
avail = _safe_float(pos.get("availPos"))
|
||||
if avail is None or avail <= 0:
|
||||
avail = abs(_safe_float(pos.get("pos")) or 0)
|
||||
avail = _position_avail_sheets(pos)
|
||||
close_sheets = int(sheets) if sheets else int(avail)
|
||||
close_sheets = min(close_sheets, int(avail))
|
||||
if close_sheets < 1:
|
||||
return jsonify({"ok": False, "msg": "可平张数不足"})
|
||||
td_mode = str(pos.get("mgnMode") or cfg["td_mode"])
|
||||
@@ -450,6 +535,118 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
)
|
||||
if not order.get("ok"):
|
||||
return jsonify(order)
|
||||
elif depth_split:
|
||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||
remaining = close_sheets
|
||||
submitted_sheets = 0
|
||||
filled_or_reduced_sheets = 0
|
||||
total_received = 0.0
|
||||
orders: list[dict[str, Any]] = []
|
||||
stopped_reason = None
|
||||
for _ in range(5):
|
||||
if remaining <= 0:
|
||||
break
|
||||
current_avail = _refresh_position_avail(cfg, ex, inst_id)
|
||||
if current_avail is None:
|
||||
stopped_reason = "refresh_position_failed"
|
||||
break
|
||||
if current_avail <= 0:
|
||||
filled_or_reduced_sheets = close_sheets
|
||||
remaining = 0
|
||||
break
|
||||
remaining = min(remaining, current_avail)
|
||||
book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
|
||||
preview = estimate_close_by_bids(book.get("bids") or [], remaining, ct_mult=ct_mult)
|
||||
levels = preview.get("levels") or []
|
||||
if not levels:
|
||||
stopped_reason = "no_bid_depth"
|
||||
break
|
||||
level = levels[0]
|
||||
level_sheets = int(level.get("sheets") or 0)
|
||||
level_px = float(level.get("px") or 0)
|
||||
if level_sheets <= 0 or level_px <= 0:
|
||||
stopped_reason = "invalid_bid_depth"
|
||||
break
|
||||
before_avail = current_avail
|
||||
order = cfg["place_option_limit_order"](
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
side="sell",
|
||||
sheets=level_sheets,
|
||||
price=level_px,
|
||||
td_mode=td_mode,
|
||||
tick_sz=tick_sz,
|
||||
reduce_only=True,
|
||||
pos_side=pos_side,
|
||||
)
|
||||
if not order.get("ok"):
|
||||
stopped_reason = order.get("msg") or "order_failed"
|
||||
break
|
||||
px = float(order.get("px", level_px))
|
||||
orders.append({"order": order, "px": px, "sheets": level_sheets})
|
||||
submitted_sheets += level_sheets
|
||||
total_received += total_premium(px, level_sheets * ct_mult)
|
||||
time.sleep(0.6)
|
||||
after_avail = _refresh_position_avail(cfg, ex, inst_id)
|
||||
if after_avail is None:
|
||||
stopped_reason = "refresh_position_failed"
|
||||
break
|
||||
reduced = max(0, before_avail - after_avail)
|
||||
if reduced <= 0:
|
||||
stopped_reason = "order_not_filled"
|
||||
break
|
||||
filled_or_reduced_sheets += min(reduced, level_sheets)
|
||||
remaining = max(0, close_sheets - filled_or_reduced_sheets)
|
||||
if not orders:
|
||||
return jsonify({"ok": False, "msg": "暂无可用买盘深度,无法拆分平仓", "stopped_reason": stopped_reason})
|
||||
bid = (total_received / (submitted_sheets * ct_mult)) if submitted_sheets > 0 and ct_mult > 0 else 0
|
||||
prem_recv = round(total_received, 4)
|
||||
fully_submitted = submitted_sheets >= close_sheets and stopped_reason is None
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
row = conn.execute(
|
||||
"SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if row and fully_submitted:
|
||||
paid = float(row["premium_paid"] or 0)
|
||||
pnl = prem_recv - paid
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
bid,
|
||||
prem_recv,
|
||||
pnl,
|
||||
",".join(str((o.get("order", {}).get("data") or {}).get("ordId") or "") for o in orders),
|
||||
int(row["id"]),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
|
||||
invalidate_option_positions_cache()
|
||||
_sync_options_trades(cfg, force=True)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"mode": "depth_split",
|
||||
"orders": orders,
|
||||
"bid": bid,
|
||||
"submitted_sheets": submitted_sheets,
|
||||
"filled_or_reduced_sheets": filled_or_reduced_sheets,
|
||||
"remaining_sheets": max(0, close_sheets - filled_or_reduced_sheets),
|
||||
"premium_received": prem_recv,
|
||||
"stopped_reason": stopped_reason,
|
||||
}
|
||||
)
|
||||
else:
|
||||
close_px = float(bid)
|
||||
order = cfg["place_option_limit_order"](
|
||||
|
||||
@@ -200,4 +200,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=20"></script>
|
||||
<script src="/static/options_panel.js?v=21"></script>
|
||||
|
||||
Reference in New Issue
Block a user