diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index d4a6436..99d9fec 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -2722,7 +2722,8 @@ option_primary: true, market_order: 1, direction: getDirection(), - entry: indexPx() || Number((state.market && state.market.mark) || 0), + // entry=永续参考价;跨币用 ETH mark,勿写 BTC 指数 + entry: Number(perpIndexPx() || (state.market && state.market.mark) || 0), contracts: sized.contracts, sheets: sized.sheets, contract_size: (state.market && state.market.contract_size) || 0.01, @@ -2730,7 +2731,7 @@ strike: state.selected.strike, ct_mult: state.selected.ct_mult || 0.01, ask: state.selected.ask, - index_px: indexPx() || 0, + index_px: Number(optionIndexPx() || 0), premium_budget: premBudget > 0 ? premBudget : Math.max(premEst / 0.95, premEst), option_perp_ratio: numInput("hp-opt-perp-ratio", 2), option_target_points: optPts, @@ -3825,12 +3826,15 @@ plan_type: "perp_options", option_primary: true, watch_entry: 1, - underlying: state.underlying, + po_mode: state.poMode, + underlying: state.optionUnderly || state.underlying, + option_underlying: state.optionUnderly || state.underlying, + perp_underlying: state.perpUnderly || 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), + index_px: Number(optionIndexPx() || 0), + entry: Number(perpIndexPx() || (state.market && state.market.mark) || 0), premium_budget: prem, option_perp_ratio: numInput("hp-opt-perp-ratio", 2), option_target_points: optPts, @@ -3909,7 +3913,10 @@ throw new Error("门禁未满足,不可开仓: " + gateTxt); } const exp = currentExp("hp-exp-select"); - const entry = indexPx() || Number((state.market && state.market.mark) || 0); + const optIdx = Number(optionIndexPx() || 0); + const perpIdx = Number( + perpIndexPx() || (state.market && state.market.mark) || 0 + ); const premEst = Number(state.selected.ask || 0) * sized.sheets * @@ -3925,7 +3932,7 @@ option_underlying: state.optionUnderly || state.underlying, perp_underlying: state.perpUnderly || state.underlying, direction: getDirection(), - entry: Number((state.market && state.market.mark) || entry), + entry: perpIdx, contracts: sized.contracts, sheets: sized.sheets, contract_size: (state.market && state.market.contract_size) || 0.01, @@ -3934,7 +3941,7 @@ opt_type: state.selected.opt_type, strike: state.selected.strike, ask: state.selected.ask, - index_px: entry, + index_px: optIdx, exchange_symbol: (state.market && state.market.exchange_symbol) || "", leverage: defaultPerpLeverage(state.perpUnderly || state.underlying), option_leverage: 0, diff --git a/lib/hedge_plan/hedge_plan_live_positions_lib.py b/lib/hedge_plan/hedge_plan_live_positions_lib.py index 2887cb7..e486494 100644 --- a/lib/hedge_plan/hedge_plan_live_positions_lib.py +++ b/lib/hedge_plan/hedge_plan_live_positions_lib.py @@ -313,9 +313,8 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any round(opt_net_coin * float(idx), 4) if idx is not None and idx > 0 else None ) perp_net = float(net.get("perp_net") or 0) + # 指数缺失时禁止用永续 U 冒充组合净利(尤其跨币会丢掉期权腿) combo_usdt = combo_net_usdt(net, option_index=float(idx or 0)) - if combo_usdt is None: - combo_usdt = 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), 2) if idx is not None and idx > 0 else None @@ -393,7 +392,11 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any "net_usdt": combo_usdt, "opt_net_usdt": opt_net_usdt, "perp_net_usdt": perp_net, - "note": "扣开平双边手续费;永续按盘口均价估", + "note": ( + "期权指数缺失,组合净利暂不可算" + if combo_usdt is None + else "扣开平双边手续费;永续按盘口均价估" + ), }, } ) diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 1d924c0..3755578 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -46,19 +46,29 @@ def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Op def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: - ex = cfg.get("exchange_options") - fn = cfg.get("fetch_index_price") - if callable(fn) and ex is not None: + """标的指数:公共 HTTP 优先,再 cfg.fetch_index_price(ex, uly);勿与异币永续价混用.""" + from lib.hedge_plan.hedge_plan_live_positions_lib import resolve_option_index_px + + uly = str(underlying or "ETH").upper() + ex_opt = cfg.get("exchange_options") + if callable(ex_opt): try: - return fn(ex, underlying) + ex_opt = ex_opt() except Exception: - return None - # 无期权账户时回退永续 ticker + ex_opt = None + px = resolve_option_index_px(cfg, uly, ex_opt=ex_opt, cross_asset=False, perp_fallback=None) + if px is not None and px > 0: + return px + # 无指数时回退同标的永续 ticker(同币兜底;调用方跨币应分别取期权/永续指数) ex_perp = cfg.get("exchange") + if callable(ex_perp): + try: + ex_perp = ex_perp() + except Exception: + ex_perp = None if ex_perp is not None: try: - base = (underlying or "ETH").upper() - sym = f"{base}/USDT:USDT" + sym = f"{uly}/USDT:USDT" t = ex_perp.fetch_ticker(sym) return _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) except Exception: @@ -66,6 +76,28 @@ def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]: return None +def _option_pnl_to_usdt( + opt_pnl: float, + option_index: Optional[float], + *, + inst_id: str = "", +) -> Optional[float]: + """期权盈亏统一到 U:币本位 × 指数;USDC 原样.指数缺失且为币本位则 None.""" + coin = False + try: + from lib.options.options_pricing_lib import _quote_in_coin_from_context + + coin = bool(_quote_in_coin_from_context(inst_id=inst_id)) + except Exception: + coin = "-USD-" in str(inst_id).upper() and "USDC" not in str(inst_id).upper() + if not coin: + return round(float(opt_pnl), 4) + idx = _sf(option_index) + if idx is None or idx <= 0: + return None + return round(float(opt_pnl) * float(idx), 4) + + def _plan_open_grace_sec() -> float: try: return max(0.0, float(os.getenv("HEDGE_PLAN_OPEN_GRACE_SEC") or "90")) @@ -378,6 +410,13 @@ def _tick_po_option_primary_watching( ) from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start from lib.hedge_plan.hedge_plan_register import _activate_watching_po + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + get_hedge_po_mode, + is_cross_asset, + option_underlying, + perp_underlying, + ) build_chain = cfg.get("build_option_chain") ex = cfg.get("exchange_options") @@ -390,7 +429,15 @@ def _tick_po_option_primary_watching( body0 = dict(blob.get("start_body") or blob or {}) except Exception: body0 = {} - uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").upper() + mode = str(plan.get("po_mode") or body0.get("po_mode") or get_hedge_po_mode()) + uly = str( + plan.get("option_underlying") + or plan.get("underlying") + or body0.get("underlying") + or option_underlying(mode) + or "ETH" + ).upper() + perp_u = str(plan.get("perp_underlying") or body0.get("perp_underlying") or perp_underlying(mode)).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") @@ -425,24 +472,34 @@ def _tick_po_option_primary_watching( return None ask = float(cand.get("ask") or 0) ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01) + # 双指数定仓:跨币禁止用 BTC 指数当 ETH 永续价 + opt_idx = _index_px(cfg, uly) or _sf(cand.get("index_px") or chain.get("index_px")) + perp_idx = _index_px(cfg, perp_u) if is_cross_asset(mode) else opt_idx + if is_cross_asset(mode) and (not opt_idx or not perp_idx): + update_plan(conn, int(plan["id"]), note="盯盘定仓失败:跨币缺期权/永续指数"[:500]) + return None 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), + option_index=opt_idx, + perp_index=perp_idx or opt_idx, ) 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, + "po_mode": mode, "underlying": uly, + "option_underlying": uly, + "perp_underlying": perp_u, "direction": direction, "moneyness": money, "opt_inst_id": cand.get("inst_id"), @@ -453,8 +510,9 @@ def _tick_po_option_primary_watching( "sheets": sized["sheets"], "contracts": sized["contracts"], "eth_qty": sized.get("eth_qty"), - "index_px": idx, - "entry": idx, + # index_px=期权指数;entry=永续参考价(跨币=ETH),禁止写成 BTC + "index_px": float(opt_idx or 0), + "entry": float(perp_idx or opt_idx or 0), "hours_to_expiry": cand.get("hours_to_expiry"), "strike_interval": interval, "min_option_hours": min_h, @@ -463,11 +521,13 @@ def _tick_po_option_primary_watching( "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"), + "profit_target_usdt": plan.get("profit_target_usdt") or body0.get("profit_target_usdt"), "leverage": plan.get("leverage") or body0.get("leverage") or 100, - "exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP", + "exchange_symbol": body0.get("exchange_symbol") or f"{perp_u}/USDT:USDT", "contract_size": body0.get("contract_size") or 0.01, } ) + body.update(apply_mode_to_start_body(body, mode=mode)) 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"): @@ -571,8 +631,10 @@ def _tick_po_option_primary_both_expired( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: """两腿仍 open 但期权已到期:结算期权并市价平永续,避免裸奔.""" + from lib.hedge_plan.hedge_plan_book_lib import fetch_perp_mark_px from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view from lib.hedge_plan.hedge_plan_orders_lib import _close_perp + from lib.hedge_plan.hedge_po_mode_lib import get_hedge_po_mode, option_underlying, perp_underlying perp = next((x for x in legs if x.get("leg_role") == "perp"), None) opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None) @@ -582,21 +644,40 @@ def _tick_po_option_primary_both_expired( return None if not leg_is_expired(opt): return None - spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if spot is None: + mode = str(plan.get("po_mode") or get_hedge_po_mode()) + opt_u = str(plan.get("underlying") or plan.get("option_underlying") or option_underlying(mode) or "ETH").upper() + perp_u = str(plan.get("perp_underlying") or perp_underlying(mode) or opt_u).upper() + opt_spot = _index_px(cfg, opt_u) + if opt_spot is None: + return None + est = settle_option_leg_at_spot(opt, float(opt_spot)) + opt_pnl_raw = _option_leg_pnl_after_close(cfg, opt, fallback=est) + opt_pnl_u = _option_pnl_to_usdt( + opt_pnl_raw, opt_spot, inst_id=str(opt.get("inst_id") or "") + ) + if opt_pnl_u is None: return None - est = settle_option_leg_at_spot(opt, float(spot)) - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) conn.execute( "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), + ("closed", "expiry", _now(), round(opt_pnl_u, 4), opt["id"]), ) view = str(plan.get("direction") or "long").lower() perp_dir = str(plan.get("perp_direction") or perp.get("side") or perp_direction_for_view(view)).lower() - symbol = str(perp.get("symbol") or "") + symbol = str(perp.get("symbol") or f"{perp_u}/USDT:USDT") contracts = float(perp.get("size") or plan.get("perp_size") or 0) perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False) - entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or float(spot) + ex_perp = cfg.get("exchange") + if callable(ex_perp): + try: + ex_perp = ex_perp() + except Exception: + ex_perp = None + perp_mark = fetch_perp_mark_px(ex_perp, symbol) if ex_perp is not None else None + if perp_mark is None or float(perp_mark) <= 0: + perp_mark = _index_px(cfg, perp_u) + entry = _sf(perp.get("avg_open")) or _sf(plan.get("entry_mark")) or _sf(perp_mark) + if entry is None or perp_mark is None: + return None cs = float(cfg.get("default_contract_size") or 0.01) get_cs = cfg.get("get_contract_size") if callable(get_cs) and symbol: @@ -606,9 +687,9 @@ def _tick_po_option_primary_both_expired( pass coins = contracts * cs if perp_dir == "short": - perp_pnl = (float(entry) - float(spot)) * coins + perp_pnl = (float(entry) - float(perp_mark)) * coins else: - perp_pnl = (float(spot) - float(entry)) * coins + perp_pnl = (float(perp_mark) - float(entry)) * coins if not perp_close.get("ok"): notify_hedge( cfg, @@ -622,7 +703,7 @@ def _tick_po_option_primary_both_expired( conn, int(plan["id"]), close_reason="opt_target_perp_pending", - realized_pnl_options=round(opt_pnl, 4), + realized_pnl_options=round(opt_pnl_u, 4), note="期权已到期结算,永续待平", ) return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close} @@ -630,14 +711,14 @@ def _tick_po_option_primary_both_expired( "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", ("closed", "option_expired", _now(), round(perp_pnl, 4), perp["id"]), ) - total = opt_pnl + perp_pnl + total = float(opt_pnl_u) + float(perp_pnl) update_plan( conn, int(plan["id"]), status="closed", close_reason="option_expired", realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), + realized_pnl_options=round(opt_pnl_u, 4), realized_pnl_total=round(total, 4), stats_bucket="opt_primary", closed_at=_now(), @@ -658,24 +739,33 @@ def _tick_po_option_primary_expiry( return None if not leg_is_expired(opt): return None - spot = _index_px(cfg, str(plan.get("underlying") or "ETH")) + from lib.hedge_plan.hedge_po_mode_lib import get_hedge_po_mode, option_underlying + + mode = str(plan.get("po_mode") or get_hedge_po_mode()) + opt_u = str(plan.get("underlying") or plan.get("option_underlying") or option_underlying(mode) or "ETH").upper() + spot = _index_px(cfg, opt_u) if spot is None: return None est = settle_option_leg_at_spot(opt, float(spot)) - opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est) + opt_pnl_raw = _option_leg_pnl_after_close(cfg, opt, fallback=est) + opt_pnl_u = _option_pnl_to_usdt( + opt_pnl_raw, spot, inst_id=str(opt.get("inst_id") or "") + ) + if opt_pnl_u is None: + return None conn.execute( "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "expiry", _now(), round(opt_pnl, 4), opt["id"]), + ("closed", "expiry", _now(), round(opt_pnl_u, 4), opt["id"]), ) perp_pnl = float(perp.get("realized_pnl") or 0) if perp else float(plan.get("realized_pnl_perp") or 0) - total = perp_pnl + opt_pnl + total = float(perp_pnl) + float(opt_pnl_u) update_plan( conn, int(plan["id"]), status="closed", close_reason="perp_target_points_expiry", realized_pnl_perp=round(perp_pnl, 4), - realized_pnl_options=round(opt_pnl, 4), + realized_pnl_options=round(opt_pnl_u, 4), realized_pnl_total=round(total, 4), stats_bucket="opt_primary", closed_at=_now(), diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 393e51a..8ccebcb 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -1282,10 +1282,15 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: opt_type = "C" if opt_type not in ("P", "C"): return "缺少期权类型(Put/Call)" - if direction == "long" and opt_type != "P": - return "做多永期对冲须用 Put" - if direction == "short" and opt_type != "C": - return "做空永期对冲须用 Call" + # 与 opt_type_for_view 一致:做多→Call+空永续,做空→Put+多永续 + from lib.hedge_plan.hedge_plan_option_primary_lib import opt_type_for_view + + want = opt_type_for_view(direction) + if opt_type != want: + return ( + f"做{'多' if direction == 'long' else '空'}永期对冲须用 " + f"{'Call' if want == 'C' else 'Put'}" + ) if direction == "long" and not (sl < entry < tp): return "做多须满足 止损 < 入场 < 止盈" if direction == "short" and not (tp < entry < sl): @@ -1775,34 +1780,38 @@ def execute_manual_close_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> d notes.append("卖币失败: " + str(sell_r.get("msg") or "")) total = float(opt_pnl) + float(perp_pnl) - # 期权币本位盈亏换算到 U:用期权标的指数(跨币=BTC,禁止×ETH) + # 币本位期权盈亏一律 × 期权指数折 U(跨币=BTC);不再用 |pnl|<10 启发式 try: from lib.hedge_plan.hedge_plan_live_positions_lib import resolve_option_index_px from lib.hedge_plan.hedge_po_mode_lib import get_hedge_po_mode, is_cross_asset, option_underlying + from lib.options.options_pricing_lib import _quote_in_coin_from_context mode = str(plan.get("po_mode") or get_hedge_po_mode()) opt_u = str(plan.get("option_underlying") or plan.get("underlying") or option_underlying(mode) or uly).upper() - ex_idx = cfg.get("exchange_options") or cfg.get("exchange") - if callable(ex_idx): - try: - ex_idx = ex_idx() - except Exception: - ex_idx = None - idx = float( - resolve_option_index_px( - cfg, - opt_u, - ex_opt=ex_idx, - cross_asset=is_cross_asset(mode), - perp_fallback=None, + inst = "" + if opt: + inst = str(opt.get("inst_id") or "") + coin = bool(_quote_in_coin_from_context(inst_id=inst)) if inst else True + if coin: + ex_idx = cfg.get("exchange_options") or cfg.get("exchange") + if callable(ex_idx): + try: + ex_idx = ex_idx() + except Exception: + ex_idx = None + idx = float( + resolve_option_index_px( + cfg, + opt_u, + ex_opt=ex_idx, + cross_asset=is_cross_asset(mode), + perp_fallback=None, + ) + or 0 ) - or 0 - ) - # opt_pnl 可能已是币;若为币且绝对值很小则 × 指数 - if abs(opt_pnl) < 10 and idx > 0 and abs(opt_pnl * idx) > abs(opt_pnl): - opt_pnl_u = opt_pnl * idx + opt_pnl_u = float(opt_pnl) * idx if idx > 0 else float(opt_pnl) else: - opt_pnl_u = opt_pnl + opt_pnl_u = float(opt_pnl) except Exception: opt_pnl_u = opt_pnl diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index b1fbc6b..52d913b 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -509,11 +509,24 @@ def _activate_watching_po( str((perp or {}).get("direction") or "") or perp_direction_for_view(view) ) + # 永续开仓价:优先成交回写,否则 body.entry(应为永续标的价,跨币=ETH) + perp_avg = None + try: + order = (perp or {}).get("order") or {} + perp_avg = float( + order.get("average") + or order.get("avgPx") + or (order.get("info") or {}).get("avgPx") + or body.get("entry") + or 0 + ) or None + except (TypeError, ValueError): + perp_avg = float(body.get("entry") or 0) or None 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, + entry_mark=perp_avg, perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0), leverage=float(body.get("leverage") or 100), premium_total=premium, @@ -531,7 +544,7 @@ def _activate_watching_po( "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, + "avg_open": perp_avg 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, @@ -1455,12 +1468,43 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: err = validate_option_primary_start(body) if err: raise ValueError(err) + from lib.hedge_plan.hedge_po_mode_lib import ( + apply_mode_to_start_body, + get_hedge_po_mode, + is_cross_asset, + option_underlying, + perp_underlying, + ) + + body = apply_mode_to_start_body(body) + mode = str(body.get("po_mode") or get_hedge_po_mode()) + opt_idx = None + perp_idx = None + try: + from lib.exchange.okx_public_http_lib import fetch_index_px + + opt_idx = float(fetch_index_px(option_underlying(mode)) or 0) or None + if is_cross_asset(mode): + perp_idx = float(fetch_index_px(perp_underlying(mode)) or 0) or None + else: + perp_idx = opt_idx + except Exception: + opt_idx = perp_idx = None + if opt_idx is None: + try: + opt_idx = float(body.get("index_px") or body.get("entry") or 0) or None + except (TypeError, ValueError): + opt_idx = None + if perp_idx is None: + perp_idx = opt_idx sized = size_from_premium( premium_budget=float(body.get("premium_budget") or 0), ask=float(body.get("ask") or 0), ct_mult=float(body.get("ct_mult") or 0.01), ratio=float(body.get("option_perp_ratio") or 2), contract_size=float(body.get("contract_size") or 0.01), + option_index=opt_idx, + perp_index=perp_idx, ) if not sized.get("ok"): raise ValueError(sized.get("msg") or "定仓失败") @@ -1468,19 +1512,25 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: body["sheets"] = sized["sheets"] body["contracts"] = sized["contracts"] body["eth_qty"] = sized["eth_qty"] + if not body.get("index_px") and opt_idx: + body["index_px"] = opt_idx + # entry=永续参考价(跨币用 ETH 指数);勿把 BTC 指数写入永续开仓价 if not body.get("entry"): - body["entry"] = body.get("index_px") or 0 + body["entry"] = perp_idx or opt_idx or body.get("index_px") or 0 out = build_option_primary_preview(body) out["sizing"] = sized return out + from lib.hedge_plan.hedge_plan_option_primary_lib import opt_type_for_view + direction = str(body.get("direction") or "long").lower() entry = float(body["entry"]) tp = float(body["tp"]) sl = float(body["sl"]) contracts = float(body["contracts"]) contract_size = float(body.get("contract_size") or 0.01) - opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C")) + # 与以期权为主同口径:long→Call, short→Put(旧保险 long→Put 已废弃) + opt_type = str(body.get("opt_type") or opt_type_for_view(direction)) strike = float(body["strike"]) sheets = float(body.get("sheets") or 1) ct_mult = float(body.get("ct_mult") or 0.01) diff --git a/lib/hedge_plan/hedge_plan_settle_lib.py b/lib/hedge_plan/hedge_plan_settle_lib.py index b1d6803..a604fa8 100644 --- a/lib/hedge_plan/hedge_plan_settle_lib.py +++ b/lib/hedge_plan/hedge_plan_settle_lib.py @@ -35,14 +35,40 @@ def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float: - """按到期结算口径估算腿盈亏(USDC).""" + """按到期结算估算腿盈亏(与权利金同单位). + + - 币本位(BTC-USD/ETH-USD):返回 **币** 盈亏(内在价值/币 − 权利金币);调用方须 × 期权指数折 U. + - USDC:返回 U 盈亏. + """ premium = float(leg.get("premium") or 0) strike = _sf(leg.get("strike")) if strike is None: return -premium sheets = float(leg.get("size") or 1) - # ct_mult 未入库时默认 0.01 ct = float(leg.get("ct_mult") or 0.01) + inst_id = str(leg.get("inst_id") or "") + try: + from lib.options.options_pricing_lib import _quote_in_coin_from_context, intrinsic_px_per_unit + + coin = _quote_in_coin_from_context(inst_id=inst_id) + except Exception: + coin = "-USD-" in inst_id.upper() and "USDC" not in inst_id.upper() + intrinsic_px_per_unit = None # type: ignore + if coin: + unit = None + if intrinsic_px_per_unit is not None: + try: + unit = intrinsic_px_per_unit( + str(leg.get("opt_type") or "P"), + float(strike), + float(spot), + inst_id=inst_id, + ) + except Exception: + unit = None + if unit is None: + unit = 0.0 + return float(unit) * sheets * ct - premium return float( option_expiry_pnl( opt_type=str(leg.get("opt_type") or "P"), diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index 4c0a285..47d771c 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -508,4 +508,4 @@ - + diff --git a/tests/test_hedge_po_mode.py b/tests/test_hedge_po_mode.py index c25d6e8..e85e7d6 100644 --- a/tests/test_hedge_po_mode.py +++ b/tests/test_hedge_po_mode.py @@ -214,6 +214,45 @@ class TestHedgePoMode(unittest.TestCase): ) self.assertAlmostEqual(px, 2476.0) + def test_settle_coin_option_returns_coin_units(self): + from lib.hedge_plan.hedge_plan_settle_lib import settle_option_leg_at_spot + + # BTC put ITM: K=90000 S=80000 → 币内在 (90000-80000)/80000=0.125; 1张×0.01=0.00125币 + # 权利金 0.01 币 → 净利 0.00125-0.01 = -0.00875 币(非 ×8万的美元差) + pnl = settle_option_leg_at_spot( + { + "inst_id": "BTC-USD-261018-90000-P", + "opt_type": "P", + "strike": 90000, + "size": 1, + "ct_mult": 0.01, + "premium": 0.01, + }, + 80000.0, + ) + self.assertAlmostEqual(pnl, 0.00125 - 0.01, places=6) + + def test_size_from_premium_preview_needs_dual_index(self): + # 无双指数 → 同币路径;有双指数 → 跨币张数 + same = size_from_premium( + premium_budget=1000, + ask=0.01, + ct_mult=0.01, + ratio=2, + contract_size=0.01, + ) + cross = size_from_premium( + premium_budget=1000, + ask=0.01, + ct_mult=0.01, + ratio=2, + contract_size=0.01, + option_index=100_000, + perp_index=2_500, + ) + self.assertTrue(same["ok"] and cross["ok"]) + self.assertGreater(float(cross["contracts"]), float(same["contracts"])) + if __name__ == "__main__": unittest.main()