对齐币本位期权:现货缓冲开仓、页头 ETH/BTC 余额与默认 coin 模式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-20 15:01:19 +08:00
parent 432adfb602
commit c8688a11ae
26 changed files with 6218 additions and 3758 deletions
+10
View File
@@ -113,6 +113,16 @@ OKX_OPTIONS_ENABLED=true
# OKX_OPTIONS_API_SECRET= # OKX_OPTIONS_API_SECRET=
# OKX_OPTIONS_API_PASSPHRASE= # OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_BUDGET_BUFFER=0.95
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 # 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
+55 -11
View File
@@ -5041,6 +5041,7 @@ def render_main_page(page="options", embed_mode=None):
show_perp_funds_enabled, show_perp_funds_enabled,
total_funds_usdt, total_funds_usdt,
trade_records_summary, trade_records_summary,
trading_account_label,
) )
plan = embed_render_plan(page, embed_mode) plan = embed_render_plan(page, embed_mode)
@@ -5054,6 +5055,11 @@ def render_main_page(page="options", embed_mode=None):
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
_sim_mode_for_header = False _sim_mode_for_header = False
_exchange_display_for_header = EXCHANGE_DISPLAY_NAME _exchange_display_for_header = EXCHANGE_DISPLAY_NAME
try: try:
@@ -5073,16 +5079,28 @@ def render_main_page(page="options", embed_mode=None):
and (getattr(exchange_options, "apiKey", None) or _sim_mode_for_header) and (getattr(exchange_options, "apiKey", None) or _sim_mode_for_header)
): ):
try: try:
from lib.exchange.okx_options_lib import options_header_balances from lib.exchange.okx_options_lib import options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( _op = options_header_balance_pack(exchange_options)
exchange_options options_trading_usdc = _op.get("trading_usdc")
) options_funding_usdc = _op.get("funding_usdc")
options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
except Exception: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
key_list = ( key_list = (
conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
@@ -5205,7 +5223,7 @@ def render_main_page(page="options", embed_mode=None):
_okx_trade_mode = get_okx_trade_mode() _okx_trade_mode = get_okx_trade_mode()
_hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options") _hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options")
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx") _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin")
template_ctx = dict( template_ctx = dict(
page=page, page=page,
key=key_list, key=key_list,
@@ -5228,6 +5246,11 @@ def render_main_page(page="options", embed_mode=None):
options_funding_usdt=options_funding_usdt, options_funding_usdt=options_funding_usdt,
options_trading_usdc=options_trading_usdc, options_trading_usdc=options_trading_usdc,
options_trading_usdt=options_trading_usdt, options_trading_usdt=options_trading_usdt,
options_funding_eth=options_funding_eth,
options_trading_eth=options_trading_eth,
options_trading_btc=options_trading_btc,
options_margin_mode=options_margin_mode,
options_underly=options_underly,
trading_day=trading_day, trading_day=trading_day,
daily_start_capital=DAILY_START_CAPITAL, daily_start_capital=DAILY_START_CAPITAL,
current_capital=current_capital, current_capital=current_capital,
@@ -5472,19 +5495,35 @@ def api_account_snapshot():
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
if OKX_OPTIONS_ENABLED and exchange_options.apiKey: if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try: try:
from lib.exchange.okx_options_lib import options_header_balances from lib.exchange.okx_options_lib import options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( _op = options_header_balance_pack(exchange_options, force=force_refresh)
exchange_options, options_trading_usdc = _op.get("trading_usdc")
force=force_refresh, options_funding_usdc = _op.get("funding_usdc")
) options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
except Exception: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
from lib.trade.trade_labels_lib import count_position_limit_active_monitors from lib.trade.trade_labels_lib import count_position_limit_active_monitors
@@ -5567,7 +5606,7 @@ def api_account_snapshot():
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
except Exception: except Exception:
options_unrealized_pnl = None options_unrealized_pnl = None
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx") _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin")
try: try:
from lib.sim.mode_lib import exchange_mode_label as _ex_mode_label from lib.sim.mode_lib import exchange_mode_label as _ex_mode_label
from lib.sim.mode_lib import is_sim_mode as _is_sim from lib.sim.mode_lib import is_sim_mode as _is_sim
@@ -5584,6 +5623,11 @@ def api_account_snapshot():
"options_funding_usdt": options_funding_usdt, "options_funding_usdt": options_funding_usdt,
"options_trading_usdc": options_trading_usdc, "options_trading_usdc": options_trading_usdc,
"options_trading_usdt": options_trading_usdt, "options_trading_usdt": options_trading_usdt,
"options_funding_eth": options_funding_eth,
"options_trading_eth": options_trading_eth,
"options_trading_btc": options_trading_btc,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
"total_funds": total_funds_usdt( "total_funds": total_funds_usdt(
funding_usdt if _show_perp_funds else None, funding_usdt if _show_perp_funds else None,
current_capital if _show_perp_funds else None, current_capital if _show_perp_funds else None,
+171 -35
View File
@@ -868,9 +868,46 @@
return Number(v).toFixed(2); return Number(v).toFixed(2);
} }
function fmtClosePreview(preview, premiumPaid) { function posPremiumCcy(p) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
}
if (isCoinMarginMode && isCoinMarginMode()) {
return ((inst.split("-")[0]) || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function isCoinPos(p) {
return posPremiumCcy(p) !== "USDC";
}
function fmtPremiumAmt(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
let s = n.toFixed(8).replace(/\.?0+$/, "");
return s || "0";
}
return fmtUsdc(n);
}
function fmtPremiumAmtSigned(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return sign + fmtPremiumAmt(n, ccy) + " " + (String(ccy || "USDC").toUpperCase());
}
function fmtClosePreview(preview, premiumPaid, p) {
if (!preview || preview.total_received == null) return "—"; if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received); const ccy = posPremiumCcy(p);
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
let cls = ""; let cls = "";
const prem = Number(premiumPaid); const prem = Number(premiumPaid);
const recv = Number(preview.total_received); const recv = Number(preview.total_received);
@@ -878,12 +915,13 @@
if (recv > prem) cls = " pos-pnl-profit"; if (recv > prem) cls = " pos-pnl-profit";
else if (recv < prem) cls = " pos-pnl-loss"; else if (recv < prem) cls = " pos-pnl-loss";
} }
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>"; return '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
} }
function fmtClosePreviewText(preview) { function fmtClosePreviewText(preview, p) {
if (!preview || preview.total_received == null) return "—"; if (!preview || preview.total_received == null) return "—";
let text = fmt(preview.total_received, 4) + " USDC"; const ccy = posPremiumCcy(p);
let text = fmtPremiumAmt(preview.total_received, ccy) + " " + ccy;
if (preview.covered_sheets != null) { if (preview.covered_sheets != null) {
text += " · 覆盖 " + preview.covered_sheets + "张"; text += " · 覆盖 " + preview.covered_sheets + "张";
} }
@@ -893,11 +931,13 @@
return text; return text;
} }
function fmtPreviewLevels(preview) { function fmtPreviewLevels(preview, p) {
const levels = (preview && preview.levels) || []; const levels = (preview && preview.levels) || [];
if (!levels.length) return "暂无可用买盘深度"; if (!levels.length) return "暂无可用买盘深度";
const ccy = posPremiumCcy(p);
return levels.map(function (x) { return levels.map(function (x) {
return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " + fmt(x.received, 4) + " USDC"; return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " +
fmtPremiumAmt(x.received, ccy) + " " + ccy;
}).join("\n"); }).join("\n");
} }
@@ -926,18 +966,29 @@
return Math.round(intrinsic * amt * 100) / 100; return Math.round(intrinsic * amt * 100) / 100;
} }
function estimateExpiryProfit(optType, strike, targetIdx, ethAmount, totalPremium) { function estimateExpiryProfit(optType, strike, targetIdx, ethAmount, totalPremium, indexPx) {
const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount);
const prem = Number(totalPremium); let prem = Number(totalPremium);
if (value == null || !Number.isFinite(prem)) return null; if (value == null || !Number.isFinite(prem)) return null;
// 币本位权利金为币:与到期美元实值对比时先×指数
if (isCoinMarginMode()) {
const idx = Number(indexPx);
if (!Number.isFinite(idx) || idx <= 0) return null;
prem = prem * idx;
}
return Math.round((value - prem) * 100) / 100; return Math.round((value - prem) * 100) / 100;
} }
/** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */ /** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */
function estimateProfitRr(profit, totalPremium) { function estimateProfitRr(profit, totalPremium, indexPx) {
const pnl = Number(profit); const pnl = Number(profit);
const prem = Number(totalPremium); let prem = Number(totalPremium);
if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null; if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null;
if (isCoinMarginMode()) {
const idx = Number(indexPx);
if (!Number.isFinite(idx) || idx <= 0) return null;
prem = prem * idx;
}
return Math.round((pnl / prem) * 100) / 100; return Math.round((pnl / prem) * 100) / 100;
} }
@@ -946,6 +997,11 @@
return Number(v).toFixed(2); return Number(v).toFixed(2);
} }
function isCoinMarginMode() {
const ch = state.chain || {};
return ch.margin_mode === "coin" || ch.options_margin_mode === "coin";
}
function calcContractLeverage(indexPx, ethAmount, totalPremium) { function calcContractLeverage(indexPx, ethAmount, totalPremium) {
if (indexPx == null || ethAmount == null || totalPremium == null) return null; if (indexPx == null || ethAmount == null || totalPremium == null) return null;
const idx = Number(indexPx); const idx = Number(indexPx);
@@ -954,6 +1010,10 @@
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) { if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
return null; return null;
} }
// USDC: 名义(U)/权利金(U)=指数×币数/权利金; 币本位权利金为币: 名义(U)/(权利金币×指数)=币数/权利金币
if (isCoinMarginMode()) {
return Math.round((amt / prem) * 10) / 10;
}
return Math.round((idx * amt) / prem * 10) / 10; return Math.round((idx * amt) / prem * 10) / 10;
} }
@@ -962,12 +1022,15 @@
return "约 " + Number(v).toFixed(1) + "×"; return "约 " + Number(v).toFixed(1) + "×";
} }
/** 链上展示:指数 ÷ 卖一(每1币). */ /** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 1÷卖一. */
function calcAskLeverage(indexPx, askPx) { function calcAskLeverage(indexPx, askPx) {
if (indexPx == null || askPx == null) return null; if (indexPx == null || askPx == null) return null;
const idx = Number(indexPx); const idx = Number(indexPx);
const ask = Number(askPx); const ask = Number(askPx);
if (!Number.isFinite(idx) || !Number.isFinite(ask) || ask <= 0) return null; if (!Number.isFinite(idx) || !Number.isFinite(ask) || ask <= 0) return null;
if (isCoinMarginMode()) {
return Math.round((1 / ask) * 10) / 10;
}
return Math.round((idx / ask) * 10) / 10; return Math.round((idx / ask) * 10) / 10;
} }
@@ -1021,18 +1084,22 @@
} }
} else { } else {
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium, q.index_px);
const rr = estimateProfitRr(profit, premium); const rr = estimateProfitRr(profit, premium, q.index_px);
if (value == null || Number.isNaN(value)) { if (value == null || Number.isNaN(value)) {
valueEl.textContent = "—"; valueEl.textContent = "—";
} else { } else {
valueEl.textContent = fmtUsdc(value) + " USDC"; valueEl.textContent = isCoinMarginMode()
? (fmtUsdc(value) + " U(估)")
: (fmtUsdc(value) + " USDC");
} }
if (profit == null || Number.isNaN(profit)) { if (profit == null || Number.isNaN(profit)) {
profitEl.textContent = "—"; profitEl.textContent = "—";
profitEl.className = "v"; profitEl.className = "v";
} else { } else {
profitEl.textContent = fmtUsdcSigned(profit); profitEl.textContent = isCoinMarginMode()
? ((Number(profit) > 0 ? "+" : "") + fmtUsdc(profit) + " U(估)")
: fmtUsdcSigned(profit);
profitEl.className = "v " + pnlCls(profit); profitEl.className = "v " + pnlCls(profit);
} }
if (rrEl) { if (rrEl) {
@@ -1252,8 +1319,12 @@
document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—"; document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—";
document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—"; document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—";
updateUnderlyingLabel(); updateUnderlyingLabel();
const coinMode = isCoinMarginMode() || (d && d.options_margin_mode === "coin");
const premCcy = (sz.premium_ccy || (coinMode ? ((d.inst_id || "").split("-")[0] || "ETH") : "USDC")).toUpperCase();
document.getElementById("opt-order-premium").textContent = document.getElementById("opt-order-premium").textContent =
canOpen && sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; canOpen && sz.total_premium != null
? (coinMode ? (fmt(sz.total_premium, 6) + " " + premCcy) : (fmtUsdc(sz.total_premium) + " USDC"))
: "—";
const beEl = document.getElementById("opt-order-expiry-be"); const beEl = document.getElementById("opt-order-expiry-be");
const distEl = document.getElementById("opt-order-dist-be"); const distEl = document.getElementById("opt-order-dist-be");
if (beEl) { if (beEl) {
@@ -1266,7 +1337,21 @@
const openBtn = document.getElementById("opt-open-btn"); const openBtn = document.getElementById("opt-open-btn");
if (openBtn) { if (openBtn) {
openBtn.disabled = !canOpen || sz.ok === false; openBtn.disabled = !canOpen || sz.ok === false;
openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; const bud = (d && d.coin_budget && d.coin_budget.budget_usdt) ||
(state.chain && state.chain.coin_budget && state.chain.coin_budget.budget_usdt);
if (!canOpen || sz.ok === false) {
openBtn.textContent = coinMode
? ((d && d.msg) || (sz && sz.msg) || "无法开仓")
: "暂无卖一深度,无法开仓";
} else if (coinMode) {
const buyU = sz && sz.buy_usdt != null ? sz.buy_usdt : null;
openBtn.textContent =
buyU != null
? ("买币并开仓(约 " + Number(buyU).toFixed(2) + " USDT)")
: (bud != null ? "买币并开仓(预算上限 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一");
} else {
openBtn.textContent = "限价买入 @ 卖一";
}
} }
const msgEl = document.getElementById("opt-order-msg"); const msgEl = document.getElementById("opt-order-msg");
if (!d.ok) { if (!d.ok) {
@@ -1288,6 +1373,9 @@
} else if (sz.ask_depth_capped) { } else if (sz.ask_depth_capped) {
msgEl.textContent = sz.msg || "已按卖一深度限制张数"; msgEl.textContent = sz.msg || "已按卖一深度限制张数";
msgEl.classList.remove("opt-error"); msgEl.classList.remove("opt-error");
} else if (coinMode && sz.est_note) {
msgEl.textContent = sz.est_note;
msgEl.classList.remove("opt-error");
} else { } else {
msgEl.textContent = ""; msgEl.textContent = "";
msgEl.classList.remove("opt-error"); msgEl.classList.remove("opt-error");
@@ -1483,8 +1571,25 @@
return false; return false;
} finally { } finally {
const latest = state.orderQuote; const latest = state.orderQuote;
const coinMode = isCoinMarginMode() || (latest && latest.options_margin_mode === "coin");
const bud = (latest && latest.coin_budget && latest.coin_budget.budget_usdt) ||
(state.chain && state.chain.coin_budget && state.chain.coin_budget.budget_usdt);
btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false));
btn.textContent = (latest && latest.can_open) ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; if (!(latest && latest.can_open) || (latest && latest.sizing && latest.sizing.ok === false)) {
btn.textContent = coinMode
? ((latest && (latest.msg || (latest.sizing && latest.sizing.msg))) || "无法开仓")
: "暂无卖一深度,无法开仓";
} else if (coinMode) {
const buyU = latest && latest.sizing && latest.sizing.buy_usdt != null
? latest.sizing.buy_usdt
: null;
btn.textContent =
buyU != null
? ("买币并开仓(约 " + Number(buyU).toFixed(2) + " USDT)")
: (bud != null ? "买币并开仓(预算上限 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一");
} else {
btn.textContent = "限价买入 @ 卖一";
}
} }
} }
@@ -1498,10 +1603,18 @@
const closePreview = p.close_preview || {}; const closePreview = p.close_preview || {};
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
const tickSz = p.tick_sz; const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); const premCcy = posPremiumCcy(p);
const coinPos = isCoinPos(p);
const premTxt = fmtDisplay(
p.premium_paid_fmt,
p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : null
);
// 优先用数值+tick 现算,避免接口侧 mark_px_fmt 带着浮点毛刺直出 // 优先用数值+tick 现算,避免接口侧 mark_px_fmt 带着浮点毛刺直出
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
const netTxt = closePreview.bid_invalid || net == null
? "—"
: (fmtPremiumAmt(net, premCcy) + (coinPos ? (" " + premCcy) : ""));
return ( return (
'<div class="pos-card-head">' + '<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' + '<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
@@ -1520,21 +1633,21 @@
: "") + : "") +
"</div>" + "</div>" +
'<div class="pos-grid">' + '<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" + '<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" + '<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" + '<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" + '<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' + '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" + netTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' + '<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" + '<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + '<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid (closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>' ? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, p.premium_paid)) + "</span></div>" + : fmtClosePreview(closePreview, p.premium_paid, p)) + "</span></div>" +
"</div>" + "</div>" +
(function () { (function () {
const hint = closeGateHint(closePreview); const hint = closeGateHint(closePreview);
@@ -1571,6 +1684,7 @@
); );
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle")); const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle; const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p);
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return ( return (
@@ -1587,7 +1701,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" + '<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(enabled (enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : "")) ? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
: "开启后自选倍数;达标按买一限价平;可随时关闭") + : "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" + "</span>" +
"</div>" "</div>"
@@ -1602,16 +1716,23 @@
return null; return null;
} }
function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid, indexPx, p) {
const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount);
const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid, indexPx);
const rr = estimateProfitRr(profit, premiumPaid); const rr = estimateProfitRr(profit, premiumPaid, indexPx);
if (value == null && profit == null && rr == null) return ""; if (value == null && profit == null && rr == null) return "";
const coinPos = isCoinPos(p);
const valueUnit = coinPos ? " U(估)" : " USDC";
const profitTxt = profit == null
? "—"
: (coinPos
? ((Number(profit) > 0 ? "+" : "") + fmtUsdc(profit) + " U(估)")
: fmtUsdcSigned(profit));
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">' +
(value == null ? "—" : fmtUsdc(value) + " USDC") + "</span></span>"; (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</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 ' + pnlCls(profit) + '">' +
(profit == null ? "—" : fmtUsdcSigned(profit)) + "</span></span>"; profitTxt + "</span></span>";
html += '<span class="opt-target-est-item"><span class="k">盈亏比</span><span class="v ' + pnlCls(rr) + '">' + html += '<span class="opt-target-est-item"><span class="k">盈亏比</span><span class="v ' + pnlCls(rr) + '">' +
(rr == null ? "—" : fmtProfitRr(rr)) + "</span></span>"; (rr == null ? "—" : fmtProfitRr(rr)) + "</span></span>";
html += "</span>"; html += "</span>";
@@ -1658,7 +1779,7 @@
const ethAmt = posEthAmount(p); const ethAmt = posEthAmount(p);
const prem = p.premium_paid; const prem = p.premium_paid;
const estHtml = armed const estHtml = armed
? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) ? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem, p.idx_px, p)
: '<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="' + inst + '"' +
@@ -1666,6 +1787,8 @@
' data-strike="' + (p.strike != null ? p.strike : "") + '"' + ' data-strike="' + (p.strike != null ? p.strike : "") + '"' +
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' + ' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' +
' data-prem="' + (prem != null ? prem : "") + '"' + ' data-prem="' + (prem != null ? prem : "") + '"' +
' data-idx="' + (p.idx_px != null ? p.idx_px : "") + '"' +
' data-prem-ccy="' + posPremiumCcy(p) + '"' +
' data-armed-target="' + (armed ? tgt : "") + '">' + ' data-armed-target="' + (armed ? tgt : "") + '">' +
'<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="' + inst + '" step="0.1" min="0" placeholder="监控目标指数" value="' +
@@ -1701,7 +1824,9 @@
row.getAttribute("data-strike"), row.getAttribute("data-strike"),
targetRaw, targetRaw,
row.getAttribute("data-eth"), row.getAttribute("data-eth"),
row.getAttribute("data-prem") row.getAttribute("data-prem"),
row.getAttribute("data-idx"),
{ premium_ccy: row.getAttribute("data-prem-ccy"), margin_mode: row.getAttribute("data-prem-ccy") === "USDC" ? "usdc" : "coin", inst_id: row.getAttribute("data-inst") }
); );
if (!html) { if (!html) {
est.className = "opt-target-est opt-target-est--idle"; est.className = "opt-target-est opt-target-est--idle";
@@ -1747,7 +1872,8 @@
(expAttr (expAttr
? '<span class="opt-pos-bar-cd">到期 <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>' ? '<span class="opt-pos-bar-cd">到期 <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
: "") + : "") +
'<span class="opt-pos-bar-pnl ' + uplCls + '">' + (net == null ? "—" : fmt(net, 2) + " USDC") + "</span>" + '<span class="opt-pos-bar-pnl ' + uplCls + '">' +
(net == null ? "—" : (fmtPremiumAmt(net, posPremiumCcy(p)) + " " + posPremiumCcy(p))) + "</span>" +
'<span class="opt-pos-bar-roi ' + uplCls + '">' + '<span class="opt-pos-bar-roi ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span>" + (roi == null ? "—" : fmt(roi, 2) + "%") + "</span>" +
"</span>" + "</span>" +
@@ -2003,12 +2129,20 @@
return; return;
} }
const lv = (preview.levels && preview.levels[0]) || {}; const lv = (preview.levels && preview.levels[0]) || {};
const posLike = {
inst_id: inst,
premium_ccy: q.premium_ccy || (preview.close_gate && preview.close_gate.premium_ccy) || null,
margin_mode: q.options_margin_mode || q.margin_mode || null,
};
const premCcy = posPremiumCcy(posLike);
const msg = [ const msg = [
"按买一限价卖出本轮可平张数?", "按买一限价卖出本轮可平张数?",
"合约: " + inst, "合约: " + inst,
"锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张", "锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张",
"预计收回: " + fmtClosePreviewText(preview), "预计收回: " + fmtClosePreviewText(preview, posLike),
preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", preview.estimated_pnl != null
? ("预估盈亏: " + fmtPremiumAmtSigned(preview.estimated_pnl, premCcy))
: "",
preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : "" preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : ""
].filter(function (x) { return x !== ""; }).join("\n"); ].filter(function (x) { return x !== ""; }).join("\n");
if (!confirm(msg)) return; if (!confirm(msg)) return;
@@ -2022,7 +2156,9 @@
if (r.ok) { if (r.ok) {
let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张"; let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张";
if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px; if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px;
if (r.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC"; if (r.premium_received != null) {
okMsg += "\n预估收回: " + fmtPremiumAmt(r.premium_received, premCcy) + " " + premCcy;
}
if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)"; if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)";
if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason; if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason;
alert(okMsg); alert(okMsg);
+45 -10
View File
@@ -34,6 +34,28 @@
return Number(v).toFixed(2); return Number(v).toFixed(2);
} }
function posPremiumCcy(p) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function fmtPremiumAmt(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
let s = n.toFixed(8).replace(/\.?0+$/, "");
return s || "0";
}
return fmtUsdc(n);
}
function optTypeLabel(t) { function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
} }
@@ -136,9 +158,10 @@
return (net / prem) * 100; return (net / prem) * 100;
} }
function fmtClosePreview(preview, premiumPaid, hub) { function fmtClosePreview(preview, premiumPaid, hub, p) {
if (!preview || preview.total_received == null) return "—"; if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received); const ccy = posPremiumCcy(p);
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
let cls = ""; let cls = "";
const prem = Number(premiumPaid); const prem = Number(premiumPaid);
const recv = Number(preview.total_received); const recv = Number(preview.total_received);
@@ -146,7 +169,7 @@
if (recv > prem) cls = " " + pnlCls(1, hub); if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub); else if (recv < prem) cls = " " + pnlCls(-1, hub);
} }
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>"; return '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
} }
function expiryCdHtml(expMs) { function expiryCdHtml(expMs) {
@@ -168,7 +191,11 @@
const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {}; const closePreview = p.close_preview || {};
const tickSz = p.tick_sz; const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); const premCcy = posPremiumCcy(p);
const premTxt = fmtDisplay(
p.premium_paid_fmt,
p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : null
);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = ""; let headActions = "";
@@ -182,7 +209,7 @@
const pnlCells = hidePnl const pnlCells = hidePnl
? "" ? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' + : '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(net == null ? "—" : fmt(net, 2)) + "</span></div>" + (net == null ? "—" : (fmtPremiumAmt(net, premCcy) + (premCcy !== "USDC" ? (" " + premCcy) : ""))) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' + '<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>"; (roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return ( return (
@@ -202,7 +229,7 @@
: "") + : "") +
"</div>" + "</div>" +
'<div class="pos-grid">' + '<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" + '<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" + '<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
@@ -213,7 +240,7 @@
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + '<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid (closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>' ? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" + : fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "</span></div>" +
"</div>" + "</div>" +
(function () { (function () {
const hint = closeGateHint(closePreview); const hint = closeGateHint(closePreview);
@@ -226,6 +253,7 @@
const strike = Number(p.strike); const strike = Number(p.strike);
const tgt = Number(p.target_index); const tgt = Number(p.target_index);
const prem = Number(p.premium_paid); const prem = Number(p.premium_paid);
const idx = Number(p.idx_px);
let profit = null; let profit = null;
let value = null; let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
@@ -233,10 +261,17 @@
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) { if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100; value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; if (!hidePnl && Number.isFinite(prem)) {
let premUsd = prem;
if (premCcy !== "USDC" && Number.isFinite(idx) && idx > 0) premUsd = prem * idx;
profit = Math.round((value - premUsd) * 100) / 100;
}
} }
} }
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); const valueUnit = premCcy !== "USDC" ? " U(估)" : " USDC";
const profitTxt = profit == null
? "—"
: ((profit > 0 ? "+" : "") + fmtUsdc(profit) + (premCcy !== "USDC" ? " U(估)" : " 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 hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
@@ -247,7 +282,7 @@
'<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">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" + '<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
profitSpan + profitSpan +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
+11 -7
View File
@@ -49,6 +49,7 @@ HOT_RELOAD_EXACT = frozenset({
"RISK_PERCENT", "RISK_PERCENT",
"MAX_ACTIVE_POSITIONS", "MAX_ACTIVE_POSITIONS",
"MANUAL_MIN_PLANNED_RR", "MANUAL_MIN_PLANNED_RR",
"KEY_AUTO_MIN_PLANNED_RR",
"DAILY_OPEN_ALERT_THRESHOLD", "DAILY_OPEN_ALERT_THRESHOLD",
"DAILY_OPEN_HARD_LIMIT", "DAILY_OPEN_HARD_LIMIT",
"TRADING_DAY_RESET_HOUR", "TRADING_DAY_RESET_HOUR",
@@ -59,6 +60,7 @@ HOT_RELOAD_EXACT = frozenset({
"RISK_MANUAL_CLOSE_DAILY_LIMIT", "RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT", "RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE", "RISK_MOOD_ISSUES_DAILY_FREEZE",
"KEY_AUTO_ORDER_ENABLED",
"TRADE_DIRECTION_RESTRICT_ENABLED", "TRADE_DIRECTION_RESTRICT_ENABLED",
"TRADE_DIRECTION", "TRADE_DIRECTION",
"TRADE_SYMBOL_RESTRICT_ENABLED", "TRADE_SYMBOL_RESTRICT_ENABLED",
@@ -98,11 +100,12 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC", "OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER", "OKX_OPTIONS_BUDGET_BUFFER",
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_TRADE_MODE", "OKX_TRADE_MODE",
"SIM_DEFAULT_MODE",
"SIM_INITIAL_EQUITY_USDT",
"SIM_INITIAL_USDC",
"SIM_FEE_RATE",
"MAX_ACTIVE_HEDGE_PLANS", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPTION_PRIMARY", "HEDGE_PLAN_OPTION_PRIMARY",
@@ -124,6 +127,7 @@ HOT_RELOAD_EXACT = frozenset({
SENSITIVE_EXACT = frozenset({ SENSITIVE_EXACT = frozenset({
"APP_PASSWORD", "APP_PASSWORD",
"FLASK_SECRET_KEY", "FLASK_SECRET_KEY",
"HUB_BRIDGE_TOKEN",
"OPENAI_API_KEY", "OPENAI_API_KEY",
}) })
@@ -163,9 +167,9 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("perp_options", "永期对冲"), ("perp_options", "永期对冲"),
("options_options", "期期对冲"), ("options_options", "期期对冲"),
), ),
"SIM_DEFAULT_MODE": ( "OKX_OPTIONS_MARGIN_MODE": (
("sim", "模拟(sim)"), ("coin", "币本位(USDT买币桥)"),
("live", "实盘(live)"), ("usdc", "USDC(USDⓈ权利金)"),
), ),
"HEDGE_PLAN_OPTION_PRIMARY": ( "HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"), ("true", "以期权为主"),
+101 -94
View File
@@ -17,44 +17,9 @@ from lib.env.env_schema import (
) )
# 各所「交易所与实盘」字段(顺序即页面顺序) # 各所「交易所与实盘」字段(顺序即页面顺序)
_OKX_LIVE_ONLY_KEYS = frozenset(
{
"LIVE_TRADING_ENABLED",
"OKX_API_KEY",
"OKX_API_SECRET",
"OKX_API_PASSPHRASE",
}
)
_SIM_FUNDS_SECTION: dict[str, Any] = {
"title": "模拟资金",
"fields": [
(
"SIM_INITIAL_EQUITY_USDT",
"初始权益 USDT",
"重置模拟钱包时写入资金账户 USDT;改完需在系统设置「模拟资金」点重置才生效",
),
(
"SIM_INITIAL_USDC",
"初始 USDC",
"重置时写入期权侧 USDC;改完需重置才生效",
),
(
"SIM_FEE_RATE",
"模拟手续费率",
"如 0.0005=万五;撮合立即按此费率扣费",
),
],
}
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [ "okx": [
( ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
"SIM_DEFAULT_MODE",
"撮合模式",
"sim=本地模拟资金; live=实盘.保存后立即切换;sim 下隐藏 API/实盘开关",
),
("LIVE_TRADING_ENABLED", "开启实盘下单", "仅 live 模式下生效;关闭时即使 live 也不向交易所发单"),
("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"), ("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"), ("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"), ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
@@ -94,6 +59,70 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
], ],
}, },
{
"title": "交易执行",
"fields": [
("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"),
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
("ALT_LEVERAGE", "山寨默认杠杆", ""),
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"),
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"切点前禁止新开仓",
"默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"),
("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"),
("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"),
],
},
{
"title": "交易风控",
"fields": [
("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"),
("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"),
],
},
{
"title": "账户冷静期",
"fields": [
("RISK_CONTROL_ENABLED", "冷静期总开关", ""),
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
{
"title": "自动划转",
"fields": [
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
("TRANSFER_CCY", "划转币种", ""),
],
},
{
"title": "当日资金",
"fields": [
("DAILY_START_CAPITAL", "日起始基数(U)", ""),
("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""),
("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""),
],
},
] ]
_MODE_SECTION: dict[str, Any] = { _MODE_SECTION: dict[str, Any] = {
@@ -114,16 +143,46 @@ _OPTIONS_SECTION: dict[str, Any] = {
"fields": [ "fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
(
"OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
( (
"OKX_OPTIONS_TRADE_BUDGET_USDC", "OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)", "单笔预算(USDC)",
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限", " USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
(
"OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
),
(
"OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)",
"仅币本位且复利关闭时生效",
),
(
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
),
(
"OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(USDT)",
"仅上限开关开启时生效",
),
(
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
), ),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
( (
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关", "全仓复利开关",
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利", "默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
), ),
( (
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
@@ -191,12 +250,6 @@ _HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
"永期模式(以期权为主/保险)", "永期模式(以期权为主/保险)",
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换", "默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
), ),
("POSITION_SIZING_MODE", "永续计仓模式", "切换须无仓后重启;以损定仓 / 全仓杠杆"),
("RISK_PERCENT", "以损定仓风险%", "永续腿单笔风险占资金比例"),
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
("BTC_LEVERAGE", "BTC 默认杠杆", "永续腿"),
("ALT_LEVERAGE", "山寨默认杠杆", "永续腿"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8;顶栏统计切日"),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
@@ -271,7 +324,6 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"OKX_OPTIONS_MAX_DTE_DAYS": "2", "OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
"OKX_TRADE_MODE": "options", "OKX_TRADE_MODE": "options",
"SIM_DEFAULT_MODE": "sim",
"MAX_ACTIVE_HEDGE_PLANS": "1", "MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
@@ -284,16 +336,6 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key == "SIM_DEFAULT_MODE":
# 展示当前生效撮合模式(运行时 trading.mode 优先)
try:
from lib.sim.mode_lib import peek_persisted_trading_mode
cur = peek_persisted_trading_mode()
if cur:
return cur
except Exception:
pass
if key == "OKX_TRADE_MODE": if key == "OKX_TRADE_MODE":
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式 # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
file_val = str(file_values.get(key) or "").strip() if key in file_values else "" file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
@@ -402,41 +444,15 @@ def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
return [] return []
def _trading_mode_for_env_ui() -> str:
try:
from lib.sim.mode_lib import peek_persisted_trading_mode, default_trading_mode
return peek_persisted_trading_mode() or default_trading_mode()
except Exception:
return "sim"
def _okx_exchange_fields_for_trading_mode(trading_mode: str) -> list[tuple[str, str, str]]:
fields = list(_EXCHANGE_LIVE_FIELDS["okx"])
tm = (trading_mode or "").strip().lower()
if tm == "sim":
return [f for f in fields if f[0] not in _OKX_LIVE_ONLY_KEYS]
return fields
def ui_sections_for_exchange( def ui_sections_for_exchange(
exchange_key: str, exchange_key: str,
*, *,
mode: str | None = None, mode: str | None = None,
trading_mode: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
ex = (exchange_key or "").strip().lower() ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = [] sections: list[dict[str, Any]] = []
tm = (trading_mode or "").strip().lower() live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
if not tm:
tm = _trading_mode_for_env_ui() if ex == "okx" else "live"
if ex == "okx":
live_fields = _okx_exchange_fields_for_trading_mode(tm)
else:
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields}) sections.append({"title": "交易所与实盘", "fields": live_fields})
if ex == "okx" and tm == "sim":
sections.append(_SIM_FUNDS_SECTION)
sections.extend(_SHARED_SECTIONS) sections.extend(_SHARED_SECTIONS)
if ex in _MODE_SECTION.get("exchanges", frozenset()): if ex in _MODE_SECTION.get("exchanges", frozenset()):
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
@@ -462,11 +478,7 @@ def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
ex = (exchange_key or "").strip().lower() ex = (exchange_key or "").strip().lower()
if ex == "okx": if ex == "okx":
keys.add("OKX_TRADE_MODE") keys.add("OKX_TRADE_MODE")
keys.add("SIM_DEFAULT_MODE") # 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
# 切模式后同请求可能带上对侧字段,始终放行
keys.update(_OKX_LIVE_ONLY_KEYS)
for item in _SIM_FUNDS_SECTION["fields"]:
keys.add(item[0])
for item in _HEDGE_PLAN_SECTION["fields"]: for item in _HEDGE_PLAN_SECTION["fields"]:
keys.add(item[0]) keys.add(item[0])
for item in _OPTIONS_SECTION["fields"]: for item in _OPTIONS_SECTION["fields"]:
@@ -482,14 +494,9 @@ def build_env_ui_payload(
schema = _schema_field_map(example_path) schema = _schema_field_map(example_path)
env_lines = read_env_lines(env_path) env_lines = read_env_lines(env_path)
values = env_get_all(env_lines) values = env_get_all(env_lines)
trading_mode = ""
if (exchange_key or "").strip().lower() == "okx":
trading_mode = _effective_env_value("SIM_DEFAULT_MODE", values, "sim") or _trading_mode_for_env_ui()
groups: list[dict[str, Any]] = [] groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange( for sec in ui_sections_for_exchange(
exchange_key, exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
mode=values.get("OKX_TRADE_MODE") or "",
trading_mode=trading_mode,
): ):
fields = [ fields = [
_build_field(key, label, note, schema, values) _build_field(key, label, note, schema, values)
+120 -22
View File
@@ -19,7 +19,7 @@ from lib.options.options_pricing_lib import (
) )
_OKX_OPTION_ERR_ZH: dict[str, str] = { _OKX_OPTION_ERR_ZH: dict[str, str] = {
"51008": "可用余额或保证金不足(期权买入请确认交易账户 USDC 足够)", "51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 USDC 足够)",
"51018": "期权账户不能持有净空头头寸", "51018": "期权账户不能持有净空头头寸",
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
} }
@@ -47,11 +47,18 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
msg = str(row.get("sMsg") or "").strip() msg = str(row.get("sMsg") or "").strip()
low = msg.lower() low = msg.lower()
if code == "51008": if code == "51008":
# 勿写死「资金账户 USDT」:期权开仓常因交易户 USDC 不足 # 勿写死「资金账户 USDT」:USDC 模式常因交易户 USDC 不足;币本位则是标的币不足
if "usdc" in low: if "usdc" in low:
return "交易账户 USDC 可用余额不足" return "交易账户 USDC 可用余额不足"
if "usdt" in low: if "usdt" in low:
return "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)" return "USDT 可用余额不足"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode():
return "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够,或减少张数)"
except Exception:
pass
return _OKX_OPTION_ERR_ZH["51008"] return _OKX_OPTION_ERR_ZH["51008"]
zh = _OKX_OPTION_ERR_ZH.get(code) zh = _OKX_OPTION_ERR_ZH.get(code)
if zh: if zh:
@@ -164,6 +171,21 @@ def format_usdc_amount(v: float | None) -> str | None:
return f"{float(v):.2f}" return f"{float(v):.2f}"
def format_premium_amount(v: float | None, *, ccy: str | None = "USDC") -> str | None:
"""权利金/回收金额文案:USDC 2 位;币本位 ETH/BTC 最多 8 位去尾零."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
unit = (ccy or "USDC").strip().upper() or "USDC"
if unit in ("ETH", "BTC"):
txt = f"{n:.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{n:.2f}"
def is_option_full_close_history(raw: dict[str, Any]) -> bool: def is_option_full_close_history(raw: dict[str, Any]) -> bool:
"""仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓.""" """仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓."""
close_type = str(raw.get("type") or "").strip() close_type = str(raw.get("type") or "").strip()
@@ -509,8 +531,8 @@ def fetch_account_balances_by_type(
ex: ccxt.okx, ex: ccxt.okx,
account_type: str, account_type: str,
) -> tuple[dict[str, float | None], dict[str, float | None]]: ) -> tuple[dict[str, float | None], dict[str, float | None]]:
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try: try:
bal = ex.fetch_balance(params={"type": account_type}) bal = ex.fetch_balance(params={"type": account_type})
for c in out: for c in out:
@@ -525,8 +547,8 @@ def fetch_funding_balances_via_asset_api(
ex: ccxt.okx, ex: ccxt.okx,
) -> tuple[dict[str, float | None], dict[str, float | None]]: ) -> tuple[dict[str, float | None], dict[str, float | None]]:
"""OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确.""" """OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确."""
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try: try:
resp = ex.private_get_asset_balances({}) resp = ex.private_get_asset_balances({})
for row in (resp or {}).get("data") or []: for row in (resp or {}).get("data") or []:
@@ -609,24 +631,34 @@ def fetch_options_balances(
funding = _merge_balance_maps(funding, asset_funding) funding = _merge_balance_maps(funding, asset_funding)
funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail) funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail)
trading, trading_avail = fetch_account_balances_by_type(ex, "trading") trading, trading_avail = fetch_account_balances_by_type(ex, "trading")
if trading.get("USDC") is None: # OKX 统一账户:option 客户端拉 type=trading 常缺 USDT/币;用 swap 补齐缺失项
if any(trading.get(c) is None for c in ("USDT", "USDC", "ETH", "BTC")):
swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap")
if swap_bal.get("USDC") is not None: for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"):
trading["USDC"] = swap_bal["USDC"] if trading.get(ccy) is None and swap_bal.get(ccy) is not None:
if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None: trading[ccy] = swap_bal[ccy]
trading_avail["USDC"] = swap_avail["USDC"] if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None:
trading_avail[ccy] = swap_avail[ccy]
result = { result = {
"scope": "main", "scope": "main",
"funding_usdt": funding.get("USDT"), "funding_usdt": funding.get("USDT"),
"funding_usdc": funding.get("USDC"), "funding_usdc": funding.get("USDC"),
"funding_usdg": funding.get("USDG"), "funding_usdg": funding.get("USDG"),
"funding_eth": funding.get("ETH"),
"funding_btc": funding.get("BTC"),
"funding_usdt_avail": funding_avail.get("USDT"), "funding_usdt_avail": funding_avail.get("USDT"),
"funding_usdc_avail": funding_avail.get("USDC"), "funding_usdc_avail": funding_avail.get("USDC"),
"funding_eth_avail": funding_avail.get("ETH"),
"funding_btc_avail": funding_avail.get("BTC"),
"trading_usdt": trading.get("USDT"), "trading_usdt": trading.get("USDT"),
"trading_usdc": trading.get("USDC"), "trading_usdc": trading.get("USDC"),
"trading_usdg": trading.get("USDG"), "trading_usdg": trading.get("USDG"),
"trading_eth": trading.get("ETH"),
"trading_btc": trading.get("BTC"),
"trading_usdt_avail": trading_avail.get("USDT"), "trading_usdt_avail": trading_avail.get("USDT"),
"trading_usdc_avail": trading_avail.get("USDC"), "trading_usdc_avail": trading_avail.get("USDC"),
"trading_eth_avail": trading_avail.get("ETH"),
"trading_btc_avail": trading_avail.get("BTC"),
} }
_OPTIONS_BALANCE_CACHE["updated_at"] = now _OPTIONS_BALANCE_CACHE["updated_at"] = now
_OPTIONS_BALANCE_CACHE["data"] = result _OPTIONS_BALANCE_CACHE["data"] = result
@@ -642,22 +674,63 @@ def options_header_balances(
返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt) 返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt)
""" """
pack = options_header_balance_pack(ex, force=force)
return (
pack.get("trading_usdc"),
pack.get("funding_usdc"),
pack.get("funding_usdt"),
pack.get("trading_usdt"),
)
def options_header_balance_pack(
ex: ccxt.okx,
*,
force: bool = False,
) -> dict[str, Any]:
"""顶栏/快照用期权资金包(含币本位 ETH/BTC)."""
import os
bal = fetch_options_balances(ex, force=force) bal = fetch_options_balances(ex, force=force)
def _round(v: Any) -> float | None: def _round(v: Any, nd: int = 2) -> float | None:
if v is None: if v is None:
return None return None
try: try:
return round(float(v), 2) return round(float(v), nd)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return ( def _round_coin(v: Any) -> float | None:
_round(bal.get("trading_usdc")), if v is None:
_round(bal.get("funding_usdc")), return None
_round(bal.get("funding_usdt")), try:
_round(bal.get("trading_usdt")), return round(float(v), 8)
) except (TypeError, ValueError):
return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
except Exception:
margin_mode = "usdc"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
coin_key = "btc" if underly == "BTC" else "eth"
return {
"trading_usdc": _round(bal.get("trading_usdc")),
"funding_usdc": _round(bal.get("funding_usdc")),
"funding_usdt": _round(bal.get("funding_usdt")),
"trading_usdt": _round(bal.get("trading_usdt")),
"funding_eth": _round_coin(bal.get("funding_eth")),
"trading_eth": _round_coin(bal.get("trading_eth")),
"funding_btc": _round_coin(bal.get("funding_btc")),
"trading_btc": _round_coin(bal.get("trading_btc")),
"options_margin_mode": margin_mode,
"options_underly": underly,
"funding_coin": _round_coin(bal.get(f"funding_{coin_key}")),
"trading_coin": _round_coin(bal.get(f"trading_{coin_key}")),
}
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
@@ -734,9 +807,19 @@ def build_option_chain(
itm_only: bool = True, itm_only: bool = True,
itm_max_dist_usd: float = 30.0, itm_max_dist_usd: float = 30.0,
index_px: float | None = None, index_px: float | None = None,
margin_mode: str | None = None,
inst_family: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
u = (underlying or "ETH").upper() u = (underlying or "ETH").upper()
family = f"{u}-USD_UM" if inst_family:
family = str(inst_family).strip()
else:
try:
from lib.options.options_margin_mode_lib import inst_family_for_underlying
family = inst_family_for_underlying(u, margin_mode=margin_mode)
except Exception:
family = f"{u}-USD_UM"
uly = f"{u}-USD" uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly) idx = index_px if index_px is not None else fetch_index_price(ex, uly)
now_ms = time.time() * 1000 now_ms = time.time() * 1000
@@ -838,6 +921,8 @@ def build_option_chain(
"underlying": u, "underlying": u,
"index_px": idx, "index_px": idx,
"inst_family": family, "inst_family": family,
"margin_mode": "usdc" if "_UM" in family.upper() else "coin",
"premium_ccy": "USDC" if "_UM" in family.upper() else u,
"expiries": exp_list, "expiries": exp_list,
"instruments_count": len(instruments), "instruments_count": len(instruments),
} }
@@ -1727,6 +1812,16 @@ def format_position_row(
ct_mult=ct_mult, ct_mult=ct_mult,
) )
exp_time_ms = normalize_option_exp_ms(pos.get("expTime"), inst_id) exp_time_ms = normalize_option_exp_ms(pos.get("expTime"), inst_id)
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = premium_ccy_for_mode(row_mode, underly)
except Exception:
row_mode = "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = "USDC"
return { return {
"inst_id": inst_id or pos.get("instId"), "inst_id": inst_id or pos.get("instId"),
"pos": sheets, "pos": sheets,
@@ -1735,11 +1830,14 @@ def format_position_row(
"mark_px": mark, "mark_px": mark,
"avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None, "avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None,
"mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None, "mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None,
"premium_paid_fmt": format_usdc_amount(premium_paid), "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"tick_sz": tick_sz, "tick_sz": tick_sz,
"ct_mult": ct_mult, "ct_mult": ct_mult,
"idx_px": idx_px, "idx_px": idx_px,
"premium_paid": premium_paid, "premium_paid": premium_paid,
"margin_mode": row_mode,
"premium_ccy": premium_ccy,
"underlying": underly,
"upl": upl, "upl": upl,
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
"exp_time": exp_time_ms, "exp_time": exp_time_ms,
+14
View File
@@ -832,6 +832,20 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
plan_type = (body.get("plan_type") or "perp_options").strip().lower() plan_type = (body.get("plan_type") or "perp_options").strip().lower()
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode() and not dry_run:
return jsonify(
{
"ok": False,
"msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲",
}
), 400
except Exception as e:
return jsonify(
{"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"}
), 400
with _hedge_start_lock(): with _hedge_start_lock():
gates = _gates_dict(cfg, plan_type) gates = _gates_dict(cfg, plan_type)
if not dry_run and not gates.get("can_start"): if not dry_run and not gates.get("can_start"):
+64 -9
View File
@@ -6,7 +6,7 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
EMBED_STRATEGY_PAGES = frozenset() EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"})
_WIN_EPS = 1e-9 _WIN_EPS = 1e-9
@@ -55,15 +55,15 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
is_strategy = page in EMBED_STRATEGY_PAGES is_strategy = page in EMBED_STRATEGY_PAGES
return EmbedRenderPlan( return EmbedRenderPlan(
exchange_capitals=is_shell, exchange_capitals=is_shell,
records_rows=False, # 永续交易记录页已移除 records_rows=page == "records",
# 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏 # 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏
records_summary=False, records_summary=is_shell and page != "records",
key_history=page == "key_monitor", key_history=page == "key_monitor",
key_list=page == "key_monitor" or is_strategy, key_list=page in ("key_monitor", "trade") or is_strategy,
orders=False, # 实盘下单界面已移除;对冲永续下单不依赖本页数据 orders=page == "trade" or is_strategy,
stats_bundle=False, stats_bundle=page == "stats",
strategy=is_strategy, strategy=is_strategy,
orphan_live=False, orphan_live=page == "trade" and is_shell,
) )
@@ -103,9 +103,11 @@ def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float
def options_funding_label( def options_funding_label(
funding_usdc: float | None, funding_usdc: float | None,
funding_usdt: float | None = None, funding_usdt: float | None = None,
funding_eth: float | None = None,
margin_mode: str | None = None,
underly: str = "ETH",
) -> str: ) -> str:
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略.""" """期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列)."""
_ = funding_usdt
if funding_usdc is None: if funding_usdc is None:
return "" return ""
try: try:
@@ -114,6 +116,59 @@ def options_funding_label(
return "" return ""
def _fmt_coin_amount(v: float | None, *, min_amt: float = 1e-6) -> str | None:
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
if n < min_amt:
return None
txt = f"{n:.6f}".rstrip("0").rstrip(".")
return txt or None
def trading_account_label(
usdt: float | None,
eth: float | None = None,
btc: float | None = None,
*,
margin_mode: str | None = None,
) -> str:
"""交易账户顶栏文案.
币本位:USDT / ETH / BTC(有余额才带上,不显示其它币种).
其它模式:xx.xxU.
"""
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
mode = normalize_options_margin_mode(margin_mode)
except Exception:
mode = str(margin_mode or "coin").strip().lower() or "coin"
if mode != "coin":
if usdt is None:
return ""
try:
return f"{float(usdt):.2f}U"
except (TypeError, ValueError):
return ""
parts: list[str] = []
if usdt is not None:
try:
parts.append(f"{float(usdt):.2f} USDT")
except (TypeError, ValueError):
pass
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
if eth_txt is not None:
parts.append(f"{eth_txt} ETH")
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
if btc_txt is not None:
parts.append(f"{btc_txt} BTC")
return " / ".join(parts) if parts else ""
def total_funds_usdt( def total_funds_usdt(
funding_usdt: float | None, funding_usdt: float | None,
trading_usdt: float | None, trading_usdt: float | None,
+26 -2
View File
@@ -26,10 +26,12 @@ from lib.instance.instance_pm2_lib import restart_instance_pm2
from lib.instance.runtime_config_lib import apply_env_reload from lib.instance.runtime_config_lib import apply_env_reload
def _api_login_required(): def _api_login_required(hub_token_write_allowed: bool = False):
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
def wrapped(*args, **kwargs): def wrapped(*args, **kwargs):
from lib.hub.hub_auth import request_allowed as hub_request_allowed
logged_in = bool(session.get("logged_in")) logged_in = bool(session.get("logged_in"))
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in ( auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
"1", "1",
@@ -37,7 +39,11 @@ def _api_login_required():
"yes", "yes",
"on", "on",
) )
if auth_disabled or logged_in: hub_hdr = (request.headers.get("X-Hub-Token") or "").strip()
bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed:
return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403
if hub_request_allowed(logged_in, auth_disabled):
return f(*args, **kwargs) return f(*args, **kwargs)
return jsonify({"ok": False, "msg": "未登录"}), 401 return jsonify({"ok": False, "msg": "未登录"}), 401
@@ -100,6 +106,24 @@ def register_instance_settings_routes(
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean: if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
if "OKX_OPTIONS_MARGIN_MODE" in clean:
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
lines = read_env_lines(env_path)
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
if old_mode != new_mode:
conn_m = get_db()
try:
block = mode_switch_block_msg(conn_m, None)
if block:
return jsonify({"ok": False, "msg": block}), 400
finally:
conn_m.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
changed = apply_env_updates(env_path, clean) changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path) groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups) reload_info = apply_env_reload(env_path, get_db, changed, groups)
+62 -21
View File
@@ -268,7 +268,7 @@ function toggleListWindowCustom(){
function applyListWindow(){ function applyListWindow(){
const qs = listWindowQueryString(); const qs = listWindowQueryString();
const path = window.location.pathname || "/options"; const path = window.location.pathname || "/trade";
window.location.href = qs ? (path + "?" + qs) : path; window.location.href = qs ? (path + "?" + qs) : path;
} }
@@ -1136,13 +1136,36 @@ function paintRealtimePnlFromSnapshot(data){
} }
} }
function formatOptionsFundingLabel(usdc, usdt) { function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
if (usdc === null || usdc === undefined || usdc === "") return "—"; if (usdc === null || usdc === undefined || usdc === "") return "—";
const n = Number(usdc); const n = Number(usdc);
if (Number.isNaN(n)) return "—"; if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`; return `${n.toFixed(2)} USDC`;
} }
function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const mode = String(marginMode || "coin").toLowerCase();
if (mode !== "coin") {
if (usdt === null || usdt === undefined || usdt === "") return "—";
const n = Number(usdt);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
const parts = [];
if (usdt !== null && usdt !== undefined && usdt !== "") {
const n = Number(usdt);
if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v === null || v === undefined || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join(" / ") : "—";
}
function setFundsFieldText(field, text){ function setFundsFieldText(field, text){
if(text == null || text === "") return; if(text == null || text === "") return;
@@ -1156,6 +1179,11 @@ function applyPerpFundsVisibility(show){
el.style.display = on ? "" : "none"; el.style.display = on ? "" : "none";
}); });
} }
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){ function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true; if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){ if(data.show_perp_funds === false){
@@ -1175,16 +1203,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0; let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
if(data.updated_at){ const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
const updatedEl = document.getElementById("price-last-updated");
if(updatedEl) updatedEl.innerText = data.updated_at;
}
if(typeof data.show_perp_funds !== "undefined"){ if(typeof data.show_perp_funds !== "undefined"){
applyPerpFundsVisibility(data.show_perp_funds); applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
} } else if (coinMode) {
if(data.exchange_mode_label){ applyPerpFundsVisibility(true);
setFundsFieldText("exchange-mode-label", data.exchange_mode_label);
} }
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} }
@@ -1192,14 +1217,34 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} }
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`); setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
} }
if(data.options_funding_usdc != null || data.options_funding_usdt != null){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt); const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding); setFundsFieldText("options-funding-usdc", optFunding);
} }
if(data.options_trading_usdc != null || data.options_trading_usdt != null){ if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt); const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined"){
@@ -1270,11 +1315,7 @@ function applyAccountSnapshot(data){
} }
function refreshAccountSnapshot(opts){ function refreshAccountSnapshot(opts){
const options = opts || {}; const options = opts || {};
const params = new URLSearchParams(); const qs = options.force ? "?force=1" : "";
if(options.force) params.set("force", "1");
const page = (document.body && document.body.getAttribute("data-page")) || "";
if(page) params.set("page", page);
const qs = params.toString() ? ("?" + params.toString()) : "";
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{ fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data); applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){ if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
+290 -39
View File
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=51"></script> <script src="/static/instance_theme.js?v=50"></script>
<script src="/static/autofill_guard.js?v=1"></script> <script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
@@ -12,16 +12,14 @@
<script src="/static/open_submit_gate.js?v=1"></script> <script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="{{ pwa_app_name }}"> <meta name="apple-mobile-web-app-title" content="{{ pwa_app_name }}">
<link rel="icon" href="/static/icons/favicon.ico?v=2" sizes="any"> <link rel="icon" href="/static/icons/favicon.ico" sizes="32x32">
<link rel="icon" href="/static/icons/icon-32.png?v=2" type="image/png" sizes="32x32"> <link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
<link rel="icon" href="/static/icons/icon.svg?v=2" type="image/svg+xml"> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png?v=2"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<link rel="manifest" href="/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=18"> <link rel="stylesheet" href="/static/instance_page.css?v=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=123"> <link rel="stylesheet" href="/static/instance_theme.css?v=117">
</head> </head>
<body <body
@@ -153,13 +151,25 @@
{% endmacro %} {% endmacro %}
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>加密货币|OKX 期权与对冲</h1> <h1>加密货币|交易监控 + AI复盘一体化</h1>
</div> </div>
<div class="top-nav"> <div class="top-nav">
<div class="top-nav-links">
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a> <a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a> <a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a> <a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %}
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
{% endif %}
{% if not intraday_discipline and display.show_nav_strategy_records %}
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
{% endif %}
{% if display.show_nav_records %}
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
{% endif %}
{% if display.show_nav_stats %}
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
{% endif %}
{% if options_nav_visible and display.show_nav_options %} {% if options_nav_visible and display.show_nav_options %}
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a> <a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
{% endif %} {% endif %}
@@ -177,13 +187,11 @@
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a> <a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
{% endif %} {% endif %}
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a> <a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
</div>
{% include 'nav_spot_tickers.html' %}
</div> </div>
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %} {% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %} {% include 'instance_header_panel.html' %}
{% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options_review') %} {% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% include 'instance_top_bar.html' %} {% include 'instance_top_bar.html' %}
{% endif %} {% endif %}
@@ -194,6 +202,165 @@
{% include 'account_ledger_panel.html' %} {% include 'account_ledger_panel.html' %}
{% elif page == 'key_monitor' %} {% elif page == 'key_monitor' %}
{% include 'key_monitor_panel.html' %} {% include 'key_monitor_panel.html' %}
{% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{% if focus_order_id %}
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
{% else %}
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
{% endif %}
</div>
{% include order_rule_tips_tpl %}
{% include 'order_monitor_open_form.html' %}
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
{% if ui_orphan_recovery_enabled %}
{% if not order and orphan_live_positions %}
{% set o = orphan_live_positions[0] %}
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:block;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8">
检测到交易所仍有 <strong>{{ o.symbol }}</strong> {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录).
{% if o.recoverable_monitor_id %}
<button type="button" class="pos-entrust-btn" onclick="recoverLivePosition({{ o.recoverable_monitor_id }})">恢复监控{% if o.plan_stop_loss and o.plan_take_profit %}并挂止盈止损{% endif %}</button>
{% else %}
未找到可恢复的监控记录,需在服务器数据库处理.
{% endif %}
</div>
{% else %}
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:none;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8"></div>
{% endif %}
{% endif %}
<div class="panel-scroll pos-list pos-list-live">
{% for o in order %}
<div class="pos-card" id="order-row-{{ o.id }}"
data-monitor-id="{{ o.id }}"
data-symbol="{{ o.symbol }}"
data-direction="{{ o.direction }}"
data-plan-sl="{% if o.stop_loss %}{{ price_fmt(o.symbol, o.stop_loss) }}{% endif %}"
data-plan-tp="{% if o.take_profit %}{{ price_fmt(o.symbol, o.take_profit) }}{% endif %}"
data-entry="{% if o.trigger_price %}{{ price_fmt(o.symbol, o.trigger_price) }}{% endif %}">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ o.exchange_symbol or o.symbol }}</strong>
{% if o.time_close_enabled %}
<span class="pos-symbol-time-close pos-meta-on pos-time-close-meta" id="order-time-close-wrap-{{ o.id }}"
data-close-at-ms="{{ o.time_close_at_ms or '' }}">
<span class="pos-time-close-label">时间平仓 {{ o.time_close_hours or '' }}h</span>
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
</span>
{% endif %}
{% include 'force_close_order_badge.html' %}
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div>
<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
</div>
</div>
<div class="pos-meta">
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
<span class="pos-meta-item">{% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %}</span>
<span class="pos-meta-item">风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %}</span>
<span class="pos-meta-item" id="order-latest-risk-wrap-{{ o.id }}" style="display:none">最新风险: —</span>
<span class="pos-meta-item {% if not intraday_discipline %}{% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}{% endif %}">
{% if intraday_discipline %}
{% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span>
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div>
<div class="pos-grid">
<div class="pos-cell">
<span class="pos-label">成交价</span>
<span class="pos-value">{{ price_fmt(o.symbol, o.trigger_price) }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">止损</span>
<span class="pos-value" id="order-plan-sl-{{ o.id }}">{{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">止盈</span>
<span class="pos-value" id="order-plan-tp-{{ o.id }}">{{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">盈亏比</span>
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
</div>
<div class="pos-cell">
<span class="pos-label">张数</span>
<span class="pos-value" id="order-contracts-{{ o.id }}">{% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %}</span>
</div>
<div class="pos-cell">
<span class="pos-label">盈利金额</span>
<span class="pos-value pos-tp-profit" id="order-tp-profit-{{ o.id }}"></span>
</div>
<div class="pos-cell">
<span class="pos-label">标记价</span>
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
</div>
<div class="pos-cell">
<span class="pos-label">浮盈亏</span>
<span class="pos-value" id="order-pnl-{{ o.id }}">-</span>
</div>
</div>
<div class="pos-footer">
<span>保证金: <span id="order-ex-margin-{{ o.id }}">-</span></span>
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
<span>杠杆: {{ o.leverage or '-' }}x</span>
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}"></span></span>
</div>
<div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
</div>
</div>
</div>
{% else %}
<div class="pos-empty">暂无持仓</div>
{% endfor %}
</div>
</div>
<div id="tpsl-modal" class="tpsl-modal-backdrop" onclick="if(event.target===this)closeTpslEntrustModal()">
<div class="tpsl-modal" onclick="event.stopPropagation()">
<h3 id="tpsl-modal-title">挂止盈止损</h3>
<p style="font-size:.78rem;color:#8892b0;margin:0 0 10px">将先撤销该合约已有 TP/SL,再按下列价格重挂.</p>
<div class="form-row">
<select id="tpsl-modal-mode" onchange="toggleTpslModalMode()">
<option value="price">价格模式</option>
<option value="pct">百分比模式</option>
</select>
</div>
<div class="form-row">
<input id="tpsl-modal-sl" step="any" placeholder="止损价格">
<input id="tpsl-modal-tp" step="any" placeholder="止盈价格">
</div>
<div class="form-row">
<input id="tpsl-modal-sl-pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="tpsl-modal-tp-pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
</div>
<div class="tpsl-modal-actions">
<button type="button" class="tpsl-modal-cancel" onclick="closeTpslEntrustModal()">取消</button>
<button type="button" class="tpsl-modal-submit" onclick="submitTpslEntrust()">先撤后挂</button>
</div>
</div>
</div>
</div>
{% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
{% include 'strategy_trading_page.html' %}
{% elif page == 'strategy_records' %}
{% include 'strategy_records_page.html' %}
{% elif page == 'options' %} {% elif page == 'options' %}
{% include 'options_panel.html' %} {% include 'options_panel.html' %}
{% elif page == 'options_review' %} {% elif page == 'options_review' %}
@@ -204,7 +371,11 @@
{% if page == 'env_config' %} {% if page == 'records' %}
{% include 'records_panel.html' %}
{% endif %}
{% if page == 'env_config' %}
{% include 'env_config_panel.html' %} {% include 'env_config_panel.html' %}
{% endif %} {% endif %}
@@ -220,7 +391,44 @@
{% include 'settings_panel.html' %} {% include 'settings_panel.html' %}
{% endif %} {% endif %}
{% if page == 'stats' %}
<div class="card stats-card full" id="stats-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
<h2 style="margin-bottom:0">数据统计</h2>
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
</div>
<div class="stats-content" id="stats-content">
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong>
</div>
<div class="form-row" style="margin-bottom:14px;align-items:center">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
统计品类
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
{% for seg in stats_bundle.segments %}
<option value="{{ seg.key }}">{{ seg.title }}</option>
{% endfor %}
</select>
</label>
</div>
{% for seg in stats_bundle.segments %}
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
<div class="stats-period-tabs" role="tablist" aria-label="统计周期">
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
</div>
{{ period_stats_pane("day", seg.day) }}
{{ period_stats_pane("week", seg.week) }}
{{ period_stats_pane("month", seg.month) }}
{{ period_stats_pane("all", seg.all) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
<div class="modal" id="imgModal" onclick="closeModal()"> <div class="modal" id="imgModal" onclick="closeModal()">
@@ -245,6 +453,7 @@
<script src="/static/journal_upload_slots.js?v=4"></script> <script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script> <script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script> <script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
<script src="/static/form_submit_guard.js?v=2"></script> <script src="/static/form_submit_guard.js?v=2"></script>
<script src="/static/journal_form_save.js?v=3"></script> <script src="/static/journal_form_save.js?v=3"></script>
<script> <script>
@@ -255,6 +464,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script src="/static/order_entry_model.js?v=5"></script> <script src="/static/order_entry_model.js?v=5"></script>
<script src="/static/manual_order_rr_preview.js?v=5"></script> <script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/symbol_live_price.js?v=2"></script> <script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/strategy_roll.js?v=6"></script>
<script src="/static/instance_stats.js?v=5"></script> <script src="/static/instance_stats.js?v=5"></script>
<script> <script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }}; const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
@@ -518,7 +728,7 @@ function toggleListWindowCustom(){
function applyListWindow(){ function applyListWindow(){
const qs = listWindowQueryString(); const qs = listWindowQueryString();
const path = window.location.pathname || "/options"; const path = window.location.pathname || "/trade";
window.location.href = qs ? (path + "?" + qs) : path; window.location.href = qs ? (path + "?" + qs) : path;
} }
@@ -1407,13 +1617,36 @@ function paintRealtimePnlFromSnapshot(data){
} }
} }
function formatOptionsFundingLabel(usdc, usdt) { function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
if(usdc == null || usdc === "") return "—"; if(usdc == null || usdc === "") return "—";
const n = Number(usdc); const n = Number(usdc);
if(Number.isNaN(n)) return "—"; if(Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`; return `${n.toFixed(2)} USDC`;
} }
function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const mode = String(marginMode || "coin").toLowerCase();
if (mode !== "coin") {
if (usdt == null || usdt === "") return "—";
const n = Number(usdt);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
const parts = [];
if (usdt != null && usdt !== "") {
const n = Number(usdt);
if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v == null || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join(" / ") : "—";
}
function setFundsFieldText(field, text){ function setFundsFieldText(field, text){
if(text == null || text === "") return; if(text == null || text === "") return;
@@ -1427,6 +1660,11 @@ function applyPerpFundsVisibility(show){
el.style.display = on ? "" : "none"; el.style.display = on ? "" : "none";
}); });
} }
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){ function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true; if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){ if(data.show_perp_funds === false){
@@ -1446,16 +1684,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0; let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
if(data.updated_at){ const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
const updatedEl = document.getElementById("price-last-updated");
if(updatedEl) updatedEl.innerText = data.updated_at;
}
if(typeof data.show_perp_funds !== "undefined"){ if(typeof data.show_perp_funds !== "undefined"){
applyPerpFundsVisibility(data.show_perp_funds); applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
} } else if (coinMode) {
if(data.exchange_mode_label){ applyPerpFundsVisibility(true);
setFundsFieldText("exchange-mode-label", data.exchange_mode_label);
} }
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} }
@@ -1463,14 +1698,34 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} }
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`); setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
} }
if(data.options_funding_usdc != null || data.options_funding_usdt != null){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt); const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding); setFundsFieldText("options-funding-usdc", optFunding);
} }
if(data.options_trading_usdc != null || data.options_trading_usdt != null){ if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt); const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined"){
@@ -1550,11 +1805,7 @@ function applyAccountSnapshot(data){
} }
function refreshAccountSnapshot(opts){ function refreshAccountSnapshot(opts){
const options = opts || {}; const options = opts || {};
const params = new URLSearchParams(); const qs = options.force ? "?force=1" : "";
if(options.force) params.set("force", "1");
const page = (document.body && document.body.getAttribute("data-page")) || "";
if(page) params.set("page", page);
const qs = params.toString() ? ("?" + params.toString()) : "";
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{ fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data); applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){ if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
@@ -1829,8 +2080,8 @@ setInterval(tickOrderHoldDurations, 1000);
tickOrderHoldDurations(); tickOrderHoldDurations();
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
</script> </script>
<script src="/static/records_review_page.js?v=4"></script>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/nav_spot_tickers.js?v=3"></script>
<script src="/static/instance_dashboard.js?v=5"></script> <script src="/static/instance_dashboard.js?v=5"></script>
<script src="/static/account_ledger.js?v=1"></script> <script src="/static/account_ledger.js?v=1"></script>
<script> <script>
@@ -1846,6 +2097,6 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
{% endif %} {% endif %}
</script> </script>
<script src="/static/instance_settings_prefs.js?v=22"></script> <script src="/static/instance_settings_prefs.js?v=21"></script>
</body> </body>
</html> </html>
@@ -38,11 +38,13 @@
{% include 'instance_header_stats.html' %} {% include 'instance_header_stats.html' %}
</div> </div>
<div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要"> <div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要">
<span class="inst-phone-chip"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1"> {% set _coin_margin = (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
<span class="inst-phone-chip"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<em>交易</em> <em>交易</em>
<b data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</b> <b data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth, options_trading_btc, margin_mode=options_margin_mode|default('coin')) }}</b>
</span> </span>
<span class="inst-phone-chip"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1"> <span class="inst-phone-chip"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<em>资金</em> <em>资金</em>
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b> <b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
</span> </span>
@@ -1,8 +1,10 @@
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} {# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
{% set _coin_margin = (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}"> <div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
<div class="stat-strip-item stat-strip-item--primary"> <div class="stat-strip-item stat-strip-item--primary">
<div class="label">交易所</div> <div class="label">交易所</div>
<div class="value" id="exchange-mode-label" data-funds-field="exchange-mode-label">{{ exchange_display }}</div> <div class="value">{{ exchange_display }}</div>
</div> </div>
<div class="stat-strip-item"> <div class="stat-strip-item">
<div class="label">交易日</div> <div class="label">交易日</div>
@@ -24,22 +26,22 @@
<div class="label">总资金</div> <div class="label">总资金</div>
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div> <div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
</div> </div>
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1"> <div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<div class="label">资金账户</div> <div class="label">资金账户</div>
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div> <div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
</div> </div>
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1"> <div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<div class="label">交易账户</div> <div class="label">交易账户</div>
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div> <div class="value" id="current-capital" data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth, options_trading_btc, margin_mode=options_margin_mode|default('coin')) }}</div>
</div> </div>
{% if options_enabled %} {% if options_enabled and not _coin_margin %}
<div class="stat-strip-item"> <div class="stat-strip-item" data-options-funds="1">
<div class="label">期权资金账户</div> <div class="label">期权资金账户</div>
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc) }}</div> <div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc, options_funding_usdt, options_funding_eth, options_margin_mode, options_underly|default('ETH')) }}</div>
</div> </div>
<div class="stat-strip-item"> <div class="stat-strip-item" data-options-funds="1">
<div class="label">期权交易账户</div> <div class="label">期权交易账户</div>
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc) }}</div> <div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc, options_trading_usdt, options_trading_eth, options_margin_mode, options_underly|default('ETH')) }}</div>
</div> </div>
{% endif %} {% endif %}
<div class="stat-strip-item stat-strip-item--pnl"> <div class="stat-strip-item stat-strip-item--pnl">
+13 -1
View File
@@ -351,7 +351,7 @@ def close_option_by_bid1(
# 自动平已挂过单:同仓续批只验流动性 # 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id) mark_close_gate_passed(inst_id)
return { out = {
"ok": True, "ok": True,
"mode": "bid1", "mode": "bid1",
"orders": [{"order": order, "px": px, "sheets": level_sheets}], "orders": [{"order": order, "px": px, "sheets": level_sheets}],
@@ -369,6 +369,18 @@ def close_option_by_bid1(
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
), ),
} }
if fully_closed:
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out)
if spot_sell is not None:
out["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回"
except Exception as e:
out["spot_sell"] = {"ok": False, "msg": str(e)}
return out
# 兼容旧名 # 兼容旧名
+41 -6
View File
@@ -60,6 +60,14 @@ def is_close_gate_passed(inst_id: str) -> bool:
return bool((_gates.get(inst) or {}).get("passed")) return bool((_gates.get(inst) or {}).get("passed"))
def _fmt_gate_amt(v: float, *, ccy: str) -> str:
unit = (ccy or "USDC").strip().upper() or "USDC"
if unit in ("ETH", "BTC"):
txt = f"{float(v):.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{float(v):.4f}"
def update_close_gate( def update_close_gate(
inst_id: str, inst_id: str,
*, *,
@@ -68,6 +76,7 @@ def update_close_gate(
now: float | None = None, now: float | None = None,
min_mult: float | None = None, min_mult: float | None = None,
hold_seconds: float | None = None, hold_seconds: float | None = None,
premium_ccy: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
根据当前买盘可回收金额刷新门控. 根据当前买盘可回收金额刷新门控.
@@ -89,9 +98,16 @@ def update_close_gate(
if hold < 0: if hold < 0:
hold = 0.0 hold = 0.0
with _lock:
prev_ccy = (_gates.get(inst) or {}).get("premium_ccy")
ccy = (premium_ccy or prev_ccy or "USDC").strip().upper() or "USDC"
if ccy not in ("ETH", "BTC", "USDC"):
ccy = "USDC"
need_decimals = 8 if ccy in ("ETH", "BTC") else 4
prem = _safe_float(premium_paid) prem = _safe_float(premium_paid)
recv = _safe_float(recycle_usdc) recv = _safe_float(recycle_usdc)
need = round(prem * mult, 4) if prem is not None and prem > 0 else None need = round(prem * mult, need_decimals) if prem is not None and prem > 0 else None
recycle_ok = bool( recycle_ok = bool(
prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need
) )
@@ -117,6 +133,7 @@ def update_close_gate(
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"passed": passed, "passed": passed,
"premium_ccy": ccy,
} }
_gates[inst] = state _gates[inst] = state
@@ -126,10 +143,13 @@ def update_close_gate(
elif recv is None: elif recv is None:
msg = "暂无有效买盘可回收金额" msg = "暂无有效买盘可回收金额"
elif not recycle_ok: elif not recycle_ok:
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过" msg = (
f"可回收 {_fmt_gate_amt(recv, ccy=ccy)} {ccy} < 权利金×{mult:g}"
f"({_fmt_gate_amt(need, ccy=ccy)}),目标平仓门控未过"
)
elif not ready: elif not ready:
msg = ( msg = (
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," f"可回收已达×{mult:g}({_fmt_gate_amt(recv, ccy=ccy)}/{_fmt_gate_amt(need, ccy=ccy)} {ccy}),"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
) )
else: else:
@@ -144,6 +164,7 @@ def update_close_gate(
"recycle_usdc": recv, "recycle_usdc": recv,
"premium_paid": prem, "premium_paid": prem,
"need_recycle_usdc": need, "need_recycle_usdc": need,
"premium_ccy": ccy,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"held_seconds": round(held, 1) if recycle_ok else 0.0, "held_seconds": round(held, 1) if recycle_ok else 0.0,
@@ -160,25 +181,39 @@ def check_close_gate(
*, *,
recycle_usdc: float | None = None, recycle_usdc: float | None = None,
premium_paid: float | None = None, premium_paid: float | None = None,
premium_ccy: str | None = None,
refresh: bool = True, refresh: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""检查是否允许平仓;默认先用最新回收/权利金刷新.""" """检查是否允许平仓;默认先用最新回收/权利金刷新."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if refresh: if refresh:
if recycle_usdc is None or premium_paid is None: if recycle_usdc is None or premium_paid is None or premium_ccy is None:
with _lock: with _lock:
prev = _gates.get(inst) or {} prev = _gates.get(inst) or {}
if recycle_usdc is None: if recycle_usdc is None:
recycle_usdc = prev.get("recycle") recycle_usdc = prev.get("recycle")
if premium_paid is None: if premium_paid is None:
premium_paid = prev.get("premium") premium_paid = prev.get("premium")
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) if premium_ccy is None:
premium_ccy = prev.get("premium_ccy")
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
)
with _lock: with _lock:
prev = _gates.get(inst) prev = _gates.get(inst)
if not prev: if not prev:
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
)
return update_close_gate( return update_close_gate(
inst, inst,
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
) )
+479
View File
@@ -0,0 +1,479 @@
"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回."""
from __future__ import annotations
import os
import time
from typing import Any
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
td_mode_for_option_buy,
wait_option_order_full_fill,
)
from lib.options.options_margin_mode_lib import (
calc_sheets_from_coin_balance,
compute_coin_budget_usdt,
is_coin_margin_mode,
margin_mode_from_inst_id,
normalize_options_margin_mode,
plan_coin_open_by_budget,
premium_ccy_for_mode,
)
from lib.options.options_spot_bridge_lib import (
BRIDGE_BOUGHT,
BRIDGE_HOLDING,
bridge_blocks_new_open_msg,
fetch_trading_coin_available,
insert_bridge,
rollback_bought_coin_to_usdt,
sell_residual_after_option_flat,
spot_market_buy_coin_with_usdt,
update_bridge,
)
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True)
trading = bal.get("trading_usdt_avail")
if trading is None:
trading = bal.get("trading_usdt")
try:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
return compute_coin_budget_usdt(trading_f, buffer=buf)
def open_coin_option_buy_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
signal_note: str = "",
target_index: float | None = None,
profit_exit_enabled: bool = False,
profit_exit_mult: float = 1.0,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算)."""
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if not is_coin_margin_mode():
return {"ok": False, "msg": "当前非币本位模式"}
if margin_mode_from_inst_id(inst_id) != "coin":
return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"}
# 解析标的
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
block = bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return {"ok": False, "msg": compound_block, "can_open": False}
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "can_open": False}
budget_info = coin_budget_preview(cfg, ex)
if not budget_info.get("ok"):
return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info}
budget_usdt = float(budget_info["budget_usdt"])
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return q
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"can_open": False,
}
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
idx = None
try:
idx = float(q.get("index_px") or q.get("idxPx") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
try:
from lib.exchange.okx_options_lib import fetch_index_price
idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0)
except Exception:
idx = 0.0
plan = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdt=budget_usdt,
index_px=float(idx),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if not plan.get("ok"):
return {
"ok": False,
"msg": plan.get("msg") or "无法规划买币张数",
"plan": plan,
"budget": budget_info,
"can_open": False,
}
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = fetch_trading_coin_available(ex, underlying) or 0.0
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
if not buy.get("ok"):
return {
"ok": False,
"msg": f"现货买入 {underlying} 失败: {buy.get('msg')}",
"budget": budget_info,
"plan": plan,
}
bridge_id = insert_bridge(
conn,
underlying=underlying,
status=BRIDGE_BOUGHT,
budget_usdt=buy_usdt,
buy_ord_id=str(buy.get("ord_id") or ""),
inst_id=inst_id,
message="已买币,待开期权",
)
# 等余额落账
time.sleep(1.5)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
coin_after = fetch_trading_coin_available(ex, underlying)
if coin_after is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额"
)
return {
"ok": False,
"msg": "买币后读不到可用余额,已尝试卖回 USDT",
"rollback": rb,
"budget": budget_info,
"plan": plan,
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
coin_available=float(coin_bought),
)
if not sizing.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=sizing.get("msg") or "张数不足",
coin_amount=float(coin_bought),
)
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan}
# 实盘以买到的币为准,但不超过规划张数
sheets = min(int(sizing["sheets"]), int(plan["sheets"]))
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz)
if capped is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足"
)
return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb}
if capped < sheets:
sheets = int(capped)
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"ask_depth_capped": True,
}
else:
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
}
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
# 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚)
if (not order.get("ok")) and sheets > 1:
msg_l = str(order.get("msg") or "").lower()
if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""):
sheets2 = max(1, sheets // 2)
if sheets2 < sheets:
order2 = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets2,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
if order2.get("ok"):
order = order2
sheets = sheets2
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"retried_half": True,
}
if not order.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败"
)
return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb}
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号"
)
return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb}
try:
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=int(sheets),
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=fill.get("msg") or "未成交",
)
return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb}
sheets = filled_n
eth_amount = round(float(sheets) * ct_mult, 8)
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
message="期权持仓中",
)
trade_id = _insert_coin_trade(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
strike=q.get("strike"),
exp_time=q.get("exp_time"),
sheets=int(sheets),
eth_amount=eth_amount,
open_quote=float(ask),
premium_paid=premium_paid,
signal_note=signal_note,
exchange_ord_id=ord_id,
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
# 目标位 / 翻倍离场 — 复用现有逻辑若存在
try:
if target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor
upsert_target_monitor(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
target_index=float(target_index),
trade_id=trade_id,
sheets=int(sheets),
)
except Exception:
pass
try:
from lib.options.options_notify_lib import notify_options_open
notify_options_open(
cfg,
conn,
trade_id=trade_id,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
sheets=int(sheets),
premium_paid=premium_paid,
open_quote=float(ask),
target_index=target_index,
signal_note=signal_note,
)
except Exception:
pass
return {
"ok": True,
"msg": f"币本位开仓成功 {sheets}",
"margin_mode": "coin",
"budget": budget_info,
"sizing": sizing,
"sheets": sheets,
"eth_amount": eth_amount,
"premium_paid": premium_paid,
"premium_ccy": premium_ccy,
"bridge_id": bridge_id,
"trade_id": trade_id,
"order": order,
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
cur = conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
kwargs["underlying"],
kwargs["opt_type"],
kwargs.get("strike"),
str(kwargs.get("exp_time") or ""),
kwargs["sheets"],
kwargs["eth_amount"],
kwargs.get("open_quote"),
kwargs.get("premium_paid"),
kwargs.get("signal_note") or "",
kwargs.get("exchange_ord_id"),
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
pe,
pe_mult,
pe_state,
),
)
conn.commit()
return int(cur.lastrowid)
def maybe_sell_spot_after_close(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
except Exception:
pass
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
try:
conn.close()
except Exception:
pass
+12 -1
View File
@@ -101,11 +101,21 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0", "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'", "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
"ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'",
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
except Exception: except Exception:
pass pass
try:
from lib.options.options_spot_bridge_lib import ensure_bridge_table
ensure_bridge_table(conn)
except Exception:
pass
init_options_review_tables(conn) init_options_review_tables(conn)
@@ -124,7 +134,8 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
).fetchone() ).fetchone()
if not row or int(row["n"] or 0) < 1: if not row or int(row["n"] or 0) < 1:
return None return None
return round(float(row["total"] or 0), 4) # 币本位权利金常 <1e-4,保留 8 位避免被裁成 0
return round(float(row["total"] or 0), 8)
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None: def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
+9 -2
View File
@@ -14,14 +14,21 @@ def enrich_position_row_display(
meta_cache: dict[str, dict[str, Any] | None] | None = None, meta_cache: dict[str, dict[str, Any] | None] | None = None,
premium_override: float | None = None, premium_override: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult from lib.exchange.okx_options_lib import format_position_row, format_premium_amount, tick_sz_and_ct_mult
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
inst_id = str(raw_pos.get("instId") or "").strip() inst_id = str(raw_pos.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = str(row.get("underlying") or (inst_id.split("-")[0] if inst_id else "ETH") or "ETH")
premium_ccy = premium_ccy_for_mode(row_mode, underly)
row["margin_mode"] = row_mode
row["premium_ccy"] = premium_ccy
row["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
if premium_override is not None: if premium_override is not None:
row["premium_paid"] = premium_override row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_usdc_amount(premium_override) row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
return row return row
+166
View File
@@ -0,0 +1,166 @@
"""中控只读聚合:OKX 期权持仓 / 资金(轻量,不含历史统计)."""
from __future__ import annotations
import os
from typing import Any
def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
if not cfg.get("enabled"):
return {"ok": True, "enabled": False}
ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready")
if not callable(ready_fn):
return {"ok": False, "enabled": True, "msg": "期权模块未就绪"}
ok, reason = ready_fn(ex)
if not ok:
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
try:
from lib.options.options_position_limit_lib import options_max_active_positions
from lib.options.options_positions_lib import build_display_option_positions
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw)
target_monitors: list[dict[str, Any]] = []
try:
conn = cfg["get_db"]()
try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
{
"inst_id": pe.get("inst_id"),
"exit_mode": "profit_exit",
"profit_exit_mult": pe.get("profit_exit_mult"),
"profit_exit_enabled": True,
}
)
for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon:
p["target_index"] = mon.get("target_index")
p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
if pe:
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target:
p["hedge_plan_target"] = hedge_target
if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index")
try:
from lib.instance.instance_dashboard_lib import (
_format_options_target,
_resolve_options_source,
)
inst = str(p.get("inst_id") or "")
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
p["source"] = source_key
p["source_label"] = source_label
p["source_plan_id"] = source_plan_id
p["target_monitor_text"] = _format_options_target(p)
except Exception:
p.setdefault("source_label", "")
p.setdefault("source_plan_id", None)
p.setdefault("target_monitor_text", "")
finally:
conn.close()
except Exception:
target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row
upl_total = 0.0
has_upl = False
for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
pnl = display_pnl_from_option_row(p)
if pnl is None:
continue
has_upl = True
upl_total += float(pnl)
bal = cfg["fetch_options_balances"](ex)
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
normalize_options_margin_mode,
premium_ccy_for_mode,
)
margin_mode = normalize_options_margin_mode()
for p in positions:
mid = str(p.get("inst_id") or "")
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode
p["margin_mode"] = row_mode
p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode(
row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH")
)
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
coin_budget = None
bridge_status = None
open_bridges = []
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
try:
conn_b = cfg["get_db"]()
try:
from lib.options.options_spot_bridge_lib import list_open_bridges
open_bridges = list_open_bridges(conn_b)
if open_bridges:
bridge_status = str(open_bridges[0].get("status") or "")
finally:
conn_b.close()
except Exception:
open_bridges = []
return {
"ok": True,
"enabled": True,
"positions": positions,
"position_count": len(positions),
"target_monitors": target_monitors,
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
"balances": bal,
"funding_usdc": bal.get("funding_usdc"),
"funding_usdt": bal.get("funding_usdt"),
"trading_usdc": bal.get("trading_usdc"),
"trading_usdt": bal.get("trading_usdt"),
# 监控区不用历史统计;保留空对象兼容旧调用方
"stats": {},
"trade_budget": cfg.get("trade_budget"),
"account_label": cfg.get("account_label") or "OKX期权",
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH",
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
}
except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)}
+325
View File
@@ -0,0 +1,325 @@
"""OKX 单笔期权本位模式与币本位 USDT 预算."""
from __future__ import annotations
import os
from typing import Any
MODE_USDC = "usdc"
MODE_COIN = "coin"
def _env_bool(name: str, default: bool = False) -> bool:
v = (os.getenv(name) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on", "y")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def normalize_options_margin_mode(raw: Any = None) -> str:
"""返回 usdc | coin;未配置时默认币本位."""
if raw is None:
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
v = str(raw or MODE_COIN).strip().lower()
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
return MODE_USDC
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
return MODE_COIN
# 空串或未知值:默认币本位
if not v:
return MODE_COIN
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode)
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
def margin_mode_from_inst_id(inst_id: str) -> str:
inst = (inst_id or "").strip().upper()
if not inst:
return normalize_options_margin_mode()
if "_UM" in inst:
return MODE_USDC
# ETH-USD-260701-2500-C / BTC-USD-...
if "-USD-" in inst and "_UM" not in inst:
return MODE_COIN
return normalize_options_margin_mode()
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
return (underlying or "ETH").strip().upper() or "ETH"
return "USDC"
def spot_quote_inst_id(underlying: str) -> str:
"""现货市价买卖: ETH-USDT / BTC-USDT."""
u = (underlying or "ETH").strip().upper() or "ETH"
return f"{u}-USDT"
def compute_coin_budget_usdt(
trading_usdt: float,
*,
compound: bool | None = None,
buffer: float | None = None,
fixed_budget_usdt: float | None = None,
max_enabled: bool | None = None,
max_usdt: float | None = None,
) -> dict[str, Any]:
"""
币本位单笔 USDT 预算.
复利开: trading_usdt × buffer; 复利关: fixed × buffer.
上限开: min(..., max_usdt).
"""
bal = max(0.0, float(trading_usdt or 0))
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if buf <= 0:
buf = 0.95
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0)
)
if use_compound:
raw = bal * buf
source = "compound"
else:
raw = max(0.0, fixed) * buf
source = "fixed"
capped = False
max_on = (
_env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False)
if max_enabled is None
else bool(max_enabled)
)
max_n = (
float(max_usdt)
if max_usdt is not None
else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0)
)
budget = raw
if max_on and max_n > 0 and budget > max_n:
budget = max_n
capped = True
return {
"ok": budget > 0,
"budget_usdt": round(budget, 8),
"raw_usdt": round(raw, 8),
"trading_usdt": round(bal, 8),
"buffer": buf,
"compound": use_compound,
"source": source,
"max_enabled": max_on,
"max_usdt": max_n if max_on else None,
"capped_by_max": capped,
"msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算",
}
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
if v <= 0:
return 1.10
if v < 1.0:
return 1.0 + v
return v
def plan_coin_open_by_budget(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdt: float,
index_px: float,
ask_sz: float | None = None,
spot_buy_buffer: float | None = None,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""
先按预算/卖一估最大可开张数,再按权利金 × 现货缓冲算应买现货 USDT.
不全额把预算换成币.
"""
import math
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
budget = max(0.0, float(budget_usdt or 0))
idx = float(index_px or 0)
buf = normalize_coin_spot_buy_buffer(spot_buy_buffer)
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0}
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0}
if budget <= 0:
return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0}
per_sheet_coin = ask * mult
# 每张开仓需买的币(含缓冲)及其约合 USDT
per_sheet_buy_coin = per_sheet_coin * buf
per_sheet_usdt = per_sheet_buy_coin * idx
if per_sheet_usdt <= 0:
return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0}
max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12))
if target_sheets is not None:
try:
want = int(target_sheets)
except (TypeError, ValueError):
want = 0
if want < need:
return {
"ok": False,
"msg": f"指定张数无效(需≥{need})",
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
}
sheets = min(want, max_by_budget)
if sheets < want:
return {
"ok": False,
"msg": (
f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g}),"
f"不足指定 {want}"
),
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
"spot_buy_buffer": buf,
}
else:
sheets = max_by_budget
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need)
ask_depth_capped = False
if capped is None:
return {
"ok": False,
"msg": cap_msg or "卖一深度不足",
"sheets": 0,
"buy_usdt": 0.0,
"spot_buy_buffer": buf,
}
if int(capped) < sheets:
sheets = int(capped)
ask_depth_capped = True
if sheets < need:
return {
"ok": False,
"msg": (
f"预算不足,无法买入 {need}"
f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})"
),
"sheets": sheets,
"buy_usdt": 0.0,
"per_sheet_usdt": round(per_sheet_usdt, 8),
"spot_buy_buffer": buf,
"max_by_budget": max_by_budget,
}
premium_coin = sheets * per_sheet_coin
buy_coin = premium_coin * buf
buy_usdt = min(budget, buy_coin * idx)
# 再保险:向下对齐,避免浮点导致略超预算
buy_usdt = min(budget, round(buy_usdt, 8))
out = {
"ok": True,
"msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets}"),
"sheets": sheets,
"eth_amount": round(sheets * mult, 8),
"coin_premium": round(premium_coin, 8),
"total_premium": round(premium_coin, 8),
"per_sheet_coin": per_sheet_coin,
"buy_coin": round(buy_coin, 8),
"buy_usdt": round(buy_usdt, 8),
"budget_usdt": round(budget, 8),
"spot_buy_buffer": buf,
"index_px": idx,
"max_by_budget": max_by_budget,
"ask_depth_capped": ask_depth_capped,
"est_note": (
f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;"
f"不全额兑换预算"
),
}
if target_sheets is not None:
out["est_note"] = (
f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换"
)
out["target_sheets"] = int(target_sheets)
return out
def calc_sheets_from_coin_balance(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
coin_available: float,
) -> dict[str, Any]:
"""用可用标的币尽量开满(权利金以币计)."""
import math
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
coin = max(0.0, float(coin_available or 0))
# 留一点手续费/精度缓冲,避免算满张后下单 51008
coin_eff = coin * 0.97
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0}
per_sheet = ask * mult
if per_sheet <= 0:
return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0}
sheets = int(math.floor((coin_eff / per_sheet) + 1e-12))
if sheets < need:
return {
"ok": False,
"msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})",
"sheets": sheets,
"coin_premium": round(sheets * per_sheet, 8),
"per_sheet_coin": per_sheet,
}
prem = sheets * per_sheet
return {
"ok": True,
"msg": "",
"sheets": sheets,
"coin_premium": round(prem, 8),
"per_sheet_coin": per_sheet,
"eth_amount": round(sheets * mult, 8),
}
+5 -1
View File
@@ -51,9 +51,12 @@ def attach_close_preview(
intrinsic_px=intrinsic, intrinsic_px=intrinsic,
max_levels=1, max_levels=1,
) )
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
# 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要) # 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"): if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid) gate = update_close_gate(
inst_id, recycle_usdc=None, premium_paid=paid, premium_ccy=premium_ccy
)
preview["close_gate"] = gate preview["close_gate"] = gate
preview["close_gate_blocked"] = True preview["close_gate_blocked"] = True
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg") preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
@@ -64,6 +67,7 @@ def attach_close_preview(
inst_id, inst_id,
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid, premium_paid=paid,
premium_ccy=premium_ccy,
) )
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready")) passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate preview["close_gate"] = gate
+14 -1
View File
@@ -512,12 +512,25 @@ def equivalent_contract_leverage(
index_px: float | None, index_px: float | None,
eth_amount: float | None, eth_amount: float | None,
total_premium: float | None, total_premium: float | None,
margin_mode: str | None = None,
) -> float | None: ) -> float | None:
"""名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用).""" """名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用).
USDC: 权利金为美元 index×eth/premium.
币本位: 权利金为币 eth/premium(=1/ask premium=ask×eth).
"""
if index_px is None or eth_amount is None or total_premium is None: if index_px is None or eth_amount is None or total_premium is None:
return None return None
if eth_amount <= 0 or total_premium <= 0: if eth_amount <= 0 or total_premium <= 0:
return None return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
mode = normalize_options_margin_mode(margin_mode)
except Exception:
mode = (str(margin_mode or "usdc").strip().lower() or "usdc")
if mode == "coin":
return round(float(eth_amount) / float(total_premium), 1)
return round(float(index_px) * float(eth_amount) / float(total_premium), 1) return round(float(index_px) * float(eth_amount) / float(total_premium), 1)
+283 -21
View File
@@ -59,10 +59,21 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
cfg = _build_cfg(app_module) cfg = _build_cfg(app_module)
app.extensions["options_cfg"] = cfg app.extensions["options_cfg"] = cfg
register_options_routes(app, cfg) register_options_routes(app, cfg)
_register_options_hub_bridge(app, cfg)
if enabled: if enabled:
_start_monitor_thread(app, cfg) _start_monitor_thread(app, cfg)
def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None:
from lib.options.options_hub_lib import build_options_hub_snapshot
def snapshot_fn():
return build_options_hub_snapshot(cfg)
hub_ctx = dict(app.config.get("HUB_CTX") or {})
hub_ctx["options_snapshot_fn"] = snapshot_fn
app.config["HUB_CTX"] = hub_ctx
def _build_cfg(app_module: Any) -> dict[str, Any]: def _build_cfg(app_module: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import ( from lib.exchange.okx_options_lib import (
@@ -83,7 +94,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
transfer_ccy, transfer_ccy,
) )
cfg = { return {
"enabled": _env_bool("OKX_OPTIONS_ENABLED", False), "enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
"get_db": app_module.get_db, "get_db": app_module.get_db,
"login_required": app_module.login_required, "login_required": app_module.login_required,
@@ -95,6 +106,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True),
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
"margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(),
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
@@ -125,12 +137,6 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"options_api_ready": options_api_ready, "options_api_ready": options_api_ready,
"app_module": app_module, "app_module": app_module,
} }
try:
from lib.sim.hooks import patch_options_cfg
return patch_options_cfg(cfg)
except Exception:
return cfg
def _mark_balances_stale(cfg: dict[str, Any]) -> None: def _mark_balances_stale(cfg: dict[str, Any]) -> None:
@@ -370,10 +376,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
from flask import render_template_string from flask import render_template_string
from lib.common.markdown_html_lib import render_markdown_html from lib.hub.hub_strategy_lib import render_markdown_html
from lib.paths import REPO_ROOT
md_path = REPO_ROOT / "docs" / "期权开平仓与监控说明.md" md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md"
try: try:
md_text = md_path.read_text(encoding="utf-8") md_text = md_path.read_text(encoding="utf-8")
except OSError: except OSError:
@@ -417,16 +422,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": err}) return jsonify({"ok": False, "msg": err})
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
bal = cfg["fetch_options_balances"](ex, force=force, scope="main") bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
return jsonify( from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode
{
"ok": True, margin_mode = normalize_options_margin_mode()
**bal, payload = {
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), "ok": True,
"compound_full_enabled": _compound_full_enabled(), **bal,
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), "compound_full_enabled": _compound_full_enabled(),
} "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
) "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
}
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
payload["coin_budget"] = coin_budget_preview(cfg, ex)
except Exception as e:
payload["coin_budget"] = {"ok": False, "msg": str(e)}
return jsonify(payload)
@app.route("/api/options/chain") @app.route("/api/options/chain")
@lr @lr
@@ -438,12 +454,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:链展示天数每次读 env,保存后刷新链即可 # 热更新:链展示天数每次读 env,保存后刷新链即可
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
try: try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
chain = cfg["build_option_chain"]( chain = cfg["build_option_chain"](
ex, ex,
u, u,
max_dte_days=chain_max_dte, max_dte_days=chain_max_dte,
itm_only=False, itm_only=False,
itm_max_dist_usd=cfg["itm_max_dist"], itm_max_dist_usd=cfg["itm_max_dist"],
margin_mode=margin_mode,
) )
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
@@ -452,6 +472,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:每次读 env,保存配置后刷新链即可生效 # 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
coin_budget = None
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_coin_open_lib import coin_budget_preview
if is_coin_margin_mode():
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
if not expiries: if not expiries:
return jsonify( return jsonify(
{ {
@@ -462,6 +491,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter, "ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer, "budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"], "trade_budget": cfg["trade_budget"],
"options_margin_mode": chain.get("margin_mode") or margin_mode,
"coin_budget": coin_budget,
} }
) )
return jsonify( return jsonify(
@@ -472,6 +503,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter, "ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer, "budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"], "trade_budget": cfg["trade_budget"],
"options_margin_mode": chain.get("margin_mode") or margin_mode,
"coin_budget": coin_budget,
} }
) )
@@ -500,8 +533,158 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if mode == "close_preview": if mode == "close_preview":
paid = _open_premium_paid(cfg, inst_id) paid = _open_premium_paid(cfg, inst_id)
target = sheet_count if sheet_count is not None else 0 target = sheet_count if sheet_count is not None else 0
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid)) from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id)
prem_ccy = premium_ccy_for_mode(row_mode, (inst_id.split("-")[0] if inst_id else "ETH"))
preview_row = {
**q,
"pos": target,
"premium_paid": paid,
"margin_mode": row_mode,
"premium_ccy": prem_ccy,
}
out = _attach_close_preview(cfg, ex, preview_row, sheets=target, premium_paid=paid)
out["options_margin_mode"] = row_mode
out["premium_ccy"] = prem_ccy
return jsonify(out)
mode, mode_note = _normalize_size_mode(mode) mode, mode_note = _normalize_size_mode(mode)
# 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC
try:
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
margin_mode_from_inst_id,
)
from lib.options.options_coin_open_lib import coin_budget_preview
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
if is_coin_margin_mode():
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
try:
from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg
mode_block = block_standalone_open_by_mode_msg()
except Exception as e:
return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"})
if mode_block:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": mode_block,
"options_margin_mode": "coin",
"sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0},
}
)
if margin_mode_from_inst_id(inst_id) != "coin":
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)",
"options_margin_mode": "coin",
"sizing": {
"ok": False,
"msg": "合约非币本位",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
budget_info = coin_budget_preview(cfg, ex)
if not can_open:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入",
"options_margin_mode": "coin",
"coin_budget": budget_info,
"sizing": {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
if not budget_info.get("ok"):
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": budget_info.get("msg") or "交易账户 USDT 不足",
"options_margin_mode": "coin",
"coin_budget": budget_info,
"sizing": {
"ok": False,
"msg": budget_info.get("msg") or "交易账户 USDT 不足",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx"))
budget_usdt = float(budget_info["budget_usdt"])
target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None
if mode == "eth" and request.args.get("eth"):
# 指定币量:按币量反推张数后再走统一规划
try:
eth_want = float(request.args.get("eth"))
except (TypeError, ValueError):
eth_want = 0.0
if eth_want > 0 and float(ct_mult) > 0:
import math
target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12)))
from lib.options.options_margin_mode_lib import plan_coin_open_by_budget
sizing = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=float(ct_mult),
min_sz=int(min_sz),
budget_usdt=budget_usdt,
index_px=float(idx or 0),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if sizing.get("ok"):
sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper()
sizing["est_coin"] = sizing.get("buy_coin")
q = _attach_close_preview(
cfg,
ex,
q,
sheets=int(sizing.get("sheets") or 0),
premium_paid=_open_premium_paid(cfg, inst_id),
)
return jsonify(
{
**q,
"can_open": bool(sizing.get("ok")),
"quote_per_unit": ask,
"premium_per_sheet": round(float(ask) * float(ct_mult), 8),
"sizing": sizing,
"mode": mode,
"mode_note": mode_note,
"options_margin_mode": "coin",
"coin_budget": budget_info,
"compound_full_enabled": _compound_full_enabled(),
}
)
except Exception as e:
return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"})
budget = cfg["trade_budget"] budget = cfg["trade_budget"]
budget_cap = cfg["trade_budget"] budget_cap = cfg["trade_budget"]
available_usdc = None available_usdc = None
@@ -806,6 +989,48 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
if not inst_id: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"}) return jsonify({"ok": False, "msg": "缺少 inst_id"})
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_coin_open_lib import open_coin_option_buy_full
if is_coin_margin_mode():
want_sheets = None
if mode == "sheets":
try:
want_sheets = int(data.get("sheets") or 0) or None
except (TypeError, ValueError):
want_sheets = None
elif mode == "eth":
try:
eth_want = float(data.get("eth") or 0)
except (TypeError, ValueError):
eth_want = 0.0
if eth_want > 0:
q0 = cfg["quote_option_contract"](ex, inst_id)
ct0 = float((q0 or {}).get("ct_mult") or 0.01)
min0 = int((q0 or {}).get("min_sz") or 1)
if ct0 > 0:
import math
want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12)))
result = open_coin_option_buy_full(
cfg,
ex,
inst_id=inst_id,
signal_note=signal_note,
target_index=target_index,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
target_sheets=want_sheets,
)
if result.get("ok"):
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache()
_mark_balances_stale(cfg)
return jsonify(result)
except Exception as e:
return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"})
q = cfg["quote_option_contract"](ex, inst_id) q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"): if not q.get("ok"):
return jsonify(q) return jsonify(q)
@@ -1430,9 +1655,46 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn2.close() conn2.close()
except Exception: except Exception:
pass pass
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result)
if spot_sell is not None:
result = dict(result)
result["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
result["msg"] = (
str(result.get("msg") or "平仓成功")
+ ";但卖回 USDT 失败,请点「重试卖回」"
)
except Exception as e:
result = dict(result)
result["spot_sell"] = {"ok": False, "msg": str(e)}
_mark_balances_stale(cfg) _mark_balances_stale(cfg)
return jsonify(result) return jsonify(result)
@app.route("/api/options/spot-bridge/retry-sell", methods=["POST"])
@lr
def api_options_spot_bridge_retry_sell():
"""币本位:重试把残留标的币市价卖回 USDT."""
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
data = request.get_json(silent=True) or {}
underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper()
inst_id = (data.get("inst_id") or "").strip() or None
conn = cfg["get_db"]()
try:
init_options_tables(conn)
from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat
out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
if out.get("ok"):
_mark_balances_stale(cfg)
return jsonify(out)
finally:
conn.close()
@app.route("/api/options/convert/quote", methods=["POST"]) @app.route("/api/options/convert/quote", methods=["POST"])
@lr @lr
def api_options_convert_quote(): def api_options_convert_quote():
+415
View File
@@ -0,0 +1,415 @@
"""币本位期权:USDT↔标的币现货桥与本地状态."""
from __future__ import annotations
import sqlite3
import time
from typing import Any
from lib.options.options_margin_mode_lib import spot_quote_inst_id
BRIDGE_BOUGHT = "bought_pending_open"
BRIDGE_HOLDING = "holding"
BRIDGE_PENDING_SELL = "pending_sell_spot"
BRIDGE_CLOSED = "closed"
def ensure_bridge_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_spot_bridge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
underlying TEXT NOT NULL,
status TEXT NOT NULL,
budget_usdt REAL,
buy_ord_id TEXT,
coin_bought REAL,
sell_ord_id TEXT,
coin_sold REAL,
usdt_recovered REAL,
inst_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_spot_bridge_status
ON options_spot_bridge(status)
"""
)
def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_bridge_table(conn)
cur = conn.execute(
"""
SELECT id, underlying, status, budget_usdt, buy_ord_id, coin_bought,
sell_ord_id, coin_sold, usdt_recovered, inst_id, message,
created_at, updated_at, closed_at
FROM options_spot_bridge
WHERE status IN (?, ?, ?)
ORDER BY id DESC
""",
(BRIDGE_BOUGHT, BRIDGE_HOLDING, BRIDGE_PENDING_SELL),
)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def has_unfinished_bridge(conn: sqlite3.Connection) -> bool:
return bool(list_open_bridges(conn))
def insert_bridge(
conn: sqlite3.Connection,
*,
underlying: str,
status: str,
budget_usdt: float | None = None,
buy_ord_id: str | None = None,
coin_bought: float | None = None,
inst_id: str | None = None,
message: str | None = None,
) -> int:
ensure_bridge_table(conn)
cur = conn.execute(
"""
INSERT INTO options_spot_bridge(
underlying, status, budget_usdt, buy_ord_id, coin_bought, inst_id, message, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(
(underlying or "ETH").upper(),
status,
budget_usdt,
buy_ord_id,
coin_bought,
inst_id,
message,
),
)
conn.commit()
return int(cur.lastrowid)
def update_bridge(
conn: sqlite3.Connection,
bridge_id: int,
*,
status: str | None = None,
buy_ord_id: str | None = None,
coin_bought: float | None = None,
sell_ord_id: str | None = None,
coin_sold: float | None = None,
usdt_recovered: float | None = None,
inst_id: str | None = None,
message: str | None = None,
close: bool = False,
) -> None:
ensure_bridge_table(conn)
fields: list[str] = ["updated_at=CURRENT_TIMESTAMP"]
vals: list[Any] = []
if status is not None:
fields.append("status=?")
vals.append(status)
if buy_ord_id is not None:
fields.append("buy_ord_id=?")
vals.append(buy_ord_id)
if coin_bought is not None:
fields.append("coin_bought=?")
vals.append(coin_bought)
if sell_ord_id is not None:
fields.append("sell_ord_id=?")
vals.append(sell_ord_id)
if coin_sold is not None:
fields.append("coin_sold=?")
vals.append(coin_sold)
if usdt_recovered is not None:
fields.append("usdt_recovered=?")
vals.append(usdt_recovered)
if inst_id is not None:
fields.append("inst_id=?")
vals.append(inst_id)
if message is not None:
fields.append("message=?")
vals.append(message)
if close or status == BRIDGE_CLOSED:
fields.append("closed_at=CURRENT_TIMESTAMP")
vals.append(int(bridge_id))
conn.execute(
f"UPDATE options_spot_bridge SET {', '.join(fields)} WHERE id=?",
vals,
)
conn.commit()
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def fetch_trading_coin_available(ex: Any, ccy: str) -> float | None:
"""交易账户标的币可用."""
from lib.exchange.okx_options_lib import _extract_ccy_free, _safe_float as _sf
ccy_u = (ccy or "").upper()
if not ccy_u:
return None
try:
bal = ex.fetch_balance(params={"type": "trading"})
free = _extract_ccy_free(bal, ccy_u)
if free is not None:
return float(free)
# 部分账户结构只有 total
from lib.exchange.okx_options_lib import _extract_ccy_balance
tot = _extract_ccy_balance(bal, ccy_u)
return float(tot) if tot is not None else None
except Exception:
return None
def spot_market_buy_coin_with_usdt(
ex: Any,
*,
underlying: str,
usdt_amount: float,
) -> dict[str, Any]:
"""交易账户:用 USDT 市价买入标的币."""
if usdt_amount <= 0:
return {"ok": False, "msg": "USDT 数量须大于 0"}
inst_id = spot_quote_inst_id(underlying)
try:
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": str(usdt_amount),
"tgtCcy": "quote_ccy",
}
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {
"ok": True,
"inst_id": inst_id,
"ord_id": str(data[0].get("ordId") or ""),
"data": data[0],
"raw": resp,
}
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(e)}
def spot_market_sell_coin_to_usdt(
ex: Any,
*,
underlying: str,
coin_amount: float | None = None,
) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.coin_amount 空则尽量卖光可用."""
ccy = (underlying or "ETH").upper()
amt = coin_amount
if amt is None or float(amt) <= 0:
avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
amt = float(avail)
if float(amt) <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
# 留一点粉尘避免精度拒单
sell_sz = float(amt)
if sell_sz > 1e-8:
sell_sz = max(0.0, sell_sz * 0.999)
inst_id = spot_quote_inst_id(ccy)
try:
# 现货卖出数量精度:截到 8 位
sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".")
if not sz or float(sz) <= 0:
return {"ok": False, "msg": f"{ccy} 可卖数量过小"}
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "sell",
"ordType": "market",
"sz": sz,
"tgtCcy": "base_ccy",
}
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {
"ok": True,
"inst_id": inst_id,
"ord_id": str(data[0].get("ordId") or ""),
"coin_sold": float(sz),
"data": data[0],
"raw": resp,
}
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(e)}
def rollback_bought_coin_to_usdt(
conn: sqlite3.Connection,
ex: Any,
*,
bridge_id: int,
underlying: str,
reason: str = "",
coin_amount: float | None = None,
) -> dict[str, Any]:
"""买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量."""
amt = coin_amount
if amt is None or float(amt) <= 0:
ensure_bridge_table(conn)
row = conn.execute(
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if row:
try:
amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0)
except (TypeError, ValueError, KeyError, IndexError):
amt = None
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt)
if not sell.get("ok"):
update_bridge(
conn,
bridge_id,
status=BRIDGE_PENDING_SELL,
message=(reason or "") + " | 回滚卖币失败: " + str(sell.get("msg") or ""),
)
return {"ok": False, "msg": sell.get("msg") or "回滚卖币失败", "bridge_status": BRIDGE_PENDING_SELL}
update_bridge(
conn,
bridge_id,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
coin_sold=_safe_float(sell.get("coin_sold")),
message=reason or "开仓失败已卖回 USDT",
close=True,
)
return {"ok": True, "sell": sell, "bridge_status": BRIDGE_CLOSED}
def sell_residual_after_option_flat(
conn: sqlite3.Connection,
ex: Any,
*,
underlying: str,
inst_id: str | None = None,
) -> dict[str, Any]:
"""期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥."""
ensure_bridge_table(conn)
bridges = list_open_bridges(conn)
target = None
for b in bridges:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
target = b
break
coin_amt = None
if target is not None:
try:
coin_amt = float(target.get("coin_bought") or 0) or None
except (TypeError, ValueError):
coin_amt = None
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=coin_amt)
if target is None:
if not sell.get("ok"):
msg = str(sell.get("msg") or "")
if "无可用" in msg or "过小" in msg:
return {"ok": True, "msg": "无残留币需卖回", "skipped": True}
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL}
return {"ok": True, "sell": sell, "bridge_status": None}
bid = int(target["id"])
if not sell.get("ok"):
update_bridge(
conn,
bid,
status=BRIDGE_PENDING_SELL,
inst_id=inst_id,
message=str(sell.get("msg") or "卖回 USDT 失败"),
)
return {
"ok": False,
"msg": sell.get("msg") or "卖回 USDT 失败",
"bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL,
}
update_bridge(
conn,
bid,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
coin_sold=_safe_float(sell.get("coin_sold")),
inst_id=inst_id,
message="期权已平,币已卖回 USDT",
close=True,
)
return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED}
def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
bridges = list_open_bridges(conn)
if not bridges:
return None
st = str(bridges[0].get("status") or "")
if st == BRIDGE_PENDING_SELL:
return "存在待卖回 USDT 的币本位桥残留,请先到期权页重试卖回后再开仓"
if st == BRIDGE_BOUGHT:
return "存在已买币未完成开仓的桥流程,请等待回滚或联系处理后重试"
if st == BRIDGE_HOLDING:
return "币本位桥仍在持仓中(一次仅一笔),请先平仓并卖回 USDT"
return "存在未完成的币本位资金桥,暂不可开仓"
def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None:
"""有单笔期权仓或未完成桥时禁止切换本位."""
if has_unfinished_bridge(conn):
return "存在未完成的币本位资金桥,禁止切换期权本位模式"
if ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
rows = fetch_option_positions(ex) or []
for p in rows:
try:
pos = float(p.get("pos") or 0)
except (TypeError, ValueError):
pos = 0.0
if abs(pos) > 1e-12:
return "存在未平期权持仓,禁止切换期权本位模式"
except Exception:
pass
# 本地 open 交易记录
try:
row = conn.execute(
"SELECT COUNT(*) FROM options_trades WHERE status='open'"
).fetchone()
n = int(row[0] if not isinstance(row, dict) else row.get("COUNT(*)") or list(row.values())[0])
if n > 0:
return "本地仍有未平期权记录,禁止切换期权本位模式"
except Exception:
pass
return None
+20 -12
View File
@@ -8,7 +8,7 @@
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}"> data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %} {% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
{% if not options_enabled %} {% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code><code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div> <div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>项目根目录/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code><code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %} {% endif %}
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %} {% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div> <div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
@@ -28,16 +28,11 @@
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li> <li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li> <li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li> <li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code> 默认):按最大可开张数×卖一权利金×现货缓冲(<code>OKX_OPTIONS_COIN_SPOT_BUY_BUFFER</code> 默认1.10)买 ETH/BTC,不全额兑换;平仓后卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。1 张名义以交易所 <code>ctMult</code> 为准(常见0.1)。</li>
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li> <li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
<li>平仓仅买一限价,详见说明文档。</li>
</ul> </ul>
<p><strong>买一平仓</strong>:平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p> <p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
<ul>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
</ul>
</div> </div>
</details> </details>
<div class="form-row options-chain-toolbar"> <div class="form-row options-chain-toolbar">
@@ -69,7 +64,7 @@
<th>类型</th> <th>类型</th>
<th>合约</th> <th>合约</th>
<th>卖一/张</th> <th>卖一/张</th>
<th title="指数÷卖一(每1币)">杠杆</th> <th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
<th>买一/张</th> <th>买一/张</th>
<th>到期平衡</th> <th>到期平衡</th>
<th>距平衡</th> <th>距平衡</th>
@@ -208,6 +203,20 @@
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div> <div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
<div id="opt-pos-cards"></div> <div id="opt-pos-cards"></div>
</div> </div>
<details class="opt-close-rule">
<summary>买一平仓规则说明</summary>
<div class="opt-close-rule-body">
<p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
<ul>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
</div> </div>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden> <div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
<div class="opt-pos-pending-pane"> <div class="opt-pos-pending-pane">
@@ -343,5 +352,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_settings.js?v=11"></script> <script src="/static/options_panel.js?v=65"></script>
<script src="/static/options_panel.js?v=64"></script>