diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 2539613..f193369 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -4091,6 +4091,79 @@ html[data-theme="light"] .options-estimate-row { .options-page-wrap .opt-pos-cell--depth { grid-column: span 2; } +.options-page-wrap .opt-target-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(67, 82, 118, 0.45); +} +.options-page-wrap .opt-target-row-label { + font-size: 0.72rem; + color: #9aa8c7; + min-width: 2.5em; +} +.options-page-wrap .opt-pos-target-input { + width: 110px; + max-width: 36vw; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #3a4660; + background: #0f1420; + color: #e8eefc; + font-size: 0.82rem; +} +.options-page-wrap .opt-target-row .btn-secondary { + padding: 4px 10px; + font-size: 0.75rem; +} +.options-page-wrap .opt-target-row-hint { + font-size: 0.7rem; +} +.opt-target-monitors { + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid rgba(99, 118, 168, 0.45); + border-radius: 10px; + background: rgba(18, 28, 48, 0.75); +} +.opt-target-monitors-head { + font-size: 0.78rem; + font-weight: 600; + color: #c9d6f5; + margin-bottom: 8px; +} +.opt-target-mon-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 6px 0; + border-top: 1px solid rgba(67, 82, 118, 0.35); +} +.opt-target-mon-item:first-child { + border-top: 0; + padding-top: 0; +} +.opt-target-mon-inst { + font-size: 0.72rem; + color: #dbe6ff; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} +.opt-target-mon-rule { + font-size: 0.78rem; + color: #9ad0ff; + font-variant-numeric: tabular-nums; +} +.opt-target-mon-item .btn-secondary { + margin-left: auto; + padding: 2px 8px; + font-size: 0.7rem; +} .options-page-wrap .opt-bid-plain { color: #dbe6ff; font-variant-numeric: tabular-nums; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index bc7efd3..0898c9d 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -788,6 +788,15 @@ } else if (mode === "sheets") { body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); } + const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); + if (tgtRaw !== "") { + const tgt = parseFloat(tgtRaw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("目标位无效"); + return; + } + body.target_index = tgt; + } const d = await apiJson("/api/options/open", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -845,6 +854,22 @@ (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid) + "
" + + "" + + renderTargetDelegateRow(p) + ); + } + + function renderTargetDelegateRow(p) { + const inst = p.inst_id || ""; + const tgt = p.target_index != null && p.target_index !== "" ? String(p.target_index) : ""; + const armed = tgt !== ""; + return ( + '
' + + '委托' + + '' + + '' + + '" + + '' + (armed ? "监控中 · 达价限价平 · 无止损" : "达价限价平 · 无止损 · 到期即止损") + "" + "
" ); } @@ -912,6 +937,28 @@ closePosition(btn.getAttribute("data-inst"), btn); }); }); + container.querySelectorAll(".opt-target-set-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + setPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-target-cancel-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + cancelPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { + inp.addEventListener("click", function (e) { e.stopPropagation(); }); + inp.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + setPositionTarget(inp.getAttribute("data-inst"), null); + } + }); + }); container.querySelectorAll(".opt-pos-bar").forEach(function (bar) { bar.addEventListener("click", function () { const item = bar.closest(".opt-pos-accordion-item"); @@ -926,6 +973,81 @@ }); } + async function setPositionTarget(inst, btn) { + if (!inst) return; + const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') || + document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]'); + const inp = card ? card.querySelector(".opt-pos-target-input") : null; + const raw = inp ? String(inp.value || "").trim() : ""; + const tgt = parseFloat(raw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("请输入有效目标指数价"); + return; + } + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst, target_index: tgt }), + }); + if (!d.ok) { + alert(d.msg || "设定失败"); + return; + } + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + async function cancelPositionTarget(inst, btn) { + if (!inst) return; + if (btn) btn.disabled = true; + try { + const d = await apiJson("/api/options/target/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inst_id: inst }), + }); + if (!d.ok) { + alert(d.msg || "取消失败"); + return; + } + await refreshAllPositions(); + } finally { + if (btn) btn.disabled = false; + } + } + + function paintTargetMonitors(list) { + const box = document.getElementById("opt-target-monitors"); + const host = document.getElementById("opt-target-monitors-list"); + if (!box || !host) return; + const rows = Array.isArray(list) ? list.filter(function (t) { return t && t.inst_id; }) : []; + if (!rows.length) { + box.hidden = true; + host.innerHTML = ""; + return; + } + box.hidden = false; + host.innerHTML = rows.map(function (t) { + const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + return ( + '
' + + '' + (t.inst_id || "") + "" + + '' + side + " " + fmt(t.target_index, 1) + "" + + '' + + "
" + ); + }).join(""); + host.querySelectorAll(".opt-target-mon-cancel").forEach(function (btn) { + btn.addEventListener("click", function () { + cancelPositionTarget(btn.getAttribute("data-inst"), btn); + }); + }); + } + async function closePosition(inst, btn) { const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null; let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview"; @@ -1063,7 +1185,25 @@ const seq = ++positionsRefreshSeq; const d = await apiJson("/api/options/positions"); if (seq !== positionsRefreshSeq) return; - paintPositions(resolvePositionsList(d)); + const list = resolvePositionsList(d); + paintPositions(list); + const fromPos = list + .filter(function (p) { return p && p.target_index != null; }) + .map(function (p) { + return { + id: p.target_monitor_id, + inst_id: p.inst_id, + opt_type: p.opt_type, + target_index: p.target_index, + }; + }); + if (fromPos.length) { + paintTargetMonitors(fromPos); + } else { + const t = await apiJson("/api/options/targets"); + if (seq !== positionsRefreshSeq) return; + paintTargetMonitors((t && t.ok && t.targets) ? t.targets : []); + } } async function refreshStats() { diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 3ff8350..8cc7308 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -113,7 +113,10 @@ (p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + fmtClosePreview(closePreview, p.premium_paid, hub) + "
" + - "" + "" + + (p.target_index != null + ? '
委托目标指数 ' + fmt(p.target_index, 1) + "监控中 · 达价限价平
" + : "") ); } diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 2ec91f3..164fbfa 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -68,3 +68,28 @@ def init_options_tables(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 1d12d79..3c1bfdf 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -30,6 +30,24 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: if raw is None: return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} positions = build_display_option_positions(cfg, ex, raw) + target_monitors: list[dict[str, Any]] = [] + try: + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import list_active_targets, targets_by_inst + + target_monitors = list_active_targets(conn) + tgt_map = targets_by_inst(conn) + for p in positions: + mon = tgt_map.get(str(p.get("inst_id") or "")) + if mon: + p["target_index"] = mon.get("target_index") + p["target_monitor_id"] = mon.get("id") + p["target_monitor"] = mon + finally: + conn.close() + except Exception: + target_monitors = [] upl_total = 0.0 has_upl = False for p in positions: @@ -45,6 +63,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "enabled": True, "positions": positions, "position_count": len(positions), + "target_monitors": target_monitors, "upl_total_usdc": round(upl_total, 4) if has_upl else None, "balances": bal, "funding_usdc": bal.get("funding_usdc"), diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index c11f7f0..e259e40 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -271,6 +271,7 @@ def options_monitor_loop( account_label: str, profit_ratio: float, sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, + target_close_fn: Callable[[str], dict[str, Any]] | None = None, stop_event: Any = None, ) -> None: if not enabled: @@ -290,6 +291,16 @@ def options_monitor_loop( account_label=account_label, ticker_bid_fn=ticker_bid_fn, ) + if target_close_fn is not None: + from lib.options.options_target_lib import run_options_target_closes + + run_options_target_closes( + conn, + positions, + close_fn=target_close_fn, + send_wechat=send_wechat, + account_label=account_label, + ) if sync_trades_fn is not None: sync_trades_fn(conn) conn.commit() diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 99b124d..244bf22 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -404,6 +404,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: inst_id = (data.get("inst_id") or "").strip() mode = (data.get("mode") or "budget_full").strip() signal_note = (data.get("signal_note") or "").strip() + target_index = None + raw_target = data.get("target_index") + if raw_target is not None and str(raw_target).strip() != "": + try: + target_index = float(raw_target) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) q = cfg["quote_option_contract"](ex, inst_id) @@ -459,11 +468,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if not order.get("ok"): return jsonify(order) conn = cfg["get_db"]() + trade_id = None + target_mon = None try: init_options_tables(conn) meta = q.get("meta") or {} u = str(meta.get("uly") or inst_id).split("-")[0] - conn.execute( + opt_type = meta.get("optType") + cur = conn.execute( """ INSERT INTO options_trades (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, @@ -473,7 +485,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ( inst_id, u, - meta.get("optType"), + opt_type, q.get("strike"), str(q.get("exp_time") or ""), sheets, @@ -484,6 +496,19 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: (order.get("data") or {}).get("ordId"), ), ) + trade_id = int(cur.lastrowid) + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + target_mon = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=u, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) conn.commit() finally: conn.close() @@ -491,7 +516,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: invalidate_option_positions_cache() _sync_options_trades(cfg, force=True) - return jsonify({"ok": True, "order": order, "sizing": sizing}) + return jsonify( + { + "ok": True, + "order": order, + "sizing": sizing, + "trade_id": trade_id, + "target_monitor": target_mon, + } + ) @app.route("/api/options/positions") @lr @@ -506,6 +539,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: meta_cache: dict[str, dict[str, Any] | None] = {} conn = cfg["get_db"]() try: + from lib.options.options_target_lib import targets_by_inst + + tgt_map = targets_by_inst(conn) rows = [] for p in raw: inst = str(p.get("instId") or "").strip() @@ -529,11 +565,102 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: premium_override=premium_override, ) _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon rows.append(row) finally: conn.close() return jsonify({"ok": True, "positions": rows}) + @app.route("/api/options/targets") + @lr + def api_options_targets(): + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import list_active_targets + + return jsonify({"ok": True, "targets": list_active_targets(conn)}) + finally: + conn.close() + + @app.route("/api/options/target", methods=["POST"]) + @lr + def api_options_target_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + target_index = float(data.get("target_index")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + pos = _find_position(raw, inst_id) + if not pos: + return jsonify({"ok": False, "msg": "未找到持仓"}) + from lib.options.options_target_lib import upsert_target_monitor + + fmt = cfg["format_position_row"](pos) + conn = cfg["get_db"]() + try: + trade = conn.execute( + """ + SELECT id, sheets, opt_type, underlying FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + trade_id = int(trade["id"]) if trade else None + sheets = int(trade["sheets"]) if trade and trade["sheets"] is not None else int(fmt.get("pos") or 0) + opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") + underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") + out = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=str(underlying) if underlying else None, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/target/cancel", methods=["POST"]) + @lr + def api_options_target_cancel(): + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() or None + monitor_id = data.get("id") + try: + mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "监控 id 无效"}) + if not inst_id and mid is None: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) + from lib.options.options_target_lib import cancel_target_monitor + + conn = cfg["get_db"]() + try: + n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) + conn.commit() + return jsonify({"ok": True, "cancelled": n}) + finally: + conn.close() + @app.route("/api/options/close", methods=["POST"]) @lr def api_options_close(): @@ -677,6 +804,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: invalidate_option_positions_cache() _sync_options_trades(cfg, force=True) + try: + from lib.options.options_target_lib import cancel_target_monitor + + conn2 = cfg["get_db"]() + try: + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + finally: + conn2.close() + except Exception: + pass return jsonify( { "ok": True, @@ -739,6 +877,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: invalidate_option_positions_cache() _sync_options_trades(cfg, force=True) + try: + from lib.options.options_target_lib import cancel_target_monitor + + conn2 = cfg["get_db"]() + try: + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + finally: + conn2.close() + except Exception: + pass return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets}) @app.route("/api/options/convert/quote", methods=["POST"]) @@ -974,6 +1123,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), ) + def _target_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_target_lib import close_option_by_bid_depth + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_depth(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + t = threading.Thread( target=options_monitor_loop, kwargs={ @@ -986,6 +1153,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: "account_label": cfg["account_label"], "profit_ratio": cfg["profit_ratio"], "sync_trades_fn": _sync, + "target_close_fn": _target_close, }, daemon=True, name="options-monitor", diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py new file mode 100644 index 0000000..e53a808 --- /dev/null +++ b/lib/options/options_target_lib.py @@ -0,0 +1,449 @@ +"""期权目标位委托:指数达价后限价平仓(无止损,到期由结算收口).""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any, Callable + +from lib.options.options_db import init_options_tables +from lib.options.options_pricing_lib import estimate_close_by_bids, total_premium + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def ensure_target_tables(conn: sqlite3.Connection) -> None: + init_options_tables(conn) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + + +def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: + """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" + ot = (opt_type or "").strip().upper() + if ot == "P": + return index_px <= target_index + return index_px >= target_index + + +def upsert_target_monitor( + conn: sqlite3.Connection, + *, + inst_id: str, + target_index: float, + underlying: str | None = None, + opt_type: str | None = None, + trade_id: int | None = None, + sheets: int | None = None, +) -> dict[str, Any]: + ensure_target_tables(conn) + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + if target_index is None or float(target_index) <= 0: + return {"ok": False, "msg": "目标位无效"} + target_index = float(target_index) + row = conn.execute( + """ + SELECT id FROM options_target_monitors + WHERE inst_id = ? AND status = 'active' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE options_target_monitors + SET target_index = ?, + underlying = COALESCE(?, underlying), + opt_type = COALESCE(?, opt_type), + trade_id = COALESCE(?, trade_id), + sheets = COALESCE(?, sheets), + message = NULL + WHERE id = ? + """, + (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), + ) + mon_id = int(row["id"]) + else: + cur = conn.execute( + """ + INSERT INTO options_target_monitors + (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + """, + (inst_id, underlying, opt_type, target_index, trade_id, sheets), + ) + mon_id = int(cur.lastrowid) + return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} + + +def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: + ensure_target_tables(conn) + if monitor_id is not None: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE id = ? AND status = 'active' + """, + (int(monitor_id),), + ) + return int(cur.rowcount or 0) + if inst_id: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE inst_id = ? AND status = 'active' + """, + (inst_id.strip(),), + ) + return int(cur.rowcount or 0) + return 0 + + +def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'active' + ORDER BY id DESC + """ + ).fetchall() + out: list[dict[str, Any]] = [] + for r in rows: + out.append( + { + "id": int(r["id"]), + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "target_index": _safe_float(r["target_index"]), + "trade_id": r["trade_id"], + "sheets": r["sheets"], + "status": r["status"], + "message": r["message"], + "created_at": r["created_at"], + } + ) + return out + + +def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + return {str(t["inst_id"]): t for t in list_active_targets(conn) if t.get("inst_id")} + + +def mark_monitor( + conn: sqlite3.Connection, + monitor_id: int, + *, + status: str, + trigger_idx: float | None = None, + close_ord_id: str | None = None, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE options_target_monitors + SET status = ?, + trigger_idx = COALESCE(?, trigger_idx), + close_ord_id = COALESCE(?, close_ord_id), + message = COALESCE(?, message), + triggered_at = CASE WHEN ? IN ('triggered', 'expired') THEN CURRENT_TIMESTAMP ELSE triggered_at END + WHERE id = ? + """, + (status, trigger_idx, close_ord_id, message, status, int(monitor_id)), + ) + + +def cancel_orphans_without_position( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" + ensure_target_tables(conn) + rows = list_active_targets(conn) + n = 0 + for t in rows: + inst = str(t.get("inst_id") or "") + if inst and inst not in live_inst_ids: + mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") + n += 1 + return n + + +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + """按买盘拆分限价卖出(最多5档),供目标位自动平仓复用.""" + from lib.exchange.okx_options_lib import ( + _pos_side_from_position, + invalidate_option_positions_cache, + ) + + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "报价失败"} + tick_sz = q.get("tick_sz") + ct_mult = float(q.get("ct_mult") or 0.01) + raw_positions = cfg["fetch_option_positions"](ex) + if raw_positions is None: + return {"ok": False, "msg": "获取期权持仓失败"} + pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) + if not pos: + return {"ok": False, "msg": "未找到持仓", "already_flat": True} + + def _avail(p: dict[str, Any]) -> int: + avail = _safe_float(p.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(p.get("pos")) or 0) + return max(0, int(avail or 0)) + + avail = _avail(pos) + close_sheets = int(sheets) if sheets else avail + close_sheets = min(close_sheets, avail) + if close_sheets < 1: + return {"ok": False, "msg": "可平张数不足", "already_flat": True} + td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated") + pos_side = _pos_side_from_position(pos) or "net" + + remaining = close_sheets + submitted_sheets = 0 + filled_or_reduced_sheets = 0 + total_received = 0.0 + orders: list[dict[str, Any]] = [] + stopped_reason = None + ord_ids: list[str] = [] + + for _ in range(5): + if remaining <= 0: + break + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + stopped_reason = "refresh_position_failed" + break + cur_pos = next((p for p in raw if str(p.get("instId")) == inst_id), None) + current_avail = _avail(cur_pos) if cur_pos else 0 + 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}) + oid = str((order.get("data") or {}).get("ordId") or "") + if oid: + ord_ids.append(oid) + submitted_sheets += level_sheets + total_received += total_premium(px, level_sheets * ct_mult) + time.sleep(0.6) + invalidate_option_positions_cache() + raw2 = cfg["fetch_option_positions"](ex) + if raw2 is None: + stopped_reason = "refresh_position_failed" + break + after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) + after_avail = _avail(after_pos) if after_pos else 0 + 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 {"ok": False, "msg": "暂无可用买盘深度,无法限价平仓", "stopped_reason": stopped_reason} + + avg_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 + close_ord_id = ",".join(ord_ids) if ord_ids else None + + conn = cfg["get_db"]() + try: + ensure_target_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, + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '目标位平仓' + ELSE signal_note + END + WHERE id = ? + """, + (avg_bid, prem_recv, pnl, close_ord_id, int(row["id"])), + ) + conn.commit() + finally: + conn.close() + + invalidate_option_positions_cache() + return { + "ok": True, + "mode": "depth_split", + "orders": orders, + "bid": avg_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, + "close_ord_id": close_ord_id, + "fully_closed": fully_submitted and remaining == 0, + } + + +def run_options_target_closes( + conn: sqlite3.Connection, + positions: list[dict[str, Any]], + *, + close_fn: Callable[[str], dict[str, Any]], + index_fn: Callable[[dict[str, Any]], float | None] | None = None, + send_wechat: Callable[[str], None] | None = None, + account_label: str = "OKX期权", +) -> int: + """ + 扫描 active 目标委托;指数到位后限价平仓. + 返回触发条数. + """ + ensure_target_tables(conn) + pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} + live_ids = {k for k in pos_by_inst if k} + cancel_orphans_without_position(conn, live_inst_ids=live_ids) + + triggered = 0 + for mon in list_active_targets(conn): + inst_id = str(mon.get("inst_id") or "") + target = _safe_float(mon.get("target_index")) + if not inst_id or target is None: + continue + pos = pos_by_inst.get(inst_id) + if not pos: + continue + if index_fn is not None: + idx = index_fn(pos) + else: + idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) + if idx is None: + continue + opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") + if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): + continue + + result = close_fn(inst_id) + if result.get("already_flat"): + mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") + continue + if not result.get("ok"): + mark_monitor( + conn, + int(mon["id"]), + status="active", + trigger_idx=idx, + message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), + ) + continue + + mark_monitor( + conn, + int(mon["id"]), + status="triggered", + trigger_idx=idx, + close_ord_id=result.get("close_ord_id"), + message="目标位触发限价平仓", + ) + triggered += 1 + if send_wechat: + try: + send_wechat( + "\n".join( + [ + "【OKX期权·目标位平仓】", + f"账户:{account_label}", + f"合约:{inst_id}", + f"目标指数:{target:g}", + f"触发指数:{idx:g}", + f"提交张数:{result.get('submitted_sheets') or '—'}", + f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC", + ] + ) + ) + except Exception: + pass + return triggered diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 877c4af..760a1a7 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -80,14 +80,14 @@
- + 预计价值 盈利 目标杠杆 - 到期测算,仅供参考 + 填写后进入右侧监控;达价限价平仓,无止损,到期即止损
@@ -115,6 +115,10 @@
+
暂无持仓
diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 31d2dc4..a23fdce 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -8772,6 +8772,38 @@ html[data-theme="light"] .hub-monitor-options .hub-monitor-block-label { margin-top: 14px; } +.hub-opt-target-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 10px; +} + +.hub-opt-target-item { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 12px; + color: var(--muted, #9aa8c7); +} + +.hub-opt-target-item code { + font-size: 11px; + color: #dbe6ff; +} + +.hub-opt-pos-card .opt-target-row--ro { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(67, 82, 118, 0.4); + font-size: 12px; +} + .hub-options-summary { display: flex; flex-wrap: wrap; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 03542d7..be4bf72 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3614,8 +3614,18 @@ html += `
${esc(opt.msg || "期权数据不可用")}
`; } else { const pos = Array.isArray(opt.positions) ? opt.positions : []; + const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : []; const bal = optionsBalanceFields(opt); html += renderStatRow(bal.funding, bal.trading, bal.upl); + if (targets.length) { + html += `
目标监控 · ${targets.length}
`; + html += '
'; + targets.forEach((t) => { + const side = String(t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; + html += `
${esc(shortOptionsInst(t.inst_id))} ${side} ${esc(fmt(t.target_index, 1))}
`; + }); + html += "
"; + } html += `
期权持仓 · ${pos.length} 仓
`; html += layout === "cards" ? renderOptionsPositionsCards(pos) : renderOptionsPositionsTable(pos); if (row.flask_url_browser || row.flask_url) { diff --git a/tests/test_options_hub_lib.py b/tests/test_options_hub_lib.py index 9ada730..acd9859 100644 --- a/tests/test_options_hub_lib.py +++ b/tests/test_options_hub_lib.py @@ -1,5 +1,5 @@ from unittest import TestCase -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from lib.options.options_hub_lib import build_options_hub_snapshot @@ -10,7 +10,15 @@ class OptionsHubLibTests(TestCase): self.assertFalse(out["enabled"]) self.assertTrue(out["ok"]) - def test_build_options_hub_snapshot_positions(self): + @patch("lib.options.options_hub_lib._compute_options_stats", return_value={}) + @patch("lib.options.options_positions_lib.build_display_option_positions") + def test_build_options_hub_snapshot_positions(self, mock_positions, _mock_stats): + mock_positions.return_value = [ + {"inst_id": "ETH-USD_UM-260703-1800-C", "pos": 2, "upl": 1.5, "mark_px": 0.1} + ] + conn = MagicMock() + conn.__enter__ = MagicMock(return_value=conn) + conn.__exit__ = MagicMock(return_value=False) cfg = { "enabled": True, "exchange_options": object(), @@ -18,19 +26,16 @@ class OptionsHubLibTests(TestCase): "fetch_option_positions": lambda ex: [ {"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"} ], - "format_position_row": lambda p: { - "inst_id": p.get("instId"), - "pos": 2, - "upl": 1.5, - "mark_px": 0.1, - }, "fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0}, - "get_db": MagicMock(), + "get_db": MagicMock(return_value=conn), "trade_budget": 10, "account_label": "OKX期权", } - out = build_options_hub_snapshot(cfg) - self.assertTrue(out["ok"]) + with patch("lib.options.options_target_lib.list_active_targets", return_value=[]): + with patch("lib.options.options_target_lib.targets_by_inst", return_value={}): + out = build_options_hub_snapshot(cfg) + self.assertTrue(out["ok"], out.get("msg")) self.assertEqual(out["position_count"], 1) self.assertEqual(out["upl_total_usdc"], 1.5) self.assertEqual(out["trading_usdc"], 9.5) + self.assertEqual(out.get("target_monitors"), []) diff --git a/tests/test_options_target_lib.py b/tests/test_options_target_lib.py new file mode 100644 index 0000000..69547dc --- /dev/null +++ b/tests/test_options_target_lib.py @@ -0,0 +1,54 @@ +"""期权目标位委托单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.options.options_target_lib import ( + ensure_target_tables, + list_active_targets, + run_options_target_closes, + target_hit, + upsert_target_monitor, +) + + +class OptionsTargetLibTests(unittest.TestCase): + def test_target_hit_call_put(self): + self.assertTrue(target_hit(opt_type="C", index_px=2000, target_index=1950)) + self.assertFalse(target_hit(opt_type="C", index_px=1900, target_index=1950)) + self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850)) + self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850)) + + def test_upsert_and_trigger_close(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + out = upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260717-1900-C", + target_index=1880, + opt_type="C", + sheets=1, + ) + self.assertTrue(out["ok"]) + self.assertEqual(len(list_active_targets(conn)), 1) + + closed = [] + + def close_fn(inst_id: str): + closed.append(inst_id) + return {"ok": True, "submitted_sheets": 1, "premium_received": 1.2, "close_ord_id": "oid1"} + + n = run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260717-1900-C", "idx_px": 1885, "opt_type": "C"}], + close_fn=close_fn, + ) + self.assertEqual(n, 1) + self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"]) + self.assertEqual(len(list_active_targets(conn)), 0) + + +if __name__ == "__main__": + unittest.main()