From 4b4dca9e3cf20ef863c0b82a151ebb3b26acd0ef Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 09:56:18 +0800 Subject: [PATCH] Fix strategy-logic P0s from audit: monitor false-flat, fill-confirmed open/close, mode gates. Co-authored-by: Cursor --- docs/审计修复报告-策略逻辑-2026-07-30.md | 80 +++++++ lib/env/env_ui_manifest.py | 13 ++ lib/hedge_plan/hedge_options_exclusive_lib.py | 6 +- lib/hedge_plan/hedge_plan_db.py | 20 ++ lib/hedge_plan/hedge_plan_monitor_lib.py | 219 +++++++++++++----- lib/hedge_plan/hedge_plan_orders_lib.py | 138 +++++++++-- lib/hedge_plan/hedge_plan_register.py | 131 ++++++----- .../templates/hedge_plan_panel.html | 14 +- lib/instance/instance_dashboard_lib.py | 3 +- lib/options/options_close_exec_lib.py | 27 ++- lib/options/options_register.py | 157 +++++++++++-- lib/options/options_review_register.py | 26 ++- lib/options/options_target_lib.py | 27 +++ tests/test_hedge_plan_orders.py | 25 +- tests/test_hedge_po_monitor_safety.py | 101 ++++++++ 15 files changed, 820 insertions(+), 167 deletions(-) create mode 100644 docs/审计修复报告-策略逻辑-2026-07-30.md create mode 100644 tests/test_hedge_po_monitor_safety.py diff --git a/docs/审计修复报告-策略逻辑-2026-07-30.md b/docs/审计修复报告-策略逻辑-2026-07-30.md new file mode 100644 index 0000000..8d1987e --- /dev/null +++ b/docs/审计修复报告-策略逻辑-2026-07-30.md @@ -0,0 +1,80 @@ +# 策略与逻辑审计修复报告 + +- 日期: 2026-07-30 +- 范围: OKX 三选一模式 / 永期·期期对冲监控与开平 / 单独期权开平 / 互斥与复盘钳制 +- 准则: 以资金与仓位正确性为准(假平仓、未成交落库、跨模式拆组等) +- 复审: 共 3 轮深度复审;最终 **剩余 P0 = 0** + +## 修复总览 + +| 轮次 | 结果 | +|------|------| +| 初审 | 约 10 项 P0 + 多项 P1(监控假平、落库≠成交、模式互斥缺口等) | +| 复审 1 | 18/20 已修;发现 SL 待平可误判 TP、监控可重复启动、单独开仓仍可缩量 | +| 复审 2 | 上述 3 项已修;剩余若干 P1 | +| 复审 3 | P1 再收口(互斥/目标监控 fail-closed、already_flat 二次验仓、监控启动锁);**P0 清零** | + +## 已修复关键项(原审计编号) + +### 永期监控 / 对冲平仓 +- **H1** `live is None` 不再当已平;仅 `live==0` 且过开仓宽限期后处理 +- **H2** 止损强平失败不写 `closed`,写 `perp_sl_pending_opt` 并重试 +- **H3** TP/SL 分类:歧义偏 SL;未知跳过;`*_pending_opt` 粘滞不再被 mark 反弹改判 +- **H4** `_sell_option` 改为 `close_option_by_bid1`,要求 `fully_closed`;`already_flat` 二次验仓 +- **H7** `partial` 计划纳入监控 +- **H5** IOC 部分成交后尝试立刻平掉孤儿仓 +- **H6** `/start` 进程内锁 + 闸门重检 +- **H8** 服务端校验 long↔Put / short↔Call 与 TP/SL 几何 +- 卖一深度不足时拒绝缩量成交(对冲买入) + +### 单独期权 +- **O1** 开仓 IOC + `wait_option_order_full_fill`,成交后再落 `open` +- **O2** 平仓后持仓 `None` 不标 `fully_closed` +- **O3** 禁止期权页 close/target 拆对冲腿;目标监控跳过托管合约 +- **O4** 模式/互斥校验异常 fail-closed +- 开仓拒绝卖一深度不足时的静默缩量 +- stub 买一路径不再撤掉他人挂单;门控通过改在下单接受后标记 + +### 三选一模式 / UI +- **M1** Jinja 去掉 `| default(true)`,避免 `False` 显示成 Tab +- **M2** 监控线程始终启动(单独期权也收口遗留计划)+ 单例锁 +- **M3** env 展示 `OKX_TRADE_MODE` 与 `get_okx_trade_mode()` 一致 +- 仪表盘始终展示进行中对冲;`complete-leg` 校验当前模式 +- 复盘 API 按模式钳制 `source_type` + +## 测试 + +```text +python -m unittest tests.test_hedge_po_monitor_safety tests.test_hedge_plan_orders \ + tests.test_okx_trade_mode tests.test_hedge_options_exclusive \ + tests.test_hedge_plan_end tests.test_hedge_partial_manual -v +→ OK (35) +``` + +新增: `tests/test_hedge_po_monitor_safety.py`(含 None 跳过、分类、SL sticky) + +## 残留非关键项(P1,可后续迭代) + +1. 连续两次持仓列表均为空时,仍可能把「短暂漏仓」当成已平(对冲路径已有二次验仓;目标/手动路径仍单次) +2. 部分成交后若孤儿平仓也失败,需人工处理(已返回 `orphan_close`) +3. 期权页对托管腿仍可能显示按钮,但 API 已拒绝 + +## 主要改动文件 + +- `lib/hedge_plan/hedge_plan_monitor_lib.py` +- `lib/hedge_plan/hedge_plan_orders_lib.py` +- `lib/hedge_plan/hedge_plan_register.py` +- `lib/hedge_plan/hedge_plan_db.py` +- `lib/hedge_plan/hedge_options_exclusive_lib.py` +- `lib/hedge_plan/templates/hedge_plan_panel.html` +- `lib/options/options_close_exec_lib.py` +- `lib/options/options_register.py` +- `lib/options/options_target_lib.py` +- `lib/options/options_review_register.py` +- `lib/env/env_ui_manifest.py` +- `lib/instance/instance_dashboard_lib.py` +- `tests/test_hedge_po_monitor_safety.py` 等 + +## 部署 + +见本轮 commit + `zk.hyf2.cc` `deploy/pull_and_restart.sh` 结果。 diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index b8618c9..ad358b0 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -250,6 +250,19 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: + if key == "OKX_TRADE_MODE": + # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式 + file_val = str(file_values.get(key) or "").strip() if key in file_values else "" + if file_val: + from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode + + return normalize_okx_trade_mode(file_val) or file_val + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + return get_okx_trade_mode() + except Exception: + pass if key in file_values: file_val = str(file_values.get(key) or "").strip() if file_val: diff --git a/lib/hedge_plan/hedge_options_exclusive_lib.py b/lib/hedge_plan/hedge_options_exclusive_lib.py index c983d3b..162af36 100644 --- a/lib/hedge_plan/hedge_options_exclusive_lib.py +++ b/lib/hedge_plan/hedge_options_exclusive_lib.py @@ -31,7 +31,7 @@ def block_standalone_option_open_msg(conn: Any) -> Optional[str]: if count_active_plans(conn) > 0: return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)" except Exception: - return None + return "互斥门控校验失败,暂禁止单独开期权" return None @@ -77,10 +77,10 @@ def block_hedge_plan_start_msg( try: rows = fetch_positions(exchange) or [] except Exception: - return None + return "获取期权持仓失败,暂禁止启动对冲计划" try: if has_standalone_option_position(conn, rows): return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)" except Exception: - return None + return "互斥门控校验失败,暂禁止启动对冲计划" return None diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 58670f9..21adc28 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -272,6 +272,26 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s return out +def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]: + """进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组.""" + rows = conn.execute( + """ + SELECT DISTINCT l.inst_id + FROM hedge_plan_legs l + JOIN hedge_plans p ON p.id = l.plan_id + WHERE p.status IN ('opening', 'active', 'partial') + AND l.status IN ('open', 'hold_to_expiry') + AND l.inst_id IS NOT NULL + AND TRIM(l.inst_id) != '' + AND ( + l.leg_role LIKE 'option%' + OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '') + ) + """ + ).fetchall() + return {str(r[0]).strip() for r in rows if r and r[0]} + + def _sf(v: Any) -> Optional[float]: try: if v is None or v == "": diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 5e5bbf0..353df7a 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -66,8 +66,63 @@ def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: return None +def _plan_open_grace_sec() -> float: + try: + return max(0.0, float(os.getenv("HEDGE_PLAN_OPEN_GRACE_SEC") or "90")) + except (TypeError, ValueError): + return 90.0 + + +def _within_open_grace(plan: dict[str, Any]) -> bool: + """开仓后宽限期:仓位尚未同步到交易所时禁止按「已平」收口.""" + grace = _plan_open_grace_sec() + if grace <= 0: + return False + raw = str(plan.get("opened_at") or plan.get("created_at") or "").strip() + if not raw: + return True + try: + # "YYYY-MM-DD HH:MM:SS" 本地墙钟 + opened = datetime.strptime(raw[:19], "%Y-%m-%d %H:%M:%S") + age = (datetime.now() - opened).total_seconds() + return age < grace + except Exception: + return True + + +def _classify_po_flat_reason( + *, + direction: str, + entry: float, + mark: Optional[float], + tp: Optional[float], + sl: Optional[float], +) -> str: + """永续已平时分类 TP/SL.歧义时偏 SL(触发强平期权),避免误判 TP 跳过强平.""" + d = (direction or "long").lower() + if mark is None or not entry: + return "perp_flat_unknown" + if sl is not None: + if d == "long" and mark <= sl: + return "perp_sl" + if d == "short" and mark >= sl: + return "perp_sl" + if tp is not None: + if d == "long" and mark >= tp: + return "perp_tp" + if d == "short" and mark <= tp: + return "perp_tp" + if sl is not None and tp is not None: + return "perp_sl" if abs(mark - sl) <= abs(mark - tp) else "perp_tp" + if sl is not None: + return "perp_sl" + if tp is not None: + return "perp_tp" + return "perp_flat_unknown" + + def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: - """扫描 active 计划 + 止盈后遗留期权到期收口.返回处理摘要.""" + """扫描 active/partial 计划 + 止盈后遗留期权到期收口.返回处理摘要.""" get_db = cfg.get("get_db") if not callable(get_db): return {"ok": False, "msg": "get_db missing"} @@ -79,7 +134,14 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: init_hedge_plan_tables(conn) plans = list_plans(conn, status="active", limit=40) + # partial:裸永续/半腿也需侦测永续 TP/SL + plans.extend(list_plans(conn, status="partial", limit=20)) + seen: set[int] = set() for plan in plans: + pid = int(plan.get("id") or 0) + if pid in seen: + continue + seen.add(pid) r = _tick_one(cfg, conn, plan) if r: acted.append(r) @@ -184,10 +246,16 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di symbol = perp.get("symbol") or "" direction = (plan.get("direction") or "long").lower() live = _perp_live_contracts(cfg, symbol, direction) - # 仍有仓 → 未触达交易所 TP/SL - if live is not None and live > 0: + # API 失败 / 未注入 → 本轮跳过,绝不当「已平」 + if live is None: return None - # 仓已平:用标记/最新粗判 TP or SL + # 仍有仓 → 未触达交易所 TP/SL + if live > 0: + return None + # 开仓后宽限期:仓位同步延迟可误读为 0 + if _within_open_grace(plan): + return None + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 tp = _sf(plan.get("tp")) sl = _sf(plan.get("sl")) @@ -199,17 +267,19 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) except Exception: mark = None - reason = "perp_tp" - if mark is not None and sl is not None and entry: - if direction == "long" and mark <= sl: - reason = "perp_sl" - elif direction == "short" and mark >= sl: - reason = "perp_sl" - elif tp is not None: - if direction == "long" and mark >= tp: - reason = "perp_tp" - elif direction == "short" and mark <= tp: - reason = "perp_tp" + reason = _classify_po_flat_reason( + direction=direction, entry=float(entry or 0), mark=mark, tp=tp, sl=sl + ) + # 上一轮止损强平未完成:粘滞为 SL,避免 mark 反弹误判 TP 跳过强平 + pending_reason = str(plan.get("close_reason") or "") + if pending_reason == "perp_sl_pending_opt": + reason = "perp_sl" + elif pending_reason == "perp_tp_pending_opt": + reason = "perp_tp" + # 未明确 TP/SL 时不收口,下轮再判 + if reason == "perp_flat_unknown": + return None + premium = float(plan.get("premium_total") or 0) cs = float(cfg.get("default_contract_size") or 0.01) get_cs = cfg.get("get_contract_size") @@ -227,66 +297,107 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di perp_pnl = (exit_px - entry) * coins opt_pnl = -premium - if reason == "perp_sl" and opt and _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True): - close_r = _sell_option( - cfg, - inst_id=str(opt.get("inst_id") or ""), - sheets=float(opt.get("size") or 1), - ) - if not close_r.get("ok"): - notify_hedge( + if reason == "perp_sl" and opt and str(opt.get("status") or "") == "open": + if _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True): + close_r = _sell_option( cfg, - build_hedge_alert_message( - title="永续止损后期权强制平仓失败", - plan_id=plan.get("id"), - detail=str(close_r.get("msg") or close_r), - ), + inst_id=str(opt.get("inst_id") or ""), + sheets=float(opt.get("size") or 1), ) - if close_r.get("ok"): - bid = _sf(close_r.get("bid")) - ask_open = _sf(opt.get("avg_open")) - if bid is not None and ask_open is not None: - ct = float(opt.get("ct_mult") or 0.01) - est = (bid - ask_open) * float(opt.get("size") or 1) * ct - else: - est = -premium - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", reason, _now(), opt_pnl, opt["id"]), - ) - elif reason == "perp_tp" and opt: - if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False): - close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1)) - if not close_r.get("ok"): + if close_r.get("already_flat"): + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + elif not close_r.get("ok") or not close_r.get("fully_closed", True): notify_hedge( cfg, build_hedge_alert_message( - title="永续止盈后期权平仓失败", + title="永续止损后期权强制平仓失败(将重试)", plan_id=plan.get("id"), detail=str(close_r.get("msg") or close_r), ), ) + update_plan(conn, int(plan["id"]), close_reason="perp_sl_pending_opt") + return { + "plan_id": plan["id"], + "msg": "止损后期权未平完", + "close": close_r, + "retry": True, + } + else: + bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px")) + ask_open = _sf(opt.get("avg_open")) + if bid is not None and ask_open is not None: + ct = float(opt.get("ct_mult") or 0.01) + est = (bid - ask_open) * float(opt.get("size") or 1) * ct + else: + est = -premium + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + else: conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=? WHERE id=?", - ("closed", reason, _now(), opt["id"]), + "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", + ("hold_to_expiry", "orphaned_after_sl", opt["id"]), ) + opt_pnl = -premium + elif reason == "perp_tp" and opt and str(opt.get("status") or "") == "open": + if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False): + close_r = _sell_option( + cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1) + ) + if close_r.get("already_flat"): + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) + elif not close_r.get("ok") or not close_r.get("fully_closed", True): + notify_hedge( + cfg, + build_hedge_alert_message( + title="永续止盈后期权平仓失败(将重试)", + plan_id=plan.get("id"), + detail=str(close_r.get("msg") or close_r), + ), + ) + update_plan(conn, int(plan["id"]), close_reason="perp_tp_pending_opt") + return { + "plan_id": plan["id"], + "msg": "止盈后期权未平完", + "close": close_r, + "retry": True, + } + else: + bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px")) + ask_open = _sf(opt.get("avg_open")) + if bid is not None and ask_open is not None: + ct = float(opt.get("ct_mult") or 0.01) + est = (bid - ask_open) * float(opt.get("size") or 1) * ct + else: + est = -premium + opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), opt_pnl, opt["id"]), + ) else: conn.execute( "UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?", ("hold_to_expiry", "orphaned_after_tp", opt["id"]), ) - opt_pnl = -premium - - if reason == "perp_tp": - total = perp_pnl + opt_pnl - else: - total = opt_pnl + perp_pnl + opt_pnl = -premium + total = perp_pnl + opt_pnl conn.execute( "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", ("closed", reason, _now(), perp_pnl, perp["id"]), ) + # partial → closed 也走同一收口 update_plan( conn, int(plan["id"]), diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 969aa5e..63cf389 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -135,10 +135,20 @@ def _buy_option( if pos_limit_msg: return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False} sheets_i = max(1, int(round(float(sheets)))) + requested_sheets = sheets_i capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) if capped is None: return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} - sheets_i = capped + if int(capped) < requested_sheets: + return { + "ok": False, + "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交", + "quote": q, + "can_open": False, + "requested_sheets": requested_sheets, + "ask_sz": ask_sz, + } + sheets_i = int(capped) ct_mult = float(q.get("ct_mult") or 0.01) premium = float(ask) * sheets_i * ct_mult if dry_run: @@ -188,6 +198,14 @@ def _buy_option( cancel_on_timeout=True, ) if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + orphan_close = None + if filled_n > 0 and not dry_run: + # 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头 + try: + orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n)) + except Exception as e: + orphan_close = {"ok": False, "msg": str(e)} return { "ok": False, "msg": fill.get("msg") or "未完全成交,开仓失败", @@ -195,7 +213,8 @@ def _buy_option( "sheets": sheets_i, "ask": float(ask), "exchange_ord_id": ord_id, - "filled_sheets": fill.get("filled_sheets"), + "filled_sheets": filled_n, + "orphan_close": orphan_close, "order": order, "fill": fill, "can_open": False, @@ -289,9 +308,17 @@ def _sell_option( sheets: float, dry_run: bool = False, ) -> dict[str, Any]: + """平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功. + + 对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False). + """ + from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions + from lib.options.options_close_exec_lib import close_option_by_bid1 + ex = cfg.get("exchange_options") quote_fn = cfg.get("quote_option_contract") - place_fn = cfg.get("place_option_limit_order") + if not inst_id: + return {"ok": False, "msg": "缺少期权合约"} if not callable(quote_fn) or ex is None: return {"ok": False, "msg": "期权报价能力未就绪"} q = quote_fn(ex, inst_id) @@ -300,20 +327,77 @@ def _sell_option( return {"ok": False, "msg": "暂无买一价,无法平期权"} sheets_i = max(1, int(round(float(sheets)))) if dry_run: - return {"ok": True, "dry_run": True, "inst_id": inst_id, "sheets": sheets_i, "bid": float(bid)} - if not callable(place_fn): + return { + "ok": True, + "dry_run": True, + "inst_id": inst_id, + "sheets": sheets_i, + "bid": float(bid), + "fully_closed": True, + } + if not callable(cfg.get("place_option_limit_order")): return {"ok": False, "msg": "期权平仓未注入"} - order = place_fn( + close_cfg = dict(cfg) + if not callable(close_cfg.get("fetch_option_positions")): + close_cfg["fetch_option_positions"] = fetch_option_positions + if not callable(close_cfg.get("fetch_option_book_depth")): + close_cfg["fetch_option_book_depth"] = fetch_option_book_depth + if "td_mode" not in close_cfg: + close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated" + result = close_option_by_bid1( + close_cfg, ex, - inst_id=inst_id, - side="sell", + inst_id, sheets=sheets_i, - price=float(bid), - td_mode="isolated", - tick_sz=q.get("tick_sz"), - reduce_only=True, + require_recycle_gate=False, ) - return order if order.get("ok") else order + out = dict(result or {}) + if out.get("already_flat"): + # 二次验仓,避免一次空列表误判已平 + import time as _time + + _time.sleep(0.35) + try: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + except Exception: + pass + rows2 = close_cfg["fetch_option_positions"](ex) + if rows2 is None: + return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False} + still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None) + still_sz = 0.0 + if still is not None: + try: + still_sz = abs(float(still.get("availPos") or still.get("pos") or 0)) + except (TypeError, ValueError): + still_sz = 0.0 + if still is not None and still_sz >= 1: + return { + "ok": False, + "msg": "二次验仓仍有持仓,拒绝 already_flat", + "fully_closed": False, + } + out["ok"] = True + out["fully_closed"] = True + out.setdefault("bid", float(bid)) + return out + if not out.get("ok"): + out.setdefault("bid", float(bid)) + return out + if not out.get("fully_closed"): + return { + "ok": False, + "msg": out.get("msg") or "期权尚未完全平仓,将下轮重试", + "bid": out.get("locked_bid_px") or float(bid), + "fully_closed": False, + "partial": True, + "close": out, + } + out["bid"] = out.get("locked_bid_px") or float(bid) + out["fully_closed"] = True + return out def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None: @@ -853,10 +937,34 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: try: if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: return "张数必须大于 0" - if float(body["tp"]) <= 0 or float(body["sl"]) <= 0: - return "止盈/止损无效" + entry = float(body["entry"]) + tp = float(body["tp"]) + sl = float(body["sl"]) + if tp <= 0 or sl <= 0 or entry <= 0: + return "止盈/止损/入场无效" except (TypeError, ValueError): return "数值字段无效" + direction = str(body.get("direction") or "").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long 或 short" + opt_type = str(body.get("opt_type") or "").strip().upper() + if not opt_type: + # 允许从合约名推断 ETH-USD-...-P / -C + inst = str(body.get("opt_inst_id") or "") + if inst.upper().endswith("-P"): + opt_type = "P" + elif inst.upper().endswith("-C"): + opt_type = "C" + if opt_type not in ("P", "C"): + return "缺少期权类型(Put/Call)" + if direction == "long" and opt_type != "P": + return "做多永期对冲须用 Put" + if direction == "short" and opt_type != "C": + return "做空永期对冲须用 Call" + if direction == "long" and not (sl < entry < tp): + return "做多须满足 止损 < 入场 < 止盈" + if direction == "short" and not (tp < entry < sl): + return "做空须满足 止盈 < 入场 < 止损" return None if pt == "options_options": a = body.get("leg_a") or {} diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 139eb17..3989bf7 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -186,13 +186,14 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: raw = fetch_option_positions(ex) if ex is not None else [] has_standalone = has_standalone_option_position(conn, raw or []) except Exception: - has_standalone = False + has_standalone = True # fail-closed conn.commit() finally: conn.close() except Exception: - active = 0 - has_standalone = False + # fail-closed:探测失败视为不可开仓 + active = 10**9 + has_standalone = True return gate_status( hedge_enabled=_hedge_enabled(), sizing_mode=load_position_sizing_mode(), @@ -220,31 +221,45 @@ def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: def _maybe_start_monitor(cfg: dict[str, Any]) -> None: - if not _hedge_enabled(): - return - try: - secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") - except ValueError: - secs = 15.0 - secs = max(5.0, secs) + # 始终启动监控线程:单独期权模式下仍需收口遗留 active/partial 计划 + with _hedge_start_lock(): + if cfg.get("hedge_monitor_thread") is not None: + return + try: + secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") + except ValueError: + secs = 15.0 + secs = max(5.0, secs) - def _loop() -> None: - import time + def _loop() -> None: + import time - from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans + from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans - while True: - try: - tick_active_plans(cfg) - except Exception: - pass - time.sleep(secs) + while True: + try: + tick_active_plans(cfg) + except Exception: + pass + time.sleep(secs) - import threading + import threading - t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) - t.start() - cfg["hedge_monitor_thread"] = t + t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) + t.start() + cfg["hedge_monitor_thread"] = t + + +_start_lock = None + + +def _hedge_start_lock(): + global _start_lock + if _start_lock is None: + import threading + + _start_lock = threading.Lock() + return _start_lock def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str: @@ -566,37 +581,38 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: body = request.get_json(silent=True) or {} plan_type = (body.get("plan_type") or "perp_options").strip().lower() dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) - gates = _gates_dict(cfg, plan_type) - if not dry_run and not gates.get("can_start"): - return jsonify( - {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} - ), 400 - err = validate_start_body(plan_type, body) - if err: - return jsonify({"ok": False, "msg": err, "gates": gates}), 400 - # 补齐永续杠杆 - if plan_type == "perp_options" and not body.get("leverage"): - base = str(body.get("underlying") or "ETH").upper() - body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10) - # ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH - if base in ("BTC", "ETH"): - body["leverage"] = int(cfg.get("btc_leverage") or 10) - if plan_type == "options_options": - out = execute_options_options_start( - cfg, - body, - dry_run=dry_run, - persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), - ) - else: - out = execute_perp_options_start( - cfg, - body, - dry_run=dry_run, - persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), - ) - out["gates"] = gates - return jsonify(out), (200 if out.get("ok") else 400) + with _hedge_start_lock(): + gates = _gates_dict(cfg, plan_type) + if not dry_run and not gates.get("can_start"): + return jsonify( + {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} + ), 400 + err = validate_start_body(plan_type, body) + if err: + return jsonify({"ok": False, "msg": err, "gates": gates}), 400 + # 补齐永续杠杆 + if plan_type == "perp_options" and not body.get("leverage"): + base = str(body.get("underlying") or "ETH").upper() + body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10) + # ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH + if base in ("BTC", "ETH"): + body["leverage"] = int(cfg.get("btc_leverage") or 10) + if plan_type == "options_options": + out = execute_options_options_start( + cfg, + body, + dry_run=dry_run, + persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), + ) + else: + out = execute_perp_options_start( + cfg, + body, + dry_run=dry_run, + persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), + ) + out["gates"] = gates + return jsonify(out), (200 if out.get("ok") else 400) @app.route("/api/hedge-plan//end", methods=["POST"]) @lr @@ -634,12 +650,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: body = request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) + if not dry_run and not _hedge_enabled(): + return jsonify({"ok": False, "msg": "当前交易模式为单独期权,不可补开对冲腿"}), 400 conn = cfg["get_db"]() try: init_hedge_plan_tables(conn) plan = get_plan(conn, plan_id) if not plan: return jsonify({"ok": False, "msg": "计划不存在"}), 404 + pt = str(plan.get("plan_type") or "") + if pt == "perp_options" and not _show_perp_options(): + return jsonify({"ok": False, "msg": "当前模式非永期对冲,不可补开"}), 400 + if pt == "options_options" and not _show_options_options(): + return jsonify({"ok": False, "msg": "当前模式非期期对冲,不可补开"}), 400 if str(plan.get("status") or "") != "partial": return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400 legs = get_plan_legs(conn, plan_id) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 8db6a36..82db747 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -2,9 +2,9 @@ data-default-underly="{{ options_default_underly | default('ETH') }}" data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}" data-options-enabled="{{ '1' if options_enabled else '0' }}" - data-show-perp="{{ '1' if hedge_plan_show_perp_options | default(true) else '0' }}" - data-show-oo="{{ '1' if hedge_plan_show_options_options | default(true) else '0' }}" - data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled | default(true) else '0' }}" + data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}" + data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}" + data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}" data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}" data-sizing-mode="{{ position_sizing_mode | default('risk') }}" data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}"> @@ -14,8 +14,8 @@ {% if not options_enabled %}
期权模块未启用,无法拉期权链.请先配置期权账户.
{% endif %} - {% if hedge_plan_enabled and not (hedge_plan_show_perp_options | default(true)) and not (hedge_plan_show_options_options | default(true)) %} -
永期与期期 Tab 均已隐藏:可在 env配置 → 对冲计划 打开显示开关;进行中/历史仍可查看.
+ {% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %} +
永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.
{% endif %}
@@ -26,10 +26,10 @@
- {% if hedge_plan_show_perp_options | default(true) %} + {% if hedge_plan_show_perp_options %} {% endif %} - {% if hedge_plan_show_options_options | default(true) %} + {% if hedge_plan_show_options_options %} {% endif %} diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 4cdb2e1..390e442 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -402,7 +402,8 @@ def build_instance_dashboard_payload( rolls = collect_rolls(conn) strategy_items = trends + rolls options_items = collect_options_items(fetch_options_positions, conn=conn) - hedge_items = collect_hedge_plans(conn) if hedge_enabled else [] + hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关 + # hedge_enabled 仅影响「新建」入口,不隐藏已有仓 now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") return { "ok": True, diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py index 59e2cd0..7fbde32 100644 --- a/lib/options/options_close_exec_lib.py +++ b/lib/options/options_close_exec_lib.py @@ -187,7 +187,7 @@ def close_option_by_bid1( max_levels=1, ) if preview.get("bid_invalid") or preview.get("auto_close_blocked"): - _cancel_sell_pending(ex, inst_id) + # 不撤他人挂单:仅拒绝本轮下单 update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) return { "ok": False, @@ -248,8 +248,6 @@ def close_option_by_bid1( "auto_close_blocked": True, "close_gate": gate, } - if gate.get("ready"): - mark_close_gate_passed(inst_id) locked_bid_px = level_px before_avail = avail @@ -272,6 +270,9 @@ def close_option_by_bid1( "locked_bid_px": locked_bid_px, "batch_sheets": level_sheets, } + # 仅下单被接受后才记门控已通过,避免下单失败却跳过后续 2× 等待 + if require_recycle_gate and gate.get("ready"): + mark_close_gate_passed(inst_id) px = float(order.get("px", locked_bid_px)) oid = str((order.get("data") or {}).get("ordId") or "") @@ -279,12 +280,20 @@ def close_option_by_bid1( time.sleep(0.6) invalidate_option_positions_cache() raw2 = cfg["fetch_option_positions"](ex) - after_avail = 0 - if raw2 is not None: - after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) - after_avail = _avail_sheets(after_pos) if after_pos else 0 - reduced = max(0, before_avail - after_avail) if raw2 is not None else 0 - remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets) + if raw2 is None: + return { + "ok": False, + "msg": "下单后获取持仓失败,未确认是否成交", + "stopped_reason": "position_fetch_failed", + "locked_bid_px": locked_bid_px, + "batch_sheets": level_sheets, + "close_ord_id": oid or None, + "fully_closed": False, + } + after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) + after_avail = _avail_sheets(after_pos) if after_pos else 0 + reduced = max(0, before_avail - after_avail) + remaining_pos = after_avail fully_closed = remaining_pos < 1 if fully_closed: diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 0a8edd8..9ad75fc 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -480,8 +480,44 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "budget_full_usdc": budget if mode == "budget_full" else None, } ) - except Exception: - pass + except Exception as e: + return jsonify( + { + "ok": False, + "can_open": False, + "msg": f"交易模式校验失败: {e}", + } + ) + try: + from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg + + conn_q = cfg["get_db"]() + try: + excl = block_standalone_option_open_msg(conn_q) + finally: + conn_q.close() + if excl: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": excl, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": excl, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + } + ) + except Exception as e: + return jsonify({"ok": False, "can_open": False, "msg": f"互斥校验失败: {e}"}) can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) if not can_open: # 合约可报价,但不可开仓:返回参考标记价供展示 @@ -598,8 +634,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mode_block = block_standalone_open_by_mode_msg() if mode_block: return jsonify({"ok": False, "msg": mode_block, "can_open": False}) - except Exception: - pass + except Exception as e: + return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) try: from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg @@ -610,8 +646,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn_gate.close() if block_msg: return jsonify({"ok": False, "msg": block_msg}) - except Exception: - pass + except Exception as e: + return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() mode = (data.get("mode") or "budget_full").strip() @@ -690,16 +726,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if capped is None: return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) if capped < sheets: - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=ct_mult, - min_sz=min_sz, - sheets=capped, - budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None, + return jsonify( + { + "ok": False, + "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交", + "requested_sheets": int(sheets), + "ask_sz": ask_sz, + } ) - if not sizing.get("ok"): - return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) - sheets = int(sizing["sheets"]) tick_sz = q.get("tick_sz") order = cfg["place_option_limit_order"]( ex, @@ -709,9 +743,56 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: price=float(ask), td_mode=td_mode_for_option_buy(cfg["td_mode"]), tick_sz=tick_sz, + ord_type="ioc", ) if not order.get("ok"): return jsonify(order) + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order}) + from lib.exchange.okx_options_lib import wait_option_order_full_fill + + try: + fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + fill_timeout = 12.0 + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=int(sheets), + timeout_sec=fill_timeout, + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + orphan_close = None + if filled_n > 0: + try: + from lib.options.options_close_exec_lib import close_option_by_bid1 + + orphan_close = close_option_by_bid1( + cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False + ) + except Exception as e: + orphan_close = {"ok": False, "msg": str(e)} + return jsonify( + { + "ok": False, + "msg": fill.get("msg") or "未完全成交,开仓失败", + "filled_sheets": filled_n, + "orphan_close": orphan_close, + "fill": fill, + "order": order, + } + ) + fill_px = float(fill.get("avg_px") or ask) + filled_n = int(fill.get("filled_sheets") or sheets) + sheets = filled_n + sizing = dict(sizing) + sizing["sheets"] = sheets + sizing["eth_amount"] = round(sheets * ct_mult, 8) + sizing["total_premium"] = round(fill_px * sheets * ct_mult, 4) conn = cfg["get_db"]() trade_id = None target_mon = None @@ -739,10 +820,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: str(q.get("exp_time") or ""), sheets, sizing["eth_amount"], - float(ask), + fill_px, sizing["total_premium"], signal_note, - (order.get("data") or {}).get("ordId"), + ord_id, ), ) trade_id = int(cur.lastrowid) @@ -778,7 +859,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: opt_type=open_opt_type, sheets=sheets, premium_paid=sizing.get("total_premium"), - open_quote=float(ask) if ask is not None else None, + open_quote=fill_px, target_index=target_index, signal_note=signal_note, ) @@ -937,6 +1018,26 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: inst_id = (data.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置目标", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) try: target_index = float(data.get("target_index")) except (TypeError, ValueError): @@ -1013,6 +1114,26 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: inst_id = (data.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页平仓", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) if data.get("market"): return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) sheets = data.get("sheets") diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py index 628c304..209a5d0 100644 --- a/lib/options/options_review_register.py +++ b/lib/options/options_review_register.py @@ -29,6 +29,28 @@ from lib.options.options_review_lib import ( ) +def _review_source_for_mode(requested: str | None) -> str | None: + """按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + mode = get_okx_trade_mode() + except Exception: + mode = "options" + allowed = { + "options": "option_spot", + "perp_options": "perp_options", + "options_options": "options_options", + }.get(mode, "option_spot") + req = (requested or "").strip() + if not req: + return allowed + if req == allowed: + return allowed + # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制 + return allowed + + def attach_options_review_templates(app: Flask, repo_root: str) -> None: tpl_dir = os.path.join(repo_root, "lib", "options", "templates") if not os.path.isdir(tpl_dir): @@ -138,7 +160,7 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s ensure_local_review_synced(conn, ex=ex if ex is not None else None) conn.commit() filt = dict( - source_type=(request.args.get("source_type") or "").strip() or None, + source_type=_review_source_for_mode(request.args.get("source_type")), underlying=(request.args.get("underlying") or "").strip() or None, opt_type=(request.args.get("opt_type") or "").strip() or None, strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, @@ -272,7 +294,7 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s conn.commit() stats = compute_review_stats( conn, - source_type=(request.args.get("source_type") or "").strip() or None, + source_type=_review_source_for_mode(request.args.get("source_type")), underlying=(request.args.get("underlying") or "").strip() or None, include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() in ("1", "true", "yes"), diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index c7631c1..ecbb8c6 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -373,6 +373,15 @@ def run_options_target_closes( 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} + hedge_managed: set[str] = set() + try: + from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables + + init_hedge_plan_tables(conn) + hedge_managed = active_hedge_option_inst_ids(conn) + except Exception: + # fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿 + return 0 cancel_orphans_without_position(conn, live_inst_ids=live_ids) _commit_monitor(conn) @@ -381,6 +390,15 @@ def run_options_target_closes( inst_id = str(mon.get("inst_id") or "") if not inst_id: continue + if inst_id in hedge_managed: + mark_monitor( + conn, + int(mon["id"]), + status="expired", + message="已移交对冲计划托管,跳过单独目标平仓", + ) + _commit_monitor(conn) + continue if inst_id not in pos_by_inst: mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") _commit_monitor(conn) @@ -414,6 +432,15 @@ def run_options_target_closes( target = _safe_float(mon.get("target_index")) if not inst_id or target is None: continue + if inst_id in hedge_managed: + mark_monitor( + conn, + int(mon["id"]), + status="expired", + message="已移交对冲计划托管,跳过单独目标平仓", + ) + _commit_monitor(conn) + continue pos = pos_by_inst.get(inst_id) if not pos: continue diff --git a/tests/test_hedge_plan_orders.py b/tests/test_hedge_plan_orders.py index 34c94db..6a8d875 100644 --- a/tests/test_hedge_plan_orders.py +++ b/tests/test_hedge_plan_orders.py @@ -50,12 +50,28 @@ class TestHedgePlanOrderPath(unittest.TestCase): "tp": 1900, "sl": 1700, "contracts": 1, - "opt_inst_id": "X", + "opt_inst_id": "ETH-USD-260731-1800-P", + "opt_type": "P", "sheets": 1, "exchange_symbol": "ETH/USDT:USDT", }, ) self.assertIsNone(ok) + bad_type = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "ETH-USD-260731-1800-C", + "opt_type": "C", + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNotNone(bad_type) def test_gate_can_start_when_live(self): g = gate_status( @@ -196,7 +212,7 @@ class TestHedgePlanOrderPath(unittest.TestCase): self.assertIn("卖一", out["msg"]) cfg["place_option_limit_order"].assert_not_called() - def test_buy_caps_sheets_to_ask_depth(self): + def test_buy_rejects_when_ask_depth_below_request(self): from lib.hedge_plan.hedge_plan_orders_lib import _buy_option quote = MagicMock( @@ -217,8 +233,9 @@ class TestHedgePlanOrderPath(unittest.TestCase): "td_mode_for_option_buy": lambda x: "isolated", } out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True) - self.assertTrue(out["ok"]) - self.assertEqual(out["sheets"], 2) + self.assertFalse(out["ok"]) + self.assertIn("不足请求", out["msg"]) + cfg["place_option_limit_order"].assert_not_called() if __name__ == "__main__": diff --git a/tests/test_hedge_po_monitor_safety.py b/tests/test_hedge_po_monitor_safety.py new file mode 100644 index 0000000..cfb1327 --- /dev/null +++ b/tests/test_hedge_po_monitor_safety.py @@ -0,0 +1,101 @@ +"""永期监控:假平仓防护与 TP/SL 分类.""" +import unittest +from unittest.mock import MagicMock, patch + +from lib.hedge_plan.hedge_plan_monitor_lib import ( + _classify_po_flat_reason, + _tick_po, + _within_open_grace, +) + + +class TestPoMonitorSafety(unittest.TestCase): + def test_classify_sl_before_tp(self): + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=1690, tp=1900, sl=1700 + ), + "perp_sl", + ) + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=1910, tp=1900, sl=1700 + ), + "perp_tp", + ) + + def test_classify_ambiguous_prefers_sl(self): + # mark 在中间:偏 SL,避免误判 TP 跳过强平期权 + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=1800, tp=1900, sl=1700 + ), + "perp_sl", + ) + + def test_classify_unknown_without_mark(self): + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=None, tp=1900, sl=1700 + ), + "perp_flat_unknown", + ) + + def test_tick_po_skips_when_live_none(self): + cfg = {"get_live_position_contracts": lambda *_a, **_k: None} + plan = {"id": 1, "direction": "long", "entry_mark": 1800, "tp": 1900, "sl": 1700} + legs = [{"leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1}] + self.assertIsNone(_tick_po(cfg, MagicMock(), plan, legs)) + + def test_tick_po_skips_when_live_positive(self): + cfg = {"get_live_position_contracts": lambda *_a, **_k: 2.0} + plan = {"id": 1, "direction": "long", "entry_mark": 1800, "tp": 1900, "sl": 1700} + legs = [{"leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1}] + self.assertIsNone(_tick_po(cfg, MagicMock(), plan, legs)) + + def test_within_grace_true_without_opened_at(self): + self.assertTrue(_within_open_grace({})) + + def test_pending_sl_sticky(self): + """perp_sl_pending_opt 粘滞,不因 mark 反弹改判 TP.""" + cfg = { + "get_live_position_contracts": lambda *_a, **_k: 0.0, + "get_contract_size": lambda *_a, **_k: 0.01, + "exchange": None, + } + plan = { + "id": 9, + "direction": "long", + "entry_mark": 1800, + "tp": 1900, + "sl": 1700, + "premium_total": 5, + "opened_at": "2000-01-01 00:00:00", + "close_reason": "perp_sl_pending_opt", + } + legs = [ + {"id": 1, "leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1, "avg_open": 1800}, + { + "id": 2, + "leg_role": "option_hedge", + "status": "open", + "inst_id": "ETH-USD-260731-1700-P", + "size": 1, + "avg_open": 10, + }, + ] + with patch("lib.hedge_plan.hedge_plan_monitor_lib._sell_option") as sell: + sell.return_value = {"ok": False, "msg": "thin book", "fully_closed": False} + with patch("lib.hedge_plan.hedge_plan_monitor_lib.notify_hedge"): + with patch("lib.hedge_plan.hedge_plan_monitor_lib.update_plan") as up: + conn = MagicMock() + out = _tick_po(cfg, conn, plan, legs) + self.assertTrue(out and out.get("retry")) + up.assert_called() + self.assertEqual(up.call_args.kwargs.get("close_reason"), "perp_sl_pending_opt") + self.assertNotEqual(up.call_args.kwargs.get("status"), "closed") + + +if __name__ == "__main__": + unittest.main() +