Files
crypto_okx/lib/hedge_plan/hedge_plan_monitor_lib.py
dekun a1abe159fa Initial standalone crypto_okx with one-click deploy.
Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:00:59 +08:00

1440 lines
55 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""对冲计划监控:永期 TP/SL、期期目标价、到期结算与微信收口推送."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any, Optional
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, list_plans, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_hedge, notify_plan_end, build_hedge_alert_message
from lib.hedge_plan.hedge_plan_orders_lib import _sell_option
from lib.hedge_plan.hedge_plan_settle_lib import (
leg_is_expired,
resolve_option_leg_realized_pnl,
settle_option_leg_at_spot,
)
def _now() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _env_bool(key: str, default: bool = False) -> bool:
raw = (os.getenv(key) or "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Optional[float]:
fn = cfg.get("get_live_position_contracts")
if not callable(fn):
return None
try:
return fn(symbol, direction)
except Exception:
return None
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:
try:
return fn(ex, underlying)
except Exception:
return None
# 无期权账户时回退永续 ticker
ex_perp = cfg.get("exchange")
if ex_perp is not None:
try:
base = (underlying or "ETH").upper()
sym = f"{base}/USDT:USDT"
t = ex_perp.fetch_ticker(sym)
return _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last"))
except Exception:
return None
return None
def _plan_open_grace_sec() -> float:
try:
return max(0.0, float(os.getenv("HEDGE_PLAN_OPEN_GRACE_SEC") or "90"))
except (TypeError, ValueError):
return 90.0
def _within_open_grace(plan: dict[str, Any]) -> bool:
"""开仓后宽限期:仓位尚未同步到交易所时禁止按「已平」收口."""
grace = _plan_open_grace_sec()
if grace <= 0:
return False
raw = str(plan.get("opened_at") or plan.get("created_at") or "").strip()
if not raw:
return True
try:
# "YYYY-MM-DD HH:MM:SS" 本地墙钟
opened = datetime.strptime(raw[:19], "%Y-%m-%d %H:%M:%S")
age = (datetime.now() - opened).total_seconds()
return age < grace
except Exception:
return True
def _classify_po_flat_reason(
*,
direction: str,
entry: float,
mark: Optional[float],
tp: Optional[float],
sl: Optional[float],
) -> str:
"""永续已平时分类 TP/SL.歧义时偏 SL(触发强平期权),避免误判 TP 跳过强平."""
d = (direction or "long").lower()
if mark is None or not entry:
return "perp_flat_unknown"
if sl is not None:
if d == "long" and mark <= sl:
return "perp_sl"
if d == "short" and mark >= sl:
return "perp_sl"
if tp is not None:
if d == "long" and mark >= tp:
return "perp_tp"
if d == "short" and mark <= tp:
return "perp_tp"
if sl is not None and tp is not None:
return "perp_sl" if abs(mark - sl) <= abs(mark - tp) else "perp_tp"
if sl is not None:
return "perp_sl"
if tp is not None:
return "perp_tp"
return "perp_flat_unknown"
def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
"""扫描 active/partial 计划 + 止盈后遗留期权到期收口.返回处理摘要."""
get_db = cfg.get("get_db")
if not callable(get_db):
return {"ok": False, "msg": "get_db missing"}
conn = get_db()
acted: list[dict[str, Any]] = []
backfill_stats: dict[str, int] = {}
try:
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
init_hedge_plan_tables(conn)
plans = list_plans(conn, status="watching", limit=20)
plans.extend(list_plans(conn, status="active", limit=40))
# partial:裸永续/半腿也需侦测永续 TP/SL
plans.extend(list_plans(conn, status="partial", limit=20))
seen: set[int] = set()
for plan in plans:
pid = int(plan.get("id") or 0)
if pid in seen:
continue
seen.add(pid)
r = _tick_one(cfg, conn, plan)
if r:
acted.append(r)
orphaned = _settle_orphaned_after_tp(cfg, conn)
acted.extend(orphaned)
try:
ex = cfg.get("exchange_options")
if ex is not None:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_stats = backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
conn.commit()
finally:
conn.close()
return {"ok": True, "acted": acted, "pnl_backfill": backfill_stats}
def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
plan = get_plan(conn, int(plan_id))
if plan:
notify_plan_end(cfg, conn, plan)
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平).
- 方案C关闭 → 强制到期平
- 计划未写 oo_close_mode(旧单) → 到期平,避免误清残腿
- 新开仓默认写入 close_all(残值平:权利金≤初始20%且有买一)
"""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
return "hold_expiry"
raw = plan.get("oo_close_mode")
if raw is None or str(raw).strip() == "":
return "hold_expiry"
v = str(raw).strip().lower()
if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"):
return "hold_expiry"
return "close_all"
# 期期亏损腿残值平:当前买一回收 ≤ 本合约初始权利金 × 该比例
OO_LOSS_LEG_RESIDUAL_RATIO = 0.20
# 期期默认盈亏比:盈利金额 / 总权利金
OO_DEFAULT_PROFIT_RR = 2.0
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
out = []
for x in legs:
if not str(x.get("leg_role") or "").startswith("option"):
continue
if str(x.get("status") or "") in statuses:
out.append(x)
return out
def _oo_quote_bid(cfg: dict[str, Any], inst_id: str) -> tuple[Optional[float], Optional[float]]:
quote_fn = cfg.get("quote_option_contract")
ex_opt = cfg.get("exchange_options")
if not callable(quote_fn) or ex_opt is None or not inst_id:
return None, None
try:
q = quote_fn(ex_opt, inst_id)
if not q.get("ok"):
return None, None
return _sf(q.get("bid")), _sf(q.get("bid_sz"))
except Exception:
return None, None
def _oo_leg_mark_value(leg: dict[str, Any], bid: Optional[float]) -> Optional[float]:
"""买一可回收金额(USDC)= bid × 张数 × ct_mult."""
b = _sf(bid)
if b is None or b < 0:
return None
sheets = float(leg.get("size") or 1)
ct = float(leg.get("ct_mult") or 0.01)
return float(b) * sheets * ct
def _oo_plan_premium_total(plan: dict[str, Any], legs: list[dict[str, Any]]) -> float:
"""双腿总权利金:优先计划字段,否则对期权腿 premium 求和."""
total = _sf(plan.get("premium_total"))
if total is not None and total > 0:
return float(total)
s = 0.0
for leg in legs:
if not str(leg.get("leg_role") or "").startswith("option"):
continue
s += float(leg.get("premium") or 0)
return s
def _oo_leg_profit_rr(
leg: dict[str, Any], bid: Optional[float], *, total_premium: float
) -> Optional[float]:
"""盈亏比 = 该腿盈利金额 / 总权利金;盈利金额 = 买一回收 − 本腿权利金."""
if total_premium <= 0:
return None
leg_prem = float(leg.get("premium") or 0)
value = _oo_leg_mark_value(leg, bid)
if value is None:
return None
return (value - leg_prem) / total_premium
def _oo_resolve_profit_rr(plan: dict[str, Any]) -> Optional[float]:
rr = _sf(plan.get("profit_rr"))
if rr is not None and rr > 0:
return rr
return None
def _finalize_oo_all_closed(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
) -> dict[str, Any]:
closed_opts = _oo_option_legs(legs, statuses=("closed",))
total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts)
close_reason = reason or "oo_rest_closed"
bucket = "oo_target" if total_opts > 0 else "oo_expiry_loss"
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=close_reason,
realized_pnl_options=round(total_opts, 4),
realized_pnl_total=round(total_opts, 4),
stats_bucket=bucket,
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": close_reason, "total": total_opts}
def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]:
pt = plan.get("plan_type")
legs = get_plan_legs(conn, int(plan["id"]))
if pt == "perp_options":
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
if is_option_primary(plan):
if str(plan.get("status") or "") == "watching":
return _tick_po_option_primary_watching(cfg, conn, plan)
# 期权为主:半平重试 → 到期 → 目标位分叉
r = _tick_po_option_primary_pending(cfg, conn, plan, legs)
if r:
return r
r = _tick_po_option_primary_expiry(cfg, conn, plan, legs)
if r:
return r
r = _tick_po_option_primary_both_expired(cfg, conn, plan, legs)
if r:
return r
return _tick_po_option_primary(cfg, conn, plan, legs)
r = _tick_po(cfg, conn, plan, legs)
return r
if pt == "options_options":
r = _tick_oo_expiry(cfg, conn, plan, legs)
if r:
return r
r = _tick_oo_close_rest(cfg, conn, plan, legs)
if r:
return r
return _tick_oo_target(cfg, conn, plan, legs)
return None
def _tick_po_option_primary_watching(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any]
) -> Optional[dict[str, Any]]:
"""盯盘:链上出现杠杆/间隔达标合约后自动开仓."""
import json
import os
from lib.hedge_plan.hedge_plan_option_primary_lib import (
pick_option_primary_candidate,
size_from_premium,
)
from lib.hedge_plan.hedge_plan_orders_lib import execute_perp_options_start
from lib.hedge_plan.hedge_plan_register import _activate_watching_po
build_chain = cfg.get("build_option_chain")
ex = cfg.get("exchange_options")
if not callable(build_chain) or ex is None:
return None
body0: dict[str, Any] = {}
try:
raw = plan.get("preview_json") or ""
blob = json.loads(raw) if raw else {}
body0 = dict(blob.get("start_body") or blob or {})
except Exception:
body0 = {}
uly = str(plan.get("underlying") or body0.get("underlying") or "ETH").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")
if interval in (None, ""):
interval = body0.get("strike_interval", 15)
min_h = plan.get("min_option_hours")
if min_h in (None, ""):
min_h = body0.get("min_option_hours", 36)
opt_lev = plan.get("option_leverage")
if opt_lev in (None, ""):
opt_lev = body0.get("option_leverage")
try:
chain = build_chain(
ex,
uly,
max_dte_days=float(cfg.get("chain_max_dte") or 14),
itm_only=False,
itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"),
)
except Exception as e:
update_plan(conn, int(plan["id"]), note=f"盯盘拉链失败: {e}"[:500])
return None
cand = pick_option_primary_candidate(
chain,
direction=direction,
moneyness=money,
strike_interval=interval,
min_hours=min_h,
min_opt_leverage=opt_lev,
)
if not cand:
return None
ask = float(cand.get("ask") or 0)
ct = float(cand.get("ct_mult") or body0.get("ct_mult") or 0.01)
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),
)
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,
"underlying": uly,
"direction": direction,
"moneyness": money,
"opt_inst_id": cand.get("inst_id"),
"opt_type": cand.get("opt_type"),
"strike": cand.get("strike"),
"ask": ask,
"ct_mult": ct,
"sheets": sized["sheets"],
"contracts": sized["contracts"],
"eth_qty": sized.get("eth_qty"),
"index_px": idx,
"entry": idx,
"hours_to_expiry": cand.get("hours_to_expiry"),
"strike_interval": interval,
"min_option_hours": min_h,
"option_leverage": opt_lev,
"option_perp_ratio": plan.get("option_perp_ratio") or body0.get("option_perp_ratio"),
"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"),
"leverage": plan.get("leverage") or body0.get("leverage") or 100,
"exchange_symbol": body0.get("exchange_symbol") or f"{uly}-USDT-SWAP",
"contract_size": body0.get("contract_size") or 0.01,
}
)
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"):
update_plan(conn, int(plan["id"]), note=f"盯盘开仓未成: {out.get('msg')}"[:500])
return {"plan_id": plan["id"], "watching_open": False, "msg": out.get("msg")}
if dry:
update_plan(conn, int(plan["id"]), note=f"dry_run命中 {cand.get('inst_id')}"[:500])
return {"plan_id": plan["id"], "watching_open": True, "dry_run": True, "inst_id": cand.get("inst_id")}
_activate_watching_po(cfg, conn, int(plan["id"]), out, body)
return {
"plan_id": plan["id"],
"watching_open": True,
"inst_id": cand.get("inst_id"),
"leverage": cand.get("leverage"),
}
def _tick_po_option_primary_pending(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期权已平、永续待平(opt_target_perp_pending)时只重试平永续."""
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
pending = str(plan.get("close_reason") or "")
if pending not in ("opt_target_perp_pending", "opt_target_pending"):
return None
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)
if not perp or str(perp.get("status") or "") != "open":
return None
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 "")
contracts = float(perp.get("size") or plan.get("perp_size") or 0)
# 期权仍 open:继续走主路径,不在此强平
if pending == "opt_target_pending" and opt and str(opt.get("status") or "") == "open":
return None
# 期权已平或 already flat:只补平永续
if opt and str(opt.get("status") or "") == "open":
return None
perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False)
if not perp_close.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期权已平·永续平仓重试失败",
plan_id=plan.get("id"),
detail=str(perp_close.get("msg") or perp_close),
),
)
update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending")
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0
mark = entry
ex = cfg.get("exchange")
if ex is not None and symbol:
try:
t = ex.fetch_ticker(symbol)
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last")) or entry
except Exception:
pass
cs = float(cfg.get("default_contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
if callable(get_cs) and symbol:
try:
cs = float(get_cs(symbol) or cs)
except Exception:
pass
coins = contracts * cs
if perp_dir == "short":
perp_pnl = (float(entry or 0) - float(mark or 0)) * coins
else:
perp_pnl = (float(mark or 0) - float(entry or 0)) * coins
opt_pnl = float(opt.get("realized_pnl") or 0) if opt else float(plan.get("realized_pnl_options") or 0)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "opt_target_points", _now(), round(perp_pnl, 4), perp["id"]),
)
total = opt_pnl + perp_pnl
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason="opt_target_points",
realized_pnl_perp=round(perp_pnl, 4),
realized_pnl_options=round(opt_pnl, 4),
realized_pnl_total=round(total, 4),
stats_bucket="opt_primary",
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": "opt_target_points", "total": total, "recovered": True}
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_option_primary_lib import perp_direction_for_view
from lib.hedge_plan.hedge_plan_orders_lib import _close_perp
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)
if not perp or str(perp.get("status") or "") != "open":
return None
if not opt or str(opt.get("status") or "") != "open":
return None
if not leg_is_expired(opt):
return None
spot = _index_px(cfg, str(plan.get("underlying") or "ETH"))
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)
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"]),
)
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 "")
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)
cs = float(cfg.get("default_contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
if callable(get_cs) and symbol:
try:
cs = float(get_cs(symbol) or cs)
except Exception:
pass
coins = contracts * cs
if perp_dir == "short":
perp_pnl = (float(entry) - float(spot)) * coins
else:
perp_pnl = (float(spot) - float(entry)) * coins
if not perp_close.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期权到期后永续平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(perp_close.get("msg") or perp_close),
),
)
update_plan(
conn,
int(plan["id"]),
close_reason="opt_target_perp_pending",
realized_pnl_options=round(opt_pnl, 4),
note="期权已到期结算,永续待平",
)
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
conn.execute(
"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
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_total=round(total, 4),
stats_bucket="opt_primary",
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": "option_expired", "total": total}
def _tick_po_option_primary_expiry(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期权为主且永续已平、期权 hold_to_expiry → 到期结算后收口计划."""
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)
if not opt or str(opt.get("status") or "") != "hold_to_expiry":
return None
if perp and str(perp.get("status") or "") == "open":
return None
if not leg_is_expired(opt):
return None
spot = _index_px(cfg, str(plan.get("underlying") or "ETH"))
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)
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"]),
)
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
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_total=round(total, 4),
stats_bucket="opt_primary",
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": "perp_target_points_expiry", "total": total}
def _tick_po_option_primary(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""以期权为主:触达目标位立即执行分叉平仓规则."""
from lib.hedge_plan.hedge_plan_option_primary_lib import (
estimate_combo_net_pnl,
option_bid_liquidity_ok,
perp_direction_for_view,
target_hit,
)
from lib.hedge_plan.hedge_plan_orders_lib import _close_perp
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)
if not perp or str(perp.get("status") or "") != "open":
return None
if not opt or str(opt.get("status") or "") != "open":
return None
if _within_open_grace(plan):
return None
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()
strike = _sf(opt.get("strike"))
n = _sf(plan.get("option_target_points"))
m = _sf(plan.get("perp_target_points"))
if strike is None or strike <= 0:
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None:
return None
hit_opt = bool(n is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(n)))
hit_perp = bool(m is not None and target_hit(view_side=view, index_px=idx, strike=strike, points=float(m)))
if not hit_opt and not hit_perp:
return None
symbol = str(perp.get("symbol") or "")
mark = None
ex = cfg.get("exchange")
if ex is not None and symbol:
try:
t = ex.fetch_ticker(symbol)
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last"))
except Exception:
mark = None
mark = mark or idx
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or mark
cs = float(cfg.get("default_contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
if callable(get_cs) and symbol:
try:
cs = float(get_cs(symbol) or cs)
except Exception:
pass
quote_fn = cfg.get("quote_option_contract")
ex_opt = cfg.get("exchange_options")
bid = None
bid_sz = None
if callable(quote_fn) and ex_opt is not None:
try:
q = quote_fn(ex_opt, str(opt.get("inst_id") or ""))
if q.get("ok"):
bid = _sf(q.get("bid"))
bid_sz = _sf(q.get("bid_sz"))
except Exception:
bid = None
ask_open = _sf(opt.get("avg_open")) or 0.0
sheets = float(opt.get("size") or 1)
ct = float(opt.get("ct_mult") or 0.01)
contracts = float(perp.get("size") or plan.get("perp_size") or 0)
# 优先期权目标;买一不足或净利≤0 时若永续目标已触达则改走永续目标
if hit_opt:
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets)
net = None
if liq_ok:
net = estimate_combo_net_pnl(
view_side=view,
strike=float(strike),
index_px=float(idx),
ask_open=float(ask_open),
bid=float(bid or 0),
sheets=sheets,
ct_mult=ct,
perp_direction=perp_dir,
perp_entry=float(entry or 0),
perp_mark=float(mark or 0),
contracts=contracts,
contract_size=cs,
)
can_opt_exit = bool(liq_ok and net is not None and float(net.get("net") or 0) > 0)
if can_opt_exit:
reason = "opt_target_points"
close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=sheets)
if close_r.get("already_flat"):
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"])
elif not close_r.get("ok") or not close_r.get("fully_closed", True):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期权目标平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="opt_target_pending")
return {"plan_id": plan["id"], "retry": True, "close": close_r}
else:
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=net["opt_net"])
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), round(opt_pnl, 4), opt["id"]),
)
perp_close = _close_perp(
cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False
)
if not perp_close.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期权已平但永续平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(perp_close.get("msg") or perp_close),
),
)
update_plan(conn, int(plan["id"]), close_reason="opt_target_perp_pending")
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
perp_pnl = float(net.get("perp_net") or 0)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), round(perp_pnl, 4), perp["id"]),
)
total = float(opt_pnl) + float(perp_pnl)
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=reason,
realized_pnl_perp=round(perp_pnl, 4),
realized_pnl_options=round(opt_pnl, 4),
realized_pnl_total=round(total, 4),
stats_bucket="opt_primary",
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net}
if not hit_perp:
return {
"plan_id": plan["id"],
"skip": True,
"msg": (liq_msg if not liq_ok else "净利≤0,继续持有"),
"net": net,
}
if not hit_perp:
return None
reason = "perp_target_points"
# 永续目标:平永续,期权持有至到期
perp_close = _close_perp(cfg, symbol=symbol, direction=perp_dir, contracts=contracts, dry_run=False)
if not perp_close.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="永续目标平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(perp_close.get("msg") or perp_close),
),
)
update_plan(conn, int(plan["id"]), close_reason="perp_target_pending")
return {"plan_id": plan["id"], "retry": True, "perp_close": perp_close}
# 估永续已实现
coins = contracts * cs
if perp_dir == "short":
perp_pnl = (float(entry or 0) - float(mark or 0)) * coins
else:
perp_pnl = (float(mark or 0) - float(entry or 0)) * coins
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), round(perp_pnl, 4), perp["id"]),
)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
("hold_to_expiry", "hold_expiry_after_perp_target", opt["id"]),
)
update_plan(
conn,
int(plan["id"]),
# 计划保持 active,等期权到期收口
close_reason="perp_target_points",
realized_pnl_perp=round(perp_pnl, 4),
note="永续已按目标平仓,期权持有至到期",
)
notify_hedge(
cfg,
build_hedge_alert_message(
title="永续目标已平·期权持有至到期",
plan_id=plan.get("id"),
detail=f"指数 {idx:.2f} · 永续盈亏约 {perp_pnl:.2f}",
),
)
return {"plan_id": plan["id"], "close_reason": reason, "perp_pnl": perp_pnl, "opt_hold": True}
def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
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)
if not perp or perp.get("status") != "open":
return None
symbol = perp.get("symbol") or ""
direction = (plan.get("perp_direction") or plan.get("direction") or "long").lower()
live = _perp_live_contracts(cfg, symbol, direction)
# API 失败 / 未注入 → 本轮跳过,绝不当「已平」
if live is None:
return None
# 仍有仓 → 未触达交易所 TP/SL
if live > 0:
return None
# 开仓后宽限期:仓位同步延迟可误读为 0
if _within_open_grace(plan):
return None
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0
tp = _sf(plan.get("tp"))
sl = _sf(plan.get("sl"))
mark = None
ex = cfg.get("exchange")
if ex is not None and symbol:
try:
t = ex.fetch_ticker(symbol)
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last"))
except Exception:
mark = None
reason = _classify_po_flat_reason(
direction=direction, entry=float(entry or 0), mark=mark, tp=tp, sl=sl
)
# 上一轮止损强平未完成:粘滞为 SL,避免 mark 反弹误判 TP 跳过强平
pending_reason = str(plan.get("close_reason") or "")
if pending_reason == "perp_sl_pending_opt":
reason = "perp_sl"
elif pending_reason == "perp_tp_pending_opt":
reason = "perp_tp"
# 未明确 TP/SL 时不收口,下轮再判
if reason == "perp_flat_unknown":
return None
premium = float(plan.get("premium_total") or 0)
cs = float(cfg.get("default_contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
if callable(get_cs) and symbol:
try:
cs = float(get_cs(symbol) or cs)
except Exception:
pass
size = float(perp.get("size") or 0)
exit_px = mark or (tp if reason == "perp_tp" else sl) or entry
coins = size * cs
if direction == "short":
perp_pnl = (entry - exit_px) * coins
else:
perp_pnl = (exit_px - entry) * coins
opt_pnl = -premium
if reason == "perp_sl" and opt and str(opt.get("status") or "") == "open":
if _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True):
close_r = _sell_option(
cfg,
inst_id=str(opt.get("inst_id") or ""),
sheets=float(opt.get("size") or 1),
)
if close_r.get("already_flat"):
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), opt_pnl, opt["id"]),
)
elif not close_r.get("ok") or not close_r.get("fully_closed", True):
notify_hedge(
cfg,
build_hedge_alert_message(
title="永续止损后期权强制平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="perp_sl_pending_opt")
return {
"plan_id": plan["id"],
"msg": "止损后期权未平完",
"close": close_r,
"retry": True,
}
else:
bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px"))
ask_open = _sf(opt.get("avg_open"))
if bid is not None and ask_open is not None:
ct = float(opt.get("ct_mult") or 0.01)
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
else:
est = -premium
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", reason, _now(), opt_pnl, opt["id"]),
)
else:
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
("hold_to_expiry", "orphaned_after_sl", opt["id"]),
)
opt_pnl = -premium
elif reason == "perp_tp" and opt and str(opt.get("status") or "") == "open":
if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False):
close_r = _sell_option(
cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1)
)
if close_r.get("already_flat"):
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=-premium)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), opt_pnl, opt["id"]),
)
elif not close_r.get("ok") or not close_r.get("fully_closed", True):
notify_hedge(
cfg,
build_hedge_alert_message(
title="永续止盈后期权平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="perp_tp_pending_opt")
return {
"plan_id": plan["id"],
"msg": "止盈后期权未平完",
"close": close_r,
"retry": True,
}
else:
bid = _sf(close_r.get("bid") or close_r.get("locked_bid_px"))
ask_open = _sf(opt.get("avg_open"))
if bid is not None and ask_open is not None:
ct = float(opt.get("ct_mult") or 0.01)
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
else:
est = -premium
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", reason, _now(), opt_pnl, opt["id"]),
)
else:
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
("hold_to_expiry", "orphaned_after_tp", opt["id"]),
)
opt_pnl = -premium
total = perp_pnl + opt_pnl
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), perp_pnl, perp["id"]),
)
# partial → closed 也走同一收口
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=reason,
realized_pnl_perp=round(perp_pnl, 4),
realized_pnl_options=round(opt_pnl, 4),
realized_pnl_total=round(total, 4),
stats_bucket="tp" if reason == "perp_tp" else "sl",
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": reason, "total": total}
def _option_leg_pnl_after_close(
cfg: dict[str, Any],
leg: dict[str, Any],
*,
fallback: float,
) -> float:
"""平仓后写腿盈亏:优先交易所历史,否则用估算."""
ex = cfg.get("exchange_options")
pnl, _src = resolve_option_leg_realized_pnl(ex=ex, leg=leg, fallback=fallback)
return float(pnl if pnl is not None else fallback)
def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Optional[float]) -> float:
"""残腿平仓盈亏估算:优先买一回收 − 权利金;无买一则用内在价值."""
premium = float(leg.get("premium") or 0)
sheets = float(leg.get("size") or 1)
ct = float(leg.get("ct_mult") or 0.01)
if bid is not None and float(bid) > 0:
return float(bid) * sheets * ct - premium
if idx is None:
return -premium
strike = _sf(leg.get("strike")) or 0
o = (leg.get("opt_type") or "").upper()
intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx)
return intrinsic * sheets * ct - premium
def _tick_oo_close_rest(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""盈利腿已平后:残值平模式清亏损腿.
条件:买一回收 ≤ 本合约初始权利金×20%,且买一有流动性;失败或未达条件则下轮重试.
"""
from lib.hedge_plan.hedge_plan_option_primary_lib import option_bid_liquidity_ok
if resolve_oo_rest_close_mode(plan) != "close_all":
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
closed_legs = _oo_option_legs(legs, statuses=("closed",))
# 至少已平一条,且仍有残腿;避免双腿都还 open 时误清
if len(closed_legs) < 1 or len(open_legs) < 1:
return None
reason0 = str(plan.get("close_reason") or "")
allowed_reasons = (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
"",
)
if reason0 not in allowed_reasons and not (
len(closed_legs) >= 1 and len(open_legs) == 1
):
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
acted = False
waiting = False
for leg in list(open_legs):
inst_id = str(leg.get("inst_id") or "")
sheets = float(leg.get("size") or 1)
premium = float(leg.get("premium") or 0)
bid, bid_sz = _oo_quote_bid(cfg, inst_id)
value = _oo_leg_mark_value(leg, bid)
# 残值门槛:相对本合约初始权利金,买一回收须 ≤ 20%
if premium > 0:
if value is None:
waiting = True
continue
if value > premium * OO_LOSS_LEG_RESIDUAL_RATIO + 1e-12:
waiting = True
continue
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets)
if not liq_ok:
waiting = True
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {
"plan_id": plan["id"],
"msg": "残值平等待买一流动性",
"detail": liq_msg,
"waiting": True,
}
close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期残值平·亏损腿平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
bid_fill = _sf(close_r.get("bid")) or bid
est = _estimate_leg_close_pnl(leg, idx, bid_fill)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "oo_rest_close", _now(), round(pnl, 4), leg["id"]),
)
leg["status"] = "closed"
leg["realized_pnl"] = round(pnl, 4)
acted = True
if not acted:
if waiting:
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残值平等待本合约权利金≤20%", "waiting": True}
return None
legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
if still_open:
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿部分已平,继续重试", "remaining": len(still_open)}
return _finalize_oo_all_closed(
cfg, conn, plan, legs2, reason="oo_rest_closed"
)
def _after_oo_winner_closed(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
open_legs: list[dict[str, Any]],
best: dict[str, Any],
*,
reason: str,
extra: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""盈利腿已平后:残值平同轮尝试 / 到期平标记 hold_to_expiry."""
rest_mode = resolve_oo_rest_close_mode(plan)
update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan)
mid["close_reason"] = reason
mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid)
out: dict[str, Any] = {
"plan_id": plan["id"],
"close_reason": reason,
"closed_leg": best.get("id"),
"oo_close_mode": rest_mode,
}
if extra:
out.update(extra)
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
if rest:
out["rest"] = rest
return out
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
)
return out
def _tick_oo_profit_rr(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, rr_target: float
) -> Optional[dict[str, Any]]:
"""期期:任一开仓腿盈亏比(该腿盈利金额/总权利金)达目标 → 平盈利腿."""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 2:
return None
total_prem = _oo_plan_premium_total(plan, legs)
if total_prem <= 0:
return None
ranked: list[tuple[float, float, dict[str, Any]]] = []
for leg in open_legs:
bid, _bid_sz = _oo_quote_bid(cfg, str(leg.get("inst_id") or ""))
rr = _oo_leg_profit_rr(leg, bid, total_premium=total_prem)
if rr is None:
continue
value = _oo_leg_mark_value(leg, bid) or 0.0
premium = float(leg.get("premium") or 0)
pnl = value - premium
ranked.append((rr, pnl, leg))
if not ranked:
return None
ranked.sort(key=lambda x: x[0], reverse=True)
best_rr, best_pnl, best = ranked[0]
if best_rr + 1e-12 < float(rr_target) or best_pnl <= 0:
return None
close_r = _sell_option(
cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1)
)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期平盈利腿失败",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "profit_rr_win_leg"
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]),
)
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
return _after_oo_winner_closed(
cfg,
conn,
plan,
open_legs,
best,
reason=reason,
extra={
"profit_rr": best_rr,
"rr_target": float(rr_target),
"total_premium": total_prem,
"index": idx,
},
)
def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期期:优先按盈亏比平盈利腿;旧单无 profit_rr 时回退上/下破目标价."""
rr_target = _oo_resolve_profit_rr(plan)
if rr_target is not None:
return _tick_oo_profit_rr(cfg, conn, plan, legs, rr_target=rr_target)
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None:
return None
up = _sf(plan.get("target_price_up"))
down = _sf(plan.get("target_price_down"))
# 旧计划仅有单目标:两边都用它
legacy = _sf(plan.get("target_price"))
if up is None and legacy is not None:
up = legacy
if down is None and legacy is not None:
down = legacy
if up is None and down is None:
return None
hit_side: Optional[str] = None
if up is not None and idx >= up * 0.998:
hit_side = "up"
elif down is not None and idx <= down * 1.002:
hit_side = "down"
if not hit_side:
return None
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 2:
return None
winners = []
for leg in open_legs:
strike = _sf(leg.get("strike")) or 0
o = (leg.get("opt_type") or "").upper()
intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx)
premium = float(leg.get("premium") or 0)
pnl = intrinsic * float(leg.get("size") or 1) * float(leg.get("ct_mult") or 0.01) - premium
winners.append((pnl, leg))
winners.sort(key=lambda x: x[0], reverse=True)
best_pnl, best = winners[0]
if best_pnl <= 0:
return None
close_r = _sell_option(cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1))
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期平盈利腿失败",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]),
)
return _after_oo_winner_closed(
cfg,
conn,
plan,
open_legs,
best,
reason=reason,
extra={"hit_side": hit_side, "index": idx},
)
def _tick_oo_expiry(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期期:剩余期权腿全部到期 → 结算合计并结束计划."""
pending = [
x
for x in legs
if str(x.get("leg_role") or "").startswith("option")
and str(x.get("status") or "") in ("open", "hold_to_expiry")
]
if not pending:
# 若腿已全部 closed 但计划仍 active(异常残留)则用腿合计收口
closed_opts = [
x for x in legs if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed"
]
if len(closed_opts) < 1:
return None
total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts)
reason = "oo_expiry_loss" if total_opts <= 0 else "oo_expiry_win"
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=reason,
realized_pnl_options=round(total_opts, 4),
realized_pnl_total=round(total_opts, 4),
stats_bucket=reason if reason == "oo_expiry_loss" else "oo_target",
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": reason, "total": total_opts}
if not all(leg_is_expired(x) for x in pending):
return None
spot = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if spot is None:
return None
settled_sum = 0.0
for leg in pending:
est = settle_option_leg_at_spot(leg, float(spot))
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
settled_sum += pnl
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "expiry", _now(), round(pnl, 4), leg["id"]),
)
already = sum(
float(x.get("realized_pnl") or 0)
for x in legs
if str(x.get("leg_role") or "").startswith("option") and x.get("status") == "closed"
)
total = already + settled_sum
reason = "oo_expiry_loss" if total <= 0 else "oo_expiry_win"
bucket = "oo_expiry_loss" if reason == "oo_expiry_loss" else "oo_target"
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=reason,
realized_pnl_options=round(total, 4),
realized_pnl_total=round(total, 4),
stats_bucket=bucket,
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": reason, "total": total, "spot": spot}
def _settle_orphaned_after_tp(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any]]:
"""永期止盈后 hold_to_expiry 期权到期:只更新腿,不回写计划合计."""
rows = conn.execute(
"""
SELECT l.id AS leg_id, l.plan_id, l.inst_id, l.opt_type, l.strike, l.size, l.premium, l.status,
p.underlying, p.status AS plan_status
FROM hedge_plan_legs l
JOIN hedge_plans p ON p.id = l.plan_id
WHERE l.status = 'hold_to_expiry' AND l.close_reason = 'orphaned_after_tp'
LIMIT 40
"""
).fetchall()
acted: list[dict[str, Any]] = []
for row in rows:
leg = dict(row)
if not leg_is_expired(leg):
continue
spot = _index_px(cfg, str(leg.get("underlying") or "ETH"))
if spot is None:
continue
pnl_est = settle_option_leg_at_spot(leg, float(spot))
# orphan row uses leg_id; map to id for resolver
leg_for_pnl = dict(leg)
leg_for_pnl["id"] = leg.get("leg_id")
pnl = _option_leg_pnl_after_close(cfg, leg_for_pnl, fallback=pnl_est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "expiry", _now(), round(pnl, 4), leg["leg_id"]),
)
# 故意不 UPDATE hedge_plans.realized_pnl_*
acted.append(
{
"plan_id": leg["plan_id"],
"close_reason": "orphaned_option_expiry",
"leg_id": leg["leg_id"],
"leg_pnl": round(pnl, 4),
"note": "不回写计划合计",
}
)
return acted