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:
+3
-3
@@ -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. 左卡默认
|
||||
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user