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 @@
历史记录
-
独立对冲计划历史(永期 / 期期)将在此展示合计盈亏、平仓原因与复盘短评.
-
P1–P5 落地后可查;当前无计划记录.
+
独立对冲计划历史(与普通交易记录分离)
+
+
+
+
+ | ID | 类型 | 标的 | 状态 | 合计≈U | 原因 | 开仓 | 结束 |
+
+
+
+ | 加载中… |
+
+
+
统计分析
-
止盈口径:止盈盈利 − 权利金;止损口径:期权盈利 − 永续亏损;期期到期无盈利记总亏损.
-
统计看板随历史表上线后提供.
+
止盈=盈利−保费;止损=期权盈亏+永续盈亏;期期到期无盈利记总亏损
+
加载中…
-
+
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()