Show editable exit on live cards; price perp PnL via close-side book VWAP.

Align monitor profit-exit with the same book VWAP so card and auto-exit stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 17:29:58 +08:00
parent 3f98ba1e88
commit 9295dcffe3
10 changed files with 597 additions and 10 deletions
+193
View File
@@ -0,0 +1,193 @@
"""永续盘口:按平仓方向扫档估 VWAP(持仓净利/出场判定与真平仓同口径)."""
from __future__ import annotations
from typing import Any, Optional
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _levels_from_book(book: dict[str, Any] | None, side: str) -> list[tuple[float, float]]:
"""side=bids|asks → [(px, sz_contracts), ...]. ccxt 多为 [px, amount]."""
if not isinstance(book, dict):
return []
raw = book.get(side) or []
out: list[tuple[float, float]] = []
for row in raw:
px = sz = None
if isinstance(row, (list, tuple)) and len(row) >= 2:
px, sz = _sf(row[0]), _sf(row[1])
elif isinstance(row, dict):
px = _sf(row.get("px") or row.get("price") or row.get(0))
sz = _sf(row.get("sz") or row.get("size") or row.get("amount") or row.get(1))
if px is None or px <= 0 or sz is None or sz <= 0:
continue
out.append((float(px), float(sz)))
return out
def walk_book_vwap(
levels: list[tuple[float, float]],
*,
need_contracts: float,
) -> dict[str, Any]:
"""从第一档往下扫 need_contracts 张,返回 VWAP 与流动性结果."""
need = float(need_contracts or 0)
if need <= 0:
return {
"ok": False,
"liq_ok": False,
"msg": "平仓张数无效",
"vwap": None,
"top_px": None,
"top_sz": None,
"filled_contracts": 0.0,
"need_contracts": need,
}
if not levels:
return {
"ok": False,
"liq_ok": False,
"msg": "盘口为空",
"vwap": None,
"top_px": None,
"top_sz": None,
"filled_contracts": 0.0,
"need_contracts": need,
}
top_px, top_sz = levels[0][0], levels[0][1]
remain = need
notional = 0.0
filled = 0.0
for px, sz in levels:
take = min(remain, sz)
if take <= 0:
continue
notional += take * px
filled += take
remain -= take
if remain <= 1e-12:
break
if filled <= 1e-12:
return {
"ok": False,
"liq_ok": False,
"msg": "盘口深度为 0",
"vwap": None,
"top_px": top_px,
"top_sz": top_sz,
"filled_contracts": 0.0,
"need_contracts": need,
}
vwap = notional / filled
liq_ok = remain <= 1e-12
msg = "" if liq_ok else f"盘口深度不足(需 {need:g} 张,可扫 {filled:g})"
return {
"ok": True,
"liq_ok": liq_ok,
"msg": msg,
"vwap": round(float(vwap), 8),
"top_px": float(top_px),
"top_sz": float(top_sz),
"filled_contracts": round(float(filled), 8),
"need_contracts": need,
}
def perp_close_book_side(position_side: str) -> str:
"""持仓方向 → 平仓吃的盘口侧:平多吃 bids,平空吃 asks."""
return "bids" if str(position_side or "").lower() == "long" else "asks"
def fetch_perp_close_vwap(
exchange: Any,
*,
symbol: str,
position_side: str,
contracts: float,
fallback_px: Optional[float] = None,
limit: int = 20,
) -> dict[str, Any]:
"""拉永续盘口并按平仓方向扫档估 VWAP.
返回含: vwap, top_px, top_sz, book_side, liq_ok, msg, filled_contracts.
深度不足时仍返回已扫部分均价(ok=True,liq_ok=False);完全无盘口则回退 fallback_px.
"""
sym = str(symbol or "").strip()
side = str(position_side or "").lower()
book_side = perp_close_book_side(side)
need = float(contracts or 0)
base: dict[str, Any] = {
"ok": False,
"liq_ok": False,
"msg": "",
"vwap": None,
"top_px": None,
"top_sz": None,
"filled_contracts": 0.0,
"need_contracts": need,
"book_side": book_side,
"symbol": sym,
"position_side": side,
}
if not sym or need <= 0:
base["msg"] = "符号或张数无效"
if fallback_px and float(fallback_px) > 0:
base.update({"ok": True, "vwap": float(fallback_px), "msg": "无盘口,回退标记/开仓价"})
return base
book = None
if exchange is not None and hasattr(exchange, "fetch_order_book"):
try:
book = exchange.fetch_order_book(sym, limit=int(limit or 20))
except Exception as e:
base["msg"] = f"拉盘口失败: {e}"
book = None
levels = _levels_from_book(book, book_side)
walked = walk_book_vwap(levels, need_contracts=need)
base.update(walked)
base["book_side"] = book_side
if base.get("vwap") is not None:
return base
# 回退:ticker 单档
try:
if exchange is not None and hasattr(exchange, "fetch_ticker"):
t = exchange.fetch_ticker(sym) or {}
if book_side == "bids":
px = _sf(t.get("bid") or (t.get("info") or {}).get("bidPx"))
else:
px = _sf(t.get("ask") or (t.get("info") or {}).get("askPx"))
if px and px > 0:
base.update(
{
"ok": True,
"liq_ok": False,
"vwap": float(px),
"top_px": float(px),
"top_sz": None,
"msg": "仅单档行情,深度未知",
"filled_contracts": need,
}
)
return base
except Exception:
pass
if fallback_px and float(fallback_px) > 0:
base.update(
{
"ok": True,
"liq_ok": False,
"vwap": float(fallback_px),
"msg": base.get("msg") or "无盘口,回退标记/开仓价",
}
)
return base
+79
View File
@@ -0,0 +1,79 @@
"""对冲计划出场条件:读取/校验/更新."""
from __future__ import annotations
from typing import Any, Optional
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def normalize_exit_mode(raw: Any, *, profit_target: Any = None) -> str:
m = str(raw or "").strip().lower()
if m in ("profit", "target"):
return m
if _sf(profit_target) is not None and float(profit_target) > 0:
return "profit"
return "target"
def validate_exit_payload(body: dict[str, Any] | None) -> tuple[bool, str, dict[str, Any]]:
"""校验改出场请求 → (ok, msg, fields_for_update)."""
b = body or {}
mode = normalize_exit_mode(b.get("exit_mode") or b.get("mode"))
if mode == "profit":
pt = _sf(b.get("profit_target_usdt"))
if pt is None or pt <= 0:
return False, "盈利金额须大于 0", {}
return True, "", {
"exit_mode": "profit",
"profit_target_usdt": float(pt),
}
opt_pts = _sf(b.get("option_target_points"))
perp_pts = _sf(b.get("perp_target_points"))
if opt_pts is None or opt_pts <= 0:
return False, "期权目标点数须大于 0", {}
if perp_pts is None or perp_pts <= 0:
return False, "永续目标点数须大于 0", {}
return True, "", {
"exit_mode": "target",
"option_target_points": float(opt_pts),
"perp_target_points": float(perp_pts),
}
def update_plan_exit(cfg: dict[str, Any], conn: Any, plan_id: int, body: dict[str, Any] | None) -> dict[str, Any]:
"""更新进行中计划的出场条件."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "").lower()
legs = get_plan_legs(conn, int(plan_id))
has_open = any(str(x.get("status") or "").lower() == "open" for x in legs)
if st not in ("opening", "active", "partial", "watching") and not (st == "closed" and has_open):
return {"ok": False, "msg": f"当前状态 {st or '—'} 不可改出场"}
ok, msg, fields = validate_exit_payload(body)
if not ok:
return {"ok": False, "msg": msg}
update_plan(conn, int(plan_id), **fields)
plan2 = get_plan(conn, int(plan_id))
return {
"ok": True,
"plan_id": int(plan_id),
"msg": "出场条件已更新",
"exit": {
"mode": fields.get("exit_mode"),
"profit_target_usdt": plan2.get("profit_target_usdt") if plan2 else fields.get("profit_target_usdt"),
"option_target_points": plan2.get("option_target_points") if plan2 else fields.get("option_target_points"),
"perp_target_points": plan2.get("perp_target_points") if plan2 else fields.get("perp_target_points"),
},
}
@@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from lib.hedge_plan.hedge_plan_book_lib import fetch_perp_close_vwap
from lib.hedge_plan.hedge_plan_option_primary_lib import (
estimate_combo_net_pnl,
fee_rate,
@@ -129,6 +130,12 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
get_perp_metrics = cfg.get("get_live_position_exchange_metrics")
get_cs = cfg.get("get_contract_size")
fetch_idx = cfg.get("fetch_index_price")
ex_perp = None
try:
get_pex = cfg.get("exchange")
ex_perp = get_pex() if callable(get_pex) else get_pex
except Exception:
ex_perp = None
fr = fee_rate()
fr_opt = option_fee_rate()
now_ms = int(datetime.now(tz=_BJ).timestamp() * 1000)
@@ -205,6 +212,21 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
if (margin is None or margin <= 0) and contracts and perp_mark and perp_lev and perp_lev > 0:
margin = round(float(contracts) * float(cs) * float(perp_mark) / float(perp_lev), 2)
# 永续净利:按平仓方向扫盘口 VWAP(方案 A);标记价仅参考
book = fetch_perp_close_vwap(
ex_perp,
symbol=sym,
position_side=perp_dir,
contracts=float(contracts or 0),
fallback_px=perp_mark or perp_entry,
)
perp_vwap = _sf(book.get("vwap")) or perp_mark or perp_entry
perp_liq_ok = bool(book.get("liq_ok")) if book.get("ok") else False
perp_liq_msg = str(book.get("msg") or "")
perp_top = _sf(book.get("top_px"))
perp_top_sz = _sf(book.get("top_sz"))
book_side = str(book.get("book_side") or "")
idx = None
if callable(fetch_idx):
try:
@@ -212,7 +234,7 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
except Exception:
idx = None
if idx is None or idx <= 0:
idx = perp_mark or strike or 0.0
idx = perp_vwap or perp_mark or strike or 0.0
net = estimate_combo_net_pnl(
view_side=view,
@@ -224,7 +246,7 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
ct_mult=float(ct or 0.01),
perp_direction=perp_dir,
perp_entry=float(perp_entry or 0),
perp_mark=float(perp_mark or 0),
perp_mark=float(perp_vwap or 0),
contracts=float(contracts or 0),
contract_size=float(cs or 0.01),
fee=fr,
@@ -245,6 +267,10 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
hold_sec = int((now_ms - opened_ms) / 1000) if opened_ms else None
remain_sec = int((exp_ms - now_ms) / 1000) if exp_ms else None
exit_mode = str(plan.get("exit_mode") or "").strip().lower()
if exit_mode not in ("profit", "target"):
exit_mode = "profit" if _sf(plan.get("profit_target_usdt")) else "target"
out.append(
{
"plan_id": plan.get("id"),
@@ -260,6 +286,12 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
"index_px": idx,
"fee_rate": fr,
"option_fee_rate": fr_opt,
"exit": {
"mode": exit_mode,
"profit_target_usdt": _sf(plan.get("profit_target_usdt")),
"option_target_points": _sf(plan.get("option_target_points")),
"perp_target_points": _sf(plan.get("perp_target_points")),
},
"option": {
"inst_id": inst_id or "—",
"opt_type": opt_type,
@@ -286,17 +318,23 @@ def build_live_hedge_cards(cfg: dict[str, Any], conn: Any) -> list[dict[str, Any
"contract_size": cs,
"avg_open": perp_entry,
"mark_px": perp_mark,
"vwap": perp_vwap,
"book_side": book_side,
"top_px": perp_top,
"top_sz": perp_top_sz,
"liq_ok": perp_liq_ok,
"liq_msg": perp_liq_msg,
"leverage": perp_lev or None,
"margin": margin,
"upl": perp_upl,
"net_usdt": perp_net,
"live": perp_upl is not None or (perp_mark is not None and perp_mark > 0),
"live": bool(book.get("ok")) or perp_upl is not None,
},
"combo": {
"net_usdt": combo_usdt,
"opt_net_usdt": opt_net_usdt,
"perp_net_usdt": perp_net,
"note": "扣开平双边手续费后估",
"note": "扣开平双边手续费;永续按盘口均价估",
},
}
)
+22 -1
View File
@@ -732,6 +732,11 @@ def _tick_po_option_primary(
symbol = str(perp.get("symbol") or "")
mark = None
ex = cfg.get("exchange")
if callable(ex):
try:
ex = ex()
except Exception:
ex = None
if ex is not None and symbol:
try:
t = ex.fetch_ticker(symbol)
@@ -750,6 +755,11 @@ def _tick_po_option_primary(
quote_fn = cfg.get("quote_option_contract")
ex_opt = cfg.get("exchange_options")
if callable(ex_opt):
try:
ex_opt = ex_opt()
except Exception:
ex_opt = None
bid = None
bid_sz = None
if callable(quote_fn) and ex_opt is not None:
@@ -765,6 +775,17 @@ def _tick_po_option_primary(
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)
# 与持仓卡同口径:永续按平仓方向扫盘口 VWAP
from lib.hedge_plan.hedge_plan_book_lib import fetch_perp_close_vwap
book = fetch_perp_close_vwap(
ex,
symbol=symbol,
position_side=perp_dir,
contracts=contracts,
fallback_px=mark or entry,
)
perp_px = _sf(book.get("vwap")) or mark or entry
# 优先期权腿平仓(须买一流动性);盈利金额模式要求扣双边费后净利≥目标
if hit_opt:
@@ -781,7 +802,7 @@ def _tick_po_option_primary(
ct_mult=ct,
perp_direction=perp_dir,
perp_entry=float(entry or 0),
perp_mark=float(mark or 0),
perp_mark=float(perp_px or 0),
contracts=contracts,
contract_size=cs,
)
+19
View File
@@ -957,6 +957,25 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn.close()
return jsonify(out)
@app.route("/api/hedge-plan/<int:plan_id>/exit", methods=["POST"])
@lr
def api_hedge_update_exit(plan_id: int):
"""持仓中改出场:可切换盈利金额/目标位并改数字."""
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
from lib.hedge_plan.hedge_plan_exit_lib import update_plan_exit
body = request.get_json(silent=True) or {}
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
out = update_plan_exit(cfg, conn, plan_id, body)
if not out.get("ok"):
return jsonify(out), 400
conn.commit()
finally:
conn.close()
return jsonify(out)
@app.route("/api/hedge-plan/<int:plan_id>/complete-leg", methods=["POST"])
@lr
def api_hedge_complete_leg(plan_id: int):
+36 -1
View File
@@ -430,5 +430,40 @@
</div>
</div>
</div>
<div id="hp-exit-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-exit-modal" role="dialog" aria-modal="true" aria-labelledby="hp-exit-title">
<div class="hp-modal-head">
<h3 id="hp-exit-title">改出场</h3>
<button type="button" class="btn-secondary" id="hp-exit-close" aria-label="关闭">关闭</button>
</div>
<div class="hp-modal-body">
<input type="hidden" id="hp-exit-plan-id" value="" />
<div class="hp-oo-seg hp-exit-mode-seg" role="group" aria-label="出场方式">
<button type="button" class="btn-secondary hp-exit-mode-btn is-selected" data-exit="profit"><span class="hp-oo-check" aria-hidden="true">✓</span>盈利金额</button>
<button type="button" class="btn-secondary hp-exit-mode-btn" data-exit="target"><span class="hp-oo-check" aria-hidden="true">✓</span>目标位</button>
</div>
<div class="hp-exit-fields" id="hp-exit-fields-profit">
<label>目标盈利 USDT
<input type="number" id="hp-exit-profit" step="0.01" min="0.01" inputmode="decimal" />
</label>
<p class="muted">扣开平双边手续费后的组合净利达标即平(期权买一+永续盘口均价).</p>
</div>
<div class="hp-exit-fields hidden" id="hp-exit-fields-target" hidden>
<label>期权目标点数
<input type="number" id="hp-exit-opt-pts" step="1" min="1" inputmode="numeric" />
</label>
<label>永续目标点数
<input type="number" id="hp-exit-perp-pts" step="1" min="1" inputmode="numeric" />
</label>
</div>
<p class="err" id="hp-exit-err" hidden></p>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-exit-cancel">取消</button>
<button type="button" class="primary" id="hp-exit-save">保存</button>
</div>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=59"></script>
<script src="/static/hedge_plan.js?v=60"></script>