Require real ask depth for options and hedge opens.
Block mark-as-ask opens, show reference mark when no depth, cap sheets to ask size; leave close paths unchanged. Document in docs/更新文档.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -90,3 +90,9 @@
|
|||||||
|
|
||||||
- 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项.
|
- 详见 `.env.example` 中 OKX(`OKX_*`)与通用风控项.
|
||||||
- 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之,无则回退.
|
- 代码更新后请 **重启 OKX 监控进程**;旧库行不做批量回填,展示字段有则用之,无则回退.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 共享更新记录
|
||||||
|
|
||||||
|
自 2026-07-16 起,期权/对冲等共享逻辑的变更统一记在仓库根目录 **[docs/更新文档.md](../docs/更新文档.md)**(含原因、改动文件、目标、验收)。最新一条:期权/对冲开仓仅认真实卖一深度。
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 更新文档(仓库级)
|
||||||
|
|
||||||
|
自 2026-07-16 起:**凡修改或更新功能,必须在本文件追加一条记录**,写明原因、改动位置、目标与交付验收。实例目录下旧版说明可保留,但共享逻辑(`lib/`)以本文为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-16 · 期权/对冲开仓仅认真实卖一深度
|
||||||
|
|
||||||
|
### 修改原因
|
||||||
|
|
||||||
|
此前报价在无盘口卖一时会用**标记价顶进 `ask`**,界面仍显示「限价买入 @ 卖一」,造成误以为在吃卖一;深度实值合约还容易「链上有 `~` 价、点选却失败或按估算价下单」。需要与产品规则对齐:**开仓只吃真实卖一,且必须有卖一量**。
|
||||||
|
|
||||||
|
### 修改的地方(明确清单)
|
||||||
|
|
||||||
|
| 文件 | 改动摘要 |
|
||||||
|
|------|----------|
|
||||||
|
| `lib/exchange/okx_options_lib.py` | 新增 `option_buy_liquidity_ok` / `cap_option_buy_sheets_to_ask_depth`;`quote_option_contract` **不再**用 mark 填充开仓 `ask`;返回 `can_open` / `ref_ask` / `open_block_msg` / `ask_source` |
|
||||||
|
| `lib/options/options_register.py` | `/api/options/quote` 开仓 sizing 仅在 `can_open` 时计算,张数 cap 到卖一深度;`/api/options/open` 服务端再验深度并 cap 张数 |
|
||||||
|
| `lib/common/static/options_panel.js` | 面板展示参考标记价;无深度禁用开仓按钮与说明文案;开仓前校验 `can_open` |
|
||||||
|
| `lib/options/templates/options_panel.html` | 提示文案;增加「参考标记价」字段;脚本 `?v=37` |
|
||||||
|
| `lib/hedge_plan/hedge_plan_orders_lib.py` | **仅** `_buy_option`(买入开仓)同步深度门禁与张数 cap;**未改** `_sell_option` 平仓 |
|
||||||
|
| `lib/hedge_plan/templates/hedge_plan_panel.html` | 单位说明补充开仓规则 |
|
||||||
|
| `tests/test_option_buy_liquidity.py` | 新增门禁/深度 cap 单测 |
|
||||||
|
| `tests/test_hedge_plan_orders.py` | mock 补 `ask_sz`;无深度拒绝 / 深度 cap 用例 |
|
||||||
|
|
||||||
|
**铁律:未改动任何平仓规则**(期权买一平仓、`_sell_option`、close_preview / close 执行路径逻辑保持原样;报价里买一仍可用 mark 补展示,仅服务平仓读 bid)。
|
||||||
|
|
||||||
|
### 达成的目标
|
||||||
|
|
||||||
|
1. 开仓条件:`askPx` 有效 **且** `askSz > 0`。
|
||||||
|
2. 无卖一/无深度:可展示参考标记价 `ref_ask`/`mark`,明确「不可用于开仓」,按钮禁用。
|
||||||
|
3. 有深度时:限价买 @ 真实卖一;张数不超过卖一深度(向下取整)。
|
||||||
|
4. 期权面板与对冲计划买入路径规则一致。
|
||||||
|
|
||||||
|
### 交付之后的验收
|
||||||
|
|
||||||
|
1. **有卖一深度**:选合约 → 卖一显示 `价/量` → 按钮「限价买入 @ 卖一」可点 → 下单张数 ≤ 卖一量。
|
||||||
|
2. **无卖一或深度为 0**:卖一为 `—`;参考标记价显示 `~xx (不可开仓)`;红色说明含「仅供参考,不可用于开仓」;按钮为「暂无卖一深度,无法开仓」且不可点;直接调 open API 应返回失败文案。
|
||||||
|
3. **链上 `~` 估算**:仍可浏览;点选后若无真实深度,不得用估算价成交。
|
||||||
|
4. **平仓**:持仓「买一平仓」行为与改前一致(抽测一条即可)。
|
||||||
|
5. **对冲计划**:执行买入腿时无深度应失败并提示;有深度 dry_run/实盘张数不超过卖一量。
|
||||||
|
6. 单测:`python -m pytest tests/test_option_buy_liquidity.py tests/test_hedge_plan_orders.py -q` 通过。
|
||||||
|
|
||||||
|
### 未纳入本次(另单)
|
||||||
|
|
||||||
|
硬刷新链可能毁掉下单面板、限频 fallback tick、CSS `?v=` 缓存等,见会话审计清单,不在本条范围。
|
||||||
@@ -701,7 +701,7 @@
|
|||||||
const targetLevEl = document.getElementById("opt-est-leverage");
|
const targetLevEl = document.getElementById("opt-est-leverage");
|
||||||
const targetEl = document.getElementById("opt-target-idx");
|
const targetEl = document.getElementById("opt-target-idx");
|
||||||
const q = state.orderQuote;
|
const q = state.orderQuote;
|
||||||
if (!q || !q.ok) {
|
if (!q || !q.ok || !q.can_open) {
|
||||||
if (levEl) levEl.textContent = "—";
|
if (levEl) levEl.textContent = "—";
|
||||||
if (valueEl) valueEl.textContent = "—";
|
if (valueEl) valueEl.textContent = "—";
|
||||||
if (profitEl) {
|
if (profitEl) {
|
||||||
@@ -905,14 +905,31 @@
|
|||||||
function fillOrderPanel(d) {
|
function fillOrderPanel(d) {
|
||||||
state.orderQuote = d && d.ok ? d : null;
|
state.orderQuote = d && d.ok ? d : null;
|
||||||
const sz = d.sizing || {};
|
const sz = d.sizing || {};
|
||||||
|
const canOpen = !!(d && d.ok && d.can_open);
|
||||||
document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || "";
|
document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || "";
|
||||||
document.getElementById("opt-order-ask").textContent = fmtPxSz(d.ask, d.ask_sz);
|
const askEl = document.getElementById("opt-order-ask");
|
||||||
|
if (askEl) {
|
||||||
|
askEl.textContent = canOpen ? fmtPxSz(d.ask, d.ask_sz) : "—";
|
||||||
|
}
|
||||||
const bidEl = document.getElementById("opt-order-bid");
|
const bidEl = document.getElementById("opt-order-bid");
|
||||||
if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz);
|
if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz);
|
||||||
document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—";
|
const refEl = document.getElementById("opt-order-ref-ask");
|
||||||
document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—";
|
if (refEl) {
|
||||||
|
if (canOpen) {
|
||||||
|
refEl.textContent = "—";
|
||||||
|
} else if (d.ref_ask != null && !Number.isNaN(Number(d.ref_ask))) {
|
||||||
|
refEl.textContent = fmtPxSz(d.ref_ask, null, true) + " (不可开仓)";
|
||||||
|
} else if (d.mark != null && !Number.isNaN(Number(d.mark))) {
|
||||||
|
refEl.textContent = fmtPxSz(d.mark, null, true) + " (不可开仓)";
|
||||||
|
} else {
|
||||||
|
refEl.textContent = "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—";
|
||||||
|
document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—";
|
||||||
updateUnderlyingLabel();
|
updateUnderlyingLabel();
|
||||||
document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—";
|
document.getElementById("opt-order-premium").textContent =
|
||||||
|
canOpen && sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—";
|
||||||
const beEl = document.getElementById("opt-order-expiry-be");
|
const beEl = document.getElementById("opt-order-expiry-be");
|
||||||
const distEl = document.getElementById("opt-order-dist-be");
|
const distEl = document.getElementById("opt-order-dist-be");
|
||||||
if (beEl) {
|
if (beEl) {
|
||||||
@@ -922,13 +939,31 @@
|
|||||||
distEl.textContent = fmtDist(d.dist_expiry_be);
|
distEl.textContent = fmtDist(d.dist_expiry_be);
|
||||||
distEl.className = "v " + distBeClass(d.dist_expiry_be);
|
distEl.className = "v " + distBeClass(d.dist_expiry_be);
|
||||||
}
|
}
|
||||||
|
const openBtn = document.getElementById("opt-open-btn");
|
||||||
|
if (openBtn) {
|
||||||
|
openBtn.disabled = !canOpen || sz.ok === false;
|
||||||
|
openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓";
|
||||||
|
}
|
||||||
const msgEl = document.getElementById("opt-order-msg");
|
const msgEl = document.getElementById("opt-order-msg");
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
msgEl.textContent = d.msg || "报价失败";
|
msgEl.textContent = d.msg || "报价失败";
|
||||||
msgEl.classList.add("opt-error");
|
msgEl.classList.add("opt-error");
|
||||||
|
} else if (!canOpen) {
|
||||||
|
const ref = d.ref_ask != null ? d.ref_ask : d.mark;
|
||||||
|
let tip = d.msg || d.open_block_msg || "当前无卖一深度,无法按卖一限价买入";
|
||||||
|
if (ref != null && !Number.isNaN(Number(ref))) {
|
||||||
|
tip += "。参考标记价 ~" + Number(ref).toFixed(4).replace(/\.?0+$/, "") + "(仅供参考,不可用于开仓)";
|
||||||
|
} else {
|
||||||
|
tip += "。无可用参考标记价";
|
||||||
|
}
|
||||||
|
msgEl.textContent = tip;
|
||||||
|
msgEl.classList.add("opt-error");
|
||||||
} else if (sz.ok === false) {
|
} else if (sz.ok === false) {
|
||||||
msgEl.textContent = sz.msg || "";
|
msgEl.textContent = sz.msg || "";
|
||||||
msgEl.classList.add("opt-error");
|
msgEl.classList.add("opt-error");
|
||||||
|
} else if (sz.ask_depth_capped) {
|
||||||
|
msgEl.textContent = sz.msg || "已按卖一深度限制张数";
|
||||||
|
msgEl.classList.remove("opt-error");
|
||||||
} else {
|
} else {
|
||||||
msgEl.textContent = "";
|
msgEl.textContent = "";
|
||||||
msgEl.classList.remove("opt-error");
|
msgEl.classList.remove("opt-error");
|
||||||
@@ -1051,6 +1086,15 @@
|
|||||||
alert("请先选择合约");
|
alert("请先选择合约");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const q = state.orderQuote;
|
||||||
|
if (!q || !q.ok || !q.can_open) {
|
||||||
|
alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (q.sizing && q.sizing.ok === false) {
|
||||||
|
alert(q.sizing.msg || "张数无效");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = document.getElementById("opt-open-btn");
|
const btn = document.getElementById("opt-open-btn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
@@ -1091,7 +1135,9 @@
|
|||||||
alert(d.msg || "下单失败");
|
alert(d.msg || "下单失败");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
const latest = state.orderQuote;
|
||||||
|
btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false));
|
||||||
|
btn.textContent = (latest && latest.can_open) ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -779,6 +779,38 @@ def build_option_chain(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def option_buy_liquidity_ok(ask: Any, ask_sz: Any) -> tuple[bool, str]:
|
||||||
|
"""开仓仅认真实卖一价+卖一深度;不接受标记价/内在价值顶包."""
|
||||||
|
a = _safe_float(ask)
|
||||||
|
s = _safe_float(ask_sz)
|
||||||
|
if a is None or a <= 0:
|
||||||
|
return False, "暂无卖一价,无法买入"
|
||||||
|
if s is None or s <= 0:
|
||||||
|
return False, "暂无卖一深度,无法买入"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def cap_option_buy_sheets_to_ask_depth(
|
||||||
|
sheets: int,
|
||||||
|
ask_sz: Any,
|
||||||
|
*,
|
||||||
|
min_sz: int = 1,
|
||||||
|
) -> tuple[int | None, str]:
|
||||||
|
"""将买入张数限制在卖一深度内(向下取整)."""
|
||||||
|
depth = _safe_float(ask_sz)
|
||||||
|
if depth is None or depth <= 0:
|
||||||
|
return None, "暂无卖一深度,无法买入"
|
||||||
|
max_sheets = int(math.floor(depth + 1e-12))
|
||||||
|
need = max(1, int(min_sz or 1))
|
||||||
|
if max_sheets < need:
|
||||||
|
return None, f"卖一深度不足 {need} 张(当前 {depth:g})"
|
||||||
|
want = max(0, int(sheets))
|
||||||
|
capped = min(want, max_sheets)
|
||||||
|
if capped < need:
|
||||||
|
return None, f"卖一深度不足 {need} 张(当前 {depth:g})"
|
||||||
|
return capped, ""
|
||||||
|
|
||||||
|
|
||||||
def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
||||||
inst_id = (inst_id or "").strip()
|
inst_id = (inst_id or "").strip()
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
@@ -805,6 +837,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
|||||||
return {"ok": False, "msg": "行情限频,请稍后重试"}
|
return {"ok": False, "msg": "行情限频,请稍后重试"}
|
||||||
return {"ok": False, "msg": "合约不存在"}
|
return {"ok": False, "msg": "合约不存在"}
|
||||||
t = t_rows[0] if t_rows else {}
|
t = t_rows[0] if t_rows else {}
|
||||||
|
# 开仓用真实盘口卖一;绝不把标记价写入 ask
|
||||||
ask = _safe_float(t.get("askPx"))
|
ask = _safe_float(t.get("askPx"))
|
||||||
bid = _safe_float(t.get("bidPx"))
|
bid = _safe_float(t.get("bidPx"))
|
||||||
ask_sz = _safe_float(t.get("askSz"))
|
ask_sz = _safe_float(t.get("askSz"))
|
||||||
@@ -821,10 +854,15 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
|||||||
bid_sz = book_bid_sz
|
bid_sz = book_bid_sz
|
||||||
mark = _safe_float(t.get("markPx"))
|
mark = _safe_float(t.get("markPx"))
|
||||||
tick_sz = meta.get("tickSz")
|
tick_sz = meta.get("tickSz")
|
||||||
if ask is None and mark is not None:
|
# 买一缺失时仍可用标记价补展示(平仓路径读 bid);开仓 ask 不顶包
|
||||||
ask = round_option_px(mark, tick_sz, "buy")
|
|
||||||
if bid is None and mark is not None:
|
if bid is None and mark is not None:
|
||||||
bid = round_option_px(mark, tick_sz, "sell")
|
bid = round_option_px(mark, tick_sz, "sell")
|
||||||
|
ref_ask = None
|
||||||
|
if ask is None and mark is not None and mark > 0:
|
||||||
|
ref_ask = round_option_px(mark, tick_sz, "buy")
|
||||||
|
can_open, open_block_msg = option_buy_liquidity_ok(ask, ask_sz)
|
||||||
|
book_ask = ask
|
||||||
|
book_ask_sz = ask_sz
|
||||||
uly = str(meta.get("uly") or "")
|
uly = str(meta.get("uly") or "")
|
||||||
idx = fetch_index_price(ex, uly)
|
idx = fetch_index_price(ex, uly)
|
||||||
opt_type = meta.get("optType")
|
opt_type = meta.get("optType")
|
||||||
@@ -832,18 +870,24 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
|||||||
expiry_be = expiry_breakeven_from_ask(
|
expiry_be = expiry_breakeven_from_ask(
|
||||||
opt_type=str(opt_type or ""),
|
opt_type=str(opt_type or ""),
|
||||||
strike=strike,
|
strike=strike,
|
||||||
ask_px=ask,
|
ask_px=book_ask if can_open else None,
|
||||||
mark_px=mark,
|
mark_px=mark,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"inst_id": inst_id,
|
"inst_id": inst_id,
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
"ask": ask,
|
"ask": book_ask if can_open else None,
|
||||||
"bid": bid,
|
"bid": bid,
|
||||||
"ask_sz": ask_sz,
|
"ask_sz": book_ask_sz if can_open else None,
|
||||||
"bid_sz": bid_sz,
|
"bid_sz": bid_sz,
|
||||||
"mark": mark,
|
"mark": mark,
|
||||||
|
"ref_ask": ref_ask,
|
||||||
|
"book_ask": book_ask,
|
||||||
|
"book_ask_sz": book_ask_sz,
|
||||||
|
"can_open": can_open,
|
||||||
|
"ask_source": "book" if can_open else "none",
|
||||||
|
"open_block_msg": "" if can_open else open_block_msg,
|
||||||
"index_px": idx,
|
"index_px": idx,
|
||||||
"expiry_be_px": expiry_be,
|
"expiry_be_px": expiry_be,
|
||||||
"dist_expiry_be": idx_distance_to_be(idx, expiry_be),
|
"dist_expiry_be": idx_distance_to_be(idx, expiry_be),
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ def _buy_option(
|
|||||||
sheets: float,
|
sheets: float,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
from lib.exchange.okx_options_lib import (
|
||||||
|
cap_option_buy_sheets_to_ask_depth,
|
||||||
|
option_buy_liquidity_ok,
|
||||||
|
)
|
||||||
|
|
||||||
ex = cfg.get("exchange_options")
|
ex = cfg.get("exchange_options")
|
||||||
quote_fn = cfg.get("quote_option_contract")
|
quote_fn = cfg.get("quote_option_contract")
|
||||||
place_fn = cfg.get("place_option_limit_order")
|
place_fn = cfg.get("place_option_limit_order")
|
||||||
@@ -89,9 +94,22 @@ def _buy_option(
|
|||||||
if not q.get("ok"):
|
if not q.get("ok"):
|
||||||
return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q}
|
return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q}
|
||||||
ask = q.get("ask")
|
ask = q.get("ask")
|
||||||
if ask is None or float(ask) <= 0:
|
ask_sz = q.get("ask_sz")
|
||||||
return {"ok": False, "msg": "暂无卖一价,无法买入"}
|
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
|
||||||
|
if not can_open:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入",
|
||||||
|
"quote": q,
|
||||||
|
"mark": q.get("mark"),
|
||||||
|
"ref_ask": q.get("ref_ask"),
|
||||||
|
"can_open": False,
|
||||||
|
}
|
||||||
sheets_i = max(1, int(round(float(sheets))))
|
sheets_i = max(1, int(round(float(sheets))))
|
||||||
|
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1)
|
||||||
|
if capped is None:
|
||||||
|
return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q}
|
||||||
|
sheets_i = capped
|
||||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||||
premium = float(ask) * sheets_i * ct_mult
|
premium = float(ask) * sheets_i * ct_mult
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -101,6 +119,7 @@ def _buy_option(
|
|||||||
"inst_id": inst_id,
|
"inst_id": inst_id,
|
||||||
"sheets": sheets_i,
|
"sheets": sheets_i,
|
||||||
"ask": float(ask),
|
"ask": float(ask),
|
||||||
|
"ask_sz": float(ask_sz),
|
||||||
"premium": premium,
|
"premium": premium,
|
||||||
"ct_mult": ct_mult,
|
"ct_mult": ct_mult,
|
||||||
"tick_sz": q.get("tick_sz"),
|
"tick_sz": q.get("tick_sz"),
|
||||||
@@ -108,6 +127,7 @@ def _buy_option(
|
|||||||
"strike": q.get("strike"),
|
"strike": q.get("strike"),
|
||||||
"exp_time": q.get("exp_time"),
|
"exp_time": q.get("exp_time"),
|
||||||
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
|
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
|
||||||
|
"can_open": True,
|
||||||
}
|
}
|
||||||
if not callable(place_fn):
|
if not callable(place_fn):
|
||||||
return {"ok": False, "msg": "期权限价下单未注入"}
|
return {"ok": False, "msg": "期权限价下单未注入"}
|
||||||
@@ -130,6 +150,7 @@ def _buy_option(
|
|||||||
"inst_id": inst_id,
|
"inst_id": inst_id,
|
||||||
"sheets": sheets_i,
|
"sheets": sheets_i,
|
||||||
"ask": float(ask),
|
"ask": float(ask),
|
||||||
|
"ask_sz": float(ask_sz),
|
||||||
"premium": premium,
|
"premium": premium,
|
||||||
"ct_mult": ct_mult,
|
"ct_mult": ct_mult,
|
||||||
"tick_sz": q.get("tick_sz"),
|
"tick_sz": q.get("tick_sz"),
|
||||||
@@ -139,6 +160,7 @@ def _buy_option(
|
|||||||
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
|
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
|
||||||
"exchange_ord_id": (order.get("data") or {}).get("ordId"),
|
"exchange_ord_id": (order.get("data") or {}).get("ordId"),
|
||||||
"order": order,
|
"order": order,
|
||||||
|
"can_open": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" /></label>
|
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" /></label>
|
||||||
<span class="muted" id="hp-premium-line"></span>
|
<span class="muted" id="hp-premium-line"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted hp-unit-hint">单位说明:权利金结算币=<strong>USDC</strong> · 张数=期权张(整张) · 卖一/买一=价格/张</p>
|
<p class="muted hp-unit-hint">单位说明:权利金结算币=<strong>USDC</strong> · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度>0;无深度不可开仓(链上~为参考估算).</p>
|
||||||
<div id="hp-opt-bal-line" class="muted hp-quote-line hp-opt-bal-line"></div>
|
<div id="hp-opt-bal-line" class="muted hp-quote-line hp-opt-bal-line"></div>
|
||||||
<div class="form-row hp-action-row">
|
<div class="form-row hp-action-row">
|
||||||
<button type="button" class="primary" id="hp-preview-btn">计算</button>
|
<button type="button" class="primary" id="hp-preview-btn">计算</button>
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ from lib.options.options_pricing_lib import (
|
|||||||
min_sz_from_meta,
|
min_sz_from_meta,
|
||||||
premium_per_sheet,
|
premium_per_sheet,
|
||||||
)
|
)
|
||||||
from lib.exchange.okx_options_lib import _safe_float, td_mode_for_option_buy
|
from lib.exchange.okx_options_lib import (
|
||||||
|
_safe_float,
|
||||||
|
cap_option_buy_sheets_to_ask_depth,
|
||||||
|
option_buy_liquidity_ok,
|
||||||
|
td_mode_for_option_buy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(key: str, default: bool = False) -> bool:
|
def _env_bool(key: str, default: bool = False) -> bool:
|
||||||
@@ -419,8 +424,30 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
eth_amount = float(request.args.get("eth_amount"))
|
eth_amount = float(request.args.get("eth_amount"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
if ask is None or ask <= 0:
|
ask = q.get("ask")
|
||||||
return jsonify({**q, "ok": False, "msg": "暂无卖一价"})
|
ask_sz = q.get("ask_sz")
|
||||||
|
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
|
||||||
|
if not can_open:
|
||||||
|
# 合约可报价,但不可开仓:返回参考标记价供展示
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
**q,
|
||||||
|
"ok": True,
|
||||||
|
"can_open": False,
|
||||||
|
"msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入",
|
||||||
|
"quote_per_unit": None,
|
||||||
|
"premium_per_sheet": None,
|
||||||
|
"sizing": {
|
||||||
|
"ok": False,
|
||||||
|
"msg": block_msg or "暂无卖一深度,无法买入",
|
||||||
|
"sheets": 0,
|
||||||
|
"eth_amount": 0.0,
|
||||||
|
"total_premium": 0.0,
|
||||||
|
},
|
||||||
|
"available_usdc": available_usdc,
|
||||||
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
sizing = calc_order_size(
|
sizing = calc_order_size(
|
||||||
quote_per_unit=float(ask),
|
quote_per_unit=float(ask),
|
||||||
ct_mult=float(ct_mult),
|
ct_mult=float(ct_mult),
|
||||||
@@ -431,6 +458,32 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
sheets=sheet_count if mode == "sheets" else None,
|
sheets=sheet_count if mode == "sheets" else None,
|
||||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
)
|
)
|
||||||
|
if sizing.get("ok"):
|
||||||
|
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
|
||||||
|
int(sizing.get("sheets") or 0),
|
||||||
|
ask_sz,
|
||||||
|
min_sz=int(min_sz),
|
||||||
|
)
|
||||||
|
if capped is None:
|
||||||
|
sizing = {
|
||||||
|
"ok": False,
|
||||||
|
"msg": cap_msg,
|
||||||
|
"sheets": 0,
|
||||||
|
"eth_amount": 0.0,
|
||||||
|
"total_premium": 0.0,
|
||||||
|
}
|
||||||
|
elif capped < int(sizing.get("sheets") or 0):
|
||||||
|
sizing = calc_order_size(
|
||||||
|
quote_per_unit=float(ask),
|
||||||
|
ct_mult=float(ct_mult),
|
||||||
|
min_sz=int(min_sz),
|
||||||
|
sheets=capped,
|
||||||
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
|
)
|
||||||
|
if sizing.get("ok"):
|
||||||
|
sizing["ask_depth_capped"] = True
|
||||||
|
sizing["ask_sz"] = ask_sz
|
||||||
|
sizing["msg"] = f"已按卖一深度限制为 {capped} 张"
|
||||||
q = _attach_close_preview(
|
q = _attach_close_preview(
|
||||||
cfg,
|
cfg,
|
||||||
ex,
|
ex,
|
||||||
@@ -441,6 +494,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
**q,
|
**q,
|
||||||
|
"can_open": True,
|
||||||
"quote_per_unit": ask,
|
"quote_per_unit": ask,
|
||||||
"premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
|
"premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
|
||||||
"sizing": sizing,
|
"sizing": sizing,
|
||||||
@@ -474,8 +528,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
if not q.get("ok"):
|
if not q.get("ok"):
|
||||||
return jsonify(q)
|
return jsonify(q)
|
||||||
ask = q.get("ask")
|
ask = q.get("ask")
|
||||||
if ask is None or ask <= 0:
|
ask_sz = q.get("ask_sz")
|
||||||
return jsonify({"ok": False, "msg": "暂无卖一价,无法买入"})
|
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
|
||||||
|
if not can_open:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入",
|
||||||
|
"can_open": False,
|
||||||
|
"mark": q.get("mark"),
|
||||||
|
"ref_ask": q.get("ref_ask"),
|
||||||
|
}
|
||||||
|
)
|
||||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||||
min_sz = int(q.get("min_sz") or 1)
|
min_sz = int(q.get("min_sz") or 1)
|
||||||
eth_amount = None
|
eth_amount = None
|
||||||
@@ -510,6 +574,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
if not sizing.get("ok"):
|
if not sizing.get("ok"):
|
||||||
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||||
sheets = int(sizing["sheets"])
|
sheets = int(sizing["sheets"])
|
||||||
|
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz)
|
||||||
|
if capped is None:
|
||||||
|
return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"})
|
||||||
|
if capped < sheets:
|
||||||
|
sizing = calc_order_size(
|
||||||
|
quote_per_unit=float(ask),
|
||||||
|
ct_mult=ct_mult,
|
||||||
|
min_sz=min_sz,
|
||||||
|
sheets=capped,
|
||||||
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
|
)
|
||||||
|
if not sizing.get("ok"):
|
||||||
|
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||||
|
sheets = int(sizing["sheets"])
|
||||||
tick_sz = q.get("tick_sz")
|
tick_sz = q.get("tick_sz")
|
||||||
order = cfg["place_option_limit_order"](
|
order = cfg["place_option_limit_order"](
|
||||||
ex,
|
ex,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="options-dual-grid">
|
<div class="options-dual-grid">
|
||||||
<div class="card options-order-card">
|
<div class="card options-order-card">
|
||||||
<h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
|
<h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
|
||||||
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.卖一无挂单时以标记价估算并标 <strong>~</strong>.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
|
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 <strong>~</strong>(仅参考).<strong>开仓只认真实卖一价且卖一深度>0</strong>;无深度时面板显示参考标记价并禁用买入.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
|
||||||
<div class="form-row options-chain-toolbar">
|
<div class="form-row options-chain-toolbar">
|
||||||
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||||
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
<div class="options-order-grid">
|
<div class="options-order-grid">
|
||||||
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v">—</span></div>
|
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v">—</span></div>
|
||||||
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v">—</span></div>
|
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v">—</span></div>
|
||||||
|
<div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted">—</span></div>
|
||||||
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
||||||
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
||||||
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
||||||
@@ -273,4 +274,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/options_panel.js?v=36"></script>
|
<script src="/static/options_panel.js?v=37"></script>
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class TestHedgePlanOrderPath(unittest.TestCase):
|
|||||||
return_value={
|
return_value={
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"ask": 12.5,
|
"ask": 12.5,
|
||||||
|
"ask_sz": 10,
|
||||||
|
"can_open": True,
|
||||||
"ct_mult": 0.01,
|
"ct_mult": 0.01,
|
||||||
"tick_sz": "0.1",
|
"tick_sz": "0.1",
|
||||||
"strike": 1800,
|
"strike": 1800,
|
||||||
@@ -134,6 +136,8 @@ class TestHedgePlanOrderPath(unittest.TestCase):
|
|||||||
return_value={
|
return_value={
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"ask": 10,
|
"ask": 10,
|
||||||
|
"ask_sz": 5,
|
||||||
|
"can_open": True,
|
||||||
"ct_mult": 0.01,
|
"ct_mult": 0.01,
|
||||||
"tick_sz": "0.1",
|
"tick_sz": "0.1",
|
||||||
"strike": 1800,
|
"strike": 1800,
|
||||||
@@ -157,6 +161,55 @@ class TestHedgePlanOrderPath(unittest.TestCase):
|
|||||||
self.assertTrue(out["ok"])
|
self.assertTrue(out["ok"])
|
||||||
self.assertEqual(len(out["results"]), 2)
|
self.assertEqual(len(out["results"]), 2)
|
||||||
|
|
||||||
|
def test_buy_rejects_without_ask_depth(self):
|
||||||
|
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
|
||||||
|
|
||||||
|
quote = MagicMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"ask": None,
|
||||||
|
"ask_sz": None,
|
||||||
|
"mark": 11.2,
|
||||||
|
"ref_ask": 11.2,
|
||||||
|
"can_open": False,
|
||||||
|
"open_block_msg": "暂无卖一深度,无法买入",
|
||||||
|
"ct_mult": 0.01,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cfg = {
|
||||||
|
"exchange_options": object(),
|
||||||
|
"quote_option_contract": quote,
|
||||||
|
"place_option_limit_order": MagicMock(),
|
||||||
|
}
|
||||||
|
out = _buy_option(cfg, inst_id="ETH-USD_UM-260717-1900-C", sheets=1, dry_run=True)
|
||||||
|
self.assertFalse(out["ok"])
|
||||||
|
self.assertIn("卖一", out["msg"])
|
||||||
|
cfg["place_option_limit_order"].assert_not_called()
|
||||||
|
|
||||||
|
def test_buy_caps_sheets_to_ask_depth(self):
|
||||||
|
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
|
||||||
|
|
||||||
|
quote = MagicMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"ask": 10,
|
||||||
|
"ask_sz": 2,
|
||||||
|
"can_open": True,
|
||||||
|
"ct_mult": 0.01,
|
||||||
|
"tick_sz": "0.1",
|
||||||
|
"meta": {"optType": "C"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cfg = {
|
||||||
|
"exchange_options": object(),
|
||||||
|
"quote_option_contract": quote,
|
||||||
|
"place_option_limit_order": MagicMock(),
|
||||||
|
"td_mode_for_option_buy": lambda x: "isolated",
|
||||||
|
}
|
||||||
|
out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True)
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertEqual(out["sheets"], 2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""期权买入流动性门禁:真实卖一价+深度."""
|
||||||
|
from lib.exchange.okx_options_lib import (
|
||||||
|
cap_option_buy_sheets_to_ask_depth,
|
||||||
|
option_buy_liquidity_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_option_buy_liquidity_ok_requires_ask_and_depth():
|
||||||
|
assert option_buy_liquidity_ok(10, 1)[0] is True
|
||||||
|
assert option_buy_liquidity_ok(10, 0)[0] is False
|
||||||
|
assert option_buy_liquidity_ok(10, None)[0] is False
|
||||||
|
assert option_buy_liquidity_ok(None, 5)[0] is False
|
||||||
|
assert option_buy_liquidity_ok(0, 5)[0] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_option_buy_sheets_to_ask_depth():
|
||||||
|
capped, msg = cap_option_buy_sheets_to_ask_depth(9, 2.8, min_sz=1)
|
||||||
|
assert capped == 2
|
||||||
|
assert msg == ""
|
||||||
|
capped, msg = cap_option_buy_sheets_to_ask_depth(1, 0.4, min_sz=1)
|
||||||
|
assert capped is None
|
||||||
|
assert "深度不足" in msg
|
||||||
Reference in New Issue
Block a user