Add hedge-plan WeChat start/end alerts and expiry settlement.
Wire idempotent notify on open/close/partial fail, settle OO at expiry, and close orphaned TP option legs without rewriting plan totals. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""对冲计划监控:永期 TP/SL 与期期目标价/到期收口."""
|
||||
"""对冲计划监控:永期 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_legs, list_plans, update_plan
|
||||
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, settle_option_leg_at_spot
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -47,11 +49,21 @@ def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]:
|
||||
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 tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""扫描 active 计划并按规则收口.返回处理摘要."""
|
||||
"""扫描 active 计划 + 止盈后遗留期权到期收口.返回处理摘要."""
|
||||
get_db = cfg.get("get_db")
|
||||
if not callable(get_db):
|
||||
return {"ok": False, "msg": "get_db missing"}
|
||||
@@ -61,31 +73,44 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
|
||||
|
||||
init_hedge_plan_tables(conn)
|
||||
plans = list_plans(conn, status="active", limit=20)
|
||||
plans = list_plans(conn, status="active", limit=40)
|
||||
for plan in plans:
|
||||
r = _tick_one(cfg, conn, plan)
|
||||
if r:
|
||||
acted.append(r)
|
||||
orphaned = _settle_orphaned_after_tp(cfg, conn)
|
||||
acted.extend(orphaned)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"ok": True, "acted": acted}
|
||||
|
||||
|
||||
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 _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":
|
||||
return _tick_po(cfg, conn, plan, legs)
|
||||
# 先判断期权是否已过期且永续仍在(罕见);主路径仍是永续平仓侦测
|
||||
r = _tick_po(cfg, conn, plan, legs)
|
||||
return r
|
||||
if pt == "options_options":
|
||||
return _tick_oo(cfg, conn, plan, legs)
|
||||
r = _tick_oo_expiry(cfg, conn, plan, legs)
|
||||
if r:
|
||||
return r
|
||||
return _tick_oo_target(cfg, conn, plan, legs)
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
if not perp or perp.get("status") != "open":
|
||||
return None
|
||||
symbol = perp.get("symbol") or ""
|
||||
direction = (plan.get("direction") or "long").lower()
|
||||
@@ -117,7 +142,6 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
elif direction == "short" and mark <= tp:
|
||||
reason = "perp_tp"
|
||||
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:
|
||||
@@ -140,13 +164,20 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
inst_id=str(opt.get("inst_id") or ""),
|
||||
sheets=float(opt.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),
|
||||
),
|
||||
)
|
||||
if close_r.get("ok"):
|
||||
# 无法精确拿到卖出价差时仍用 −premium 作为下限;有 bid 则近似
|
||||
bid = _sf(close_r.get("bid"))
|
||||
ask_open = _sf(opt.get("avg_open"))
|
||||
if bid is not None and ask_open is not None:
|
||||
ct = 0.01
|
||||
ct = float(opt.get("ct_mult") or 0.01)
|
||||
opt_pnl = (bid - ask_open) * float(opt.get("size") or 1) * ct
|
||||
else:
|
||||
opt_pnl = -premium
|
||||
@@ -155,9 +186,17 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
("closed", reason, _now(), opt_pnl, opt["id"]),
|
||||
)
|
||||
elif reason == "perp_tp" and opt:
|
||||
# 止盈默认不平期权
|
||||
if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False):
|
||||
_sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1))
|
||||
close_r = _sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.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),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=? WHERE id=?",
|
||||
("closed", reason, _now(), opt["id"]),
|
||||
@@ -170,9 +209,9 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
opt_pnl = -premium
|
||||
|
||||
if reason == "perp_tp":
|
||||
total = perp_pnl + opt_pnl # = 止盈盈利 − 权利金
|
||||
total = perp_pnl + opt_pnl
|
||||
else:
|
||||
total = opt_pnl + perp_pnl # 期权盈亏 + 永续盈亏
|
||||
total = opt_pnl + perp_pnl
|
||||
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
@@ -189,33 +228,32 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
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 _tick_oo(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
||||
def _tick_oo_target(
|
||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
target = _sf(plan.get("target_price"))
|
||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||
if target is None or idx is None:
|
||||
return None
|
||||
# 简化:接近目标价(相对 0.15%)时平盈利腿
|
||||
if abs(idx - target) / max(target, 1) > 0.0015 and not (idx >= target or idx <= target):
|
||||
pass
|
||||
near = abs(idx - target) / max(abs(target), 1.0) <= 0.002
|
||||
if not near:
|
||||
return None
|
||||
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
|
||||
return None
|
||||
open_legs = [x for x in legs if x.get("status") == "open" and x.get("leg_role", "").startswith("option")]
|
||||
open_legs = [x for x in legs if x.get("status") == "open" and str(x.get("leg_role") or "").startswith("option")]
|
||||
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) * 0.01 - premium
|
||||
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]
|
||||
@@ -223,11 +261,130 @@ def _tick_oo(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
|
||||
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}
|
||||
conn.execute(
|
||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||
("closed", "target_win_leg", _now(), best_pnl, best["id"]),
|
||||
)
|
||||
# 计划暂不 closed,等另一腿到期;先标 note
|
||||
update_plan(conn, int(plan["id"]), close_reason="target_win_leg")
|
||||
mid = dict(plan)
|
||||
mid["close_reason"] = "target_win_leg"
|
||||
mid["status"] = "active"
|
||||
notify_plan_end(cfg, conn, mid)
|
||||
return {"plan_id": plan["id"], "close_reason": "target_win_leg", "closed_leg": best.get("id")}
|
||||
|
||||
|
||||
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:
|
||||
pnl = settle_option_leg_at_spot(leg, float(spot))
|
||||
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 = settle_option_leg_at_spot(leg, float(spot))
|
||||
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
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import update_plan
|
||||
|
||||
|
||||
def _fmt(v: Any, d: int = 2) -> str:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return "—"
|
||||
return f"{float(v):.{d}f}"
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def _type_label(plan_type: str) -> str:
|
||||
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
|
||||
|
||||
|
||||
def _dir_label(direction: str) -> str:
|
||||
d = (direction or "").lower()
|
||||
if d == "long":
|
||||
return "做多"
|
||||
if d == "short":
|
||||
return "做空"
|
||||
return "—"
|
||||
|
||||
|
||||
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
|
||||
pt = plan.get("plan_type") or ""
|
||||
lines = [
|
||||
f"🟢 对冲计划启动 #{plan.get('id')}",
|
||||
f"📌 类型:{_type_label(pt)}",
|
||||
f"🪙 标的:{plan.get('underlying') or '—'}",
|
||||
]
|
||||
if pt == "perp_options":
|
||||
lines.extend(
|
||||
[
|
||||
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
|
||||
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
|
||||
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
|
||||
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
f"🎯 目标价 S*:{_fmt(plan.get('target_price'))}",
|
||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||
]
|
||||
)
|
||||
if legs:
|
||||
for leg in legs:
|
||||
role = leg.get("leg_role") or ""
|
||||
if role == "perp":
|
||||
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
|
||||
else:
|
||||
lines.append(
|
||||
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
|
||||
f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}"
|
||||
)
|
||||
lines.append("📎 独立模块推送,不进普通交易复盘")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
||||
reason = plan.get("close_reason") or "—"
|
||||
total = plan.get("realized_pnl_total")
|
||||
try:
|
||||
tv = float(total) if total is not None else None
|
||||
except (TypeError, ValueError):
|
||||
tv = None
|
||||
head = "🔴" if (tv is not None and tv < 0) else "🟢"
|
||||
reason_map = {
|
||||
"perp_tp": "永续止盈(期权默认不平)",
|
||||
"perp_sl": "永续止损(期权强制平)",
|
||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
||||
"oo_expiry_win": "期期到期仍盈利",
|
||||
"expiry": "到期收口",
|
||||
"manual": "人工结束",
|
||||
"partial_fail": "半腿失败收尾",
|
||||
"cancelled": "已取消",
|
||||
}
|
||||
lines = [
|
||||
f"{head} 对冲计划结束 #{plan.get('id')}",
|
||||
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
|
||||
f"🪙 标的:{plan.get('underlying') or '—'}",
|
||||
f"📎 原因:{reason_map.get(reason, reason)}",
|
||||
f"💰 合计≈U:{_fmt(total)}",
|
||||
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
|
||||
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
|
||||
f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_hedge_alert_message(
|
||||
*,
|
||||
title: str,
|
||||
plan_id: Any = None,
|
||||
detail: str = "",
|
||||
) -> str:
|
||||
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
|
||||
if detail:
|
||||
lines.append(str(detail)[:800])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def notify_hedge(
|
||||
cfg: dict[str, Any],
|
||||
content: str,
|
||||
) -> bool:
|
||||
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
|
||||
if not callable(send):
|
||||
return False
|
||||
try:
|
||||
send(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def notify_plan_start(
|
||||
cfg: dict[str, Any],
|
||||
conn: Any,
|
||||
plan: dict[str, Any],
|
||||
legs: Optional[list[dict[str, Any]]] = None,
|
||||
) -> bool:
|
||||
if int(plan.get("wechat_start_sent") or 0):
|
||||
return False
|
||||
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
|
||||
if ok and plan.get("id") is not None:
|
||||
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
|
||||
plan["wechat_start_sent"] = 1
|
||||
return ok
|
||||
|
||||
|
||||
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
|
||||
if int(plan.get("wechat_end_sent") or 0):
|
||||
return False
|
||||
# 中间态 target_win_leg 不算正式结束推送(用告警)
|
||||
if (plan.get("close_reason") or "") == "target_win_leg" and (plan.get("status") or "") != "closed":
|
||||
notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(
|
||||
title="期期已平盈利腿,亏损腿继续持有至到期",
|
||||
plan_id=plan.get("id"),
|
||||
detail=f"目标价 {_fmt(plan.get('target_price'))}",
|
||||
),
|
||||
)
|
||||
return True
|
||||
ok = notify_hedge(cfg, build_hedge_end_message(plan))
|
||||
if ok and plan.get("id") is not None:
|
||||
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
|
||||
plan["wechat_end_sent"] = 1
|
||||
return ok
|
||||
|
||||
|
||||
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
|
||||
detail = msg
|
||||
if results:
|
||||
try:
|
||||
detail = f"{msg}\n路径结果:{results}"[:800]
|
||||
except Exception:
|
||||
pass
|
||||
return notify_hedge(
|
||||
cfg,
|
||||
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
|
||||
)
|
||||
@@ -278,9 +278,19 @@ def execute_perp_options_start(
|
||||
sheets=float(opt_res.get("sheets") or body.get("sheets") or 1),
|
||||
)
|
||||
results.append({"step": "options_auto_close_on_perp_fail", **close_r})
|
||||
msg = perp_res.get("msg") or "永续开仓失败"
|
||||
if not dry_run:
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
|
||||
|
||||
notify_partial_fail(
|
||||
cfg, plan_type="perp_options", msg=msg, results=results
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": perp_res.get("msg") or "永续开仓失败",
|
||||
"msg": msg,
|
||||
"path": path,
|
||||
"results": results,
|
||||
"partial": True,
|
||||
@@ -322,9 +332,17 @@ def execute_options_options_start(
|
||||
if not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
|
||||
close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1))
|
||||
results.append({"step": "options_auto_close_leg_a", **close_r})
|
||||
msg = b_res.get("msg") or "腿B开仓失败"
|
||||
if not dry_run:
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
|
||||
|
||||
notify_partial_fail(cfg, plan_type="options_options", msg=msg, results=results)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": b_res.get("msg") or "腿B开仓失败",
|
||||
"msg": msg,
|
||||
"path": path,
|
||||
"results": results,
|
||||
"partial": True,
|
||||
|
||||
@@ -98,6 +98,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(),
|
||||
"options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(),
|
||||
"live_trading": _env_bool("LIVE_TRADING_ENABLED", False),
|
||||
"send_wechat": getattr(app_module, "send_wechat_msg", None),
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +172,14 @@ def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
get_plan,
|
||||
get_plan_legs,
|
||||
init_hedge_plan_tables,
|
||||
insert_leg,
|
||||
insert_plan,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
@@ -229,13 +237,25 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
plan = get_plan(conn, plan_id)
|
||||
legs = get_plan_legs(conn, plan_id)
|
||||
if plan:
|
||||
notify_plan_start(cfg, conn, plan, legs)
|
||||
conn.commit()
|
||||
return plan_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
get_plan,
|
||||
get_plan_legs,
|
||||
init_hedge_plan_tables,
|
||||
insert_leg,
|
||||
insert_plan,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
@@ -274,6 +294,11 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
plan = get_plan(conn, plan_id)
|
||||
legs = get_plan_legs(conn, plan_id)
|
||||
if plan:
|
||||
notify_plan_start(cfg, conn, plan, legs)
|
||||
conn.commit()
|
||||
return plan_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.exchange.okx_options_lib import normalize_option_exp_ms
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
|
||||
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
|
||||
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
|
||||
|
||||
|
||||
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
|
||||
exp = leg_exp_ms(leg)
|
||||
if exp is None:
|
||||
return False
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return now >= int(exp)
|
||||
|
||||
|
||||
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
|
||||
"""按到期结算口径估算腿盈亏(USDC)."""
|
||||
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)
|
||||
return float(
|
||||
option_expiry_pnl(
|
||||
opt_type=str(leg.get("opt_type") or "P"),
|
||||
strike=float(strike),
|
||||
spot=float(spot),
|
||||
sheets=sheets,
|
||||
ct_mult=ct,
|
||||
premium_paid=premium,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
|
||||
opts = [
|
||||
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 opts:
|
||||
return False
|
||||
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""对冲计划微信文案与到期结算."""
|
||||
import sqlite3
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import expiry_ms_from_inst_id
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import _tick_oo_expiry, _settle_orphaned_after_tp
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import (
|
||||
build_hedge_end_message,
|
||||
build_hedge_start_message,
|
||||
notify_plan_end,
|
||||
notify_plan_start,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot
|
||||
|
||||
|
||||
def _mem_db():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgeNotify(unittest.TestCase):
|
||||
def test_start_end_copy(self):
|
||||
plan = {
|
||||
"id": 7,
|
||||
"plan_type": "perp_options",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"entry_mark": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"perp_size": 2,
|
||||
"leverage": 10,
|
||||
"premium_total": 1.5,
|
||||
"close_reason": "perp_tp",
|
||||
"realized_pnl_total": 12.3,
|
||||
"realized_pnl_perp": 15,
|
||||
"realized_pnl_options": -1.5,
|
||||
"opened_at": "2026-07-01 10:00:00",
|
||||
"closed_at": "2026-07-01 12:00:00",
|
||||
}
|
||||
s = build_hedge_start_message(plan)
|
||||
self.assertIn("启动 #7", s)
|
||||
self.assertIn("永期", s)
|
||||
e = build_hedge_end_message(plan)
|
||||
self.assertIn("结束 #7", e)
|
||||
self.assertIn("止盈", e)
|
||||
|
||||
def test_idempotent_flags(self):
|
||||
conn = _mem_db()
|
||||
sent = []
|
||||
cfg = {"send_wechat": lambda c: sent.append(c)}
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price": 2000,
|
||||
"premium_total": 2.0,
|
||||
"opened_at": "t0",
|
||||
},
|
||||
)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertTrue(notify_plan_start(cfg, conn, plan, []))
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertEqual(int(plan["wechat_start_sent"]), 1)
|
||||
self.assertFalse(notify_plan_start(cfg, conn, plan, []))
|
||||
self.assertEqual(len(sent), 1)
|
||||
|
||||
plan["status"] = "closed"
|
||||
plan["close_reason"] = "oo_expiry_loss"
|
||||
plan["realized_pnl_total"] = -2
|
||||
plan["closed_at"] = "t1"
|
||||
self.assertTrue(notify_plan_end(cfg, conn, plan))
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertEqual(int(plan["wechat_end_sent"]), 1)
|
||||
self.assertFalse(notify_plan_end(cfg, conn, plan))
|
||||
self.assertEqual(len(sent), 2)
|
||||
|
||||
|
||||
class TestHedgeSettle(unittest.TestCase):
|
||||
def test_put_expiry_otm(self):
|
||||
pnl = settle_option_leg_at_spot(
|
||||
{"opt_type": "P", "strike": 1700, "size": 2, "premium": 1.2, "ct_mult": 0.01},
|
||||
spot=1800,
|
||||
)
|
||||
self.assertAlmostEqual(pnl, -1.2)
|
||||
|
||||
def test_call_expiry_itm(self):
|
||||
# intrinsic (1900-1800)*2*0.01 - 0.5 = 2 - 0.5
|
||||
pnl = settle_option_leg_at_spot(
|
||||
{"opt_type": "C", "strike": 1800, "size": 2, "premium": 0.5, "ct_mult": 0.01},
|
||||
spot=1900,
|
||||
)
|
||||
self.assertAlmostEqual(pnl, 1.5)
|
||||
|
||||
def test_leg_expired_from_inst(self):
|
||||
# past date in inst_id
|
||||
past = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
yy = past.year % 100
|
||||
tag = f"ETH-USD-{yy:02d}{past.month:02d}{past.day:02d}-1800-P"
|
||||
self.assertTrue(leg_is_expired({"inst_id": tag}))
|
||||
future = datetime.now(timezone.utc) + timedelta(days=10)
|
||||
tag2 = f"ETH-USD-{future.year % 100:02d}{future.month:02d}{future.day:02d}-1800-P"
|
||||
self.assertFalse(leg_is_expired({"inst_id": tag2}))
|
||||
self.assertIsNotNone(expiry_ms_from_inst_id(tag))
|
||||
|
||||
|
||||
class TestHedgeMonitorExpiry(unittest.TestCase):
|
||||
def test_oo_expiry_loss_closes_plan(self):
|
||||
conn = _mem_db()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1800-P"
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price": 2000,
|
||||
"premium_total": 2.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": tag,
|
||||
"opt_type": "P",
|
||||
"strike": 1800,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": tag.replace("-P", "-C").replace("1800", "1900"),
|
||||
"opt_type": "C",
|
||||
"strike": 1900,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
sent = []
|
||||
cfg = {
|
||||
"send_wechat": lambda c: sent.append(c),
|
||||
"fetch_index_price": lambda ex, u: 1850.0,
|
||||
"exchange_options": object(),
|
||||
}
|
||||
plan = get_plan(conn, pid)
|
||||
legs = [
|
||||
dict(r)
|
||||
for r in conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchall()
|
||||
]
|
||||
r = _tick_oo_expiry(cfg, conn, plan, legs)
|
||||
self.assertIsNotNone(r)
|
||||
self.assertEqual(r["close_reason"], "oo_expiry_loss")
|
||||
plan2 = get_plan(conn, pid)
|
||||
self.assertEqual(plan2["status"], "closed")
|
||||
self.assertLessEqual(float(plan2["realized_pnl_total"]), 0)
|
||||
self.assertTrue(any("结束" in x for x in sent))
|
||||
|
||||
def test_orphaned_option_does_not_rewrite_plan_total(self):
|
||||
conn = _mem_db()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1700-P"
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"close_reason": "perp_tp",
|
||||
"realized_pnl_total": 10.0,
|
||||
"realized_pnl_options": -1.0,
|
||||
"stats_bucket": "tp",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_hedge",
|
||||
"inst_id": tag,
|
||||
"opt_type": "P",
|
||||
"strike": 1700,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "hold_to_expiry",
|
||||
"close_reason": "orphaned_after_tp",
|
||||
},
|
||||
)
|
||||
cfg = {
|
||||
"fetch_index_price": lambda ex, u: 1800.0,
|
||||
"exchange_options": object(),
|
||||
}
|
||||
acted = _settle_orphaned_after_tp(cfg, conn)
|
||||
self.assertEqual(len(acted), 1)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertAlmostEqual(float(plan["realized_pnl_total"]), 10.0)
|
||||
leg = conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchone()
|
||||
self.assertEqual(leg["status"], "closed")
|
||||
self.assertEqual(leg["close_reason"], "expiry")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user