feat(hedge): option-primary watch entry with leverage gate

Start strategy arms a watching plan instead of opening immediately; list filters by leverage; type is a dropdown defaulting to OTM.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-09 09:27:42 +08:00
parent 6ab27cebcd
commit eca6d091e9
10 changed files with 608 additions and 39 deletions
+3 -3
View File
@@ -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. 左卡默认
+125 -6
View File
@@ -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 '<span class="hp-plan-watching">盯盘中</span>';
}
if ((p.status || "") === "partial") {
return '<span class="hp-plan-partial">半腿待补</span>';
}
@@ -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 = '<tr><td colspan="5" class="err">' + (e.message || e) + "</td></tr>";
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="err">' + (e.message || e) + "</td></tr>";
}
}
+8 -10
View File
@@ -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,
+25 -4
View File
@@ -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
+124 -1
View File
@@ -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]]:
+103 -1
View File
@@ -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()
+7 -1
View File
@@ -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))
+159 -2
View File
@@ -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)
+10 -10
View File
@@ -56,7 +56,7 @@
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
<p><strong>以期权为主</strong>做多买 Call+永续空、做空买 Put+永续多;左分资金/选约/出场三组;右侧上永续下期权(含杠杆),按类型自动匹配;开仓先期权市价永续。</p>
<p><strong>以期权为主</strong>填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权市价永续。右侧列表仅展示达标候选。</p>
</div>
</details>
<div class="form-row hp-uly-row">
@@ -116,14 +116,14 @@
<span class="hp-po-field-lab">期权间隔 <em></em></span>
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<div class="hp-po-field hp-po-field--type">
<label class="hp-po-field hp-po-field--type">
<span class="hp-po-field-lab">类型</span>
<div class="hp-oo-seg hp-po-type-seg" role="group" aria-label="虚实值类型">
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值"><span class="hp-oo-check" aria-hidden="true"></span>实/平</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值"><span class="hp-oo-check" aria-hidden="true"></span>平值</button>
<button type="button" class="btn-secondary hp-money-btn hp-money-otm" data-money="otm" title="虚值"><span class="hp-oo-check" aria-hidden="true"></span></button>
</div>
</div>
<select id="hp-money-select" aria-label="虚实值类型">
<option value="otm" selected>虚值</option>
<option value="itm">实值/平值</option>
<option value="atm">仅平</option>
</select>
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
@@ -194,7 +194,7 @@
</div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn">计算</button>
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
</div>
</div>
</div>
@@ -403,4 +403,4 @@
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=42"></script>
<script src="/static/hedge_plan.js?v=43"></script>
+44 -1
View File
@@ -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",