diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 072fdfd..e4fca51 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -1499,9 +1499,11 @@ def init_db(): from lib.strategy.strategy_db import init_strategy_tables from lib.options.options_db import init_options_tables + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables init_strategy_tables(conn) init_options_tables(conn) + init_hedge_plan_tables(conn) from lib.trade.account_risk_lib import ensure_account_risk_schema ensure_account_risk_schema(conn) diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 7fc1eac..7661335 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -135,7 +135,12 @@ if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; ")); el.textContent = parts.join(" · "); const start = $("hp-start-btn"); - if (start) start.disabled = !gates.can_start; + const startOo = $("hp-start-btn-oo"); + if ((gates.plan_type || state.mode) === "options_options") { + if (startOo) startOo.disabled = !gates.can_start; + } else { + if (start) start.disabled = !gates.can_start; + } } function setOptionsBalance(chain) { @@ -702,14 +707,13 @@ syncTabUI(); if (state.tab === "perp_options" || state.tab === "options_options") { void loadGates(); + } else if (state.tab === "history") { + void loadHistory(); + } else if (state.tab === "stats") { + void loadStats(); } else { const el = $("hp-gate-line"); - if (el) { - el.textContent = - state.tab === "history" - ? "历史记录:独立对冲表,与普通交易记录分离" - : "统计:止盈=盈利−保费;止损=期权盈利−永续亏损"; - } + if (el) el.textContent = ""; } }); }); @@ -768,6 +772,141 @@ state.mode = "options_options"; void runPreview(); }); + if ($("hp-start-btn")) + $("hp-start-btn").addEventListener("click", function () { + void startPlan("perp_options"); + }); + if ($("hp-start-btn-oo")) + $("hp-start-btn-oo").addEventListener("click", function () { + void startPlan("options_options"); + }); + } + + async function loadHistory() { + const tbody = $("hp-history-tbody"); + if (!tbody) return; + try { + const d = await apiJson("/api/hedge-plan/history"); + const rows = d.plans || []; + if (!rows.length) { + tbody.innerHTML = '暂无已结束计划'; + return; + } + tbody.innerHTML = ""; + rows.forEach(function (p) { + const tr = document.createElement("tr"); + tr.innerHTML = + "" + + p.id + + "" + + (p.plan_type === "perp_options" ? "永期" : "期期") + + "" + + (p.underlying || "") + + "" + + (p.status || "") + + "" + + fmt(p.realized_pnl_total) + + "" + + (p.close_reason || "—") + + "" + + (p.opened_at || "—") + + "" + + (p.closed_at || "—") + + ""; + tbody.appendChild(tr); + }); + } catch (e) { + tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + async function loadStats() { + const box = $("hp-stats-box"); + if (!box) return; + try { + const d = await apiJson("/api/hedge-plan/stats"); + const parts = [ + "活跃计划 " + (d.active || 0) + "", + "已结笔数 " + (d.closed_count || 0) + "", + "已结合计 " + fmt(d.closed_pnl_total) + " ≈U", + ]; + const by = d.by_reason || []; + if (by.length) { + parts.push( + "
按原因: " + + by + .map(function (r) { + return ( + (r.plan_type || "") + + "/" + + (r.close_reason || "") + + " ×" + + r.n + + " pnl=" + + fmt(r.pnl) + ); + }) + .join(" · ") + ); + } + box.innerHTML = parts.join(" · "); + } catch (e) { + box.textContent = e.message || String(e); + } + } + + async function startPlan(planType) { + const isOo = planType === "options_options"; + try { + let body; + if (isOo) { + if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + const target = Number(($("hp-target") && $("hp-target").value) || 0); + if (!target) throw new Error("请填写目标价"); + body = { + plan_type: "options_options", + underlying: state.underlying, + target_price: target, + leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), + leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), + }; + } else { + if (!state.selected) throw new Error("请选用期权腿"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + underlying: state.underlying, + direction: ($("hp-direction") && $("hp-direction").value) || "long", + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + sheets: sheets, + opt_inst_id: state.selected.inst_id, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + exchange_symbol: (state.market && state.market.exchange_symbol) || "", + leverage: 10, + margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital, + }; + } + if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return; + const d = await apiJson("/api/hedge-plan/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : "")); + void loadGates(); + } catch (e) { + alert(e.message || String(e)); + } } async function refreshAll() { diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 93f557f..182dfd0 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -130,7 +130,7 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = { "exchanges": frozenset({"okx"}), "fields": [ ("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"), - ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘开关与;P0 仅测算"), + ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"), ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py index d200785..130cc21 100644 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -299,13 +299,17 @@ def gate_status( sizing_mode: str, plan_type: str, options_enabled: bool, + live_order: bool = False, + live_trading: bool = False, + active_count: int = 0, + max_active: int = 1, ) -> dict[str, Any]: from lib.trade.position_sizing_lib import is_full_margin_mode full = is_full_margin_mode(sizing_mode) pt = (plan_type or "").strip().lower() can_preview = True - can_start = False + can_start = True reasons: list[str] = [] if not hedge_enabled: can_start = False @@ -314,23 +318,36 @@ def gate_status( can_preview = False can_start = False reasons.append("期权模块未启用") + if not live_order: + can_start = False + reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)") + if active_count >= max(1, int(max_active or 1)): + can_start = False + reasons.append(f"活跃计划已达上限({max_active})") if pt == "perp_options": if not full: can_start = False reasons.append("永期开仓仅全仓模式可用(当前可测算)") - elif hedge_enabled and options_enabled: + if not live_trading: can_start = False - reasons.append("P0 仅测算,真实开仓将在后续版本开放") + reasons.append("未开启实盘(LIVE_TRADING_ENABLED)") elif pt == "options_options": - if hedge_enabled and options_enabled: - can_start = False - reasons.append("P0 仅测算,真实开仓将在后续版本开放") + pass + else: + can_start = False + reasons.append("未知计划类型") + if can_start: + reasons = [] return { "hedge_enabled": hedge_enabled, "options_enabled": options_enabled, "sizing_mode": sizing_mode, "is_full_margin": full, "plan_type": pt, + "live_order": live_order, + "live_trading": live_trading, + "active_count": active_count, + "max_active": max_active, "can_preview": can_preview, "can_start": can_start, "reasons": reasons, diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py new file mode 100644 index 0000000..414c574 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_db.py @@ -0,0 +1,173 @@ +"""对冲计划 SQLite 表.""" +from __future__ import annotations + +import sqlite3 +from typing import Any, Optional + + +def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hedge_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_type TEXT NOT NULL, + status TEXT NOT NULL, + underlying TEXT NOT NULL, + direction TEXT, + entry_mark REAL, + tp REAL, + sl REAL, + target_price REAL, + sizing_mode_at_open TEXT, + perp_size REAL, + margin REAL, + leverage REAL, + premium_total REAL, + realized_pnl_perp REAL, + realized_pnl_options REAL, + realized_pnl_total REAL, + stats_bucket TEXT, + close_reason TEXT, + wechat_start_sent INTEGER DEFAULT 0, + wechat_end_sent INTEGER DEFAULT 0, + note TEXT, + preview_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + opened_at TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hedge_plan_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL, + leg_role TEXT NOT NULL, + symbol TEXT, + inst_id TEXT, + opt_type TEXT, + strike REAL, + side TEXT, + size REAL, + avg_open REAL, + premium REAL, + status TEXT, + linked_monitor_id INTEGER, + options_trade_id INTEGER, + exchange_ord_id TEXT, + realized_pnl REAL, + close_reason TEXT, + opened_at TIMESTAMP, + closed_at TIMESTAMP, + FOREIGN KEY(plan_id) REFERENCES hedge_plans(id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)" + ) + + +def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: + if plan_type: + row = conn.execute( + "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?", + (plan_type,), + ).fetchone() + else: + row = conn.execute( + "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')" + ).fetchone() + return int((row["c"] if row else 0) or 0) + + +def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int: + cols = list(row.keys()) + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})", + [row[c] for c in cols], + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int: + cols = list(row.keys()) + placeholders = ",".join(["?"] * len(cols)) + conn.execute( + f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})", + [row[c] for c in cols], + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None: + if not fields: + return + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id]) + + +def list_plans( + conn: sqlite3.Connection, + *, + status: Optional[str] = None, + plan_type: Optional[str] = None, + underlying: Optional[str] = None, + limit: int = 50, +) -> list[dict[str, Any]]: + wheres: list[str] = [] + args: list[Any] = [] + if status: + wheres.append("status=?") + args.append(status) + if plan_type: + wheres.append("plan_type=?") + args.append(plan_type) + if underlying: + wheres.append("underlying=?") + args.append(underlying) + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" + rows = conn.execute( + f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?", + [*args, int(limit)], + ).fetchall() + return [dict(r) for r in rows] + + +def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]: + row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone() + return dict(row) if row else None + + +def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]: + rows = conn.execute( + """ + SELECT plan_type, close_reason, COUNT(1) AS n, + COALESCE(SUM(realized_pnl_total), 0) AS pnl + FROM hedge_plans + WHERE status='closed' + GROUP BY plan_type, close_reason + """ + ).fetchall() + closed = conn.execute( + "SELECT COUNT(1) AS c, COALESCE(SUM(realized_pnl_total),0) AS pnl FROM hedge_plans WHERE status='closed'" + ).fetchone() + active = count_active_plans(conn) + return { + "active": active, + "closed_count": int((closed["c"] if closed else 0) or 0), + "closed_pnl_total": float((closed["pnl"] if closed else 0) or 0), + "by_reason": [dict(r) for r in rows], + } diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py new file mode 100644 index 0000000..717cd19 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -0,0 +1,233 @@ +"""对冲计划监控:永期 TP/SL 与期期目标价/到期收口.""" +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Optional + +from lib.hedge_plan.hedge_plan_db import get_plan_legs, list_plans, update_plan +from lib.hedge_plan.hedge_plan_orders_lib import _sell_option + + +def _now() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _sf(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Optional[float]: + fn = cfg.get("get_live_position_contracts") + if not callable(fn): + return None + try: + return fn(symbol, direction) + except Exception: + return None + + +def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: + ex = cfg.get("exchange_options") + fn = cfg.get("fetch_index_price") + if callable(fn) and ex is not None: + try: + return fn(ex, underlying) + except Exception: + return None + return None + + +def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]: + """扫描 active 计划并按规则收口.返回处理摘要.""" + get_db = cfg.get("get_db") + if not callable(get_db): + return {"ok": False, "msg": "get_db missing"} + conn = get_db() + acted: list[dict[str, Any]] = [] + try: + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + + init_hedge_plan_tables(conn) + plans = list_plans(conn, status="active", limit=20) + for plan in plans: + r = _tick_one(cfg, conn, plan) + if r: + acted.append(r) + conn.commit() + finally: + conn.close() + return {"ok": True, "acted": acted} + + +def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]: + pt = plan.get("plan_type") + legs = get_plan_legs(conn, int(plan["id"])) + if pt == "perp_options": + return _tick_po(cfg, conn, plan, legs) + if pt == "options_options": + return _tick_oo(cfg, conn, plan, legs) + return None + + +def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]: + perp = next((x for x in legs if x.get("leg_role") == "perp"), None) + opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) + if not perp: + return None + 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: + return None + # 仓已平:用标记/最新粗判 TP or SL + entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0 + tp = _sf(plan.get("tp")) + sl = _sf(plan.get("sl")) + mark = None + ex = cfg.get("exchange") + if ex is not None and symbol: + try: + t = ex.fetch_ticker(symbol) + 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" + 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") + if callable(get_cs) and symbol: + try: + cs = float(get_cs(symbol) or cs) + except Exception: + pass + size = float(perp.get("size") or 0) + exit_px = mark or (tp if reason == "perp_tp" else sl) or entry + coins = size * cs + if direction == "short": + perp_pnl = (entry - exit_px) * coins + else: + 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 close_r.get("ok"): + # 无法精确拿到卖出价差时仍用 −premium 作为下限;有 bid 则近似 + 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 = 0.01 + opt_pnl = (bid - ask_open) * float(opt.get("size") or 1) * ct + else: + opt_pnl = -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 reason == "perp_tp" and opt: + # 止盈默认不平期权 + if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False): + _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1)) + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=? WHERE id=?", + ("closed", reason, _now(), 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 # 期权盈亏 + 永续盈亏 + + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", reason, _now(), perp_pnl, perp["id"]), + ) + update_plan( + conn, + int(plan["id"]), + status="closed", + close_reason=reason, + realized_pnl_perp=round(perp_pnl, 4), + realized_pnl_options=round(opt_pnl, 4), + realized_pnl_total=round(total, 4), + stats_bucket="tp" if reason == "perp_tp" else "sl", + closed_at=_now(), + ) + return {"plan_id": plan["id"], "close_reason": reason, "total": total} + + +def _tick_oo(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]: + target = _sf(plan.get("target_price")) + idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) + if target is None or idx is None: + return None + # 简化:接近目标价(相对 0.15%)时平盈利腿 + if abs(idx - target) / max(target, 1) > 0.0015 and not (idx >= target or idx <= target): + pass + near = abs(idx - target) / max(abs(target), 1.0) <= 0.002 + if not near: + return None + if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): + return None + open_legs = [x for x in legs if x.get("status") == "open" and x.get("leg_role", "").startswith("option")] + if len(open_legs) < 2: + return None + # 用内在价值粗判盈利腿 + winners = [] + for leg in open_legs: + strike = _sf(leg.get("strike")) or 0 + o = (leg.get("opt_type") or "").upper() + intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx) + premium = float(leg.get("premium") or 0) + pnl = intrinsic * float(leg.get("size") or 1) * 0.01 - premium + winners.append((pnl, leg)) + winners.sort(key=lambda x: x[0], reverse=True) + best_pnl, best = winners[0] + if best_pnl <= 0: + return None + close_r = _sell_option(cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1)) + if not close_r.get("ok"): + return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} + conn.execute( + "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", + ("closed", "target_win_leg", _now(), best_pnl, best["id"]), + ) + # 计划暂不 closed,等另一腿到期;先标 note + update_plan(conn, int(plan["id"]), close_reason="target_win_leg") + return {"plan_id": plan["id"], "close_reason": "target_win_leg", "closed_leg": best.get("id")} diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py new file mode 100644 index 0000000..18b6077 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -0,0 +1,377 @@ +"""对冲计划开仓/平仓编排(可 dry_run 校验下单路径).""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from typing import Any, Callable, Optional + + +def _now() -> str: + return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def open_order_mode() -> str: + v = (os.getenv("HEDGE_PLAN_OPEN_ORDER") or "options_first").strip().lower() + return v if v in ("options_first", "perp_first") else "options_first" + + +def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: + """永期下单路径清单(不交易).""" + mode = open_order_mode() + opt = { + "step": "options_buy_limit", + "account": "options", + "inst_id": body.get("opt_inst_id"), + "sheets": float(body.get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + } + perp = { + "step": "perp_market_open", + "account": "swap", + "symbol": body.get("exchange_symbol"), + "direction": body.get("direction") or "long", + "contracts": float(body.get("contracts") or 0), + "tp": body.get("tp"), + "sl": body.get("sl"), + "attach_tpsl": True, + } + return [opt, perp] if mode == "options_first" else [perp, opt] + + +def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "step": "options_buy_limit", + "account": "options", + "leg": "a", + "inst_id": (body.get("leg_a") or {}).get("inst_id"), + "sheets": float((body.get("leg_a") or {}).get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + }, + { + "step": "options_buy_limit", + "account": "options", + "leg": "b", + "inst_id": (body.get("leg_b") or {}).get("inst_id"), + "sheets": float((body.get("leg_b") or {}).get("sheets") or 1), + "side": "buy", + "price_hint": "ask", + }, + ] + + +def _buy_option( + cfg: dict[str, Any], + *, + inst_id: str, + sheets: float, + dry_run: bool, +) -> dict[str, Any]: + ex = cfg.get("exchange_options") + quote_fn = cfg.get("quote_option_contract") + place_fn = cfg.get("place_option_limit_order") + td_buy = cfg.get("td_mode_for_option_buy") + 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) + if not q.get("ok"): + return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} + ask = q.get("ask") + if ask is None or float(ask) <= 0: + return {"ok": False, "msg": "暂无卖一价,无法买入"} + sheets_i = max(1, int(round(float(sheets)))) + ct_mult = float(q.get("ct_mult") or 0.01) + premium = float(ask) * sheets_i * ct_mult + if dry_run: + return { + "ok": True, + "dry_run": True, + "inst_id": inst_id, + "sheets": sheets_i, + "ask": float(ask), + "premium": premium, + "ct_mult": ct_mult, + "tick_sz": q.get("tick_sz"), + "meta": q.get("meta") or {}, + "strike": q.get("strike"), + "exp_time": q.get("exp_time"), + "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + } + if not callable(place_fn): + return {"ok": False, "msg": "期权限价下单未注入"} + td = "isolated" + if callable(td_buy): + td = td_buy(cfg.get("options_td_mode") or "isolated") + order = place_fn( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets_i, + price=float(ask), + td_mode=td, + tick_sz=q.get("tick_sz"), + ) + if not order.get("ok"): + return order + return { + "ok": True, + "inst_id": inst_id, + "sheets": sheets_i, + "ask": float(ask), + "premium": premium, + "ct_mult": ct_mult, + "tick_sz": q.get("tick_sz"), + "meta": q.get("meta") or {}, + "strike": q.get("strike"), + "exp_time": q.get("exp_time"), + "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), + "exchange_ord_id": (order.get("data") or {}).get("ordId"), + "order": order, + } + + +def _open_perp( + cfg: dict[str, Any], + *, + symbol: str, + direction: str, + contracts: float, + leverage: int, + tp: float, + sl: float, + dry_run: bool, +) -> dict[str, Any]: + if not symbol or contracts <= 0: + return {"ok": False, "msg": "永续符号或张数无效"} + amount = float(contracts) + to_prec = cfg.get("amount_to_precision") + ex = cfg.get("exchange") + if callable(to_prec) and ex is not None: + try: + amount = float(to_prec(symbol, amount)) + except Exception: + pass + if amount <= 0: + return {"ok": False, "msg": "张数经精度舍入后为 0"} + if dry_run: + return { + "ok": True, + "dry_run": True, + "symbol": symbol, + "direction": direction, + "contracts": amount, + "leverage": leverage, + "tp": tp, + "sl": sl, + } + ensure = cfg.get("ensure_okx_live_ready") + if callable(ensure): + ok, msg = ensure() + if not ok: + return {"ok": False, "msg": msg or "实盘未就绪"} + place = cfg.get("place_exchange_order") + if not callable(place): + return {"ok": False, "msg": "永续下单函数未注入"} + try: + order = place(symbol, direction, amount, leverage, stop_loss=sl, take_profit=tp) + except Exception as e: + return {"ok": False, "msg": f"永续开仓失败: {e}"} + return { + "ok": True, + "symbol": symbol, + "direction": direction, + "contracts": amount, + "leverage": leverage, + "tp": tp, + "sl": sl, + "order": order, + "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), + } + + +def _sell_option( + cfg: dict[str, Any], + *, + inst_id: str, + sheets: float, + dry_run: bool = False, +) -> dict[str, Any]: + ex = cfg.get("exchange_options") + quote_fn = cfg.get("quote_option_contract") + place_fn = cfg.get("place_option_limit_order") + if not callable(quote_fn) or ex is None: + return {"ok": False, "msg": "期权报价能力未就绪"} + q = quote_fn(ex, inst_id) + bid = q.get("bid") if q.get("ok") else None + if bid is None or float(bid) <= 0: + 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": False, "msg": "期权平仓未注入"} + order = place_fn( + ex, + inst_id=inst_id, + side="sell", + sheets=sheets_i, + price=float(bid), + td_mode="isolated", + tick_sz=q.get("tick_sz"), + reduce_only=True, + ) + return order if order.get("ok") else order + + +def execute_perp_options_start( + cfg: dict[str, Any], + body: dict[str, Any], + *, + dry_run: bool = False, + persist: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + path = build_po_path_plan(body) + results: list[dict[str, Any]] = [] + opt_res: Optional[dict[str, Any]] = None + perp_res: Optional[dict[str, Any]] = None + for step in path: + if step["step"] == "options_buy_limit": + opt_res = _buy_option( + cfg, + inst_id=str(body.get("opt_inst_id") or ""), + sheets=float(body.get("sheets") or 1), + dry_run=dry_run, + ) + results.append({"step": step["step"], **opt_res}) + if not opt_res.get("ok"): + return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} + else: + perp_res = _open_perp( + cfg, + symbol=str(body.get("exchange_symbol") or ""), + direction=str(body.get("direction") or "long"), + contracts=float(body.get("contracts") or 0), + leverage=int(body.get("leverage") or 10), + tp=float(body["tp"]), + sl=float(body["sl"]), + dry_run=dry_run, + ) + results.append({"step": step["step"], **perp_res}) + if not perp_res.get("ok"): + # 半腿补偿:期权已成 + 配置允许则平期权 + if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True): + close_r = _sell_option( + cfg, + inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), + sheets=float(opt_res.get("sheets") or body.get("sheets") or 1), + ) + results.append({"step": "options_auto_close_on_perp_fail", **close_r}) + return { + "ok": False, + "msg": perp_res.get("msg") or "永续开仓失败", + "path": path, + "results": results, + "partial": True, + } + + out = { + "ok": True, + "dry_run": dry_run, + "plan_type": "perp_options", + "path": path, + "results": results, + "option": opt_res, + "perp": perp_res, + "opened_at": _now(), + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def execute_options_options_start( + cfg: dict[str, Any], + body: dict[str, Any], + *, + dry_run: bool = False, + persist: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + path = build_oo_path_plan(body) + results: list[dict[str, Any]] = [] + leg_a = body.get("leg_a") or {} + leg_b = body.get("leg_b") or {} + a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) + results.append({"step": "options_buy_limit", "leg": "a", **a_res}) + if not a_res.get("ok"): + return {"ok": False, "msg": a_res.get("msg") or "腿A开仓失败", "path": path, "results": results} + b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) + results.append({"step": "options_buy_limit", "leg": "b", **b_res}) + if not b_res.get("ok"): + if not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True): + close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1)) + results.append({"step": "options_auto_close_leg_a", **close_r}) + return { + "ok": False, + "msg": b_res.get("msg") or "腿B开仓失败", + "path": path, + "results": results, + "partial": True, + } + out = { + "ok": True, + "dry_run": dry_run, + "plan_type": "options_options", + "path": path, + "results": results, + "leg_a": a_res, + "leg_b": b_res, + "opened_at": _now(), + } + if persist and not dry_run: + out["plan_id"] = persist(out, body) + return out + + +def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: + pt = (plan_type or "").strip().lower() + if pt == "perp_options": + need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") + for k in need: + if body.get(k) in (None, ""): + return f"缺少字段: {k}" + try: + if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: + return "张数必须大于 0" + if float(body["tp"]) <= 0 or float(body["sl"]) <= 0: + return "止盈/止损无效" + except (TypeError, ValueError): + return "数值字段无效" + return None + if pt == "options_options": + a = body.get("leg_a") or {} + b = body.get("leg_b") or {} + if not a.get("inst_id") or not b.get("inst_id"): + return "请选用两条期权腿" + if body.get("target_price") in (None, ""): + return "缺少目标价" + return None + return "未知计划类型" + + +def dump_preview(preview: Any) -> str: + try: + return json.dumps(preview, ensure_ascii=False)[:8000] + except Exception: + return "" diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index f49cad9..e863b29 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -48,10 +48,24 @@ def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: cfg = _build_cfg(app_module) app.extensions["hedge_plan_cfg"] = cfg register_hedge_plan_routes(app, cfg) + _maybe_start_monitor(cfg) def _build_cfg(app_module: Any) -> dict[str, Any]: - from lib.exchange.okx_options_lib import build_option_chain, options_header_balances + from lib.exchange.okx_options_lib import ( + build_option_chain, + fetch_index_price, + options_header_balances, + place_option_limit_order, + quote_option_contract, + td_mode_for_option_buy, + ) + + def _amount_to_precision(sym: str, amt: float) -> float: + ex = getattr(app_module, "exchange", None) + if ex is None: + return float(amt) + return float(ex.amount_to_precision(sym, amt)) return { "get_db": app_module.get_db, @@ -63,8 +77,17 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "get_contract_size": getattr(app_module, "get_contract_size", None), "normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None), "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), + "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), + "place_exchange_order": getattr(app_module, "place_exchange_order", None), + "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), + "amount_to_precision": _amount_to_precision, "build_option_chain": build_option_chain, "options_header_balances": options_header_balances, + "quote_option_contract": quote_option_contract, + "place_option_limit_order": place_option_limit_order, + "td_mode_for_option_buy": td_mode_for_option_buy, + "fetch_index_price": fetch_index_price, + "options_td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), "btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10), "alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5), "full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98), @@ -74,6 +97,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), + "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), } @@ -81,6 +105,180 @@ def _hedge_enabled() -> bool: return _env_bool("HEDGE_PLAN_ENABLED", False) +def _live_order() -> bool: + return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) + + +def _max_active() -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_HEDGE_PLANS") or "1")) + except ValueError: + return 1 + + +def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]: + active = 0 + try: + from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + active = count_active_plans(conn) + conn.commit() + finally: + conn.close() + except Exception: + active = 0 + return gate_status( + hedge_enabled=_hedge_enabled(), + sizing_mode=load_position_sizing_mode(), + plan_type=plan_type, + options_enabled=bool(cfg.get("options_enabled")), + live_order=_live_order(), + live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False), + active_count=active, + max_active=_max_active(), + ) + + +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) + + def _loop() -> None: + import time + + 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) + + import threading + + t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) + t.start() + cfg["hedge_monitor_thread"] = t + + +def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + opt = result.get("option") or {} + perp = result.get("perp") or {} + premium = float(opt.get("premium") or 0) + plan_id = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "direction": str(body.get("direction") or "long"), + "entry_mark": float(body.get("entry") or 0), + "tp": float(body.get("tp") or 0), + "sl": float(body.get("sl") or 0), + "sizing_mode_at_open": load_position_sizing_mode(), + "perp_size": float(perp.get("contracts") or body.get("contracts") or 0), + "margin": body.get("margin"), + "leverage": float(body.get("leverage") or 10), + "premium_total": premium, + "opened_at": result.get("opened_at"), + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "perp", + "symbol": str(body.get("exchange_symbol") or ""), + "side": str(body.get("direction") or "long"), + "size": float(perp.get("contracts") or body.get("contracts") or 0), + "avg_open": float(body.get("entry") or 0), + "status": "open", + "exchange_ord_id": str(perp.get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at"), + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "option_hedge", + "inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""), + "opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""), + "strike": opt.get("strike") or body.get("strike"), + "side": "buy", + "size": float(opt.get("sheets") or body.get("sheets") or 1), + "avg_open": float(opt.get("ask") or 0), + "premium": premium, + "status": "open", + "exchange_ord_id": str(opt.get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at"), + }, + ) + conn.commit() + return plan_id + finally: + conn.close() + + +def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + a = result.get("leg_a") or {} + b = result.get("leg_b") or {} + premium = float(a.get("premium") or 0) + float(b.get("premium") or 0) + plan_id = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": str(body.get("underlying") or "ETH").upper(), + "target_price": float(body.get("target_price") or 0), + "sizing_mode_at_open": load_position_sizing_mode(), + "premium_total": premium, + "opened_at": result.get("opened_at"), + }, + ) + for role, res, src in (("option_a", a, body.get("leg_a") or {}), ("option_b", b, body.get("leg_b") or {})): + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": role, + "inst_id": str(res.get("inst_id") or src.get("inst_id") or ""), + "opt_type": str(res.get("opt_type") or src.get("opt_type") or ""), + "strike": res.get("strike") or src.get("strike"), + "side": "buy", + "size": float(res.get("sheets") or src.get("sheets") or 1), + "avg_open": float(res.get("ask") or 0), + "premium": float(res.get("premium") or 0), + "status": "open", + "exchange_ord_id": str(res.get("exchange_ord_id") or ""), + "opened_at": result.get("opened_at"), + }, + ) + conn.commit() + return plan_id + finally: + conn.close() + + def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: lr = cfg["login_required"] @@ -98,17 +296,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: @lr def api_hedge_gates(): plan_type = (request.args.get("plan_type") or "perp_options").strip() - return jsonify( - { - "ok": True, - **gate_status( - hedge_enabled=_hedge_enabled(), - sizing_mode=load_position_sizing_mode(), - plan_type=plan_type, - options_enabled=bool(cfg.get("options_enabled")), - ), - } - ) + return jsonify({"ok": True, **_gates_dict(cfg, plan_type)}) @app.route("/api/hedge-plan/market") @lr @@ -123,12 +311,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: if err: return jsonify({"ok": False, "msg": err}), 400 sizing_mode = load_position_sizing_mode() - gates = gate_status( - hedge_enabled=_hedge_enabled(), - sizing_mode=sizing_mode, - plan_type="perp_options", - options_enabled=bool(cfg.get("options_enabled")), - ) + gates = _gates_dict(cfg, "perp_options") out = { "ok": True, "base": base, @@ -181,12 +364,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: def api_hedge_preview(): body = request.get_json(silent=True) or {} plan_type = (body.get("plan_type") or "perp_options").strip().lower() - gates = gate_status( - hedge_enabled=_hedge_enabled(), - sizing_mode=load_position_sizing_mode(), - plan_type=plan_type, - options_enabled=bool(cfg.get("options_enabled")), - ) + gates = _gates_dict(cfg, plan_type) if not gates.get("can_preview"): return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400 try: @@ -200,6 +378,144 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500 return jsonify({"ok": True, "gates": gates, **data}) + @app.route("/api/hedge-plan/validate-path", methods=["POST"]) + @lr + def api_hedge_validate_path(): + """只校验下单路径(强制 dry_run),不真实成交.""" + from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_options_options_start, + execute_perp_options_start, + validate_start_body, + ) + + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + err = validate_start_body(plan_type, body) + if err: + return jsonify({"ok": False, "msg": err}), 400 + if plan_type == "options_options": + out = execute_options_options_start(cfg, body, dry_run=True) + else: + out = execute_perp_options_start(cfg, body, dry_run=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/hedge-plan/start", methods=["POST"]) + @lr + def api_hedge_start(): + from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_options_options_start, + execute_perp_options_start, + validate_start_body, + ) + + 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) + + @app.route("/api/hedge-plan/list") + @lr + def api_hedge_list(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans + + status = (request.args.get("status") or "").strip() or None + plan_type = (request.args.get("plan_type") or "").strip() or None + underlying = (request.args.get("underlying") or "").strip() or None + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = list_plans( + conn, status=status, plan_type=plan_type, underlying=underlying, limit=80 + ) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": rows}) + + @app.route("/api/hedge-plan/history") + @lr + def api_hedge_history(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + rows = list_plans(conn, status="closed", limit=100) + failed = list_plans(conn, status="failed", limit=50) + cancelled = list_plans(conn, status="cancelled", limit=50) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plans": rows + failed + cancelled}) + + @app.route("/api/hedge-plan/stats") + @lr + def api_hedge_stats(): + from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, stats_summary + + conn = cfg["get_db"]() + try: + init_hedge_plan_tables(conn) + s = stats_summary(conn) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, **s}) + + @app.route("/api/hedge-plan/") + @lr + def api_hedge_detail(plan_id: int): + from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, init_hedge_plan_tables + + 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 + legs = get_plan_legs(conn, plan_id) + conn.commit() + finally: + conn.close() + return jsonify({"ok": True, "plan": plan, "legs": legs}) + + @app.route("/api/hedge-plan/monitor-tick", methods=["POST"]) + @lr + def api_hedge_monitor_tick(): + from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans + + return jsonify(tick_active_plans(cfg)) + def _preview_po(body: dict[str, Any]) -> dict[str, Any]: direction = str(body.get("direction") or "long").lower() diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index f904fe2..6b75a50 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -13,7 +13,7 @@
-

