diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index ccda357..91584d4 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -39,6 +39,7 @@ ooCloseMode: "close_all", direction: "long", optionPrimary: root.getAttribute("data-option-primary") !== "0", + exitMode: "profit", // profit=盈利金额出场 | target=目标位出场 opLevTouched: false, opRatioTouched: false, perpLevTouched: false, @@ -449,9 +450,24 @@ }; } + function orderSheets() { + const right = numInput("hp-order-sheets", 0); + if (right > 0) return Math.floor(right); + const left = numInput("hp-sheets", 0); + return left > 0 ? Math.floor(left) : 0; + } + + function syncSheetsInputs(fromId) { + const n = numInput(fromId, 0); + if (!(n > 0)) return; + const v = String(Math.floor(n)); + if (fromId !== "hp-sheets" && $("hp-sheets")) $("hp-sheets").value = v; + if (fromId !== "hp-order-sheets" && $("hp-order-sheets")) $("hp-order-sheets").value = v; + } + function currentOpSizing() { if (!state.selected) return null; - const sheets = numInput("hp-sheets", 0); + const sheets = orderSheets(); return computeOpSizing( state.selected.ask, state.selected.ct_mult || 0.01, @@ -489,6 +505,112 @@ return Number.isFinite(eth) && eth > 0 ? eth : 5; } + /** 按交易账户 USDT(买币+5%滑点)与合约账户永续杠杆算最大期权张数. */ + function computeMaxSheets() { + const ask = state.selected && Number(state.selected.ask); + const ct = Number((state.selected && state.selected.ct_mult) || 0.01); + const ratio = numInput("hp-opt-perp-ratio", 2); + const cs = Number((state.market && state.market.contract_size) || 0.01); + const mark = Number((state.market && (state.market.mark || state.market.index_px)) || indexPx() || 0); + const lev = defaultPerpLeverage(state.underlying); + const tradingUsdt = Number(state.tradingUsdt != null ? state.tradingUsdt : state.tradingUsdc); + const perpAvail = Number((state.market && state.market.available_usdt) || 0); + if (!(ask > 0) || !(ct > 0) || !(ratio > 0) || !(cs > 0) || !(mark > 0) || !(lev > 0)) { + return null; + } + // 买币:权利金币×1.05×指数 ≈ 所需 USDT + const coinPerSheet = ask * ct; + const usdtPerSheet = coinPerSheet * 1.05 * mark; + let maxByOpt = Infinity; + if (tradingUsdt > 0 && usdtPerSheet > 0) { + maxByOpt = Math.floor(tradingUsdt / usdtPerSheet + 1e-12); + } + // 永续:名义 = 保证金×杠杆; 永续币 = 期权币/比例; 合约张 = 永续币/cs + // 期权张 n → 永续名义 = (n*ct/ratio)*mark ≤ avail*lev + let maxByPerp = Infinity; + if (perpAvail > 0) { + const maxPerpNotional = perpAvail * lev; + const notionalPerSheet = (ct / ratio) * mark; + if (notionalPerSheet > 0) { + maxByPerp = Math.floor(maxPerpNotional / notionalPerSheet + 1e-12); + } + } + const maxSheets = Math.max(0, Math.min(maxByOpt, maxByPerp)); + if (!(maxSheets > 0) && !(tradingUsdt > 0) && !(perpAvail > 0)) return null; + return { + max_sheets: maxSheets, + max_by_opt: Number.isFinite(maxByOpt) ? maxByOpt : null, + max_by_perp: Number.isFinite(maxByPerp) ? maxByPerp : null, + lev: lev, + trading_usdt: tradingUsdt, + perp_avail: perpAvail, + usdt_per_sheet: usdtPerSheet, + }; + } + + function updatePoOpenSummary() { + const maxEl = $("hp-po-max-line"); + const perpEl = $("hp-po-perp-auto"); + const maxInfo = computeMaxSheets(); + const sized = currentOpSizing(); + const coin = (state.underlying || "ETH").toUpperCase(); + const lev = defaultPerpLeverage(state.underlying); + if (maxEl) { + if (maxInfo) { + maxEl.innerHTML = + "最大约 " + + maxInfo.max_sheets + + " 张 · 永续杠杆 " + + lev + + "x(env) · 交易 USDT " + + fmt(maxInfo.trading_usdt, 2) + + " · 合约可用 " + + fmt(maxInfo.perp_avail, 2); + } else { + maxEl.textContent = "选择期权并刷新行情后显示最大开仓张数(交易账户买币+永续杠杆)"; + } + } + if (perpEl) { + if (sized) { + perpEl.innerHTML = + "永续自动开 " + + fmt(sized.perp_qty, 4) + + " " + + coin + + " / " + + fmt(sized.contracts, coin === "BTC" ? 4 : 2) + + " 张 · 买币约 " + + fmt(sized.premium_est * 1.05, 4) + + " " + + coin + + "(含5%滑点)"; + } else { + perpEl.textContent = "永续开仓 — (填张数并选择期权后按比例自动算)"; + } + } + } + + function syncExitModeUI() { + const mode = state.exitMode === "target" ? "target" : "profit"; + document.querySelectorAll(".hp-po-exit-mode").forEach(function (b) { + const on = (b.getAttribute("data-exit") || "") === mode; + b.classList.toggle("is-selected", on); + b.classList.toggle("active", on); + }); + const profitBox = $("hp-po-exit-profit"); + const targetBox = $("hp-po-exit-target"); + if (profitBox) { + profitBox.classList.toggle("hidden", mode !== "profit"); + if (mode === "profit") profitBox.removeAttribute("hidden"); + else profitBox.setAttribute("hidden", "hidden"); + } + if (targetBox) { + targetBox.classList.toggle("hidden", mode !== "target"); + if (mode === "target") targetBox.removeAttribute("hidden"); + else targetBox.setAttribute("hidden", "hidden"); + } + } + function syncUnderlyingUI() { const uly = state.underlying || "ETH"; document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { @@ -622,12 +744,11 @@ return (Math.round((k / a) * 10) / 10).toFixed(1) + "×"; } - /** 永期期权行情杠杆:指数÷卖一(与选约杠杆门一致). */ + /** CoinOX 币本位:卖一为币报价 → 杠杆≈1÷卖一(与期权页一致). */ function calcIndexAskLeverage(ask) { - const idx = indexPx(); const a = Number(ask); - if (!(idx > 0) || !(a > 0)) return "—"; - return (Math.round((idx / a) * 10) / 10).toFixed(1) + "×"; + if (!(a > 0)) return "—"; + return (Math.round((1 / a) * 10) / 10).toFixed(1) + "×"; } function clearPoSelection() { @@ -800,6 +921,11 @@ if (acct.trading_usdc != null && acct.trading_usdc !== "") { state.tradingUsdc = Number(acct.trading_usdc); } + if (acct.trading_usdt != null && acct.trading_usdt !== "") { + state.tradingUsdt = Number(acct.trading_usdt); + } else if (acct.trading_usdc != null && acct.trading_usdc !== "") { + state.tradingUsdt = Number(acct.trading_usdc); + } if (acct.funding_usdc != null && acct.funding_usdc !== "") { state.fundingUsdc = Number(acct.funding_usdc); } @@ -1174,12 +1300,20 @@ const label = $("hp-opt-type-label"); if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put"; updatePerpPnlHint(); + updatePoOpenSummary(); } function updatePerpPnlHint() { const el = $("hp-perp-pnl-line"); if (!el) return; if (isOptionPrimary()) { + if (state.exitMode === "profit") { + const p = numInput("hp-profit-target", NaN); + el.innerHTML = p > 0 + ? '目标净利 ' + fmt(p, 2) + ' USDT (扣开平双边手续费后)' + : '填写目标盈利金额(扣买入+卖出手续费后的组合净利)'; + return; + } const n = numInput("hp-opt-target-pts", NaN); const m = numInput("hp-perp-target-pts", NaN); const k = state.selected && Number(state.selected.strike); @@ -1195,7 +1329,7 @@ (optT != null ? fmt(optT, 2) : "—") + " · 永续目标指数 " + (perpT != null ? fmt(perpT, 2) : "—") + - ' (相对K;期权目标需买一且扣费净利>0)'; + ' (相对K;须买一流动性且扣费净利>0)'; return; } const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN); @@ -1324,10 +1458,17 @@ } updatePremiumLine(); if (isOptionPrimary()) { - const sized = computeOpSizing(c.ask, c.ct_mult || 0.01); - if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets); + // 手动下单以张数为主:选约后若未填张数则默认 1,并同步左右 + if (!(orderSheets() > 0)) { + if ($("hp-sheets")) $("hp-sheets").value = "1"; + if ($("hp-order-sheets")) $("hp-order-sheets").value = "1"; + } else { + syncSheetsInputs("hp-order-sheets"); + if (!(numInput("hp-order-sheets", 0) > 0)) syncSheetsInputs("hp-sheets"); + } void loadMarket(); updatePerpPnlHint(); + updatePoOpenSummary(); } } @@ -2028,6 +2169,7 @@ const sz = $("hp-sizing-line"); const sized = currentOpSizing(); if (sz && sized) sz.innerHTML = formatOpSizingHtml(sized); + updatePoOpenSummary(); void loadMarket(); }); } @@ -2155,17 +2297,38 @@ } if ($("hp-exp-select")) $("hp-exp-select").addEventListener("change", renderListStrikes); if ($("hp-oo-exp-select")) $("hp-oo-exp-select").addEventListener("change", renderTStrikes); + function onSheetsInput(fromId) { + syncSheetsInputs(fromId); + updatePremiumLine(); + if (isOptionPrimary()) { + const sz = $("hp-sizing-line"); + const sized = currentOpSizing(); + if (sz && sized) sz.innerHTML = formatOpSizingHtml(sized); + updatePoOpenSummary(); + void loadMarket(); + } + } if ($("hp-sheets")) { $("hp-sheets").addEventListener("input", function () { - updatePremiumLine(); - if (isOptionPrimary() && state.selected) { - const sz = $("hp-sizing-line"); - const sized = currentOpSizing(); - if (sz && sized) sz.innerHTML = formatOpSizingHtml(sized); - void loadMarket(); - } + onSheetsInput("hp-sheets"); }); } + if ($("hp-order-sheets")) { + $("hp-order-sheets").addEventListener("input", function () { + onSheetsInput("hp-order-sheets"); + }); + } + document.querySelectorAll(".hp-po-exit-mode").forEach(function (b) { + b.addEventListener("click", function () { + state.exitMode = b.getAttribute("data-exit") === "target" ? "target" : "profit"; + syncExitModeUI(); + updatePerpPnlHint(); + }); + }); + if ($("hp-profit-target")) { + $("hp-profit-target").addEventListener("input", updatePerpPnlHint); + } + syncExitModeUI(); ["hp-oo-sheets-a", "hp-oo-sheets-b"].forEach(function (id) { const el = $(id); if (el) el.addEventListener("input", updateOoPremiumLine); @@ -2820,21 +2983,30 @@ if (!state.selected) throw new Error("请选用期权腿"); if (isOptionPrimary()) { const sized = currentOpSizing(); - if (!sized) throw new Error("请选择期权并填写张数(或权利金预算)"); - const optPts = numInput("hp-opt-target-pts", NaN); - const perpPts = numInput("hp-perp-target-pts", NaN); - if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + if (!sized || !(sized.sheets > 0)) throw new Error("请选择期权并填写张数"); + const exitMode = state.exitMode === "target" ? "target" : "profit"; + let optPts = 0; + let perpPts = 0; + let profitTarget = 0; + if (exitMode === "profit") { + profitTarget = numInput("hp-profit-target", NaN); + if (!(profitTarget > 0)) throw new Error("请填写目标盈利金额(USDT,扣双边手续费后净利)"); + } else { + optPts = numInput("hp-opt-target-pts", NaN); + perpPts = numInput("hp-perp-target-pts", NaN); + if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)"); + } const exp = currentExp("hp-exp-select"); const entry = indexPx() || Number((state.market && state.market.mark) || 0); const premEst = Number(state.selected.ask || 0) * sized.sheets * Number(state.selected.ct_mult || 0.01); - const premBudget = numInput("hp-premium-budget", 0); body = { plan_type: "perp_options", option_primary: true, market_order: 1, + exit_mode: exitMode, underlying: state.underlying, direction: getDirection(), entry: entry, @@ -2848,16 +3020,18 @@ ask: state.selected.ask, index_px: entry, exchange_symbol: (state.market && state.market.exchange_symbol) || "", - leverage: numInput("hp-perp-leverage", defaultPerpLeverage(state.underlying)), + leverage: defaultPerpLeverage(state.underlying), option_leverage: 0, - premium_budget: premBudget > 0 ? premBudget : Math.max(premEst / 0.95, premEst), + premium_budget: Math.max(premEst / 0.95, premEst), option_perp_ratio: numInput("hp-opt-perp-ratio", 2), - option_target_points: optPts, - perp_target_points: perpPts, + option_target_points: optPts || 0, + perp_target_points: perpPts || 0, + profit_target_usdt: profitTarget || 0, strike_interval: 0, min_option_hours: 0, moneyness: (state.selected.moneyness || opMoneyKind() || "all").toLowerCase(), hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null, + spot_buy_buffer: 1.05, }; } else { const m = (state.selected.moneyness || "").toLowerCase(); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 57e4602..e45bc7a 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3937,6 +3937,22 @@ html[data-theme="light"] .opt-be-dist-down { font-size: 0.78rem; line-height: 1.4; } +.hedge-plan-page-wrap .hp-po-exit-seg { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0 10px; +} +.hedge-plan-page-wrap .hp-po-max-line, +.hedge-plan-page-wrap .hp-po-perp-auto { + margin: 4px 0; + font-size: 0.8rem; + line-height: 1.4; +} +.hedge-plan-page-wrap #hp-perp-leverage[readonly] { + opacity: 0.85; + cursor: default; +} .hedge-plan-page-wrap .hp-po-opt-toolbar { flex-wrap: wrap; gap: 6px 8px; diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 319452f..729d116 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -87,6 +87,9 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: _ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT") _ensure_column(conn, "hedge_plans", "option_leverage", "REAL") _ensure_column(conn, "hedge_plans", "perp_direction", "TEXT") + # 出场:profit=盈利金额(扣双边费);target=目标位点数 + _ensure_column(conn, "hedge_plans", "exit_mode", "TEXT") + _ensure_column(conn, "hedge_plans", "profit_target_usdt", "REAL") _ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL") diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 9dddff5..f0f0eae 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -172,6 +172,52 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None: notify_plan_end(cfg, conn, plan) +def _sell_all_coin_to_usdt(cfg: dict[str, Any], underlying: str) -> dict[str, Any]: + """平仓后把交易账户标的币全部卖回 USDT.""" + try: + from lib.options.options_spot_bridge_lib import spot_market_sell_coin_to_usdt + + ex = cfg.get("exchange_options") or cfg.get("exchange") + if ex is None: + return {"ok": False, "msg": "无交易所实例,跳过卖币"} + return spot_market_sell_coin_to_usdt(ex, underlying=str(underlying or "ETH").upper()) + except Exception as e: + return {"ok": False, "msg": str(e)} + + +def _plan_exit_mode(plan: dict[str, Any]) -> str: + raw = str(plan.get("exit_mode") or "").strip().lower() + if raw in ("profit", "target"): + return raw + # 兼容旧单:preview_json + try: + import json + + meta = json.loads(plan.get("preview_json") or "{}") + m = str(meta.get("exit_mode") or "").strip().lower() + if m in ("profit", "target"): + return m + except Exception: + pass + return "target" + + +def _plan_profit_target(plan: dict[str, Any]) -> float: + v = _sf(plan.get("profit_target_usdt")) + if v is not None and v > 0: + return float(v) + try: + import json + + meta = json.loads(plan.get("preview_json") or "{}") + v2 = _sf(meta.get("profit_target_usdt")) + if v2 is not None and v2 > 0: + return float(v2) + except Exception: + pass + return 0.0 + + def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str: """盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平). @@ -639,7 +685,7 @@ def _tick_po_option_primary_expiry( def _tick_po_option_primary( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: - """以期权为主:触达目标位立即执行分叉平仓规则.""" + """以期权为主:盈利金额出场或目标位出场;先平期权(须买一流动性)再平永续,再卖币回 U.""" from lib.hedge_plan.hedge_plan_option_primary_lib import ( estimate_combo_net_pnl, option_bid_liquidity_ok, @@ -662,6 +708,8 @@ def _tick_po_option_primary( strike = _sf(opt.get("strike")) n = _sf(plan.get("option_target_points")) m = _sf(plan.get("perp_target_points")) + exit_mode = _plan_exit_mode(plan) + profit_target = _plan_profit_target(plan) if strike is None or strike <= 0: return None idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) @@ -670,7 +718,13 @@ def _tick_po_option_primary( hit_opt = bool(n is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(n))) hit_perp = bool(m is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(m))) - if not hit_opt and not hit_perp: + # 盈利金额出场:不依赖点数,后面按净利判断 + if exit_mode == "profit": + if profit_target <= 0: + return None + hit_opt = True + hit_perp = False + elif not hit_opt and not hit_perp: return None symbol = str(perp.get("symbol") or "") @@ -710,7 +764,7 @@ def _tick_po_option_primary( ct = float(opt.get("ct_mult") or 0.01) contracts = float(perp.get("size") or plan.get("perp_size") or 0) - # 优先期权目标;买一不足或净利≤0 时若永续目标已触达则改走永续目标 + # 优先期权腿平仓(须买一流动性);盈利金额模式要求扣双边费后净利≥目标 if hit_opt: liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets) net = None @@ -729,9 +783,14 @@ def _tick_po_option_primary( contracts=contracts, contract_size=cs, ) - can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0) + if exit_mode == "profit": + can_opt_exit = bool( + liq_ok and net is not None and float(net.get("net") or 0) + 1e-9 >= profit_target + ) + else: + can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0) if can_opt_exit: - reason = "opt_target_points" + reason = "profit_target_usdt" if exit_mode == "profit" else "opt_target_points" close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=sheets) if close_r.get("already_flat"): opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"]) @@ -783,8 +842,26 @@ def _tick_po_option_primary( stats_bucket="opt_primary", closed_at=_now(), ) + sell_r = _sell_all_coin_to_usdt(cfg, str(plan.get("underlying") or "ETH")) _notify_end_reload(cfg, conn, int(plan["id"])) - return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net} + return { + "plan_id": plan["id"], + "close_reason": reason, + "total": total, + "net": net, + "sell_coin": sell_r, + } + if exit_mode == "profit": + return { + "plan_id": plan["id"], + "skip": True, + "msg": ( + liq_msg + if not liq_ok + else f"净利未达目标 {profit_target:g}(当前 {float((net or {}).get('net') or 0):.2f})" + ), + "net": net, + } if not hit_perp: return { "plan_id": plan["id"], diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py index 6b53af0..0de83f0 100644 --- a/lib/hedge_plan/hedge_plan_option_primary_lib.py +++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py @@ -350,18 +350,19 @@ def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]: def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: market = body.get("market_order") in (True, 1, "1", "true", "yes", "on") + exit_mode = str(body.get("exit_mode") or ("target" if not market else "profit")).strip().lower() + if exit_mode not in ("profit", "target"): + exit_mode = "profit" if market else "target" need = ( "direction", "contracts", "opt_inst_id", "sheets", "exchange_symbol", - "option_target_points", - "perp_target_points", "option_perp_ratio", ) if not market: - need = need + ("premium_budget",) + need = need + ("premium_budget", "option_target_points", "perp_target_points") for k in need: if body.get(k) in (None, ""): return f"缺少字段: {k}" @@ -371,8 +372,13 @@ def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]: prem = _sf(body.get("premium_budget")) if not market and (prem is None or prem <= 0): return "权利金须大于 0" - if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0: - return "目标位点数须大于 0" + if exit_mode == "profit": + pt = _sf(body.get("profit_target_usdt")) + if pt is None or pt <= 0: + return "盈利金额出场须填写目标盈利(USDT)" + else: + if float(body.get("option_target_points") or 0) <= 0 or float(body.get("perp_target_points") or 0) <= 0: + return "目标位点数须大于 0" if float(body["option_perp_ratio"]) <= 0: return "期权永续比例须大于 0" except (TypeError, ValueError): diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 304263a..881cc0e 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -204,7 +204,10 @@ def _buy_option( ) if idx <= 0: return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "quote": q} - buf = normalize_coin_spot_buy_buffer(None) + # 权利金币×缓冲买币;默认 +5%滑点(1.05),可经 cfg.spot_buy_buffer / env 覆盖 + buf = normalize_coin_spot_buy_buffer( + cfg.get("spot_buy_buffer") if cfg.get("spot_buy_buffer") is not None else 1.05 + ) need_coin = float(premium) * float(buf) avail = float(fetch_trading_coin_available(ex, underlying) or 0) if avail + 1e-12 < need_coin: @@ -774,28 +777,46 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An cs = float(get_cs(sym) or cs) except Exception: pass - sized = size_from_premium( - premium_budget=float(body.get("premium_budget") or 0), - ask=float(body["ask"]), - ct_mult=float(body.get("ct_mult") or 0.01), - ratio=float(body.get("option_perp_ratio") or 2), - contract_size=cs, - ) - if not sized.get("ok"): - return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} - body["sheets"] = sized["sheets"] - body["contracts"] = sized["contracts"] - body["eth_qty"] = sized["eth_qty"] + market = body.get("market_order") in (True, 1, "1", "true", "yes", "on") + ct = float(body.get("ct_mult") or q.get("ct_mult") or 0.01) + ratio = float(body.get("option_perp_ratio") or 2) body["contract_size"] = cs + if market and float(body.get("sheets") or 0) > 0: + # 手动下单:以张数为准,永续按比例 + sheets_i = float(int(float(body["sheets"]))) + eth = round(sheets_i * ct, 2) + body["sheets"] = sheets_i + body["eth_qty"] = eth + body["contracts"] = (eth / ratio) / cs if ratio > 0 and cs > 0 else 0 + sized = { + "ok": True, + "sheets": sheets_i, + "eth_qty": eth, + "contracts": body["contracts"], + "ratio": ratio, + } + else: + sized = size_from_premium( + premium_budget=float(body.get("premium_budget") or 0), + ask=float(body["ask"]), + ct_mult=ct, + ratio=ratio, + contract_size=cs, + ) + if not sized.get("ok"): + return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} + body["sheets"] = sized["sheets"] + body["contracts"] = sized["contracts"] + body["eth_qty"] = sized["eth_qty"] # 深度不足则缩量 ask_sz = float(q.get("ask_sz") or 0) if ask_sz > 0 and float(body["sheets"]) > ask_sz: body["sheets"] = float(int(ask_sz)) if body["sheets"] <= 0: return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized} - eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2) + eth = round(float(body["sheets"]) * ct, 2) body["eth_qty"] = eth - body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs + body["contracts"] = (eth / ratio) / cs if ratio > 0 and cs > 0 else 0 return { "ok": True, "ask": float(q["ask"]), @@ -806,8 +827,13 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An "sizing": sized, "quote": q, "msg": ( - f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " + f"手动定仓: {body.get('eth_qty')}币 / " f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" + if market + else ( + f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " + f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" + ) ), } return { @@ -827,6 +853,13 @@ def execute_perp_options_start( dry_run: bool = False, persist: Optional[Callable[..., Any]] = None, ) -> dict[str, Any]: + # 买币滑点缓冲:手动单默认 +5% + if body.get("spot_buy_buffer") is not None: + cfg = dict(cfg) + cfg["spot_buy_buffer"] = body.get("spot_buy_buffer") + elif body.get("market_order") in (True, 1, "1", "true", "yes", "on"): + cfg = dict(cfg) + cfg["spot_buy_buffer"] = 1.05 refresh = refresh_po_option_quote_before_start(cfg, body) if not refresh.get("ok"): return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh} diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 8cdd9f0..3fef8e9 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -340,6 +340,9 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "perp_direction": perp_dir, } if opt_primary: + exit_mode = str(body.get("exit_mode") or "target").strip().lower() + if exit_mode not in ("profit", "target"): + exit_mode = "target" plan_row.update( { "option_target_points": float(body.get("option_target_points") or 0), @@ -349,6 +352,8 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "strike_interval": float(body.get("strike_interval") or 15), "min_option_hours": float(body.get("min_option_hours") or 36), "option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""), + "exit_mode": exit_mode, + "profit_target_usdt": float(body.get("profit_target_usdt") or 0), } ) plan_id = insert_plan(conn, plan_row) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index e3fcc05..990f718 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -87,7 +87,7 @@
手动下单:左侧人工选约(同期权页:到期/Call·Put/实虚值),点「选择」后右侧下单;开仓按卖一限价(非交易所市价单);非条件盯盘。
-组合:买 Call → 永续空;买 Put → 永续多。
-比例:期权:永续默认 2(期权名义 ÷ 比例 = 永续名义)。例:期权 1 BTC → 永续 0.5 BTC。
+手动下单:左侧人工选约,右侧填张数;开仓按卖一限价。先开期权(按张数买币并+5%滑点),再开永续。
+组合:买 Call → 永续空;买 Put → 永续多。永续杠杆取 env:BTC=BTC_LEVERAGE(默认10)/ETH=ALT_LEVERAGE(默认5)。
出场:盈利金额(扣开平双边手续费后净利)或目标位点数二选一。平仓先期权(须买一流动性)再永续,再把币全部卖回 USDT。一账户同时仅一笔对冲仓。
+比例:默认 2(期权名义 ÷ 比例 = 永续名义)。
最大开仓 —
+永续开仓 — (按比例自动)
+ {# 兼容旧 JS:权利金隐藏,市价/手动用张数定仓 #} +