diff --git a/lib/hub/hub_bridge_register.py b/lib/hub/hub_bridge_register.py index 66b8134..ee39983 100644 --- a/lib/hub/hub_bridge_register.py +++ b/lib/hub/hub_bridge_register.py @@ -549,6 +549,77 @@ def register_hub_bridge_routes(app: Flask, app_module: Any) -> None: finally: conn.close() + @app.route("/api/hub/options/market_orders", methods=["GET"]) + def api_hub_options_market_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_db import init_options_tables + from lib.options.options_market_order_lib import list_market_orders + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + orders = list_market_orders(conn, active_only=False) + return jsonify({"ok": True, "version": 1, "orders": orders}) + finally: + conn.close() + + @app.route("/api/hub/options/market_orders", methods=["POST"]) + def api_hub_options_market_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_market_order_lib import execute_market_open + + data = request.get_json(silent=True) or {} + ex = cfg.get("exchange_options") + out = execute_market_open( + cfg, + ex, + data, + get_db=cfg.get("get_db"), + send_wechat=cfg.get("send_wechat"), + account_label=str(cfg.get("account_label") or ""), + ) + if not isinstance(out, dict): + out = {"ok": False, "msg": "市价开仓返回异常"} + out["version"] = 1 + return jsonify(out) + + @app.route("/api/hub/options/market_orders//exit", methods=["POST"]) + def api_hub_options_market_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_db import init_options_tables + from lib.options.options_market_order_lib import update_market_order_exit + + data = request.get_json(silent=True) or {} + conn = cfg["get_db"]() + try: + init_options_tables(conn) + out = update_market_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("/api/hub/version", methods=["GET"]) def hub_version(): """中控探测客户端当前 git 版本/在线.""" diff --git a/lib/options/options_cond_order_lib.py b/lib/options/options_cond_order_lib.py index e9f0097..0f38525 100644 --- a/lib/options/options_cond_order_lib.py +++ b/lib/options/options_cond_order_lib.py @@ -561,6 +561,22 @@ def band_bounds(trigger_px: float, deviation_pct: float) -> tuple[float, float]: return lo, hi +def _chain_contract_leverage(c: dict[str, Any], *, index_px: float) -> float | None: + """用链上报价估合约杠杆(币本位≈1/ask).""" + from lib.options.options_pricing_lib import equivalent_contract_leverage + + ask = _safe_float(c.get("ask")) + ct_mult = _safe_float(c.get("ct_mult")) or 0.01 + if ask is None or ask <= 0 or ct_mult <= 0: + return None + return equivalent_contract_leverage( + index_px=index_px, + eth_amount=float(ct_mult), + total_premium=float(ask) * float(ct_mult), + margin_mode="coin", + ) + + def pick_nearest_itm_contract( cfg: dict[str, Any], ex: Any, @@ -569,8 +585,12 @@ def pick_nearest_itm_contract( opt_type: str, index_px: float, min_expiry_hours: float, + target_leverage: float | None = None, ) -> dict[str, Any] | None: - """按当前指数选最近一档实值;到期 ≥ min_expiry_hours.""" + """按当前指数选最近一档实值;到期 ≥ min_expiry_hours. + + target_leverage 有值时:仅在第一档实值(最近行权距)内,选杠杆最接近目标的合约。 + """ from lib.exchange.okx_options_lib import build_option_chain, option_buy_liquidity_ok from lib.options.options_pricing_lib import option_moneyness @@ -614,6 +634,59 @@ def pick_nearest_itm_contract( if not candidates: return None candidates.sort(key=lambda x: (x[0], str(x[1].get("inst_id") or ""))) + + want_lev = _safe_float(target_leverage) + if want_lev is not None and want_lev > 0: + min_dist = candidates[0][0] + rung = [c for d, c in candidates if abs(float(d) - float(min_dist)) <= 1e-9] + scored: list[tuple[float, int, dict[str, Any], dict[str, Any] | None]] = [] + for c in rung: + inst = str(c.get("inst_id") or "") + if not inst: + continue + q: dict[str, Any] | None = None + try: + q = cfg["quote_option_contract"](ex, inst) + except Exception: + q = None + ask = (q or {}).get("ask") if q else c.get("ask") + ask_sz = (q or {}).get("ask_sz") if q else c.get("ask_sz") + ok_liq, _ = option_buy_liquidity_ok(ask, ask_sz) + lev = None + if q and ask is not None: + ct = _safe_float((q or {}).get("ct_mult")) or _safe_float(c.get("ct_mult")) or 0.01 + from lib.options.options_pricing_lib import equivalent_contract_leverage + + lev = equivalent_contract_leverage( + index_px=index_px, + eth_amount=float(ct), + total_premium=float(ask) * float(ct), + margin_mode="coin", + ) + if lev is None: + lev = _chain_contract_leverage(c, index_px=index_px) + if lev is None: + continue + scored.append((abs(float(lev) - float(want_lev)), 0 if ok_liq else 1, c, q)) + if scored: + scored.sort(key=lambda x: (x[0], x[1], str(x[2].get("inst_id") or ""))) + best_c = dict(scored[0][2]) + if scored[0][3]: + best_c["quote"] = scored[0][3] + best_c["picked_leverage"] = _chain_contract_leverage(best_c, index_px=index_px) + if scored[0][3] and scored[0][3].get("ask") is not None: + ct = _safe_float(scored[0][3].get("ct_mult")) or _safe_float(best_c.get("ct_mult")) or 0.01 + from lib.options.options_pricing_lib import equivalent_contract_leverage + + best_c["picked_leverage"] = equivalent_contract_leverage( + index_px=index_px, + eth_amount=float(ct), + total_premium=float(scored[0][3]["ask"]) * float(ct), + margin_mode="coin", + ) + return best_c + # 杠杆估不出时退回第一档最近合约 + # 优先有卖一深度的最近档 for _dist, c in candidates[:12]: inst = str(c.get("inst_id") or "") diff --git a/lib/options/options_db.py b/lib/options/options_db.py index f4f5860..5a5553e 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -152,6 +152,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None: ensure_cond_order_table(conn) except Exception: pass + try: + from lib.options.options_market_order_lib import ensure_market_order_table + + ensure_market_order_table(conn) + except Exception: + pass init_options_review_tables(conn) diff --git a/lib/options/options_market_order_lib.py b/lib/options/options_market_order_lib.py new file mode 100644 index 0000000..aa2bdbf --- /dev/null +++ b/lib/options/options_market_order_lib.py @@ -0,0 +1,651 @@ +"""中控市价开仓:按条件选第一档实值+杠杆接近,立即全仓复利开仓.""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any + +ACTIVE_STATUSES = ("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_market_order_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_market_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hub_order_id TEXT NOT NULL UNIQUE, + underlying TEXT NOT NULL, + opt_type TEXT NOT NULL, + min_expiry_hours REAL NOT NULL, + target_leverage REAL NOT NULL, + picked_leverage REAL, + 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 'open', + end_reason TEXT, + opened_inst_id TEXT, + opened_trade_id INTEGER, + opened_at TEXT, + message TEXT, + created_at TEXT, + updated_at TEXT, + ended_at TEXT + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_market_orders_status + ON options_market_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 _notify_mkt( + send_wechat: Any, + *, + title: str, + order: dict[str, Any], + account_label: str = "", + extra: list[str] | None = None, +) -> None: + if not callable(send_wechat) or not order: + return + try: + lines = [ + title, + f"账户:{account_label or 'OKX期权'}", + f"单号:{order.get('hub_order_id') or '—'}", + f"标的:{order.get('underlying') or '—'} {order.get('opt_type') or '—'}", + f"杠杆目标:{order.get('target_leverage') if order.get('target_leverage') is not None else '—'}", + ] + for line in extra or []: + if line: + lines.append(line) + send_wechat("\n".join(lines)) + except Exception: + pass + + +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_market_orders(conn: sqlite3.Connection, *, active_only: bool = False) -> list[dict[str, Any]]: + ensure_market_order_table(conn) + if active_only: + rows = conn.execute( + f""" + SELECT * FROM options_market_orders + WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))}) + ORDER BY id DESC + """, + ACTIVE_STATUSES, + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM options_market_orders ORDER BY id DESC LIMIT 100" + ).fetchall() + return [_row_to_dict(r) for r in rows if r] + + +def get_market_order_by_hub_id(conn: sqlite3.Connection, hub_order_id: str) -> dict[str, Any] | None: + ensure_market_order_table(conn) + row = conn.execute( + "SELECT * FROM options_market_orders WHERE hub_order_id = ?", + ((hub_order_id or "").strip(),), + ).fetchone() + return _row_to_dict(row) + + +def has_active_market_order(conn: sqlite3.Connection) -> bool: + ensure_market_order_table(conn) + row = conn.execute( + f""" + SELECT 1 FROM options_market_orders + WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))}) + LIMIT 1 + """, + ACTIVE_STATUSES, + ).fetchone() + return row is not None + + +def execute_market_open( + cfg: dict[str, Any], + ex: Any, + payload: dict[str, Any], + *, + get_db: Any = None, + send_wechat: Any = None, + account_label: str = "", +) -> dict[str, Any]: + """立即选约并全仓复利开仓;成功后写入市价单记录.""" + from lib.options.options_cond_order_lib import ( + _apply_exit_to_position, + _fetch_index, + has_active_cond_order, + machine_has_option_position, + pick_nearest_itm_contract, + ) + from lib.options.options_db import init_options_tables + from lib.options.options_position_limit_lib import option_position_limit_block_msg + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + hub_order_id = (payload.get("hub_order_id") or "").strip() + if not hub_order_id: + return {"ok": False, "msg": "缺少 hub_order_id"} + + underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH" + try: + from lib.options.options_underly_lib import resolve_default_underly, underly_not_allowed_msg + + if not (payload.get("underlying") or "").strip(): + underlying = resolve_default_underly() + uly_block = underly_not_allowed_msg(underlying) + if uly_block: + return {"ok": False, "msg": uly_block} + except Exception: + pass + + 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"} + + 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"} + + target_leverage = _safe_float(payload.get("target_leverage") or payload.get("leverage")) + if target_leverage is None or target_leverage <= 0: + return {"ok": False, "msg": "杠杆倍数须 > 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")) + 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": "请至少配置目标位或翻倍出场"} + + if ex is None: + return {"ok": False, "msg": "期权交易所未就绪"} + + db_fn = get_db or (cfg.get("get_db") if isinstance(cfg, dict) else None) + if not callable(db_fn): + return {"ok": False, "msg": "缺少 get_db"} + + conn = db_fn() + try: + init_options_tables(conn) + existing = get_market_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": "本机已有活跃条件单"} + if has_active_market_order(conn): + return {"ok": False, "msg": "本机已有活跃市价单"} + if machine_has_option_position(cfg, ex): + return {"ok": False, "msg": "本机已有期权仓,拒绝市价单"} + limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id="", + fetch_positions=cfg.get("fetch_option_positions"), + ) + if limit_msg: + return {"ok": False, "msg": limit_msg} + try: + conn.commit() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + + idx = _fetch_index(cfg, ex, underlying) + if idx is None or idx <= 0: + return {"ok": False, "msg": "指数价获取失败"} + + picked = pick_nearest_itm_contract( + cfg, + ex, + underlying=underlying, + opt_type=opt_type, + index_px=float(idx), + min_expiry_hours=float(min_expiry_hours), + target_leverage=float(target_leverage), + ) + if not picked or not picked.get("inst_id"): + return {"ok": False, "msg": "暂无合适的第一档实值合约"} + + 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: + return {"ok": False, "msg": f"深度不足: {msg_liq or '无卖一'}"} + + from lib.options.options_coin_open_lib import open_coin_option_buy_full + + note = f"中控市价单:{hub_order_id}" + result = 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, + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("msg") or "开仓失败", + "picked_inst_id": inst_id, + "picked_leverage": picked.get("picked_leverage"), + } + + trade_id = result.get("trade_id") + picked_lev = _safe_float(picked.get("picked_leverage")) + now = _now() + order_stub = { + "hub_order_id": hub_order_id, + "underlying": underlying, + "opt_type": opt_type, + "target_leverage": float(target_leverage), + "opened_inst_id": inst_id, + "target_index": target_index, + "profit_exit_enabled": pe_en, + "profit_exit_mult": pe_mult, + } + + conn = db_fn() + try: + init_options_tables(conn) + ensure_market_order_table(conn) + conn.execute( + """ + INSERT INTO options_market_orders ( + hub_order_id, underlying, opt_type, min_expiry_hours, target_leverage, + picked_leverage, target_index, profit_exit_enabled, profit_exit_mult, + status, opened_inst_id, opened_trade_id, opened_at, message, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?, 'open', ?,?,?,?,?,?) + """, + ( + hub_order_id, + underlying, + opt_type, + float(min_expiry_hours), + float(target_leverage), + float(picked_lev) if picked_lev is not None else None, + float(target_index) if target_index is not None else None, + 1 if pe_en else 0, + float(pe_mult), + inst_id, + int(trade_id) if trade_id is not None else None, + now, + f"已开仓 {inst_id}", + now, + now, + ), + ) + conn.commit() + order = get_market_order_by_hub_id(conn, hub_order_id) or order_stub + _apply_exit_to_position( + conn, + order, + target_index=target_index, + profit_exit_enabled=pe_en, + profit_exit_mult=float(pe_mult), + ) + _notify_mkt( + send_wechat, + title="【OKX期权·市价单开仓】", + order=order, + account_label=account_label, + extra=[ + f"合约:{inst_id}", + f"实选杠杆:{picked_lev:g}" if picked_lev is not None else "", + f"目标杠杆:{float(target_leverage):g}", + ], + ) + return { + "ok": True, + "msg": "市价开仓成功", + "order": order, + "inst_id": inst_id, + "trade_id": trade_id, + "picked_leverage": picked_lev, + } + finally: + try: + conn.close() + except Exception: + pass + + +def update_market_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]: + from lib.options.options_cond_order_lib import _apply_exit_to_position, _exit_close_in_progress + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + order = get_market_order_by_hub_id(conn, hub_order_id) + if not order: + return {"ok": False, "msg": "市价单不存在"} + if order.get("status") != "open": + return {"ok": False, "msg": "仅持仓中的市价单可改出场"} + + 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_market_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_market_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_market_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_market_order_by_hub_id(conn, hub_order_id)} + + +def _end_order( + conn: sqlite3.Connection, + order: dict[str, Any], + *, + end_reason: str, + message: str, + send_wechat: Any = None, + account_label: str = "", +) -> None: + now = _now() + conn.execute( + """ + UPDATE options_market_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() + _notify_mkt( + send_wechat, + title="【OKX期权·市价单结束】", + order=order, + account_label=account_label, + extra=[f"原因:{end_reason}", f"说明:{message}"], + ) + + +def run_market_orders_tick( + conn_or_get_db: Any, + cfg: dict[str, Any], + *, + send_wechat: Any = None, + account_label: str = "", +) -> None: + """持仓消失时结束市价单;应用延迟出场.""" + get_db = conn_or_get_db if callable(conn_or_get_db) else None + if get_db is None: + conn = conn_or_get_db + ensure_market_order_table(conn) + ex = cfg.get("exchange_options") + if ex is None: + return + for order in list_market_orders(conn, active_only=True): + try: + _tick_one_market( + conn, + cfg, + ex, + order, + send_wechat=send_wechat, + account_label=account_label, + ) + except Exception as e: + try: + conn.execute( + "UPDATE options_market_orders SET message=?, updated_at=? WHERE hub_order_id=?", + (f"监控异常: {e}"[:200], _now(), order["hub_order_id"]), + ) + conn.commit() + except Exception: + pass + return + + conn = get_db() + orders: list[dict[str, Any]] = [] + ex: Any = None + try: + ensure_market_order_table(conn) + ex = cfg.get("exchange_options") + if ex is not None: + orders = list_market_orders(conn, active_only=True) + try: + conn.commit() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + if ex is None or not orders: + return + for order in orders: + conn = get_db() + try: + _tick_one_market( + conn, + cfg, + ex, + order, + send_wechat=send_wechat, + account_label=account_label, + ) + try: + conn.commit() + except Exception: + pass + except Exception as e: + try: + conn.execute( + "UPDATE options_market_orders SET message=?, updated_at=? WHERE hub_order_id=?", + (f"监控异常: {e}"[:200], _now(), order["hub_order_id"]), + ) + conn.commit() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + + +def _tick_one_market( + conn: sqlite3.Connection, + cfg: dict[str, Any], + ex: Any, + order: dict[str, Any], + *, + send_wechat: Any, + account_label: str, +) -> None: + from lib.options.options_cond_order_lib import ( + _apply_exit_to_position, + _exit_close_in_progress, + machine_has_option_position, + ) + + if str(order.get("status") or "") != "open": + return + + 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_market_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 machine_has_option_position(cfg, ex): + return + 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, + ) diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 246d8d0..8cb39c2 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1546,6 +1546,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: def _cond_order_tick(_conn=None) -> None: from lib.options.options_cond_order_lib import run_cond_orders_tick + from lib.options.options_market_order_lib import run_market_orders_tick # 传 get_db:条件单开仓前会释放连接,避免嵌套锁库 run_cond_orders_tick( @@ -1554,6 +1555,12 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: send_wechat=cfg.get("send_wechat"), account_label=str(cfg.get("account_label") or ""), ) + run_market_orders_tick( + cfg["get_db"], + cfg, + send_wechat=cfg.get("send_wechat"), + account_label=str(cfg.get("account_label") or ""), + ) t = threading.Thread( target=options_monitor_loop, diff --git a/tests/test_market_order_leverage_pick.py b/tests/test_market_order_leverage_pick.py new file mode 100644 index 0000000..a12a609 --- /dev/null +++ b/tests/test_market_order_leverage_pick.py @@ -0,0 +1,138 @@ +"""市价单选约:第一档实值内按杠杆接近度优选.""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + + +class TestMarketOrderLeveragePick(unittest.TestCase): + def test_picks_closest_leverage_on_first_itm_rung(self): + from lib.options.options_cond_order_lib import pick_nearest_itm_contract + + # 指数 3000, Put 实值一档=3100;同档不同 ask → 杠杆≈1/ask + chain = { + "expiries": [ + { + "exp_time": 9_999_999_999_000, + "contracts": [ + { + "inst_id": "ETH-USD-3100-P-far", + "strike": 3100, + "opt_type": "P", + "ask": 0.05, + "ask_sz": 10, + "ct_mult": 0.1, + }, + { + "inst_id": "ETH-USD-3100-P-near-lev", + "strike": 3100, + "opt_type": "P", + "ask": 0.1, + "ask_sz": 10, + "ct_mult": 0.1, + }, + { + "inst_id": "ETH-USD-3200-P-deeper", + "strike": 3200, + "opt_type": "P", + "ask": 0.1, + "ask_sz": 10, + "ct_mult": 0.1, + }, + ], + } + ] + } + + def quote(_ex, inst_id): + for c in chain["expiries"][0]["contracts"]: + if c["inst_id"] == inst_id: + return { + "ok": True, + "ask": c["ask"], + "ask_sz": c["ask_sz"], + "ct_mult": c["ct_mult"], + } + return {"ok": False} + + cfg = {"quote_option_contract": quote} + with patch( + "lib.exchange.okx_options_lib.build_option_chain", return_value=chain + ), patch( + "lib.exchange.okx_options_lib.option_buy_liquidity_ok", + return_value=(True, ""), + ): + # 目标杠杆 10 → 1/0.1=10, 应选 near-lev 而非 far(杠杆20) + picked = pick_nearest_itm_contract( + cfg, + MagicMock(), + underlying="ETH", + opt_type="P", + index_px=3000.0, + min_expiry_hours=1.0, + target_leverage=10.0, + ) + self.assertIsNotNone(picked) + self.assertEqual(picked["inst_id"], "ETH-USD-3100-P-near-lev") + self.assertAlmostEqual(float(picked.get("picked_leverage") or 0), 10.0, places=1) + + def test_without_leverage_keeps_nearest_itm(self): + from lib.options.options_cond_order_lib import pick_nearest_itm_contract + + chain = { + "expiries": [ + { + "exp_time": 9_999_999_999_000, + "contracts": [ + { + "inst_id": "ETH-USD-3100-P", + "strike": 3100, + "opt_type": "P", + "ask": 0.1, + "ask_sz": 10, + "ct_mult": 0.1, + }, + { + "inst_id": "ETH-USD-3200-P", + "strike": 3200, + "opt_type": "P", + "ask": 0.05, + "ask_sz": 10, + "ct_mult": 0.1, + }, + ], + } + ] + } + + def quote(_ex, inst_id): + for c in chain["expiries"][0]["contracts"]: + if c["inst_id"] == inst_id: + return { + "ok": True, + "ask": c["ask"], + "ask_sz": c["ask_sz"], + "ct_mult": c["ct_mult"], + } + return {"ok": False} + + cfg = {"quote_option_contract": quote} + with patch( + "lib.exchange.okx_options_lib.build_option_chain", return_value=chain + ), patch( + "lib.exchange.okx_options_lib.option_buy_liquidity_ok", + return_value=(True, ""), + ): + picked = pick_nearest_itm_contract( + cfg, + MagicMock(), + underlying="ETH", + opt_type="P", + index_px=3000.0, + min_expiry_hours=1.0, + ) + self.assertEqual(picked["inst_id"], "ETH-USD-3100-P") + + +if __name__ == "__main__": + unittest.main()