diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 2142438..c3cb4cd 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -726,7 +726,7 @@ el.textContent = "持仓中" + idPart; el.classList.add("is-holding"); } else { - el.textContent = "未启动"; + el.textContent = ""; el.classList.add("is-idle"); } } @@ -990,6 +990,10 @@ "标记 " + fmt(opt.mark_px, 4) + "" + + '权利金 ' + + (opt.premium_coin != null ? fmt(opt.premium_coin, 4) + " 币" : "—") + + (opt.premium_usdt != null ? " / " + fmt(opt.premium_usdt, 2) + " U" : "") + + "" + ' 0 ? prem * buyPx : null; + line.textContent = + "权利金 ≈ " + + fmt(prem, 4) + + " 币" + + (premU != null ? " / " + fmt(premU, 2) + " U" : "") + + " · 卖一 " + + fmt(ask, 4) + + (buyPx > 0 ? " · 买币参考 " + fmt(buyPx, 2) : ""); } function buildStraddleRows(contracts) { diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index dbef1a8..7a135e7 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -4246,6 +4246,14 @@ html[data-theme="light"] .opt-be-dist-down { color: #e8ecf4; font-weight: 600; } +.hedge-plan-page-wrap .hp-live-leg-grid strong.hp-pnl-pos, +.hedge-plan-page-wrap .hp-live-combo strong.hp-pnl-pos { + color: #7ee787 !important; +} +.hedge-plan-page-wrap .hp-live-leg-grid strong.hp-pnl-neg, +.hedge-plan-page-wrap .hp-live-combo strong.hp-pnl-neg { + color: #ff8a8a !important; +} .hedge-plan-page-wrap .hp-live-combo { display: flex; justify-content: space-between; diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 826b297..6dd1186 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -706,11 +706,17 @@ def options_header_balance_pack( margin_mode = "usdc" underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" coin_key = "btc" if underly == "BTC" else "eth" + trading_usdt = _round(bal.get("trading_usdt")) + trading_usdt_avail = _round(bal.get("trading_usdt_avail")) + if trading_usdt_avail is None: + trading_usdt_avail = trading_usdt return { "trading_usdc": _round(bal.get("trading_usdc")), "funding_usdc": _round(bal.get("funding_usdc")), "funding_usdt": _round(bal.get("funding_usdt")), - "trading_usdt": _round(bal.get("trading_usdt")), + "trading_usdt": trading_usdt, + "trading_usdt_avail": trading_usdt_avail, + "perp_margin_usdt": _round(bal.get("perp_margin_usdt")), "funding_eth": _round_coin(bal.get("funding_eth")), "trading_eth": _round_coin(bal.get("trading_eth")), "funding_btc": _round_coin(bal.get("funding_btc")), diff --git a/lib/hedge_plan/hedge_plan_live_positions_lib.py b/lib/hedge_plan/hedge_plan_live_positions_lib.py index 5a56b0d..b098632 100644 --- a/lib/hedge_plan/hedge_plan_live_positions_lib.py +++ b/lib/hedge_plan/hedge_plan_live_positions_lib.py @@ -4,9 +4,10 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any, Optional -from lib.hedge_plan.hedge_plan_option_primary_lib import ( + from lib.hedge_plan.hedge_plan_option_primary_lib import ( estimate_combo_net_pnl, fee_rate, + option_fee_rate, opt_type_for_view, option_bid_liquidity_ok, perp_direction_for_view, @@ -129,6 +130,7 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any get_cs = cfg.get("get_contract_size") fetch_idx = cfg.get("fetch_index_price") fr = fee_rate() + fr_opt = option_fee_rate() now_ms = int(datetime.now(tz=_BJ).timestamp() * 1000) out: list[dict[str, Any]] = [] @@ -226,11 +228,15 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any contracts=float(contracts or 0), contract_size=float(cs or 0.01), fee=fr, + option_fee=fr_opt, ) opt_net_coin = float(net.get("opt_net") or 0) + # 币→U:用当前指数(与买币参考价同口径) opt_net_usdt = round(opt_net_coin * float(idx or 0), 4) if idx else None perp_net = float(net.get("perp_net") or 0) combo_usdt = round((opt_net_usdt or 0) + perp_net, 4) if opt_net_usdt is not None else round(perp_net, 4) + prem_coin = float(net.get("premium") or (float(avg_open or 0) * float(sheets or 0) * float(ct or 0.01))) + prem_usdt = round(prem_coin * float(idx or 0), 2) if idx else None opened_at = plan.get("opened_at") opened_ms = _parse_opened_ms(opened_at) @@ -253,6 +259,7 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any "remain_sec": remain_sec, "index_px": idx, "fee_rate": fr, + "option_fee_rate": fr_opt, "option": { "inst_id": inst_id or "—", "opt_type": opt_type, @@ -266,6 +273,8 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any "leverage": opt_leverage, "liq_ok": bool(liq_ok), "liq_msg": liq_msg or "", + "premium_coin": round(prem_coin, 4), + "premium_usdt": prem_usdt, "net_coin": opt_net_coin, "net_usdt": opt_net_usdt, "live": bool(opt_raw), diff --git a/lib/hedge_plan/hedge_plan_option_primary_lib.py b/lib/hedge_plan/hedge_plan_option_primary_lib.py index 0de83f0..d131680 100644 --- a/lib/hedge_plan/hedge_plan_option_primary_lib.py +++ b/lib/hedge_plan/hedge_plan_option_primary_lib.py @@ -38,12 +38,29 @@ def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool: def fee_rate() -> float: + """永续 taker 费率(默认 0.05%).""" try: return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005")) except (TypeError, ValueError): return 0.0005 +def option_fee_rate() -> float: + """币本位期权 taker 费率(OKX 普通档默认 0.03%).""" + try: + return max( + 0.0, + float( + os.getenv("HEDGE_PLAN_OPTION_FEE_RATE") + or os.getenv("OKX_OPTIONS_TAKER_FEE") + or os.getenv("SIM_OPTION_FEE_RATE") + or "0.0003" + ), + ) + except (TypeError, ValueError): + return 0.0003 + + def floor2(v: float) -> float: """ETH 数量向下取两位小数.""" if v <= 0: @@ -201,17 +218,30 @@ def estimate_combo_net_pnl( contracts: float, contract_size: float, fee: Optional[float] = None, + option_fee: Optional[float] = None, ) -> dict[str, Any]: - """组合净利(扣费);平仓/卖出手续费按买入费率估算.""" - fr = fee if fee is not None else fee_rate() + """组合净利(扣开平双边手续费). + + 期权:吃卖一开/买一平(无滑点);费=Min(费率×标的币, 权利金×7%). + 永续:名义×费率(开+平). + """ + from lib.sim.pricing_lib import option_fee_coin + + fr_perp = fee if fee is not None else fee_rate() + fr_opt = option_fee if option_fee is not None else option_fee_rate() ct = float(ct_mult or 0.01) sh = float(sheets or 0) a = float(ask_open or 0) b = float(bid or 0) - premium = a * sh * ct - opt_proceeds = b * sh * ct - opt_open_fee = premium * fr - opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率 + underlying = sh * ct + premium = a * underlying + opt_proceeds = b * underlying + opt_open_fee = option_fee_coin( + underlying_coin=underlying, premium_coin=premium, fee_rate=fr_opt + ) + opt_close_fee = option_fee_coin( + underlying_coin=underlying, premium_coin=opt_proceeds, fee_rate=fr_opt + ) opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee coins = float(contracts or 0) * float(contract_size or 0.01) @@ -224,15 +254,20 @@ def estimate_combo_net_pnl( perp_gross = (mark - entry) * coins perp_notional_open = abs(entry * coins) perp_notional_close = abs(mark * coins) - perp_open_fee = perp_notional_open * fr - perp_close_fee = perp_notional_close * fr + perp_open_fee = perp_notional_open * fr_perp + perp_close_fee = perp_notional_close * fr_perp perp_net = perp_gross - perp_open_fee - perp_close_fee total = opt_net + perp_net return { "opt_net": round(opt_net, 4), "perp_net": round(perp_net, 4), "net": round(total, 4), - "fee_rate": fr, + "fee_rate": fr_perp, + "option_fee_rate": fr_opt, + "opt_open_fee": round(opt_open_fee, 8), + "opt_close_fee": round(opt_close_fee, 8), + "perp_open_fee": round(perp_open_fee, 4), + "perp_close_fee": round(perp_close_fee, 4), "premium": round(premium, 4), "opt_proceeds": round(opt_proceeds, 4), } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index b4fc5a5..08aee35 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -1431,30 +1431,53 @@ def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], def _options_account_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: - """期权账户资金快照(与期权页同源: exchange_options).""" + """期权账户资金快照(与期权页同源: exchange_options). + + trading_usdt=展示权益(含永续全仓占用);trading_usdt_avail=可开仓余额. + """ out: dict[str, Any] = { "label": cfg.get("options_account_label") or "期权账户", "trading_usdc": None, "funding_usdc": None, "trading_usdt": None, "funding_usdt": None, + "trading_usdt_avail": None, + "perp_margin_usdt": None, + "margin_note": "永续全仓 · 期权逐仓", } ex = cfg.get("exchange_options") - hdr = cfg.get("options_header_balances") - if ex is None or not callable(hdr): + if ex is None: return out try: - trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False) + from lib.exchange.okx_options_lib import options_header_balance_pack + + pack = options_header_balance_pack(ex, force=False) or {} out.update( { - "trading_usdc": trading_usdc, - "funding_usdc": funding_usdc, - "trading_usdt": trading_usdt, - "funding_usdt": funding_usdt, + "trading_usdc": pack.get("trading_usdc"), + "funding_usdc": pack.get("funding_usdc"), + "trading_usdt": pack.get("trading_usdt"), + "funding_usdt": pack.get("funding_usdt"), + "trading_usdt_avail": pack.get("trading_usdt_avail", pack.get("trading_usdt")), + "perp_margin_usdt": pack.get("perp_margin_usdt"), } ) except Exception: - pass + hdr = cfg.get("options_header_balances") + if callable(hdr): + try: + trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False) + out.update( + { + "trading_usdc": trading_usdc, + "funding_usdc": funding_usdc, + "trading_usdt": trading_usdt, + "funding_usdt": funding_usdt, + "trading_usdt_avail": trading_usdt, + } + ) + except Exception: + pass return out diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index dca64af..4cd3683 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -431,4 +431,4 @@ - + diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index d9b8351..cd9a22c 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -8,7 +8,7 @@ - + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index ebb8ded..4b2475a 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -19,7 +19,7 @@ {{ pwa_app_name }} - + dict[str, float]: - return self.wallets.view() + """资金快照.trading_usdt=全仓权益(可用+永续占用保证金);trading_usdt_free=可开仓余额.""" + w = dict(self.wallets.view()) + free = float(w.get("trading_usdt") or 0) + margin = 0.0 + try: + for p in self.list_perp_positions(): + margin += float(p.get("margin_usdt") or 0) + except Exception: + margin = 0.0 + w["trading_usdt_free"] = free + w["perp_margin_usdt"] = margin + # 永续全仓:展示权益含占用保证金;期权逐仓权利金在币余额,不并入 USDT + w["trading_usdt"] = round(free + margin, 8) + return w def list_perp_positions(self) -> list[dict[str, Any]]: conn = self.get_db() @@ -431,7 +444,7 @@ class SimBroker: n = int(sheets) if n < 1: return {"ok": False, "msg": "张数至少为 1"} - fr = sim_fee_rate(fee_rate) + fr = sim_option_fee_rate(fee_rate) bid, ask, ct_mult = _option_bid_ask(exchange_options, inst_id) if price is not None and float(price) > 0: # 限价: 用 min(limit, ask) 作为基准卖一近似, 仍走 option_fill 滑点 @@ -540,7 +553,7 @@ class SimBroker: close_n = min(pos_s, float(n)) if close_n <= 0: return {"ok": False, "msg": "平仓张数无效"} - fr = sim_fee_rate(fee_rate) + fr = sim_option_fee_rate(fee_rate) qty = close_n * ct_mult pr = option_fill(action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr) credit = pr.notional - pr.fee diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index 37399b1..62835eb 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -55,7 +55,11 @@ def apply_sim_hooks(app_module: Any) -> None: def get_available_trading_usdt(): if is_sim_mode(app_module.get_db): - return float(broker().balances_header()["trading_usdt"]) + w = broker().balances_header() + # 开仓可用=未占用保证金的 USDT(勿用全仓权益) + if w.get("trading_usdt_free") is not None: + return float(w.get("trading_usdt_free") or 0) + return float(w.get("trading_usdt") or 0) return _orig_avail() def get_live_position_contracts(exchange_symbol, direction): @@ -215,6 +219,7 @@ def _patch_okx_options_lib(app_module: Any) -> None: te = float(w.get("trading_eth") or 0) fb = float(w.get("funding_btc") or 0) tb = float(w.get("trading_btc") or 0) + tu_free = float(w.get("trading_usdt_free") if w.get("trading_usdt_free") is not None else tu) return { "scope": "main", "funding_usdt": fu, @@ -227,12 +232,13 @@ def _patch_okx_options_lib(app_module: Any) -> None: "trading_btc": tb, "funding_usdt_avail": fu, "funding_usdc_avail": fc, - "trading_usdt_avail": tu, + "trading_usdt_avail": tu_free, "trading_usdc_avail": tc, "funding_eth_avail": fe, "trading_eth_avail": te, "funding_btc_avail": fb, "trading_btc_avail": tb, + "perp_margin_usdt": float(w.get("perp_margin_usdt") or 0), } except Exception: pass diff --git a/lib/sim/pricing_lib.py b/lib/sim/pricing_lib.py index 495ead9..57e47d1 100644 --- a/lib/sim/pricing_lib.py +++ b/lib/sim/pricing_lib.py @@ -1,8 +1,8 @@ """成交价与手续费. 永续:滑点=1×fee_rate,手续费=名义×fee_rate. -期权(币本位):滑点仍按权利金价×fee_rate;手续费对齐 OKX= - Min(fee_rate×标的币数量, 权利金×7%). +期权(币本位):吃卖一/买一按报价成交(无额外滑点);手续费对齐 OKX= + Min(fee_rate×标的币数量, 权利金×7%);普通用户 taker 默认 0.03%. """ from __future__ import annotations @@ -32,6 +32,20 @@ def sim_fee_rate(override: float | None = None) -> float: return 0.0005 +def sim_option_fee_rate(override: float | None = None) -> float: + """OKX 币本位期权 taker 费率(默认 0.03%).""" + if override is not None: + return float(override) + try: + return float( + os.getenv("SIM_OPTION_FEE_RATE") + or os.getenv("OKX_OPTIONS_TAKER_FEE") + or "0.0003" + ) + except (TypeError, ValueError): + return 0.0003 + + def perp_fill( *, side: str, @@ -74,7 +88,7 @@ def option_fee_coin( Min(费率 × 张数对应标的币数量, 权利金×封顶比例). 注意:费率作用在标的币面值上,不是权利金名义上. """ - f = sim_fee_rate(fee_rate) + f = sim_option_fee_rate(fee_rate) qty = abs(float(underlying_coin or 0.0)) prem = abs(float(premium_coin or 0.0)) if qty <= 0 and prem <= 0: @@ -92,20 +106,19 @@ def option_fill( bid: float, ask: float, qty: float, - fee_rate: float, + fee_rate: float | None = None, ) -> PriceResult: """开仓买入吃卖一; 平仓卖出吃买一. qty = sheets × ct_mult(标的币数量). - 滑点仍按权利金价×费率;手续费按 OKX 币本位公式(标的币×费率,权利金7%封顶). + 成交价=买卖一(无额外滑点);手续费=OKX 币本位公式(标的币×费率,权利金7%封顶). """ - f = float(fee_rate) + f = sim_option_fee_rate(fee_rate) if action == "open": base = float(ask) - fill = base * (1.0 + f) else: base = float(bid) - fill = base * (1.0 - f) + fill = base qty_f = abs(float(qty)) notional = abs(fill * qty_f) fee = option_fee_coin( @@ -113,8 +126,7 @@ def option_fill( premium_coin=notional, fee_rate=f, ) - slip = abs(fill - base) * qty_f - return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional) + return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=0.0, notional=notional) @dataclass(slots=True)