永期手动下单:修币本位杠杆显示,张数定仓与双出场模式.

右侧以张数+比例算永续,杠杆取 env(BTC10/ETH5);开仓买币+5%滑点;出场可选盈利金额(扣双边费)或目标位;平仓后卖光币回 U.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 15:43:44 +08:00
parent e474c89c35
commit d68e9c3946
8 changed files with 392 additions and 63 deletions
+3
View File
@@ -87,6 +87,9 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
# 出场:profit=盈利金额(扣双边费);target=目标位点数
_ensure_column(conn, "hedge_plans", "exit_mode", "TEXT")
_ensure_column(conn, "hedge_plans", "profit_target_usdt", "REAL")
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
+83 -6
View File
@@ -172,6 +172,52 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
notify_plan_end(cfg, conn, plan)
def _sell_all_coin_to_usdt(cfg: dict[str, Any], underlying: str) -> dict[str, Any]:
"""平仓后把交易账户标的币全部卖回 USDT."""
try:
from lib.options.options_spot_bridge_lib import spot_market_sell_coin_to_usdt
ex = cfg.get("exchange_options") or cfg.get("exchange")
if ex is None:
return {"ok": False, "msg": "无交易所实例,跳过卖币"}
return spot_market_sell_coin_to_usdt(ex, underlying=str(underlying or "ETH").upper())
except Exception as e:
return {"ok": False, "msg": str(e)}
def _plan_exit_mode(plan: dict[str, Any]) -> str:
raw = str(plan.get("exit_mode") or "").strip().lower()
if raw in ("profit", "target"):
return raw
# 兼容旧单:preview_json
try:
import json
meta = json.loads(plan.get("preview_json") or "{}")
m = str(meta.get("exit_mode") or "").strip().lower()
if m in ("profit", "target"):
return m
except Exception:
pass
return "target"
def _plan_profit_target(plan: dict[str, Any]) -> float:
v = _sf(plan.get("profit_target_usdt"))
if v is not None and v > 0:
return float(v)
try:
import json
meta = json.loads(plan.get("preview_json") or "{}")
v2 = _sf(meta.get("profit_target_usdt"))
if v2 is not None and v2 > 0:
return float(v2)
except Exception:
pass
return 0.0
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平).
@@ -639,7 +685,7 @@ def _tick_po_option_primary_expiry(
def _tick_po_option_primary(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""以期权为主:触达目标位立即执行分叉平仓规则."""
"""以期权为主:盈利金额出场或目标位出场;先平期权(须买一流动性)再平永续,再卖币回 U."""
from lib.hedge_plan.hedge_plan_option_primary_lib import (
estimate_combo_net_pnl,
option_bid_liquidity_ok,
@@ -662,6 +708,8 @@ def _tick_po_option_primary(
strike = _sf(opt.get("strike"))
n = _sf(plan.get("option_target_points"))
m = _sf(plan.get("perp_target_points"))
exit_mode = _plan_exit_mode(plan)
profit_target = _plan_profit_target(plan)
if strike is None or strike <= 0:
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
@@ -670,7 +718,13 @@ def _tick_po_option_primary(
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:
# 盈利金额出场:不依赖点数,后面按净利判断
if exit_mode == "profit":
if profit_target <= 0:
return None
hit_opt = True
hit_perp = False
elif not hit_opt and not hit_perp:
return None
symbol = str(perp.get("symbol") or "")
@@ -710,7 +764,7 @@ def _tick_po_option_primary(
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
@@ -729,9 +783,14 @@ def _tick_po_option_primary(
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 exit_mode == "profit":
can_opt_exit = bool(
liq_ok and net is not None and float(net.get("net") or 0) + 1e-9 >= profit_target
)
else:
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"
reason = "profit_target_usdt" if exit_mode == "profit" else "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"])
@@ -783,8 +842,26 @@ def _tick_po_option_primary(
stats_bucket="opt_primary",
closed_at=_now(),
)
sell_r = _sell_all_coin_to_usdt(cfg, str(plan.get("underlying") or "ETH"))
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": reason, "total": total, "net": net}
return {
"plan_id": plan["id"],
"close_reason": reason,
"total": total,
"net": net,
"sell_coin": sell_r,
}
if exit_mode == "profit":
return {
"plan_id": plan["id"],
"skip": True,
"msg": (
liq_msg
if not liq_ok
else f"净利未达目标 {profit_target:g}(当前 {float((net or {}).get('net') or 0):.2f})"
),
"net": net,
}
if not hit_perp:
return {
"plan_id": plan["id"],
@@ -350,18 +350,19 @@ def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
market = body.get("market_order") in (True, 1, "1", "true", "yes", "on")
exit_mode = str(body.get("exit_mode") or ("target" if not market else "profit")).strip().lower()
if exit_mode not in ("profit", "target"):
exit_mode = "profit" if market else "target"
need = (
"direction",
"contracts",
"opt_inst_id",
"sheets",
"exchange_symbol",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
)
if not market:
need = need + ("premium_budget",)
need = need + ("premium_budget", "option_target_points", "perp_target_points")
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
@@ -371,8 +372,13 @@ def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
prem = _sf(body.get("premium_budget"))
if not market and (prem is None or prem <= 0):
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if exit_mode == "profit":
pt = _sf(body.get("profit_target_usdt"))
if pt is None or pt <= 0:
return "盈利金额出场须填写目标盈利(USDT)"
else:
if float(body.get("option_target_points") or 0) <= 0 or float(body.get("perp_target_points") or 0) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
except (TypeError, ValueError):
+49 -16
View File
@@ -204,7 +204,10 @@ def _buy_option(
)
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "quote": q}
buf = normalize_coin_spot_buy_buffer(None)
# 权利金币×缓冲买币;默认 +5%滑点(1.05),可经 cfg.spot_buy_buffer / env 覆盖
buf = normalize_coin_spot_buy_buffer(
cfg.get("spot_buy_buffer") if cfg.get("spot_buy_buffer") is not None else 1.05
)
need_coin = float(premium) * float(buf)
avail = float(fetch_trading_coin_available(ex, underlying) or 0)
if avail + 1e-12 < need_coin:
@@ -774,28 +777,46 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An
cs = float(get_cs(sym) or cs)
except Exception:
pass
sized = size_from_premium(
premium_budget=float(body.get("premium_budget") or 0),
ask=float(body["ask"]),
ct_mult=float(body.get("ct_mult") or 0.01),
ratio=float(body.get("option_perp_ratio") or 2),
contract_size=cs,
)
if not sized.get("ok"):
return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized}
body["sheets"] = sized["sheets"]
body["contracts"] = sized["contracts"]
body["eth_qty"] = sized["eth_qty"]
market = body.get("market_order") in (True, 1, "1", "true", "yes", "on")
ct = float(body.get("ct_mult") or q.get("ct_mult") or 0.01)
ratio = float(body.get("option_perp_ratio") or 2)
body["contract_size"] = cs
if market and float(body.get("sheets") or 0) > 0:
# 手动下单:以张数为准,永续按比例
sheets_i = float(int(float(body["sheets"])))
eth = round(sheets_i * ct, 2)
body["sheets"] = sheets_i
body["eth_qty"] = eth
body["contracts"] = (eth / ratio) / cs if ratio > 0 and cs > 0 else 0
sized = {
"ok": True,
"sheets": sheets_i,
"eth_qty": eth,
"contracts": body["contracts"],
"ratio": ratio,
}
else:
sized = size_from_premium(
premium_budget=float(body.get("premium_budget") or 0),
ask=float(body["ask"]),
ct_mult=ct,
ratio=ratio,
contract_size=cs,
)
if not sized.get("ok"):
return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized}
body["sheets"] = sized["sheets"]
body["contracts"] = sized["contracts"]
body["eth_qty"] = sized["eth_qty"]
# 深度不足则缩量
ask_sz = float(q.get("ask_sz") or 0)
if ask_sz > 0 and float(body["sheets"]) > ask_sz:
body["sheets"] = float(int(ask_sz))
if body["sheets"] <= 0:
return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized}
eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2)
eth = round(float(body["sheets"]) * ct, 2)
body["eth_qty"] = eth
body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs
body["contracts"] = (eth / ratio) / cs if ratio > 0 and cs > 0 else 0
return {
"ok": True,
"ask": float(q["ask"]),
@@ -806,8 +827,13 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An
"sizing": sized,
"quote": q,
"msg": (
f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / "
f"手动定仓: {body.get('eth_qty')}币 / "
f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}"
if market
else (
f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / "
f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}"
)
),
}
return {
@@ -827,6 +853,13 @@ def execute_perp_options_start(
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
# 买币滑点缓冲:手动单默认 +5%
if body.get("spot_buy_buffer") is not None:
cfg = dict(cfg)
cfg["spot_buy_buffer"] = body.get("spot_buy_buffer")
elif body.get("market_order") in (True, 1, "1", "true", "yes", "on"):
cfg = dict(cfg)
cfg["spot_buy_buffer"] = 1.05
refresh = refresh_po_option_quote_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh}
+5
View File
@@ -340,6 +340,9 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"perp_direction": perp_dir,
}
if opt_primary:
exit_mode = str(body.get("exit_mode") or "target").strip().lower()
if exit_mode not in ("profit", "target"):
exit_mode = "target"
plan_row.update(
{
"option_target_points": float(body.get("option_target_points") or 0),
@@ -349,6 +352,8 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"strike_interval": float(body.get("strike_interval") or 15),
"min_option_hours": float(body.get("min_option_hours") or 36),
"option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""),
"exit_mode": exit_mode,
"profit_target_usdt": float(body.get("profit_target_usdt") or 0),
}
)
plan_id = insert_plan(conn, plan_row)
+27 -12
View File
@@ -87,7 +87,7 @@
<th>类型</th>
<th>合约</th>
<th>卖一/张</th>
<th title="指数÷卖一">杠杆</th>
<th title="币本位:1÷卖一">杠杆</th>
<th>买一/张</th>
<th>操作</th>
</tr>
@@ -117,9 +117,10 @@
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>手动下单</strong>:左侧人工选约(同期权页:到期/Call·Put/实虚值),点「选择」后右侧下单;开仓按<strong>卖一限价</strong>(非交易所市价单);非条件盯盘。</p>
<p><strong>组合</strong>:买 Call → 永续空;买 Put → 永续多。</p>
<p><strong>比例</strong>:期权:永续默认 <strong>2</strong>(期权名义 ÷ 比例 = 永续名义)。例:期权 1 BTC → 永续 0.5 BTC。</p>
<p><strong>手动下单</strong>:左侧人工选约,右侧填张数;开仓按<strong>卖一限价</strong>。先开期权(按张数买币并+5%滑点),再开永续。</p>
<p><strong>组合</strong>:买 Call → 永续空;买 Put → 永续多。永续杠杆取 env:BTC=<code>BTC_LEVERAGE</code>(默认10)/ETH=<code>ALT_LEVERAGE</code>(默认5)。</p>
<p><strong>出场</strong>:盈利金额(扣开平双边手续费后净利)或目标位点数二选一。平仓先期权(须买一流动性)再永续,再把币全部卖回 USDT。一账户同时仅一笔对冲仓。</p>
<p><strong>比例</strong>:默认 <strong>2</strong>(期权名义 ÷ 比例 = 永续名义)。</p>
</div>
</details>
<div class="hp-po-inner-card hp-po-perp-quote-card">
@@ -150,25 +151,39 @@
</div>
<div id="hp-po-fields-option-primary">
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆</h3>
<h3 id="hp-po-sec-capital" class="hp-po-section-title">开仓</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
<label class="hp-po-field">
<span class="hp-po-field-lab">权利金预算 <em>币</em></span>
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续杠杆 <em>开仓</em></span>
<input type="number" step="1" id="hp-perp-leverage" value="{{ hedge_plan_eth_leverage | default(5) }}" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" title="默认取 env 永期·永续杠杆" />
<span class="hp-po-field-lab">张数 <em>期权张</em></span>
<input type="number" step="1" min="1" id="hp-order-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" title="与左侧张数同步" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
<input type="number" step="any" min="0.1" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" title="默认2:期权名义÷比例=永续名义" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续杠杆 <em>env</em></span>
<input type="number" step="1" id="hp-perp-leverage" value="{{ hedge_plan_eth_leverage | default(5) }}" readonly autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" title="BTC=BTC_LEVERAGE(默认10) ETH=ALT_LEVERAGE(默认5),在 env「永期·永续杠杆」改" />
</label>
</div>
<p class="muted hp-po-max-line" id="hp-po-max-line">最大开仓 —</p>
<p class="muted hp-po-perp-auto" id="hp-po-perp-auto">永续开仓 — (按比例自动)</p>
{# 兼容旧 JS:权利金隐藏,市价/手动用张数定仓 #}
<input type="hidden" id="hp-premium-budget" value="0" />
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
<div class="hp-po-fields hp-po-fields--section">
<div class="hp-oo-seg hp-po-exit-seg" role="group" aria-label="出场方式">
<button type="button" class="btn-secondary hp-po-exit-mode is-selected" data-exit="profit" title="组合净利(扣开平双边手续费)达目标金额即平"><span class="hp-oo-check" aria-hidden="true">✓</span>盈利金额出场</button>
<button type="button" class="btn-secondary hp-po-exit-mode" data-exit="target" title="指数相对行权价达目标点数出场"><span class="hp-oo-check" aria-hidden="true">✓</span>目标位出场</button>
</div>
<div class="hp-po-fields hp-po-fields--section" id="hp-po-exit-profit">
<label class="hp-po-field">
<span class="hp-po-field-lab">目标盈利 <em>USDT</em></span>
<input type="number" step="any" min="0.01" id="hp-profit-target" placeholder="扣双边手续费后净利" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-po-fields hp-po-fields--section hidden" id="hp-po-exit-target" hidden>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />