From 2eb6b28a398a4e576a16d4b5440ec766f17e9def Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 3 Oct 2026 02:13:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AD=E6=8E=A7=E6=9D=A1=E4=BB=B6=E5=BC=80?= =?UTF-8?q?=E4=BB=93=E5=8D=95=EF=BC=9A=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=9B=91=E6=8E=A7=E7=A9=BF=E8=B6=8A/=E5=81=8F?= =?UTF-8?q?=E7=A6=BB=E5=8C=BA=E5=B9=B6=E6=8C=89=E6=9C=AC=E6=9C=BA=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=BC=80=E5=B9=B3=E4=BB=93=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_panel.js | 13 + lib/hub/hub_bridge_register.py | 101 +++ lib/options/options_cond_order_lib.py | 798 +++++++++++++++++++++++ lib/options/options_db.py | 6 + lib/options/options_hub_lib.py | 19 +- lib/options/options_monitor_lib.py | 6 + lib/options/options_register.py | 54 +- lib/options/templates/options_panel.html | 1 + 8 files changed, 996 insertions(+), 2 deletions(-) create mode 100644 lib/options/options_cond_order_lib.py diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 6c75021..bdfdd1e 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2301,6 +2301,18 @@ head.hidden = false; } + function paintCondOrderLock(d) { + const el = document.getElementById("opt-cond-lock-banner"); + if (!el) return; + if (d && d.cond_order_locked) { + const extra = d.cond_order_msg ? " · " + d.cond_order_msg : ""; + el.textContent = "中控条件单占用中,禁止手动开仓" + extra; + el.style.display = ""; + } else { + el.style.display = "none"; + } + } + async function retrySpotSell() { const btn = document.getElementById("opt-spot-sell-retry-btn"); const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase(); @@ -2426,6 +2438,7 @@ const list = resolvePositionsList(d); paintPositions(list); paintSpotSellBanner(d, list); + paintCondOrderLock(d); const fromPos = list.reduce(function (targets, p) { if (!p) return targets; if (p.target_index != null) { diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index 267fdcd..4782b18 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -173,6 +173,9 @@ def _enrich_hub_protocol(snap: dict[str, Any], *, app_module: Any) -> dict[str, "ratio_pct": ratio_pct, } out["positions_brief"] = slim_pos + out["cond_order_locked"] = bool(out.get("cond_order_locked")) + active_cond = out.get("cond_orders_active") + out["cond_orders_active"] = active_cond if isinstance(active_cond, list) else [] try: from lib.options.options_time_lib import options_app_now_str @@ -229,6 +232,104 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: out["version"] = 1 return jsonify(out) + @app.route("/api/hub/options/cond_orders", methods=["GET"]) + def api_hub_options_cond_orders_list(): + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401 + hub_ctx = app.config.get("HUB_CTX") or {} + cfg = hub_ctx.get("options_cfg") + if not isinstance(cfg, dict): + return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503 + from lib.options.options_cond_order_lib import list_cond_orders + from lib.options.options_db import init_options_tables + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + orders = list_cond_orders(conn, active_only=False) + return jsonify({"ok": True, "version": 1, "orders": orders}) + finally: + conn.close() + + @app.route("/api/hub/options/cond_orders", methods=["POST"]) + def api_hub_options_cond_orders_create(): + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401 + hub_ctx = app.config.get("HUB_CTX") or {} + cfg = hub_ctx.get("options_cfg") + if not isinstance(cfg, dict): + return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503 + from lib.options.options_cond_order_lib import ( + create_cond_order, + machine_has_option_position, + ) + from lib.options.options_db import init_options_tables + + ex = cfg.get("exchange_options") + if machine_has_option_position(cfg, ex): + return jsonify({"ok": False, "msg": "本机已有期权仓,拒绝条件单", "version": 1}) + data = request.get_json(silent=True) or {} + conn = cfg["get_db"]() + try: + init_options_tables(conn) + out = create_cond_order(conn, data) + out["version"] = 1 + return jsonify(out) + finally: + conn.close() + + @app.route("/api/hub/options/cond_orders//cancel", methods=["POST"]) + def api_hub_options_cond_orders_cancel(hub_order_id: str): + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401 + hub_ctx = app.config.get("HUB_CTX") or {} + cfg = hub_ctx.get("options_cfg") + if not isinstance(cfg, dict): + return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503 + from lib.options.options_cond_order_lib import cancel_cond_order + from lib.options.options_db import init_options_tables + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + out = cancel_cond_order( + conn, + hub_order_id, + send_wechat=cfg.get("send_wechat"), + account_label=str(cfg.get("account_label") or ""), + ) + out["version"] = 1 + return jsonify(out) + finally: + conn.close() + + @app.route("/api/hub/options/cond_orders//exit", methods=["POST"]) + def api_hub_options_cond_orders_exit(hub_order_id: str): + if not hub_bridge_token_ok(request.headers): + return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401 + hub_ctx = app.config.get("HUB_CTX") or {} + cfg = hub_ctx.get("options_cfg") + if not isinstance(cfg, dict): + return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503 + from lib.options.options_cond_order_lib import update_cond_order_exit + from lib.options.options_db import init_options_tables + + data = request.get_json(silent=True) or {} + conn = cfg["get_db"]() + try: + init_options_tables(conn) + out = update_cond_order_exit( + conn, + hub_order_id, + target_index=data.get("target_index"), + profit_exit_enabled=data.get("profit_exit_enabled"), + profit_exit_mult=data.get("profit_exit_mult"), + ) + out["version"] = 1 + return jsonify(out) + finally: + conn.close() + @app.route("/hub_sso") def hub_sso_login(): token = (request.args.get("token") or "").strip() diff --git a/lib/options/options_cond_order_lib.py b/lib/options/options_cond_order_lib.py new file mode 100644 index 0000000..80e8080 --- /dev/null +++ b/lib/options/options_cond_order_lib.py @@ -0,0 +1,798 @@ +"""中控条件开仓单:客户端本地监控/开仓/出场(中控只下发不盯盘).""" +from __future__ import annotations + +import json +import sqlite3 +import time +from typing import Any + +# armed:待命穿越; triggering:已穿越等偏离区内开仓; open:持仓中; +# done:终态(leave_up/leave_down/cancel/closed) +ACTIVE_STATUSES = ("armed", "triggering", "open") + + +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_cond_order_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_cond_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hub_order_id TEXT NOT NULL UNIQUE, + underlying TEXT NOT NULL, + opt_type TEXT NOT NULL, + trigger_side TEXT NOT NULL, + trigger_px REAL NOT NULL, + deviation_pct REAL NOT NULL, + min_expiry_hours REAL NOT NULL, + target_index REAL, + profit_exit_enabled INTEGER DEFAULT 0, + profit_exit_mult REAL DEFAULT 1.0, + pending_target_index REAL, + pending_profit_exit_enabled INTEGER, + pending_profit_exit_mult REAL, + exit_update_pending INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'armed', + end_reason TEXT, + last_index REAL, + crossed_at TEXT, + opened_inst_id TEXT, + opened_trade_id INTEGER, + message TEXT, + created_at TEXT, + updated_at TEXT, + ended_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_cond_orders_status + ON options_cond_orders(status) + """ + ) + + +def _now() -> str: + try: + from lib.options.options_time_lib import options_app_now_str + + return options_app_now_str() + except Exception: + return time.strftime("%Y-%m-%d %H:%M:%S") + + +def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + d = dict(row) + d["profit_exit_enabled"] = bool(int(d.get("profit_exit_enabled") or 0)) + d["exit_update_pending"] = bool(int(d.get("exit_update_pending") or 0)) + if d.get("pending_profit_exit_enabled") is not None: + d["pending_profit_exit_enabled"] = bool(int(d["pending_profit_exit_enabled"])) + return d + + +def list_cond_orders(conn: sqlite3.Connection, *, active_only: bool = False) -> list[dict[str, Any]]: + ensure_cond_order_table(conn) + if active_only: + rows = conn.execute( + f""" + SELECT * FROM options_cond_orders + WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))}) + ORDER BY id DESC + """, + ACTIVE_STATUSES, + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM options_cond_orders ORDER BY id DESC LIMIT 100" + ).fetchall() + return [_row_to_dict(r) for r in rows if r] + + +def get_cond_order_by_hub_id(conn: sqlite3.Connection, hub_order_id: str) -> dict[str, Any] | None: + ensure_cond_order_table(conn) + row = conn.execute( + "SELECT * FROM options_cond_orders WHERE hub_order_id = ?", + ((hub_order_id or "").strip(),), + ).fetchone() + return _row_to_dict(row) + + +def has_active_cond_order(conn: sqlite3.Connection) -> bool: + ensure_cond_order_table(conn) + row = conn.execute( + f""" + SELECT 1 FROM options_cond_orders + WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))}) + LIMIT 1 + """, + ACTIVE_STATUSES, + ).fetchone() + return row is not None + + +def cond_order_blocks_manual_open_msg(conn: sqlite3.Connection) -> str | None: + if has_active_cond_order(conn): + return "中控条件单占用中,禁止手动开仓" + return None + + +def machine_has_option_position(cfg: dict[str, Any], ex: Any) -> bool: + try: + raw = cfg["fetch_option_positions"](ex) + except Exception: + return False + if not raw: + return False + from lib.options.options_position_limit_lib import count_live_option_positions + + return count_live_option_positions(raw) > 0 + + +def create_cond_order(conn: sqlite3.Connection, payload: dict[str, Any]) -> dict[str, Any]: + """接收中控下发;同机仅允许一张活跃条件单.""" + ensure_cond_order_table(conn) + hub_order_id = (payload.get("hub_order_id") or "").strip() + if not hub_order_id: + return {"ok": False, "msg": "缺少 hub_order_id"} + existing = get_cond_order_by_hub_id(conn, hub_order_id) + if existing: + return {"ok": True, "msg": "条件单已存在", "order": existing, "deduped": True} + if has_active_cond_order(conn): + return {"ok": False, "msg": "本机已有活跃条件单"} + + underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH" + opt_type = (payload.get("opt_type") or "P").strip().upper() + if opt_type not in ("P", "C"): + return {"ok": False, "msg": "opt_type 须为 P 或 C"} + trigger_side = (payload.get("trigger_side") or "gte").strip().lower() + if trigger_side not in ("gte", "lte"): + return {"ok": False, "msg": "trigger_side 须为 gte 或 lte"} + trigger_px = _safe_float(payload.get("trigger_px")) + if trigger_px is None or trigger_px <= 0: + return {"ok": False, "msg": "trigger_px 无效"} + deviation_pct = _safe_float(payload.get("deviation_pct")) + if deviation_pct is None or deviation_pct <= 0: + return {"ok": False, "msg": "deviation_pct 须 > 0"} + min_expiry_hours = _safe_float(payload.get("min_expiry_hours")) + if min_expiry_hours is None or min_expiry_hours <= 0: + return {"ok": False, "msg": "min_expiry_hours 须 > 0"} + + exit_cfg = payload.get("exit") if isinstance(payload.get("exit"), dict) else {} + target_index = _safe_float(exit_cfg.get("target_index") or payload.get("target_index")) + pe_en = bool(exit_cfg.get("profit_exit_enabled") or payload.get("profit_exit_enabled")) + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + pe_mult = normalize_profit_exit_mult( + exit_cfg.get("profit_exit_mult", payload.get("profit_exit_mult")), default=1.0 + ) + if target_index is not None and target_index <= 0: + return {"ok": False, "msg": "target_index 无效"} + if not pe_en and target_index is None: + return {"ok": False, "msg": "请至少配置目标位或翻倍出场"} + + now = _now() + conn.execute( + """ + INSERT INTO options_cond_orders ( + hub_order_id, underlying, opt_type, trigger_side, trigger_px, deviation_pct, + min_expiry_hours, target_index, profit_exit_enabled, profit_exit_mult, + status, message, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?, 'armed', ?, ?, ?) + """, + ( + hub_order_id, + underlying, + opt_type, + trigger_side, + float(trigger_px), + float(deviation_pct), + float(min_expiry_hours), + float(target_index) if target_index is not None else None, + 1 if pe_en else 0, + float(pe_mult), + "已接收,待命穿越", + now, + now, + ), + ) + conn.commit() + order = get_cond_order_by_hub_id(conn, hub_order_id) + return {"ok": True, "msg": "条件单已 armed", "order": order} + + +def cancel_cond_order( + conn: sqlite3.Connection, + hub_order_id: str, + *, + send_wechat: Any = None, + account_label: str = "", +) -> dict[str, Any]: + order = get_cond_order_by_hub_id(conn, hub_order_id) + if not order: + return {"ok": False, "msg": "条件单不存在"} + if order.get("status") not in ACTIVE_STATUSES: + return {"ok": True, "msg": "条件单已结束", "order": order} + if order.get("status") == "open": + return {"ok": False, "msg": "已开仓,请用平仓/出场结束;取消仅适用于未成交条件单"} + _end_order( + conn, + order, + end_reason="cancel", + message="中控取消", + send_wechat=send_wechat, + account_label=account_label, + ) + return {"ok": True, "msg": "已取消", "order": get_cond_order_by_hub_id(conn, hub_order_id)} + + +def update_cond_order_exit( + conn: sqlite3.Connection, + hub_order_id: str, + *, + target_index: Any = None, + profit_exit_enabled: Any = None, + profit_exit_mult: Any = None, +) -> dict[str, Any]: + """成交后改出场;若正在平仓挂单则先记 pending,本轮结束后再套用.""" + order = get_cond_order_by_hub_id(conn, hub_order_id) + if not order: + return {"ok": False, "msg": "条件单不存在"} + if order.get("status") != "open": + return {"ok": False, "msg": "仅持仓中的条件单可改出场"} + + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + new_target = _safe_float(target_index) if target_index is not None else order.get("target_index") + if target_index is not None and str(target_index).strip() == "": + new_target = None + if new_target is not None and new_target <= 0: + return {"ok": False, "msg": "target_index 无效"} + if profit_exit_enabled is None: + pe_en = bool(order.get("profit_exit_enabled")) + else: + pe_en = bool(profit_exit_enabled) + pe_mult = normalize_profit_exit_mult( + profit_exit_mult if profit_exit_mult is not None else order.get("profit_exit_mult"), + default=1.0, + ) + if not pe_en and new_target is None: + return {"ok": False, "msg": "请至少配置目标位或翻倍出场"} + + closing = _exit_close_in_progress(conn, str(order.get("opened_inst_id") or "")) + now = _now() + if closing: + conn.execute( + """ + UPDATE options_cond_orders SET + pending_target_index = ?, + pending_profit_exit_enabled = ?, + pending_profit_exit_mult = ?, + exit_update_pending = 1, + message = ?, + updated_at = ? + WHERE hub_order_id = ? + """, + ( + float(new_target) if new_target is not None else None, + 1 if pe_en else 0, + float(pe_mult), + "出场已更新,等待本轮平仓挂单结束后生效", + now, + hub_order_id, + ), + ) + conn.commit() + return { + "ok": True, + "msg": "已记录,本轮平仓结束后生效", + "deferred": True, + "order": get_cond_order_by_hub_id(conn, hub_order_id), + } + + _apply_exit_to_position( + conn, + order, + target_index=new_target, + profit_exit_enabled=pe_en, + profit_exit_mult=pe_mult, + ) + conn.execute( + """ + UPDATE options_cond_orders SET + target_index = ?, + profit_exit_enabled = ?, + profit_exit_mult = ?, + exit_update_pending = 0, + pending_target_index = NULL, + pending_profit_exit_enabled = NULL, + pending_profit_exit_mult = NULL, + message = ?, + updated_at = ? + WHERE hub_order_id = ? + """, + ( + float(new_target) if new_target is not None else None, + 1 if pe_en else 0, + float(pe_mult), + "出场已更新并生效", + now, + hub_order_id, + ), + ) + conn.commit() + return {"ok": True, "msg": "出场已更新", "order": get_cond_order_by_hub_id(conn, hub_order_id)} + + +def _exit_close_in_progress(conn: sqlite3.Connection, inst_id: str) -> bool: + if not inst_id: + return False + try: + from lib.options.options_target_lib import list_closing_targets + + for t in list_closing_targets(conn): + if str(t.get("inst_id") or "") == inst_id: + return True + except Exception: + pass + try: + row = conn.execute( + """ + SELECT profit_exit_state FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id ASC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if row and str(row["profit_exit_state"] or "") == "closing": + return True + except Exception: + pass + return False + + +def _apply_exit_to_position( + conn: sqlite3.Connection, + order: dict[str, Any], + *, + target_index: float | None, + profit_exit_enabled: bool, + profit_exit_mult: float, +) -> None: + inst_id = str(order.get("opened_inst_id") or "").strip() + if not inst_id: + return + from lib.options.options_profit_exit_lib import set_profit_exit + from lib.options.options_target_lib import cancel_target_monitor, upsert_target_monitor + + set_profit_exit(conn, inst_id=inst_id, enabled=profit_exit_enabled, mult=profit_exit_mult) + if target_index is not None: + upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=float(target_index), + underlying=str(order.get("underlying") or ""), + opt_type=str(order.get("opt_type") or ""), + ) + else: + cancel_target_monitor(conn, inst_id=inst_id) + conn.commit() + + +def _end_order( + conn: sqlite3.Connection, + order: dict[str, Any], + *, + end_reason: str, + message: str, + send_wechat: Any = None, + account_label: str = "", + index_px: float | None = None, +) -> None: + now = _now() + conn.execute( + """ + UPDATE options_cond_orders SET + status = 'done', + end_reason = ?, + message = ?, + updated_at = ?, + ended_at = ? + WHERE hub_order_id = ? + """, + (end_reason, message, now, now, order["hub_order_id"]), + ) + conn.commit() + if callable(send_wechat): + try: + reason_map = { + "leave_up": "上破偏离区结束", + "leave_down": "下破偏离区结束", + "cancel": "中控取消", + "closed": "出场完成", + } + lines = [ + "【OKX期权·条件单结束】", + f"账户:{account_label or 'OKX期权'}", + f"单号:{order.get('hub_order_id')}", + f"标的:{order.get('underlying')} {order.get('opt_type')}", + f"触发价:{order.get('trigger_px')}", + f"原因:{reason_map.get(end_reason, end_reason)}", + f"说明:{message}", + ] + if index_px is not None: + lines.append(f"当前指数:{index_px:g}") + send_wechat("\n".join(lines)) + except Exception: + pass + + +def band_bounds(trigger_px: float, deviation_pct: float) -> tuple[float, float]: + d = max(0.0, float(deviation_pct)) / 100.0 + lo = float(trigger_px) * (1.0 - d) + hi = float(trigger_px) * (1.0 + d) + return lo, hi + + +def pick_nearest_itm_contract( + cfg: dict[str, Any], + ex: Any, + *, + underlying: str, + opt_type: str, + index_px: float, + min_expiry_hours: float, +) -> dict[str, Any] | None: + """按当前指数选最近一档实值;到期 ≥ min_expiry_hours.""" + from lib.exchange.okx_options_lib import build_option_chain, option_buy_liquidity_ok + from lib.options.options_pricing_lib import option_moneyness + + max_dte_days = max(float(min_expiry_hours) / 24.0 + 14.0, 14.0) + # 放宽实值距离:取全部 ITM,再按距离排序 + chain = build_option_chain( + ex, + underlying, + max_dte_days=max_dte_days, + itm_only=False, + itm_max_dist_usd=1e12, + index_px=index_px, + ) + min_ms = time.time() * 1000 + float(min_expiry_hours) * 3600 * 1000 + want = (opt_type or "P").upper() + candidates: list[tuple[float, dict[str, Any]]] = [] + expiries = chain.get("expiries") or [] + # build_option_chain 返回 list[{exp_time, contracts}] + if isinstance(expiries, dict): + exp_iter = [{"exp_time": k, "contracts": v} for k, v in expiries.items()] + else: + exp_iter = list(expiries) + for bucket in exp_iter: + try: + exp_ms = int(bucket.get("exp_time") or 0) + except (TypeError, ValueError): + exp_ms = 0 + if exp_ms and exp_ms < min_ms: + continue + for c in bucket.get("contracts") or []: + if str(c.get("opt_type") or "").upper() != want: + continue + strike = _safe_float(c.get("strike")) + if strike is None: + continue + mny = option_moneyness(opt_type=want, strike=strike, index_px=index_px) + if mny != "itm": + continue + dist = abs(float(strike) - float(index_px)) + candidates.append((dist, c)) + if not candidates: + return None + candidates.sort(key=lambda x: (x[0], str(x[1].get("inst_id") or ""))) + # 优先有卖一深度的最近档 + for _dist, c in candidates[:12]: + inst = str(c.get("inst_id") or "") + if not inst: + continue + try: + q = cfg["quote_option_contract"](ex, inst) + except Exception: + continue + ask = q.get("ask") + ask_sz = q.get("ask_sz") + ok, _ = option_buy_liquidity_ok(ask, ask_sz) + if ok: + out = dict(c) + out["quote"] = q + return out + # 深度都不够时仍返回最近档,由上层继续等 + best = dict(candidates[0][1]) + return best + + +def _fetch_index(cfg: dict[str, Any], ex: Any, underlying: str) -> float | None: + from lib.exchange.okx_options_lib import fetch_index_price + + uly = f"{(underlying or 'ETH').upper()}-USD" + try: + return fetch_index_price(ex, uly) + except Exception: + return None + + +def _try_open_full( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + order: dict[str, Any], +) -> dict[str, Any]: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import open_coin_option_buy_full + + note = f"中控条件单:{order.get('hub_order_id')}" + target_index = _safe_float(order.get("target_index")) + pe_en = bool(order.get("profit_exit_enabled")) + pe_mult = float(order.get("profit_exit_mult") or 1.0) + if is_coin_margin_mode(): + return open_coin_option_buy_full( + cfg, + ex, + inst_id=inst_id, + signal_note=note, + target_index=target_index, + profit_exit_enabled=pe_en, + profit_exit_mult=pe_mult, + target_sheets=None, + ) + return { + "ok": False, + "msg": "条件单开仓当前仅支持币本位客户端规则(买币→开仓)", + "need_depth": False, + } + + +def run_cond_orders_tick( + conn: sqlite3.Connection, + cfg: dict[str, Any], + *, + send_wechat: Any = None, + account_label: str = "", +) -> None: + """监控循环单步:穿越/偏离区/开仓/出场收尾.""" + ensure_cond_order_table(conn) + ex = cfg.get("exchange_options") + if ex is None: + return + orders = list_cond_orders(conn, active_only=True) + if not orders: + return + + for order in orders: + try: + _tick_one( + conn, + cfg, + ex, + order, + send_wechat=send_wechat, + account_label=account_label, + ) + except Exception as e: + try: + conn.execute( + "UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?", + (f"监控异常: {e}"[:200], _now(), order["hub_order_id"]), + ) + conn.commit() + except Exception: + pass + + +def _tick_one( + conn: sqlite3.Connection, + cfg: dict[str, Any], + ex: Any, + order: dict[str, Any], + *, + send_wechat: Any, + account_label: str, +) -> None: + status = str(order.get("status") or "") + underlying = str(order.get("underlying") or "ETH") + idx = _fetch_index(cfg, ex, underlying) + if idx is None or idx <= 0: + return + last = _safe_float(order.get("last_index")) + conn.execute( + "UPDATE options_cond_orders SET last_index=?, updated_at=? WHERE hub_order_id=?", + (float(idx), _now(), order["hub_order_id"]), + ) + conn.commit() + + trigger_px = float(order["trigger_px"]) + lo, hi = band_bounds(trigger_px, float(order["deviation_pct"])) + side = str(order.get("trigger_side") or "gte") + + if status == "armed": + crossed = False + if side == "gte" and last is not None and last < trigger_px and idx >= trigger_px: + crossed = True + elif side == "lte" and last is not None and last > trigger_px and idx <= trigger_px: + crossed = True + # 首次无 last:只记录,等下一跳再判穿越(避免挂单时已在上方立刻开) + if last is None: + return + if crossed: + conn.execute( + """ + UPDATE options_cond_orders SET status='triggering', crossed_at=?, message=?, updated_at=? + WHERE hub_order_id=? + """, + (_now(), f"已穿越 {trigger_px:g},等待偏离区内开仓", _now(), order["hub_order_id"]), + ) + conn.commit() + order["status"] = "triggering" + status = "triggering" + else: + return + + if status == "triggering": + if idx > hi: + _end_order( + conn, + order, + end_reason="leave_up", + message=f"指数 {idx:g} 上破偏离上沿 {hi:g}", + send_wechat=send_wechat, + account_label=account_label, + index_px=idx, + ) + return + if idx < lo: + _end_order( + conn, + order, + end_reason="leave_down", + message=f"指数 {idx:g} 下破偏离下沿 {lo:g}", + send_wechat=send_wechat, + account_label=account_label, + index_px=idx, + ) + return + # 区内:有仓则不再开 + if machine_has_option_position(cfg, ex): + conn.execute( + "UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?", + ("偏离区内但已有持仓,等待", _now(), order["hub_order_id"]), + ) + conn.commit() + return + picked = pick_nearest_itm_contract( + cfg, + ex, + underlying=underlying, + opt_type=str(order.get("opt_type") or "P"), + index_px=idx, + min_expiry_hours=float(order.get("min_expiry_hours") or 24), + ) + if not picked or not picked.get("inst_id"): + conn.execute( + "UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?", + ("偏离区内暂无合适实值合约", _now(), order["hub_order_id"]), + ) + conn.commit() + return + inst_id = str(picked["inst_id"]) + # 深度检查 + from lib.exchange.okx_options_lib import option_buy_liquidity_ok + + q = picked.get("quote") or cfg["quote_option_contract"](ex, inst_id) + ok_liq, msg_liq = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) + if not ok_liq: + conn.execute( + "UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?", + (f"深度不足继续等待: {msg_liq or '无卖一'}", _now(), order["hub_order_id"]), + ) + conn.commit() + return + result = _try_open_full(cfg, ex, inst_id=inst_id, order=order) + if not result.get("ok"): + conn.execute( + "UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?", + (f"开仓未成继续等: {result.get('msg') or '失败'}"[:180], _now(), order["hub_order_id"]), + ) + conn.commit() + return + trade_id = result.get("trade_id") + conn.execute( + """ + UPDATE options_cond_orders SET + status='open', opened_inst_id=?, opened_trade_id=?, + message=?, updated_at=? + WHERE hub_order_id=? + """, + ( + inst_id, + int(trade_id) if trade_id is not None else None, + f"已开仓 {inst_id}", + _now(), + order["hub_order_id"], + ), + ) + conn.commit() + # 确保出场规则挂上 + order2 = get_cond_order_by_hub_id(conn, order["hub_order_id"]) or order + order2["opened_inst_id"] = inst_id + _apply_exit_to_position( + conn, + order2, + target_index=_safe_float(order2.get("target_index")), + profit_exit_enabled=bool(order2.get("profit_exit_enabled")), + profit_exit_mult=float(order2.get("profit_exit_mult") or 1.0), + ) + return + + if status == "open": + # 延迟出场更新 + if order.get("exit_update_pending") and not _exit_close_in_progress( + conn, str(order.get("opened_inst_id") or "") + ): + pe_en = order.get("pending_profit_exit_enabled") + if pe_en is None: + pe_en = order.get("profit_exit_enabled") + _apply_exit_to_position( + conn, + order, + target_index=_safe_float(order.get("pending_target_index")), + profit_exit_enabled=bool(pe_en), + profit_exit_mult=float( + order.get("pending_profit_exit_mult") + if order.get("pending_profit_exit_mult") is not None + else order.get("profit_exit_mult") + or 1.0 + ), + ) + conn.execute( + """ + UPDATE options_cond_orders SET + target_index = pending_target_index, + profit_exit_enabled = COALESCE(pending_profit_exit_enabled, profit_exit_enabled), + profit_exit_mult = COALESCE(pending_profit_exit_mult, profit_exit_mult), + exit_update_pending = 0, + pending_target_index = NULL, + pending_profit_exit_enabled = NULL, + pending_profit_exit_mult = NULL, + message = ?, + updated_at = ? + WHERE hub_order_id = ? + """, + ("延迟出场已生效", _now(), order["hub_order_id"]), + ) + conn.commit() + + inst_id = str(order.get("opened_inst_id") or "") + if not inst_id: + return + if not machine_has_option_position(cfg, ex): + # 确认该合约已平 + raw = cfg["fetch_option_positions"](ex) or [] + still = any(str(p.get("instId") or "") == inst_id for p in raw) + if still: + return + _end_order( + conn, + order, + end_reason="closed", + message="持仓已平,条件单完成", + send_wechat=send_wechat, + account_label=account_label, + index_px=idx, + ) diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 2b69371..77b5c28 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -120,6 +120,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None: ensure_bridge_table(conn) except Exception: pass + try: + from lib.options.options_cond_order_lib import ensure_cond_order_table + + ensure_cond_order_table(conn) + except Exception: + pass init_options_review_tables(conn) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 15b61e2..38df6b8 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -130,7 +130,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: except Exception: open_bridges = [] - return { + out = { "ok": True, "enabled": True, "positions": positions, @@ -153,6 +153,23 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "coin_budget": coin_budget, "bridge_status": bridge_status, "open_bridges": open_bridges, + "cond_order_locked": False, + "cond_orders_active": [], } + try: + from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders + + conn_c = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn_c) + out["cond_order_locked"] = has_active_cond_order(conn_c) + out["cond_orders_active"] = list_cond_orders(conn_c, active_only=True) + finally: + conn_c.close() + except Exception: + pass + return out except Exception as e: return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index 1c94db8..9657546 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -440,6 +440,7 @@ def options_monitor_loop( profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None, profit_exit_cfg: dict[str, Any] | None = None, stale_pending_fn: Callable[[], dict[str, Any]] | None = None, + cond_order_tick_fn: Callable[[sqlite3.Connection], None] | None = None, stop_event: Any = None, ) -> None: if not enabled: @@ -450,6 +451,11 @@ def options_monitor_loop( try: conn = get_db() try: + if cond_order_tick_fn is not None: + try: + cond_order_tick_fn(conn) + except Exception: + pass positions = fetch_positions() run_options_profit_alerts( conn, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index e92b419..c2dd66b 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -83,6 +83,7 @@ def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: hub_ctx = dict(app.config.get("HUB_CTX") or {}) hub_ctx["options_snapshot_fn"] = snapshot_fn hub_ctx["options_close_fn"] = close_fn + hub_ctx["options_cfg"] = cfg app.config["HUB_CTX"] = hub_ctx @@ -1009,6 +1010,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mode = (data.get("mode") or "sheets").strip() mode, mode_note = _normalize_size_mode(mode) signal_note = (data.get("signal_note") or "").strip() + # 中控条件单占用时禁止手动开仓 + try: + from lib.options.options_cond_order_lib import cond_order_blocks_manual_open_msg + from lib.options.options_db import init_options_tables + + conn_blk = cfg["get_db"]() + try: + init_options_tables(conn_blk) + block = cond_order_blocks_manual_open_msg(conn_blk) + if block: + return jsonify({"ok": False, "msg": block, "cond_order_locked": True}) + finally: + conn_blk.close() + except Exception: + pass if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 pass @@ -1487,7 +1503,32 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn_b.close() except Exception: spot_sell_hint = None - return jsonify({"ok": True, "positions": rows, "spot_sell": spot_sell_hint}) + cond_locked = False + cond_msg = "" + try: + from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders + + conn_c = cfg["get_db"]() + try: + init_options_tables(conn_c) + cond_locked = has_active_cond_order(conn_c) + if cond_locked: + act = list_cond_orders(conn_c, active_only=True) + if act: + cond_msg = str(act[0].get("message") or act[0].get("status") or "") + finally: + conn_c.close() + except Exception: + pass + return jsonify( + { + "ok": True, + "positions": rows, + "spot_sell": spot_sell_hint, + "cond_order_locked": cond_locked, + "cond_order_msg": cond_msg, + } + ) @app.route("/api/options/targets") @lr @@ -2001,6 +2042,16 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: pass return out + def _cond_order_tick(conn) -> None: + from lib.options.options_cond_order_lib import run_cond_orders_tick + + run_cond_orders_tick( + conn, + cfg, + send_wechat=cfg.get("send_wechat"), + account_label=str(cfg.get("account_label") or ""), + ) + t = threading.Thread( target=options_monitor_loop, kwargs={ @@ -2017,6 +2068,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: "profit_exit_close_fn": _profit_exit_close, "profit_exit_cfg": cfg, "stale_pending_fn": _stale_pending, + "cond_order_tick_fn": _cond_order_tick, }, daemon=True, name="options-monitor", diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 5a1631a..a29b6a8 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -13,6 +13,7 @@ {% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.
{% endif %} +