diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 02582fd..8298cf7 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -862,7 +862,8 @@ } function setHpPosTab(tab) { - const next = tab === "pending" || tab === "history" || tab === "stats" ? tab : "live"; + const allowed = { live: 1, plans: 1, pending: 1, history: 1, stats: 1 }; + const next = allowed[tab] ? tab : "live"; state.posTab = next; document.querySelectorAll(".hp-pos-tab").forEach(function (btn) { const on = btn.getAttribute("data-hp-pos-tab") === next; @@ -875,17 +876,146 @@ if (on) pane.removeAttribute("hidden"); else pane.setAttribute("hidden", ""); }); - if (next === "live" || next === "pending") void loadActivePlans(); + if (next === "live") void loadLivePositions(); + else if (next === "plans" || next === "pending") void loadActivePlans(); else if (next === "history") void loadHistory(); else if (next === "stats") void loadStats(); } function refreshHpPosPanel() { + void loadLivePositions(); void loadActivePlans(); void loadHistory(); if (state.posTab === "stats") void loadStats(); } + function pnlClsNum(v) { + if (v == null || Number.isNaN(Number(v))) return ""; + const n = Number(v); + if (n > 0) return "hp-pnl-pos"; + if (n < 0) return "hp-pnl-neg"; + return ""; + } + + function fmtSigned(v, d) { + if (v == null || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const s = fmt(n, d == null ? 2 : d); + return n > 0 ? "+" + s : s; + } + + function renderLivePosCard(card) { + const opt = card.option || {}; + const perp = card.perp || {}; + const combo = card.combo || {}; + const optType = String(opt.opt_type || "").toUpperCase() === "P" ? "Put" : "Call"; + const perpSide = String(perp.side || "").toLowerCase() === "short" ? "空" : "多"; + const comboCls = pnlClsNum(combo.net_usdt); + return ( + '
' + + '
' + + "#" + + (card.plan_id || "—") + + " · " + + (card.underlying || "") + + '' + + (card.opened_at || "") + + "
" + + '
' + + '
' + + '
期权 · ' + + optType + + "
" + + "" + + (opt.inst_id || "—") + + "" + + '
' + + "张数 " + + fmt(opt.sheets, 0) + + "" + + "开仓 " + + fmt(opt.avg_open, 4) + + "" + + "标记 " + + fmt(opt.mark_px, 4) + + "" + + '净利 ' + + fmtSigned(opt.net_usdt, 2) + + " U" + + "
" + + '
' + + '
永续 · ' + + perpSide + + "
" + + "" + + (perp.symbol || "—") + + "" + + '
' + + "张数 " + + fmt(perp.contracts, 4) + + "" + + "开仓 " + + fmt(perp.avg_open, 2) + + "" + + "标记 " + + fmt(perp.mark_px, 2) + + "" + + '净利 ' + + fmtSigned(perp.net_usdt, 2) + + " U" + + "
" + + "
" + + '
' + + '组合净盈亏(扣开平手续费)' + + '' + + fmtSigned(combo.net_usdt, 2) + + " U" + + "
" + + '
' + + ' ' + + '' + + "
" + ); + } + + async function loadLivePositions() { + const box = $("hp-live-pos-list"); + if (!box) return; + try { + const d = await apiJson("/api/hedge-plan/live-positions"); + const cards = d.cards || []; + if (!cards.length) { + box.innerHTML = '

暂无期权/永续对冲持仓

'; + return; + } + box.innerHTML = cards.map(renderLivePosCard).join(""); + box.querySelectorAll(".hp-btn-detail").forEach(function (btn) { + btn.addEventListener("click", function () { + void showPlanDetail(Number(btn.getAttribute("data-id"))); + }); + }); + box.querySelectorAll(".hp-btn-end").forEach(function (btn) { + btn.addEventListener("click", function () { + void endActivePlan(Number(btn.getAttribute("data-id"))); + }); + }); + } catch (e) { + box.innerHTML = '

' + (e.message || e) + "

"; + } + } + /** 手动选约:不自动代选;仅在已选失效(换 Call/Put 或筛掉)时清空. */ function autoMatchPoOption(force) { if (!isOptionPrimary()) return; @@ -3379,8 +3509,8 @@ if (state.tab === "perp_options" && isOptionPrimary()) void refreshPoStrategyStatus(); }, 15000); setInterval(function () { - if (state.tab === "perp_options" && (state.posTab === "live" || state.posTab === "pending")) { - void loadActivePlans(); - } - }, 20000); + if (state.tab !== "perp_options") return; + if (state.posTab === "live") void loadLivePositions(); + else if (state.posTab === "plans" || state.posTab === "pending") void loadActivePlans(); + }, 8000); })(); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 58414f7..6f0c60f 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -4118,6 +4118,83 @@ html[data-theme="light"] .opt-be-dist-down { .hedge-plan-page-wrap .hp-po-pos-card .options-pos-pane { overflow: auto; } +.hedge-plan-page-wrap .hp-live-pos-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.hedge-plan-page-wrap .hp-live-card { + padding: 12px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} +.hedge-plan-page-wrap .hp-live-card-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: baseline; + margin-bottom: 10px; + font-size: 0.88rem; +} +.hedge-plan-page-wrap .hp-live-legs { + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} +@media (min-width: 720px) { + .hedge-plan-page-wrap .hp-live-legs { + grid-template-columns: 1fr 1fr; + } +} +.hedge-plan-page-wrap .hp-live-leg { + padding: 10px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255, 255, 255, 0.05); +} +.hedge-plan-page-wrap .hp-live-leg-title { + font-size: 0.78rem; + font-weight: 650; + color: #9ec0ff; + margin-bottom: 4px; +} +.hedge-plan-page-wrap .hp-live-leg code { + display: block; + font-size: 0.78rem; + word-break: break-all; + margin-bottom: 8px; + color: #c5d0ee; +} +.hedge-plan-page-wrap .hp-live-leg-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 10px; + font-size: 0.76rem; + color: #9aa4b2; +} +.hedge-plan-page-wrap .hp-live-leg-grid strong { + color: #e8ecf4; + font-weight: 600; +} +.hedge-plan-page-wrap .hp-live-combo { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.9rem; +} +.hedge-plan-page-wrap .hp-live-combo strong { + font-size: 1.05rem; +} +.hedge-plan-page-wrap .hp-live-actions { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} .hedge-plan-page-wrap .hp-po-order-dialog .hp-po-section-title { margin: 0 0 6px; font-size: 0.85rem; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 1d1e1e7..4ed8af5 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -1931,7 +1931,6 @@ '
' + '
' + (p.inst_id || "") + '' + '' + optTypeLabel(p.opt_type) + "" + - sourceBadgeHtml(p) + "
" + '
' + '' + @@ -2192,7 +2191,6 @@ '' + '' + inst + "" + '' + optTypeLabel(p.opt_type) + "" + - sourceBadgeHtml(p) + "" + '行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张" + "" + diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 3e45b4a..fb1b5d1 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -249,7 +249,6 @@ '
' + '
' + (p.inst_id || "") + "" + '' + optTypeLabel(p.opt_type) + "" + - sourceBadgeHtml(p) + "
" + headActions + "
" + diff --git a/lib/hedge_plan/hedge_plan_live_positions_lib.py b/lib/hedge_plan/hedge_plan_live_positions_lib.py new file mode 100644 index 0000000..99113e7 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_live_positions_lib.py @@ -0,0 +1,203 @@ +"""永期对冲 · 当前持仓:期权+永续实时卡(组合净利扣开平双边手续费).""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.hedge_plan.hedge_plan_option_primary_lib import ( + estimate_combo_net_pnl, + fee_rate, + opt_type_for_view, + perp_direction_for_view, +) + + +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(ex: Any, inst_id: str, fallback: 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") or bids[0].get("price")) + if bid is not None and bid > 0: + return bid + except Exception: + pass + return fallback + + +def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any]]: + """进行中的永期计划 → 期权/永续腿实时卡 + 扣费组合净利.""" + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + rows: list[dict[str, Any]] = [] + for status in ("opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=40)) + rows = [r for r in rows if str(r.get("plan_type") or "") == "perp_options"] + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + 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") + fr = fee_rate() + out: list[dict[str, Any]] = [] + + for plan in plans: + legs = plan.get("legs") or [] + opt_leg = next((x for x in legs if str(x.get("leg_role") or "") in ("option_hedge", "option_a")), None) + perp_leg = next((x for x in legs if str(x.get("leg_role") or "") == "perp"), 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 = _option_bid(ex_opt, inst_id, mark_opt) if inst_id else mark_opt + 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() + + 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 + 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 + 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")) + 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 + + 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_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_mark or 0), + contracts=float(contracts or 0), + contract_size=float(cs or 0.01), + fee=fr, + ) + # 币本位期权净利(币) → USDT;永续净利已是 U + opt_net_coin = float(net.get("opt_net") or 0) + 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) + + out.append( + { + "plan_id": plan.get("id"), + "status": plan.get("status"), + "underlying": uly, + "direction": view, + "opened_at": plan.get("opened_at"), + "index_px": idx, + "fee_rate": fr, + "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, + "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, + "upl": perp_upl, + "net_usdt": perp_net, + "live": perp_upl is not None or (perp_mark is not None and perp_mark > 0), + }, + "combo": { + "net_usdt": combo_usdt, + "opt_net_usdt": opt_net_usdt, + "perp_net_usdt": perp_net, + "note": "扣开平双边手续费后估", + }, + } + ) + return out diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 3977a90..6b8ca29 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -50,6 +50,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: from lib.exchange.okx_options_lib import ( build_option_chain, fetch_index_price, + fetch_option_positions, options_header_balances, place_option_limit_order, quote_option_contract, @@ -76,6 +77,10 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "place_exchange_order": getattr(app_module, "place_exchange_order", None), "close_exchange_order": getattr(app_module, "close_exchange_order", None), "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), + "get_live_position_exchange_metrics": getattr( + app_module, "get_live_position_exchange_metrics", None + ), + "fetch_option_positions": fetch_option_positions, "amount_to_precision": _amount_to_precision, "build_option_chain": build_option_chain, "options_header_balances": options_header_balances, @@ -1117,6 +1122,22 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: conn.close() return jsonify({"ok": True, "plans": plans}) + @app.route("/api/hedge-plan/live-positions") + @lr + def api_hedge_live_positions(): + """当前持仓:期权+永续实时卡,组合净利已扣开平双边手续费.""" + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + from lib.hedge_plan.hedge_plan_live_positions_lib import build_live_hedge_cards + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + cards = build_live_hedge_cards(cfg, conn) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "cards": cards}) + @app.route("/api/hedge-plan/stats") @lr def api_hedge_stats(): diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 7e47496..ca77389 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -26,7 +26,8 @@
-