对齐币本位期权:现货缓冲开仓、页头 ETH/BTC 余额与默认 coin 模式。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -113,6 +113,16 @@ OKX_OPTIONS_ENABLED=true
|
||||
# OKX_OPTIONS_API_SECRET=
|
||||
# OKX_OPTIONS_API_PASSPHRASE=
|
||||
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_BUDGET_BUFFER=0.95
|
||||
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
|
||||
|
||||
@@ -5041,6 +5041,7 @@ def render_main_page(page="options", embed_mode=None):
|
||||
show_perp_funds_enabled,
|
||||
total_funds_usdt,
|
||||
trade_records_summary,
|
||||
trading_account_label,
|
||||
)
|
||||
|
||||
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_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
|
||||
_exchange_display_for_header = EXCHANGE_DISPLAY_NAME
|
||||
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)
|
||||
):
|
||||
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(
|
||||
exchange_options
|
||||
)
|
||||
_op = options_header_balance_pack(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:
|
||||
options_trading_usdc = None
|
||||
options_funding_usdc = None
|
||||
options_funding_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)
|
||||
key_list = (
|
||||
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()
|
||||
_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(
|
||||
page=page,
|
||||
key=key_list,
|
||||
@@ -5228,6 +5246,11 @@ def render_main_page(page="options", embed_mode=None):
|
||||
options_funding_usdt=options_funding_usdt,
|
||||
options_trading_usdc=options_trading_usdc,
|
||||
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,
|
||||
daily_start_capital=DAILY_START_CAPITAL,
|
||||
current_capital=current_capital,
|
||||
@@ -5472,19 +5495,35 @@ def api_account_snapshot():
|
||||
options_funding_usdc = None
|
||||
options_funding_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:
|
||||
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(
|
||||
exchange_options,
|
||||
force=force_refresh,
|
||||
)
|
||||
_op = options_header_balance_pack(exchange_options, force=force_refresh)
|
||||
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:
|
||||
options_trading_usdc = None
|
||||
options_funding_usdc = None
|
||||
options_funding_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)
|
||||
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)
|
||||
except Exception:
|
||||
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:
|
||||
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
|
||||
@@ -5584,6 +5623,11 @@ def api_account_snapshot():
|
||||
"options_funding_usdt": options_funding_usdt,
|
||||
"options_trading_usdc": options_trading_usdc,
|
||||
"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(
|
||||
funding_usdt if _show_perp_funds else None,
|
||||
current_capital if _show_perp_funds else None,
|
||||
|
||||
@@ -868,9 +868,46 @@
|
||||
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 "—";
|
||||
const recvTxt = fmtUsdc(preview.total_received);
|
||||
const ccy = posPremiumCcy(p);
|
||||
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
|
||||
let cls = "";
|
||||
const prem = Number(premiumPaid);
|
||||
const recv = Number(preview.total_received);
|
||||
@@ -878,12 +915,13 @@
|
||||
if (recv > prem) cls = " pos-pnl-profit";
|
||||
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 "—";
|
||||
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) {
|
||||
text += " · 覆盖 " + preview.covered_sheets + "张";
|
||||
}
|
||||
@@ -893,11 +931,13 @@
|
||||
return text;
|
||||
}
|
||||
|
||||
function fmtPreviewLevels(preview) {
|
||||
function fmtPreviewLevels(preview, p) {
|
||||
const levels = (preview && preview.levels) || [];
|
||||
if (!levels.length) return "暂无可用买盘深度";
|
||||
const ccy = posPremiumCcy(p);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -926,18 +966,29 @@
|
||||
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 prem = Number(totalPremium);
|
||||
let prem = Number(totalPremium);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */
|
||||
function estimateProfitRr(profit, totalPremium) {
|
||||
function estimateProfitRr(profit, totalPremium, indexPx) {
|
||||
const pnl = Number(profit);
|
||||
const prem = Number(totalPremium);
|
||||
let prem = Number(totalPremium);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -946,6 +997,11 @@
|
||||
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) {
|
||||
if (indexPx == null || ethAmount == null || totalPremium == null) return null;
|
||||
const idx = Number(indexPx);
|
||||
@@ -954,6 +1010,10 @@
|
||||
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
|
||||
return null;
|
||||
}
|
||||
// USDC: 名义(U)/权利金(U)=指数×币数/权利金; 币本位权利金为币: 名义(U)/(权利金币×指数)=币数/权利金币
|
||||
if (isCoinMarginMode()) {
|
||||
return Math.round((amt / prem) * 10) / 10;
|
||||
}
|
||||
return Math.round((idx * amt) / prem * 10) / 10;
|
||||
}
|
||||
|
||||
@@ -962,12 +1022,15 @@
|
||||
return "约 " + Number(v).toFixed(1) + "×";
|
||||
}
|
||||
|
||||
/** 链上展示:指数 ÷ 卖一(每1币). */
|
||||
/** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 1÷卖一. */
|
||||
function calcAskLeverage(indexPx, askPx) {
|
||||
if (indexPx == null || askPx == null) return null;
|
||||
const idx = Number(indexPx);
|
||||
const ask = Number(askPx);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1021,18 +1084,22 @@
|
||||
}
|
||||
} else {
|
||||
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
|
||||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium);
|
||||
const rr = estimateProfitRr(profit, premium);
|
||||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium, q.index_px);
|
||||
const rr = estimateProfitRr(profit, premium, q.index_px);
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
valueEl.textContent = "—";
|
||||
} else {
|
||||
valueEl.textContent = fmtUsdc(value) + " USDC";
|
||||
valueEl.textContent = isCoinMarginMode()
|
||||
? (fmtUsdc(value) + " U(估)")
|
||||
: (fmtUsdc(value) + " USDC");
|
||||
}
|
||||
if (profit == null || Number.isNaN(profit)) {
|
||||
profitEl.textContent = "—";
|
||||
profitEl.className = "v";
|
||||
} else {
|
||||
profitEl.textContent = fmtUsdcSigned(profit);
|
||||
profitEl.textContent = isCoinMarginMode()
|
||||
? ((Number(profit) > 0 ? "+" : "") + fmtUsdc(profit) + " U(估)")
|
||||
: fmtUsdcSigned(profit);
|
||||
profitEl.className = "v " + pnlCls(profit);
|
||||
}
|
||||
if (rrEl) {
|
||||
@@ -1252,8 +1319,12 @@
|
||||
document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—";
|
||||
document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—";
|
||||
updateUnderlyingLabel();
|
||||
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 =
|
||||
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 distEl = document.getElementById("opt-order-dist-be");
|
||||
if (beEl) {
|
||||
@@ -1266,7 +1337,21 @@
|
||||
const openBtn = document.getElementById("opt-open-btn");
|
||||
if (openBtn) {
|
||||
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");
|
||||
if (!d.ok) {
|
||||
@@ -1288,6 +1373,9 @@
|
||||
} else if (sz.ask_depth_capped) {
|
||||
msgEl.textContent = sz.msg || "已按卖一深度限制张数";
|
||||
msgEl.classList.remove("opt-error");
|
||||
} else if (coinMode && sz.est_note) {
|
||||
msgEl.textContent = sz.est_note;
|
||||
msgEl.classList.remove("opt-error");
|
||||
} else {
|
||||
msgEl.textContent = "";
|
||||
msgEl.classList.remove("opt-error");
|
||||
@@ -1483,8 +1571,25 @@
|
||||
return false;
|
||||
} finally {
|
||||
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.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 closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
|
||||
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 带着浮点毛刺直出
|
||||
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 netTxt = closePreview.bid_invalid || net == null
|
||||
? "—"
|
||||
: (fmtPremiumAmt(net, premCcy) + (coinPos ? (" " + premCcy) : ""));
|
||||
return (
|
||||
'<div class="pos-card-head">' +
|
||||
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
|
||||
@@ -1520,21 +1633,21 @@
|
||||
: "") +
|
||||
"</div>" +
|
||||
'<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">' + 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.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 ' + 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 + '">' +
|
||||
(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--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
|
||||
(closePreview.bid_invalid
|
||||
? '<span class="muted">暂无有效买盘</span>'
|
||||
: fmtClosePreview(closePreview, p.premium_paid)) + "</span></div>" +
|
||||
: fmtClosePreview(closePreview, p.premium_paid, p)) + "</span></div>" +
|
||||
"</div>" +
|
||||
(function () {
|
||||
const hint = closeGateHint(closePreview);
|
||||
@@ -1571,6 +1684,7 @@
|
||||
);
|
||||
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
||||
const req = p.profit_exit_required_recycle;
|
||||
const premCcy = posPremiumCcy(p);
|
||||
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
||||
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
||||
return (
|
||||
@@ -1587,7 +1701,7 @@
|
||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(enabled
|
||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : ""))
|
||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
|
||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
@@ -1602,16 +1716,23 @@
|
||||
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 profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid);
|
||||
const rr = estimateProfitRr(profit, premiumPaid);
|
||||
const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid, indexPx);
|
||||
const rr = estimateProfitRr(profit, premiumPaid, indexPx);
|
||||
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">';
|
||||
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) + '">' +
|
||||
(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) + '">' +
|
||||
(rr == null ? "—" : fmtProfitRr(rr)) + "</span></span>";
|
||||
html += "</span>";
|
||||
@@ -1658,7 +1779,7 @@
|
||||
const ethAmt = posEthAmount(p);
|
||||
const prem = p.premium_paid;
|
||||
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>';
|
||||
return (
|
||||
'<div class="opt-target-row" data-inst="' + inst + '"' +
|
||||
@@ -1666,6 +1787,8 @@
|
||||
' data-strike="' + (p.strike != null ? p.strike : "") + '"' +
|
||||
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' +
|
||||
' data-prem="' + (prem != null ? prem : "") + '"' +
|
||||
' data-idx="' + (p.idx_px != null ? p.idx_px : "") + '"' +
|
||||
' data-prem-ccy="' + posPremiumCcy(p) + '"' +
|
||||
' data-armed-target="' + (armed ? tgt : "") + '">' +
|
||||
'<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="' +
|
||||
@@ -1701,7 +1824,9 @@
|
||||
row.getAttribute("data-strike"),
|
||||
targetRaw,
|
||||
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) {
|
||||
est.className = "opt-target-est opt-target-est--idle";
|
||||
@@ -1747,7 +1872,8 @@
|
||||
(expAttr
|
||||
? '<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 + '">' +
|
||||
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span>" +
|
||||
"</span>" +
|
||||
@@ -2003,12 +2129,20 @@
|
||||
return;
|
||||
}
|
||||
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 = [
|
||||
"按买一限价卖出本轮可平张数?",
|
||||
"合约: " + inst,
|
||||
"锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张",
|
||||
"预计收回: " + fmtClosePreviewText(preview),
|
||||
preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "",
|
||||
"预计收回: " + fmtClosePreviewText(preview, posLike),
|
||||
preview.estimated_pnl != null
|
||||
? ("预估盈亏: " + fmtPremiumAmtSigned(preview.estimated_pnl, premCcy))
|
||||
: "",
|
||||
preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : ""
|
||||
].filter(function (x) { return x !== ""; }).join("\n");
|
||||
if (!confirm(msg)) return;
|
||||
@@ -2022,7 +2156,9 @@
|
||||
if (r.ok) {
|
||||
let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张";
|
||||
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.stopped_reason) okMsg += "\n状态: " + r.stopped_reason;
|
||||
alert(okMsg);
|
||||
|
||||
@@ -34,6 +34,28 @@
|
||||
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) {
|
||||
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
|
||||
}
|
||||
@@ -136,9 +158,10 @@
|
||||
return (net / prem) * 100;
|
||||
}
|
||||
|
||||
function fmtClosePreview(preview, premiumPaid, hub) {
|
||||
function fmtClosePreview(preview, premiumPaid, hub, p) {
|
||||
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 = "";
|
||||
const prem = Number(premiumPaid);
|
||||
const recv = Number(preview.total_received);
|
||||
@@ -146,7 +169,7 @@
|
||||
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) {
|
||||
@@ -168,7 +191,11 @@
|
||||
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||
const closePreview = p.close_preview || {};
|
||||
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 markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
|
||||
let headActions = "";
|
||||
@@ -182,7 +209,7 @@
|
||||
const pnlCells = hidePnl
|
||||
? ""
|
||||
: '<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 + '">' +
|
||||
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
|
||||
return (
|
||||
@@ -202,7 +229,7 @@
|
||||
: "") +
|
||||
"</div>" +
|
||||
'<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">' + markTxt + "</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">' +
|
||||
(closePreview.bid_invalid
|
||||
? '<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>" +
|
||||
(function () {
|
||||
const hint = closeGateHint(closePreview);
|
||||
@@ -226,6 +253,7 @@
|
||||
const strike = Number(p.strike);
|
||||
const tgt = Number(p.target_index);
|
||||
const prem = Number(p.premium_paid);
|
||||
const idx = Number(p.idx_px);
|
||||
let profit = null;
|
||||
let value = null;
|
||||
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;
|
||||
if (intrinsic != null) {
|
||||
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 hedgeTarget = p.hedge_plan_target || null;
|
||||
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" : "") + '">' +
|
||||
'<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">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
|
||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
|
||||
profitSpan +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
||||
|
||||
Vendored
+430
-426
@@ -1,426 +1,430 @@
|
||||
"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
|
||||
|
||||
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
|
||||
_SEPARATOR_RE = re.compile(r"^#\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*=")
|
||||
|
||||
RESTART_REQUIRED_EXACT = frozenset({
|
||||
"APP_HOST",
|
||||
"APP_PORT",
|
||||
"APP_DEBUG",
|
||||
"DB_PATH",
|
||||
"UPLOAD_DIR",
|
||||
"FLASK_SECRET_KEY",
|
||||
"POSITION_SIZING_MODE",
|
||||
"LIVE_TRADING_ENABLED",
|
||||
"OKX_TD_MODE",
|
||||
"OKX_POS_MODE",
|
||||
"OKX_POSITION_INST_TYPE",
|
||||
"BINANCE_MARGIN_MODE",
|
||||
"BINANCE_POSITION_MODE",
|
||||
"GATE_TD_MODE",
|
||||
"GATE_POS_MODE",
|
||||
"PM2_APP_NAME",
|
||||
})
|
||||
|
||||
RESTART_REQUIRED_PREFIXES = (
|
||||
"OKX_API_",
|
||||
"OKX_OPTIONS_API_",
|
||||
"BINANCE_API_",
|
||||
"GATE_API_",
|
||||
"OKX_SOCKS_",
|
||||
"OKX_HTTP_",
|
||||
"OKX_HTTPS_",
|
||||
"BINANCE_HTTP_",
|
||||
"BINANCE_HTTPS_",
|
||||
"GATE_HTTP_",
|
||||
"GATE_HTTPS_",
|
||||
)
|
||||
|
||||
HOT_RELOAD_EXACT = frozenset({
|
||||
"RISK_PERCENT",
|
||||
"MAX_ACTIVE_POSITIONS",
|
||||
"MANUAL_MIN_PLANNED_RR",
|
||||
"DAILY_OPEN_ALERT_THRESHOLD",
|
||||
"DAILY_OPEN_HARD_LIMIT",
|
||||
"TRADING_DAY_RESET_HOUR",
|
||||
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
|
||||
"RISK_CONTROL_ENABLED",
|
||||
"RISK_COOLING_HOURS_MANUAL",
|
||||
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
|
||||
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
|
||||
"RISK_DAILY_LOSS_LIMIT",
|
||||
"RISK_MOOD_ISSUES_DAILY_FREEZE",
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED",
|
||||
"TRADE_DIRECTION",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED",
|
||||
"TRADE_SYMBOL_WHITELIST",
|
||||
"BALANCE_REFRESH_SECONDS",
|
||||
"PRICE_REFRESH_SECONDS",
|
||||
"MONITOR_POLL_SECONDS",
|
||||
"AUTO_TRANSFER_ENABLED",
|
||||
"AUTO_TRANSFER_AMOUNT",
|
||||
"AUTO_TRANSFER_FROM",
|
||||
"AUTO_TRANSFER_TO",
|
||||
"AUTO_TRANSFER_BJ_HOUR",
|
||||
"TRANSFER_CCY",
|
||||
"FORCE_CLOSE_ENABLED",
|
||||
"FORCE_CLOSE_BJ_HOUR",
|
||||
"FORCE_CLOSE_GRACE_MINUTES",
|
||||
"BTC_LEVERAGE",
|
||||
"ALT_LEVERAGE",
|
||||
"DAILY_START_CAPITAL",
|
||||
"DAILY_LOSS_CAPITAL",
|
||||
"DAILY_PROFIT_CAPITAL",
|
||||
"FULL_MARGIN_BUFFER_RATIO",
|
||||
"APP_USERNAME",
|
||||
"APP_PASSWORD",
|
||||
"APP_AUTH_DISABLED",
|
||||
"WECHAT_WEBHOOK",
|
||||
"HEDGE_PLAN_ENABLED",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"OKX_OPTIONS_BUDGET_BUFFER",
|
||||
"OKX_TRADE_MODE",
|
||||
"SIM_DEFAULT_MODE",
|
||||
"SIM_INITIAL_EQUITY_USDT",
|
||||
"SIM_INITIAL_USDC",
|
||||
"SIM_FEE_RATE",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_LIVE_ORDER",
|
||||
"HEDGE_PLAN_OPTION_PRIMARY",
|
||||
"HEDGE_PLAN_OPEN_ORDER",
|
||||
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO",
|
||||
"HEDGE_PLAN_BUDGET_BUFFER",
|
||||
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
|
||||
})
|
||||
|
||||
SENSITIVE_EXACT = frozenset({
|
||||
"APP_PASSWORD",
|
||||
"FLASK_SECRET_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
})
|
||||
|
||||
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
|
||||
|
||||
# env 配置页下拉:value → 中文标签
|
||||
SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
"OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
|
||||
"OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")),
|
||||
"BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
|
||||
"BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")),
|
||||
"GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
|
||||
"GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")),
|
||||
"POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")),
|
||||
"TRADE_DIRECTION": (
|
||||
("both", "双向均可"),
|
||||
("long_only", "仅做多"),
|
||||
("short_only", "仅做空"),
|
||||
),
|
||||
"AUTO_TRANSFER_FROM": (
|
||||
("funding", "funding 资金账户"),
|
||||
("swap", "swap 交易账户"),
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"AUTO_TRANSFER_TO": (
|
||||
("swap", "swap 交易账户"),
|
||||
("funding", "funding 资金账户"),
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"TRANSFER_CCY": (("USDT", "USDT"),),
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
|
||||
("budget", "预算金额"),
|
||||
("sheets", "张数"),
|
||||
),
|
||||
"OKX_TRADE_MODE": (
|
||||
("options", "单独期权"),
|
||||
("perp_options", "永期对冲"),
|
||||
("options_options", "期期对冲"),
|
||||
),
|
||||
"SIM_DEFAULT_MODE": (
|
||||
("sim", "模拟(sim)"),
|
||||
("live", "实盘(live)"),
|
||||
),
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": (
|
||||
("true", "以期权为主"),
|
||||
("false", "保险模式"),
|
||||
),
|
||||
}
|
||||
|
||||
_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"},
|
||||
"TRANSFER_CCY": {"usdt": "USDT"},
|
||||
}
|
||||
|
||||
|
||||
def _is_sensitive(key: str) -> bool:
|
||||
if key in SENSITIVE_EXACT:
|
||||
return True
|
||||
return any(s in key for s in SENSITIVE_SUBSTR)
|
||||
|
||||
|
||||
def select_options_for(key: str) -> list[dict[str, str]]:
|
||||
opts = SELECT_OPTIONS.get(key) or ()
|
||||
return [{"value": v, "label": lab} for v, lab in opts]
|
||||
|
||||
|
||||
def normalize_select_value(key: str, value: Optional[str]) -> str:
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
low = raw.lower()
|
||||
aliases = _SELECT_ALIASES.get(key) or {}
|
||||
if low in aliases:
|
||||
return aliases[low]
|
||||
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
|
||||
allowed_by_lower = {v.lower(): v for v in allowed}
|
||||
if low in allowed:
|
||||
return low
|
||||
if raw in allowed:
|
||||
return raw
|
||||
if low in allowed_by_lower:
|
||||
return allowed_by_lower[low]
|
||||
return raw
|
||||
|
||||
|
||||
def _restart_required(key: str) -> bool:
|
||||
if key in HOT_RELOAD_EXACT:
|
||||
return False
|
||||
if key in RESTART_REQUIRED_EXACT:
|
||||
return True
|
||||
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
|
||||
|
||||
|
||||
def _hot_reload(key: str) -> bool:
|
||||
if key in HOT_RELOAD_EXACT:
|
||||
return True
|
||||
if _restart_required(key):
|
||||
return False
|
||||
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
|
||||
|
||||
|
||||
def _field_type(key: str, value: str) -> str:
|
||||
if key in SELECT_OPTIONS:
|
||||
return "select"
|
||||
low = (value or "").strip().lower()
|
||||
if low in ("true", "false"):
|
||||
return "bool"
|
||||
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
):
|
||||
return "bool"
|
||||
try:
|
||||
if "." in low:
|
||||
float(low)
|
||||
return "float"
|
||||
int(low)
|
||||
return "int"
|
||||
except ValueError:
|
||||
pass
|
||||
return "text"
|
||||
|
||||
|
||||
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
|
||||
if value is None or value == "":
|
||||
return {"value": "", "masked": "", "tail": "", "has_value": False}
|
||||
if not _is_sensitive(key):
|
||||
return {"value": value, "masked": value, "tail": "", "has_value": True}
|
||||
tail = value[-4:] if len(value) >= 4 else value
|
||||
return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True}
|
||||
|
||||
|
||||
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
|
||||
if not os.path.isfile(example_path):
|
||||
return []
|
||||
lines = read_env_lines(example_path)
|
||||
groups: list[dict[str, Any]] = []
|
||||
group_map: dict[str, dict[str, Any]] = {}
|
||||
current_group = "基础配置"
|
||||
pending_note: list[str] = []
|
||||
in_section_block = False
|
||||
section_title_set = False
|
||||
allow_section_blocks = False
|
||||
|
||||
def _ensure_group(title: str) -> dict[str, Any]:
|
||||
title = (title or "").strip() or "其他"
|
||||
if title not in group_map:
|
||||
group_map[title] = {"title": title, "fields": []}
|
||||
groups.append(group_map[title])
|
||||
return group_map[title]
|
||||
|
||||
for raw in lines:
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
pending_note = []
|
||||
continue
|
||||
if _SEPARATOR_RE.match(stripped):
|
||||
if not allow_section_blocks:
|
||||
continue
|
||||
if not in_section_block:
|
||||
in_section_block = True
|
||||
section_title_set = False
|
||||
else:
|
||||
in_section_block = False
|
||||
continue
|
||||
if in_section_block and stripped.startswith("#"):
|
||||
note = stripped.lstrip("#").strip()
|
||||
if note and not section_title_set:
|
||||
current_group = note
|
||||
_ensure_group(current_group)
|
||||
section_title_set = True
|
||||
elif note:
|
||||
pending_note.append(note)
|
||||
continue
|
||||
gm = _GROUP_RE.match(stripped)
|
||||
if gm:
|
||||
title = gm.group(1).strip()
|
||||
if title and title != "=":
|
||||
current_group = title
|
||||
_ensure_group(current_group)
|
||||
in_section_block = False
|
||||
section_title_set = False
|
||||
pending_note = []
|
||||
continue
|
||||
dash = _SECTION_DASH_RE.match(stripped)
|
||||
if dash:
|
||||
allow_section_blocks = True
|
||||
current_group = dash.group(1).strip()
|
||||
_ensure_group(current_group)
|
||||
in_section_block = False
|
||||
section_title_set = False
|
||||
pending_note = []
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
note = stripped.lstrip("#").strip()
|
||||
if note and not note.startswith("="):
|
||||
pending_note.append(note)
|
||||
continue
|
||||
km = _KEY_LINE.match(stripped)
|
||||
if not km:
|
||||
continue
|
||||
key = km.group(1)
|
||||
allow_section_blocks = True
|
||||
default_val = env_get(lines, key) or ""
|
||||
grp = _ensure_group(current_group)
|
||||
note = " ".join(pending_note).strip()
|
||||
grp["fields"].append(
|
||||
{
|
||||
"key": key,
|
||||
"label": key,
|
||||
"note": note,
|
||||
"default": default_val,
|
||||
"type": _field_type(key, default_val),
|
||||
"sensitive": _is_sensitive(key),
|
||||
"restart_required": _restart_required(key),
|
||||
"hot_reload": _hot_reload(key),
|
||||
}
|
||||
)
|
||||
pending_note = []
|
||||
return [g for g in groups if g.get("fields")]
|
||||
|
||||
|
||||
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
|
||||
groups = parse_env_example_schema(example_path)
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
key = field["key"]
|
||||
val = values.get(key)
|
||||
if val is None:
|
||||
val = field.get("default") or ""
|
||||
masked = _mask_value(key, val)
|
||||
field["current"] = masked["value"] if not field["sensitive"] else ""
|
||||
field["masked"] = masked["masked"]
|
||||
field["has_value"] = masked["has_value"]
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
||||
allowed = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
allowed[field["key"]] = field
|
||||
clean: dict[str, str] = {}
|
||||
errors: list[str] = []
|
||||
for key, value in (updates or {}).items():
|
||||
if key not in allowed:
|
||||
errors.append(f"未知配置项: {key}")
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
val = str(value).strip()
|
||||
if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)):
|
||||
continue
|
||||
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
|
||||
if key.endswith("_API_KEY") and 0 < len(val) < 16:
|
||||
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
|
||||
continue
|
||||
ftype = allowed[key].get("type")
|
||||
if ftype == "bool":
|
||||
low = val.lower()
|
||||
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
||||
errors.append(f"{key} 须为 true/false")
|
||||
continue
|
||||
val = "true" if low in ("true", "1", "yes", "on") else "false"
|
||||
elif ftype == "select" or key in SELECT_OPTIONS:
|
||||
allowed_vals = {
|
||||
str(o.get("value") if isinstance(o, dict) else o[0]).lower()
|
||||
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:
|
||||
labels = " / ".join(
|
||||
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))
|
||||
)
|
||||
errors.append(f"{key} 须为: {labels}")
|
||||
continue
|
||||
val = norm
|
||||
clean[key] = val
|
||||
return clean, errors
|
||||
|
||||
|
||||
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
|
||||
field_map = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
field_map[field["key"]] = field
|
||||
for key in changed_keys:
|
||||
meta = field_map.get(key) or {}
|
||||
if meta.get("restart_required"):
|
||||
return True
|
||||
if not meta.get("hot_reload"):
|
||||
return True
|
||||
return False
|
||||
"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
|
||||
|
||||
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
|
||||
_SEPARATOR_RE = re.compile(r"^#\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*=")
|
||||
|
||||
RESTART_REQUIRED_EXACT = frozenset({
|
||||
"APP_HOST",
|
||||
"APP_PORT",
|
||||
"APP_DEBUG",
|
||||
"DB_PATH",
|
||||
"UPLOAD_DIR",
|
||||
"FLASK_SECRET_KEY",
|
||||
"POSITION_SIZING_MODE",
|
||||
"LIVE_TRADING_ENABLED",
|
||||
"OKX_TD_MODE",
|
||||
"OKX_POS_MODE",
|
||||
"OKX_POSITION_INST_TYPE",
|
||||
"BINANCE_MARGIN_MODE",
|
||||
"BINANCE_POSITION_MODE",
|
||||
"GATE_TD_MODE",
|
||||
"GATE_POS_MODE",
|
||||
"PM2_APP_NAME",
|
||||
})
|
||||
|
||||
RESTART_REQUIRED_PREFIXES = (
|
||||
"OKX_API_",
|
||||
"OKX_OPTIONS_API_",
|
||||
"BINANCE_API_",
|
||||
"GATE_API_",
|
||||
"OKX_SOCKS_",
|
||||
"OKX_HTTP_",
|
||||
"OKX_HTTPS_",
|
||||
"BINANCE_HTTP_",
|
||||
"BINANCE_HTTPS_",
|
||||
"GATE_HTTP_",
|
||||
"GATE_HTTPS_",
|
||||
)
|
||||
|
||||
HOT_RELOAD_EXACT = frozenset({
|
||||
"RISK_PERCENT",
|
||||
"MAX_ACTIVE_POSITIONS",
|
||||
"MANUAL_MIN_PLANNED_RR",
|
||||
"KEY_AUTO_MIN_PLANNED_RR",
|
||||
"DAILY_OPEN_ALERT_THRESHOLD",
|
||||
"DAILY_OPEN_HARD_LIMIT",
|
||||
"TRADING_DAY_RESET_HOUR",
|
||||
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
|
||||
"RISK_CONTROL_ENABLED",
|
||||
"RISK_COOLING_HOURS_MANUAL",
|
||||
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
|
||||
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
|
||||
"RISK_DAILY_LOSS_LIMIT",
|
||||
"RISK_MOOD_ISSUES_DAILY_FREEZE",
|
||||
"KEY_AUTO_ORDER_ENABLED",
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED",
|
||||
"TRADE_DIRECTION",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED",
|
||||
"TRADE_SYMBOL_WHITELIST",
|
||||
"BALANCE_REFRESH_SECONDS",
|
||||
"PRICE_REFRESH_SECONDS",
|
||||
"MONITOR_POLL_SECONDS",
|
||||
"AUTO_TRANSFER_ENABLED",
|
||||
"AUTO_TRANSFER_AMOUNT",
|
||||
"AUTO_TRANSFER_FROM",
|
||||
"AUTO_TRANSFER_TO",
|
||||
"AUTO_TRANSFER_BJ_HOUR",
|
||||
"TRANSFER_CCY",
|
||||
"FORCE_CLOSE_ENABLED",
|
||||
"FORCE_CLOSE_BJ_HOUR",
|
||||
"FORCE_CLOSE_GRACE_MINUTES",
|
||||
"BTC_LEVERAGE",
|
||||
"ALT_LEVERAGE",
|
||||
"DAILY_START_CAPITAL",
|
||||
"DAILY_LOSS_CAPITAL",
|
||||
"DAILY_PROFIT_CAPITAL",
|
||||
"FULL_MARGIN_BUFFER_RATIO",
|
||||
"APP_USERNAME",
|
||||
"APP_PASSWORD",
|
||||
"APP_AUTH_DISABLED",
|
||||
"WECHAT_WEBHOOK",
|
||||
"HEDGE_PLAN_ENABLED",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
|
||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
||||
"OKX_OPTIONS_BUDGET_BUFFER",
|
||||
"OKX_OPTIONS_COIN_COMPOUND",
|
||||
"OKX_OPTIONS_COIN_BUDGET_USDT",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
|
||||
"OKX_OPTIONS_COIN_MAX_USDT",
|
||||
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
|
||||
"OKX_TRADE_MODE",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_LIVE_ORDER",
|
||||
"HEDGE_PLAN_OPTION_PRIMARY",
|
||||
"HEDGE_PLAN_OPEN_ORDER",
|
||||
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
|
||||
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
|
||||
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
|
||||
"HEDGE_PLAN_OO_BIAS_RATIO",
|
||||
"HEDGE_PLAN_BUDGET_BUFFER",
|
||||
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
|
||||
"MAX_ACTIVE_HEDGE_PLANS",
|
||||
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
|
||||
})
|
||||
|
||||
SENSITIVE_EXACT = frozenset({
|
||||
"APP_PASSWORD",
|
||||
"FLASK_SECRET_KEY",
|
||||
"HUB_BRIDGE_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
})
|
||||
|
||||
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
|
||||
|
||||
# env 配置页下拉:value → 中文标签
|
||||
SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
|
||||
"OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
|
||||
"OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")),
|
||||
"BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
|
||||
"BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")),
|
||||
"GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
|
||||
"GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")),
|
||||
"POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")),
|
||||
"TRADE_DIRECTION": (
|
||||
("both", "双向均可"),
|
||||
("long_only", "仅做多"),
|
||||
("short_only", "仅做空"),
|
||||
),
|
||||
"AUTO_TRANSFER_FROM": (
|
||||
("funding", "funding 资金账户"),
|
||||
("swap", "swap 交易账户"),
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"AUTO_TRANSFER_TO": (
|
||||
("swap", "swap 交易账户"),
|
||||
("funding", "funding 资金账户"),
|
||||
("spot", "spot 现货"),
|
||||
),
|
||||
"TRANSFER_CCY": (("USDT", "USDT"),),
|
||||
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
|
||||
("budget", "预算金额"),
|
||||
("sheets", "张数"),
|
||||
),
|
||||
"OKX_TRADE_MODE": (
|
||||
("options", "单独期权"),
|
||||
("perp_options", "永期对冲"),
|
||||
("options_options", "期期对冲"),
|
||||
),
|
||||
"OKX_OPTIONS_MARGIN_MODE": (
|
||||
("coin", "币本位(USDT买币桥)"),
|
||||
("usdc", "USDC(USDⓈ权利金)"),
|
||||
),
|
||||
"HEDGE_PLAN_OPTION_PRIMARY": (
|
||||
("true", "以期权为主"),
|
||||
("false", "保险模式"),
|
||||
),
|
||||
}
|
||||
|
||||
_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"},
|
||||
"TRANSFER_CCY": {"usdt": "USDT"},
|
||||
}
|
||||
|
||||
|
||||
def _is_sensitive(key: str) -> bool:
|
||||
if key in SENSITIVE_EXACT:
|
||||
return True
|
||||
return any(s in key for s in SENSITIVE_SUBSTR)
|
||||
|
||||
|
||||
def select_options_for(key: str) -> list[dict[str, str]]:
|
||||
opts = SELECT_OPTIONS.get(key) or ()
|
||||
return [{"value": v, "label": lab} for v, lab in opts]
|
||||
|
||||
|
||||
def normalize_select_value(key: str, value: Optional[str]) -> str:
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
low = raw.lower()
|
||||
aliases = _SELECT_ALIASES.get(key) or {}
|
||||
if low in aliases:
|
||||
return aliases[low]
|
||||
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
|
||||
allowed_by_lower = {v.lower(): v for v in allowed}
|
||||
if low in allowed:
|
||||
return low
|
||||
if raw in allowed:
|
||||
return raw
|
||||
if low in allowed_by_lower:
|
||||
return allowed_by_lower[low]
|
||||
return raw
|
||||
|
||||
|
||||
def _restart_required(key: str) -> bool:
|
||||
if key in HOT_RELOAD_EXACT:
|
||||
return False
|
||||
if key in RESTART_REQUIRED_EXACT:
|
||||
return True
|
||||
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
|
||||
|
||||
|
||||
def _hot_reload(key: str) -> bool:
|
||||
if key in HOT_RELOAD_EXACT:
|
||||
return True
|
||||
if _restart_required(key):
|
||||
return False
|
||||
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
|
||||
|
||||
|
||||
def _field_type(key: str, value: str) -> str:
|
||||
if key in SELECT_OPTIONS:
|
||||
return "select"
|
||||
low = (value or "").strip().lower()
|
||||
if low in ("true", "false"):
|
||||
return "bool"
|
||||
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
|
||||
"OKX_SHOW_PERP_FUNDS",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
|
||||
):
|
||||
return "bool"
|
||||
try:
|
||||
if "." in low:
|
||||
float(low)
|
||||
return "float"
|
||||
int(low)
|
||||
return "int"
|
||||
except ValueError:
|
||||
pass
|
||||
return "text"
|
||||
|
||||
|
||||
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
|
||||
if value is None or value == "":
|
||||
return {"value": "", "masked": "", "tail": "", "has_value": False}
|
||||
if not _is_sensitive(key):
|
||||
return {"value": value, "masked": value, "tail": "", "has_value": True}
|
||||
tail = value[-4:] if len(value) >= 4 else value
|
||||
return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True}
|
||||
|
||||
|
||||
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
|
||||
if not os.path.isfile(example_path):
|
||||
return []
|
||||
lines = read_env_lines(example_path)
|
||||
groups: list[dict[str, Any]] = []
|
||||
group_map: dict[str, dict[str, Any]] = {}
|
||||
current_group = "基础配置"
|
||||
pending_note: list[str] = []
|
||||
in_section_block = False
|
||||
section_title_set = False
|
||||
allow_section_blocks = False
|
||||
|
||||
def _ensure_group(title: str) -> dict[str, Any]:
|
||||
title = (title or "").strip() or "其他"
|
||||
if title not in group_map:
|
||||
group_map[title] = {"title": title, "fields": []}
|
||||
groups.append(group_map[title])
|
||||
return group_map[title]
|
||||
|
||||
for raw in lines:
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
pending_note = []
|
||||
continue
|
||||
if _SEPARATOR_RE.match(stripped):
|
||||
if not allow_section_blocks:
|
||||
continue
|
||||
if not in_section_block:
|
||||
in_section_block = True
|
||||
section_title_set = False
|
||||
else:
|
||||
in_section_block = False
|
||||
continue
|
||||
if in_section_block and stripped.startswith("#"):
|
||||
note = stripped.lstrip("#").strip()
|
||||
if note and not section_title_set:
|
||||
current_group = note
|
||||
_ensure_group(current_group)
|
||||
section_title_set = True
|
||||
elif note:
|
||||
pending_note.append(note)
|
||||
continue
|
||||
gm = _GROUP_RE.match(stripped)
|
||||
if gm:
|
||||
title = gm.group(1).strip()
|
||||
if title and title != "=":
|
||||
current_group = title
|
||||
_ensure_group(current_group)
|
||||
in_section_block = False
|
||||
section_title_set = False
|
||||
pending_note = []
|
||||
continue
|
||||
dash = _SECTION_DASH_RE.match(stripped)
|
||||
if dash:
|
||||
allow_section_blocks = True
|
||||
current_group = dash.group(1).strip()
|
||||
_ensure_group(current_group)
|
||||
in_section_block = False
|
||||
section_title_set = False
|
||||
pending_note = []
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
note = stripped.lstrip("#").strip()
|
||||
if note and not note.startswith("="):
|
||||
pending_note.append(note)
|
||||
continue
|
||||
km = _KEY_LINE.match(stripped)
|
||||
if not km:
|
||||
continue
|
||||
key = km.group(1)
|
||||
allow_section_blocks = True
|
||||
default_val = env_get(lines, key) or ""
|
||||
grp = _ensure_group(current_group)
|
||||
note = " ".join(pending_note).strip()
|
||||
grp["fields"].append(
|
||||
{
|
||||
"key": key,
|
||||
"label": key,
|
||||
"note": note,
|
||||
"default": default_val,
|
||||
"type": _field_type(key, default_val),
|
||||
"sensitive": _is_sensitive(key),
|
||||
"restart_required": _restart_required(key),
|
||||
"hot_reload": _hot_reload(key),
|
||||
}
|
||||
)
|
||||
pending_note = []
|
||||
return [g for g in groups if g.get("fields")]
|
||||
|
||||
|
||||
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
|
||||
groups = parse_env_example_schema(example_path)
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
key = field["key"]
|
||||
val = values.get(key)
|
||||
if val is None:
|
||||
val = field.get("default") or ""
|
||||
masked = _mask_value(key, val)
|
||||
field["current"] = masked["value"] if not field["sensitive"] else ""
|
||||
field["masked"] = masked["masked"]
|
||||
field["has_value"] = masked["has_value"]
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
||||
allowed = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
allowed[field["key"]] = field
|
||||
clean: dict[str, str] = {}
|
||||
errors: list[str] = []
|
||||
for key, value in (updates or {}).items():
|
||||
if key not in allowed:
|
||||
errors.append(f"未知配置项: {key}")
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
val = str(value).strip()
|
||||
if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)):
|
||||
continue
|
||||
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
|
||||
if key.endswith("_API_KEY") and 0 < len(val) < 16:
|
||||
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
|
||||
continue
|
||||
ftype = allowed[key].get("type")
|
||||
if ftype == "bool":
|
||||
low = val.lower()
|
||||
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
||||
errors.append(f"{key} 须为 true/false")
|
||||
continue
|
||||
val = "true" if low in ("true", "1", "yes", "on") else "false"
|
||||
elif ftype == "select" or key in SELECT_OPTIONS:
|
||||
allowed_vals = {
|
||||
str(o.get("value") if isinstance(o, dict) else o[0]).lower()
|
||||
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:
|
||||
labels = " / ".join(
|
||||
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))
|
||||
)
|
||||
errors.append(f"{key} 须为: {labels}")
|
||||
continue
|
||||
val = norm
|
||||
clean[key] = val
|
||||
return clean, errors
|
||||
|
||||
|
||||
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
|
||||
field_map = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
field_map[field["key"]] = field
|
||||
for key in changed_keys:
|
||||
meta = field_map.get(key) or {}
|
||||
if meta.get("restart_required"):
|
||||
return True
|
||||
if not meta.get("hot_reload"):
|
||||
return True
|
||||
return False
|
||||
|
||||
Vendored
+594
-587
File diff suppressed because it is too large
Load Diff
+120
-22
@@ -19,7 +19,7 @@ from lib.options.options_pricing_lib import (
|
||||
)
|
||||
|
||||
_OKX_OPTION_ERR_ZH: dict[str, str] = {
|
||||
"51008": "可用余额或保证金不足(期权买入请确认交易账户 USDC 足够)",
|
||||
"51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 USDC 足够)",
|
||||
"51018": "期权账户不能持有净空头头寸",
|
||||
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
|
||||
}
|
||||
@@ -47,11 +47,18 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
|
||||
msg = str(row.get("sMsg") or "").strip()
|
||||
low = msg.lower()
|
||||
if code == "51008":
|
||||
# 勿写死「资金账户 USDT」:期权开仓常因交易户 USDC 不足
|
||||
# 勿写死「资金账户 USDT」:USDC 模式常因交易户 USDC 不足;币本位则是标的币不足
|
||||
if "usdc" in low:
|
||||
return "交易账户 USDC 可用余额不足"
|
||||
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"]
|
||||
zh = _OKX_OPTION_ERR_ZH.get(code)
|
||||
if zh:
|
||||
@@ -164,6 +171,21 @@ def format_usdc_amount(v: float | None) -> str | None:
|
||||
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:
|
||||
"""仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓."""
|
||||
close_type = str(raw.get("type") or "").strip()
|
||||
@@ -509,8 +531,8 @@ def fetch_account_balances_by_type(
|
||||
ex: ccxt.okx,
|
||||
account_type: str,
|
||||
) -> tuple[dict[str, float | None], dict[str, float | None]]:
|
||||
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||
avail: 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, "ETH": None, "BTC": None}
|
||||
try:
|
||||
bal = ex.fetch_balance(params={"type": account_type})
|
||||
for c in out:
|
||||
@@ -525,8 +547,8 @@ def fetch_funding_balances_via_asset_api(
|
||||
ex: ccxt.okx,
|
||||
) -> tuple[dict[str, float | None], dict[str, float | None]]:
|
||||
"""OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确."""
|
||||
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||
avail: 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, "ETH": None, "BTC": None}
|
||||
try:
|
||||
resp = ex.private_get_asset_balances({})
|
||||
for row in (resp or {}).get("data") or []:
|
||||
@@ -609,24 +631,34 @@ def fetch_options_balances(
|
||||
funding = _merge_balance_maps(funding, asset_funding)
|
||||
funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail)
|
||||
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")
|
||||
if swap_bal.get("USDC") is not None:
|
||||
trading["USDC"] = swap_bal["USDC"]
|
||||
if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None:
|
||||
trading_avail["USDC"] = swap_avail["USDC"]
|
||||
for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"):
|
||||
if trading.get(ccy) is None and swap_bal.get(ccy) is not None:
|
||||
trading[ccy] = swap_bal[ccy]
|
||||
if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None:
|
||||
trading_avail[ccy] = swap_avail[ccy]
|
||||
result = {
|
||||
"scope": "main",
|
||||
"funding_usdt": funding.get("USDT"),
|
||||
"funding_usdc": funding.get("USDC"),
|
||||
"funding_usdg": funding.get("USDG"),
|
||||
"funding_eth": funding.get("ETH"),
|
||||
"funding_btc": funding.get("BTC"),
|
||||
"funding_usdt_avail": funding_avail.get("USDT"),
|
||||
"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_usdc": trading.get("USDC"),
|
||||
"trading_usdg": trading.get("USDG"),
|
||||
"trading_eth": trading.get("ETH"),
|
||||
"trading_btc": trading.get("BTC"),
|
||||
"trading_usdt_avail": trading_avail.get("USDT"),
|
||||
"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["data"] = result
|
||||
@@ -642,22 +674,63 @@ def options_header_balances(
|
||||
|
||||
返回:(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)
|
||||
|
||||
def _round(v: Any) -> float | None:
|
||||
def _round(v: Any, nd: int = 2) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), 2)
|
||||
return round(float(v), nd)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return (
|
||||
_round(bal.get("trading_usdc")),
|
||||
_round(bal.get("funding_usdc")),
|
||||
_round(bal.get("funding_usdt")),
|
||||
_round(bal.get("trading_usdt")),
|
||||
)
|
||||
def _round_coin(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
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:
|
||||
@@ -734,9 +807,19 @@ def build_option_chain(
|
||||
itm_only: bool = True,
|
||||
itm_max_dist_usd: float = 30.0,
|
||||
index_px: float | None = None,
|
||||
margin_mode: str | None = None,
|
||||
inst_family: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
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"
|
||||
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
||||
now_ms = time.time() * 1000
|
||||
@@ -838,6 +921,8 @@ def build_option_chain(
|
||||
"underlying": u,
|
||||
"index_px": idx,
|
||||
"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,
|
||||
"instruments_count": len(instruments),
|
||||
}
|
||||
@@ -1727,6 +1812,16 @@ def format_position_row(
|
||||
ct_mult=ct_mult,
|
||||
)
|
||||
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 {
|
||||
"inst_id": inst_id or pos.get("instId"),
|
||||
"pos": sheets,
|
||||
@@ -1735,11 +1830,14 @@ def format_position_row(
|
||||
"mark_px": mark,
|
||||
"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,
|
||||
"premium_paid_fmt": format_usdc_amount(premium_paid),
|
||||
"premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
|
||||
"tick_sz": tick_sz,
|
||||
"ct_mult": ct_mult,
|
||||
"idx_px": idx_px,
|
||||
"premium_paid": premium_paid,
|
||||
"margin_mode": row_mode,
|
||||
"premium_ccy": premium_ccy,
|
||||
"underlying": underly,
|
||||
"upl": upl,
|
||||
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
|
||||
"exp_time": exp_time_ms,
|
||||
|
||||
@@ -832,6 +832,20 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
body = request.get_json(silent=True) or {}
|
||||
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)
|
||||
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():
|
||||
gates = _gates_dict(cfg, plan_type)
|
||||
if not dry_run and not gates.get("can_start"):
|
||||
|
||||
@@ -1,194 +1,249 @@
|
||||
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
EMBED_STRATEGY_PAGES = frozenset()
|
||||
|
||||
_WIN_EPS = 1e-9
|
||||
|
||||
|
||||
def env_truthy(raw: str | None, default: bool = False) -> bool:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool:
|
||||
"""OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True."""
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if ex and ex != "okx":
|
||||
return True
|
||||
return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbedRenderPlan:
|
||||
exchange_capitals: bool
|
||||
records_rows: bool
|
||||
records_summary: bool
|
||||
key_history: bool
|
||||
key_list: bool
|
||||
orders: bool
|
||||
stats_bundle: bool
|
||||
strategy: bool
|
||||
orphan_live: bool
|
||||
|
||||
|
||||
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
||||
if embed_mode not in ("fragment", "shell"):
|
||||
return EmbedRenderPlan(
|
||||
exchange_capitals=True,
|
||||
records_rows=True,
|
||||
records_summary=False,
|
||||
key_history=True,
|
||||
key_list=True,
|
||||
orders=True,
|
||||
stats_bundle=True,
|
||||
strategy=True,
|
||||
orphan_live=True,
|
||||
)
|
||||
is_shell = embed_mode == "shell"
|
||||
is_strategy = page in EMBED_STRATEGY_PAGES
|
||||
return EmbedRenderPlan(
|
||||
exchange_capitals=is_shell,
|
||||
records_rows=False, # 永续交易记录页已移除
|
||||
# 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏
|
||||
records_summary=False,
|
||||
key_history=page == "key_monitor",
|
||||
key_list=page == "key_monitor" or is_strategy,
|
||||
orders=False, # 实盘下单界面已移除;对冲永续下单不依赖本页数据
|
||||
stats_bundle=False,
|
||||
strategy=is_strategy,
|
||||
orphan_live=False,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
aw = float(avg_win)
|
||||
al = float(avg_loss)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if al == 0:
|
||||
return None
|
||||
return round(aw / abs(al), 2)
|
||||
|
||||
|
||||
def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None:
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
for row in trades or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if pnl > _WIN_EPS:
|
||||
wins.append(pnl)
|
||||
elif pnl < -_WIN_EPS:
|
||||
losses.append(pnl)
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
return profit_loss_ratio_from_averages(avg_win, avg_loss)
|
||||
|
||||
|
||||
def options_funding_label(
|
||||
funding_usdc: float | None,
|
||||
funding_usdt: float | None = None,
|
||||
) -> str:
|
||||
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略."""
|
||||
_ = funding_usdt
|
||||
if funding_usdc is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(funding_usdc):.2f} USDC"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def total_funds_usdt(
|
||||
funding_usdt: float | None,
|
||||
trading_usdt: float | None,
|
||||
options_trading_usdc: float | None = None,
|
||||
options_funding_usdc: float | None = None,
|
||||
options_funding_usdt: float | None = None,
|
||||
options_trading_usdt: float | None = None,
|
||||
) -> float | None:
|
||||
parts = [
|
||||
funding_usdt,
|
||||
trading_usdt,
|
||||
options_funding_usdc,
|
||||
options_funding_usdt,
|
||||
options_trading_usdc,
|
||||
options_trading_usdt,
|
||||
]
|
||||
if all(v is None for v in parts):
|
||||
return None
|
||||
try:
|
||||
total = 0.0
|
||||
for v in parts:
|
||||
if v is not None:
|
||||
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": []}
|
||||
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"})
|
||||
|
||||
_WIN_EPS = 1e-9
|
||||
|
||||
|
||||
def env_truthy(raw: str | None, default: bool = False) -> bool:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool:
|
||||
"""OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True."""
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if ex and ex != "okx":
|
||||
return True
|
||||
return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbedRenderPlan:
|
||||
exchange_capitals: bool
|
||||
records_rows: bool
|
||||
records_summary: bool
|
||||
key_history: bool
|
||||
key_list: bool
|
||||
orders: bool
|
||||
stats_bundle: bool
|
||||
strategy: bool
|
||||
orphan_live: bool
|
||||
|
||||
|
||||
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
||||
if embed_mode not in ("fragment", "shell"):
|
||||
return EmbedRenderPlan(
|
||||
exchange_capitals=True,
|
||||
records_rows=True,
|
||||
records_summary=False,
|
||||
key_history=True,
|
||||
key_list=True,
|
||||
orders=True,
|
||||
stats_bundle=True,
|
||||
strategy=True,
|
||||
orphan_live=True,
|
||||
)
|
||||
is_shell = embed_mode == "shell"
|
||||
is_strategy = page in EMBED_STRATEGY_PAGES
|
||||
return EmbedRenderPlan(
|
||||
exchange_capitals=is_shell,
|
||||
records_rows=page == "records",
|
||||
# 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏
|
||||
records_summary=is_shell and page != "records",
|
||||
key_history=page == "key_monitor",
|
||||
key_list=page in ("key_monitor", "trade") or is_strategy,
|
||||
orders=page == "trade" or is_strategy,
|
||||
stats_bundle=page == "stats",
|
||||
strategy=is_strategy,
|
||||
orphan_live=page == "trade" and is_shell,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
aw = float(avg_win)
|
||||
al = float(avg_loss)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if al == 0:
|
||||
return None
|
||||
return round(aw / abs(al), 2)
|
||||
|
||||
|
||||
def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None:
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
for row in trades or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if pnl > _WIN_EPS:
|
||||
wins.append(pnl)
|
||||
elif pnl < -_WIN_EPS:
|
||||
losses.append(pnl)
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
return profit_loss_ratio_from_averages(avg_win, avg_loss)
|
||||
|
||||
|
||||
def options_funding_label(
|
||||
funding_usdc: float | None,
|
||||
funding_usdt: float | None = None,
|
||||
funding_eth: float | None = None,
|
||||
margin_mode: str | None = None,
|
||||
underly: str = "ETH",
|
||||
) -> str:
|
||||
"""期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列)."""
|
||||
if funding_usdc is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(funding_usdc):.2f} USDC"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _fmt_coin_amount(v: float | None, *, min_amt: float = 1e-6) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
n = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if n < min_amt:
|
||||
return None
|
||||
txt = f"{n:.6f}".rstrip("0").rstrip(".")
|
||||
return txt or None
|
||||
|
||||
|
||||
def trading_account_label(
|
||||
usdt: float | None,
|
||||
eth: float | None = None,
|
||||
btc: float | None = None,
|
||||
*,
|
||||
margin_mode: str | None = None,
|
||||
) -> str:
|
||||
"""交易账户顶栏文案.
|
||||
|
||||
币本位:USDT / ETH / BTC(有余额才带上,不显示其它币种).
|
||||
其它模式:xx.xxU.
|
||||
"""
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
|
||||
mode = normalize_options_margin_mode(margin_mode)
|
||||
except Exception:
|
||||
mode = str(margin_mode or "coin").strip().lower() or "coin"
|
||||
if mode != "coin":
|
||||
if usdt is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(usdt):.2f}U"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
parts: list[str] = []
|
||||
if usdt is not None:
|
||||
try:
|
||||
parts.append(f"{float(usdt):.2f} USDT")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
|
||||
if eth_txt is not None:
|
||||
parts.append(f"{eth_txt} ETH")
|
||||
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
|
||||
if btc_txt is not None:
|
||||
parts.append(f"{btc_txt} BTC")
|
||||
return " / ".join(parts) if parts else "—"
|
||||
|
||||
|
||||
def total_funds_usdt(
|
||||
funding_usdt: float | None,
|
||||
trading_usdt: float | None,
|
||||
options_trading_usdc: float | None = None,
|
||||
options_funding_usdc: float | None = None,
|
||||
options_funding_usdt: float | None = None,
|
||||
options_trading_usdt: float | None = None,
|
||||
) -> float | None:
|
||||
parts = [
|
||||
funding_usdt,
|
||||
trading_usdt,
|
||||
options_funding_usdc,
|
||||
options_funding_usdt,
|
||||
options_trading_usdc,
|
||||
options_trading_usdt,
|
||||
]
|
||||
if all(v is None for v in parts):
|
||||
return None
|
||||
try:
|
||||
total = 0.0
|
||||
for v in parts:
|
||||
if v is not None:
|
||||
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": []}
|
||||
|
||||
@@ -1,170 +1,194 @@
|
||||
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
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_ui_manifest import (
|
||||
build_env_ui_payload,
|
||||
filter_updates_for_ui,
|
||||
coerce_hedge_partial_close_with_manual,
|
||||
validate_env_ui_updates,
|
||||
)
|
||||
from lib.env.env_schema import parse_env_example_schema
|
||||
from lib.instance.instance_display_prefs_lib import (
|
||||
display_meta_for_ui,
|
||||
get_display_prefs,
|
||||
normalize_display_prefs,
|
||||
save_display_prefs,
|
||||
tab_allowed,
|
||||
)
|
||||
from lib.instance.instance_pm2_lib import restart_instance_pm2
|
||||
from lib.instance.runtime_config_lib import apply_env_reload
|
||||
|
||||
|
||||
def _api_login_required():
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
logged_in = bool(session.get("logged_in"))
|
||||
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
if auth_disabled or logged_in:
|
||||
return f(*args, **kwargs)
|
||||
return jsonify({"ok": False, "msg": "未登录"}), 401
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_instance_settings_routes(
|
||||
app,
|
||||
*,
|
||||
get_db: Callable,
|
||||
login_required_fn: Callable,
|
||||
base_dir: str,
|
||||
exchange_key: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
env_path = os.path.join(base_dir, ".env")
|
||||
example_path = os.path.join(base_dir, ".env.example")
|
||||
api_auth = _api_login_required()
|
||||
|
||||
@app.route("/api/settings/display", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_display():
|
||||
if request.method == "GET":
|
||||
prefs = get_display_prefs(get_db)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"display": prefs,
|
||||
"meta": display_meta_for_ui(),
|
||||
}
|
||||
)
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get("display") if isinstance(body.get("display"), dict) else body
|
||||
saved = save_display_prefs(get_db, raw)
|
||||
return jsonify({"ok": True, "display": saved})
|
||||
|
||||
@app.route("/api/settings/env/meta", methods=["GET"])
|
||||
@api_auth
|
||||
def api_env_meta():
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
|
||||
@app.route("/api/settings/env", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_env():
|
||||
if request.method == "GET":
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
body = request.get_json(silent=True) or {}
|
||||
updates = body.get("values") if isinstance(body.get("values"), dict) else body
|
||||
if not isinstance(updates, dict):
|
||||
return jsonify({"ok": False, "msg": "无效请求体"}), 400
|
||||
updates = filter_updates_for_ui(exchange_key, updates)
|
||||
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
|
||||
if errors:
|
||||
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
|
||||
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
|
||||
if not clean:
|
||||
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
|
||||
changed = apply_env_updates(env_path, clean)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"changed_keys": changed,
|
||||
"restart_required": reload_info.get("restart_required", False),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings/password", methods=["POST"])
|
||||
@api_auth
|
||||
def api_change_password():
|
||||
body = request.get_json(silent=True) or {}
|
||||
old_password = str(body.get("old_password") or "")
|
||||
new_username = str(body.get("new_username") or "").strip()
|
||||
new_password = str(body.get("new_password") or "")
|
||||
confirm = str(body.get("confirm_password") or "")
|
||||
if not old_password or old_password != password:
|
||||
return jsonify({"ok": False, "msg": "当前密码错误"}), 400
|
||||
if len(new_password) < 6:
|
||||
return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
|
||||
if new_password != confirm:
|
||||
return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
|
||||
updates: dict[str, str] = {"APP_PASSWORD": new_password}
|
||||
if new_username:
|
||||
updates["APP_USERNAME"] = new_username
|
||||
changed = apply_env_updates(env_path, updates)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
|
||||
|
||||
@app.route("/api/admin/restart", methods=["POST"])
|
||||
@api_auth
|
||||
def api_admin_restart():
|
||||
result = restart_instance_pm2(exchange_key, defer=True)
|
||||
code = 200 if result.get("ok") else 500
|
||||
return jsonify({"ok": bool(result.get("ok")), **result}), code
|
||||
|
||||
@app.route("/api/admin/health", methods=["GET"])
|
||||
def api_admin_health():
|
||||
return jsonify({"ok": True, "status": "up"})
|
||||
|
||||
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
|
||||
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
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_ui_manifest import (
|
||||
build_env_ui_payload,
|
||||
filter_updates_for_ui,
|
||||
coerce_hedge_partial_close_with_manual,
|
||||
validate_env_ui_updates,
|
||||
)
|
||||
from lib.env.env_schema import parse_env_example_schema
|
||||
from lib.instance.instance_display_prefs_lib import (
|
||||
display_meta_for_ui,
|
||||
get_display_prefs,
|
||||
normalize_display_prefs,
|
||||
save_display_prefs,
|
||||
tab_allowed,
|
||||
)
|
||||
from lib.instance.instance_pm2_lib import restart_instance_pm2
|
||||
from lib.instance.runtime_config_lib import apply_env_reload
|
||||
|
||||
|
||||
def _api_login_required(hub_token_write_allowed: bool = False):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
from lib.hub.hub_auth import request_allowed as hub_request_allowed
|
||||
|
||||
logged_in = bool(session.get("logged_in"))
|
||||
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
hub_hdr = (request.headers.get("X-Hub-Token") or "").strip()
|
||||
bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
|
||||
if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed:
|
||||
return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403
|
||||
if hub_request_allowed(logged_in, auth_disabled):
|
||||
return f(*args, **kwargs)
|
||||
return jsonify({"ok": False, "msg": "未登录"}), 401
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_instance_settings_routes(
|
||||
app,
|
||||
*,
|
||||
get_db: Callable,
|
||||
login_required_fn: Callable,
|
||||
base_dir: str,
|
||||
exchange_key: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
env_path = os.path.join(base_dir, ".env")
|
||||
example_path = os.path.join(base_dir, ".env.example")
|
||||
api_auth = _api_login_required()
|
||||
|
||||
@app.route("/api/settings/display", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_display():
|
||||
if request.method == "GET":
|
||||
prefs = get_display_prefs(get_db)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"display": prefs,
|
||||
"meta": display_meta_for_ui(),
|
||||
}
|
||||
)
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get("display") if isinstance(body.get("display"), dict) else body
|
||||
saved = save_display_prefs(get_db, raw)
|
||||
return jsonify({"ok": True, "display": saved})
|
||||
|
||||
@app.route("/api/settings/env/meta", methods=["GET"])
|
||||
@api_auth
|
||||
def api_env_meta():
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
|
||||
@app.route("/api/settings/env", methods=["GET", "POST"])
|
||||
@api_auth
|
||||
def api_settings_env():
|
||||
if request.method == "GET":
|
||||
groups = build_env_ui_payload(exchange_key, example_path, env_path)
|
||||
return jsonify({"ok": True, "groups": groups})
|
||||
body = request.get_json(silent=True) or {}
|
||||
updates = body.get("values") if isinstance(body.get("values"), dict) else body
|
||||
if not isinstance(updates, dict):
|
||||
return jsonify({"ok": False, "msg": "无效请求体"}), 400
|
||||
updates = filter_updates_for_ui(exchange_key, updates)
|
||||
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
|
||||
if errors:
|
||||
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
|
||||
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
|
||||
if not clean:
|
||||
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
|
||||
if "OKX_OPTIONS_MARGIN_MODE" in clean:
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
|
||||
|
||||
lines = read_env_lines(env_path)
|
||||
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
|
||||
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
|
||||
if old_mode != new_mode:
|
||||
conn_m = get_db()
|
||||
try:
|
||||
block = mode_switch_block_msg(conn_m, None)
|
||||
if block:
|
||||
return jsonify({"ok": False, "msg": block}), 400
|
||||
finally:
|
||||
conn_m.close()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
|
||||
changed = apply_env_updates(env_path, clean)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"changed_keys": changed,
|
||||
"restart_required": reload_info.get("restart_required", False),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings/password", methods=["POST"])
|
||||
@api_auth
|
||||
def api_change_password():
|
||||
body = request.get_json(silent=True) or {}
|
||||
old_password = str(body.get("old_password") or "")
|
||||
new_username = str(body.get("new_username") or "").strip()
|
||||
new_password = str(body.get("new_password") or "")
|
||||
confirm = str(body.get("confirm_password") or "")
|
||||
if not old_password or old_password != password:
|
||||
return jsonify({"ok": False, "msg": "当前密码错误"}), 400
|
||||
if len(new_password) < 6:
|
||||
return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
|
||||
if new_password != confirm:
|
||||
return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
|
||||
updates: dict[str, str] = {"APP_PASSWORD": new_password}
|
||||
if new_username:
|
||||
updates["APP_USERNAME"] = new_username
|
||||
changed = apply_env_updates(env_path, updates)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
|
||||
|
||||
@app.route("/api/admin/restart", methods=["POST"])
|
||||
@api_auth
|
||||
def api_admin_restart():
|
||||
result = restart_instance_pm2(exchange_key, defer=True)
|
||||
code = 200 if result.get("ok") else 500
|
||||
return jsonify({"ok": bool(result.get("ok")), **result}), code
|
||||
|
||||
@app.route("/api/admin/health", methods=["GET"])
|
||||
def api_admin_health():
|
||||
return jsonify({"ok": True, "status": "up"})
|
||||
|
||||
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
|
||||
|
||||
@@ -268,7 +268,7 @@ function toggleListWindowCustom(){
|
||||
|
||||
function applyListWindow(){
|
||||
const qs = listWindowQueryString();
|
||||
const path = window.location.pathname || "/options";
|
||||
const path = window.location.pathname || "/trade";
|
||||
window.location.href = qs ? (path + "?" + qs) : path;
|
||||
}
|
||||
|
||||
@@ -1136,13 +1136,36 @@ function paintRealtimePnlFromSnapshot(data){
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptionsFundingLabel(usdc, usdt) {
|
||||
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
|
||||
function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
|
||||
if (usdc === null || usdc === undefined || usdc === "") return "—";
|
||||
const n = Number(usdc);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
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){
|
||||
if(text == null || text === "") return;
|
||||
@@ -1156,6 +1179,11 @@ function applyPerpFundsVisibility(show){
|
||||
el.style.display = on ? "" : "none";
|
||||
});
|
||||
}
|
||||
function applyOptionsFundsVisibility(show){
|
||||
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
|
||||
el.style.display = show ? "" : "none";
|
||||
});
|
||||
}
|
||||
function accountSnapshotFundingMissing(data){
|
||||
if(!data || typeof data !== "object") return true;
|
||||
if(data.show_perp_funds === false){
|
||||
@@ -1175,16 +1203,13 @@ function accountSnapshotFundingMissing(data){
|
||||
let accountSnapshotRetryCount = 0;
|
||||
function applyAccountSnapshot(data){
|
||||
if(!data || typeof data !== "object") return;
|
||||
if(data.updated_at){
|
||||
const updatedEl = document.getElementById("price-last-updated");
|
||||
if(updatedEl) updatedEl.innerText = data.updated_at;
|
||||
}
|
||||
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
|
||||
if(typeof data.show_perp_funds !== "undefined"){
|
||||
applyPerpFundsVisibility(data.show_perp_funds);
|
||||
}
|
||||
if(data.exchange_mode_label){
|
||||
setFundsFieldText("exchange-mode-label", data.exchange_mode_label);
|
||||
applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
|
||||
} else if (coinMode) {
|
||||
applyPerpFundsVisibility(true);
|
||||
}
|
||||
applyOptionsFundsVisibility(!coinMode);
|
||||
if(data.funding_usdt != null && data.funding_usdt !== ""){
|
||||
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`);
|
||||
}
|
||||
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){
|
||||
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt);
|
||||
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,
|
||||
data.options_funding_eth,
|
||||
data.options_margin_mode,
|
||||
data.options_underly
|
||||
);
|
||||
setFundsFieldText("options-funding-usdc", optFunding);
|
||||
}
|
||||
if(data.options_trading_usdc != null || data.options_trading_usdt != null){
|
||||
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt);
|
||||
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,
|
||||
data.options_trading_eth,
|
||||
data.options_margin_mode,
|
||||
data.options_underly
|
||||
);
|
||||
setFundsFieldText("options-trading-usdc", optTrading);
|
||||
}
|
||||
if(typeof data.unrealized_pnl !== "undefined"){
|
||||
@@ -1270,11 +1315,7 @@ function applyAccountSnapshot(data){
|
||||
}
|
||||
function refreshAccountSnapshot(opts){
|
||||
const options = opts || {};
|
||||
const params = new URLSearchParams();
|
||||
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()) : "";
|
||||
const qs = options.force ? "?force=1" : "";
|
||||
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
|
||||
applyAccountSnapshot(data);
|
||||
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
|
||||
|
||||
+2101
-1850
File diff suppressed because it is too large
Load Diff
@@ -38,11 +38,13 @@
|
||||
{% include 'instance_header_stats.html' %}
|
||||
</div>
|
||||
<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>
|
||||
<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 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>
|
||||
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
|
||||
</span>
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
|
||||
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
|
||||
<div class="stat-strip-item stat-strip-item--primary">
|
||||
<div class="label">交易所</div>
|
||||
<div class="value" id="exchange-mode-label" data-funds-field="exchange-mode-label">{{ exchange_display }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">交易日</div>
|
||||
<div class="value">{{ trading_day }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item stat-strip-item--primary">
|
||||
<div class="label">总交易</div>
|
||||
<div class="value" id="stat-total" data-funds-field="stat-total">{{ total }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">胜率</div>
|
||||
<div class="value" id="stat-rate" data-funds-field="stat-rate">{{ rate }}%</div>
|
||||
</div>
|
||||
<div class="stat-strip-item" title="平均盈利 ÷ 平均亏损(当前列表窗口)">
|
||||
<div class="label">盈亏比</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>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">总资金</div>
|
||||
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<div class="label">资金账户</div>
|
||||
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<div class="label">交易账户</div>
|
||||
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div>
|
||||
</div>
|
||||
{% if options_enabled %}
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">期权资金账户</div>
|
||||
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc) }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">期权交易账户</div>
|
||||
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-strip-item stat-strip-item--pnl">
|
||||
<div class="label">实时盈亏</div>
|
||||
<div class="value" id="realtime-pnl" data-funds-field="realtime-pnl">—</div>
|
||||
</div>
|
||||
</div>
|
||||
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
|
||||
{% set _coin_margin = (options_margin_mode|default('coin')) == 'coin' %}
|
||||
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
|
||||
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
|
||||
<div class="stat-strip-item stat-strip-item--primary">
|
||||
<div class="label">交易所</div>
|
||||
<div class="value">{{ exchange_display }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">交易日</div>
|
||||
<div class="value">{{ trading_day }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item stat-strip-item--primary">
|
||||
<div class="label">总交易</div>
|
||||
<div class="value" id="stat-total" data-funds-field="stat-total">{{ total }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">胜率</div>
|
||||
<div class="value" id="stat-rate" data-funds-field="stat-rate">{{ rate }}%</div>
|
||||
</div>
|
||||
<div class="stat-strip-item" title="平均盈利 ÷ 平均亏损(当前列表窗口)">
|
||||
<div class="label">盈亏比</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>
|
||||
<div class="stat-strip-item">
|
||||
<div class="label">总资金</div>
|
||||
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<div class="label">资金账户</div>
|
||||
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
|
||||
<div class="label">交易账户</div>
|
||||
<div class="value" id="current-capital" data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth, options_trading_btc, margin_mode=options_margin_mode|default('coin')) }}</div>
|
||||
</div>
|
||||
{% if options_enabled and not _coin_margin %}
|
||||
<div class="stat-strip-item" data-options-funds="1">
|
||||
<div class="label">期权资金账户</div>
|
||||
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc, options_funding_usdt, options_funding_eth, options_margin_mode, options_underly|default('ETH')) }}</div>
|
||||
</div>
|
||||
<div class="stat-strip-item" data-options-funds="1">
|
||||
<div class="label">期权交易账户</div>
|
||||
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc, options_trading_usdt, options_trading_eth, options_margin_mode, options_underly|default('ETH')) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-strip-item stat-strip-item--pnl">
|
||||
<div class="label">实时盈亏</div>
|
||||
<div class="value" id="realtime-pnl" data-funds-field="realtime-pnl">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,7 +351,7 @@ def close_option_by_bid1(
|
||||
# 自动平已挂过单:同仓续批只验流动性
|
||||
mark_close_gate_passed(inst_id)
|
||||
|
||||
return {
|
||||
out = {
|
||||
"ok": True,
|
||||
"mode": "bid1",
|
||||
"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:
|
||||
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
|
||||
|
||||
|
||||
# 兼容旧名
|
||||
|
||||
@@ -60,6 +60,14 @@ def is_close_gate_passed(inst_id: str) -> bool:
|
||||
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(
|
||||
inst_id: str,
|
||||
*,
|
||||
@@ -68,6 +76,7 @@ def update_close_gate(
|
||||
now: float | None = None,
|
||||
min_mult: float | None = None,
|
||||
hold_seconds: float | None = None,
|
||||
premium_ccy: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
根据当前买盘可回收金额刷新门控.
|
||||
@@ -89,9 +98,16 @@ def update_close_gate(
|
||||
if hold < 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)
|
||||
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(
|
||||
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,
|
||||
"hold_seconds": hold,
|
||||
"passed": passed,
|
||||
"premium_ccy": ccy,
|
||||
}
|
||||
_gates[inst] = state
|
||||
|
||||
@@ -126,10 +143,13 @@ def update_close_gate(
|
||||
elif recv is None:
|
||||
msg = "暂无有效买盘可回收金额"
|
||||
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:
|
||||
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)门控才通过"
|
||||
)
|
||||
else:
|
||||
@@ -144,6 +164,7 @@ def update_close_gate(
|
||||
"recycle_usdc": recv,
|
||||
"premium_paid": prem,
|
||||
"need_recycle_usdc": need,
|
||||
"premium_ccy": ccy,
|
||||
"min_mult": mult,
|
||||
"hold_seconds": hold,
|
||||
"held_seconds": round(held, 1) if recycle_ok else 0.0,
|
||||
@@ -160,25 +181,39 @@ def check_close_gate(
|
||||
*,
|
||||
recycle_usdc: float | None = None,
|
||||
premium_paid: float | None = None,
|
||||
premium_ccy: str | None = None,
|
||||
refresh: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""检查是否允许平仓;默认先用最新回收/权利金刷新."""
|
||||
inst = (inst_id or "").strip()
|
||||
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:
|
||||
prev = _gates.get(inst) or {}
|
||||
if recycle_usdc is None:
|
||||
recycle_usdc = prev.get("recycle")
|
||||
if premium_paid is None:
|
||||
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:
|
||||
prev = _gates.get(inst)
|
||||
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(
|
||||
inst,
|
||||
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_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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_mult REAL DEFAULT 1.0",
|
||||
"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:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
|
||||
@@ -124,7 +134,8 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
|
||||
).fetchone()
|
||||
if not row or int(row["n"] or 0) < 1:
|
||||
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:
|
||||
|
||||
@@ -14,14 +14,21 @@ def enrich_position_row_display(
|
||||
meta_cache: dict[str, dict[str, Any] | None] | None = None,
|
||||
premium_override: float | None = None,
|
||||
) -> 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()
|
||||
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_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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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)}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -51,9 +51,12 @@ def attach_close_preview(
|
||||
intrinsic_px=intrinsic,
|
||||
max_levels=1,
|
||||
)
|
||||
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
|
||||
# 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要)
|
||||
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_blocked"] = True
|
||||
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
|
||||
@@ -64,6 +67,7 @@ def attach_close_preview(
|
||||
inst_id,
|
||||
recycle_usdc=_safe_float(preview.get("total_received")),
|
||||
premium_paid=paid,
|
||||
premium_ccy=premium_ccy,
|
||||
)
|
||||
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
|
||||
preview["close_gate"] = gate
|
||||
|
||||
@@ -512,12 +512,25 @@ def equivalent_contract_leverage(
|
||||
index_px: float | None,
|
||||
eth_amount: float | None,
|
||||
total_premium: float | None,
|
||||
margin_mode: str | None = 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:
|
||||
return None
|
||||
if eth_amount <= 0 or total_premium <= 0:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+283
-21
@@ -59,10 +59,21 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
|
||||
cfg = _build_cfg(app_module)
|
||||
app.extensions["options_cfg"] = cfg
|
||||
register_options_routes(app, cfg)
|
||||
_register_options_hub_bridge(app, cfg)
|
||||
if enabled:
|
||||
_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]:
|
||||
from lib.exchange.okx_options_lib import (
|
||||
@@ -83,7 +94,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
transfer_ccy,
|
||||
)
|
||||
|
||||
cfg = {
|
||||
return {
|
||||
"enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
|
||||
"get_db": app_module.get_db,
|
||||
"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_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),
|
||||
"margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(),
|
||||
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
||||
"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),
|
||||
@@ -125,12 +137,6 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
"options_api_ready": options_api_ready,
|
||||
"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:
|
||||
@@ -370,10 +376,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
|
||||
from flask import render_template_string
|
||||
|
||||
from lib.common.markdown_html_lib import render_markdown_html
|
||||
from lib.paths import REPO_ROOT
|
||||
from lib.hub.hub_strategy_lib import render_markdown_html
|
||||
|
||||
md_path = REPO_ROOT / "docs" / "期权开平仓与监控说明.md"
|
||||
md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md"
|
||||
try:
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
@@ -417,16 +422,27 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||
bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
**bal,
|
||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
|
||||
"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),
|
||||
}
|
||||
)
|
||||
from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode
|
||||
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
payload = {
|
||||
"ok": True,
|
||||
**bal,
|
||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
|
||||
"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")
|
||||
@lr
|
||||
@@ -438,12 +454,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
||||
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
|
||||
try:
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
|
||||
margin_mode = normalize_options_margin_mode()
|
||||
chain = cfg["build_option_chain"](
|
||||
ex,
|
||||
u,
|
||||
max_dte_days=chain_max_dte,
|
||||
itm_only=False,
|
||||
itm_max_dist_usd=cfg["itm_max_dist"],
|
||||
margin_mode=margin_mode,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
||||
@@ -452,6 +472,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
# 热更新:每次读 env,保存配置后刷新链即可生效
|
||||
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
|
||||
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:
|
||||
return jsonify(
|
||||
{
|
||||
@@ -462,6 +491,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"ask_liq_filter_enabled": ask_liq_filter,
|
||||
"budget_buffer": budget_buffer,
|
||||
"trade_budget": cfg["trade_budget"],
|
||||
"options_margin_mode": chain.get("margin_mode") or margin_mode,
|
||||
"coin_budget": coin_budget,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
@@ -472,6 +503,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"ask_liq_filter_enabled": ask_liq_filter,
|
||||
"budget_buffer": budget_buffer,
|
||||
"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":
|
||||
paid = _open_premium_paid(cfg, inst_id)
|
||||
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)
|
||||
|
||||
# 币本位:报价预览走 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_cap = cfg["trade_budget"]
|
||||
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)
|
||||
if not 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)
|
||||
if not q.get("ok"):
|
||||
return jsonify(q)
|
||||
@@ -1430,9 +1655,46 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn2.close()
|
||||
except Exception:
|
||||
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)
|
||||
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"])
|
||||
@lr
|
||||
def api_options_convert_quote():
|
||||
|
||||
@@ -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
|
||||
@@ -1,347 +1,355 @@
|
||||
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
|
||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||
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-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 %}">
|
||||
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
|
||||
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
|
||||
{% endif %}
|
||||
|
||||
<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 %}>
|
||||
<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">
|
||||
<summary>开仓规则说明</summary>
|
||||
<div class="opt-close-rule-body">
|
||||
<p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
|
||||
<ul>
|
||||
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
|
||||
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
|
||||
<li><strong>开仓只认真实卖一价且卖一深度≥1</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>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
||||
</ul>
|
||||
<p><strong>买一平仓</strong>:平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
|
||||
<ul>
|
||||
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
||||
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
||||
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
||||
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
|
||||
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row options-chain-toolbar">
|
||||
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||
<select id="opt-exp-select"><option value="">选择到期日</option></select>
|
||||
<span class="opt-chain-view-group">
|
||||
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
|
||||
<button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button>
|
||||
</span>
|
||||
<span id="opt-type-btn-group" class="opt-type-btn-group">
|
||||
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
|
||||
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
|
||||
</span>
|
||||
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
|
||||
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label">
|
||||
<input type="checkbox" id="opt-strike-expand-all"> 展开全部
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
|
||||
</div>
|
||||
<div id="opt-index-line" class="muted"></div>
|
||||
<div class="options-strike-table-wrap" id="opt-strike-table-wrap">
|
||||
<table class="options-strike-table" id="opt-strike-table">
|
||||
<thead>
|
||||
<tr id="opt-strike-head-list">
|
||||
<th>行权价</th>
|
||||
<th>类型</th>
|
||||
<th>合约</th>
|
||||
<th>卖一/张</th>
|
||||
<th title="指数÷卖一(每1币)">杠杆</th>
|
||||
<th>买一/张</th>
|
||||
<th>到期平衡</th>
|
||||
<th>距平衡</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
<tr id="opt-strike-head-t" class="hidden" hidden>
|
||||
<th colspan="3" class="opt-t-head-call">Call</th>
|
||||
<th colspan="3" class="opt-t-head-mid">跨式</th>
|
||||
<th colspan="3" class="opt-t-head-put">Put</th>
|
||||
</tr>
|
||||
<tr id="opt-strike-head-t-cols" class="hidden" hidden>
|
||||
<th>卖一/张</th>
|
||||
<th>类型</th>
|
||||
<th>操作</th>
|
||||
<th>行权价</th>
|
||||
<th title="Call卖一+Put卖一(每1币)">双买/币</th>
|
||||
<th title="到期测算平衡带">平衡带</th>
|
||||
<th>类型</th>
|
||||
<th>卖一/张</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-strike-tbody">
|
||||
<tr><td colspan="9" class="muted">请选择到期日</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
|
||||
<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-dialog-head">
|
||||
<h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3>
|
||||
<button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button>
|
||||
</div>
|
||||
<div class="opt-order-layout">
|
||||
<div class="opt-order-main">
|
||||
<div id="opt-order-inst" class="options-order-inst"></div>
|
||||
<div class="options-order-grid">
|
||||
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v">—</span></div>
|
||||
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v">—</span></div>
|
||||
<div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted">—</span></div>
|
||||
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
||||
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
||||
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
||||
<div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用">—</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-dist-be" class="v">—</span></div>
|
||||
</div>
|
||||
<div class="options-estimate-row">
|
||||
<div class="opt-est-main">
|
||||
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
|
||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<span class="k">预计价值</span>
|
||||
<span id="opt-est-value" class="v">—</span>
|
||||
<span class="k">盈利</span>
|
||||
<span id="opt-est-profit" class="v">—</span>
|
||||
<span class="k">盈亏比</span>
|
||||
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金">—</span>
|
||||
</div>
|
||||
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
|
||||
</div>
|
||||
<div class="options-estimate-row opt-profit-exit-row">
|
||||
<div class="opt-est-main">
|
||||
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
|
||||
<input type="checkbox" id="opt-profit-exit-enabled">
|
||||
<span>翻倍出场</span>
|
||||
</label>
|
||||
<label class="k" for="opt-profit-exit-mult">倍数</label>
|
||||
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
</div>
|
||||
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
|
||||
</div>
|
||||
<div class="form-row options-order-mode-row">
|
||||
<div class="opt-size-mode-bar">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
|
||||
<span>指定张数</span>
|
||||
</label>
|
||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
|
||||
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-budget-wrap"{% if compound_on %} hidden{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
|
||||
<span>按可用余额打满</span>
|
||||
</label>
|
||||
<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="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
|
||||
<span>全仓复利</span>
|
||||
</label>
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
|
||||
<span>指定币数量</span>
|
||||
</label>
|
||||
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
</div>
|
||||
<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">
|
||||
余额 > 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
|
||||
</p>
|
||||
<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">
|
||||
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
|
||||
</p>
|
||||
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
|
||||
</div>
|
||||
<div class="opt-order-dialog-actions">
|
||||
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
|
||||
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
|
||||
</div>
|
||||
<div id="opt-order-msg" class="muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card options-pos-card-wrap">
|
||||
<div class="options-pos-head">
|
||||
<h2>持仓</h2>
|
||||
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
|
||||
</div>
|
||||
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
|
||||
<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>
|
||||
<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 opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
|
||||
<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>
|
||||
<div class="options-pos-tab-body">
|
||||
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
|
||||
<div id="opt-target-monitors" class="opt-target-monitors" hidden>
|
||||
<div class="opt-target-monitors-head">目标监控</div>
|
||||
<div id="opt-target-monitors-list"></div>
|
||||
</div>
|
||||
<div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
|
||||
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
|
||||
<div id="opt-pos-cards"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
|
||||
<div class="opt-pos-pending-pane">
|
||||
<div class="opt-order-pending-head">
|
||||
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p>
|
||||
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
|
||||
</div>
|
||||
<div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab">
|
||||
<div class="muted opt-pending-empty">暂无未成交委托</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
|
||||
<div class="options-stats-panel">
|
||||
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
|
||||
<div class="options-stat-item opt-stats-net-item">
|
||||
<span class="k">合计盈亏</span>
|
||||
<span class="v" id="opt-stats-total-pnl">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平净盈亏</span>
|
||||
<span class="v" id="opt-stats-net-realized">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓浮盈</span>
|
||||
<span class="v" id="opt-stats-open-float">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stats-charts">
|
||||
<div class="opt-stats-chart opt-stats-chart--ring">
|
||||
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
|
||||
<span class="opt-stats-ring-label" id="opt-stats-ring-label">—</span>
|
||||
</div>
|
||||
<span class="opt-stats-chart-caption">胜率</span>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--pnl">
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">平均盈利</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">平均亏损</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--hold">
|
||||
<div class="opt-stats-chart-title">持仓时长对比</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">盈单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-win-hold-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">亏单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-loss-hold-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stats-grid">
|
||||
<div class="options-stat-item">
|
||||
<span class="k">胜率</span>
|
||||
<span class="v" id="opt-stats-winrate">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈亏比</span>
|
||||
<span class="v" id="opt-stats-plr">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平笔数</span>
|
||||
<span class="v" id="opt-stats-closed">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">平均盈利</span>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-profit">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">平均亏损</span>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-loss">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">均持仓</span>
|
||||
<span class="v" id="opt-stats-avg-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈单持仓</span>
|
||||
<span class="v" id="opt-stats-win-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">亏单持仓</span>
|
||||
<span class="v" id="opt-stats-loss-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓中</span>
|
||||
<span class="v" id="opt-stats-open-hold">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="history" role="tabpanel" aria-labelledby="opt-pos-tab-history" hidden>
|
||||
<div class="options-history-table-wrap">
|
||||
<table class="options-strike-table opt-history-table" id="opt-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合约</th>
|
||||
<th>张数</th>
|
||||
<th>权利金</th>
|
||||
<th>状态</th>
|
||||
<th>盈亏</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-history-tbody">
|
||||
<tr><td colspan="7" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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_settings.js?v=11"></script>
|
||||
<script src="/static/options_panel.js?v=64"></script>
|
||||
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
|
||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||
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-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 %}">
|
||||
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
|
||||
{% if not options_enabled %}
|
||||
<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 %}
|
||||
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
|
||||
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
|
||||
{% endif %}
|
||||
|
||||
<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 %}>
|
||||
<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">
|
||||
<summary>开仓规则说明</summary>
|
||||
<div class="opt-close-rule-body">
|
||||
<p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
|
||||
<ul>
|
||||
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
|
||||
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
|
||||
<li><strong>开仓只认真实卖一价且卖一深度≥1</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>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
||||
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code> 默认):按最大可开张数×卖一权利金×现货缓冲(<code>OKX_OPTIONS_COIN_SPOT_BUY_BUFFER</code> 默认1.10)买 ETH/BTC,不全额兑换;平仓后卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。1 张名义以交易所 <code>ctMult</code> 为准(常见0.1)。</li>
|
||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
||||
<li>平仓仅买一限价,详见说明文档。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-row options-chain-toolbar">
|
||||
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||
<select id="opt-exp-select"><option value="">选择到期日</option></select>
|
||||
<span class="opt-chain-view-group">
|
||||
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
|
||||
<button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button>
|
||||
</span>
|
||||
<span id="opt-type-btn-group" class="opt-type-btn-group">
|
||||
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
|
||||
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
|
||||
</span>
|
||||
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
|
||||
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label">
|
||||
<input type="checkbox" id="opt-strike-expand-all"> 展开全部
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
|
||||
</div>
|
||||
<div id="opt-index-line" class="muted"></div>
|
||||
<div class="options-strike-table-wrap" id="opt-strike-table-wrap">
|
||||
<table class="options-strike-table" id="opt-strike-table">
|
||||
<thead>
|
||||
<tr id="opt-strike-head-list">
|
||||
<th>行权价</th>
|
||||
<th>类型</th>
|
||||
<th>合约</th>
|
||||
<th>卖一/张</th>
|
||||
<th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
|
||||
<th>买一/张</th>
|
||||
<th>到期平衡</th>
|
||||
<th>距平衡</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
<tr id="opt-strike-head-t" class="hidden" hidden>
|
||||
<th colspan="3" class="opt-t-head-call">Call</th>
|
||||
<th colspan="3" class="opt-t-head-mid">跨式</th>
|
||||
<th colspan="3" class="opt-t-head-put">Put</th>
|
||||
</tr>
|
||||
<tr id="opt-strike-head-t-cols" class="hidden" hidden>
|
||||
<th>卖一/张</th>
|
||||
<th>类型</th>
|
||||
<th>操作</th>
|
||||
<th>行权价</th>
|
||||
<th title="Call卖一+Put卖一(每1币)">双买/币</th>
|
||||
<th title="到期测算平衡带">平衡带</th>
|
||||
<th>类型</th>
|
||||
<th>卖一/张</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-strike-tbody">
|
||||
<tr><td colspan="9" class="muted">请选择到期日</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
|
||||
<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-dialog-head">
|
||||
<h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3>
|
||||
<button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button>
|
||||
</div>
|
||||
<div class="opt-order-layout">
|
||||
<div class="opt-order-main">
|
||||
<div id="opt-order-inst" class="options-order-inst"></div>
|
||||
<div class="options-order-grid">
|
||||
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v">—</span></div>
|
||||
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v">—</span></div>
|
||||
<div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted">—</span></div>
|
||||
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
||||
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
||||
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
||||
<div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用">—</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-dist-be" class="v">—</span></div>
|
||||
</div>
|
||||
<div class="options-estimate-row">
|
||||
<div class="opt-est-main">
|
||||
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
|
||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
<span class="k">预计价值</span>
|
||||
<span id="opt-est-value" class="v">—</span>
|
||||
<span class="k">盈利</span>
|
||||
<span id="opt-est-profit" class="v">—</span>
|
||||
<span class="k">盈亏比</span>
|
||||
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金">—</span>
|
||||
</div>
|
||||
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
|
||||
</div>
|
||||
<div class="options-estimate-row opt-profit-exit-row">
|
||||
<div class="opt-est-main">
|
||||
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
|
||||
<input type="checkbox" id="opt-profit-exit-enabled">
|
||||
<span>翻倍出场</span>
|
||||
</label>
|
||||
<label class="k" for="opt-profit-exit-mult">倍数</label>
|
||||
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
</div>
|
||||
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
|
||||
</div>
|
||||
<div class="form-row options-order-mode-row">
|
||||
<div class="opt-size-mode-bar">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
|
||||
<span>指定张数</span>
|
||||
</label>
|
||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
|
||||
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-budget-wrap"{% if compound_on %} hidden{% endif %}>
|
||||
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
|
||||
<span>按可用余额打满</span>
|
||||
</label>
|
||||
<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="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
|
||||
<span>全仓复利</span>
|
||||
</label>
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
|
||||
<span>指定币数量</span>
|
||||
</label>
|
||||
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
|
||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||
</div>
|
||||
<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">
|
||||
余额 > 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
|
||||
</p>
|
||||
<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">
|
||||
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
|
||||
</p>
|
||||
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
|
||||
</div>
|
||||
<div class="opt-order-dialog-actions">
|
||||
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
|
||||
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
|
||||
</div>
|
||||
<div id="opt-order-msg" class="muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card options-pos-card-wrap">
|
||||
<div class="options-pos-head">
|
||||
<h2>持仓</h2>
|
||||
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
|
||||
</div>
|
||||
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
|
||||
<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>
|
||||
<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 opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
|
||||
<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>
|
||||
<div class="options-pos-tab-body">
|
||||
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
|
||||
<div id="opt-target-monitors" class="opt-target-monitors" hidden>
|
||||
<div class="opt-target-monitors-head">目标监控</div>
|
||||
<div id="opt-target-monitors-list"></div>
|
||||
</div>
|
||||
<div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
|
||||
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
|
||||
<div id="opt-pos-cards"></div>
|
||||
</div>
|
||||
<details class="opt-close-rule">
|
||||
<summary>买一平仓规则说明</summary>
|
||||
<div class="opt-close-rule-body">
|
||||
<p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
|
||||
<ul>
|
||||
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
||||
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
||||
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
||||
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
|
||||
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
|
||||
<div class="opt-pos-pending-pane">
|
||||
<div class="opt-order-pending-head">
|
||||
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p>
|
||||
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
|
||||
</div>
|
||||
<div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab">
|
||||
<div class="muted opt-pending-empty">暂无未成交委托</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
|
||||
<div class="options-stats-panel">
|
||||
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
|
||||
<div class="options-stat-item opt-stats-net-item">
|
||||
<span class="k">合计盈亏</span>
|
||||
<span class="v" id="opt-stats-total-pnl">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平净盈亏</span>
|
||||
<span class="v" id="opt-stats-net-realized">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓浮盈</span>
|
||||
<span class="v" id="opt-stats-open-float">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stats-charts">
|
||||
<div class="opt-stats-chart opt-stats-chart--ring">
|
||||
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
|
||||
<span class="opt-stats-ring-label" id="opt-stats-ring-label">—</span>
|
||||
</div>
|
||||
<span class="opt-stats-chart-caption">胜率</span>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--pnl">
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">平均盈利</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">平均亏损</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--hold">
|
||||
<div class="opt-stats-chart-title">持仓时长对比</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">盈单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-win-hold-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">亏单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-loss-hold-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stats-grid">
|
||||
<div class="options-stat-item">
|
||||
<span class="k">胜率</span>
|
||||
<span class="v" id="opt-stats-winrate">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈亏比</span>
|
||||
<span class="v" id="opt-stats-plr">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平笔数</span>
|
||||
<span class="v" id="opt-stats-closed">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">平均盈利</span>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-profit">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">平均亏损</span>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-loss">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">均持仓</span>
|
||||
<span class="v" id="opt-stats-avg-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈单持仓</span>
|
||||
<span class="v" id="opt-stats-win-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">亏单持仓</span>
|
||||
<span class="v" id="opt-stats-loss-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓中</span>
|
||||
<span class="v" id="opt-stats-open-hold">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="history" role="tabpanel" aria-labelledby="opt-pos-tab-history" hidden>
|
||||
<div class="options-history-table-wrap">
|
||||
<table class="options-strike-table opt-history-table" id="opt-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合约</th>
|
||||
<th>张数</th>
|
||||
<th>权利金</th>
|
||||
<th>状态</th>
|
||||
<th>盈亏</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-history-tbody">
|
||||
<tr><td colspan="7" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user