对齐币本位期权:现货缓冲开仓、页头 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 ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
+430 -426
View File
@@ -1,426 +1,430 @@
"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注).""" """从 .env.example 构建 env 配置 schema(分组,敏感,重启标注)."""
from __future__ import annotations from __future__ import annotations
import os import os
import re import re
from typing import Any, Optional from typing import Any, Optional
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$") _GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
_SEPARATOR_RE = re.compile(r"^#\s*=+\s*$") _SEPARATOR_RE = re.compile(r"^#\s*=+\s*$")
_SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$") _SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$")
_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=") _KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
RESTART_REQUIRED_EXACT = frozenset({ RESTART_REQUIRED_EXACT = frozenset({
"APP_HOST", "APP_HOST",
"APP_PORT", "APP_PORT",
"APP_DEBUG", "APP_DEBUG",
"DB_PATH", "DB_PATH",
"UPLOAD_DIR", "UPLOAD_DIR",
"FLASK_SECRET_KEY", "FLASK_SECRET_KEY",
"POSITION_SIZING_MODE", "POSITION_SIZING_MODE",
"LIVE_TRADING_ENABLED", "LIVE_TRADING_ENABLED",
"OKX_TD_MODE", "OKX_TD_MODE",
"OKX_POS_MODE", "OKX_POS_MODE",
"OKX_POSITION_INST_TYPE", "OKX_POSITION_INST_TYPE",
"BINANCE_MARGIN_MODE", "BINANCE_MARGIN_MODE",
"BINANCE_POSITION_MODE", "BINANCE_POSITION_MODE",
"GATE_TD_MODE", "GATE_TD_MODE",
"GATE_POS_MODE", "GATE_POS_MODE",
"PM2_APP_NAME", "PM2_APP_NAME",
}) })
RESTART_REQUIRED_PREFIXES = ( RESTART_REQUIRED_PREFIXES = (
"OKX_API_", "OKX_API_",
"OKX_OPTIONS_API_", "OKX_OPTIONS_API_",
"BINANCE_API_", "BINANCE_API_",
"GATE_API_", "GATE_API_",
"OKX_SOCKS_", "OKX_SOCKS_",
"OKX_HTTP_", "OKX_HTTP_",
"OKX_HTTPS_", "OKX_HTTPS_",
"BINANCE_HTTP_", "BINANCE_HTTP_",
"BINANCE_HTTPS_", "BINANCE_HTTPS_",
"GATE_HTTP_", "GATE_HTTP_",
"GATE_HTTPS_", "GATE_HTTPS_",
) )
HOT_RELOAD_EXACT = frozenset({ HOT_RELOAD_EXACT = frozenset({
"RISK_PERCENT", "RISK_PERCENT",
"MAX_ACTIVE_POSITIONS", "MAX_ACTIVE_POSITIONS",
"MANUAL_MIN_PLANNED_RR", "MANUAL_MIN_PLANNED_RR",
"DAILY_OPEN_ALERT_THRESHOLD", "KEY_AUTO_MIN_PLANNED_RR",
"DAILY_OPEN_HARD_LIMIT", "DAILY_OPEN_ALERT_THRESHOLD",
"TRADING_DAY_RESET_HOUR", "DAILY_OPEN_HARD_LIMIT",
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "TRADING_DAY_RESET_HOUR",
"RISK_CONTROL_ENABLED", "TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"RISK_COOLING_HOURS_MANUAL", "RISK_CONTROL_ENABLED",
"RISK_COOLING_HOURS_MANUAL_JOURNAL", "RISK_COOLING_HOURS_MANUAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT", "RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_DAILY_LOSS_LIMIT", "RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE", "RISK_DAILY_LOSS_LIMIT",
"TRADE_DIRECTION_RESTRICT_ENABLED", "RISK_MOOD_ISSUES_DAILY_FREEZE",
"TRADE_DIRECTION", "KEY_AUTO_ORDER_ENABLED",
"TRADE_SYMBOL_RESTRICT_ENABLED", "TRADE_DIRECTION_RESTRICT_ENABLED",
"TRADE_SYMBOL_WHITELIST", "TRADE_DIRECTION",
"BALANCE_REFRESH_SECONDS", "TRADE_SYMBOL_RESTRICT_ENABLED",
"PRICE_REFRESH_SECONDS", "TRADE_SYMBOL_WHITELIST",
"MONITOR_POLL_SECONDS", "BALANCE_REFRESH_SECONDS",
"AUTO_TRANSFER_ENABLED", "PRICE_REFRESH_SECONDS",
"AUTO_TRANSFER_AMOUNT", "MONITOR_POLL_SECONDS",
"AUTO_TRANSFER_FROM", "AUTO_TRANSFER_ENABLED",
"AUTO_TRANSFER_TO", "AUTO_TRANSFER_AMOUNT",
"AUTO_TRANSFER_BJ_HOUR", "AUTO_TRANSFER_FROM",
"TRANSFER_CCY", "AUTO_TRANSFER_TO",
"FORCE_CLOSE_ENABLED", "AUTO_TRANSFER_BJ_HOUR",
"FORCE_CLOSE_BJ_HOUR", "TRANSFER_CCY",
"FORCE_CLOSE_GRACE_MINUTES", "FORCE_CLOSE_ENABLED",
"BTC_LEVERAGE", "FORCE_CLOSE_BJ_HOUR",
"ALT_LEVERAGE", "FORCE_CLOSE_GRACE_MINUTES",
"DAILY_START_CAPITAL", "BTC_LEVERAGE",
"DAILY_LOSS_CAPITAL", "ALT_LEVERAGE",
"DAILY_PROFIT_CAPITAL", "DAILY_START_CAPITAL",
"FULL_MARGIN_BUFFER_RATIO", "DAILY_LOSS_CAPITAL",
"APP_USERNAME", "DAILY_PROFIT_CAPITAL",
"APP_PASSWORD", "FULL_MARGIN_BUFFER_RATIO",
"APP_AUTH_DISABLED", "APP_USERNAME",
"WECHAT_WEBHOOK", "APP_PASSWORD",
"HEDGE_PLAN_ENABLED", "APP_AUTH_DISABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS", "WECHAT_WEBHOOK",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "HEDGE_PLAN_ENABLED",
"OKX_SHOW_PERP_FUNDS", "HEDGE_PLAN_SHOW_PERP_OPTIONS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", "OKX_SHOW_PERP_FUNDS",
"OKX_OPTIONS_MAX_DTE_DAYS", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", "OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_TRADE_BUDGET_USDC", "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_BUDGET_BUFFER", "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_TRADE_MODE", "OKX_OPTIONS_TRADE_BUDGET_USDC",
"SIM_DEFAULT_MODE", "OKX_OPTIONS_BUDGET_BUFFER",
"SIM_INITIAL_EQUITY_USDT", "OKX_OPTIONS_COIN_COMPOUND",
"SIM_INITIAL_USDC", "OKX_OPTIONS_COIN_BUDGET_USDT",
"SIM_FEE_RATE", "OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"MAX_ACTIVE_HEDGE_PLANS", "OKX_OPTIONS_COIN_MAX_USDT",
"HEDGE_PLAN_LIVE_ORDER", "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"HEDGE_PLAN_OPTION_PRIMARY", "OKX_TRADE_MODE",
"HEDGE_PLAN_OPEN_ORDER", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "HEDGE_PLAN_OPTION_PRIMARY",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY", "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_BIAS_RATIO", "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_BUDGET_BUFFER", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", "HEDGE_PLAN_OO_BIAS_RATIO",
"MAX_ACTIVE_HEDGE_PLANS", "HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_MONITOR_POLL_SECONDS", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
}) "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
SENSITIVE_EXACT = frozenset({ "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"APP_PASSWORD", })
"FLASK_SECRET_KEY",
"OPENAI_API_KEY", SENSITIVE_EXACT = frozenset({
}) "APP_PASSWORD",
"FLASK_SECRET_KEY",
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD") "HUB_BRIDGE_TOKEN",
"OPENAI_API_KEY",
# env 配置页下拉:value → 中文标签 })
SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
"OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")), SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
"OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")),
"BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")), # env 配置页下拉:value → 中文标签
"BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")), SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
"GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")), "OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")), "OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")),
"POSITION_SIZING_MODE": (("risk", "以损定"), ("full_margin", "全仓杠杆")), "BINANCE_MARGIN_MODE": (("cross", ""), ("isolated", "逐仓")),
"TRADE_DIRECTION": ( "BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")),
("both", "双向均可"), "GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
("long_only", "仅做多"), "GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")),
("short_only", "仅做空"), "POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")),
), "TRADE_DIRECTION": (
"AUTO_TRANSFER_FROM": ( ("both", "双向均可"),
("funding", "funding 资金账户"), ("long_only", "仅做多"),
("swap", "swap 交易账户"), ("short_only", "仅做空"),
("spot", "spot 现货"), ),
), "AUTO_TRANSFER_FROM": (
"AUTO_TRANSFER_TO": ( ("funding", "funding 资金账户"),
("swap", "swap 交易账户"), ("swap", "swap 交易账户"),
("funding", "funding 资金账户"), ("spot", "spot 现货"),
("spot", "spot 现货"), ),
), "AUTO_TRANSFER_TO": (
"TRANSFER_CCY": (("USDT", "USDT"),), ("swap", "swap 交易账户"),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": ( ("funding", "funding 资金账户"),
("budget", "预算金额"), ("spot", "spot 现货"),
("sheets", "张数"), ),
), "TRANSFER_CCY": (("USDT", "USDT"),),
"OKX_TRADE_MODE": ( "HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("options", "单独期权"), ("budget", "预算金额"),
("perp_options", "永期对冲"), ("sheets", "张数"),
("options_options", "期期对冲"), ),
), "OKX_TRADE_MODE": (
"SIM_DEFAULT_MODE": ( ("options", "单独期权"),
("sim", "模拟(sim)"), ("perp_options", "永期对冲"),
("live", "实盘(live)"), ("options_options", "期期对冲"),
), ),
"HEDGE_PLAN_OPTION_PRIMARY": ( "OKX_OPTIONS_MARGIN_MODE": (
("true", "以期权为主"), ("coin", "币本位(USDT买币桥)"),
("false", "保险模式"), ("usdc", "USDC(USDⓈ权利金)"),
), ),
} "HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
_SELECT_ALIASES: dict[str, dict[str, str]] = { ("false", "保险模式"),
"OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, ),
"BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, }
"GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"TRANSFER_CCY": {"usdt": "USDT"}, _SELECT_ALIASES: dict[str, dict[str, str]] = {
} "OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
def _is_sensitive(key: str) -> bool: "TRANSFER_CCY": {"usdt": "USDT"},
if key in SENSITIVE_EXACT: }
return True
return any(s in key for s in SENSITIVE_SUBSTR)
def _is_sensitive(key: str) -> bool:
if key in SENSITIVE_EXACT:
def select_options_for(key: str) -> list[dict[str, str]]: return True
opts = SELECT_OPTIONS.get(key) or () return any(s in key for s in SENSITIVE_SUBSTR)
return [{"value": v, "label": lab} for v, lab in opts]
def select_options_for(key: str) -> list[dict[str, str]]:
def normalize_select_value(key: str, value: Optional[str]) -> str: opts = SELECT_OPTIONS.get(key) or ()
raw = (value or "").strip() return [{"value": v, "label": lab} for v, lab in opts]
if not raw:
return ""
low = raw.lower() def normalize_select_value(key: str, value: Optional[str]) -> str:
aliases = _SELECT_ALIASES.get(key) or {} raw = (value or "").strip()
if low in aliases: if not raw:
return aliases[low] return ""
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())} low = raw.lower()
allowed_by_lower = {v.lower(): v for v in allowed} aliases = _SELECT_ALIASES.get(key) or {}
if low in allowed: if low in aliases:
return low return aliases[low]
if raw in allowed: allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
return raw allowed_by_lower = {v.lower(): v for v in allowed}
if low in allowed_by_lower: if low in allowed:
return allowed_by_lower[low] return low
return raw if raw in allowed:
return raw
if low in allowed_by_lower:
def _restart_required(key: str) -> bool: return allowed_by_lower[low]
if key in HOT_RELOAD_EXACT: return raw
return False
if key in RESTART_REQUIRED_EXACT:
return True def _restart_required(key: str) -> bool:
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES) if key in HOT_RELOAD_EXACT:
return False
if key in RESTART_REQUIRED_EXACT:
def _hot_reload(key: str) -> bool: return True
if key in HOT_RELOAD_EXACT: return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
return True
if _restart_required(key):
return False def _hot_reload(key: str) -> bool:
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_")) if key in HOT_RELOAD_EXACT:
return True
if _restart_required(key):
def _field_type(key: str, value: str) -> str: return False
if key in SELECT_OPTIONS: return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
return "select"
low = (value or "").strip().lower()
if low in ("true", "false"): def _field_type(key: str, value: str) -> str:
return "bool" if key in SELECT_OPTIONS:
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in ( return "select"
"OKX_SHOW_PERP_FUNDS", low = (value or "").strip().lower()
"HEDGE_PLAN_SHOW_PERP_OPTIONS", if low in ("true", "false"):
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", return "bool"
): if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
return "bool" "OKX_SHOW_PERP_FUNDS",
try: "HEDGE_PLAN_SHOW_PERP_OPTIONS",
if "." in low: "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
float(low) ):
return "float" return "bool"
int(low) try:
return "int" if "." in low:
except ValueError: float(low)
pass return "float"
return "text" int(low)
return "int"
except ValueError:
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]: pass
if value is None or value == "": return "text"
return {"value": "", "masked": "", "tail": "", "has_value": False}
if not _is_sensitive(key):
return {"value": value, "masked": value, "tail": "", "has_value": True} def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
tail = value[-4:] if len(value) >= 4 else value if value is None or value == "":
return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True} return {"value": "", "masked": "", "tail": "", "has_value": False}
if not _is_sensitive(key):
return {"value": value, "masked": value, "tail": "", "has_value": True}
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]: tail = value[-4:] if len(value) >= 4 else value
if not os.path.isfile(example_path): return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True}
return []
lines = read_env_lines(example_path)
groups: list[dict[str, Any]] = [] def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
group_map: dict[str, dict[str, Any]] = {} if not os.path.isfile(example_path):
current_group = "基础配置" return []
pending_note: list[str] = [] lines = read_env_lines(example_path)
in_section_block = False groups: list[dict[str, Any]] = []
section_title_set = False group_map: dict[str, dict[str, Any]] = {}
allow_section_blocks = False current_group = "基础配置"
pending_note: list[str] = []
def _ensure_group(title: str) -> dict[str, Any]: in_section_block = False
title = (title or "").strip() or "其他" section_title_set = False
if title not in group_map: allow_section_blocks = False
group_map[title] = {"title": title, "fields": []}
groups.append(group_map[title]) def _ensure_group(title: str) -> dict[str, Any]:
return group_map[title] title = (title or "").strip() or "其他"
if title not in group_map:
for raw in lines: group_map[title] = {"title": title, "fields": []}
line = raw.rstrip() groups.append(group_map[title])
stripped = line.strip() return group_map[title]
if not stripped:
pending_note = [] for raw in lines:
continue line = raw.rstrip()
if _SEPARATOR_RE.match(stripped): stripped = line.strip()
if not allow_section_blocks: if not stripped:
continue pending_note = []
if not in_section_block: continue
in_section_block = True if _SEPARATOR_RE.match(stripped):
section_title_set = False if not allow_section_blocks:
else: continue
in_section_block = False if not in_section_block:
continue in_section_block = True
if in_section_block and stripped.startswith("#"): section_title_set = False
note = stripped.lstrip("#").strip() else:
if note and not section_title_set: in_section_block = False
current_group = note continue
_ensure_group(current_group) if in_section_block and stripped.startswith("#"):
section_title_set = True note = stripped.lstrip("#").strip()
elif note: if note and not section_title_set:
pending_note.append(note) current_group = note
continue _ensure_group(current_group)
gm = _GROUP_RE.match(stripped) section_title_set = True
if gm: elif note:
title = gm.group(1).strip() pending_note.append(note)
if title and title != "=": continue
current_group = title gm = _GROUP_RE.match(stripped)
_ensure_group(current_group) if gm:
in_section_block = False title = gm.group(1).strip()
section_title_set = False if title and title != "=":
pending_note = [] current_group = title
continue _ensure_group(current_group)
dash = _SECTION_DASH_RE.match(stripped) in_section_block = False
if dash: section_title_set = False
allow_section_blocks = True pending_note = []
current_group = dash.group(1).strip() continue
_ensure_group(current_group) dash = _SECTION_DASH_RE.match(stripped)
in_section_block = False if dash:
section_title_set = False allow_section_blocks = True
pending_note = [] current_group = dash.group(1).strip()
continue _ensure_group(current_group)
if stripped.startswith("#"): in_section_block = False
note = stripped.lstrip("#").strip() section_title_set = False
if note and not note.startswith("="): pending_note = []
pending_note.append(note) continue
continue if stripped.startswith("#"):
km = _KEY_LINE.match(stripped) note = stripped.lstrip("#").strip()
if not km: if note and not note.startswith("="):
continue pending_note.append(note)
key = km.group(1) continue
allow_section_blocks = True km = _KEY_LINE.match(stripped)
default_val = env_get(lines, key) or "" if not km:
grp = _ensure_group(current_group) continue
note = " ".join(pending_note).strip() key = km.group(1)
grp["fields"].append( allow_section_blocks = True
{ default_val = env_get(lines, key) or ""
"key": key, grp = _ensure_group(current_group)
"label": key, note = " ".join(pending_note).strip()
"note": note, grp["fields"].append(
"default": default_val, {
"type": _field_type(key, default_val), "key": key,
"sensitive": _is_sensitive(key), "label": key,
"restart_required": _restart_required(key), "note": note,
"hot_reload": _hot_reload(key), "default": default_val,
} "type": _field_type(key, default_val),
) "sensitive": _is_sensitive(key),
pending_note = [] "restart_required": _restart_required(key),
return [g for g in groups if g.get("fields")] "hot_reload": _hot_reload(key),
}
)
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]: pending_note = []
groups = parse_env_example_schema(example_path) return [g for g in groups if g.get("fields")]
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
for group in groups: def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
for field in group.get("fields") or []: groups = parse_env_example_schema(example_path)
key = field["key"] env_lines = read_env_lines(env_path)
val = values.get(key) values = env_get_all(env_lines)
if val is None: for group in groups:
val = field.get("default") or "" for field in group.get("fields") or []:
masked = _mask_value(key, val) key = field["key"]
field["current"] = masked["value"] if not field["sensitive"] else "" val = values.get(key)
field["masked"] = masked["masked"] if val is None:
field["has_value"] = masked["has_value"] val = field.get("default") or ""
return {"groups": groups} masked = _mask_value(key, val)
field["current"] = masked["value"] if not field["sensitive"] else ""
field["masked"] = masked["masked"]
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]: field["has_value"] = masked["has_value"]
allowed = {} return {"groups": groups}
for group in groups:
for field in group.get("fields") or []:
allowed[field["key"]] = field def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
clean: dict[str, str] = {} allowed = {}
errors: list[str] = [] for group in groups:
for key, value in (updates or {}).items(): for field in group.get("fields") or []:
if key not in allowed: allowed[field["key"]] = field
errors.append(f"未知配置项: {key}") clean: dict[str, str] = {}
continue errors: list[str] = []
if value is None: for key, value in (updates or {}).items():
continue if key not in allowed:
val = str(value).strip() errors.append(f"未知配置项: {key}")
if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)): continue
continue if value is None:
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位 continue
if key.endswith("_API_KEY") and 0 < len(val) < 16: val = str(value).strip()
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥") if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)):
continue continue
ftype = allowed[key].get("type") # API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
if ftype == "bool": if key.endswith("_API_KEY") and 0 < len(val) < 16:
low = val.lower() errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"): continue
errors.append(f"{key} 须为 true/false") ftype = allowed[key].get("type")
continue if ftype == "bool":
val = "true" if low in ("true", "1", "yes", "on") else "false" low = val.lower()
elif ftype == "select" or key in SELECT_OPTIONS: if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
allowed_vals = { errors.append(f"{key} 须为 true/false")
str(o.get("value") if isinstance(o, dict) else o[0]).lower() continue
for o in (allowed[key].get("options") or select_options_for(key)) val = "true" if low in ("true", "1", "yes", "on") else "false"
} elif ftype == "select" or key in SELECT_OPTIONS:
norm = normalize_select_value(key, val) allowed_vals = {
if allowed_vals and norm.lower() not in allowed_vals: str(o.get("value") if isinstance(o, dict) else o[0]).lower()
labels = " / ".join( for o in (allowed[key].get("options") or select_options_for(key))
f"{o['value']}({o['label']})" if isinstance(o, dict) else f"{o[0]}({o[1]})" }
for o in (allowed[key].get("options") or select_options_for(key)) norm = normalize_select_value(key, val)
) if allowed_vals and norm.lower() not in allowed_vals:
errors.append(f"{key} 须为: {labels}") labels = " / ".join(
continue f"{o['value']}({o['label']})" if isinstance(o, dict) else f"{o[0]}({o[1]})"
val = norm for o in (allowed[key].get("options") or select_options_for(key))
clean[key] = val )
return clean, errors errors.append(f"{key} 须为: {labels}")
continue
val = norm
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool: clean[key] = val
field_map = {} return clean, errors
for group in groups:
for field in group.get("fields") or []:
field_map[field["key"]] = field def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
for key in changed_keys: field_map = {}
meta = field_map.get(key) or {} for group in groups:
if meta.get("restart_required"): for field in group.get("fields") or []:
return True field_map[field["key"]] = field
if not meta.get("hot_reload"): for key in changed_keys:
return True meta = field_map.get(key) or {}
return False if meta.get("restart_required"):
return True
if not meta.get("hot_reload"):
return True
return False
+594 -587
View File
File diff suppressed because it is too large Load Diff
+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"):
+249 -194
View File
@@ -1,194 +1,249 @@
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力.""" """embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力."""
from __future__ import annotations from __future__ import annotations
import os 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
def env_truthy(raw: str | None, default: bool = False) -> bool: def env_truthy(raw: str | None, default: bool = False) -> bool:
if raw is None or str(raw).strip() == "": if raw is None or str(raw).strip() == "":
return default return default
return str(raw).strip().lower() in ("1", "true", "yes", "on") return str(raw).strip().lower() in ("1", "true", "yes", "on")
def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool: def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool:
"""OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True.""" """OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True."""
ex = (exchange_key or "").strip().lower() ex = (exchange_key or "").strip().lower()
if ex and ex != "okx": if ex and ex != "okx":
return True return True
return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True) return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True)
@dataclass(frozen=True) @dataclass(frozen=True)
class EmbedRenderPlan: class EmbedRenderPlan:
exchange_capitals: bool exchange_capitals: bool
records_rows: bool records_rows: bool
records_summary: bool records_summary: bool
key_history: bool key_history: bool
key_list: bool key_list: bool
orders: bool orders: bool
stats_bundle: bool stats_bundle: bool
strategy: bool strategy: bool
orphan_live: bool orphan_live: bool
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan: def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
if embed_mode not in ("fragment", "shell"): if embed_mode not in ("fragment", "shell"):
return EmbedRenderPlan( return EmbedRenderPlan(
exchange_capitals=True, exchange_capitals=True,
records_rows=True, records_rows=True,
records_summary=False, records_summary=False,
key_history=True, key_history=True,
key_list=True, key_list=True,
orders=True, orders=True,
stats_bundle=True, stats_bundle=True,
strategy=True, strategy=True,
orphan_live=True, orphan_live=True,
) )
is_shell = embed_mode == "shell" is_shell = embed_mode == "shell"
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,
) )
def profit_loss_ratio_from_averages(avg_win: float | None, avg_loss: float | None) -> float | None: def profit_loss_ratio_from_averages(avg_win: float | None, avg_loss: float | None) -> float | None:
"""盈亏比 = 平均盈利 / |平均亏损|.""" """盈亏比 = 平均盈利 / |平均亏损|."""
if avg_win is None or avg_loss is None: if avg_win is None or avg_loss is None:
return None return None
try: try:
aw = float(avg_win) aw = float(avg_win)
al = float(avg_loss) al = float(avg_loss)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if al == 0: if al == 0:
return None return None
return round(aw / abs(al), 2) return round(aw / abs(al), 2)
def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None: def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None:
wins: list[float] = [] wins: list[float] = []
losses: list[float] = [] losses: list[float] = []
for row in trades or []: for row in trades or []:
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
try: try:
pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0) pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
if pnl > _WIN_EPS: if pnl > _WIN_EPS:
wins.append(pnl) wins.append(pnl)
elif pnl < -_WIN_EPS: elif pnl < -_WIN_EPS:
losses.append(pnl) losses.append(pnl)
avg_win = sum(wins) / len(wins) if wins else None avg_win = sum(wins) / len(wins) if wins else None
avg_loss = sum(losses) / len(losses) if losses else None avg_loss = sum(losses) / len(losses) if losses else None
return profit_loss_ratio_from_averages(avg_win, avg_loss) return profit_loss_ratio_from_averages(avg_win, avg_loss)
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,
) -> str: funding_eth: float | None = None,
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略.""" margin_mode: str | None = None,
_ = funding_usdt underly: str = "ETH",
if funding_usdc is None: ) -> str:
return "" """期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列)."""
try: if funding_usdc is None:
return f"{float(funding_usdc):.2f} USDC" return ""
except (TypeError, ValueError): try:
return "" return f"{float(funding_usdc):.2f} USDC"
except (TypeError, ValueError):
return ""
def total_funds_usdt(
funding_usdt: float | None,
trading_usdt: float | None, def _fmt_coin_amount(v: float | None, *, min_amt: float = 1e-6) -> str | None:
options_trading_usdc: float | None = None, if v is None:
options_funding_usdc: float | None = None, return None
options_funding_usdt: float | None = None, try:
options_trading_usdt: float | None = None, n = float(v)
) -> float | None: except (TypeError, ValueError):
parts = [ return None
funding_usdt, if n < min_amt:
trading_usdt, return None
options_funding_usdc, txt = f"{n:.6f}".rstrip("0").rstrip(".")
options_funding_usdt, return txt or None
options_trading_usdc,
options_trading_usdt,
] def trading_account_label(
if all(v is None for v in parts): usdt: float | None,
return None eth: float | None = None,
try: btc: float | None = None,
total = 0.0 *,
for v in parts: margin_mode: str | None = None,
if v is not None: ) -> str:
total += float(v) """交易账户顶栏文案.
return round(total, 2)
except (TypeError, ValueError): 币本位:USDT / ETH / BTC(有余额才带上,不显示其它币种).
return None 其它模式:xx.xxU.
"""
try:
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]: from lib.options.options_margin_mode_lib import normalize_options_margin_mode
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录."""
from lib.trade.trade_result_lib import sql_effective_pnl_expr mode = normalize_options_margin_mode(margin_mode)
except Exception:
pnl_sql = sql_effective_pnl_expr() mode = str(margin_mode or "coin").strip().lower() or "coin"
row = conn.execute( if mode != "coin":
f""" if usdt is None:
SELECT return ""
COUNT(*) AS total, try:
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins, return f"{float(usdt):.2f}U"
AVG(CASE WHEN {pnl_sql} > 0 THEN {pnl_sql} END) AS avg_win, except (TypeError, ValueError):
AVG(CASE WHEN {pnl_sql} < 0 THEN {pnl_sql} END) AS avg_loss return ""
FROM trade_records parts: list[str] = []
WHERE {tr_ts} >= ? AND {tr_ts} <= ? if usdt is not None:
AND COALESCE(result, '') != '错过' try:
AND COALESCE(reviewed_result, '') != '错过' parts.append(f"{float(usdt):.2f} USDT")
""", except (TypeError, ValueError):
(start_bj, end_bj), pass
).fetchone() eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
total = int(row["total"] or 0) if row else 0 if eth_txt is not None:
wins = int(row["wins"] or 0) if row else 0 parts.append(f"{eth_txt} ETH")
rate = round(wins / total * 100, 2) if total else 0 btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
avg_win = float(row["avg_win"]) if row and row["avg_win"] is not None else None if btc_txt is not None:
avg_loss = float(row["avg_loss"]) if row and row["avg_loss"] is not None else None parts.append(f"{btc_txt} BTC")
return { return " / ".join(parts) if parts else ""
"records": [],
"total": total,
"rate": rate, def total_funds_usdt(
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), funding_usdt: float | None,
} trading_usdt: float | None,
options_trading_usdc: float | None = None,
options_funding_usdc: float | None = None,
def header_trade_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]: options_funding_usdt: float | None = None,
"""account_snapshot / 顶栏刷新:按当前列表窗返回总交易/胜率/盈亏比.""" options_trading_usdt: float | None = None,
from lib.common.history_window_lib import sql_list_time_field, utc_window_to_bj_sql_strings ) -> float | None:
parts = [
start_bj, end_bj = utc_window_to_bj_sql_strings( funding_usdt,
list_window["start_utc"], list_window["end_utc"], app_tz trading_usdt,
) options_funding_usdc,
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") options_funding_usdt,
summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) options_trading_usdc,
return { options_trading_usdt,
"total": summary["total"], ]
"rate": summary["rate"], if all(v is None for v in parts):
"profit_loss_ratio": summary.get("profit_loss_ratio"), return None
} try:
total = 0.0
for v in parts:
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]: if v is not None:
return {"stats_reset_hour": reset_hour, "segments": []} total += float(v)
return round(total, 2)
except (TypeError, ValueError):
return None
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]:
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录."""
from lib.trade.trade_result_lib import sql_effective_pnl_expr
pnl_sql = sql_effective_pnl_expr()
row = conn.execute(
f"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
AVG(CASE WHEN {pnl_sql} > 0 THEN {pnl_sql} END) AS avg_win,
AVG(CASE WHEN {pnl_sql} < 0 THEN {pnl_sql} END) AS avg_loss
FROM trade_records
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
AND COALESCE(result, '') != '错过'
AND COALESCE(reviewed_result, '') != '错过'
""",
(start_bj, end_bj),
).fetchone()
total = int(row["total"] or 0) if row else 0
wins = int(row["wins"] or 0) if row else 0
rate = round(wins / total * 100, 2) if total else 0
avg_win = float(row["avg_win"]) if row and row["avg_win"] is not None else None
avg_loss = float(row["avg_loss"]) if row and row["avg_loss"] is not None else None
return {
"records": [],
"total": total,
"rate": rate,
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
}
def header_trade_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]:
"""account_snapshot / 顶栏刷新:按当前列表窗返回总交易/胜率/盈亏比."""
from lib.common.history_window_lib import sql_list_time_field, utc_window_to_bj_sql_strings
start_bj, end_bj = utc_window_to_bj_sql_strings(
list_window["start_utc"], list_window["end_utc"], app_tz
)
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
summary = trade_records_summary(conn, start_bj, end_bj, tr_ts)
return {
"total": summary["total"],
"rate": summary["rate"],
"profit_loss_ratio": summary.get("profit_loss_ratio"),
}
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
return {"stats_reset_hour": reset_hour, "segments": []}
+194 -170
View File
@@ -1,170 +1,194 @@
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启.""" """实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
from __future__ import annotations from __future__ import annotations
import os import os
from functools import wraps from functools import wraps
from typing import Any, Callable from typing import Any, Callable
from flask import jsonify, request, session from flask import jsonify, request, session
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
from lib.env.env_ui_manifest import ( from lib.env.env_ui_manifest import (
build_env_ui_payload, build_env_ui_payload,
filter_updates_for_ui, filter_updates_for_ui,
coerce_hedge_partial_close_with_manual, coerce_hedge_partial_close_with_manual,
validate_env_ui_updates, validate_env_ui_updates,
) )
from lib.env.env_schema import parse_env_example_schema from lib.env.env_schema import parse_env_example_schema
from lib.instance.instance_display_prefs_lib import ( from lib.instance.instance_display_prefs_lib import (
display_meta_for_ui, display_meta_for_ui,
get_display_prefs, get_display_prefs,
normalize_display_prefs, normalize_display_prefs,
save_display_prefs, save_display_prefs,
tab_allowed, tab_allowed,
) )
from lib.instance.instance_pm2_lib import restart_instance_pm2 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):
logged_in = bool(session.get("logged_in")) from lib.hub.hub_auth import request_allowed as hub_request_allowed
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
"1", logged_in = bool(session.get("logged_in"))
"true", auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
"yes", "1",
"on", "true",
) "yes",
if auth_disabled or logged_in: "on",
return f(*args, **kwargs) )
return jsonify({"ok": False, "msg": "未登录"}), 401 hub_hdr = (request.headers.get("X-Hub-Token") or "").strip()
bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
return wrapped if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed:
return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403
return decorator if hub_request_allowed(logged_in, auth_disabled):
return f(*args, **kwargs)
return jsonify({"ok": False, "msg": "未登录"}), 401
def register_instance_settings_routes(
app, return wrapped
*,
get_db: Callable, return decorator
login_required_fn: Callable,
base_dir: str,
exchange_key: str, def register_instance_settings_routes(
username: str, app,
password: str, *,
) -> None: get_db: Callable,
env_path = os.path.join(base_dir, ".env") login_required_fn: Callable,
example_path = os.path.join(base_dir, ".env.example") base_dir: str,
api_auth = _api_login_required() exchange_key: str,
username: str,
@app.route("/api/settings/display", methods=["GET", "POST"]) password: str,
@api_auth ) -> None:
def api_settings_display(): env_path = os.path.join(base_dir, ".env")
if request.method == "GET": example_path = os.path.join(base_dir, ".env.example")
prefs = get_display_prefs(get_db) api_auth = _api_login_required()
return jsonify(
{ @app.route("/api/settings/display", methods=["GET", "POST"])
"ok": True, @api_auth
"display": prefs, def api_settings_display():
"meta": display_meta_for_ui(), if request.method == "GET":
} prefs = get_display_prefs(get_db)
) return jsonify(
body = request.get_json(silent=True) or {} {
raw = body.get("display") if isinstance(body.get("display"), dict) else body "ok": True,
saved = save_display_prefs(get_db, raw) "display": prefs,
return jsonify({"ok": True, "display": saved}) "meta": display_meta_for_ui(),
}
@app.route("/api/settings/env/meta", methods=["GET"]) )
@api_auth body = request.get_json(silent=True) or {}
def api_env_meta(): raw = body.get("display") if isinstance(body.get("display"), dict) else body
groups = build_env_ui_payload(exchange_key, example_path, env_path) saved = save_display_prefs(get_db, raw)
return jsonify({"ok": True, "groups": groups}) return jsonify({"ok": True, "display": saved})
@app.route("/api/settings/env", methods=["GET", "POST"]) @app.route("/api/settings/env/meta", methods=["GET"])
@api_auth @api_auth
def api_settings_env(): def api_env_meta():
if request.method == "GET": groups = build_env_ui_payload(exchange_key, example_path, env_path)
groups = build_env_ui_payload(exchange_key, example_path, env_path) return jsonify({"ok": True, "groups": groups})
return jsonify({"ok": True, "groups": groups})
body = request.get_json(silent=True) or {} @app.route("/api/settings/env", methods=["GET", "POST"])
updates = body.get("values") if isinstance(body.get("values"), dict) else body @api_auth
if not isinstance(updates, dict): def api_settings_env():
return jsonify({"ok": False, "msg": "无效请求体"}), 400 if request.method == "GET":
updates = filter_updates_for_ui(exchange_key, updates) groups = build_env_ui_payload(exchange_key, example_path, env_path)
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates) return jsonify({"ok": True, "groups": groups})
if errors: body = request.get_json(silent=True) or {}
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400 updates = body.get("values") if isinstance(body.get("values"), dict) else body
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) if not isinstance(updates, dict):
if not clean: return jsonify({"ok": False, "msg": "无效请求体"}), 400
return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) updates = filter_updates_for_ui(exchange_key, updates)
changed = apply_env_updates(env_path, clean) clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
groups = parse_env_example_schema(example_path) if errors:
reload_info = apply_env_reload(env_path, get_db, changed, groups) return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
return jsonify( clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
{ if not clean:
"ok": True, return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
"changed_keys": changed, if "OKX_OPTIONS_MARGIN_MODE" in clean:
"restart_required": reload_info.get("restart_required", False), 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
@app.route("/api/settings/password", methods=["POST"]) lines = read_env_lines(env_path)
@api_auth old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
def api_change_password(): new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
body = request.get_json(silent=True) or {} if old_mode != new_mode:
old_password = str(body.get("old_password") or "") conn_m = get_db()
new_username = str(body.get("new_username") or "").strip() try:
new_password = str(body.get("new_password") or "") block = mode_switch_block_msg(conn_m, None)
confirm = str(body.get("confirm_password") or "") if block:
if not old_password or old_password != password: return jsonify({"ok": False, "msg": block}), 400
return jsonify({"ok": False, "msg": "当前密码错误"}), 400 finally:
if len(new_password) < 6: conn_m.close()
return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400 except Exception as e:
if new_password != confirm: return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400 changed = apply_env_updates(env_path, clean)
updates: dict[str, str] = {"APP_PASSWORD": new_password} groups = parse_env_example_schema(example_path)
if new_username: reload_info = apply_env_reload(env_path, get_db, changed, groups)
updates["APP_USERNAME"] = new_username return jsonify(
changed = apply_env_updates(env_path, updates) {
groups = parse_env_example_schema(example_path) "ok": True,
apply_env_reload(env_path, get_db, changed, groups) "changed_keys": changed,
return jsonify({"ok": True, "restart_required": True, "changed_keys": changed}) "restart_required": reload_info.get("restart_required", False),
}
@app.route("/api/admin/restart", methods=["POST"]) )
@api_auth
def api_admin_restart(): @app.route("/api/settings/password", methods=["POST"])
result = restart_instance_pm2(exchange_key, defer=True) @api_auth
code = 200 if result.get("ok") else 500 def api_change_password():
return jsonify({"ok": bool(result.get("ok")), **result}), code body = request.get_json(silent=True) or {}
old_password = str(body.get("old_password") or "")
@app.route("/api/admin/health", methods=["GET"]) new_username = str(body.get("new_username") or "").strip()
def api_admin_health(): new_password = str(body.get("new_password") or "")
return jsonify({"ok": True, "status": "up"}) confirm = str(body.get("confirm_password") or "")
if not old_password or old_password != password:
def tab_allowed_fn(tab: str) -> bool: return jsonify({"ok": False, "msg": "当前密码错误"}), 400
prefs = get_display_prefs(get_db) if len(new_password) < 6:
return tab_allowed(tab, prefs) return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
if new_password != confirm:
app.config["INSTANCE_GET_DB"] = get_db return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn updates: dict[str, str] = {"APP_PASSWORD": new_password}
if new_username:
@app.route("/api/embed/tab_allowed/<tab>", methods=["GET"]) updates["APP_USERNAME"] = new_username
@api_auth changed = apply_env_updates(env_path, updates)
def api_tab_allowed(tab: str): groups = parse_env_example_schema(example_path)
prefs = get_display_prefs(get_db) apply_env_reload(env_path, get_db, changed, groups)
return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)}) return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
@app.route("/api/admin/restart", methods=["POST"])
def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]: @api_auth
from lib.instance.instance_settings_lib import settings_page_context def api_admin_restart():
result = restart_instance_pm2(exchange_key, defer=True)
prefs = get_display_prefs(get_db) code = 200 if result.get("ok") else 500
ctx = { return jsonify({"ok": bool(result.get("ok")), **result}), code
"display": prefs,
"display_meta": display_meta_for_ui(), @app.route("/api/admin/health", methods=["GET"])
**settings_page_context(page, display=prefs, **settings_kwargs), def api_admin_health():
} return jsonify({"ok": True, "status": "up"})
return ctx
def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs)
app.config["INSTANCE_GET_DB"] = get_db
app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn
@app.route("/api/embed/tab_allowed/<tab>", methods=["GET"])
@api_auth
def api_tab_allowed(tab: str):
prefs = get_display_prefs(get_db)
return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)})
def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]:
from lib.instance.instance_settings_lib import settings_page_context
prefs = get_display_prefs(get_db)
ctx = {
"display": prefs,
"display_meta": display_meta_for_ui(),
**settings_page_context(page, display=prefs, **settings_kwargs),
}
return ctx
+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){
File diff suppressed because it is too large Load Diff
@@ -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,49 +1,51 @@
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} {# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}"> {% set _coin_margin = (options_margin_mode|default('coin')) == 'coin' %}
<div class="stat-strip-item stat-strip-item--primary"> {% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
<div class="label">交易所</div> <div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
<div class="value" id="exchange-mode-label" data-funds-field="exchange-mode-label">{{ exchange_display }}</div> <div class="stat-strip-item stat-strip-item--primary">
</div> <div class="label">交易所</div>
<div class="stat-strip-item"> <div class="value">{{ exchange_display }}</div>
<div class="label">交易日</div> </div>
<div class="value">{{ trading_day }}</div> <div class="stat-strip-item">
</div> <div class="label">交易日</div>
<div class="stat-strip-item stat-strip-item--primary"> <div class="value">{{ trading_day }}</div>
<div class="label">总交易</div> </div>
<div class="value" id="stat-total" data-funds-field="stat-total">{{ total }}</div> <div class="stat-strip-item stat-strip-item--primary">
</div> <div class="label">总交易</div>
<div class="stat-strip-item"> <div class="value" id="stat-total" data-funds-field="stat-total">{{ total }}</div>
<div class="label">胜率</div> </div>
<div class="value" id="stat-rate" data-funds-field="stat-rate">{{ rate }}%</div> <div class="stat-strip-item">
</div> <div class="label">胜率</div>
<div class="stat-strip-item" title="平均盈利 ÷ 平均亏损(当前列表窗口)"> <div class="value" id="stat-rate" data-funds-field="stat-rate">{{ rate }}%</div>
<div class="label">盈亏比</div> </div>
<div class="value" id="stat-pl-ratio" data-funds-field="stat-pl-ratio">{% if profit_loss_ratio is not none %}{{ profit_loss_ratio }}{% else %}—{% endif %}</div> <div class="stat-strip-item" title="平均盈利 ÷ 平均亏损(当前列表窗口)">
</div> <div class="label">盈亏比</div>
<div class="stat-strip-item"> <div class="value" id="stat-pl-ratio" data-funds-field="stat-pl-ratio">{% if profit_loss_ratio is not none %}{{ profit_loss_ratio }}{% else %}—{% endif %}</div>
<div class="label">总资金</div> </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="stat-strip-item">
</div> <div class="label">总资金</div>
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1"> <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="label">资金账户</div> </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="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
</div> <div class="label">资金账户</div>
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1"> <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="label">交易账户</div> </div>
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div> <div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
</div> <div class="label">交易账户</div>
{% if options_enabled %} <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 class="stat-strip-item"> </div>
<div class="label">期权资金账户</div> {% if options_enabled and not _coin_margin %}
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc) }}</div> <div class="stat-strip-item" data-options-funds="1">
</div> <div class="label">期权资金账户</div>
<div class="stat-strip-item"> <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 class="label">期权交易账户</div> </div>
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc) }}</div> <div class="stat-strip-item" data-options-funds="1">
</div> <div class="label">期权交易账户</div>
{% endif %} <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 class="stat-strip-item stat-strip-item--pnl"> </div>
<div class="label">实时盈亏</div> {% endif %}
<div class="value" id="realtime-pnl" data-funds-field="realtime-pnl"></div> <div class="stat-strip-item stat-strip-item--pnl">
</div> <div class="label">实时盈亏</div>
</div> <div class="value" id="realtime-pnl" data-funds-field="realtime-pnl"></div>
</div>
</div>
+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
+355 -347
View File
@@ -1,347 +1,355 @@
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root" <div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}" data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}" data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}" data-trade-budget="{{ options_trade_budget | default(10) }}"
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}" data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}" data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}" data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
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>
{% endif %} {% endif %}
<div class="options-dual-grid"> <div class="options-dual-grid">
<div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}> <div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}>
<h2>期权下单{% if options_open_allowed is defined and not options_open_allowed %} <small class="muted">(对冲模式已禁用开仓)</small>{% endif %}</h2> <h2>期权下单{% if options_open_allowed is defined and not options_open_allowed %} <small class="muted">(对冲模式已禁用开仓)</small>{% endif %}</h2>
<details class="opt-close-rule opt-open-rule"> <details class="opt-close-rule opt-open-rule">
<summary>开仓规则说明</summary> <summary>开仓规则说明</summary>
<div class="opt-close-rule-body"> <div class="opt-close-rule-body">
<p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p> <p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
<ul> <ul>
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li> <li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li> <li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li> <li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<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>:开仓时可勾选;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>
</ul> <li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
<p><strong>买一平仓</strong>:平仓前重新读盘口并校验有效流动性;市价平仓已禁用</p> <li>平仓仅买一限价,详见说明文档</li>
<ul> </ul>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li> <p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li> </div>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li> </details>
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li> <div class="form-row options-chain-toolbar">
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li> <button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
</ul> <button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
</div> <select id="opt-exp-select"><option value="">选择到期日</option></select>
</details> <span class="opt-chain-view-group">
<div class="form-row options-chain-toolbar"> <button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button> <button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button> </span>
<select id="opt-exp-select"><option value="">选择到期日</option></select> <span id="opt-type-btn-group" class="opt-type-btn-group">
<span class="opt-chain-view-group"> <button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button> <button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
<button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button> </span>
</span> <button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
<span id="opt-type-btn-group" class="opt-type-btn-group"> <button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button> <button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button> <label id="opt-strike-expand-wrap" class="opt-strike-expand-label">
</span> <input type="checkbox" id="opt-strike-expand-all"> 展开全部
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button> </label>
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button> <button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button> </div>
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label"> <div id="opt-index-line" class="muted"></div>
<input type="checkbox" id="opt-strike-expand-all"> 展开全部 <div class="options-strike-table-wrap" id="opt-strike-table-wrap">
</label> <table class="options-strike-table" id="opt-strike-table">
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button> <thead>
</div> <tr id="opt-strike-head-list">
<div id="opt-index-line" class="muted"></div> <th>行权价</th>
<div class="options-strike-table-wrap" id="opt-strike-table-wrap"> <th>类型</th>
<table class="options-strike-table" id="opt-strike-table"> <th>合约</th>
<thead> <th>卖一/张</th>
<tr id="opt-strike-head-list"> <th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
<th>行权价</th> <th>买一/张</th>
<th>类型</th> <th>到期平衡</th>
<th>合约</th> <th>距平衡</th>
<th>卖一/张</th> <th>操作</th>
<th title="指数÷卖一(每1币)">杠杆</th> </tr>
<th>买一/张</th> <tr id="opt-strike-head-t" class="hidden" hidden>
<th>到期平衡</th> <th colspan="3" class="opt-t-head-call">Call</th>
<th>距平衡</th> <th colspan="3" class="opt-t-head-mid">跨式</th>
<th>操作</th> <th colspan="3" class="opt-t-head-put">Put</th>
</tr> </tr>
<tr id="opt-strike-head-t" class="hidden" hidden> <tr id="opt-strike-head-t-cols" class="hidden" hidden>
<th colspan="3" class="opt-t-head-call">Call</th> <th>卖一/张</th>
<th colspan="3" class="opt-t-head-mid">跨式</th> <th>类型</th>
<th colspan="3" class="opt-t-head-put">Put</th> <th>操作</th>
</tr> <th>行权价</th>
<tr id="opt-strike-head-t-cols" class="hidden" hidden> <th title="Call卖一+Put卖一(每1币)">双买/币</th>
<th>卖一/张</th> <th title="到期测算平衡带">平衡带</th>
<th>类型</th> <th>类型</th>
<th>操作</th> <th>卖一/张</th>
<th>行权价</th> <th>操作</th>
<th title="Call卖一+Put卖一(每1币)">双买/币</th> </tr>
<th title="到期测算平衡带">平衡带</th> </thead>
<th>类型</th> <tbody id="opt-strike-tbody">
<th>卖一/张</th> <tr><td colspan="9" class="muted">请选择到期日</td></tr>
<th>操作</th> </tbody>
</tr> </table>
</thead> </div>
<tbody id="opt-strike-tbody"> <div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
<tr><td colspan="9" class="muted">请选择到期日</td></tr> <div id="opt-order-panel" class="opt-order-dialog" role="dialog" aria-modal="true" aria-labelledby="opt-order-dialog-title" style="display:none">
</tbody> <div class="opt-order-dialog-head">
</table> <h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3>
</div> <button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button>
<div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true"> </div>
<div id="opt-order-panel" class="opt-order-dialog" role="dialog" aria-modal="true" aria-labelledby="opt-order-dialog-title" style="display:none"> <div class="opt-order-layout">
<div class="opt-order-dialog-head"> <div class="opt-order-main">
<h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3> <div id="opt-order-inst" class="options-order-inst"></div>
<button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button> <div class="options-order-grid">
</div> <div><span class="k">卖一/张</span><span id="opt-order-ask" class="v"></span></div>
<div class="opt-order-layout"> <div><span class="k">买一/张</span><span id="opt-order-bid" class="v"></span></div>
<div class="opt-order-main"> <div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted"></span></div>
<div id="opt-order-inst" class="options-order-inst"></div> <div><span class="k">张数</span><span id="opt-order-sheets" class="v"></span></div>
<div class="options-order-grid"> <div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v"></span></div>
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v"></span></div> <div><span class="k">预估权利金</span><span id="opt-order-premium" class="v"></span></div>
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v"></span></div> <div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用"></span></div>
<div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted"></span></div> <div><span class="k">到期平衡</span><span id="opt-order-expiry-be" class="v"></span></div>
<div><span class="k">张数</span><span id="opt-order-sheets" class="v"></span></div> <div><span class="k">距平衡</span><span id="opt-order-dist-be" class="v"></span></div>
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v"></span></div> </div>
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v"></span></div> <div class="options-estimate-row">
<div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用"></span></div> <div class="opt-est-main">
<div><span class="k">到期平衡</span><span id="opt-order-expiry-be" class="v"></span></div> <label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
<div><span class="k">距平衡</span><span id="opt-order-dist-be" class="v"></span></div> <input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
</div> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<div class="options-estimate-row"> <span class="k">预计价值</span>
<div class="opt-est-main"> <span id="opt-est-value" class="v"></span>
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label> <span class="k">盈利</span>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值" <span id="opt-est-profit" class="v"></span>
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> <span class="k">盈亏比</span>
<span class="k">预计价值</span> <span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金"></span>
<span id="opt-est-value" class="v"></span> </div>
<span class="k">盈利</span> <span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
<span id="opt-est-profit" class="v"></span> </div>
<span class="k">盈亏比</span> <div class="options-estimate-row opt-profit-exit-row">
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金"></span> <div class="opt-est-main">
</div> <label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span> <input type="checkbox" id="opt-profit-exit-enabled">
</div> <span>翻倍出场</span>
<div class="options-estimate-row opt-profit-exit-row"> </label>
<div class="opt-est-main"> <label class="k" for="opt-profit-exit-mult">倍数</label>
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平"> <input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
<input type="checkbox" id="opt-profit-exit-enabled"> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span>翻倍出场</span> </div>
</label> <span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
<label class="k" for="opt-profit-exit-mult">倍数</label> </div>
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1" <div class="form-row options-order-mode-row">
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> <div class="opt-size-mode-bar">
</div> <label class="btn-secondary opt-order-chip opt-size-mode-chip">
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span> <input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
</div> <span>指定张数</span>
<div class="form-row options-order-mode-row"> </label>
<div class="opt-size-mode-bar"> <input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
<label class="btn-secondary opt-order-chip opt-size-mode-chip"> autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}> <label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}>
<span>指定张数</span> <input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
</label> <span>按可用余额打满</span>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数" </label>
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> <label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}> <input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}> <span>全仓复利</span>
<span>按可用余额打满</span> </label>
</label> <label class="btn-secondary opt-order-chip opt-size-mode-chip">
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}> <input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}> <span>指定币数量</span>
<span>全仓复利</span> </label>
</label> <input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
<label class="btn-secondary opt-order-chip opt-size-mode-chip"> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth"> </div>
<span>指定币数量</span> <p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
</label> 余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none" </p>
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other"> <p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
</div> 用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4"> </p>
余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。 <input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
</p> autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4"> data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。 </div>
</p> <div class="opt-order-dialog-actions">
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)" <button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" <button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly> </div>
</div> <div id="opt-order-msg" class="muted"></div>
<div class="opt-order-dialog-actions"> </div>
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button> </div>
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button> </div>
</div> </div>
<div id="opt-order-msg" class="muted"></div> </div>
</div>
</div> <div class="card options-pos-card-wrap">
</div> <div class="options-pos-head">
</div> <h2>持仓</h2>
</div> <button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
</div>
<div class="card options-pos-card-wrap"> <div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
<div class="options-pos-head"> <button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button>
<h2>持仓</h2> <button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button>
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button> <button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
</div> <button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="history" role="tab" aria-selected="false" id="opt-pos-tab-history">期权历史</button>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板"> </div>
<button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button> <div class="options-pos-tab-body">
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button> <div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button> <div id="opt-target-monitors" class="opt-target-monitors" hidden>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="history" role="tab" aria-selected="false" id="opt-pos-tab-history">期权历史</button> <div class="opt-target-monitors-head">目标监控</div>
</div> <div id="opt-target-monitors-list"></div>
<div class="options-pos-tab-body"> </div>
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live"> <div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
<div id="opt-target-monitors" class="opt-target-monitors" hidden> <div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
<div class="opt-target-monitors-head">目标监控</div> <div id="opt-pos-cards"></div>
<div id="opt-target-monitors-list"></div> </div>
</div> <details class="opt-close-rule">
<div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane"> <summary>买一平仓规则说明</summary>
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div> <div class="opt-close-rule-body">
<div id="opt-pos-cards"></div> <p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
</div> <ul>
</div> <li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden> <li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<div class="opt-pos-pending-pane"> <li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<div class="opt-order-pending-head"> <li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p> <li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button> </ul>
</div> <p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
<div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab"> </div>
<div class="muted opt-pending-empty">暂无未成交委托</div> </details>
</div> </div>
</div> <div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
</div> <div class="opt-pos-pending-pane">
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden> <div class="opt-order-pending-head">
<div class="options-stats-panel"> <p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p>
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary"> <button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
<div class="options-stat-item opt-stats-net-item"> </div>
<span class="k">合计盈亏</span> <div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab">
<span class="v" id="opt-stats-total-pnl"></span> <div class="muted opt-pending-empty">暂无未成交委托</div>
</div> </div>
<div class="options-stat-item"> </div>
<span class="k">已平净盈亏</span> </div>
<span class="v" id="opt-stats-net-realized"></span> <div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
</div> <div class="options-stats-panel">
<div class="options-stat-item"> <div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
<span class="k">持仓浮盈</span> <div class="options-stat-item opt-stats-net-item">
<span class="v" id="opt-stats-open-float"></span> <span class="k">合计盈亏</span>
</div> <span class="v" id="opt-stats-total-pnl"></span>
</div> </div>
<div class="options-stats-charts"> <div class="options-stat-item">
<div class="opt-stats-chart opt-stats-chart--ring"> <span class="k">已平净盈亏</span>
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0"> <span class="v" id="opt-stats-net-realized"></span>
<span class="opt-stats-ring-label" id="opt-stats-ring-label"></span> </div>
</div> <div class="options-stat-item">
<span class="opt-stats-chart-caption">胜率</span> <span class="k">持仓浮盈</span>
</div> <span class="v" id="opt-stats-open-float"></span>
<div class="opt-stats-chart opt-stats-chart--pnl"> </div>
<div class="opt-stats-bar-row"> </div>
<span class="k">平均盈利</span> <div class="options-stats-charts">
<div class="opt-stats-bar-track"> <div class="opt-stats-chart opt-stats-chart--ring">
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div> <div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
</div> <span class="opt-stats-ring-label" id="opt-stats-ring-label"></span>
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label"></span> </div>
</div> <span class="opt-stats-chart-caption">胜率</span>
<div class="opt-stats-bar-row"> </div>
<span class="k">平均亏损</span> <div class="opt-stats-chart opt-stats-chart--pnl">
<div class="opt-stats-bar-track"> <div class="opt-stats-bar-row">
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div> <span class="k">平均盈利</span>
</div> <div class="opt-stats-bar-track">
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label"></span> <div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
</div> </div>
</div> <span class="v pos-pnl-profit" id="opt-stats-bar-profit-label"></span>
</div> </div>
<div class="opt-stats-chart opt-stats-chart--hold"> <div class="opt-stats-bar-row">
<div class="opt-stats-chart-title">持仓时长对比</div> <span class="k">平均亏损</span>
<div class="opt-stats-bar-row"> <div class="opt-stats-bar-track">
<span class="k">盈单</span> <div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
<div class="opt-stats-bar-track"> </div>
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div> <span class="v pos-pnl-loss" id="opt-stats-bar-loss-label"></span>
</div> </div>
<span class="v" id="opt-stats-win-hold-label"></span> </div>
</div> </div>
<div class="opt-stats-bar-row"> <div class="opt-stats-chart opt-stats-chart--hold">
<span class="k">亏单</span> <div class="opt-stats-chart-title">持仓时长对比</div>
<div class="opt-stats-bar-track"> <div class="opt-stats-bar-row">
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div> <span class="k">盈单</span>
</div> <div class="opt-stats-bar-track">
<span class="v" id="opt-stats-loss-hold-label"></span> <div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
</div> </div>
</div> <span class="v" id="opt-stats-win-hold-label"></span>
<div class="options-stats-grid"> </div>
<div class="options-stat-item"> <div class="opt-stats-bar-row">
<span class="k">胜率</span> <span class="k">亏单</span>
<span class="v" id="opt-stats-winrate"></span> <div class="opt-stats-bar-track">
</div> <div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
<div class="options-stat-item"> </div>
<span class="k">盈亏比</span> <span class="v" id="opt-stats-loss-hold-label"></span>
<span class="v" id="opt-stats-plr"></span> </div>
</div> </div>
<div class="options-stat-item"> <div class="options-stats-grid">
<span class="k">已平笔数</span> <div class="options-stat-item">
<span class="v" id="opt-stats-closed"></span> <span class="k">胜率</span>
</div> <span class="v" id="opt-stats-winrate"></span>
<div class="options-stat-item"> </div>
<span class="k">平均盈利</span> <div class="options-stat-item">
<span class="v pos-pnl-profit" id="opt-stats-profit"></span> <span class="k">盈亏比</span>
</div> <span class="v" id="opt-stats-plr"></span>
<div class="options-stat-item"> </div>
<span class="k">平均亏损</span> <div class="options-stat-item">
<span class="v pos-pnl-loss" id="opt-stats-loss"></span> <span class="k">已平笔数</span>
</div> <span class="v" id="opt-stats-closed"></span>
<div class="options-stat-item"> </div>
<span class="k">均持仓</span> <div class="options-stat-item">
<span class="v" id="opt-stats-avg-hold"></span> <span class="k">平均盈利</span>
</div> <span class="v pos-pnl-profit" id="opt-stats-profit"></span>
<div class="options-stat-item"> </div>
<span class="k">盈单持仓</span> <div class="options-stat-item">
<span class="v" id="opt-stats-win-hold"></span> <span class="k">平均亏损</span>
</div> <span class="v pos-pnl-loss" id="opt-stats-loss"></span>
<div class="options-stat-item"> </div>
<span class="k">亏单持仓</span> <div class="options-stat-item">
<span class="v" id="opt-stats-loss-hold"></span> <span class="k">均持仓</span>
</div> <span class="v" id="opt-stats-avg-hold"></span>
<div class="options-stat-item"> </div>
<span class="k">持仓中</span> <div class="options-stat-item">
<span class="v" id="opt-stats-open-hold"></span> <span class="k">盈单持仓</span>
</div> <span class="v" id="opt-stats-win-hold"></span>
</div> </div>
</div> <div class="options-stat-item">
</div> <span class="k">亏单持仓</span>
<div class="options-pos-pane" data-opt-pos-pane="history" role="tabpanel" aria-labelledby="opt-pos-tab-history" hidden> <span class="v" id="opt-stats-loss-hold"></span>
<div class="options-history-table-wrap"> </div>
<table class="options-strike-table opt-history-table" id="opt-history-table"> <div class="options-stat-item">
<thead> <span class="k">持仓中</span>
<tr> <span class="v" id="opt-stats-open-hold"></span>
<th>合约</th> </div>
<th>张数</th> </div>
<th>权利金</th> </div>
<th>状态</th> </div>
<th>盈亏</th> <div class="options-pos-pane" data-opt-pos-pane="history" role="tabpanel" aria-labelledby="opt-pos-tab-history" hidden>
<th>时间</th> <div class="options-history-table-wrap">
<th>操作</th> <table class="options-strike-table opt-history-table" id="opt-history-table">
</tr> <thead>
</thead> <tr>
<tbody id="opt-history-tbody"> <th>合约</th>
<tr><td colspan="7" class="muted">加载中…</td></tr> <th>张数</th>
</tbody> <th>权利金</th>
</table> <th>状态</th>
</div> <th>盈亏</th>
</div> <th>时间</th>
</div> <th>操作</th>
{% include 'options_page_funds.html' %} </tr>
</div> </thead>
</div> <tbody id="opt-history-tbody">
</div> <tr><td colspan="7" class="muted">加载中…</td></tr>
<script src="/static/options_expiry_countdown.js?v=1"></script> </tbody>
<script src="/static/options_settings.js?v=11"></script> </table>
<script src="/static/options_panel.js?v=64"></script> </div>
</div>
</div>
{% include 'options_page_funds.html' %}
</div>
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=65"></script>