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:
@@ -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
|
||||
@@ -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": "扣开平双边手续费;永续按盘口均价估",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user