diff --git a/docs/对冲计划-以期权为主.md b/docs/对冲计划-以期权为主.md
index 1f1f719..6e57e85 100644
--- a/docs/对冲计划-以期权为主.md
+++ b/docs/对冲计划-以期权为主.md
@@ -10,9 +10,9 @@
| UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 |
| UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 |
| 左卡 | 开仓价 / 张数 / TP / SL | 资金与杠杆 / 选约条件 / 出场条件 三组 |
-| 右卡 | 上永续行情 · 下期权链 | 同上;期权表含杠杆(指数÷卖一),按类型自动匹配 |
-| 选约 | 仅实值/平值 | 实/平/虚 + 间隔 + 杠杆门 |
-| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **强制先期权**,成交后**立即市价**开永续(**不挂**交易所 TP/SL) |
+| 右卡 | 上永续行情 · 下期权链 | 同上;仅展示间隔+类型+杠杆达标候选 |
+| 选约 | 仅实值/平值 | 类型下拉(默认虚值)+间隔+杠杆门槛 |
+| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **策略启动=盯盘**(status=`watching`),达标后才先期权后市价永续(**不挂**交易所 TP/SL) |
| 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 |
## 2. 左卡默认
diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js
index de1a7ff..0c83beb 100644
--- a/lib/common/static/hedge_plan.js
+++ b/lib/common/static/hedge_plan.js
@@ -22,7 +22,7 @@
tab: pickDefaultTab(),
mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options",
underlying: root.getAttribute("data-default-underly") || "ETH",
- moneyFilter: "itm", // 永期锁定:实值+平值
+ moneyFilter: root.getAttribute("data-option-primary") !== "0" ? "otm" : "itm",
ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值
ooRecommend: null, // atm_straddle | double_otm | null
ooStrikeExpandAll: false, // 默认 Call/Put 各 3 档
@@ -149,17 +149,35 @@
return fmt(v, 2) + ":1";
}
+ /** 列表/盯盘候选杠杆门槛(与后端 effective_min_opt_leverage 对齐). */
+ function optionPrimaryMinLev() {
+ const minLev = numInput("hp-opt-leverage", opMoneyKind() === "otm" ? 200 : 100);
+ if (!(minLev > 0)) return 0;
+ if (opMoneyKind() === "otm") return Math.max(minLev, 180);
+ return minLev;
+ }
+
+ function optionPrimaryLevOk(c) {
+ const idx = indexPx();
+ const ask = Number(c && c.ask);
+ const floor = optionPrimaryMinLev();
+ if (!(floor > 0)) return true;
+ if (!(idx > 0) || !(ask > 0)) return false;
+ return idx / ask >= floor - 1e-9;
+ }
+
function matchesMoneyFilter(c) {
const f = state.moneyFilter || "itm";
const m = (c.moneyness || "").toLowerCase();
if (!isOptionPrimary() && f === "otm") return false;
- // 列表只按间隔+虚实值;期权杠杆仅启动/计算时由后端校验
+ // 列表:间隔+虚实值+杠杆门槛(达标才显示;启动盯盘后监控同样门槛)
if (isOptionPrimary()) {
const idx = indexPx();
const interval = numInput("hp-strike-interval", 15);
if (idx && interval > 0 && Math.abs(Number(c.strike) - idx) > interval + 1e-9) {
return false;
}
+ if (!optionPrimaryLevOk(c)) return false;
}
if (f === "itm") return m === "itm" || m === "atm";
if (f === "atm") return m === "atm";
@@ -315,6 +333,7 @@
const dirShort = document.querySelector('.hp-po-dir[data-dir="short"]');
if (dirLong) dirLong.title = on ? "做多=买Call+永续空" : "做多永续";
if (dirShort) dirShort.title = on ? "做空=买Put+永续多" : "做空永续";
+ syncPoActionBtn();
}
function hoursFromExpMs(expMs) {
@@ -372,6 +391,10 @@
}
function syncMoneyUI() {
+ const moneySel = $("hp-money-select");
+ if (moneySel && isOptionPrimary()) {
+ moneySel.value = state.moneyFilter === "otm" ? "otm" : state.moneyFilter === "atm" ? "atm" : "itm";
+ }
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
const on = b.getAttribute("data-money") === state.moneyFilter;
b.classList.toggle("active", on);
@@ -386,6 +409,18 @@
});
}
+ function syncPoActionBtn() {
+ const btn = $("hp-preview-btn");
+ if (!btn) return;
+ if (isOptionPrimary()) {
+ btn.textContent = "策略启动";
+ btn.title = "按参数启动盯盘;杠杆/间隔达标后自动开仓(非现场开)";
+ } else {
+ btn.textContent = "计算";
+ btn.title = "情景测算后再启动";
+ }
+ }
+
function syncOoRecommendUI() {
const cur = state.ooRecommend || "";
document.querySelectorAll(".hp-oo-recommend-btn").forEach(function (b) {
@@ -1097,7 +1132,7 @@
return;
}
if (isOptionPrimary() && !matchesMoneyFilter(c)) {
- alert("不符合当前间隔/虚实值过滤");
+ alert("不符合当前间隔/虚实值/杠杆门槛");
return;
}
state.selected = c;
@@ -1149,7 +1184,8 @@
sameType.length +
" 档)·检查间隔" +
(interval != null ? "≤" + interval : "") +
- "点/虚实值";
+ "点/虚实值/杠杆≥" +
+ optionPrimaryMinLev();
} else if (!sameType.length) {
hint =
"该到期无 " +
@@ -1741,9 +1777,21 @@
renderListStrikes();
});
});
+ if ($("hp-money-select")) {
+ $("hp-money-select").addEventListener("change", function () {
+ const m = $("hp-money-select").value || "otm";
+ state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm";
+ state.opLevTouched = false;
+ state.opRatioTouched = false;
+ applyOpDefaultsFromMoney(true);
+ syncMoneyUI();
+ renderListStrikes();
+ });
+ }
if ($("hp-opt-leverage")) {
$("hp-opt-leverage").addEventListener("input", function () {
state.opLevTouched = true;
+ if (isOptionPrimary()) renderListStrikes();
});
}
if ($("hp-opt-perp-ratio")) {
@@ -1940,7 +1988,8 @@
if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () {
state.mode = "perp_options";
- void runPreview();
+ if (isOptionPrimary()) void startOptionPrimaryWatch();
+ else void runPreview();
});
if ($("hp-preview-btn-oo"))
$("hp-preview-btn-oo").addEventListener("click", function () {
@@ -2036,6 +2085,14 @@
}
function activeTargetLabel(p) {
+ if (p.plan_type === "perp_options" && (p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1)) {
+ return (
+ "期权K±" +
+ fmt(p.option_target_points, 0) +
+ " · 永续K±" +
+ fmt(p.perp_target_points, 0)
+ );
+ }
if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
}
@@ -2043,6 +2100,9 @@
}
function activeStatusLabel(p) {
+ if ((p.status || "") === "watching") {
+ return '盯盘中';
+ }
if ((p.status || "") === "partial") {
return '半腿待补';
}
@@ -2422,6 +2482,65 @@
}
}
+ async function startOptionPrimaryWatch() {
+ try {
+ const optPts = numInput("hp-opt-target-pts", NaN);
+ const perpPts = numInput("hp-perp-target-pts", NaN);
+ const prem = numInput("hp-premium-budget", 0);
+ const optLev = numInput("hp-opt-leverage", 200);
+ if (!(prem > 0)) throw new Error("请填写权利金预算");
+ if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)");
+ if (!(optLev > 0)) throw new Error("请填写期权杠杆门槛");
+ if (!(state.market && state.market.exchange_symbol)) throw new Error("永续行情未就绪,请先刷新");
+ if (!state.canStart) {
+ throw new Error("当前不可启动(门禁未满足),请查看上方提示");
+ }
+ const msg =
+ "确认启动盯盘?\n" +
+ "类型 " +
+ (opMoneyKind() === "otm" ? "虚值" : opMoneyKind() === "atm" ? "平值" : "实/平") +
+ " · 间隔 " +
+ numInput("hp-strike-interval", 15) +
+ " · 杠杆≥" +
+ optionPrimaryMinLev() +
+ "\n达标后自动开仓(非现场立即开)";
+ if (!window.confirm(msg)) return;
+ const body = {
+ plan_type: "perp_options",
+ option_primary: true,
+ watch_entry: 1,
+ underlying: state.underlying,
+ direction: getDirection(),
+ exchange_symbol: state.market.exchange_symbol,
+ contract_size: state.market.contract_size || 0.01,
+ index_px: indexPx() || Number(state.market.mark || 0),
+ entry: indexPx() || Number(state.market.mark || 0),
+ premium_budget: prem,
+ option_perp_ratio: numInput("hp-opt-perp-ratio", 4),
+ option_target_points: optPts,
+ perp_target_points: perpPts,
+ strike_interval: numInput("hp-strike-interval", 15),
+ min_option_hours: numInput("hp-min-hours", 36),
+ option_leverage: optLev,
+ leverage: numInput("hp-perp-leverage", 100),
+ moneyness: opMoneyKind(),
+ };
+ const d = await apiJson("/api/hedge-plan/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ setGateLine(d.gates);
+ alert((d.msg || "已启动盯盘") + (d.plan_id ? "\n计划 #" + d.plan_id : ""));
+ state.tab = "active";
+ syncTabUI();
+ void loadActivePlans();
+ void loadGates();
+ } catch (e) {
+ alert(e.message || String(e));
+ }
+ }
+
async function startPlan(planType, fromPreviewModal) {
const isOo = planType === "options_options";
const startBtn = $("hp-preview-start");
@@ -2567,7 +2686,7 @@
await loadChain();
} catch (e) {
const tbody = $("hp-strike-tbody");
- if (tbody) tbody.innerHTML = '
| ' + (e.message || e) + " |
";
+ if (tbody) tbody.innerHTML = '| ' + (e.message || e) + " |
";
}
}
diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css
index ceb377d..56bd334 100644
--- a/lib/common/static/instance_theme.css
+++ b/lib/common/static/instance_theme.css
@@ -3515,22 +3515,20 @@ html[data-theme="light"] .opt-be-dist-down {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hedge-plan-page-wrap .hp-po-fields--select {
- grid-template-columns: minmax(72px, 0.9fr) minmax(72px, 0.9fr) minmax(0, 1.6fr) minmax(72px, 0.9fr);
+ grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: end;
}
.hedge-plan-page-wrap .hp-po-field--type {
min-width: 0;
}
-.hedge-plan-page-wrap .hp-po-type-seg {
- display: flex;
- flex-wrap: nowrap;
- gap: 4px;
+.hedge-plan-page-wrap .hp-po-field--type select {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
}
-.hedge-plan-page-wrap .hp-po-type-seg .hp-money-btn {
- flex: 1 1 0;
- padding: 4px 6px;
- font-size: 0.72rem;
- white-space: nowrap;
+.hedge-plan-page-wrap .hp-plan-watching {
+ color: #fbbf24;
+ font-weight: 650;
}
@media (max-width: 720px) {
.hedge-plan-page-wrap .hp-po-fields--capital,
diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py
index f108791..299efa6 100644
--- a/lib/hedge_plan/hedge_plan_db.py
+++ b/lib/hedge_plan/hedge_plan_db.py
@@ -83,6 +83,7 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
+ _ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
@@ -99,15 +100,19 @@ def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str)
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
+_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
+
+
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
+ statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
if plan_type:
row = conn.execute(
- "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?",
+ f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
(plan_type,),
).fetchone()
else:
row = conn.execute(
- "SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')"
+ f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
).fetchone()
return int((row["c"] if row else 0) or 0)
@@ -200,7 +205,7 @@ def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
- if st in ("opening", "active", "partial"):
+ if st in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
@@ -236,7 +241,23 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
legs = get_plan_legs(conn, int(p["id"]))
row = dict(p)
row["legs"] = legs
- row["contracts_summary"] = legs_contract_summary(legs)
+ summary = legs_contract_summary(legs)
+ if str(p.get("status") or "") == "watching" and (not legs or summary == "—"):
+ money = str(p.get("option_moneyness") or "otm")
+ money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
+ parts = [f"盯盘·{money_lab}"]
+ try:
+ if p.get("strike_interval") not in (None, ""):
+ parts.append(f"间隔{float(p.get('strike_interval')):g}")
+ except (TypeError, ValueError):
+ pass
+ try:
+ if p.get("option_leverage") not in (None, ""):
+ parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
+ except (TypeError, ValueError):
+ pass
+ summary = "·".join(parts)
+ row["contracts_summary"] = summary
row["missing_leg"] = missing_leg_role(legs)
out.append(row)
return out
diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py
index fa6b37c..53addea 100644
--- a/lib/hedge_plan/hedge_plan_monitor_lib.py
+++ b/lib/hedge_plan/hedge_plan_monitor_lib.py
@@ -133,7 +133,8 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
init_hedge_plan_tables(conn)
- plans = list_plans(conn, status="active", limit=40)
+ plans = list_plans(conn, status="watching", limit=20)
+ plans.extend(list_plans(conn, status="active", limit=40))
# partial:裸永续/半腿也需侦测永续 TP/SL
plans.extend(list_plans(conn, status="partial", limit=20))
seen: set[int] = set()
@@ -227,6 +228,8 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
if is_option_primary(plan):
+ if str(plan.get("status") or "") == "watching":
+ return _tick_po_option_primary_watching(cfg, conn, plan)
# 期权为主:半平重试 → 到期 → 目标位分叉
r = _tick_po_option_primary_pending(cfg, conn, plan, legs)
if r:
@@ -251,6 +254,126 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[
return None
+def _tick_po_option_primary_watching(
+ cfg: dict[str, Any], conn: Any, plan: dict[str, Any]
+) -> Optional[dict[str, Any]]:
+ """盯盘:链上出现杠杆/间隔达标合约后自动开仓."""
+ import json
+ import os
+
+ from lib.hedge_plan.hedge_plan_option_primary_lib import (
+ pick_option_primary_candidate,
+ size_from_premium,
+ )
+ from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start
+ from lib.hedge_plan.hedge_plan_register import _activate_watching_po
+
+ build_chain = cfg.get("build_option_chain")
+ ex = cfg.get("exchange_options")
+ if not callable(build_chain) or ex is None:
+ return None
+ body0: dict[str, Any] = {}
+ try:
+ raw = plan.get("preview_json") or ""
+ blob = json.loads(raw) if raw else {}
+ body0 = dict(blob.get("start_body") or blob or {})
+ except Exception:
+ body0 = {}
+ uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").upper()
+ direction = str(plan.get("direction") or body0.get("direction") or "long").lower()
+ money = str(plan.get("option_moneyness") or body0.get("moneyness") or "otm").lower()
+ interval = plan.get("strike_interval")
+ if interval in (None, ""):
+ interval = body0.get("strike_interval", 15)
+ min_h = plan.get("min_option_hours")
+ if min_h in (None, ""):
+ min_h = body0.get("min_option_hours", 36)
+ opt_lev = plan.get("option_leverage")
+ if opt_lev in (None, ""):
+ opt_lev = body0.get("option_leverage")
+ try:
+ chain = build_chain(
+ ex,
+ uly,
+ max_dte_days=float(cfg.get("chain_max_dte") or 14),
+ itm_only=False,
+ itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"),
+ )
+ except Exception as e:
+ update_plan(conn, int(plan["id"]), note=f"盯盘拉链失败: {e}"[:500])
+ return None
+ cand = pick_option_primary_candidate(
+ chain,
+ direction=direction,
+ moneyness=money,
+ strike_interval=interval,
+ min_hours=min_h,
+ min_opt_leverage=opt_lev,
+ )
+ if not cand:
+ return None
+ ask = float(cand.get("ask") or 0)
+ ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01)
+ sized = size_from_premium(
+ premium_budget=float(plan.get("premium_budget") or body0.get("premium_budget") or 0),
+ ask=ask,
+ ct_mult=ct,
+ ratio=float(plan.get("option_perp_ratio") or body0.get("option_perp_ratio") or 2),
+ contract_size=float(body0.get("contract_size") or 0.01),
+ )
+ if not sized.get("ok"):
+ update_plan(conn, int(plan["id"]), note=f"盯盘定仓失败: {sized.get('msg')}"[:500])
+ return None
+ idx = float(cand.get("index_px") or chain.get("index_px") or 0)
+ body = dict(body0)
+ body.update(
+ {
+ "plan_type": "perp_options",
+ "option_primary": True,
+ "watch_entry": 0,
+ "underlying": uly,
+ "direction": direction,
+ "moneyness": money,
+ "opt_inst_id": cand.get("inst_id"),
+ "opt_type": cand.get("opt_type"),
+ "strike": cand.get("strike"),
+ "ask": ask,
+ "ct_mult": ct,
+ "sheets": sized["sheets"],
+ "contracts": sized["contracts"],
+ "eth_qty": sized.get("eth_qty"),
+ "index_px": idx,
+ "entry": idx,
+ "hours_to_expiry": cand.get("hours_to_expiry"),
+ "strike_interval": interval,
+ "min_option_hours": min_h,
+ "option_leverage": opt_lev,
+ "option_perp_ratio": plan.get("option_perp_ratio") or body0.get("option_perp_ratio"),
+ "option_target_points": plan.get("option_target_points") or body0.get("option_target_points"),
+ "perp_target_points": plan.get("perp_target_points") or body0.get("perp_target_points"),
+ "premium_budget": plan.get("premium_budget") or body0.get("premium_budget"),
+ "leverage": plan.get("leverage") or body0.get("leverage") or 100,
+ "exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP",
+ "contract_size": body0.get("contract_size") or 0.01,
+ }
+ )
+ dry = str(os.getenv("HEDGE_PLAN_DRY_RUN") or "").strip().lower() in ("1", "true", "yes", "on")
+ out = execute_perp_options_start(cfg, body, dry_run=dry, persist=None)
+ if not out.get("ok"):
+ update_plan(conn, int(plan["id"]), note=f"盯盘开仓未成: {out.get('msg')}"[:500])
+ return {"plan_id": plan["id"], "watching_open": False, "msg": out.get("msg")}
+ if dry:
+ update_plan(conn, int(plan["id"]), note=f"dry_run命中 {cand.get('inst_id')}"[:500])
+ return {"plan_id": plan["id"], "watching_open": True, "dry_run": True, "inst_id": cand.get("inst_id")}
+ _activate_watching_po(cfg, conn, int(plan["id"]), out, body)
+ return {
+ "plan_id": plan["id"],
+ "watching_open": True,
+ "inst_id": cand.get("inst_id"),
+ "leverage": cand.get("leverage"),
+ }
+
+
def _tick_po_option_primary_pending(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py
index 1f9b577..ec82129 100644
--- a/lib/hedge_plan/hedge_plan_option_primary_lib.py
+++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py
@@ -298,6 +298,43 @@ def validate_option_primary_moneyness(
return None
+def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
+ """盯盘启动校验:只要参数,不要求已选具体合约."""
+ need = (
+ "direction",
+ "exchange_symbol",
+ "premium_budget",
+ "option_target_points",
+ "perp_target_points",
+ "option_perp_ratio",
+ "option_leverage",
+ )
+ for k in need:
+ if body.get(k) in (None, ""):
+ return f"缺少字段: {k}"
+ try:
+ if float(body["premium_budget"]) <= 0:
+ return "权利金须大于 0"
+ if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
+ return "目标位点数须大于 0"
+ if float(body["option_perp_ratio"]) <= 0:
+ return "期权永续比例须大于 0"
+ if float(body["option_leverage"]) <= 0:
+ return "期权杠杆须大于 0"
+ lev_perp = _sf(body.get("leverage"))
+ if lev_perp is not None and lev_perp <= 0:
+ return "永续杠杆须大于 0"
+ except (TypeError, ValueError):
+ return "数值字段无效"
+ direction = str(body.get("direction") or "").strip().lower()
+ if direction not in ("long", "short"):
+ return "方向须为 long 或 short"
+ moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
+ if moneyness not in ("itm", "atm", "otm"):
+ return "期权类型(实/平/虚)无效"
+ return None
+
+
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
need = (
"direction",
@@ -337,7 +374,7 @@ def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
want = opt_type_for_view(direction)
if opt_type != want:
return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}"
- moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "atm").strip().lower()
+ moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
strike = body.get("strike")
@@ -357,6 +394,71 @@ def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
)
+def pick_option_primary_candidate(
+ chain: dict[str, Any],
+ *,
+ direction: str,
+ moneyness: str = "otm",
+ strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
+ min_hours: Any = DEFAULT_MIN_HOURS,
+ min_opt_leverage: Any = None,
+) -> Optional[dict[str, Any]]:
+ """从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
+ from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
+
+ want = opt_type_for_view(direction)
+ m_want = (moneyness or "otm").strip().lower()
+ interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
+ min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
+ try:
+ idx = float(chain.get("index_px") or 0)
+ except (TypeError, ValueError):
+ idx = 0.0
+ if idx <= 0:
+ return None
+
+ best: Optional[dict[str, Any]] = None
+ best_dist: Optional[float] = None
+ for exp in chain.get("expiries") or []:
+ h = hours_to_expiry_from_ms(exp.get("exp_time"))
+ if min_h > 0 and h is not None and h < min_h:
+ continue
+ for c in exp.get("contracts") or []:
+ if str(c.get("opt_type") or "").upper() != want:
+ continue
+ try:
+ k = float(c.get("strike") or 0)
+ ask = float(c.get("ask") or 0)
+ except (TypeError, ValueError):
+ continue
+ if k <= 0 or ask <= 0:
+ continue
+ if interval > 0 and abs(k - idx) > interval + 1e-9:
+ continue
+ m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
+ if m_want == "itm" and m_got not in ("itm", "atm"):
+ continue
+ if m_want == "atm" and m_got != "atm":
+ continue
+ if m_want == "otm" and m_got == "itm":
+ continue
+ min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
+ if min_lev > 0 and idx / ask < min_lev - 1e-9:
+ continue
+ dist = abs(k - idx)
+ if best is None or best_dist is None or dist < best_dist:
+ best = {
+ **dict(c),
+ "hours_to_expiry": h,
+ "exp_time": exp.get("exp_time"),
+ "moneyness": m_got,
+ "index_px": idx,
+ "leverage": round(idx / ask, 1),
+ }
+ best_dist = dist
+ return best
+
+
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
"""情景:期权目标 / 永续目标粗估净利."""
view = str(body.get("direction") or "long").lower()
diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py
index 198d5f9..d0180d4 100644
--- a/lib/hedge_plan/hedge_plan_orders_lib.py
+++ b/lib/hedge_plan/hedge_plan_orders_lib.py
@@ -1078,6 +1078,12 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
)
if is_option_primary(body):
+ from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch
+
+ # 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验
+ watch = body.get("watch_entry")
+ if watch in (None, "", True, 1, "1", "true", "yes", "on"):
+ return validate_option_primary_watch(body)
return validate_option_primary_start(body)
need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol")
for k in need:
@@ -1289,7 +1295,7 @@ def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dic
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
- if st not in ("opening", "active", "partial"):
+ if st not in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"}
notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py
index cd32cab..02bbe22 100644
--- a/lib/hedge_plan/hedge_plan_register.py
+++ b/lib/hedge_plan/hedge_plan_register.py
@@ -387,6 +387,134 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn.close()
+def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int:
+ """以期权为主:只落库盯盘计划,不下单."""
+ from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan
+ from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
+
+ conn = cfg["get_db"]()
+ try:
+ init_hedge_plan_tables(conn)
+ view = str(body.get("direction") or "long")
+ money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
+ plan_id = insert_plan(
+ conn,
+ {
+ "plan_type": "perp_options",
+ "status": "watching",
+ "underlying": str(body.get("underlying") or "ETH").upper(),
+ "direction": view,
+ "entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None,
+ "tp": 0,
+ "sl": 0,
+ "sizing_mode_at_open": None,
+ "perp_size": None,
+ "margin": None,
+ "leverage": float(body.get("leverage") or 100),
+ "premium_total": 0,
+ "preview_json": _start_body_json(body),
+ "close_reason": None,
+ "opened_at": None,
+ "note": "盯盘中:等待杠杆/间隔达标后自动开仓",
+ "option_primary": 1,
+ "perp_direction": perp_direction_for_view(view),
+ "option_target_points": float(body.get("option_target_points") or 0),
+ "perp_target_points": float(body.get("perp_target_points") or 0),
+ "option_perp_ratio": float(body.get("option_perp_ratio") or 0),
+ "premium_budget": float(body.get("premium_budget") or 0),
+ "strike_interval": float(body.get("strike_interval") or 15),
+ "min_option_hours": float(body.get("min_option_hours") or 36),
+ "option_moneyness": money,
+ "option_leverage": float(body.get("option_leverage") or 0),
+ },
+ )
+ conn.commit()
+ return plan_id
+ finally:
+ conn.close()
+
+
+def _activate_watching_po(
+ cfg: dict[str, Any],
+ conn: Any,
+ plan_id: int,
+ result: dict[str, Any],
+ body: dict[str, Any],
+) -> None:
+ """盯盘命中后:写入腿并把 watching → active/partial."""
+ from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan
+ from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
+ from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
+
+ is_partial = bool(result.get("partial"))
+ missing = str(result.get("missing_leg") or "") if is_partial else ""
+ opt = result.get("option") or {}
+ perp = result.get("perp") or {}
+ if is_partial:
+ opt_ok = missing != "option_hedge" and bool(result.get("option"))
+ perp_ok = missing != "perp" and bool(result.get("perp"))
+ else:
+ opt_ok = True
+ perp_ok = True
+ premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
+ view = str(body.get("direction") or "long")
+ perp_dir = (
+ str((perp or {}).get("direction") or "")
+ or perp_direction_for_view(view)
+ )
+ update_plan(
+ conn,
+ int(plan_id),
+ status="partial" if is_partial else "active",
+ entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None,
+ perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0),
+ leverage=float(body.get("leverage") or 100),
+ premium_total=premium,
+ preview_json=_start_body_json(body, missing or None),
+ close_reason="partial_fail" if is_partial else None,
+ opened_at=result.get("opened_at"),
+ note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓",
+ perp_direction=perp_dir,
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": int(plan_id),
+ "leg_role": "perp",
+ "symbol": str(body.get("exchange_symbol") or ""),
+ "side": perp_dir,
+ "size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
+ "avg_open": float(body.get("entry") or 0) if perp_ok else None,
+ "status": "open" if perp_ok else "pending",
+ "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
+ "opened_at": result.get("opened_at") if perp_ok else None,
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": int(plan_id),
+ "leg_role": "option_hedge",
+ "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
+ "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
+ "strike": (opt or {}).get("strike") or body.get("strike"),
+ "side": "buy",
+ "size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
+ "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None,
+ "premium": premium if opt_ok else 0,
+ "ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01),
+ "status": "open" if opt_ok else "pending",
+ "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
+ "opened_at": result.get("opened_at") if opt_ok else None,
+ },
+ )
+ if not is_partial:
+ plan = get_plan(conn, int(plan_id))
+ legs = get_plan_legs(conn, int(plan_id))
+ if plan:
+ notify_plan_start(cfg, conn, plan, legs)
+
+
def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
from lib.hedge_plan.hedge_plan_db import (
get_plan,
@@ -702,6 +830,33 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
body["leverage"] = int(cfg.get("btc_leverage") or 10)
else:
body["leverage"] = int(cfg.get("alt_leverage") or 5)
+ # 以期权为主:策略启动=盯盘,不现场开仓
+ if plan_type == "perp_options":
+ from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
+
+ watch = body.get("watch_entry")
+ watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on")
+ if is_option_primary(body) and watch_on:
+ if dry_run:
+ return jsonify(
+ {
+ "ok": True,
+ "dry_run": True,
+ "watching": True,
+ "msg": "dry_run:将创建盯盘计划(不落库)",
+ "gates": gates,
+ }
+ )
+ plan_id = _persist_po_watching(cfg, body)
+ return jsonify(
+ {
+ "ok": True,
+ "watching": True,
+ "plan_id": plan_id,
+ "msg": "已启动盯盘,杠杆/间隔达标后自动开仓",
+ "gates": gates,
+ }
+ )
if plan_type == "options_options":
out = execute_options_options_start(
cfg,
@@ -904,17 +1059,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
try:
init_hedge_plan_tables(conn)
rows = []
- for status in ("opening", "active", "partial"):
+ for status in ("watching", "opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
for row in rows:
+ if str(row.get("status") or "") == "watching":
+ continue
try:
reconcile_unfilled_option_legs(cfg, conn, int(row["id"]))
except Exception:
pass
# 校正后可能 status 变化,重新拉一遍
rows = []
- for status in ("opening", "active", "partial"):
+ for status in ("watching", "opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows)
diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html
index b14e8be..c3fe9bf 100644
--- a/lib/hedge_plan/templates/hedge_plan_panel.html
+++ b/lib/hedge_plan/templates/hedge_plan_panel.html
@@ -56,7 +56,7 @@
账户:永续腿走合约账户(USDT);期权腿走期权账户(USDC)。两账户分开下单、资金不互通。
模式:在 env HEDGE_PLAN_OPTION_PRIMARY 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。
保险模式:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。
- 以期权为主:做多买 Call+永续空、做空买 Put+永续多;左分资金/选约/出场三组;右侧上永续下期权(含杠杆),按类型自动匹配;开仓先期权后市价永续。
+ 以期权为主:填参后点「策略启动」进入盯盘(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。
-
+
@@ -403,4 +403,4 @@
-
+
diff --git a/tests/test_hedge_plan_option_primary.py b/tests/test_hedge_plan_option_primary.py
index 6cefe8f..7505dc5 100644
--- a/tests/test_hedge_plan_option_primary.py
+++ b/tests/test_hedge_plan_option_primary.py
@@ -9,9 +9,11 @@ from lib.hedge_plan.hedge_plan_option_primary_lib import (
opt_type_for_view,
option_bid_liquidity_ok,
perp_direction_for_view,
+ pick_option_primary_candidate,
size_from_premium,
target_hit,
validate_option_primary_start,
+ validate_option_primary_watch,
)
from lib.hedge_plan.hedge_plan_orders_lib import build_po_path_plan, validate_start_body
@@ -93,9 +95,25 @@ class TestOptionPrimary(unittest.TestCase):
self.assertEqual(path[1]["direction"], "short")
self.assertFalse(path[1]["attach_tpsl"])
- def test_validate_option_primary_start(self):
+ def test_validate_option_primary_watch_and_start(self):
+ watch_body = {
+ "option_primary": True,
+ "watch_entry": 1,
+ "direction": "long",
+ "exchange_symbol": "ETH/USDT:USDT",
+ "premium_budget": 100,
+ "option_target_points": 50,
+ "perp_target_points": 30,
+ "option_perp_ratio": 4,
+ "option_leverage": 200,
+ "moneyness": "otm",
+ }
+ self.assertIsNone(validate_option_primary_watch(watch_body))
+ self.assertIsNone(validate_start_body("perp_options", watch_body))
+
body = {
"option_primary": True,
+ "watch_entry": 0,
"direction": "long",
"contracts": 1,
"opt_inst_id": "ETH-USD-260831-1900-C",
@@ -120,6 +138,31 @@ class TestOptionPrimary(unittest.TestCase):
bad = dict(body, opt_type="P")
self.assertIsNotNone(validate_start_body("perp_options", bad))
+ def test_pick_candidate_respects_leverage(self):
+ chain = {
+ "index_px": 1900,
+ "expiries": [
+ {
+ "exp_time": 9_999_999_999_999,
+ "contracts": [
+ {"inst_id": "LOW", "opt_type": "C", "strike": 1920, "ask": 20, "ct_mult": 0.01},
+ {"inst_id": "OK", "opt_type": "C", "strike": 1925, "ask": 8, "ct_mult": 0.01},
+ ],
+ }
+ ],
+ }
+ # 1900/20=95 < 200; 1900/8=237.5 ≥ 200
+ picked = pick_option_primary_candidate(
+ chain,
+ direction="long",
+ moneyness="otm",
+ strike_interval=50,
+ min_hours=1,
+ min_opt_leverage=200,
+ )
+ self.assertIsNotNone(picked)
+ self.assertEqual(picked["inst_id"], "OK")
+
def test_preview_builds_scenarios(self):
body = {
"direction": "long",