diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 8b97866..93749bc 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3600,62 +3600,15 @@ html[data-theme="light"] .options-estimate-row { .options-page-wrap .opt-pos-cell--depth { grid-column: span 2; } -.options-page-wrap .opt-bid-depth { - display: flex; - flex-wrap: wrap; - gap: 5px; - align-items: center; -} -.options-page-wrap .opt-bid-level { - display: inline-grid; - grid-template-columns: auto auto; - align-items: center; - gap: 5px; - min-height: 24px; - padding: 3px 9px; - border: 1px solid rgba(82, 101, 143, 0.55); - border-radius: 999px; - background: rgba(18, 24, 37, 0.72); +.options-page-wrap .opt-bid-plain { color: #dbe6ff; - line-height: 1; font-variant-numeric: tabular-nums; + line-height: 1.35; + white-space: normal; } -.options-page-wrap .opt-bid-level--best { - border-color: rgba(93, 143, 255, 0.9); - background: linear-gradient(135deg, rgba(47, 86, 170, 0.72), rgba(28, 39, 68, 0.84)); - box-shadow: inset 0 0 0 1px rgba(122, 164, 255, 0.1), 0 6px 14px rgba(24, 46, 92, 0.24); -} -.options-page-wrap .opt-bid-rank { - color: #91a4cc; - font-size: 0.64rem; - font-weight: 600; -} -.options-page-wrap .opt-bid-level--best .opt-bid-rank { - color: #c8d8ff; -} -.options-page-wrap .opt-bid-price { - color: #ffffff; +.options-page-wrap .opt-close-value { font-weight: 700; -} -.options-page-wrap .opt-close-preview { - display: flex; - flex-direction: column; - align-items: flex-start; -} -.options-page-wrap .opt-close-main { - display: inline-flex; - align-items: baseline; - gap: 4px; - color: #f2f6ff; - font-size: 0.9rem; - font-weight: 800; font-variant-numeric: tabular-nums; - line-height: 1.05; -} -.options-page-wrap .opt-close-main span { - color: #9aa8c2; - font-size: 0.62rem; - font-weight: 600; } .options-page-wrap .opt-close-rule { margin-top: 8px; @@ -3774,11 +3727,6 @@ html[data-theme="light"] .options-estimate-row { .opt-history-table td:nth-child(6) { width: 20%; } -.opt-history-table th:nth-child(7), -.opt-history-table td:nth-child(7) { - width: 8%; - text-align: center; -} .opt-hist-time { font-size: 0.64rem; white-space: nowrap; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index d148f11..d69c532 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -30,6 +30,24 @@ return Number(v).toFixed(d == null ? 2 : d); } + function fmtDisplay(v, fallback) { + if (v !== null && v !== undefined && String(v).trim() !== "") return String(v); + if (fallback !== undefined) return fmtDisplay(fallback); + return "—"; + } + + function fmtOptionPx(v, tickSz) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const tick = Number(tickSz); + if (!tickSz || Number.isNaN(tick) || tick <= 0) return String(n); + let decimals = 0; + if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); + else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; + let s = n.toFixed(decimals).replace(/\.?0+$/, ""); + return s || "0"; + } + async function apiJson(url, opts) { const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); return r.json(); @@ -185,28 +203,32 @@ return price + "/" + size; } - function fmtCloseLevels(preview) { + function fmtCloseLevels(preview, tickSz) { const levels = ((preview && preview.levels) || []).slice(0, 5); if (!levels.length) return "—"; - return '
' + levels.map(function (x, idx) { + return levels.map(function (x, idx) { const levelNo = x.level != null ? x.level : idx + 1; - const levelCls = idx === 0 ? " opt-bid-level--best" : ""; - return ( - '' + - '买' + levelNo + "" + - '' + fmt(x.px, 4) + "" + - "" - ); - }).join("") + "
"; + const pxTxt = fmtOptionPx(x.px, tickSz); + return "买" + levelNo + " " + pxTxt; + }).join(" · "); } - function fmtClosePreview(preview) { + function fmtUsdc(v) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(4).replace(/\.?0+$/, "") || "0"; + } + + function fmtClosePreview(preview, premiumPaid) { if (!preview || preview.total_received == null) return "—"; - return ( - '
' + - '
' + fmt(preview.total_received, 4) + 'USDC
' + - "
" - ); + const recvTxt = fmtUsdc(preview.total_received); + let cls = ""; + const prem = Number(premiumPaid); + const recv = Number(preview.total_received); + if (!Number.isNaN(prem) && !Number.isNaN(recv)) { + if (recv > prem) cls = " pos-pnl-profit"; + else if (recv < prem) cls = " pos-pnl-loss"; + } + return '' + recvTxt + " USDC"; } function fmtClosePreviewText(preview) { @@ -502,6 +524,10 @@ const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; const closePreview = p.close_preview || {}; const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; + const tickSz = p.tick_sz; + const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmt(p.premium_paid, 4).replace(/\.?0+$/, "") : null); + const avgTxt = fmtDisplay(p.avg_px_fmt, fmtOptionPx(p.avg_px, tickSz)); + const markTxt = fmtDisplay(p.mark_px_fmt, fmtOptionPx(p.mark_px, tickSz)); return ( '
' + '
' + (p.inst_id || "") + '' + @@ -517,17 +543,17 @@ : "") + "
" + '
' + - '
权利金' + fmt(p.premium_paid, 4) + " USDC
" + - '
开仓均价' + fmt(p.avg_px, 4) + "
" + - '
标记价' + fmt(p.mark_px, 4) + "
" + + '
权利金' + premTxt + " USDC
" + + '
开仓均价' + avgTxt + "
" + + '
标记价' + markTxt + "
" + '
指数价' + fmt(p.idx_px, 0) + "
" + - '
买盘深度' + fmtCloseLevels(closePreview) + "
" + - '
按买盘收回' + fmtClosePreview(closePreview) + "
" + '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
浮盈亏' + fmt(p.upl, 2) + "
" + '
收益率' + (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "
" + + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + + '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid) + "
" + "
" ); } @@ -873,25 +899,10 @@ } } - async function deleteHistoryRow(id, status) { - const warn = status === "open" - ? "该记录仍为持仓中,仅删除本地记录,不影响交易所持仓.确认删除?" - : "确认删除该条期权历史记录?"; - if (!confirm(warn)) return; - const r = await apiJson("/api/options/history/" + encodeURIComponent(id), { method: "DELETE" }); - if (!r.ok) { - alert(r.msg || "删除失败"); - return; - } - refreshAllPositions(); - } - function optHistoryStatus(h) { + if (h.status_label) return h.status_label; + if (h.status === "open") return "持仓中"; if (h.status !== "closed") return "持仓中"; - if ((h.signal_note || "").indexOf("到期结算") >= 0) return "到期"; - if (h.premium_received === 0 && h.realized_pnl != null && h.realized_pnl < 0 && !h.close_ord_id) { - return "到期"; - } return "已平"; } @@ -899,7 +910,7 @@ const s = optHistoryStatus(h); let cls = "opt-hist-status"; if (s === "已平") cls += " opt-hist-status--closed"; - else if (s === "到期") cls += " opt-hist-status--expired"; + else if (s === "到期" || s === "强平") cls += " opt-hist-status--expired"; else cls += " opt-hist-status--open"; return '' + s + ""; } @@ -910,13 +921,14 @@ tbody.innerHTML = ""; const list = (d.ok && d.history) || []; if (!list.length) { - tbody.innerHTML = '暂无历史记录'; + tbody.innerHTML = '暂无历史记录'; return; } list.forEach(function (h) { const tr = document.createElement("tr"); - const premTxt = h.premium_paid != null ? fmt(h.premium_paid, 2) : "—"; - const pnl = h.realized_pnl; + const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmt(h.premium_paid, 2) : null); + const isOpen = h.status === "open"; + const pnl = isOpen ? null : h.realized_pnl; const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); @@ -926,15 +938,9 @@ "" + premTxt + "" + "" + optHistoryStatusHtml(h) + "" + '' + pnlTxt + "" + - "" + timeTxt + "" + - ''; + '' + timeTxt + ""; tbody.appendChild(tr); }); - tbody.querySelectorAll(".opt-history-del").forEach(function (btn) { - btn.addEventListener("click", function () { - deleteHistoryRow(btn.getAttribute("data-id"), btn.getAttribute("data-status")); - }); - }); } function refreshAllPositions() { diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 6cc01b0..d615ee3 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -116,6 +116,53 @@ def format_option_px(px: float, tick_sz: Any) -> str: return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0" +def format_usdc_amount(v: float | None) -> str | None: + """USDC 金额展示(与交易所持仓/历史一致,最多 4 位小数).""" + if v is None: + return None + return f"{float(v):.4f}".rstrip("0").rstrip(".") or "0" + + +def _ms_to_iso(ms: Any) -> str | None: + val = _safe_float(ms) + if val is None or val <= 0: + return None + try: + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone() + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError): + return None + + +def option_instrument_meta_cached( + ex: ccxt.okx, + inst_id: str, + cache: dict[str, dict[str, Any] | None] | None = None, +) -> dict[str, Any] | None: + inst_id = (inst_id or "").strip() + if not inst_id: + return None + if cache is not None and inst_id in cache: + return cache[inst_id] + meta = fetch_option_instrument_meta(ex, inst_id) + if cache is not None: + cache[inst_id] = meta + return meta + + +def tick_sz_and_ct_mult( + ex: ccxt.okx, + inst_id: str, + cache: dict[str, dict[str, Any] | None] | None = None, +) -> tuple[Any, float]: + meta = option_instrument_meta_cached(ex, inst_id, cache) + tick_sz = meta.get("tickSz") if meta else None + ct_mult = _safe_float(meta.get("ctMult")) if meta else None + return tick_sz, ct_mult or 0.01 + + def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None: o = (opt_type or "").upper() if o == "C" and index_px > strike: @@ -732,6 +779,144 @@ def fetch_option_position_history( return [] +def fetch_all_option_positions_history( + ex: ccxt.okx, + *, + limit: int = 200, +) -> list[dict[str, Any]]: + """拉取 OKX 期权全部历史仓位(分页,按平仓时间倒序).""" + cap = max(1, min(int(limit), 500)) + out: list[dict[str, Any]] = [] + after: str | None = None + while len(out) < cap: + page_limit = min(100, cap - len(out)) + params: dict[str, Any] = { + "instType": "OPTION", + "limit": str(page_limit), + } + if after is not None: + params["after"] = after + try: + resp = ex.private_get_account_positions_history(params) + except Exception: + break + rows = (resp or {}).get("data") or [] + batch = [r for r in rows if isinstance(r, dict)] + if not batch: + break + out.extend(batch) + if len(batch) < page_limit: + break + utimes = [_safe_float(r.get("uTime")) for r in batch] + utimes = [int(u) for u in utimes if u is not None and u > 0] + if not utimes: + break + oldest = min(utimes) + if after is not None and str(oldest) == after: + break + after = str(oldest) + out.sort(key=lambda r: int(_safe_float(r.get("uTime")) or 0), reverse=True) + return out[:cap] + + +def format_option_history_row( + raw: dict[str, Any], + *, + tick_sz: Any = None, + ct_mult: float = 0.01, +) -> dict[str, Any]: + """标准化 OKX positions-history 单条记录供前端展示.""" + from lib.options.options_pricing_lib import total_premium + + inst_id = str(raw.get("instId") or "").strip() + open_avg = _safe_float(raw.get("openAvgPx")) + close_avg = _safe_float(raw.get("closeAvgPx")) + sheets = _safe_float(raw.get("closeTotalPos")) + if sheets is None or sheets <= 0: + sheets = _safe_float(raw.get("openMaxPos")) + sheets_i = int(abs(sheets or 0)) + eth_amount = round(abs(sheets or 0) * ct_mult, 8) if sheets else 0.0 + premium_paid = ( + round(total_premium(open_avg, eth_amount), 8) + if open_avg is not None and eth_amount > 0 + else None + ) + realized = _safe_float(raw.get("realizedPnl")) + if realized is None: + realized = _safe_float(raw.get("pnl")) + pnl_ratio = _safe_float(raw.get("pnlRatio")) + close_type = str(raw.get("type") or "").strip() + utime = _safe_float(raw.get("uTime")) + ctime = _safe_float(raw.get("cTime")) + opt_type, strike = option_fields_from_inst_id(inst_id) + uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") + if close_type in ("3", "4"): + status_label = "强平" + else: + status_label = "已平" + return { + "source": "exchange", + "pos_id": str(raw.get("posId") or "").strip() or None, + "inst_id": inst_id, + "underlying": uly, + "opt_type": opt_type, + "strike": strike, + "sheets": sheets_i, + "eth_amount": eth_amount, + "open_avg_px": open_avg, + "open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None, + "close_avg_px": close_avg, + "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, + "premium_paid": premium_paid, + "premium_paid_fmt": format_usdc_amount(premium_paid), + "realized_pnl": realized, + "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, + "status": "closed", + "status_label": status_label, + "close_type": close_type, + "created_at": _ms_to_iso(ctime), + "closed_at": _ms_to_iso(utime), + "close_ms": int(utime) if utime is not None else None, + "tick_sz": tick_sz, + "raw": raw, + } + + +def format_live_option_history_row( + row: dict[str, Any], + *, + open_ms: int | None = None, +) -> dict[str, Any]: + """将当前持仓格式化为历史列表中的「持仓中」行.""" + inst_id = str(row.get("inst_id") or "").strip() + return { + "source": "live", + "pos_id": str((row.get("raw") or {}).get("posId") or "").strip() or None, + "inst_id": inst_id, + "underlying": str(row.get("underlying") or inst_id.split("-")[0] or ""), + "opt_type": row.get("opt_type"), + "strike": row.get("strike"), + "sheets": int(abs(_safe_float(row.get("pos")) or 0)), + "eth_amount": row.get("eth_amount"), + "open_avg_px": row.get("avg_px"), + "open_avg_px_fmt": row.get("avg_px_fmt"), + "close_avg_px": None, + "close_avg_px_fmt": None, + "premium_paid": row.get("premium_paid"), + "premium_paid_fmt": row.get("premium_paid_fmt"), + "realized_pnl": row.get("upl"), + "pnl_ratio_pct": row.get("upl_ratio_pct"), + "status": "open", + "status_label": "持仓中", + "close_type": None, + "created_at": _ms_to_iso(open_ms), + "closed_at": None, + "close_ms": open_ms, + "tick_sz": row.get("tick_sz"), + "raw": row.get("raw"), + } + + def resolve_option_close_from_history( hist_rows: list[dict[str, Any]], *, @@ -947,7 +1132,12 @@ def transfer_main_sub_account( return {"ok": False, "msg": str(e)} -def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, Any]: +def format_position_row( + pos: dict[str, Any], + ct_mult: float = 0.01, + *, + tick_sz: Any = None, +) -> dict[str, Any]: from lib.options.options_pricing_lib import ( close_breakeven_idx, expiry_breakeven_px, @@ -971,7 +1161,7 @@ def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, strike = parsed_strike eth_amount = round(abs(sheets) * ct_mult, 8) premium_paid = ( - round(total_premium(avg, eth_amount), 4) if avg is not None and eth_amount > 0 else None + round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None ) delta_pa = _safe_float(pos.get("deltaPA")) expiry_be = expiry_breakeven_px( @@ -996,6 +1186,11 @@ def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, "eth_amount": eth_amount, "avg_px": avg, "mark_px": mark, + "avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None, + "mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None, + "premium_paid_fmt": format_usdc_amount(premium_paid), + "tick_sz": tick_sz, + "ct_mult": ct_mult, "idx_px": idx_px, "premium_paid": premium_paid, "upl": upl, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 8b19b29..b526fa2 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -189,6 +189,27 @@ def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | return _position_avail_sheets(pos) +def _enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.exchange.okx_options_lib import format_position_row, tick_sz_and_ct_mult + + inst_id = str(raw_pos.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) + if premium_override is not None: + row["premium_paid"] = premium_override + from lib.exchange.okx_options_lib import format_usdc_amount + + row["premium_paid_fmt"] = format_usdc_amount(premium_override) + return row + + def _attach_close_preview( cfg: dict[str, Any], ex: Any, @@ -469,24 +490,33 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if raw is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) _sync_options_trades(cfg, raw_positions=raw) - rows = [cfg["format_position_row"](p) for p in raw] + meta_cache: dict[str, dict[str, Any] | None] = {} conn = cfg["get_db"]() try: - for row in rows: - inst = row.get("inst_id") - if not inst: - continue - rec = conn.execute( - """ - SELECT premium_paid FROM options_trades - WHERE inst_id = ? AND status = 'open' - ORDER BY id DESC LIMIT 1 - """, - (inst,), - ).fetchone() - if rec and rec["premium_paid"] is not None: - row["premium_paid"] = round(float(rec["premium_paid"]), 4) + rows = [] + for p in raw: + inst = str(p.get("instId") or "").strip() + premium_override = None + if inst: + rec = conn.execute( + """ + SELECT premium_paid FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst,), + ).fetchone() + if rec and rec["premium_paid"] is not None: + premium_override = float(rec["premium_paid"]) + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + rows.append(row) finally: conn.close() return jsonify({"ok": True, "positions": rows}) @@ -838,24 +868,66 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) - _sync_options_trades(cfg) + from lib.exchange.okx_options_lib import ( + fetch_all_option_positions_history, + format_live_option_history_row, + format_option_history_row, + tick_sz_and_ct_mult, + ) + + meta_cache: dict[str, dict[str, Any] | None] = {} + items: list[dict[str, Any]] = [] + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) conn = cfg["get_db"]() try: - init_options_tables(conn) - rows = conn.execute( - """ - SELECT id, inst_id, underlying, opt_type, strike, sheets, eth_amount, - open_quote, premium_paid, close_quote, premium_received, - realized_pnl, status, signal_note, created_at, closed_at - FROM options_trades - ORDER BY id DESC - LIMIT 200 - """ - ).fetchall() - items = [dict(r) for r in rows] + for p in raw_live: + inst = str(p.get("instId") or "").strip() + premium_override = None + if inst: + rec = conn.execute( + """ + SELECT premium_paid FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst,), + ).fetchone() + if rec and rec["premium_paid"] is not None: + premium_override = float(rec["premium_paid"]) + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + open_ms = None + ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime") + try: + if ctime is not None and str(ctime).strip(): + open_ms = int(float(ctime)) + except (TypeError, ValueError): + open_ms = None + items.append(format_live_option_history_row(row, open_ms=open_ms)) finally: conn.close() - return jsonify({"ok": True, "history": items}) + + hist_raw = fetch_all_option_positions_history(ex, limit=200) + for raw in hist_raw: + inst_id = str(raw.get("instId") or "").strip() + tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) + items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)) + + open_rows = [x for x in items if x.get("status") == "open"] + closed = [x for x in items if x.get("status") != "open"] + closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) + history = open_rows + closed + live_ids = {str(x.get("inst_id") or "") for x in open_rows} + return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) @app.route("/api/options/stats") @lr @@ -867,26 +939,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])}) - @app.route("/api/options/history/", methods=["DELETE"]) + @app.route("/api/options/history/", methods=["DELETE"]) @lr - def api_options_history_delete(trade_id: int): + def api_options_history_delete(trade_id: str): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) - conn = cfg["get_db"]() - try: - init_options_tables(conn) - row = conn.execute( - "SELECT id, status FROM options_trades WHERE id = ?", - (trade_id,), - ).fetchone() - if not row: - return jsonify({"ok": False, "msg": "记录不存在"}) - conn.execute("DELETE FROM options_trades WHERE id = ?", (trade_id,)) - conn.commit() - finally: - conn.close() - return jsonify({"ok": True}) + return jsonify({"ok": False, "msg": "历史仓位来自交易所,不支持本地删除"}) def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 3cd32fc..cb898df 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -198,11 +198,10 @@ 状态 盈亏 时间 - 操作 - 加载中… + 加载中…
@@ -212,4 +211,4 @@ - + diff --git a/tests/test_options_sync.py b/tests/test_options_sync.py index 53c7bb7..a38b300 100644 --- a/tests/test_options_sync.py +++ b/tests/test_options_sync.py @@ -1,11 +1,44 @@ """期权平仓/到期状态同步单测.""" import sqlite3 -from lib.exchange.okx_options_lib import resolve_option_close_from_history +from lib.exchange.okx_options_lib import ( + format_option_history_row, + format_usdc_amount, + resolve_option_close_from_history, +) from lib.options.options_db import init_options_tables from lib.options.options_monitor_lib import sync_open_options_trades +def test_format_usdc_amount(): + assert format_usdc_amount(4.896) == "4.896" + assert format_usdc_amount(4.90) == "4.9" + assert format_usdc_amount(4.0) == "4" + + +def test_format_option_history_row(): + raw = { + "instId": "BTC-USD_UM-260710-62000-P", + "openAvgPx": "380", + "closeAvgPx": "0", + "closeTotalPos": "1", + "openMaxPos": "1", + "realizedPnl": "-3.99", + "pnlRatio": "-1.049", + "type": "2", + "cTime": "1784000000000", + "uTime": "1784088035000", + "posId": "pos-btc", + } + row = format_option_history_row(raw, tick_sz="0.1", ct_mult=0.01) + assert row["inst_id"] == "BTC-USD_UM-260710-62000-P" + assert row["sheets"] == 1 + assert row["realized_pnl"] == -3.99 + assert row["status_label"] == "已平" + assert row["open_avg_px_fmt"] == "380" + assert row["premium_paid_fmt"] == "3.8" + + def test_resolve_option_close_from_history_picks_latest(): rows = [ {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"},