9295dcffe3
Align monitor profit-exit with the same book VWAP so card and auto-exit stay consistent. Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
13 KiB
Python
342 lines
13 KiB
Python
"""永期对冲 · 当前持仓:期权+永续实时卡(组合净利扣开平双边手续费)."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Optional
|
|
|
|
from lib.hedge_plan.hedge_plan_book_lib import fetch_perp_close_vwap
|
|
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
|
estimate_combo_net_pnl,
|
|
fee_rate,
|
|
option_fee_rate,
|
|
opt_type_for_view,
|
|
option_bid_liquidity_ok,
|
|
perp_direction_for_view,
|
|
)
|
|
|
|
_BJ = timezone(timedelta(hours=8))
|
|
|
|
|
|
def _sf(v: Any) -> Optional[float]:
|
|
try:
|
|
if v is None or v == "":
|
|
return None
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _pick_option_row(rows: list[dict[str, Any]] | None, inst_id: str) -> Optional[dict[str, Any]]:
|
|
want = str(inst_id or "").strip()
|
|
if not want or not rows:
|
|
return None
|
|
for r in rows:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
rid = str(r.get("instId") or r.get("inst_id") or "").strip()
|
|
if rid == want:
|
|
return r
|
|
return None
|
|
|
|
|
|
def _option_bid_quote(
|
|
ex: Any, inst_id: str, fallback: Optional[float]
|
|
) -> tuple[Optional[float], Optional[float]]:
|
|
"""返回 (买一价, 买一张数)."""
|
|
try:
|
|
from lib.exchange.okx_options_lib import fetch_option_book_depth
|
|
|
|
book = fetch_option_book_depth(ex, inst_id) or {}
|
|
bids = book.get("bids") or []
|
|
if bids and isinstance(bids[0], dict):
|
|
bid = _sf(bids[0].get("px"))
|
|
sz = _sf(bids[0].get("sz"))
|
|
if bid is not None and bid > 0:
|
|
return bid, sz
|
|
except Exception:
|
|
pass
|
|
return fallback, None
|
|
|
|
|
|
def _parse_opt_exp_ms(inst_id: str) -> Optional[int]:
|
|
"""BTC-USD-261017-84000-P → 到期日 16:00 北京时间."""
|
|
parts = str(inst_id or "").upper().split("-")
|
|
if len(parts) < 4:
|
|
return None
|
|
raw = parts[2]
|
|
if len(raw) != 6 or not raw.isdigit():
|
|
return None
|
|
try:
|
|
yy, mm, dd = int(raw[0:2]), int(raw[2:4]), int(raw[4:6])
|
|
year = 2000 + yy
|
|
dt = datetime(year, mm, dd, 16, 0, 0, tzinfo=_BJ)
|
|
return int(dt.timestamp() * 1000)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _parse_opened_ms(opened_at: Any) -> Optional[int]:
|
|
s = str(opened_at or "").strip()
|
|
if not s:
|
|
return None
|
|
try:
|
|
# "2026-10-14 16:22:21" 按北京时间
|
|
dt = datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=_BJ)
|
|
return int(dt.timestamp() * 1000)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _pick_leg(
|
|
legs: list[dict[str, Any]], roles: tuple[str, ...], *, prefer_open: bool = True
|
|
) -> Optional[dict[str, Any]]:
|
|
matched = [x for x in legs if str(x.get("leg_role") or "") in roles]
|
|
if not matched:
|
|
return None
|
|
if prefer_open:
|
|
open_ones = [x for x in matched if str(x.get("status") or "").lower() == "open"]
|
|
if open_ones:
|
|
return open_ones[0]
|
|
return matched[0]
|
|
|
|
|
|
def _has_open_leg(legs: list[dict[str, Any]]) -> bool:
|
|
return any(str(x.get("status") or "").lower() == "open" for x in legs)
|
|
|
|
|
|
def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any]]:
|
|
"""当前持仓:未平完的永期仓(含计划已结束但腿仍 open)."""
|
|
from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans_for_live_positions
|
|
|
|
rows = list_plans_for_live_positions(conn, plan_type="perp_options", limit=40)
|
|
if not rows:
|
|
return []
|
|
plans = attach_legs_to_plans(conn, rows)
|
|
|
|
fetch_opt = cfg.get("fetch_option_positions")
|
|
ex_opt = None
|
|
try:
|
|
get_ex = cfg.get("exchange_options")
|
|
ex_opt = get_ex() if callable(get_ex) else get_ex
|
|
except Exception:
|
|
ex_opt = None
|
|
opt_rows: list[dict[str, Any]] = []
|
|
if callable(fetch_opt) and ex_opt is not None:
|
|
try:
|
|
opt_rows = fetch_opt(ex_opt) or []
|
|
except Exception:
|
|
opt_rows = []
|
|
|
|
get_perp_metrics = cfg.get("get_live_position_exchange_metrics")
|
|
get_cs = cfg.get("get_contract_size")
|
|
fetch_idx = cfg.get("fetch_index_price")
|
|
ex_perp = None
|
|
try:
|
|
get_pex = cfg.get("exchange")
|
|
ex_perp = get_pex() if callable(get_pex) else get_pex
|
|
except Exception:
|
|
ex_perp = None
|
|
fr = fee_rate()
|
|
fr_opt = option_fee_rate()
|
|
now_ms = int(datetime.now(tz=_BJ).timestamp() * 1000)
|
|
out: list[dict[str, Any]] = []
|
|
|
|
for plan in plans:
|
|
legs = plan.get("legs") or []
|
|
plan_st = str(plan.get("status") or "").lower()
|
|
# 已结束计划:仅当仍有未平腿才进当前持仓
|
|
if plan_st == "closed" and not _has_open_leg(legs):
|
|
continue
|
|
opt_leg = _pick_leg(legs, ("option_hedge", "option_a"))
|
|
perp_leg = _pick_leg(legs, ("perp",))
|
|
if not opt_leg and not perp_leg:
|
|
continue
|
|
# 已结束:只展示仍 open 的腿;若该腿已平则置空
|
|
if plan_st == "closed":
|
|
if opt_leg and str(opt_leg.get("status") or "").lower() != "open":
|
|
opt_leg = None
|
|
if perp_leg and str(perp_leg.get("status") or "").lower() != "open":
|
|
perp_leg = None
|
|
if not opt_leg and not perp_leg:
|
|
continue
|
|
|
|
view = str(plan.get("direction") or "long").lower()
|
|
uly = str(plan.get("underlying") or "ETH").upper()
|
|
inst_id = str((opt_leg or {}).get("inst_id") or "")
|
|
opt_raw = _pick_option_row(opt_rows, inst_id) if inst_id else None
|
|
|
|
sheets = _sf((opt_leg or {}).get("size")) or _sf((opt_raw or {}).get("pos")) or 0.0
|
|
ct = _sf((opt_leg or {}).get("ct_mult")) or 0.01
|
|
avg_open = _sf((opt_leg or {}).get("avg_open")) or _sf((opt_raw or {}).get("avgPx")) or 0.0
|
|
mark_opt = _sf((opt_raw or {}).get("markPx")) or _sf((opt_raw or {}).get("last")) or avg_open
|
|
bid, bid_sz = _option_bid_quote(ex_opt, inst_id, mark_opt) if inst_id else (mark_opt, None)
|
|
strike = _sf((opt_leg or {}).get("strike")) or 0.0
|
|
opt_type = str((opt_leg or {}).get("opt_type") or opt_type_for_view(view)).upper()
|
|
# 币本位杠杆≈1÷卖一/标记
|
|
lev_ref = _sf(avg_open) or _sf(mark_opt) or 0.0
|
|
opt_leverage = round(1.0 / lev_ref, 1) if lev_ref and lev_ref > 0 else None
|
|
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=float(sheets or 0))
|
|
|
|
sym = str((perp_leg or {}).get("symbol") or plan.get("exchange_symbol") or "")
|
|
perp_dir = str(
|
|
(perp_leg or {}).get("side") or plan.get("perp_direction") or perp_direction_for_view(view)
|
|
).lower()
|
|
contracts = _sf((perp_leg or {}).get("size")) or _sf(plan.get("perp_size")) or 0.0
|
|
perp_entry = _sf((perp_leg or {}).get("avg_open")) or _sf(plan.get("entry_mark")) or 0.0
|
|
perp_lev = _sf(plan.get("leverage")) or 0.0
|
|
cs = 0.01
|
|
if callable(get_cs) and sym:
|
|
try:
|
|
cs = float(get_cs(sym) or 0.01)
|
|
except Exception:
|
|
cs = 0.01
|
|
|
|
perp_mark = None
|
|
perp_upl = None
|
|
live_contracts = None
|
|
margin = None
|
|
if callable(get_perp_metrics) and sym:
|
|
try:
|
|
m = get_perp_metrics(sym, perp_dir, order_leverage=plan.get("leverage"))
|
|
if isinstance(m, dict):
|
|
perp_mark = _sf(m.get("mark_price") or m.get("mark"))
|
|
perp_upl = _sf(m.get("unrealized_pnl"))
|
|
live_contracts = _sf(m.get("contracts") or m.get("size"))
|
|
margin = _sf(m.get("initial_margin") or m.get("margin"))
|
|
except Exception:
|
|
pass
|
|
if perp_mark is None or perp_mark <= 0:
|
|
perp_mark = perp_entry
|
|
if live_contracts and live_contracts > 0:
|
|
contracts = live_contracts
|
|
if (margin is None or margin <= 0) and contracts and perp_mark and perp_lev and perp_lev > 0:
|
|
margin = round(float(contracts) * float(cs) * float(perp_mark) / float(perp_lev), 2)
|
|
|
|
# 永续净利:按平仓方向扫盘口 VWAP(方案 A);标记价仅参考
|
|
book = fetch_perp_close_vwap(
|
|
ex_perp,
|
|
symbol=sym,
|
|
position_side=perp_dir,
|
|
contracts=float(contracts or 0),
|
|
fallback_px=perp_mark or perp_entry,
|
|
)
|
|
perp_vwap = _sf(book.get("vwap")) or perp_mark or perp_entry
|
|
perp_liq_ok = bool(book.get("liq_ok")) if book.get("ok") else False
|
|
perp_liq_msg = str(book.get("msg") or "")
|
|
perp_top = _sf(book.get("top_px"))
|
|
perp_top_sz = _sf(book.get("top_sz"))
|
|
book_side = str(book.get("book_side") or "")
|
|
|
|
idx = None
|
|
if callable(fetch_idx):
|
|
try:
|
|
idx = _sf(fetch_idx(uly))
|
|
except Exception:
|
|
idx = None
|
|
if idx is None or idx <= 0:
|
|
idx = perp_vwap or perp_mark or strike or 0.0
|
|
|
|
net = estimate_combo_net_pnl(
|
|
view_side=view,
|
|
strike=float(strike or 0),
|
|
index_px=float(idx or 0),
|
|
ask_open=float(avg_open or 0),
|
|
bid=float(bid or 0),
|
|
sheets=float(sheets or 0),
|
|
ct_mult=float(ct or 0.01),
|
|
perp_direction=perp_dir,
|
|
perp_entry=float(perp_entry or 0),
|
|
perp_mark=float(perp_vwap or 0),
|
|
contracts=float(contracts or 0),
|
|
contract_size=float(cs or 0.01),
|
|
fee=fr,
|
|
option_fee=fr_opt,
|
|
)
|
|
opt_net_coin = float(net.get("opt_net") or 0)
|
|
# 币→U:用当前指数(与买币参考价同口径)
|
|
opt_net_usdt = round(opt_net_coin * float(idx or 0), 4) if idx else None
|
|
perp_net = float(net.get("perp_net") or 0)
|
|
combo_usdt = round((opt_net_usdt or 0) + perp_net, 4) if opt_net_usdt is not None else round(perp_net, 4)
|
|
prem_coin = float(net.get("premium") or (float(avg_open or 0) * float(sheets or 0) * float(ct or 0.01)))
|
|
prem_usdt = round(prem_coin * float(idx or 0), 2) if idx else None
|
|
|
|
opened_at = plan.get("opened_at")
|
|
opened_ms = _parse_opened_ms(opened_at)
|
|
# 两腿开仓时间统一取计划 opened_at
|
|
exp_ms = _parse_opt_exp_ms(inst_id)
|
|
hold_sec = int((now_ms - opened_ms) / 1000) if opened_ms else None
|
|
remain_sec = int((exp_ms - now_ms) / 1000) if exp_ms else None
|
|
|
|
exit_mode = str(plan.get("exit_mode") or "").strip().lower()
|
|
if exit_mode not in ("profit", "target"):
|
|
exit_mode = "profit" if _sf(plan.get("profit_target_usdt")) else "target"
|
|
|
|
out.append(
|
|
{
|
|
"plan_id": plan.get("id"),
|
|
"status": plan.get("status"),
|
|
"unflat": plan_st == "closed",
|
|
"underlying": uly,
|
|
"direction": view,
|
|
"opened_at": opened_at,
|
|
"opened_ms": opened_ms,
|
|
"exp_ms": exp_ms,
|
|
"hold_sec": hold_sec,
|
|
"remain_sec": remain_sec,
|
|
"index_px": idx,
|
|
"fee_rate": fr,
|
|
"option_fee_rate": fr_opt,
|
|
"exit": {
|
|
"mode": exit_mode,
|
|
"profit_target_usdt": _sf(plan.get("profit_target_usdt")),
|
|
"option_target_points": _sf(plan.get("option_target_points")),
|
|
"perp_target_points": _sf(plan.get("perp_target_points")),
|
|
},
|
|
"option": {
|
|
"inst_id": inst_id or "—",
|
|
"opt_type": opt_type,
|
|
"strike": strike,
|
|
"sheets": sheets,
|
|
"ct_mult": ct,
|
|
"avg_open": avg_open,
|
|
"mark_px": mark_opt,
|
|
"bid": bid,
|
|
"bid_sz": bid_sz,
|
|
"leverage": opt_leverage,
|
|
"liq_ok": bool(liq_ok),
|
|
"liq_msg": liq_msg or "",
|
|
"premium_coin": round(prem_coin, 4),
|
|
"premium_usdt": prem_usdt,
|
|
"net_coin": opt_net_coin,
|
|
"net_usdt": opt_net_usdt,
|
|
"live": bool(opt_raw),
|
|
},
|
|
"perp": {
|
|
"symbol": sym or "—",
|
|
"side": perp_dir,
|
|
"contracts": contracts,
|
|
"contract_size": cs,
|
|
"avg_open": perp_entry,
|
|
"mark_px": perp_mark,
|
|
"vwap": perp_vwap,
|
|
"book_side": book_side,
|
|
"top_px": perp_top,
|
|
"top_sz": perp_top_sz,
|
|
"liq_ok": perp_liq_ok,
|
|
"liq_msg": perp_liq_msg,
|
|
"leverage": perp_lev or None,
|
|
"margin": margin,
|
|
"upl": perp_upl,
|
|
"net_usdt": perp_net,
|
|
"live": bool(book.get("ok")) or perp_upl is not None,
|
|
},
|
|
"combo": {
|
|
"net_usdt": combo_usdt,
|
|
"opt_net_usdt": opt_net_usdt,
|
|
"perp_net_usdt": perp_net,
|
|
"note": "扣开平双边手续费;永续按盘口均价估",
|
|
},
|
|
}
|
|
)
|
|
return out
|