diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md index 7c6f5e3..be86c17 100644 --- a/docs/对冲计划开发方案.md +++ b/docs/对冲计划开发方案.md @@ -93,7 +93,11 @@ - **T 型报价链**(复用期权页 T 型样式/数据结构). - 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). -- 预算受 `OKX_OPTIONS_TRADE_BUDGET_USDC` 等既有约束;可拆预算到两腿. +- 预算:`B = min(交易户 USDC × OKX_OPTIONS_BUDGET_BUFFER, OKX_OPTIONS_TRADE_BUDGET_USDC)`(默认 buffer=0.95). +- 自动张数(选齐两腿后写入,可手改): + - **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n` + - **均分预算**:各用 `B/2` 反推张数(两腿可不同) + - 另受各自卖一深度上限约束 ### 4.2 目标价 diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index a856708..d0310bb 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -28,6 +28,10 @@ legA: null, legB: null, market: null, + ooSheetsMode: "same_sheets", + tradingUsdc: null, + tradeBudgetUsdc: null, + budgetBuffer: 0.95, }; function $(id) { @@ -176,6 +180,16 @@ const label = (chain && chain.account_label) || acct.label || "期权账户"; const tag = $("hp-opt-acct-tag"); if (tag) tag.textContent = label; + if (acct.trading_usdc != null && acct.trading_usdc !== "") { + state.tradingUsdc = Number(acct.trading_usdc); + } + if (chain && chain.trade_budget_usdc != null && chain.trade_budget_usdc !== "") { + state.tradeBudgetUsdc = Number(chain.trade_budget_usdc); + } + if (chain && chain.budget_buffer != null && chain.budget_buffer !== "") { + const buf = Number(chain.budget_buffer); + if (!Number.isNaN(buf) && buf > 0) state.budgetBuffer = buf; + } const line = label + " · 交易 USDC " + @@ -186,6 +200,132 @@ if (el) el.textContent = line; const oo = $("hp-oo-bal-line"); if (oo) oo.textContent = line; + autoFillOoSheets(); + } + + function resolveOoBudget() { + const buf = state.budgetBuffer > 0 ? state.budgetBuffer : 0.95; + const trading = state.tradingUsdc; + const cap = state.tradeBudgetUsdc; + let tradingCap = null; + let tradeCap = null; + if (trading != null && !Number.isNaN(Number(trading))) { + tradingCap = Math.max(0, Number(trading) * buf); + } + if (cap != null && !Number.isNaN(Number(cap))) { + tradeCap = Math.max(0, Number(cap)); + } + if (tradingCap == null && tradeCap == null) { + return { ok: false, budget: 0, tradingCap: null, tradeCap: null, buf: buf, msg: "缺少交易户余额与单笔预算" }; + } + let budget = 0; + if (tradingCap == null) budget = tradeCap; + else if (tradeCap == null) budget = tradingCap; + else budget = Math.min(tradingCap, tradeCap); + budget = Math.floor(budget * 1e6 + 1e-12) / 1e6; + return { + ok: budget > 0, + budget: budget, + tradingCap: tradingCap, + tradeCap: tradeCap, + buf: buf, + msg: budget > 0 ? "" : "可用预算为 0", + }; + } + + function unitCost(c) { + if (!c) return 0; + const ask = Number(c.ask || 0); + if (!(ask > 0)) return 0; + return ask * Number(c.ct_mult || 0.01); + } + + function capByDepth(n, askSz) { + let out = Math.max(0, Math.floor(Number(n) || 0)); + if (askSz == null || askSz === "") return out; + const d = Number(askSz); + if (Number.isNaN(d)) return out; + if (d <= 0) return 0; + return Math.min(out, Math.floor(d + 1e-12)); + } + + function suggestOoSheetsLocal(budget, mode) { + const costA = unitCost(state.legA); + const costB = unitCost(state.legB); + if (!(budget > 0)) { + return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "可用预算为 0" }; + } + if (!(costA > 0) || !(costB > 0)) { + return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "缺少有效卖一价,无法建议张数" }; + } + let nA = 0; + let nB = 0; + if (mode === "split_budget") { + const half = budget / 2; + nA = Math.floor(half / costA + 1e-12); + nB = Math.floor(half / costB + 1e-12); + } else { + const pair = costA + costB; + const n = pair > 0 ? Math.floor(budget / pair + 1e-12) : 0; + nA = n; + nB = n; + } + nA = capByDepth(nA, state.legA && state.legA.ask_sz); + nB = capByDepth(nB, state.legB && state.legB.ask_sz); + if (mode !== "split_budget") { + const n = Math.min(nA, nB); + nA = n; + nB = n; + } + const premium = costA * nA + costB * nB; + const ok = nA >= 1 && nB >= 1; + return { + sheetsA: nA, + sheetsB: nB, + premium: premium, + ok: ok, + msg: ok ? "" : "预算不够开 1+1(或卖一深度不足)", + }; + } + + function syncOoSizeModeUI() { + document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) { + const on = b.getAttribute("data-oo-size") === state.ooSheetsMode; + b.classList.toggle("active", on); + }); + } + + function updateOoBudgetLine(extra) { + const line = $("hp-oo-budget-line"); + if (!line) return; + const b = resolveOoBudget(); + const modeLabel = state.ooSheetsMode === "split_budget" ? "均分预算" : "同张数"; + const parts = [ + "预算 min(交易×" + fmt(b.buf, 2) + ", 单笔) ≈ " + fmt(b.budget, 2) + " USDC", + "模式:" + modeLabel, + ]; + if (b.tradingCap != null) parts.push("交易×缓冲 " + fmt(b.tradingCap, 2)); + if (b.tradeCap != null) parts.push("单笔上限 " + fmt(b.tradeCap, 2)); + if (extra && extra.msg) parts.push(extra.msg); + else if (b.msg) parts.push(b.msg); + line.textContent = parts.join(" · "); + line.classList.toggle("hp-oo-budget-warn", !!(extra && extra.msg) || !b.ok); + } + + function autoFillOoSheets() { + syncOoSizeModeUI(); + if (!state.legA || !state.legB) { + updateOoBudgetLine(null); + return; + } + const b = resolveOoBudget(); + const sug = suggestOoSheetsLocal(b.budget, state.ooSheetsMode); + const a = $("hp-oo-sheets-a"); + const bb = $("hp-oo-sheets-b"); + if (a && !a.disabled) a.value = String(sug.sheetsA); + if (bb && !bb.disabled) bb.value = String(sug.sheetsB); + updateOoBudgetLine(sug.ok ? null : sug); + updateOoPremiumLine(); } async function loadGates() { @@ -562,7 +702,7 @@ } fill("腿A", state.legA, "hp-oo-leg-a-info", "hp-oo-sheets-a"); fill("腿B", state.legB, "hp-oo-leg-b-info", "hp-oo-sheets-b"); - updateOoPremiumLine(); + autoFillOoSheets(); } function ooSheets(id) { @@ -806,6 +946,13 @@ const el = $(id); if (el) el.addEventListener("input", updateOoPremiumLine); }); + document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) { + b.addEventListener("click", function () { + state.ooSheetsMode = b.getAttribute("data-oo-size") || "same_sheets"; + syncOoSizeModeUI(); + autoFillOoSheets(); + }); + }); if ($("hp-preview-btn")) $("hp-preview-btn").addEventListener("click", function () { state.mode = "perp_options"; @@ -1262,6 +1409,8 @@ syncTabUI(); syncUnderlyingUI(); syncMoneyUI(); + syncOoSizeModeUI(); + updateOoBudgetLine(null); bind(); void refreshAll(); })(); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 9d705fd..1f9acba 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3258,6 +3258,21 @@ html[data-theme="light"] .opt-be-dist-down { flex-direction: column; gap: 8px; } +.hedge-plan-page-wrap .hp-oo-size-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 6px 0 2px; +} +.hedge-plan-page-wrap .hp-oo-size-mode.active { + border-color: var(--accent, #00d4ff); + color: var(--text, #fff); + background: rgba(0, 212, 255, 0.12); +} +.hedge-plan-page-wrap #hp-oo-budget-line.hp-oo-budget-warn { + color: #ff8a8a; +} .hedge-plan-page-wrap .hp-oo-leg-row { display: flex; flex-wrap: wrap; diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py index 0f2633b..0889d35 100644 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -86,6 +86,146 @@ def floor_contracts_to_precision(contracts: float, decimals: int) -> float: return math.floor(raw * scale + 1e-12) / scale +def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float: + """单张权利金(USDC) = 卖一价 × ct_mult.""" + a = _f(ask) + if a is None or a <= 0: + return 0.0 + return float(a) * float(ct_mult or 0.01) + + +def resolve_oo_budget_usdc( + *, + trading_usdc: Any, + trade_budget_usdc: Any, + buffer_ratio: Any = 0.95, +) -> dict[str, Any]: + """期期可用预算 = min(交易户×buffer, 单笔预算).""" + import math + + trading = _f(trading_usdc) + cap = _f(trade_budget_usdc) + buf = _f(buffer_ratio) + if buf is None or buf <= 0: + buf = 0.95 + if buf > 1: + buf = 1.0 + trading_cap = None if trading is None else max(0.0, float(trading) * float(buf)) + trade_cap = None if cap is None else max(0.0, float(cap)) + if trading_cap is None and trade_cap is None: + return { + "ok": False, + "budget_usdc": 0.0, + "trading_cap": None, + "trade_budget_cap": None, + "buffer_ratio": float(buf), + "msg": "缺少交易户余额与单笔预算", + } + if trading_cap is None: + budget = float(trade_cap or 0.0) + elif trade_cap is None: + budget = float(trading_cap) + else: + budget = min(float(trading_cap), float(trade_cap)) + budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6) + return { + "ok": budget > 0, + "budget_usdc": budget, + "trading_cap": None if trading_cap is None else round(float(trading_cap), 6), + "trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6), + "buffer_ratio": float(buf), + "msg": "" if budget > 0 else "可用预算为 0", + } + + +def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int: + import math + + n = max(0, int(sheets)) + depth = _f(ask_sz) + if depth is None: + return n + if depth <= 0: + return 0 + return min(n, int(math.floor(float(depth) + 1e-12))) + + +def suggest_oo_sheets( + *, + mode: str, + budget_usdc: float, + ask_a: float, + ct_mult_a: float = 0.01, + ask_sz_a: Any = None, + ask_b: float, + ct_mult_b: float = 0.01, + ask_sz_b: Any = None, +) -> dict[str, Any]: + """期期建议张数:same_sheets(同张数,默认) / split_budget(均分预算).""" + import math + + m = (mode or "same_sheets").strip().lower() + if m in ("split", "equal_budget", "split_budget", "均分"): + m = "split_budget" + else: + m = "same_sheets" + budget = max(0.0, float(budget_usdc or 0.0)) + cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a) + cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b) + if budget <= 0: + return { + "mode": m, + "sheets_a": 0, + "sheets_b": 0, + "cost_a": round(cost_a, 8), + "cost_b": round(cost_b, 8), + "premium_est": 0.0, + "ok": False, + "msg": "可用预算为 0", + } + if cost_a <= 0 or cost_b <= 0: + return { + "mode": m, + "sheets_a": 0, + "sheets_b": 0, + "cost_a": round(cost_a, 8), + "cost_b": round(cost_b, 8), + "premium_est": 0.0, + "ok": False, + "msg": "缺少有效卖一价,无法建议张数", + } + if m == "split_budget": + half = budget / 2.0 + n_a = int(math.floor(half / cost_a + 1e-12)) + n_b = int(math.floor(half / cost_b + 1e-12)) + else: + pair = cost_a + cost_b + n = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0 + n_a = n + n_b = n + n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a) + n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b) + if m == "same_sheets": + n = min(n_a, n_b) + n_a = n + n_b = n + prem = cost_a * n_a + cost_b * n_b + ok = n_a >= 1 and n_b >= 1 + msg = "" + if not ok: + msg = "预算不够开 1+1(或卖一深度不足)" + return { + "mode": m, + "sheets_a": n_a, + "sheets_b": n_b, + "cost_a": round(cost_a, 8), + "cost_b": round(cost_b, 8), + "premium_est": round(prem, 6), + "ok": ok, + "msg": msg, + } + + def build_perp_options_preview( *, direction: str, diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index b339d2a..a5ea506 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -97,6 +97,8 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), + "trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"), + "budget_buffer": float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), "live_trading": _env_bool("LIVE_TRADING_ENABLED", False), "send_wechat": getattr(app_module, "send_wechat_msg", None), } @@ -405,6 +407,8 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: "account_label": cfg.get("options_account_label") or "期权账户", "account_note": "期权腿使用期权账户(交易 USDC)", "options_account": opt_acct, + "trade_budget_usdc": cfg.get("trade_budget_usdc"), + "budget_buffer": cfg.get("budget_buffer"), } ) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 1204ccf..97b6199 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -138,17 +138,23 @@

震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=期权张 · 权利金=USDC

+
+ 自动张数 + + +
+

腿A: 尚未选用
腿B: 尚未选用

@@ -264,4 +270,4 @@
- + diff --git a/tests/test_hedge_plan_calc.py b/tests/test_hedge_plan_calc.py index fb49ef2..c15a0b4 100644 --- a/tests/test_hedge_plan_calc.py +++ b/tests/test_hedge_plan_calc.py @@ -9,6 +9,8 @@ from lib.hedge_plan.hedge_plan_calc_lib import ( option_expiry_pnl, option_premium_total, perp_pnl, + resolve_oo_budget_usdc, + suggest_oo_sheets, ) @@ -129,6 +131,55 @@ class TestHedgePlanCalc(unittest.TestCase): self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0) self.assertEqual(floor_contracts_to_precision(0, 4), 0.0) + def test_oo_budget_min_trading_and_cap(self): + b = resolve_oo_budget_usdc(trading_usdc=11.07, trade_budget_usdc=10, buffer_ratio=0.95) + self.assertTrue(b["ok"]) + self.assertAlmostEqual(b["trading_cap"], 11.07 * 0.95, places=4) + self.assertEqual(b["budget_usdc"], 10.0) + + def test_suggest_oo_same_sheets_default(self): + # cost_a=1, cost_b=1 → pair=2; budget=10 → n=5 + s = suggest_oo_sheets( + mode="same_sheets", + budget_usdc=10, + ask_a=100, + ct_mult_a=0.01, + ask_b=100, + ct_mult_b=0.01, + ) + self.assertEqual(s["mode"], "same_sheets") + self.assertEqual(s["sheets_a"], 5) + self.assertEqual(s["sheets_b"], 5) + self.assertTrue(s["ok"]) + + def test_suggest_oo_split_budget(self): + # cost_a=1, cost_b=2; half=5 → nA=5, nB=2 + s = suggest_oo_sheets( + mode="split_budget", + budget_usdc=10, + ask_a=100, + ct_mult_a=0.01, + ask_b=200, + ct_mult_b=0.01, + ) + self.assertEqual(s["mode"], "split_budget") + self.assertEqual(s["sheets_a"], 5) + self.assertEqual(s["sheets_b"], 2) + + def test_suggest_oo_depth_cap(self): + s = suggest_oo_sheets( + mode="same_sheets", + budget_usdc=100, + ask_a=100, + ct_mult_a=0.01, + ask_sz_a=2, + ask_b=100, + ct_mult_b=0.01, + ask_sz_b=50, + ) + self.assertEqual(s["sheets_a"], 2) + self.assertEqual(s["sheets_b"], 2) + if __name__ == "__main__": unittest.main()