9 Commits

Author SHA1 Message Date
dekun bab42b1b53 fix(hub): restore pre-WS quote path and relieve Gate/account contention
Two audit rounds after WS rollback: lighten hub options snapshot, cache hub balances without extra fetch_balance, soft-poll single-flight, and document fixes in R1/R2 reports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:30:27 +08:00
dekun d592632834 Revert "feat(options): push chain asks/bids via OKX WS + SSE"
This reverts commit 14a7adae1f.
2026-08-11 12:22:41 +08:00
dekun a488e2fabd Revert "fix(options): speed up chain refresh with fast path and non-blocking UI"
This reverts commit 6fad68f7b1.
2026-08-11 12:22:41 +08:00
dekun 6fad68f7b1 fix(options): speed up chain refresh with fast path and non-blocking UI
Skip full REST tickers when WS is warm, seed subscriptions off-request, and keep the old chain visible while refreshing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:06:16 +08:00
dekun 14a7adae1f feat(options): push chain asks/bids via OKX WS + SSE
Replace soft REST polling with OKX public tickers WS ingest and browser SSE patches so list quotes stay live while watching an expiry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:49:49 +08:00
dekun 24bb8532c4 fix(options): soft-poll chain quotes so list asks stay fresh
SSE only refreshed positions; chain asks were one-shot until manual reload, which could mislead open decisions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:21:30 +08:00
dekun c514a75026 fix(options): cache instruments and backoff on OKX 50011
期权链拉取遇限频时退避重试并回退短缓存,前端提示更友好。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:11:30 +08:00
dekun 5c3969674a feat(options): use premium profit RR instead of target index
单独期权与中控改为盈亏比×权利金触发买一平仓,默认2;不达标等到期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:34:14 +08:00
dekun 3b56e15fb1 feat(hedge): replace OO breakout targets with premium profit RR
期期改用盈亏比×权利金止盈(默认2);不达标持有至到期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:21:47 +08:00
34 changed files with 1348 additions and 399 deletions
+3 -3
View File
@@ -10042,14 +10042,14 @@ def _hub_meta_bundle():
def _hub_account_bundle(): def _hub_account_bundle():
funding_capital, trading_capital = get_exchange_capitals(force=True) # 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance
funding_capital, trading_capital = get_exchange_capitals(force=False)
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
available = get_available_trading_usdt()
return { return {
"funding_usdt": funding_usdt, "funding_usdt": funding_usdt,
"trading_usdt": trading_usdt, "trading_usdt": trading_usdt,
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, "available_trading_usdt": trading_usdt,
"trading_day": get_trading_day(app_now()), "trading_day": get_trading_day(app_now()),
} }
+18 -5
View File
@@ -467,6 +467,8 @@ from lib.exchange.gate_ccxt_lib import gate_ccxt_class
# Gate.io USDT 永续(swap) # Gate.io USDT 永续(swap)
exchange = gate_ccxt_class()({ exchange = gate_ccxt_class()({
"enableRateLimit": True, "enableRateLimit": True,
# 避免关键位监控/账户拉取无限挂起拖垮中控
"timeout": int(os.getenv("GATE_CCXT_TIMEOUT_MS", "8000")),
"options": { "options": {
"defaultType": "swap", "defaultType": "swap",
"defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE,
@@ -4611,14 +4613,25 @@ def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason):
conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
_RS_BAR_CACHE: dict[str, dict] = {}
_RS_BAR_CACHE_TTL_SEC = float(os.getenv("GATE_RS_BAR_CACHE_SEC", "45"))
def _fetch_last_closed_bar(symbol): def _fetch_last_closed_bar(symbol):
"""最近一根闭合 K:[ts, o, h, l, c, v] 或 None.""" """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.短缓存减轻关键位监控打爆 ccxt."""
ex_sym = normalize_exchange_symbol(symbol) ex_sym = normalize_exchange_symbol(symbol)
now = time.time()
cached = _RS_BAR_CACHE.get(ex_sym)
if cached and now - float(cached.get("updated_at") or 0) < _RS_BAR_CACHE_TTL_SEC:
return cached.get("bar")
bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or []
if len(bars) < 2: if len(bars) < 2:
_RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": None}
return None return None
closed = bars[:-1] closed = bars[:-1]
return closed[-1] if closed else None bar = closed[-1] if closed else None
_RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": bar}
return bar
def _key_rs_gate_preview(symbol, upper, lower): def _key_rs_gate_preview(symbol, upper, lower):
@@ -9893,14 +9906,14 @@ def _hub_meta_bundle():
def _hub_account_bundle(): def _hub_account_bundle():
funding_capital, trading_capital = get_exchange_capitals(force=True) # 中控看板高频拉取:仅走余额缓存;不再额外 fetch_balance(会与关键位监控争用 ccxt)
funding_capital, trading_capital = get_exchange_capitals(force=False)
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
trading_usdt = round(trading_capital, 2) if trading_capital is not None else None trading_usdt = round(trading_capital, 2) if trading_capital is not None else None
available = get_available_trading_usdt()
return { return {
"funding_usdt": funding_usdt, "funding_usdt": funding_usdt,
"trading_usdt": trading_usdt, "trading_usdt": trading_usdt,
"available_trading_usdt": round(available, 2) if available is not None else None, "available_trading_usdt": trading_usdt,
"trading_day": get_trading_day(app_now()), "trading_day": get_trading_day(app_now()),
} }
+3 -3
View File
@@ -9665,14 +9665,14 @@ def _hub_meta_bundle():
def _hub_account_bundle(): def _hub_account_bundle():
funding_capital, trading_capital = get_exchange_capitals(force=True) # 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance
funding_capital, trading_capital = get_exchange_capitals(force=False)
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
available = get_available_trading_usdt()
return { return {
"funding_usdt": funding_usdt, "funding_usdt": funding_usdt,
"trading_usdt": trading_usdt, "trading_usdt": trading_usdt,
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, "available_trading_usdt": trading_usdt,
"trading_day": get_trading_day(app_now()), "trading_day": get_trading_day(app_now()),
} }
@@ -0,0 +1,53 @@
# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 1 轮)
## 背景
期权链接入 OKX WS 推送后,生产中控出现「期权数据不可用 / 子代理不可用」。按要求**先回滚 WS 链路**,再全量审计并修复。
## 回滚
| 提交 | 说明 |
|------|------|
| `a488e2f` | Revert fast-path(依赖 WS 热缓存) |
| `d592632` | Revert OKX WS + SSE 推送整栈 |
恢复为 **REST 拉链 + 前端约 15s soft-poll**(commit `24bb853` 行为),删除:
- `lib/exchange/okx_public_ws_lib.py`
- `lib/options/options_quote_live_lib.py`
- `tests/test_options_quote_live_lib.py`
## 根因结论(非仅 WS)
| 级别 | 问题 | 证据 |
|------|------|------|
| Critical | Gate 关键位 RS 监控在后台线程高频 `fetch_ohlcv`,与 `/api/hub/account` 争用同一 ccxt 客户端,账户/子代理超时 | 日志 `[key_rs_level_alert] BTC/USDT id=13`;本机 `5000/api/hub/account` 25s 超时 |
| Critical | 中控期权快照对每仓拉 books 深度,易超 `HUB_FLASK_TIMEOUT=10` | `build_display_option_positions``attach_close_preview``fetch_option_book_depth` |
| High | Soft 拉链 3 次重试 + 无单飞,易与 SSE tick 叠打 OKX | `options_panel.js` loadChain |
| High | 中控账户接口每轮 `force=True` 绕过余额缓存 | Gate/OKX/Binance `_hub_account_bundle` |
| Medium | Flask 超时错误只有 `error``msg`,前端易落默认文案 | `hub.py` `_fetch_flask_json` |
| Medium | `options` 为 null 时前端当成「0 仓」而非不可用 | `app.js` renderOptionsSectionBody |
WS 部署触发的**全进程重启**放大了 Gate 争用与快照超时,表现为「全不可用」;OKX 快照在轻负载下仍可 `ok:true`
## 本轮修复
1. **Hub 期权快照**关闭逐仓 `close_preview`/books(`with_close_preview=False`)
2. **Hub 账户**三所改为 `get_exchange_capitals(force=False)`
3. **Gate ccxt** 增加 `timeout=8000ms`;RS K 线 **45s 缓存**
4. **期权 tickers** 恢复 **10s** 短缓存(无 WS)
5. **前端 soft 拉链**:单飞 + soft 仅 1 次尝试;已有链不先清空表格
6. **Hub**:超时补 `msg`;期权快照与 account/monitor **并行 gather**
7. **中控 UI**:capabilities 含 options 且 snapshot 缺失时显式「期权数据不可用」
## 测试建议
- 强刷中控监控区:OKX 期权资金/持仓应恢复;Gate 子代理 status 应在数秒内恢复
- 期权页「刷新链」不应长时间白屏;指数行显示约 15s 静默刷新
- Gate 关键位监控日志不应再每秒刷屏 `fetch_ohlcv` 失败
## 残留风险(交第 2 轮)
- Gate 仍与监控共用单一 ccxt 客户端(未加全局锁)
- 中控 board 仍可能被最慢交易所拉长整轮等待
- Soft-poll 仍是 REST,非真·实时
@@ -0,0 +1,41 @@
# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 2 轮)
## 范围
复查第 1 轮修复是否引入回归,并扫清仍会导致「中控不可用」的残留高优先级问题。
## 复查结论
| 项 | 结论 |
|----|------|
| Hub 并行 options 索引进位 | 正确(day / options 组合无错位) |
| Hub 关闭 close_preview | UI 降级为 upl/`—`,不崩 |
| Gate RS 缓存 / timeout | timeout 已为 int;缓存可接受 |
| board row capabilities | `_fetch_agent_status` 始终带上 |
| `with_close_preview` 默认 | 实例路径仍为 True |
## 本轮新发现问题与修复
| 级别 | 问题 | 修复 |
|------|------|------|
| High | Hub 账户在 `force=False` 后仍调用 `get_available_trading_usdt()` 再打一枪 `fetch_balance`,Gate 争用依旧 | 三所 `_hub_account_bundle` 改为用缓存的 `trading_usdt` 作为 `available_trading_usdt` |
| Medium | `loadChain` soft 门禁在 `seq++` 之后,叠刷可导致 `chainLoadInFlight` 永不清理 | soft 门禁移到 `seq++` 之前 |
## 与第 1 轮一并交付的状态
- WS 推送链路已回滚(REST + 15s soft-poll)
- 中控期权快照轻量化 + 并行拉取
- Gate RS K 线短缓存 + ccxt timeout
- 期权 tickers 10s 缓存;soft 单飞/单次尝试
## 已知残留(不阻塞本次部署)
- Gate 监控与账户仍共用单一 ccxt 客户端(无全局锁)
- 中控 board 仍可能被最慢交易所拉长整轮
- Soft-poll 非真·实时报价
## 部署后验收
1. 中控强刷:OKX 期权区有资金数字,不再长期「期权数据不可用」
2. Gate 卡:子代理恢复绿色/有资金;不再长时间「子代理不可用」
3. 期权页刷新链不白屏;约 15s 静默更新时间戳
+39 -28
View File
@@ -1165,14 +1165,9 @@
fillExpSelect($("hp-oo-exp-select"), d); fillExpSelect($("hp-oo-exp-select"), d);
renderListStrikes(); renderListStrikes();
renderTStrikes(); renderTStrikes();
if (d.index_px) { // 期期盈亏比默认 2,不再用指数自动填上破/下破
const idx = Number(d.index_px); if ($("hp-oo-rr") && !$("hp-oo-rr").value) {
if ($("hp-target-up") && !$("hp-target-up").value) { $("hp-oo-rr").value = "2";
$("hp-target-up").value = String(Math.round(idx * 1.03));
}
if ($("hp-target-down") && !$("hp-target-down").value) {
$("hp-target-down").value = String(Math.round(idx * 0.97));
}
} }
} }
@@ -1581,8 +1576,7 @@
if ($("hp-contracts")) $("hp-contracts").value = ""; if ($("hp-contracts")) $("hp-contracts").value = "";
if ($("hp-tp")) $("hp-tp").value = ""; if ($("hp-tp")) $("hp-tp").value = "";
if ($("hp-sl")) $("hp-sl").value = ""; if ($("hp-sl")) $("hp-sl").value = "";
if ($("hp-target-up")) $("hp-target-up").value = ""; if ($("hp-oo-rr")) $("hp-oo-rr").value = "2";
if ($("hp-target-down")) $("hp-target-down").value = "";
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
if ($("hp-oo-sheets-a")) { if ($("hp-oo-sheets-a")) {
@@ -1618,16 +1612,12 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值"); throw new Error("期期两腿须为平值或虚值,不可选实值");
} }
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); const rr = numInput("hp-oo-rr", 2);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
body = { body = {
plan_type: "options_options", plan_type: "options_options",
target_price_up: up, oo_profit_rr: rr,
target_price_down: down, index_px: indexPx() || 0,
target_price: up,
index_px: indexPx() || (up + down) / 2,
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
}; };
@@ -1718,6 +1708,21 @@
" · 保费 " + " · 保费 " +
fmt(s.premium_paid) + fmt(s.premium_paid) +
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
} else {
const rr = s.oo_profit_rr != null ? s.oo_profit_rr : s.rr_target;
const tgt = s.target_profit != null ? s.target_profit : s.at_target_total;
if (rr != null) {
summary.innerHTML =
"盈亏比 ×" +
fmt(rr, 2) +
" · 目标盈利 " +
fmtPnlHtml(tgt) +
" · 到期现价 " +
fmtPnlHtml(s.expiry_flat_total) +
" · 保费 " +
fmt(s.premium_paid) +
'<span class="muted">(达标全平;不达标等到期)</span>' +
(s.expiry_is_loss ? " · 到期现价情景为亏" : "");
} else { } else {
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total; const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
const dnTot = s.at_target_down_total; const dnTot = s.at_target_down_total;
@@ -1743,6 +1748,7 @@
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
} }
} }
}
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ""; tbody.innerHTML = "";
(d.scenarios || []).forEach(function (sc) { (d.scenarios || []).forEach(function (sc) {
@@ -2057,8 +2063,7 @@
"hp-tp", "hp-tp",
"hp-sl", "hp-sl",
"hp-sheets", "hp-sheets",
"hp-target-up", "hp-oo-rr",
"hp-target-down",
]); ]);
if ($("hp-preview-btn")) if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () { $("hp-preview-btn").addEventListener("click", function () {
@@ -2171,6 +2176,9 @@
if (p.plan_type === "perp_options") { if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
} }
if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
return "盈亏比 ×" + fmt(p.oo_profit_rr, 2) + "(达标全平)";
}
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
} }
@@ -2330,6 +2338,8 @@
target_win_leg: "期期平盈利腿", target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
oo_rr_target: "期期盈亏比达标",
oo_rr_closing: "期期盈亏比平仓中",
oo_rest_closing: "期期全平·清残腿中", oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平", oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
@@ -2404,6 +2414,11 @@
"x · 张数 " + "x · 张数 " +
fmt(p.perp_size, 4) + fmt(p.perp_size, 4) +
"</div>"; "</div>";
} else if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
html +=
"<div><span class=\"muted\">盈亏比</span> ×" +
fmt(p.oo_profit_rr, 2) +
"(浮盈达标全平;不达标等到期)</div>";
} else { } else {
html += html +=
"<div><span class=\"muted\">目标价</span> 上破 " + "<div><span class=\"muted\">目标价</span> 上破 " +
@@ -2626,17 +2641,13 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值"); throw new Error("期期两腿须为平值或虚值,不可选实值");
} }
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); const rr = numInput("hp-oo-rr", 2);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
body = { body = {
plan_type: "options_options", plan_type: "options_options",
underlying: state.underlying, underlying: state.underlying,
target_price_up: up, oo_profit_rr: rr,
target_price_down: down, index_px: indexPx() || 0,
target_price: up,
index_px: indexPx() || (up + down) / 2,
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
oo_sheets_mode: state.ooSheetsMode || "same_sheets", oo_sheets_mode: state.ooSheetsMode || "same_sheets",
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
+189 -100
View File
@@ -37,9 +37,15 @@
let selectSeq = 0; let selectSeq = 0;
let refreshAllTimer = null; let refreshAllTimer = null;
let pendingRefreshTimer = null; let pendingRefreshTimer = null;
let chainSoftTimer = null;
let lastChainSoftAt = 0;
let chainQuotedAt = 0;
let chainLoadInFlight = false;
let pendingTtlSeconds = 600; let pendingTtlSeconds = 600;
const POSITIONS_STALE_MS = 45000; const POSITIONS_STALE_MS = 45000;
const PENDING_POLL_MS = 8000; const PENDING_POLL_MS = 8000;
/** 链卖一/买一静默刷新节流:无推送,靠拉;过密会撞 OKX 50011 */
const CHAIN_SOFT_POLL_MS = 15000;
const orderPanelHome = (function () { const orderPanelHome = (function () {
const host = document.getElementById("opt-order-panel-host"); const host = document.getElementById("opt-order-panel-host");
return host ? host.parentElement : null; return host ? host.parentElement : null;
@@ -321,7 +327,7 @@
[ [
"opt-sheets-amount", "opt-sheets-amount",
"opt-eth-amount", "opt-eth-amount",
"opt-target-idx", "opt-profit-rr",
].forEach(function (id) { ].forEach(function (id) {
harden(document.getElementById(id)); harden(document.getElementById(id));
}); });
@@ -632,6 +638,15 @@
if (el) el.textContent = fmt(buf, 2); if (el) el.textContent = fmt(buf, 2);
} }
function fmtChainQuotedAt() {
if (!chainQuotedAt) return "";
const d = new Date(chainQuotedAt);
const pad = function (n) {
return n < 10 ? "0" + n : String(n);
};
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
}
function renderIndexLine() { function renderIndexLine() {
const idx = state.chain && state.chain.index_px; const idx = state.chain && state.chain.index_px;
const dte = state.chain && state.chain.chain_max_dte_days; const dte = state.chain && state.chain.chain_max_dte_days;
@@ -645,12 +660,37 @@
const line = document.getElementById("opt-index-line"); const line = document.getElementById("opt-index-line");
if (line) { if (line) {
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)"; const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
const ageHint = chainQuotedAt ? " · 链报价 " + fmtChainQuotedAt() + "(约每15s静默刷新)" : "";
line.textContent = line.textContent =
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) + "指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外"; " · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + ageHint;
} }
} }
function softRefreshChainThrottled(force) {
if (document.hidden) return;
if (!document.getElementById("options-root")) return;
if (chainLoadInFlight) return;
const now = Date.now();
if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return;
lastChainSoftAt = now;
void loadChain({ soft: true });
}
function startChainSoftPoll() {
if (chainSoftTimer) return;
chainSoftTimer = setInterval(function () {
if (!document.getElementById("options-root")) {
if (chainSoftTimer) {
clearInterval(chainSoftTimer);
chainSoftTimer = null;
}
return;
}
softRefreshChainThrottled(false);
}, CHAIN_SOFT_POLL_MS);
}
function pickNearestExpiry(exps) { function pickNearestExpiry(exps) {
if (!exps || !exps.length) return ""; if (!exps || !exps.length) return "";
const now = Date.now(); const now = Date.now();
@@ -894,11 +934,10 @@
} }
function updateOrderEstimates() { function updateOrderEstimates() {
const levEl = document.getElementById("opt-order-leverage");
const valueEl = document.getElementById("opt-est-value"); const valueEl = document.getElementById("opt-est-value");
const profitEl = document.getElementById("opt-est-profit"); const profitEl = document.getElementById("opt-est-profit");
const targetLevEl = document.getElementById("opt-est-leverage"); const levEl = document.getElementById("opt-order-leverage");
const targetEl = document.getElementById("opt-target-idx"); const rrEl = document.getElementById("opt-profit-rr");
const q = state.orderQuote; const q = state.orderQuote;
if (!q || !q.ok || !q.can_open) { if (!q || !q.ok || !q.can_open) {
if (levEl) levEl.textContent = "—"; if (levEl) levEl.textContent = "—";
@@ -907,7 +946,6 @@
profitEl.textContent = "—"; profitEl.textContent = "—";
profitEl.className = "v"; profitEl.className = "v";
} }
if (targetLevEl) targetLevEl.textContent = "—";
return; return;
} }
const sz = q.sizing || {}; const sz = q.sizing || {};
@@ -916,30 +954,19 @@
const lev = calcContractLeverage(q.index_px, ethAmount, premium); const lev = calcContractLeverage(q.index_px, ethAmount, premium);
if (levEl) levEl.textContent = fmtLeverage(lev); if (levEl) levEl.textContent = fmtLeverage(lev);
if (valueEl && profitEl && targetEl) { if (valueEl && profitEl && rrEl) {
const targetRaw = targetEl.value; const rrRaw = rrEl.value;
if (targetRaw === "" || targetRaw == null) { const rr = rrRaw === "" || rrRaw == null ? NaN : Number(rrRaw);
if (!Number.isFinite(rr) || rr <= 0 || !(Number(premium) > 0)) {
valueEl.textContent = "—"; valueEl.textContent = "—";
profitEl.textContent = "—"; profitEl.textContent = "—";
profitEl.className = "v"; profitEl.className = "v";
if (targetLevEl) targetLevEl.textContent = "—";
} else { } else {
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); const targetProfit = Number(premium) * rr;
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); const needRecycle = Number(premium) + targetProfit;
if (value == null || Number.isNaN(value)) { valueEl.textContent = fmtUsdc(needRecycle) + " USDC";
valueEl.textContent = "—"; profitEl.textContent = fmtUsdcSigned(targetProfit);
} else { profitEl.className = "v " + pnlCls(targetProfit);
valueEl.textContent = fmtUsdc(value) + " USDC";
}
if (profit == null || Number.isNaN(profit)) {
profitEl.textContent = "—";
profitEl.className = "v";
} else {
profitEl.textContent = fmtUsdcSigned(profit);
profitEl.className = "v " + pnlCls(profit);
}
const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium);
if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev);
} }
} }
} }
@@ -1214,11 +1241,16 @@
async function loadChain(opts) { async function loadChain(opts) {
const soft = !!(opts && opts.soft); const soft = !!(opts && opts.soft);
// soft 门禁必须在 seq++ 之前,否则叠刷会抬高 seq 导致 inFlight 永不清理
if (chainLoadInFlight && soft) return;
const uly = state.underlying; const uly = state.underlying;
const seq = ++chainLoadSeq; const seq = ++chainLoadSeq;
const btn = document.getElementById("opt-load-chain"); const btn = document.getElementById("opt-load-chain");
const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly;
chainLoadInFlight = true;
if (btn && !soft) btn.disabled = true; if (btn && !soft) btn.disabled = true;
if (!soft) { // 已有链时不先清空,避免刷新白屏
if (!soft && !hadChain) {
setExpirySelectStatus("加载到期日中…"); setExpirySelectStatus("加载到期日中…");
const tbody = document.getElementById("opt-strike-tbody"); const tbody = document.getElementById("opt-strike-tbody");
if (tbody) { if (tbody) {
@@ -1229,16 +1261,27 @@
try { try {
let d = null; let d = null;
let lastMsg = ""; let lastMsg = "";
for (let attempt = 0; attempt < 2; attempt++) { // soft 只试 1 次,避免与 15s 轮询叠加重试打爆 OKX
const maxAttempts = soft ? 1 : 3;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (seq !== chainLoadSeq) return; if (seq !== chainLoadSeq) return;
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
if (seq !== chainLoadSeq) return; if (seq !== chainLoadSeq) return;
if (d && d.ok && chainHasExpiries(d)) break; if (d && d.ok && chainHasExpiries(d)) break;
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
const rateLimited =
!!(d && d.rate_limited) ||
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
d = null; d = null;
if (attempt === 0) { if (attempt < maxAttempts - 1) {
if (!soft) setExpirySelectStatus("重试加载到期日…"); if (!soft && !hadChain) {
await new Promise(function (resolve) { setTimeout(resolve, 400); }); setExpirySelectStatus(
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
);
}
await new Promise(function (resolve) {
setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 400);
});
} }
} }
if (seq !== chainLoadSeq) return; if (seq !== chainLoadSeq) return;
@@ -1253,13 +1296,19 @@
if (soft) return; if (soft) return;
setExpirySelectStatus("选择到期日"); setExpirySelectStatus("选择到期日");
const tbody = document.getElementById("opt-strike-tbody"); const tbody = document.getElementById("opt-strike-tbody");
const friendly =
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""))
? "OKX 请求过于频繁,请稍后再点「刷新链」"
: lastMsg || "暂无到期日,请点「刷新链」";
if (tbody) { if (tbody) {
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' + '<tr><td colspan="' +
(lastMsg || "暂无到期日,请点「刷新链」") + strikeTableColspan() +
'" class="muted">' +
friendly +
"</td></tr>"; "</td></tr>";
} }
alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); alert(friendly);
return; return;
} }
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
@@ -1267,6 +1316,8 @@
panelCache.chain = d; panelCache.chain = d;
panelCache.underlying = uly; panelCache.underlying = uly;
panelCache.optType = state.optType; panelCache.optType = state.optType;
chainQuotedAt = Date.now();
lastChainSoftAt = chainQuotedAt;
syncAskLiqFilterFromChain(d); syncAskLiqFilterFromChain(d);
if (!soft) { if (!soft) {
state.selectedInst = null; state.selectedInst = null;
@@ -1297,7 +1348,10 @@
"</td></tr>"; "</td></tr>";
} }
} finally { } finally {
if (seq === chainLoadSeq && btn) btn.disabled = false; if (seq === chainLoadSeq) {
chainLoadInFlight = false;
if (btn) btn.disabled = false;
}
} }
} }
@@ -1329,15 +1383,13 @@
} else if (mode === "sheets") { } else if (mode === "sheets") {
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
} }
const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); const rrRaw = (document.getElementById("opt-profit-rr").value || "").trim();
if (tgtRaw !== "") { const rr = rrRaw === "" ? 2 : parseFloat(rrRaw);
const tgt = parseFloat(tgtRaw); if (!Number.isFinite(rr) || rr <= 0) {
if (!Number.isFinite(tgt) || tgt <= 0) { alert("盈亏比无效");
alert("目标位无效");
return false; return false;
} }
body.target_index = tgt; body.profit_rr = rr;
}
const d = await apiJson("/api/options/open", { const d = await apiJson("/api/options/open", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -1428,15 +1480,17 @@
return null; return null;
} }
function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { function formatRrEstimateHtml(rr, premiumPaid) {
const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); const r = Number(rr);
const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); const prem = Number(premiumPaid);
if (value == null && profit == null) return ""; if (!Number.isFinite(r) || r <= 0 || !Number.isFinite(prem) || prem <= 0) return "";
const profit = Math.round(prem * r * 100) / 100;
const need = Math.round((prem + profit) * 100) / 100;
let html = '<span class="opt-target-est">'; let html = '<span class="opt-target-est">';
html += '<span class="opt-target-est-item"><span class="k">价值</span><span class="v">' + html += '<span class="opt-target-est-item"><span class="k">目标盈利</span><span class="v ' + pnlCls(profit) + '">' +
(value == null ? "—" : fmtUsdc(value) + " USDC") + "</span></span>"; fmtUsdcSigned(profit) + "</span></span>";
html += '<span class="opt-target-est-item"><span class="k">预估盈利</span><span class="v ' + pnlCls(profit) + '">' + html += '<span class="opt-target-est-item"><span class="k">需回收</span><span class="v">' +
(profit == null ? "—" : fmtUsdcSigned(profit)) + "</span></span>"; fmtUsdc(need) + " USDC</span></span>";
html += "</span>"; html += "</span>";
return html; return html;
} }
@@ -1444,47 +1498,73 @@
function renderTargetDelegateRow(p) { function renderTargetDelegateRow(p) {
const inst = p.inst_id || ""; const inst = p.inst_id || "";
const hedgeTarget = p.hedge_plan_target || null; const hedgeTarget = p.hedge_plan_target || null;
if (hedgeTarget && Number(hedgeTarget.target_index) > 0) { if (hedgeTarget && hedgeTarget.managed_by === "hedge_plan") {
const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; const rr = hedgeTarget.oo_profit_rr != null ? Number(hedgeTarget.oo_profit_rr) : null;
const armedTxt =
rr != null && Number.isFinite(rr) && rr > 0
? "盈亏比 ×" + fmt(rr, 2)
: hedgeTarget.target_index != null
? "目标 " + fmt(hedgeTarget.target_index, 1)
: "托管中";
return ( return (
'<div class="opt-target-row opt-target-row--managed">' + '<div class="opt-target-row opt-target-row--managed">' +
'<span class="opt-target-row-label">对冲计划</span>' + '<span class="opt-target-row-label">对冲计划</span>' +
'<span class="opt-target-armed">计划 #' + '<span class="opt-target-armed">计划 #' +
hedgeTarget.plan_id + hedgeTarget.plan_id +
" · " + " · " +
side + armedTxt +
" " +
fmt(hedgeTarget.target_index, 1) +
"</span>" + "</span>" +
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</span>' + '<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控</span>' +
"</div>" "</div>"
); );
} }
const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null; const rrArmed =
const armed = tgt != null && Number.isFinite(tgt) && tgt > 0; p.profit_rr != null && p.profit_rr !== ""
const ethAmt = posEthAmount(p); ? Number(p.profit_rr)
: p.target_monitor && p.target_monitor.profit_rr != null
? Number(p.target_monitor.profit_rr)
: null;
const armed = rrArmed != null && Number.isFinite(rrArmed) && rrArmed > 0;
const prem = p.premium_paid; const prem = p.premium_paid;
const draft =
state.targetDraftByInst[inst] != null
? String(state.targetDraftByInst[inst])
: armed
? String(rrArmed)
: "2";
const estHtml = armed const estHtml = armed
? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) ? formatRrEstimateHtml(rrArmed, prem)
: '<span class="opt-target-est opt-target-est--idle"></span>'; : '<span class="opt-target-est opt-target-est--idle"></span>';
return ( return (
'<div class="opt-target-row" data-inst="' + inst + '"' + '<div class="opt-target-row" data-inst="' +
' data-opt-type="' + (p.opt_type || "") + '"' + inst +
' data-strike="' + (p.strike != null ? p.strike : "") + '"' + '"' +
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' + ' data-prem="' +
' data-prem="' + (prem != null ? prem : "") + '"' + (prem != null ? prem : "") +
' data-armed-target="' + (armed ? tgt : "") + '">' + '"' +
' data-armed-rr="' +
(armed ? rrArmed : "") +
'">' +
'<span class="opt-target-row-label">委托</span>' + '<span class="opt-target-row-label">委托</span>' +
'<input type="number" class="opt-pos-target-input" data-inst="' + inst + '" step="0.1" min="0" placeholder="监控目标指数" value="' + '<input type="number" class="opt-pos-target-input" data-inst="' +
(state.targetDraftByInst[inst] != null ? String(state.targetDraftByInst[inst]) : "") + '">' + inst +
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' + inst + '">设定</button>' + '" step="0.1" min="0.1" placeholder="盈亏比" value="' +
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' + inst + '"' + (armed ? "" : " disabled") + ">取消</button>" + draft +
(armed '">' +
? '<span class="opt-target-armed">目标 ' + fmt(tgt, 1) + "</span>" '<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' +
: "") + inst +
'">设定</button>' +
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' +
inst +
'"' +
(armed ? "" : " disabled") +
">取消</button>" +
(armed ? '<span class="opt-target-armed">盈亏比 ×' + fmt(rrArmed, 2) + "</span>" : "") +
estHtml + estHtml +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") + (armed
? "监控中 · 买一浮盈达盈亏比后全平"
: "默认2 · 买一浮盈达盈亏比×权利金后全平 · 不达标等到期") +
"</span>" + "</span>" +
"</div>" "</div>"
); );
@@ -1496,20 +1576,14 @@
if (!est) return; if (!est) return;
const inp = row.querySelector(".opt-pos-target-input"); const inp = row.querySelector(".opt-pos-target-input");
const typed = inp ? String(inp.value || "").trim() : ""; const typed = inp ? String(inp.value || "").trim() : "";
const armed = row.getAttribute("data-armed-target") || ""; const armed = row.getAttribute("data-armed-rr") || "";
const targetRaw = typed !== "" ? typed : armed; const rrRaw = typed !== "" ? typed : armed;
if (targetRaw === "") { if (rrRaw === "") {
est.className = "opt-target-est opt-target-est--idle"; est.className = "opt-target-est opt-target-est--idle";
est.innerHTML = ""; est.innerHTML = "";
return; return;
} }
const html = formatTargetEstimateHtml( const html = formatRrEstimateHtml(rrRaw, row.getAttribute("data-prem"));
row.getAttribute("data-opt-type"),
row.getAttribute("data-strike"),
targetRaw,
row.getAttribute("data-eth"),
row.getAttribute("data-prem")
);
if (!html) { if (!html) {
est.className = "opt-target-est opt-target-est--idle"; est.className = "opt-target-est opt-target-est--idle";
est.innerHTML = ""; est.innerHTML = "";
@@ -1641,9 +1715,9 @@
const row = card ? card.querySelector(".opt-target-row") : null; const row = card ? card.querySelector(".opt-target-row") : null;
const inp = card ? card.querySelector(".opt-pos-target-input") : null; const inp = card ? card.querySelector(".opt-pos-target-input") : null;
const raw = inp ? String(inp.value || "").trim() : ""; const raw = inp ? String(inp.value || "").trim() : "";
const tgt = parseFloat(raw); const rr = raw === "" ? 2 : parseFloat(raw);
if (!Number.isFinite(tgt) || tgt <= 0) { if (!Number.isFinite(rr) || rr <= 0) {
alert("请输入有效目标指数价"); alert("请输入有效盈亏比(相对权利金,默认2)");
return; return;
} }
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@@ -1651,17 +1725,16 @@
const d = await apiJson("/api/options/target", { const d = await apiJson("/api/options/target", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inst_id: inst, target_index: tgt }), body: JSON.stringify({ inst_id: inst, profit_rr: rr }),
}); });
if (!d.ok) { if (!d.ok) {
alert(d.msg || "设定失败"); alert(d.msg || "设定失败");
return; return;
} }
delete state.targetDraftByInst[inst]; delete state.targetDraftByInst[inst];
if (inp) inp.value = ""; if (inp) inp.value = String(rr);
if (row) { if (row) {
row.setAttribute("data-armed-target", String(tgt)); row.setAttribute("data-armed-rr", String(rr));
updatePosTargetEstimate(row);
} }
await refreshAllPositions(); await refreshAllPositions();
} finally { } finally {
@@ -1701,12 +1774,22 @@
} }
box.hidden = false; box.hidden = false;
host.innerHTML = rows.map(function (t) { host.innerHTML = rows.map(function (t) {
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
const managed = t.managed_by === "hedge_plan"; const managed = t.managed_by === "hedge_plan";
let rule;
if (t.profit_rr != null && Number(t.profit_rr) > 0) {
rule = "盈亏比 ×" + fmt(t.profit_rr, 2);
} else if (t.oo_profit_rr != null && Number(t.oo_profit_rr) > 0) {
rule = "盈亏比 ×" + fmt(t.oo_profit_rr, 2);
} else if (t.target_index != null && Number(t.target_index) > 0) {
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
rule = side + " " + fmt(t.target_index, 1);
} else {
rule = "委托中";
}
return ( return (
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' + '<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" + '<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
'<span class="opt-target-mon-rule">' + side + " " + fmt(t.target_index, 1) + "</span>" + '<span class="opt-target-mon-rule">' + rule + "</span>" +
(managed (managed
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>" ? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') + : '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
@@ -1887,20 +1970,23 @@
paintPositions(list); paintPositions(list);
const fromPos = list.reduce(function (targets, p) { const fromPos = list.reduce(function (targets, p) {
if (!p) return targets; if (!p) return targets;
if (p.target_index != null) { if (p.profit_rr != null || p.target_index != null) {
targets.push({ targets.push({
id: p.target_monitor_id, id: p.target_monitor_id,
inst_id: p.inst_id, inst_id: p.inst_id,
opt_type: p.opt_type, opt_type: p.opt_type,
target_index: p.target_index, target_index: p.target_index,
profit_rr: p.profit_rr,
}); });
} }
const hedgeTarget = p.hedge_plan_target; const hedgeTarget = p.hedge_plan_target;
if (hedgeTarget && hedgeTarget.target_index != null) { if (hedgeTarget) {
targets.push({ targets.push({
inst_id: p.inst_id, inst_id: p.inst_id,
opt_type: p.opt_type || hedgeTarget.opt_type, opt_type: p.opt_type || hedgeTarget.opt_type,
target_index: hedgeTarget.target_index, target_index: hedgeTarget.target_index,
oo_profit_rr: hedgeTarget.oo_profit_rr,
profit_rr: hedgeTarget.oo_profit_rr,
plan_id: hedgeTarget.plan_id, plan_id: hedgeTarget.plan_id,
managed_by: hedgeTarget.managed_by, managed_by: hedgeTarget.managed_by,
}); });
@@ -2172,6 +2258,7 @@
updateUnderlyingLabel(); updateUnderlyingLabel();
refreshPendingOrders(); refreshPendingOrders();
startPendingOrdersPoll(); startPendingOrdersPoll();
startChainSoftPoll();
const hasCache = const hasCache =
chainHasExpiries(panelCache.chain) && chainHasExpiries(panelCache.chain) &&
panelCache.underlying === state.underlying && panelCache.underlying === state.underlying &&
@@ -2181,8 +2268,8 @@
renderExpiries(); renderExpiries();
renderStrikes(); renderStrikes();
refreshAllPositions(); refreshAllPositions();
// 后台静默刷新,避免缓存过期后到期日变空 // 后台静默刷新,避免缓存过期后到期日变空 / 卖一过期
loadChain({ soft: true }); softRefreshChainThrottled(true);
return; return;
} }
requestAnimationFrame(function () { requestAnimationFrame(function () {
@@ -2286,17 +2373,17 @@
} }
bindOrderDialogChrome(); bindOrderDialogChrome();
["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { ["opt-sheets-amount", "opt-eth-amount", "opt-profit-rr"].forEach(function (id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
el.addEventListener("change", function () { el.addEventListener("change", function () {
if (id === "opt-target-idx") { if (id === "opt-profit-rr") {
updateEstimatedProfit(); updateEstimatedProfit();
return; return;
} }
if (state.selectedInst) selectContract(state.selectedInst, null, true); if (state.selectedInst) selectContract(state.selectedInst, null, true);
}); });
if (id === "opt-target-idx") { if (id === "opt-profit-rr") {
el.addEventListener("input", updateEstimatedProfit); el.addEventListener("input", updateEstimatedProfit);
} }
}); });
@@ -2306,6 +2393,8 @@
window.OptionsPanelLive = { window.OptionsPanelLive = {
refreshSoft: function () { refreshSoft: function () {
refreshAllPositions(); refreshAllPositions();
// embed SSE 只通知「该拉了」,不推送链报价;这里节流拉新鲜卖一/买一
softRefreshChainThrottled(false);
}, },
refreshChain: loadChain, refreshChain: loadChain,
}; };
+23 -19
View File
@@ -219,38 +219,42 @@
const hint = closeGateHint(closePreview); const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : ""; return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() + })() +
(p.target_index != null (p.profit_rr != null || p.target_index != null
? (function () { ? (function () {
const eth = p.eth_amount != null ? Number(p.eth_amount) const hedgeTarget = p.hedge_plan_target || null;
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null); const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const strike = Number(p.strike); const rr =
const tgt = Number(p.target_index); managed && hedgeTarget.oo_profit_rr != null
? Number(hedgeTarget.oo_profit_rr)
: p.profit_rr != null
? Number(p.profit_rr)
: null;
const prem = Number(p.premium_paid); const prem = Number(p.premium_paid);
let profit = null; let profit = null;
let value = null; let need = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) {
const o = String(p.opt_type || "").toUpperCase(); profit = Math.round(prem * rr * 100) / 100;
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; need = Math.round((prem + profit) * 100) / 100;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
} }
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const profitSpan = hidePnl const profitSpan = hidePnl
? "" ? ""
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>"; : '<span class="pos-value' + profitCls + '">目标盈利 ' + profitTxt + "</span>";
const ruleTxt =
rr != null && Number.isFinite(rr) && rr > 0
? "盈亏比 ×" + fmt(rr, 2)
: p.target_index != null
? "目标 " + fmt(p.target_index, 1)
: "委托中";
return ( return (
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' + '<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" + '<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" + '<span class="pos-value">' + ruleTxt + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" + (need != null ? '<span class="pos-value">需回收 ' + fmtUsdc(need) + " USDC</span>" : "") +
profitSpan + profitSpan +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + (managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") +
"</span></div>" "</span></div>"
); );
})() })()
+2
View File
@@ -76,6 +76,8 @@
target_win_leg: "期期平盈利腿", target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
oo_rr_target: "期期盈亏比达标",
oo_rr_closing: "期期盈亏比平仓中",
oo_rest_closing: "期期全平·清残腿中", oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平", oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
+111 -17
View File
@@ -25,6 +25,14 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = {
} }
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} _OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链
_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
_INSTRUMENTS_STALE_SEC = 600.0
_TICKERS_CACHE: dict[str, dict[str, Any]] = {}
_TICKERS_CACHE_LOCK = threading.Lock()
_TICKERS_CACHE_TTL_SEC = float(os.getenv("OKX_OPTIONS_TICKERS_CACHE_SEC", "10") or "10")
def invalidate_options_balance_cache() -> None: def invalidate_options_balance_cache() -> None:
@@ -32,6 +40,14 @@ def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["data"] = None _OPTIONS_BALANCE_CACHE["data"] = None
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
with _INSTRUMENTS_CACHE_LOCK:
if inst_family:
_INSTRUMENTS_CACHE.pop(str(inst_family), None)
else:
_INSTRUMENTS_CACHE.clear()
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
row: dict[str, Any] | None = None row: dict[str, Any] | None = None
if isinstance(resp, dict): if isinstance(resp, dict):
@@ -645,24 +661,105 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
def fetch_option_instruments( def fetch_option_instruments(
ex: ccxt.okx, ex: ccxt.okx,
inst_family: str, inst_family: str,
*,
force: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = ex.public_get_public_instruments( """拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存."""
{"instType": "OPTION", "instFamily": inst_family} family = (inst_family or "").strip()
if not family:
return []
now = time.time()
with _INSTRUMENTS_CACHE_LOCK:
cached = _INSTRUMENTS_CACHE.get(family)
if (
not force
and cached
and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC
and isinstance(cached.get("rows"), list)
and cached["rows"]
):
return list(cached["rows"])
last_err: BaseException | None = None
rows: list[dict[str, Any]] = []
for attempt in range(4):
try:
raw = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family}
).get("data") or [] ).get("data") or []
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"]
last_err = None
break
except Exception as e:
last_err = e
if _is_okx_rate_limit(e) and attempt < 3:
time.sleep(0.8 * (2**attempt))
continue
break
if rows:
with _INSTRUMENTS_CACHE_LOCK:
_INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)}
return rows
# 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」
if cached and isinstance(cached.get("rows"), list) and cached["rows"]:
age = now - float(cached.get("updated_at") or 0)
if age < _INSTRUMENTS_STALE_SEC and (
last_err is None or _is_okx_rate_limit(last_err) or not rows
):
return list(cached["rows"])
if last_err is not None:
raise last_err
return []
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: def fetch_option_tickers(
ex: ccxt.okx,
inst_family: str,
*,
force: bool = False,
) -> dict[str, dict[str, Any]]:
family = (inst_family or "").strip()
if not family:
return {}
now = time.time()
with _TICKERS_CACHE_LOCK:
cached = _TICKERS_CACHE.get(family)
if (
not force
and cached
and now - float(cached.get("updated_at") or 0) < max(1.0, _TICKERS_CACHE_TTL_SEC)
and isinstance(cached.get("rows"), dict)
and cached["rows"]
):
return dict(cached["rows"])
out: dict[str, dict[str, Any]] = {} out: dict[str, dict[str, Any]] = {}
last_err: BaseException | None = None
for attempt in range(3):
try: try:
rows = ex.public_get_market_tickers( rows = ex.public_get_market_tickers(
{"instType": "OPTION", "instFamily": inst_family} {"instType": "OPTION", "instFamily": family}
).get("data") or [] ).get("data") or []
for r in rows: for r in rows:
if isinstance(r, dict) and r.get("instId"): if isinstance(r, dict) and r.get("instId"):
out[str(r["instId"])] = r out[str(r["instId"])] = r
except Exception: if out:
pass with _TICKERS_CACHE_LOCK:
_TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)}
return out
except Exception as e:
last_err = e
if _is_okx_rate_limit(e) and attempt < 2:
time.sleep(0.6 * (attempt + 1))
continue
break
if cached and isinstance(cached.get("rows"), dict) and cached["rows"]:
return dict(cached["rows"])
if last_err is not None and _is_okx_rate_limit(last_err):
return out
return out return out
@@ -683,22 +780,17 @@ def build_option_chain(
max_ms = now_ms + max_dte_days * 86400 * 1000 max_ms = now_ms + max_dte_days * 86400 * 1000
instruments_err = "" instruments_err = ""
instruments: list[dict[str, Any]] = [] instruments: list[dict[str, Any]] = []
for attempt in range(2): rate_limited = False
try: try:
instruments = fetch_option_instruments(ex, family) instruments = fetch_option_instruments(ex, family)
instruments_err = "" if not instruments:
if instruments:
break
instruments_err = "期权合约列表为空" instruments_err = "期权合约列表为空"
except Exception as e: except Exception as e:
instruments = [] instruments = []
instruments_err = str(e) or e.__class__.__name__ instruments_err = str(e) or e.__class__.__name__
if attempt == 0: rate_limited = _is_okx_rate_limit(e)
time.sleep(0.35) if rate_limited:
continue instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
break
if attempt == 0 and not instruments:
time.sleep(0.35)
tickers = fetch_option_tickers(ex, family) tickers = fetch_option_tickers(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {} expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0 skipped_no_index = 0
@@ -777,6 +869,8 @@ def build_option_chain(
"expiries": exp_list, "expiries": exp_list,
"instruments_count": len(instruments), "instruments_count": len(instruments),
} }
if rate_limited:
out["rate_limited"] = True
if not exp_list: if not exp_list:
if instruments_err: if instruments_err:
out["chain_error"] = f"拉取期权合约失败: {instruments_err}" out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
+69 -12
View File
@@ -444,6 +444,7 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
def build_options_options_preview( def build_options_options_preview(
*, *,
profit_rr: float | None = None,
target_price: float | None = None, target_price: float | None = None,
target_price_up: float | None = None, target_price_up: float | None = None,
target_price_down: float | None = None, target_price_down: float | None = None,
@@ -451,7 +452,11 @@ def build_options_options_preview(
leg_a: dict[str, Any], leg_a: dict[str, Any],
leg_b: dict[str, Any], leg_b: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期.
仍接受旧上破/下破参数仅作兼容测算.
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float: def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl( return option_expiry_pnl(
@@ -463,15 +468,73 @@ def build_options_options_preview(
premium_paid=float(leg.get("premium_paid") or 0), premium_paid=float(leg.get("premium_paid") or 0),
) )
# 兼容旧单目标:若未传上下目标则用 target_price 填两边 prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
rr = None
if profit_rr not in (None, ""):
try:
rr = float(profit_rr)
except (TypeError, ValueError):
rr = None
if rr is not None and rr > 0:
target_pnl = rr * prem
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"oo_profit_rr": round(rr, 4),
"target_profit": round(target_pnl, 4),
"scenarios": [
{
"id": "rr_target",
"label": f"盈亏比×{rr:g}",
"spot": None,
"leg_a_pnl": None,
"leg_b_pnl": None,
"total": round(target_pnl, 4),
"note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期",
},
{
"id": "expiry_flat",
"label": "到期·现价(未达标)",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "中途未达盈亏比则持有至到期结算",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"oo_profit_rr": round(rr, 4),
"target_profit": round(target_pnl, 4),
"at_target_total": round(target_pnl, 4),
"expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6),
"rr_target": round(rr, 4),
},
}
# 兼容旧上破/下破测算
up = target_price_up if target_price_up is not None else target_price up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price down = target_price_down if target_price_down is not None else target_price
if up is None or down is None: if up is None or down is None:
raise ValueError("缺少上破/下破目标价") raise ValueError("请填写盈亏比(相对权利金,默认2)")
up_f = float(up) up_f = float(up)
down_f = float(down) down_f = float(down)
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
a_up = _leg_pnl(leg_a, up_f) a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f) b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up at_up = a_up + b_up
@@ -482,15 +545,10 @@ def build_options_options_preview(
at_dn = a_dn + b_dn at_dn = a_dn + b_dn
win_dn = "a" if a_dn >= b_dn else "b" win_dn = "a" if a_dn >= b_dn else "b"
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
expiry_loss = flat_total if flat_total <= 0 else flat_total
return { return {
"plan_type": "options_options", "plan_type": "options_options",
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"target_price": up_f, # 兼容旧字段,取上破 "target_price": up_f,
"target_price_up": up_f, "target_price_up": up_f,
"target_price_down": down_f, "target_price_down": down_f,
"winner_at_up": win_up, "winner_at_up": win_up,
@@ -538,10 +596,9 @@ def build_options_options_preview(
"at_target_up_total": round(at_up, 4), "at_target_up_total": round(at_up, 4),
"at_target_down_total": round(at_dn, 4), "at_target_down_total": round(at_dn, 4),
"at_target_total": round(at_up, 4), "at_target_total": round(at_up, 4),
"expiry_flat_total": round(expiry_loss, 4), "expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0, "expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6), "rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None, "rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
+30 -2
View File
@@ -72,6 +72,8 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
) )
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL") _ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期
_ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL")
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) # close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」 # 永期「以期权为主」
@@ -272,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
l.inst_id, l.opt_type p.oo_profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options' WHERE p.plan_type = 'options_options'
@@ -287,10 +289,36 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
for raw in rows: for raw in rows:
row = dict(raw) row = dict(raw)
inst_id = str(row.get("inst_id") or "") inst_id = str(row.get("inst_id") or "")
if not inst_id or inst_id in out:
continue
opt_type = str(row.get("opt_type") or "").upper() opt_type = str(row.get("opt_type") or "").upper()
rr = _sf(row.get("oo_profit_rr"))
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target) target_f = _sf(target)
if not inst_id or target_f is None or target_f <= 0 or inst_id in out: # 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示
if rr is not None and rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"target_index": None,
"oo_profit_rr": rr,
"plan_type": "options_options",
"managed_by": "hedge_plan",
}
continue
if target_f is None or target_f <= 0:
# 无目标价也标记托管,避免期权页误拆组
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"target_index": None,
"plan_type": "options_options",
"managed_by": "hedge_plan",
}
continue continue
out[inst_id] = { out[inst_id] = {
"plan_id": int(row["plan_id"]), "plan_id": int(row["plan_id"]),
+109 -1
View File
@@ -999,6 +999,8 @@ def _tick_oo_close_rest(
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"oo_rest_closing", "oo_rest_closing",
"oo_rr_closing",
"oo_rr_target",
"", "",
) )
if reason0 not in allowed_reasons and not ( if reason0 not in allowed_reasons and not (
@@ -1049,7 +1051,113 @@ def _tick_oo_close_rest(
def _tick_oo_target( def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿.""" """期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破."""
rr = _sf(plan.get("oo_profit_rr"))
if rr is not None and rr > 0:
return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr))
return _tick_oo_price_target(cfg, conn, plan, legs)
def _tick_oo_rr_target(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
legs: list[dict[str, Any]],
*,
rr: float,
) -> Optional[dict[str, Any]]:
"""浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期."""
open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 1:
return None
premium = float(plan.get("premium_total") or 0)
if premium <= 0:
premium = sum(float(x.get("premium") or 0) for x in open_legs)
if premium <= 0:
return None
need = float(rr) * premium
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
total_pnl = 0.0
missing_bid = 0
for leg in open_legs:
inst = str(leg.get("inst_id") or "")
bid = None
try:
q = quote_fn(ex, inst) if inst else {}
if isinstance(q, dict) and q.get("ok"):
bid = _sf(q.get("bid"))
except Exception:
bid = None
if bid is None or float(bid) <= 0:
missing_bid += 1
# 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过
total_pnl += _estimate_leg_close_pnl(leg, idx, None)
else:
total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid))
if missing_bid >= len(open_legs):
return None
if total_pnl + 1e-9 < need:
return None
acted = False
for leg in list(open_legs):
close_r = _sell_option(
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期盈亏比达标·平仓失败(将重试)",
plan_id=plan.get("id"),
detail=(
f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; "
f"{close_r.get('msg') or close_r}"
),
),
)
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
return {
"plan_id": plan["id"],
"msg": "盈亏比达标但平仓失败",
"close": close_r,
"retry": True,
"rr": rr,
"need": need,
"mtm": total_pnl,
}
bid = _sf(close_r.get("bid"))
est = _estimate_leg_close_pnl(leg, idx, bid)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "oo_rr_target", _now(), round(pnl, 4), leg["id"]),
)
acted = True
if not acted:
return None
legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
if still_open:
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
return {
"plan_id": plan["id"],
"msg": "盈亏比达标·部分已平,继续重试",
"remaining": len(still_open),
"rr": rr,
}
return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target")
def _tick_oo_price_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""旧逻辑:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None: if idx is None:
return None return None
+22
View File
@@ -45,6 +45,15 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
] ]
) )
else:
rr = plan.get("oo_profit_rr")
if rr not in (None, ""):
lines.extend(
[
f"🎯 盈亏比:{_fmt(rr)}×权利金(达标全平;不达标等到期)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else: else:
lines.extend( lines.extend(
[ [
@@ -81,6 +90,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)", "target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿", "target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿",
"oo_rr_target": "期期盈亏比达标·两腿已平",
"oo_rr_closing": "期期盈亏比达标·平仓中",
"oo_rest_closing": "期期全平·清残腿中", "oo_rest_closing": "期期全平·清残腿中",
"oo_rest_closed": "期期全平·两腿已平", "oo_rest_closed": "期期全平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_loss": "期期到期无盈利·总亏损",
@@ -153,7 +164,18 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"oo_rest_closing", "oo_rest_closing",
"oo_rr_closing",
) and (plan.get("status") or "") != "closed": ) and (plan.get("status") or "") != "closed":
if "oo_rr" in str(plan.get("close_reason") or ""):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期盈亏比达标·平仓进行中",
plan_id=plan.get("id"),
detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金",
),
)
return True
side = "上破" if "up" in str(plan.get("close_reason")) else ( side = "上破" if "up" in str(plan.get("close_reason")) else (
"下破" if "down" in str(plan.get("close_reason")) else "目标价" "下破" if "down" in str(plan.get("close_reason")) else "目标价"
) )
+12 -6
View File
@@ -1146,6 +1146,17 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
b = body.get("leg_b") or {} b = body.get("leg_b") or {}
if not a.get("inst_id") or not b.get("inst_id"): if not a.get("inst_id") or not b.get("inst_id"):
return "请选用两条期权腿" return "请选用两条期权腿"
rr_raw = body.get("oo_profit_rr")
if rr_raw in (None, ""):
rr_raw = body.get("profit_rr")
if rr_raw not in (None, ""):
try:
rr = float(rr_raw)
except (TypeError, ValueError):
return "盈亏比无效"
if rr <= 0:
return "盈亏比须大于 0"
else:
up = body.get("target_price_up") up = body.get("target_price_up")
down = body.get("target_price_down") down = body.get("target_price_down")
legacy = body.get("target_price") legacy = body.get("target_price")
@@ -1154,7 +1165,7 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
if down in (None, "") and legacy not in (None, ""): if down in (None, "") and legacy not in (None, ""):
down = legacy down = legacy
if up in (None, "") or down in (None, ""): if up in (None, "") or down in (None, ""):
return "请填写上破与下破目标价" return "请填写盈亏比(相对权利金,默认2)"
try: try:
if float(up) <= float(down): if float(up) <= float(down):
return "上破目标价必须大于下破目标价" return "上破目标价必须大于下破目标价"
@@ -1179,11 +1190,6 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
return {"opt_type": opt_type, "strike": strike} return {"opt_type": opt_type, "strike": strike}
index_px = body.get("index_px") index_px = body.get("index_px")
if index_px in (None, ""):
try:
index_px = (float(up) + float(down)) / 2.0
except (TypeError, ValueError):
index_px = None
money_err = validate_oo_legs_moneyness( money_err = validate_oo_legs_moneyness(
_leg_for_money(a), _leg_for_money(a),
_leg_for_money(b), _leg_for_money(b),
+58 -31
View File
@@ -537,27 +537,25 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0 float(b.get("premium") or 0) if b_ok else 0.0
) )
rr_raw = body.get("oo_profit_rr")
if rr_raw in (None, ""):
rr_raw = body.get("profit_rr")
try:
oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
except (TypeError, ValueError):
oo_rr = 2.0
if oo_rr <= 0:
oo_rr = 2.0
plan_id = insert_plan( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "options_options", "plan_type": "options_options",
"status": "partial" if is_partial else "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float( "target_price": None,
body.get("target_price_up") "target_price_up": None,
or body.get("target_price") "target_price_down": None,
or 0 "oo_profit_rr": oo_rr,
),
"target_price_up": float(
body.get("target_price_up")
or body.get("target_price")
or 0
),
"target_price_down": float(
body.get("target_price_down")
or body.get("target_price")
or 0
),
"sizing_mode_at_open": load_position_sizing_mode(), "sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium, "premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
@@ -1238,20 +1236,24 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
up = body.get("target_price_up") rr_raw = body.get("oo_profit_rr")
down = body.get("target_price_down") if rr_raw in (None, ""):
legacy = body.get("target_price") rr_raw = body.get("profit_rr")
if up in (None, "") and legacy not in (None, ""): rr = None
up = legacy if rr_raw not in (None, ""):
if down in (None, "") and legacy not in (None, ""): try:
down = legacy rr = float(rr_raw)
if up in (None, "") or down in (None, ""): except (TypeError, ValueError) as e:
raise ValueError("请填写上破与下破目标价") raise ValueError("盈亏比无效") from e
up_f = float(up) if rr <= 0:
down_f = float(down) raise ValueError("盈亏比须大于 0")
if up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价") index_px = body.get("index_px")
index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) try:
index_px_f = float(index_px) if index_px not in (None, "") else 0.0
except (TypeError, ValueError):
index_px_f = 0.0
leg_a = body.get("leg_a") or {} leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {} leg_b = body.get("leg_b") or {}
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
@@ -1265,13 +1267,38 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
) )
if leg.get("premium_paid") is None: if leg.get("premium_paid") is None:
raise ValueError(f"缺少 {name} 权利金") raise ValueError(f"缺少 {name} 权利金")
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px_f or None)
if money_err: if money_err:
raise ValueError(money_err) raise ValueError(money_err)
if rr is not None:
return build_options_options_preview(
profit_rr=rr,
index_px=index_px_f,
leg_a=leg_a,
leg_b=leg_b,
)
# 兼容旧上破/下破
up = body.get("target_price_up")
down = body.get("target_price_down")
legacy = body.get("target_price")
if up in (None, "") and legacy not in (None, ""):
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if up in (None, "") or down in (None, ""):
raise ValueError("请填写盈亏比(相对权利金,默认2)")
up_f = float(up)
down_f = float(down)
if up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价")
if index_px_f <= 0:
index_px_f = (up_f + down_f) / 2
return build_options_options_preview( return build_options_options_preview(
target_price_up=up_f, target_price_up=up_f,
target_price_down=down_f, target_price_down=down_f,
index_px=index_px, index_px=index_px_f,
leg_a=leg_a, leg_a=leg_a,
leg_b=leg_b, leg_b=leg_b,
) )
@@ -213,7 +213,7 @@
<div class="tip-collapse-body rule-tip"> <div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p> <p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p> <p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期</p> <p><strong>板块</strong>:左填<strong>盈亏比</strong>(相对权利金,默认 2=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理</p>
</div> </div>
</details> </details>
<div class="form-row hp-uly-row"> <div class="form-row hp-uly-row">
@@ -221,8 +221,7 @@
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div> </div>
<div class="form-row hp-target-row hp-oo-target-row"> <div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label> <label title="目标盈利 = 盈亏比 × 两腿权利金合计;例 2=赚满 2 倍权利金后全平">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-oo-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span> <span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div> </div>
<div class="hp-oo-controls"> <div class="hp-oo-controls">
@@ -406,4 +405,4 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/static/hedge_plan.js?v=46"></script> <script src="/static/hedge_plan.js?v=47"></script>
+14 -4
View File
@@ -123,21 +123,31 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
def _format_options_target(p: dict[str, Any]) -> str: def _format_options_target(p: dict[str, Any]) -> str:
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
if hedge: if hedge:
ot = str(hedge.get("opt_type") or opt_type).upper() rr = _safe_float(hedge.get("oo_profit_rr") or hedge.get("profit_rr"))
pid = hedge.get("plan_id")
if rr is not None and rr > 0:
return f"对冲#{pid} 盈亏比×{rr:g}" if pid is not None else f"盈亏比×{rr:g}"
ot = str(hedge.get("opt_type") or p.get("opt_type") or p.get("optType") or "").upper()
side = "Put ≤" if ot == "P" else "Call ≥" side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index")) tgt = _safe_float(hedge.get("target_index"))
pid = hedge.get("plan_id")
if tgt is not None: if tgt is not None:
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None
rr = _safe_float(p.get("profit_rr"))
if rr is None and mon:
rr = _safe_float(mon.get("profit_rr"))
if rr is not None and rr > 0:
return f"盈亏比×{rr:g}"
tgt = _safe_float(p.get("target_index")) tgt = _safe_float(p.get("target_index"))
if tgt is None and mon:
tgt = _safe_float(mon.get("target_index"))
if tgt is not None and tgt > 0: if tgt is not None and tgt > 0:
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
side = "Put ≤" if opt_type == "P" else "Call ≥" side = "Put ≤" if opt_type == "P" else "Call ≥"
return f"{side} {tgt:g}" return f"{side} {tgt:g}"
return "" return ""
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
+3
View File
@@ -72,11 +72,14 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
mon = tgt_map.get(str(row.get("inst_id") or "")) mon = tgt_map.get(str(row.get("inst_id") or ""))
if mon: if mon:
row["target_index"] = mon.get("target_index") row["target_index"] = mon.get("target_index")
row["profit_rr"] = mon.get("profit_rr")
row["target_monitor_id"] = mon.get("id") row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon row["target_monitor"] = mon
hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
if hedge_target: if hedge_target:
row["hedge_plan_target"] = hedge_target row["hedge_plan_target"] = hedge_target
if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None:
row["profit_rr"] = hedge_target.get("oo_profit_rr")
if not mon: if not mon:
row["target_index"] = hedge_target.get("target_index") row["target_index"] = hedge_target.get("target_index")
rows.append(row) rows.append(row)
+4 -2
View File
@@ -22,7 +22,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw) # 中控看板不拉逐仓 books(易超 HUB_FLASK_TIMEOUT);实例页仍走完整 preview
positions = build_display_option_positions(cfg, ex, raw, with_close_preview=False)
target_monitors: list[dict[str, Any]] = [] target_monitors: list[dict[str, Any]] = []
try: try:
conn = cfg["get_db"]() conn = cfg["get_db"]()
@@ -38,14 +39,15 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
mon = tgt_map.get(str(p.get("inst_id") or "")) mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon: if mon:
p["target_index"] = mon.get("target_index") p["target_index"] = mon.get("target_index")
p["profit_rr"] = mon.get("profit_rr")
p["target_monitor_id"] = mon.get("id") p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon p["target_monitor"] = mon
hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target: if hedge_target:
p["hedge_plan_target"] = hedge_target p["hedge_plan_target"] = hedge_target
if not mon: if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index") p["target_index"] = hedge_target.get("target_index")
p["profit_rr"] = hedge_target.get("oo_profit_rr")
try: try:
from lib.instance.instance_dashboard_lib import ( from lib.instance.instance_dashboard_lib import (
_format_options_target, _format_options_target,
+1
View File
@@ -455,6 +455,7 @@ def options_monitor_loop(
conn, conn,
positions, positions,
close_fn=target_close_fn, close_fn=target_close_fn,
bid_fn=ticker_bid_fn,
send_wechat=send_wechat, send_wechat=send_wechat,
account_label=account_label, account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label}, cfg={"send_wechat": send_wechat, "account_label": account_label},
+19 -2
View File
@@ -55,6 +55,7 @@ def build_options_open_message(
premium_paid: Any = None, premium_paid: Any = None,
open_quote: Any = None, open_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
signal_note: str = "", signal_note: str = "",
trade_id: Any = None, trade_id: Any = None,
) -> str: ) -> str:
@@ -73,7 +74,12 @@ def build_options_open_message(
f"权利金:{_fmt(premium_paid)} USDC", f"权利金:{_fmt(premium_paid)} USDC",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if profit_rr is not None and str(profit_rr).strip() != "":
try:
lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)")
except (TypeError, ValueError):
lines.append(f"盈亏比:{profit_rr}")
elif target_index is not None and str(target_index).strip() != "":
try: try:
lines.append(f"目标指数:{float(target_index):g}") lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -96,6 +102,7 @@ def build_options_close_message(
realized_pnl: Any = None, realized_pnl: Any = None,
close_quote: Any = None, close_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
trade_id: Any = None, trade_id: Any = None,
) -> str: ) -> str:
@@ -116,7 +123,12 @@ def build_options_close_message(
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if profit_rr is not None and str(profit_rr).strip() != "":
try:
lines.append(f"盈亏比:×{float(profit_rr):g}")
except (TypeError, ValueError):
lines.append(f"盈亏比:{profit_rr}")
elif target_index is not None and str(target_index).strip() != "":
try: try:
lines.append(f"目标指数:{float(target_index):g}") lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -141,6 +153,7 @@ def notify_options_open(
premium_paid: Any = None, premium_paid: Any = None,
open_quote: Any = None, open_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
signal_note: str = "", signal_note: str = "",
) -> bool: ) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None ensure_options_notify_columns(conn) if conn is not None else None
@@ -160,6 +173,7 @@ def notify_options_open(
premium_paid=premium_paid, premium_paid=premium_paid,
open_quote=open_quote, open_quote=open_quote,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
signal_note=signal_note, signal_note=signal_note,
trade_id=trade_id, trade_id=trade_id,
) )
@@ -196,6 +210,7 @@ def notify_options_close(
realized_pnl: Any = None, realized_pnl: Any = None,
close_quote: Any = None, close_quote: Any = None,
target_index: Any = None, target_index: Any = None,
profit_rr: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
force: bool = False, force: bool = False,
) -> bool: ) -> bool:
@@ -257,6 +272,7 @@ def notify_options_close(
realized_pnl=total_pnl, realized_pnl=total_pnl,
close_quote=close_quote if close_quote is not None else head.get("close_quote"), close_quote=close_quote if close_quote is not None else head.get("close_quote"),
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None, trade_id=head.get("id") if len(rows) == 1 else None,
) )
@@ -286,6 +302,7 @@ def notify_options_close(
realized_pnl=realized_pnl, realized_pnl=realized_pnl,
close_quote=close_quote, close_quote=close_quote,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=trade_id, trade_id=trade_id,
) )
+4 -1
View File
@@ -145,8 +145,10 @@ def build_display_option_positions(
cfg: dict[str, Any], cfg: dict[str, Any],
ex: Any, ex: Any,
raw_positions: list[dict[str, Any]], raw_positions: list[dict[str, Any]],
*,
with_close_preview: bool = True,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""与实例 /api/options/positions 相同 enrichment + close_preview.""" """与实例 /api/options/positions 相同 enrichment;中控可关 close_preview 避免逐仓拉盘口超时."""
meta_cache: dict[str, dict[str, Any] | None] = {} meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
conn = cfg["get_db"]() conn = cfg["get_db"]()
@@ -162,6 +164,7 @@ def build_display_option_positions(
meta_cache=meta_cache, meta_cache=meta_cache,
premium_override=premium_override, premium_override=premium_override,
) )
if with_close_preview:
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
rows.append(row) rows.append(row)
finally: finally:
+56 -3
View File
@@ -646,6 +646,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
mode = (data.get("mode") or "budget_full").strip() mode = (data.get("mode") or "budget_full").strip()
signal_note = (data.get("signal_note") or "").strip() signal_note = (data.get("signal_note") or "").strip()
target_index = None target_index = None
profit_rr = None
raw_rr = data.get("profit_rr")
if raw_rr is None or str(raw_rr).strip() == "":
raw_rr = data.get("oo_profit_rr")
if raw_rr is not None and str(raw_rr).strip() != "":
try:
profit_rr = float(raw_rr)
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "盈亏比无效"})
if profit_rr <= 0:
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
raw_target = data.get("target_index") raw_target = data.get("target_index")
if raw_target is not None and str(raw_target).strip() != "": if raw_target is not None and str(raw_target).strip() != "":
try: try:
@@ -654,6 +665,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0: if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
# 未显式传目标时默认盈亏比 2
if profit_rr is None and target_index is None:
profit_rr = 2.0
if not inst_id: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"}) return jsonify({"ok": False, "msg": "缺少 inst_id"})
q = cfg["quote_option_contract"](ex, inst_id) q = cfg["quote_option_contract"](ex, inst_id)
@@ -820,13 +834,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
), ),
) )
trade_id = int(cur.lastrowid) trade_id = int(cur.lastrowid)
if target_index is not None: if profit_rr is not None or target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor from lib.options.options_target_lib import upsert_target_monitor
target_mon = upsert_target_monitor( target_mon = upsert_target_monitor(
conn, conn,
inst_id=inst_id, inst_id=inst_id,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
underlying=u, underlying=u,
opt_type=str(opt_type) if opt_type else None, opt_type=str(opt_type) if opt_type else None,
trade_id=trade_id, trade_id=trade_id,
@@ -854,6 +869,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
premium_paid=sizing.get("total_premium"), premium_paid=sizing.get("total_premium"),
open_quote=fill_px, open_quote=fill_px,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
signal_note=signal_note, signal_note=signal_note,
) )
finally: finally:
@@ -969,11 +985,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
mon = tgt_map.get(inst) mon = tgt_map.get(inst)
if mon: if mon:
row["target_index"] = mon.get("target_index") row["target_index"] = mon.get("target_index")
row["profit_rr"] = mon.get("profit_rr")
row["target_monitor_id"] = mon.get("id") row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon row["target_monitor"] = mon
hedge_target = hedge_target_map.get(inst) hedge_target = hedge_target_map.get(inst)
if hedge_target: if hedge_target:
row["hedge_plan_target"] = hedge_target row["hedge_plan_target"] = hedge_target
if hedge_target.get("oo_profit_rr") is not None:
row.setdefault("profit_rr", hedge_target.get("oo_profit_rr"))
try: try:
from lib.instance.instance_dashboard_lib import _resolve_options_source from lib.instance.instance_dashboard_lib import _resolve_options_source
@@ -1031,12 +1050,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn_h.close() conn_h.close()
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
profit_rr = None
target_index = None
raw_rr = data.get("profit_rr")
if raw_rr is None or str(raw_rr).strip() == "":
raw_rr = data.get("oo_profit_rr")
if raw_rr is not None and str(raw_rr).strip() != "":
try: try:
target_index = float(data.get("target_index")) profit_rr = float(raw_rr)
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "盈亏比无效"})
if profit_rr <= 0:
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
raw_tgt = data.get("target_index")
if raw_tgt is not None and str(raw_tgt).strip() != "":
try:
target_index = float(raw_tgt)
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0: if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if profit_rr is None and target_index is None:
profit_rr = 2.0
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"}) return jsonify({"ok": False, "msg": "获取期权持仓失败"})
@@ -1065,6 +1100,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn, conn,
inst_id=inst_id, inst_id=inst_id,
target_index=target_index, target_index=target_index,
profit_rr=profit_rr,
underlying=str(underlying) if underlying else None, underlying=str(underlying) if underlying else None,
opt_type=str(opt_type) if opt_type else None, opt_type=str(opt_type) if opt_type else None,
trade_id=trade_id, trade_id=trade_id,
@@ -1411,7 +1447,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return [] return []
return [cfg["format_position_row"](p) for p in raw] rows = [cfg["format_position_row"](p) for p in raw]
try:
from lib.options.options_db import sum_open_premium_paid
conn = cfg["get_db"]()
try:
for row in rows:
inst = str(row.get("inst_id") or "")
if not inst:
continue
paid = sum_open_premium_paid(conn, inst)
if paid is not None:
row["premium_paid"] = paid
finally:
conn.close()
except Exception:
pass
return rows
def _sync(conn): def _sync(conn):
from lib.exchange.okx_options_lib import fetch_option_position_history from lib.exchange.okx_options_lib import fetch_option_position_history
+176 -52
View File
@@ -1,11 +1,14 @@
"""期权目标委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" """期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算).
兼容旧目标指数委托: profit_rr 时仍按指数到位触发.
"""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import time import time
from typing import Any, Callable from typing import Any, Callable
from lib.options.options_db import init_options_tables from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
@@ -18,6 +21,18 @@ def _safe_float(v: Any) -> float | None:
return None return None
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
names: set[str] = set()
for r in rows:
try:
names.add(str(r["name"]))
except (TypeError, KeyError, IndexError):
names.add(str(r[1]))
if col not in names:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
from lib.exchange.okx_options_lib import option_fields_from_inst_id from lib.exchange.okx_options_lib import option_fields_from_inst_id
@@ -63,21 +78,44 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status) ON options_target_monitors(status)
""" """
) )
# 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0
_ensure_column(conn, "options_target_monitors", "profit_rr", "REAL")
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" """旧逻辑:Call 指数≥目标;Put 指数≤目标."""
ot = (opt_type or "").strip().upper() ot = (opt_type or "").strip().upper()
if ot == "P": if ot == "P":
return index_px <= target_index return index_px <= target_index
return index_px >= target_index return index_px >= target_index
def profit_rr_hit(
*,
premium: float,
bid: float | None,
sheets: float,
ct_mult: float,
profit_rr: float,
) -> bool:
"""买一回收 − 权利金 ≥ 盈亏比 × 权利金."""
if premium <= 0 or profit_rr <= 0:
return False
if bid is None or float(bid) <= 0:
return False
if sheets <= 0 or ct_mult <= 0:
return False
recycle = float(bid) * float(sheets) * float(ct_mult)
pnl = recycle - float(premium)
return pnl + 1e-9 >= float(profit_rr) * float(premium)
def upsert_target_monitor( def upsert_target_monitor(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
inst_id: str, inst_id: str,
target_index: float, target_index: float | None = None,
profit_rr: float | None = None,
underlying: str | None = None, underlying: str | None = None,
opt_type: str | None = None, opt_type: str | None = None,
trade_id: int | None = None, trade_id: int | None = None,
@@ -87,9 +125,18 @@ def upsert_target_monitor(
inst_id = (inst_id or "").strip() inst_id = (inst_id or "").strip()
if not inst_id: if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"} return {"ok": False, "msg": "缺少 inst_id"}
if target_index is None or float(target_index) <= 0:
return {"ok": False, "msg": "目标位无效"} rr = _safe_float(profit_rr)
target_index = float(target_index) tgt = _safe_float(target_index)
if rr is not None and rr > 0:
tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0
rr_store = float(rr)
elif tgt is not None and tgt > 0:
tgt_store = float(tgt)
rr_store = None
else:
return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"}
row = conn.execute( row = conn.execute(
""" """
SELECT id FROM options_target_monitors SELECT id FROM options_target_monitors
@@ -104,6 +151,7 @@ def upsert_target_monitor(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET target_index = ?, SET target_index = ?,
profit_rr = ?,
underlying = COALESCE(?, underlying), underlying = COALESCE(?, underlying),
opt_type = COALESCE(?, opt_type), opt_type = COALESCE(?, opt_type),
trade_id = COALESCE(?, trade_id), trade_id = COALESCE(?, trade_id),
@@ -115,14 +163,13 @@ def upsert_target_monitor(
triggered_at = NULL triggered_at = NULL
WHERE id = ? WHERE id = ?
""", """,
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), (tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])),
) )
mon_id = int(row["id"]) mon_id = int(row["id"])
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
conn.execute( conn.execute(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET status = 'cancelled', message = '被新目标覆盖' SET status = 'cancelled', message = '被新目标委托覆盖'
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
""", """,
(inst_id, mon_id), (inst_id, mon_id),
@@ -131,13 +178,21 @@ def upsert_target_monitor(
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO options_target_monitors INSERT INTO options_target_monitors
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status) (inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status)
VALUES (?, ?, ?, ?, ?, ?, 'active') VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
""", """,
(inst_id, underlying, opt_type, target_index, trade_id, sheets), (inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets),
) )
mon_id = int(cur.lastrowid) mon_id = int(cur.lastrowid)
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} out: dict[str, Any] = {
"ok": True,
"id": mon_id,
"inst_id": inst_id,
"target_index": tgt_store if tgt_store > 0 else None,
}
if rr_store is not None:
out["profit_rr"] = rr_store
return out
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
@@ -166,12 +221,19 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
tgt = _safe_float(r["target_index"])
rr = None
try:
rr = _safe_float(r["profit_rr"])
except (KeyError, IndexError):
rr = None
return { return {
"id": int(r["id"]), "id": int(r["id"]),
"inst_id": r["inst_id"], "inst_id": r["inst_id"],
"underlying": r["underlying"], "underlying": r["underlying"],
"opt_type": r["opt_type"], "opt_type": r["opt_type"],
"target_index": _safe_float(r["target_index"]), "target_index": tgt if tgt is not None and tgt > 0 else None,
"profit_rr": rr if rr is not None and rr > 0 else None,
"trade_id": r["trade_id"], "trade_id": r["trade_id"],
"sheets": r["sheets"], "sheets": r["sheets"],
"status": r["status"], "status": r["status"],
@@ -180,16 +242,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
} }
_TARGET_SELECT = (
"SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, "
"status, message, created_at FROM options_target_monitors"
)
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_target_tables(conn) ensure_target_tables(conn)
rows = conn.execute( rows = conn.execute(
""" f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC"
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'active'
ORDER BY id DESC
"""
).fetchall() ).fetchall()
return [_row_to_target(r) for r in rows] return [_row_to_target(r) for r in rows]
@@ -198,13 +260,7 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""已挂出平仓单、等待成交的目标(不再重复推送微信).""" """已挂出平仓单、等待成交的目标(不再重复推送微信)."""
ensure_target_tables(conn) ensure_target_tables(conn)
rows = conn.execute( rows = conn.execute(
""" f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC"
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'closing'
ORDER BY id DESC
"""
).fetchall() ).fetchall()
return [_row_to_target(r) for r in rows] return [_row_to_target(r) for r in rows]
@@ -286,23 +342,23 @@ def close_option_by_bid_depth(
inst_id, inst_id,
sheets=sheets, sheets=sheets,
require_recycle_gate=True, require_recycle_gate=True,
signal_note="目标位平仓", signal_note="盈亏比平仓",
) )
def _notify_target_close( def _notify_target_close(
cfg: dict[str, Any] | None, cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None, send_wechat: Callable[[str], None] | None,
*, *,
account_label: str, account_label: str,
inst_id: str, inst_id: str,
target: float, target: float | None,
idx: float, profit_rr: float | None,
idx: float | None,
result: dict[str, Any], result: dict[str, Any],
conn: Any = None, conn: Any = None,
) -> None: ) -> None:
"""目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" """目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
if result.get("fully_closed") or result.get("already_flat"): if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None: if cfg is not None:
try: try:
@@ -312,12 +368,13 @@ def _notify_target_close(
cfg, cfg,
conn, conn,
inst_id=inst_id, inst_id=inst_id,
reason="目标位平仓", reason="盈亏比平仓" if profit_rr else "目标位平仓",
sheets=result.get("submitted_sheets"), sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"), premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"), close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target, target_index=target,
trigger_idx=idx, trigger_idx=idx,
profit_rr=profit_rr,
) )
return return
except Exception: except Exception:
@@ -325,14 +382,20 @@ def _notify_target_close(
if not send_wechat: if not send_wechat:
return return
try: try:
if profit_rr is not None and profit_rr > 0:
rule = f"盈亏比×{profit_rr:g}"
elif target is not None:
rule = f"目标指数:{target:g}"
else:
rule = "目标委托"
send_wechat( send_wechat(
"\n".join( "\n".join(
[ [
"【OKX期权·目标位平仓】", "【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】",
f"账户:{account_label}", f"账户:{account_label}",
f"合约:{inst_id}", f"合约:{inst_id}",
f"目标指数:{target:g}", rule,
f"触发指数:{idx:g}", f"触发指数:{idx:g}" if idx is not None else "触发指数:—",
f"提交张数:{result.get('submitted_sheets') or ''}", f"提交张数:{result.get('submitted_sheets') or ''}",
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC", f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
@@ -354,18 +417,77 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
return False return False
def _monitor_should_close(
conn: sqlite3.Connection,
mon: dict[str, Any],
pos: dict[str, Any],
*,
bid_fn: Callable[[str], float | None] | None,
index_fn: Callable[[dict[str, Any]], float | None] | None,
) -> tuple[bool, float | None]:
"""返回 (是否触发, 当前指数)."""
inst_id = str(mon.get("inst_id") or "")
rr = _safe_float(mon.get("profit_rr"))
if index_fn is not None:
idx = index_fn(pos)
else:
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
if rr is not None and rr > 0:
premium = sum_open_premium_paid(conn, inst_id)
if premium is None or premium <= 0:
premium = _safe_float(pos.get("premium_paid"))
sheets = _safe_float(mon.get("sheets"))
if sheets is None or sheets <= 0:
sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos"))
ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01
bid = None
if bid_fn is not None:
try:
bid = bid_fn(inst_id)
except Exception:
bid = None
if bid is None:
bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid"))
preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {}
if bid is None:
bid = _safe_float(preview.get("bid") or preview.get("best_bid"))
if premium is None or sheets is None:
return False, idx
return (
profit_rr_hit(
premium=float(premium),
bid=bid,
sheets=float(sheets),
ct_mult=float(ct),
profit_rr=float(rr),
),
idx,
)
target = _safe_float(mon.get("target_index"))
if target is None or target <= 0 or idx is None:
return False, idx
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
return (
target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target),
idx,
)
def run_options_target_closes( def run_options_target_closes(
conn: sqlite3.Connection, conn: sqlite3.Connection,
positions: list[dict[str, Any]], positions: list[dict[str, Any]],
*, *,
close_fn: Callable[[str], dict[str, Any]], close_fn: Callable[[str], dict[str, Any]],
index_fn: Callable[[dict[str, Any]], float | None] | None = None, index_fn: Callable[[dict[str, Any]], float | None] | None = None,
bid_fn: Callable[[str], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None, send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权", account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None, cfg: dict[str, Any] | None = None,
) -> int: ) -> int:
""" """
扫描 active 目标委托;指数到位后限价平仓. 扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
未完全成交进入 closing,仅重试平仓不再推送. 未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数. 返回本次新触发(并推送)的条数.
@@ -412,7 +534,7 @@ def run_options_target_closes(
status="triggered", status="triggered",
trigger_idx=idx, trigger_idx=idx,
close_ord_id=result.get("close_ord_id"), close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成", message="盈亏比限价平仓完成",
) )
_commit_monitor(conn) _commit_monitor(conn)
continue continue
@@ -429,8 +551,7 @@ def run_options_target_closes(
triggered = 0 triggered = 0
for mon in list_active_targets(conn): for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "") inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index")) if not inst_id:
if not inst_id or target is None:
continue continue
if inst_id in hedge_managed: if inst_id in hedge_managed:
mark_monitor( mark_monitor(
@@ -444,17 +565,15 @@ def run_options_target_closes(
pos = pos_by_inst.get(inst_id) pos = pos_by_inst.get(inst_id)
if not pos: if not pos:
continue continue
if index_fn is not None: should, idx = _monitor_should_close(
idx = index_fn(pos) conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn
else: )
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) if not should:
if idx is None:
continue
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue continue
result = close_fn(inst_id) result = close_fn(inst_id)
rr = _safe_float(mon.get("profit_rr"))
target = _safe_float(mon.get("target_index"))
if result.get("already_flat"): if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn) _commit_monitor(conn)
@@ -472,15 +591,19 @@ def run_options_target_closes(
done = _result_fully_done(result) done = _result_fully_done(result)
status = "triggered" if done else "closing" status = "triggered" if done else "closing"
hit_msg = (
"盈亏比达标限价平仓"
if (rr is not None and rr > 0)
else "目标位触发限价平仓"
)
mark_monitor( mark_monitor(
conn, conn,
int(mon["id"]), int(mon["id"]),
status=status, status=status,
trigger_idx=idx, trigger_idx=idx,
close_ord_id=result.get("close_ord_id"), close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", message=hit_msg if done else "已挂买一限价,等待成交",
) )
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
_commit_monitor(conn) _commit_monitor(conn)
triggered += 1 triggered += 1
_notify_target_close( _notify_target_close(
@@ -489,6 +612,7 @@ def run_options_target_closes(
account_label=account_label, account_label=account_label,
inst_id=inst_id, inst_id=inst_id,
target=target, target=target,
profit_rr=rr,
idx=idx, idx=idx,
result=result, result=result,
conn=conn, conn=conn,
+7 -9
View File
@@ -107,17 +107,15 @@
</div> </div>
<div class="options-estimate-row"> <div class="options-estimate-row">
<div class="opt-est-main"> <div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label> <label class="btn-secondary opt-order-chip" for="opt-profit-rr" title="目标盈利=盈亏比×权利金;例2=赚满2倍权利金后全平">盈亏比</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓" <input type="number" id="opt-profit-rr" class="opt-target-idx" step="0.1" min="0.1" value="2" placeholder="默认2"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span> <span class="k">目标盈利</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span> <span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span> <span class="k">需回收</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span> <span id="opt-est-value" class="v" title="权利金+目标盈利"></span>
</div> </div>
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span> <span class="muted opt-est-note">按买一浮盈达盈亏比×权利金后限价全平;不达标等到期;无止损</span>
</div> </div>
<div class="form-row options-order-mode-row"> <div class="form-row options-order-mode-row">
<div class="opt-size-mode-bar"> <div class="opt-size-mode-bar">
@@ -324,4 +322,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=54"></script> <script src="/static/options_panel.js?v=60"></script>
@@ -416,4 +416,4 @@
</section> </section>
</div> </div>
<script src="/static/options_review.js?v=23"></script> <script src="/static/options_review.js?v=24"></script>
+21 -8
View File
@@ -2028,7 +2028,8 @@ async def _fetch_flask_json(
return parsed return parsed
return _parse_http_json_body(r) return _parse_http_json_body(r)
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} err = str(e)
return {"ok": False, "error": err, "msg": err}
async def _notify_instance_user_close( async def _notify_instance_user_close(
@@ -2567,8 +2568,8 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict
async def _fetch_exchange_flask_bundle( async def _fetch_exchange_flask_bundle(
client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None
) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]: ) -> tuple:
"""单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取.""" """单所 Flask:monitor / meta / price_snapshot / account / trades/today / options 并行拉取."""
caps = ex.get("capabilities") or [] caps = ex.get("capabilities") or []
tasks = [ tasks = [
_fetch_flask_json(client, ex, "/api/hub/monitor"), _fetch_flask_json(client, ex, "/api/hub/monitor"),
@@ -2576,6 +2577,7 @@ async def _fetch_exchange_flask_bundle(
] ]
has_flask = bool((ex.get("flask_url") or "").strip()) has_flask = bool((ex.get("flask_url") or "").strip())
day = (trading_day or "").strip() day = (trading_day or "").strip()
want_options = has_flask and "options" in caps
if has_flask: if has_flask:
tasks.extend( tasks.extend(
[ [
@@ -2592,15 +2594,26 @@ async def _fetch_exchange_flask_bundle(
params={"trading_day": day}, params={"trading_day": day},
) )
) )
if want_options:
tasks.append(_fetch_flask_json(client, ex, "/api/hub/options/snapshot"))
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
hub_mon = results[0] hub_mon = results[0]
meta = results[1] meta = results[1]
snap = results[2] if has_flask and len(results) > 2 else None idx = 2
account = results[3] if has_flask and len(results) > 3 else None snap = None
trades_today = results[4] if has_flask and day and len(results) > 4 else None account = None
trades_today = None
options_snap = None options_snap = None
if has_flask and "options" in caps: if has_flask:
options_snap = await _fetch_flask_json(client, ex, "/api/hub/options/snapshot") snap = results[idx]
idx += 1
account = results[idx]
idx += 1
if day:
trades_today = results[idx]
idx += 1
if want_options:
options_snap = results[idx]
key_prices = None key_prices = None
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
if want_prices and isinstance(snap, dict): if want_prices and isinstance(snap, dict):
+29 -10
View File
@@ -3930,6 +3930,19 @@
function renderOptionsTargetCell(target) { function renderOptionsTargetCell(target) {
if (!target) return "<td>—</td>"; if (!target) return "<td>—</td>";
const rr =
target.profit_rr != null
? Number(target.profit_rr)
: target.oo_profit_rr != null
? Number(target.oo_profit_rr)
: null;
if (rr != null && Number.isFinite(rr) && rr > 0) {
const txt = `盈亏比×${fmt(rr, 2)}`;
if (target.managed_by === "hedge_plan") {
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(txt)}</td>`;
}
return `<td class="hub-opt-target-cell is-on" title="盈亏比监控">${esc(txt)}</td>`;
}
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
if (target.managed_by === "hedge_plan") { if (target.managed_by === "hedge_plan") {
@@ -3942,7 +3955,7 @@
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>'; if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
const showPnl = showAccountPnlPref(); const showPnl = showAccountPnlPref();
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>'; let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>"; html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>盈亏比</th>";
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>"; if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
html += "</tr></thead><tbody>"; html += "</tr></thead><tbody>";
pos.forEach((p) => { pos.forEach((p) => {
@@ -4009,20 +4022,26 @@
function renderOptionsSectionBody(row, opts) { function renderOptionsSectionBody(row, opts) {
const options = opts || {}; const options = opts || {};
const layout = options.layout || "table"; const layout = options.layout || "table";
const opt = row.options || {}; const caps = Array.isArray(row.capabilities) ? row.capabilities : [];
const wantsOptions = caps.indexOf("options") >= 0;
const opt = row.options;
let html = ""; let html = "";
if (opt.enabled === false) { if (wantsOptions && (opt == null || typeof opt !== "object")) {
html += renderOptionsAccountStatRow(opt); html += '<div class="section-title hub-options-title">期权持仓</div>';
html += `<div class="err">期权数据不可用</div>`;
} else if ((opt || {}).enabled === false) {
html += renderOptionsAccountStatRow(opt || {});
html += '<div class="section-title hub-options-title">期权持仓</div>'; html += '<div class="section-title hub-options-title">期权持仓</div>';
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>'; html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
} else if (opt.ok === false) { } else if ((opt || {}).ok === false) {
html += renderOptionsAccountStatRow(opt); html += renderOptionsAccountStatRow(opt || {});
html += '<div class="section-title hub-options-title">期权持仓</div>'; html += '<div class="section-title hub-options-title">期权持仓</div>';
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`; html += `<div class="err">${esc((opt && (opt.msg || opt.error)) || "期权数据不可用")}</div>`;
} else { } else {
const pos = Array.isArray(opt.positions) ? opt.positions : []; const optSafe = opt || {};
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : []; const pos = Array.isArray(optSafe.positions) ? optSafe.positions : [];
html += renderOptionsAccountStatRow(opt); const targets = Array.isArray(optSafe.target_monitors) ? optSafe.target_monitors : [];
html += renderOptionsAccountStatRow(optSafe);
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`; html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
html += html +=
layout === "cards" layout === "cards"
+3 -3
View File
@@ -115,7 +115,7 @@
<span class="plan-radio-row" id="plan-create-direction"></span> <span class="plan-radio-row" id="plan-create-direction"></span>
</label> </label>
<label class="plan-field"> <label class="plan-field">
<span>目标位</span> <span>盈亏比</span>
<input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /> <input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label> </label>
<label class="plan-field"> <label class="plan-field">
@@ -1765,8 +1765,8 @@
<script src="/assets/ai_review_render.js?v=3"></script> <script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script> <script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/options_expiry_countdown.js?v=1"></script> <script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=4"></script> <script src="/assets/options_position_cards.js?v=5"></script>
<script src="/assets/backup.js?v=1"></script> <script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260807-opt-float"></script> <script src="/assets/app.js?v=20260811-opt-rr"></script>
</body> </body>
</html> </html>
+21 -7
View File
@@ -102,8 +102,7 @@ class TestHedgePlanCalc(unittest.TestCase):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview( p = build_options_options_preview(
target_price_up=3500, profit_rr=2,
target_price_down=3000,
index_px=3200, index_px=3200,
leg_a=a, leg_a=a,
leg_b=b, leg_b=b,
@@ -111,11 +110,11 @@ class TestHedgePlanCalc(unittest.TestCase):
self.assertEqual(p["summary"]["premium_paid"], 10) self.assertEqual(p["summary"]["premium_paid"], 10)
self.assertTrue(p["summary"]["expiry_is_loss"]) self.assertTrue(p["summary"]["expiry_is_loss"])
self.assertEqual(p["summary"]["rr_risk_premium"], 10) self.assertEqual(p["summary"]["rr_risk_premium"], 10)
self.assertIsNotNone(p["summary"]["rr_at_up"]) self.assertEqual(p["summary"]["oo_profit_rr"], 2)
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4) self.assertAlmostEqual(p["summary"]["target_profit"], 20.0, places=4)
self.assertEqual(len(p["scenarios"]), 4) self.assertEqual(len(p["scenarios"]), 3)
self.assertEqual(p["scenarios"][0]["id"], "target_up") self.assertEqual(p["scenarios"][0]["id"], "rr_target")
self.assertEqual(p["scenarios"][1]["id"], "target_down") self.assertEqual(p["scenarios"][1]["id"], "expiry_flat")
def test_oo_legacy_single_target_still_works(self): def test_oo_legacy_single_target_still_works(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
@@ -124,6 +123,21 @@ class TestHedgePlanCalc(unittest.TestCase):
self.assertEqual(p["target_price_up"], 3500) self.assertEqual(p["target_price_up"], 3500)
self.assertEqual(p["target_price_down"], 3500) self.assertEqual(p["target_price_down"], 3500)
def test_oo_legacy_up_down_rr_fields(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview(
target_price_up=3500,
target_price_down=3000,
index_px=3200,
leg_a=a,
leg_b=b,
)
self.assertIsNotNone(p["summary"]["rr_at_up"])
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4)
self.assertEqual(p["scenarios"][0]["id"], "target_up")
self.assertEqual(p["scenarios"][1]["id"], "target_down")
def test_perp_short_pnl(self): def test_perp_short_pnl(self):
self.assertEqual( self.assertEqual(
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1), perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
+26
View File
@@ -155,6 +155,32 @@ class TestHedgeHistoryStats(unittest.TestCase):
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800) self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
def test_active_options_targets_rr_mode_marks_managed(self):
conn = _mem()
pid = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "active",
"underlying": "ETH",
"oo_profit_rr": 2,
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_a",
"inst_id": "ETH-USD_UM-260719-1890-C",
"opt_type": "C",
"status": "open",
},
)
targets = active_options_targets_by_inst(conn)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["oo_profit_rr"], 2.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,55 @@
"""期权合约列表缓存与限频退避."""
from __future__ import annotations
import time
import unittest
from unittest.mock import MagicMock, patch
from lib.exchange import okx_options_lib as m
class FetchOptionInstrumentsCacheTests(unittest.TestCase):
def setUp(self):
m.invalidate_option_instruments_cache()
def tearDown(self):
m.invalidate_option_instruments_cache()
def test_cache_hit_skips_second_api_call(self):
ex = MagicMock()
ex.public_get_public_instruments.return_value = {
"data": [
{
"instId": "ETH-USD_UM-260812-2000-C",
"state": "live",
"expTime": "9999999999999",
}
]
}
a = m.fetch_option_instruments(ex, "ETH-USD_UM")
b = m.fetch_option_instruments(ex, "ETH-USD_UM")
self.assertEqual(len(a), 1)
self.assertEqual(len(b), 1)
self.assertEqual(ex.public_get_public_instruments.call_count, 1)
@patch("lib.exchange.okx_options_lib.time.sleep", return_value=None)
def test_rate_limit_falls_back_to_stale_cache(self, _sleep):
ex = MagicMock()
ex.public_get_public_instruments.return_value = {
"data": [{"instId": "ETH-USD_UM-260812-2000-C", "state": "live"}]
}
first = m.fetch_option_instruments(ex, "ETH-USD_UM")
self.assertEqual(len(first), 1)
# 过期 TTL,但仍在 stale 窗口
with m._INSTRUMENTS_CACHE_LOCK:
m._INSTRUMENTS_CACHE["ETH-USD_UM"]["updated_at"] = time.time() - 120
ex.public_get_public_instruments.side_effect = Exception(
'okx {"msg":"Too Many Requests","code":"50011"}'
)
second = m.fetch_option_instruments(ex, "ETH-USD_UM")
self.assertEqual(len(second), 1)
self.assertEqual(second[0]["instId"], "ETH-USD_UM-260812-2000-C")
if __name__ == "__main__":
unittest.main()
+63 -6
View File
@@ -1,4 +1,4 @@
"""期权目标委托单元测试.""" """期权目标委托单元测试(盈亏比 + 旧指数兼容)."""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
@@ -8,6 +8,7 @@ from lib.options.options_target_lib import (
ensure_target_tables, ensure_target_tables,
list_active_targets, list_active_targets,
list_closing_targets, list_closing_targets,
profit_rr_hit,
run_options_target_closes, run_options_target_closes,
target_hit, target_hit,
upsert_target_monitor, upsert_target_monitor,
@@ -21,7 +22,67 @@ class OptionsTargetLibTests(unittest.TestCase):
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850)) self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850)) self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
def test_upsert_and_trigger_close(self): def test_profit_rr_hit(self):
# premium=10, rr=2 → need pnl≥20 → recycle≥30 → bid*sheets*ct ≥30
self.assertTrue(
profit_rr_hit(premium=10, bid=30, sheets=1, ct_mult=1, profit_rr=2)
)
self.assertFalse(
profit_rr_hit(premium=10, bid=29.9, sheets=1, ct_mult=1, profit_rr=2)
)
self.assertFalse(
profit_rr_hit(premium=10, bid=None, sheets=1, ct_mult=1, profit_rr=2)
)
def test_upsert_rr_and_trigger_close(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
ensure_target_tables(conn)
out = upsert_target_monitor(
conn,
inst_id="ETH-USD_UM-260717-1900-C",
profit_rr=2,
opt_type="C",
sheets=1,
)
self.assertTrue(out["ok"])
self.assertEqual(out.get("profit_rr"), 2.0)
self.assertEqual(len(list_active_targets(conn)), 1)
closed = []
def close_fn(inst_id: str):
closed.append(inst_id)
return {
"ok": True,
"submitted_sheets": 1,
"premium_received": 30.0,
"close_ord_id": "oid1",
"fully_closed": True,
"remaining_sheets": 0,
}
# bid=30, ct=1 → pnl=20 ≥ 2*10; premium 来自持仓字段
n = run_options_target_closes(
conn,
[
{
"inst_id": "ETH-USD_UM-260717-1900-C",
"idx_px": 1885,
"opt_type": "C",
"pos": 1,
"ct_mult": 1,
"premium_paid": 10,
}
],
close_fn=close_fn,
bid_fn=lambda _i: 30.0,
)
self.assertEqual(n, 1)
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
self.assertEqual(len(list_active_targets(conn)), 0)
def test_upsert_and_trigger_close_legacy_index(self):
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
ensure_target_tables(conn) ensure_target_tables(conn)
@@ -107,7 +168,6 @@ class OptionsTargetLibTests(unittest.TestCase):
self.assertEqual(len(list_active_targets(conn)), 0) self.assertEqual(len(list_active_targets(conn)), 0)
self.assertEqual(len(list_closing_targets(conn)), 1) self.assertEqual(len(list_closing_targets(conn)), 1)
# 模拟后续 sync 异常也不会再推:closing 重试静默
n2 = run_options_target_closes( n2 = run_options_target_closes(
conn, conn,
pos, pos,
@@ -120,7 +180,6 @@ class OptionsTargetLibTests(unittest.TestCase):
self.assertEqual(len(list_closing_targets(conn)), 0) self.assertEqual(len(list_closing_targets(conn)), 0)
def test_commit_before_wechat_survives_later_rollback(self): def test_commit_before_wechat_survives_later_rollback(self):
"""状态在推送前已 commit,外层异常回滚不应让委托回到 active."""
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
ensure_target_tables(conn) ensure_target_tables(conn)
@@ -149,12 +208,10 @@ class OptionsTargetLibTests(unittest.TestCase):
close_fn=close_fn, close_fn=close_fn,
send_wechat=notices.append, send_wechat=notices.append,
) )
# 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit
conn.rollback() conn.rollback()
self.assertEqual(len(notices), 1) self.assertEqual(len(notices), 1)
self.assertEqual(len(list_active_targets(conn)), 0) self.assertEqual(len(list_active_targets(conn)), 0)
# 下一轮不应再次触发推送
n2 = run_options_target_closes( n2 = run_options_target_closes(
conn, conn,
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}], [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],