对冲计划 P0 测算

+

对冲计划 测算 · 下单

@@ -84,7 +84,7 @@
- +
@@ -166,6 +166,7 @@
+
@@ -195,17 +196,28 @@ - + diff --git a/tests/test_hedge_plan_orders.py b/tests/test_hedge_plan_orders.py new file mode 100644 index 0000000..f6b71dc --- /dev/null +++ b/tests/test_hedge_plan_orders.py @@ -0,0 +1,160 @@ +"""对冲计划下单路径校验(dry_run + 门禁).""" +import unittest +from unittest.mock import MagicMock + +from lib.hedge_plan.hedge_plan_calc_lib import gate_status +from lib.hedge_plan.hedge_plan_orders_lib import ( + build_oo_path_plan, + build_po_path_plan, + execute_options_options_start, + execute_perp_options_start, + validate_start_body, +) + + +class TestHedgePlanOrderPath(unittest.TestCase): + def test_po_path_options_first(self): + body = { + "opt_inst_id": "ETH-USD-260731-1800-P", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "direction": "long", + "contracts": 4.5, + "tp": 1900, + "sl": 1700, + } + path = build_po_path_plan(body) + self.assertEqual(path[0]["step"], "options_buy_limit") + self.assertEqual(path[0]["account"], "options") + self.assertEqual(path[1]["step"], "perp_market_open") + self.assertEqual(path[1]["account"], "swap") + self.assertTrue(path[1]["attach_tpsl"]) + + def test_oo_path_two_option_buys(self): + body = { + "leg_a": {"inst_id": "ETH-USD-260731-1800-C", "sheets": 1}, + "leg_b": {"inst_id": "ETH-USD-260731-1700-P", "sheets": 3}, + } + path = build_oo_path_plan(body) + self.assertEqual(len(path), 2) + self.assertEqual(path[0]["leg"], "a") + self.assertEqual(path[1]["sheets"], 3) + + def test_validate_body(self): + self.assertIsNotNone(validate_start_body("perp_options", {})) + ok = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "X", + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNone(ok) + + def test_gate_can_start_when_live(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="full_margin", + plan_type="perp_options", + options_enabled=True, + live_order=True, + live_trading=True, + active_count=0, + max_active=1, + ) + self.assertTrue(g["can_start"]) + self.assertEqual(g["reasons"], []) + + def test_gate_oo_without_live_trading(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="options_options", + options_enabled=True, + live_order=True, + live_trading=False, + active_count=0, + max_active=1, + ) + self.assertTrue(g["can_start"]) + + def test_dry_run_po_calls_quote_not_place(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 12.5, + "ct_mult": 0.01, + "tick_sz": "0.1", + "strike": 1800, + "exp_time": 1, + "meta": {"optType": "P"}, + } + ) + place_opt = MagicMock() + place_perp = MagicMock() + cfg = { + "exchange_options": object(), + "exchange": object(), + "quote_option_contract": quote, + "place_option_limit_order": place_opt, + "place_exchange_order": place_perp, + "td_mode_for_option_buy": lambda x: "isolated", + "amount_to_precision": lambda s, a: a, + "ensure_okx_live_ready": lambda: (True, ""), + } + body = { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 4.5, + "opt_inst_id": "ETH-USD-260731-1800-P", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "leverage": 10, + "underlying": "ETH", + } + out = execute_perp_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"]) + self.assertTrue(out["dry_run"]) + place_opt.assert_not_called() + place_perp.assert_not_called() + quote.assert_called() + self.assertEqual(out["path"][0]["account"], "options") + self.assertEqual(out["path"][1]["account"], "swap") + + def test_dry_run_oo(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 10, + "ct_mult": 0.01, + "tick_sz": "0.1", + "strike": 1800, + "meta": {"optType": "C"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + } + body = { + "target_price": 1900, + "leg_a": {"inst_id": "A", "sheets": 1}, + "leg_b": {"inst_id": "B", "sheets": 1}, + } + out = execute_options_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"]) + self.assertEqual(len(out["results"]), 2) + + +if __name__ == "__main__": + unittest.main()