diff --git a/lib/hub/hub_perp_options_calc_lib.py b/lib/hub/hub_perp_options_calc_lib.py new file mode 100644 index 0000000..771b9ab --- /dev/null +++ b/lib/hub/hub_perp_options_calc_lib.py @@ -0,0 +1,146 @@ +"""中控永期对冲计算器:永续 1 币 + 按目标盈利反推期权仓位(纯函数).""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt, taker_fee_rate + +DEFAULT_CT_MULT = 0.01 +PERP_COINS = 1.0 + + +def _f(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def calc_perp_options_hedge( + *, + base: str = "ETH", + spot: float, + capital_usdt: float, + target_profit_u: float, + move_mode: str = "points", + move_value: float, + perp_leverage: float, + option_leverage: float, + ct_mult: float = DEFAULT_CT_MULT, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """测算期权开仓币数/张数,并给出永续对 / 期权对两套情景. + + 单币权利金 = 现价 / 期权杠杆 + 权利金预算 = 永续毛收益 − 目标盈利 − 永续开平手续费 + 期权币数 = 权利金预算 / 单币权利金 + """ + b = (base or "ETH").strip().upper() + if b not in ("ETH", "BTC"): + return None, "币种仅支持 BTC / ETH" + + s = _f(spot) + capital = _f(capital_usdt) + target = _f(target_profit_u) + move = _f(move_value) + p_lev = _f(perp_leverage) + o_lev = _f(option_leverage) + ct = _f(ct_mult) + mode = (move_mode or "points").strip().lower() + if mode not in ("points", "pct", "percent", "rate"): + return None, "波动模式须为 points 或 pct" + if mode in ("percent", "rate"): + mode = "pct" + + if s is None or capital is None or target is None or move is None or p_lev is None or o_lev is None: + return None, "参数格式错误" + if ct is None or ct <= 0: + ct = DEFAULT_CT_MULT + if s <= 0 or capital <= 0 or move <= 0 or p_lev <= 0 or o_lev <= 0: + return None, "现价、资金、波动、杠杆须大于 0" + if target < 0: + return None, "目标盈利不能为负" + + # 波动对应的绝对点数(价格变动) + if mode == "pct": + move_points = s * (move / 100.0) + else: + move_points = move + + if move_points <= 0: + return None, "波动对应价格变动须大于 0" + + exit_px = s + move_points # 永续方向对:按上涨测算 1 币多头 + perp_gross = move_points * PERP_COINS + fee = estimate_roundtrip_fee_usdt( + s, + exit_px, + qty=PERP_COINS, + contract_size=1.0, + ) + fee_rate = taker_fee_rate() + + prem_per_coin = s / o_lev + if prem_per_coin <= 0: + return None, "单币权利金无效" + + premium_budget = perp_gross - target - fee + if premium_budget <= 0: + return None, "波动收益不足以覆盖目标盈利+手续费,无法开期权" + + opt_coins = premium_budget / prem_per_coin + opt_sheets = opt_coins / ct + premium_total = opt_coins * prem_per_coin # == premium_budget + + margin = (s * PERP_COINS) / p_lev + capital_ok = capital >= margin + + # A: 永续方向对,期权权利金全亏 + case_a_net = perp_gross - premium_total - fee + + # B: 期权方向对,永续 1 币反向亏掉同等波动 + opt_intrinsic = opt_coins * move_points + opt_net = opt_intrinsic - premium_total + perp_loss = -perp_gross + portfolio_net = opt_net + perp_loss + + return { + "base": b, + "spot": round(s, 8), + "capital_usdt": round(capital, 8), + "target_profit_u": round(target, 8), + "move_mode": mode, + "move_value": round(move, 8), + "move_points": round(move_points, 8), + "exit_price": round(exit_px, 8), + "perp_coins": PERP_COINS, + "perp_leverage": round(p_lev, 8), + "option_leverage": round(o_lev, 8), + "ct_mult": ct, + "prem_per_coin": round(prem_per_coin, 8), + "perp_gross_u": round(perp_gross, 8), + "perp_fee_u": round(fee, 8), + "fee_rate": fee_rate, + "premium_budget_u": round(premium_budget, 8), + "opt_coins": round(opt_coins, 8), + "opt_sheets": round(opt_sheets, 8), + "premium_total_u": round(premium_total, 8), + "perp_margin_u": round(margin, 8), + "capital_ok": bool(capital_ok), + "case_a": { + "label": "永续方向对", + "perp_pnl_u": round(perp_gross, 8), + "premium_u": round(premium_total, 8), + "fee_u": round(fee, 8), + "net_u": round(case_a_net, 8), + }, + "case_b": { + "label": "期权方向对", + "opt_intrinsic_u": round(opt_intrinsic, 8), + "premium_u": round(premium_total, 8), + "opt_net_u": round(opt_net, 8), + "perp_pnl_u": round(perp_loss, 8), + "portfolio_net_u": round(portfolio_net, 8), + }, + }, None diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 848d578..c381ce8 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1269,6 +1269,18 @@ class RollCalculatorBody(BaseModel): base: str = "ETH" +class PerpOptionsCalculatorBody(BaseModel): + base: str = "ETH" + spot: float = Field(gt=0) + capital_usdt: float = Field(gt=0) + target_profit_u: float = Field(ge=0) + move_mode: str = "points" + move_value: float = Field(gt=0) + perp_leverage: float = Field(gt=0) + option_leverage: float = Field(gt=0) + ct_mult: float = Field(default=0.01, gt=0) + + class CompareOptionLegBody(BaseModel): opt_type: str = "C" strike: float | None = None @@ -1350,6 +1362,26 @@ def api_calculator_roll(body: RollCalculatorBody): return {"ok": True, "data": data} +@app.post("/api/calculator/perp-options") +def api_calculator_perp_options(body: PerpOptionsCalculatorBody): + from lib.hub.hub_perp_options_calc_lib import calc_perp_options_hedge + + data, err = calc_perp_options_hedge( + base=body.base, + spot=body.spot, + capital_usdt=body.capital_usdt, + target_profit_u=body.target_profit_u, + move_mode=body.move_mode, + move_value=body.move_value, + perp_leverage=body.perp_leverage, + option_leverage=body.option_leverage, + ct_mult=body.ct_mult, + ) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} + + @app.post("/api/compare/calc") def api_compare_calc(body: CompareBody): from lib.hub.hub_compare_lib import run_compare diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 1b9c98c..177a708 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -4708,7 +4708,11 @@ body.login-page { } body.hub-phone .calc-layout[data-calc-tab="trend"] [data-calc-pane="roll"], - body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"] { + body.hub-phone .calc-layout[data-calc-tab="trend"] [data-calc-pane="po"], + body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"], + body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="po"], + body.hub-phone .calc-layout[data-calc-tab="po"] [data-calc-pane="trend"], + body.hub-phone .calc-layout[data-calc-tab="po"] [data-calc-pane="roll"] { display: none; } @@ -9776,10 +9780,33 @@ body:not(.hub-phone) #page-calculator .calc-layout { } body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="trend"] [data-calc-pane="roll"], -body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"] { +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="trend"] [data-calc-pane="po"], +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"], +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="po"], +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="po"] [data-calc-pane="trend"], +body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="po"] [data-calc-pane="roll"] { display: none; } +#page-calculator .calc-po-cases { + display: grid; + gap: 12px; + margin-top: 12px; +} + +#page-calculator .calc-po-case { + padding: 10px 12px; + border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.08)); + border-radius: 10px; + background: color-mix(in srgb, var(--panel, #12161f) 88%, transparent); +} + +#page-calculator .calc-po-case h4 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; +} + body:not(.hub-phone) #page-calculator .calc-card { padding: 0; background: transparent; diff --git a/manual_trading_hub/static/calculator.js b/manual_trading_hub/static/calculator.js index b542d71..317eba4 100644 --- a/manual_trading_hub/static/calculator.js +++ b/manual_trading_hub/static/calculator.js @@ -572,8 +572,135 @@ } } + function renderPerpOptionsResult(data) { + const box = $("calc-po-result"); + if (!box) return; + const a = data.case_a || {}; + const b = data.case_b || {}; + const capitalHint = data.capital_ok + ? "资金充足(参考)" + : "保证金高于交易资金(仅提示)"; + box.classList.remove("hidden"); + box.innerHTML = + '
' + + "
标的" + + esc(data.base || "—") + + " · 永续 " + + fmtTrim(data.perp_coins, 4) + + " 币
" + + "
单币权利金" + + fmt(data.prem_per_coin, 4) + + "U
" + + "
永续毛收益' + + fmtU(data.perp_gross_u) + + "
" + + "
永续手续费" + + fmt(data.perp_fee_u, 4) + + "U
" + + "
权利金预算" + + fmt(data.premium_budget_u, 4) + + "U
" + + "
期权开仓" + + fmtTrim(data.opt_coins, 6) + + " 币 / " + + fmtTrim(data.opt_sheets, 4) + + " 张
" + + "
永续保证金" + + fmt(data.perp_margin_u, 2) + + "U
" + + "
开仓参考" + + esc(capitalHint) + + "
" + + "
" + + '
' + + '
' + + "

情景 A · 永续方向对

" + + '
' + + "
永续盈亏' + + fmtU(a.perp_pnl_u) + + "
" + + "
权利金(全亏)" + + fmt(a.premium_u, 4) + + "U
" + + "
手续费" + + fmt(a.fee_u, 4) + + "U
" + + "
净利' + + fmtU(a.net_u) + + "
" + + "
" + + '
' + + "

情景 B · 期权方向对

" + + '
' + + "
期权内在" + + fmtU(b.opt_intrinsic_u) + + "
" + + "
权利金" + + fmt(b.premium_u, 4) + + "U
" + + "
期权净利' + + fmtU(b.opt_net_u) + + "
" + + "
永续盈亏' + + fmtU(b.perp_pnl_u) + + "
" + + "
组合净利' + + fmtU(b.portfolio_net_u) + + "
" + + "
"; + } + + async function submitPerpOptions(e) { + e.preventDefault(); + const body = { + base: ($("calc-po-base") && $("calc-po-base").value) || "ETH", + spot: num("calc-po-spot"), + capital_usdt: num("calc-po-capital"), + target_profit_u: num("calc-po-target"), + move_mode: ($("calc-po-move-mode") && $("calc-po-move-mode").value) || "points", + move_value: num("calc-po-move"), + perp_leverage: num("calc-po-perp-lev"), + option_leverage: num("calc-po-opt-lev"), + ct_mult: num("calc-po-ct-mult") || 0.01, + }; + try { + const r = await fetch("/api/calculator/perp-options", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) { + showErr("calc-po-result", j.msg || "计算失败"); + return; + } + renderPerpOptionsResult(j.data); + } catch (err) { + showErr("calc-po-result", String(err)); + } + } + + function syncPoMoveLabel() { + const mode = ($("calc-po-move-mode") && $("calc-po-move-mode").value) || "points"; + const lab = $("calc-po-move-label"); + if (lab) lab.textContent = mode === "pct" ? "波动率 %" : "波动点数"; + } + function applyCalcTab(tab) { - const t = tab === "roll" ? "roll" : "trend"; + const t = tab === "roll" || tab === "po" ? tab : "trend"; const layout = page.querySelector(".calc-layout"); if (layout) layout.setAttribute("data-calc-tab", t); page.querySelectorAll(".calc-m-tab").forEach(function (btn) { @@ -609,13 +736,20 @@ await loadCalculatorExchanges(); const trendForm = $("calc-trend-form"); const rollForm = $("calc-roll-form"); + const poForm = $("calc-po-form"); const dirSel = $("calc-trend-direction"); + const poMode = $("calc-po-move-mode"); if (trendForm) trendForm.addEventListener("submit", submitTrend); if (rollForm) rollForm.addEventListener("submit", submitRoll); + if (poForm) poForm.addEventListener("submit", submitPerpOptions); if (dirSel) { dirSel.addEventListener("change", syncTrendAddLabel); syncTrendAddLabel(); } + if (poMode) { + poMode.addEventListener("change", syncPoMoveLabel); + syncPoMoveLabel(); + } bindRollLegsUI(); bindMarket("calc-trend"); bindMarket("calc-roll"); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index a7493e9..f35085d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -818,6 +818,9 @@ +
@@ -958,6 +961,69 @@
+ +
+
+
+

永期对冲计算器

+

永续固定 1 币;单币权利金 = 现价 / 期权杠杆;权利金按全亏;只扣永续开平手续费(各 0.05%).主要反推期权开仓币数/张数.

+
+
+ + + + + + + + + +
+
+ +
+
+
+ +
+
@@ -1674,7 +1740,7 @@ - + diff --git a/tests/test_hub_perp_options_calc_lib.py b/tests/test_hub_perp_options_calc_lib.py new file mode 100644 index 0000000..c2aa59a --- /dev/null +++ b/tests/test_hub_perp_options_calc_lib.py @@ -0,0 +1,86 @@ +"""hub_perp_options_calc_lib 永期对冲测算.""" + +import unittest + +from lib.hub.hub_perp_options_calc_lib import calc_perp_options_hedge + + +class HubPerpOptionsCalcTests(unittest.TestCase): + def test_example_1800_50_15_100(self): + data, err = calc_perp_options_hedge( + base="ETH", + spot=1800, + capital_usdt=3000, + target_profit_u=15, + move_mode="points", + move_value=50, + perp_leverage=10, + option_leverage=100, + ct_mult=0.01, + ) + self.assertIsNone(err) + assert data is not None + self.assertEqual(data["prem_per_coin"], 18.0) + self.assertEqual(data["perp_gross_u"], 50.0) + # fee = (1800+1850)*0.0005 = 1.825 + self.assertAlmostEqual(data["perp_fee_u"], 1.825, places=6) + self.assertAlmostEqual(data["premium_budget_u"], 33.175, places=6) + self.assertAlmostEqual(data["opt_coins"], 33.175 / 18.0, places=6) + self.assertAlmostEqual(data["opt_sheets"], data["opt_coins"] / 0.01, places=4) + self.assertAlmostEqual(data["case_a"]["net_u"], 15.0, places=6) + # B: opt_net = coins*50 - coins*18 = coins*32; portfolio = opt_net - 50 + coins = data["opt_coins"] + self.assertAlmostEqual(data["case_b"]["opt_net_u"], coins * 32.0, places=6) + self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], coins * 32.0 - 50.0, places=6) + self.assertAlmostEqual(data["perp_margin_u"], 180.0, places=6) + self.assertTrue(data["capital_ok"]) + + def test_pct_mode(self): + data, err = calc_perp_options_hedge( + base="BTC", + spot=100000, + capital_usdt=5000, + target_profit_u=100, + move_mode="pct", + move_value=1, + perp_leverage=5, + option_leverage=50, + ct_mult=0.01, + ) + self.assertIsNone(err) + assert data is not None + self.assertAlmostEqual(data["move_points"], 1000.0, places=6) + self.assertAlmostEqual(data["perp_gross_u"], 1000.0, places=6) + self.assertAlmostEqual(data["prem_per_coin"], 2000.0, places=6) + + def test_budget_too_small(self): + data, err = calc_perp_options_hedge( + base="ETH", + spot=1800, + capital_usdt=3000, + target_profit_u=60, + move_mode="points", + move_value=50, + perp_leverage=10, + option_leverage=100, + ) + self.assertIsNone(data) + self.assertIn("无法开期权", err or "") + + def test_bad_base(self): + data, err = calc_perp_options_hedge( + base="SOL", + spot=100, + capital_usdt=1000, + target_profit_u=10, + move_mode="points", + move_value=5, + perp_leverage=5, + option_leverage=20, + ) + self.assertIsNone(data) + self.assertIsNotNone(err) + + +if __name__ == "__main__": + unittest.main()