Fix btc_eth watching entry, preview sizing, and coin-to-U settlement.

Use dual indices for watch/preview; perp entry uses ETH mark; expiry/close convert coin PnL via option index; align legacy long→Call; omit combo U when index missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 21:52:20 +08:00
parent 450296e04b
commit a841c8f31b
8 changed files with 296 additions and 72 deletions
+120 -30
View File
@@ -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(),