diff --git a/.env.example b/.env.example
index 4d12d86..2d1725c 100644
--- a/.env.example
+++ b/.env.example
@@ -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
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
diff --git a/app.py b/app.py
index 2cbe323..12bf604 100644
--- a/app.py
+++ b/app.py
@@ -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,
diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js
index 99ed92b..8802fbb 100644
--- a/lib/common/static/options_panel.js
+++ b/lib/common/static/options_panel.js
@@ -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 '' + recvTxt + " USDC";
+ return '' + recvTxt + " " + ccy + "";
}
- 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 (
'
' +
'
' + (p.inst_id || "") + '' +
@@ -1520,21 +1633,21 @@
: "") +
"
" +
'
' +
- '
权利金' + premTxt + " USDC
" +
+ '
权利金' + premTxt + " " + premCcy + "
" +
'
开仓均价' + avgTxt + "
" +
'
标记价' + markTxt + "
" +
'
指数价' + fmt(p.idx_px, 0) + "
" +
'
到期平衡' + fmt(p.expiry_be_px, 0) + "
" +
'
平掉回本' + fmt(p.close_be_px, 0) + "
" +
'
净盈亏' +
- (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
" +
+ netTxt + "
" +
'
收益率' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
" +
'
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" +
'
按买盘回收' +
(closePreview.bid_invalid
? '暂无有效买盘'
- : fmtClosePreview(closePreview, p.premium_paid)) + "
" +
+ : fmtClosePreview(closePreview, p.premium_paid, p)) + "
" +
"" +
(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 @@
'' + statusTxt + "" +
'' +
(enabled
- ? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : ""))
+ ? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
"" +
""
@@ -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 = '';
html += '价值' +
- (value == null ? "—" : fmtUsdc(value) + " USDC") + "";
+ (value == null ? "—" : fmtUsdc(value) + valueUnit) + "";
html += '预估盈利' +
- (profit == null ? "—" : fmtUsdcSigned(profit)) + "";
+ profitTxt + "";
html += '盈亏比' +
(rr == null ? "—" : fmtProfitRr(rr)) + "";
html += "";
@@ -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)
: '';
return (
'' +
'
委托' +
'
到期
—'
: "") +
- '
' + (net == null ? "—" : fmt(net, 2) + " USDC") + "" +
+ '
' +
+ (net == null ? "—" : (fmtPremiumAmt(net, posPremiumCcy(p)) + " " + posPremiumCcy(p))) + "" +
'
' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "" +
"" +
@@ -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);
diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js
index d2010d2..e1079f4 100644
--- a/lib/common/static/options_position_cards.js
+++ b/lib/common/static/options_position_cards.js
@@ -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 '
' + recvTxt + " USDC";
+ return '
' + recvTxt + " " + ccy + "";
}
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
? ""
: '
净盈亏' +
- (net == null ? "—" : fmt(net, 2)) + "
" +
+ (net == null ? "—" : (fmtPremiumAmt(net, premCcy) + (premCcy !== "USDC" ? (" " + premCcy) : ""))) + "
" +
'收益率' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "
";
return (
@@ -202,7 +229,7 @@
: "") +
"" +
'' +
- '
权利金' + premTxt + " USDC
" +
+ '
权利金' + premTxt + " " + premCcy + "
" +
'
开仓均价' + avgTxt + "
" +
'
标记价' + markTxt + "
" +
'
指数价' + fmt(p.idx_px, 0) + "
" +
@@ -213,7 +240,7 @@
'
按买盘回收' +
(closePreview.bid_invalid
? '暂无有效买盘'
- : fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "
" +
+ : fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "
" +
"" +
(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 @@
'' +
'
' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "" +
'
目标 ' + fmt(p.target_index, 1) + "" +
- '
价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "" +
+ '
价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "" +
profitSpan +
'
' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py
index bd4e47e..cde5282 100644
--- a/lib/env/env_schema.py
+++ b/lib/env/env_schema.py
@@ -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
diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py
index 7cac240..7e8c5ac 100644
--- a/lib/env/env_ui_manifest.py
+++ b/lib/env/env_ui_manifest.py
@@ -1,587 +1,594 @@
-"""env 配置页 UI 白名单:中文标签,按交易所过滤."""
-from __future__ import annotations
-
-import os
-from typing import Any, Optional
-
-from lib.env.env_file_lib import env_get_all, read_env_lines
-from lib.env.env_schema import (
- _field_type,
- _hot_reload,
- _is_sensitive,
- _mask_value,
- _restart_required,
- normalize_select_value,
- parse_env_example_schema,
- select_options_for,
-)
-
-# 各所「交易所与实盘」字段(顺序即页面顺序)
-_OKX_LIVE_ONLY_KEYS = frozenset(
- {
- "LIVE_TRADING_ENABLED",
- "OKX_API_KEY",
- "OKX_API_SECRET",
- "OKX_API_PASSPHRASE",
- }
-)
-
-_SIM_FUNDS_SECTION: dict[str, Any] = {
- "title": "模拟资金",
- "fields": [
- (
- "SIM_INITIAL_EQUITY_USDT",
- "初始权益 USDT",
- "重置模拟钱包时写入资金账户 USDT;改完需在系统设置「模拟资金」点重置才生效",
- ),
- (
- "SIM_INITIAL_USDC",
- "初始 USDC",
- "重置时写入期权侧 USDC;改完需重置才生效",
- ),
- (
- "SIM_FEE_RATE",
- "模拟手续费率",
- "如 0.0005=万五;撮合立即按此费率扣费",
- ),
- ],
-}
-
-_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
- "okx": [
- (
- "SIM_DEFAULT_MODE",
- "撮合模式",
- "sim=本地模拟资金; live=实盘.保存后立即切换;sim 下隐藏 API/实盘开关",
- ),
- ("LIVE_TRADING_ENABLED", "开启实盘下单", "仅 live 模式下生效;关闭时即使 live 也不向交易所发单"),
- ("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"),
- ("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
- ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
- ("OKX_TD_MODE", "保证金模式", ""),
- ("OKX_POS_MODE", "持仓模式", ""),
- ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
- ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
- (
- "OKX_SHOW_PERP_FUNDS",
- "显示永续资金",
- "默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
- ),
- ],
- "binance": [
- ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
- ("BINANCE_API_KEY", "API Key", "永续子账户"),
- ("BINANCE_API_SECRET", "API Secret", "永续子账户"),
- ("BINANCE_MARGIN_MODE", "保证金模式", ""),
- ("BINANCE_POSITION_MODE", "持仓模式", ""),
- ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
- ],
- "gate": [
- ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
- ("GATE_API_KEY", "API Key", "永续子账户"),
- ("GATE_API_SECRET", "API Secret", "永续子账户"),
- ("GATE_TD_MODE", "保证金模式", ""),
- ("GATE_POS_MODE", "持仓模式", ""),
- ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
- ],
-}
-
-_SHARED_SECTIONS: list[dict[str, Any]] = [
- {
- "title": "企业微信",
- "fields": [
- ("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
- ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
- ],
- },
-]
-
-_MODE_SECTION: dict[str, Any] = {
- "title": "期权/对冲模式",
- "exchanges": frozenset({"okx"}),
- "fields": [
- (
- "OKX_TRADE_MODE",
- "交易模式",
- "三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权",
- ),
- ],
-}
-
-_OPTIONS_SECTION: dict[str, Any] = {
- "title": "期权账户",
- "exchanges": frozenset({"okx"}),
- "fields": [
- ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
- ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
- (
- "OKX_OPTIONS_TRADE_BUDGET_USDC",
- "单笔预算(USDC)",
- "仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
- ),
- ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
- (
- "OKX_OPTIONS_COMPOUND_FULL_ENABLED",
- "全仓复利开关",
- "默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
- ),
- (
- "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
- "全仓复利上限开关",
- "仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
- ),
- (
- "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
- "全仓复利上限(USDC)",
- "仅「全仓复利」且「上限开关」都开启时生效;例如 300",
- ),
- (
- "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
- "期权持仓上限(笔)",
- "仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
- ),
- ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
- (
- "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
- "期权链展示天数",
- "默认 14;下拉到期日只出现该天数内的合约(含明天)",
- ),
- (
- "OKX_OPTIONS_MAX_DTE_DAYS",
- "开仓最大剩余天数",
- "默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
- ),
- (
- "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
- "链上仅显示有卖一",
- "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
- ),
- ],
-}
-
-# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
-_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
- ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
- (
- "MAX_ACTIVE_HEDGE_PLANS",
- "对冲组数上限",
- "默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
- ),
- ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
- (
- "HEDGE_PLAN_BUDGET_BUFFER",
- "对冲预算缓冲比例",
- "默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
- ),
- (
- "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
- "半腿失败改手动补开",
- "默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
- ),
- (
- "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
- "半腿失败时自动平期权",
- "默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
- ),
-]
-
-_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
- (
- "HEDGE_PLAN_OPTION_PRIMARY",
- "永期模式(以期权为主/保险)",
- "默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
- ),
- ("POSITION_SIZING_MODE", "永续计仓模式", "切换须无仓后重启;以损定仓 / 全仓杠杆"),
- ("RISK_PERCENT", "以损定仓风险%", "永续腿单笔风险占资金比例"),
- ("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
- ("BTC_LEVERAGE", "BTC 默认杠杆", "永续腿"),
- ("ALT_LEVERAGE", "山寨默认杠杆", "永续腿"),
- ("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8;顶栏统计切日"),
- ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
- ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
- ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
- (
- "HEDGE_PLAN_ITM_MAX_DIST_USD",
- "永期实值最大深度(U)",
- "默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
- ),
- (
- "HEDGE_PLAN_MIN_OPTION_HOURS",
- "对冲期权最低剩余小时",
- "默认 8;测算/启动时若传 hours_to_expiry 则校验",
- ),
- (
- "HEDGE_PLAN_MIN_OPTION_LEVERAGE",
- "对冲期权最低杠杆(S/ask)",
- "默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
- ),
-]
-
-_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
- ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
- (
- "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
- "期期平仓模式(方案C)",
- "默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
- ),
- (
- "HEDGE_PLAN_OO_BIAS_SPLIT_BY",
- "期期做多做空拆分口径",
- "默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
- ),
- (
- "HEDGE_PLAN_OO_BIAS_RATIO",
- "期期做多做空主腿占比",
- "默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
- ),
-]
-
-# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
-_HEDGE_PLAN_SECTION: dict[str, Any] = {
- "title": "对冲计划",
- "exchanges": frozenset({"okx"}),
- "fields": [
- ("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
- ("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
- ("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
- ("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
- *_HEDGE_COMMON_FIELDS,
- *_HEDGE_PO_FIELDS,
- *_HEDGE_OO_FIELDS,
- ],
-}
-
-
-# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
-_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
- "RISK_CONTROL_ENABLED": "true",
- "RISK_COOLING_HOURS_MANUAL": "4",
- "RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
- "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
- "RISK_DAILY_LOSS_LIMIT": "2",
- "RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
- "AUTO_TRANSFER_FROM": "funding",
- "AUTO_TRANSFER_TO": "swap",
- "TRANSFER_CCY": "USDT",
- "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
- "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
- "OKX_SHOW_PERP_FUNDS": "true",
- "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
- "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
- "OKX_OPTIONS_MAX_DTE_DAYS": "2",
- "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
- "OKX_TRADE_MODE": "options",
- "SIM_DEFAULT_MODE": "sim",
- "MAX_ACTIVE_HEDGE_PLANS": "1",
- "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
- "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
- "HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
- "HEDGE_PLAN_BUDGET_BUFFER": "0.95",
- "HEDGE_PLAN_OPTION_PRIMARY": "true",
- "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
- "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
-}
-
-
-def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
- if key == "SIM_DEFAULT_MODE":
- # 展示当前生效撮合模式(运行时 trading.mode 优先)
- try:
- from lib.sim.mode_lib import peek_persisted_trading_mode
-
- cur = peek_persisted_trading_mode()
- if cur:
- return cur
- except Exception:
- pass
- if key == "OKX_TRADE_MODE":
- # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
- file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
- if file_val:
- from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
-
- return normalize_okx_trade_mode(file_val) or file_val
- try:
- from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
-
- return get_okx_trade_mode()
- except Exception:
- pass
- if key in file_values:
- file_val = str(file_values.get(key) or "").strip()
- if file_val:
- return file_val
- runtime = os.getenv(key)
- if runtime is not None and str(runtime).strip() != "":
- return str(runtime).strip()
- if schema_default:
- return schema_default
- return _RUNTIME_ENV_DEFAULTS.get(key, "")
-
-
-def _env_truthy(raw: str) -> bool:
- return str(raw or "").strip().lower() in ("1", "true", "yes", "on")
-
-
-def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
- out: dict[str, dict[str, Any]] = {}
- for group in parse_env_example_schema(example_path):
- for field in group.get("fields") or []:
- out[field["key"]] = dict(field)
- return out
-
-
-def _build_field(
- key: str,
- label: str,
- note: str,
- schema: dict[str, dict[str, Any]],
- values: dict[str, str],
-) -> dict[str, Any]:
- meta = schema.get(key) or {}
- schema_default = meta.get("default") or ""
- val = _effective_env_value(key, values, schema_default)
- # 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
- if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
- manual = _effective_env_value(
- "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
- )
- if _env_truthy(manual):
- val = "false"
- masked = _mask_value(key, val)
- ftype = meta.get("type") or _field_type(key, val or schema_default)
- options = select_options_for(key)
- if options:
- ftype = "select"
- val = normalize_select_value(key, val) or val
- masked = _mask_value(key, val)
- out: dict[str, Any] = {
- "key": key,
- "label": label,
- "note": note or meta.get("note") or "",
- "default": val,
- "type": ftype,
- "sensitive": meta.get("sensitive", _is_sensitive(key)),
- "restart_required": meta.get("restart_required", _restart_required(key)),
- "hot_reload": meta.get("hot_reload", _hot_reload(key)),
- "current": masked["value"] if not _is_sensitive(key) else "",
- "masked": masked["masked"],
- "tail": masked.get("tail") or "",
- "has_value": masked["has_value"],
- }
- if options:
- cur = (out["current"] or out["default"] or "").strip()
- opt_vals = {o["value"] for o in options}
- if cur and cur not in opt_vals:
- options = [{"value": cur, "label": cur}] + options
- out["options"] = options
- return out
-
-
-def _okx_mode_for_env_ui() -> str:
- try:
- from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
-
- return get_okx_trade_mode()
- except Exception:
- return "options"
-
-
-def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
- fields = list(_OPTIONS_SECTION["fields"])
- if mode != "options":
- fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
- return fields
-
-
-def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
- if mode == "perp_options":
- return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
- if mode == "options_options":
- return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
- return []
-
-
-def _trading_mode_for_env_ui() -> str:
- try:
- from lib.sim.mode_lib import peek_persisted_trading_mode, default_trading_mode
-
- return peek_persisted_trading_mode() or default_trading_mode()
- except Exception:
- return "sim"
-
-
-def _okx_exchange_fields_for_trading_mode(trading_mode: str) -> list[tuple[str, str, str]]:
- fields = list(_EXCHANGE_LIVE_FIELDS["okx"])
- tm = (trading_mode or "").strip().lower()
- if tm == "sim":
- return [f for f in fields if f[0] not in _OKX_LIVE_ONLY_KEYS]
- return fields
-
-
-def ui_sections_for_exchange(
- exchange_key: str,
- *,
- mode: str | None = None,
- trading_mode: str | None = None,
-) -> list[dict[str, Any]]:
- ex = (exchange_key or "").strip().lower()
- sections: list[dict[str, Any]] = []
- tm = (trading_mode or "").strip().lower()
- if not tm:
- tm = _trading_mode_for_env_ui() if ex == "okx" else "live"
- if ex == "okx":
- live_fields = _okx_exchange_fields_for_trading_mode(tm)
- else:
- live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
- sections.append({"title": "交易所与实盘", "fields": live_fields})
- if ex == "okx" and tm == "sim":
- sections.append(_SIM_FUNDS_SECTION)
- sections.extend(_SHARED_SECTIONS)
- if ex in _MODE_SECTION.get("exchanges", frozenset()):
- from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
-
- m = normalize_okx_trade_mode(mode) if mode else ""
- if not m:
- m = _okx_mode_for_env_ui()
- sections.append(_MODE_SECTION)
- sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
- hedge_fields = _hedge_fields_for_mode(m)
- if hedge_fields:
- title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期"
- sections.append({"title": title, "fields": hedge_fields})
- return sections
-
-
-def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
- """可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
- keys: set[str] = set()
- for sec in ui_sections_for_exchange(exchange_key):
- for item in sec["fields"]:
- keys.add(item[0])
- ex = (exchange_key or "").strip().lower()
- if ex == "okx":
- keys.add("OKX_TRADE_MODE")
- keys.add("SIM_DEFAULT_MODE")
- # 切模式后同请求可能带上对侧字段,始终放行
- keys.update(_OKX_LIVE_ONLY_KEYS)
- for item in _SIM_FUNDS_SECTION["fields"]:
- keys.add(item[0])
- for item in _HEDGE_PLAN_SECTION["fields"]:
- keys.add(item[0])
- for item in _OPTIONS_SECTION["fields"]:
- keys.add(item[0])
- return frozenset(keys)
-
-
-def build_env_ui_payload(
- exchange_key: str,
- example_path: str,
- env_path: str,
-) -> list[dict[str, Any]]:
- schema = _schema_field_map(example_path)
- env_lines = read_env_lines(env_path)
- values = env_get_all(env_lines)
- trading_mode = ""
- if (exchange_key or "").strip().lower() == "okx":
- trading_mode = _effective_env_value("SIM_DEFAULT_MODE", values, "sim") or _trading_mode_for_env_ui()
- groups: list[dict[str, Any]] = []
- for sec in ui_sections_for_exchange(
- exchange_key,
- mode=values.get("OKX_TRADE_MODE") or "",
- trading_mode=trading_mode,
- ):
- fields = [
- _build_field(key, label, note, schema, values)
- for key, label, note in sec["fields"]
- ]
- fields = _mark_compound_budget_hidden(fields)
- groups.append({
- "title": sec["title"],
- "fields": fields,
- "has_restart": any(f.get("restart_required") for f in fields),
- })
- return groups
-
-
-def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
- """全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
- compound_on = True
- for f in fields:
- if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
- compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
- break
- if not compound_on:
- return fields
- out: list[dict[str, Any]] = []
- for f in fields:
- if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
- item = dict(f)
- item["hidden"] = True
- out.append(item)
- else:
- out.append(f)
- return out
-
-
-def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
- allowed = ui_allowed_keys(exchange_key)
- return {k: v for k, v in (updates or {}).items() if k in allowed}
-
-
-def validate_env_ui_updates(
- exchange_key: str,
- example_path: str,
- updates: dict[str, str],
-) -> tuple[dict[str, str], list[str]]:
- from lib.env.env_schema import validate_env_updates
-
- schema = _schema_field_map(example_path)
- groups: list[dict[str, Any]] = []
- for sec in ui_sections_for_exchange(exchange_key):
- fields: list[dict[str, Any]] = []
- for key, _label, _note in sec["fields"]:
- if key in schema:
- field = dict(schema[key])
- opts = select_options_for(key)
- if opts:
- field["type"] = "select"
- field["options"] = opts
- fields.append(field)
- else:
- default = ""
- fields.append(
- {
- "key": key,
- "type": _field_type(key, default),
- "sensitive": _is_sensitive(key),
- "restart_required": _restart_required(key),
- "hot_reload": _hot_reload(key),
- "options": select_options_for(key),
- }
- )
- groups.append({"title": sec["title"], "fields": fields})
- return validate_env_updates(groups, updates)
-
-
-def coerce_hedge_partial_close_with_manual(
- clean: dict[str, str],
- *,
- env_path: str = "",
-) -> dict[str, str]:
- """手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
- out = dict(clean or {})
- manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
- if manual is None and env_path:
- try:
- from lib.env.env_file_lib import env_get_all, read_env_lines
-
- file_vals = env_get_all(read_env_lines(env_path))
- manual = _effective_env_value(
- "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
- )
- except Exception:
- manual = "true"
- if _env_truthy(str(manual or "")):
- out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
- return out
+"""env 配置页 UI 白名单:中文标签,按交易所过滤."""
+from __future__ import annotations
+
+import os
+from typing import Any, Optional
+
+from lib.env.env_file_lib import env_get_all, read_env_lines
+from lib.env.env_schema import (
+ _field_type,
+ _hot_reload,
+ _is_sensitive,
+ _mask_value,
+ _restart_required,
+ normalize_select_value,
+ parse_env_example_schema,
+ select_options_for,
+)
+
+# 各所「交易所与实盘」字段(顺序即页面顺序)
+_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
+ "okx": [
+ ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
+ ("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"),
+ ("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
+ ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
+ ("OKX_TD_MODE", "保证金模式", ""),
+ ("OKX_POS_MODE", "持仓模式", ""),
+ ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
+ ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
+ (
+ "OKX_SHOW_PERP_FUNDS",
+ "显示永续资金",
+ "默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
+ ),
+ ],
+ "binance": [
+ ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
+ ("BINANCE_API_KEY", "API Key", "永续子账户"),
+ ("BINANCE_API_SECRET", "API Secret", "永续子账户"),
+ ("BINANCE_MARGIN_MODE", "保证金模式", ""),
+ ("BINANCE_POSITION_MODE", "持仓模式", ""),
+ ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
+ ],
+ "gate": [
+ ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
+ ("GATE_API_KEY", "API Key", "永续子账户"),
+ ("GATE_API_SECRET", "API Secret", "永续子账户"),
+ ("GATE_TD_MODE", "保证金模式", ""),
+ ("GATE_POS_MODE", "持仓模式", ""),
+ ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
+ ],
+}
+
+_SHARED_SECTIONS: list[dict[str, Any]] = [
+ {
+ "title": "企业微信",
+ "fields": [
+ ("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
+ ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
+ ],
+ },
+ {
+ "title": "交易执行",
+ "fields": [
+ ("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"),
+ ("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
+ ("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
+ ("BTC_LEVERAGE", "BTC 默认杠杆", ""),
+ ("ALT_LEVERAGE", "山寨默认杠杆", ""),
+ ("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
+ ("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"),
+ ("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
+ ("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
+ ("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
+ (
+ "TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
+ "切点前禁止新开仓",
+ "默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
+ ),
+ ("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
+ ("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
+ ("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"),
+ ("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"),
+ ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
+ ("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
+ ("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"),
+ ],
+ },
+ {
+ "title": "交易风控",
+ "fields": [
+ ("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"),
+ ("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"),
+ ],
+ },
+ {
+ "title": "账户冷静期",
+ "fields": [
+ ("RISK_CONTROL_ENABLED", "冷静期总开关", ""),
+ ("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
+ ("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
+ ("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
+ ("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
+ ("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
+ ],
+ },
+ {
+ "title": "自动划转",
+ "fields": [
+ ("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
+ ("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
+ ("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
+ ("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
+ ("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
+ ("TRANSFER_CCY", "划转币种", ""),
+ ],
+ },
+ {
+ "title": "当日资金",
+ "fields": [
+ ("DAILY_START_CAPITAL", "日起始基数(U)", ""),
+ ("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""),
+ ("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""),
+ ],
+ },
+]
+
+_MODE_SECTION: dict[str, Any] = {
+ "title": "期权/对冲模式",
+ "exchanges": frozenset({"okx"}),
+ "fields": [
+ (
+ "OKX_TRADE_MODE",
+ "交易模式",
+ "三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权",
+ ),
+ ],
+}
+
+_OPTIONS_SECTION: dict[str, Any] = {
+ "title": "期权账户",
+ "exchanges": frozenset({"okx"}),
+ "fields": [
+ ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
+ ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
+ (
+ "OKX_OPTIONS_MARGIN_MODE",
+ "单笔期权本位",
+ "usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
+ ),
+ (
+ "OKX_OPTIONS_TRADE_BUDGET_USDC",
+ "单笔预算(USDC)",
+ "仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
+ ),
+ ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
+ (
+ "OKX_OPTIONS_COIN_COMPOUND",
+ "币本位按交易户USDT复利",
+ "默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
+ ),
+ (
+ "OKX_OPTIONS_COIN_BUDGET_USDT",
+ "币本位固定预算(USDT)",
+ "仅币本位且复利关闭时生效",
+ ),
+ (
+ "OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
+ "币本位单笔上限开关",
+ "默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
+ ),
+ (
+ "OKX_OPTIONS_COIN_MAX_USDT",
+ "币本位单笔上限(USDT)",
+ "仅上限开关开启时生效",
+ ),
+ (
+ "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
+ "币本位现货买入缓冲",
+ "相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
+ ),
+ (
+ "OKX_OPTIONS_COMPOUND_FULL_ENABLED",
+ "全仓复利开关",
+ "默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
+ ),
+ (
+ "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
+ "全仓复利上限开关",
+ "仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
+ ),
+ (
+ "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
+ "全仓复利上限(USDC)",
+ "仅「全仓复利」且「上限开关」都开启时生效;例如 300",
+ ),
+ (
+ "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
+ "期权持仓上限(笔)",
+ "仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
+ ),
+ ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
+ (
+ "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
+ "期权链展示天数",
+ "默认 14;下拉到期日只出现该天数内的合约(含明天)",
+ ),
+ (
+ "OKX_OPTIONS_MAX_DTE_DAYS",
+ "开仓最大剩余天数",
+ "默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
+ ),
+ (
+ "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
+ "链上仅显示有卖一",
+ "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
+ ),
+ ],
+}
+
+# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
+_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
+ ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
+ (
+ "MAX_ACTIVE_HEDGE_PLANS",
+ "对冲组数上限",
+ "默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
+ ),
+ ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
+ (
+ "HEDGE_PLAN_BUDGET_BUFFER",
+ "对冲预算缓冲比例",
+ "默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
+ ),
+ (
+ "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
+ "半腿失败改手动补开",
+ "默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
+ ),
+ (
+ "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
+ "半腿失败时自动平期权",
+ "默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
+ ),
+]
+
+_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
+ (
+ "HEDGE_PLAN_OPTION_PRIMARY",
+ "永期模式(以期权为主/保险)",
+ "默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
+ ),
+ ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
+ ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
+ ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
+ (
+ "HEDGE_PLAN_ITM_MAX_DIST_USD",
+ "永期实值最大深度(U)",
+ "默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
+ ),
+ (
+ "HEDGE_PLAN_MIN_OPTION_HOURS",
+ "对冲期权最低剩余小时",
+ "默认 8;测算/启动时若传 hours_to_expiry 则校验",
+ ),
+ (
+ "HEDGE_PLAN_MIN_OPTION_LEVERAGE",
+ "对冲期权最低杠杆(S/ask)",
+ "默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
+ ),
+]
+
+_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
+ ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
+ (
+ "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
+ "期期平仓模式(方案C)",
+ "默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
+ ),
+ (
+ "HEDGE_PLAN_OO_BIAS_SPLIT_BY",
+ "期期做多做空拆分口径",
+ "默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
+ ),
+ (
+ "HEDGE_PLAN_OO_BIAS_RATIO",
+ "期期做多做空主腿占比",
+ "默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
+ ),
+]
+
+# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
+_HEDGE_PLAN_SECTION: dict[str, Any] = {
+ "title": "对冲计划",
+ "exchanges": frozenset({"okx"}),
+ "fields": [
+ ("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
+ ("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
+ ("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
+ ("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
+ *_HEDGE_COMMON_FIELDS,
+ *_HEDGE_PO_FIELDS,
+ *_HEDGE_OO_FIELDS,
+ ],
+}
+
+
+# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
+_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
+ "RISK_CONTROL_ENABLED": "true",
+ "RISK_COOLING_HOURS_MANUAL": "4",
+ "RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
+ "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
+ "RISK_DAILY_LOSS_LIMIT": "2",
+ "RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
+ "AUTO_TRANSFER_FROM": "funding",
+ "AUTO_TRANSFER_TO": "swap",
+ "TRANSFER_CCY": "USDT",
+ "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
+ "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
+ "OKX_SHOW_PERP_FUNDS": "true",
+ "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
+ "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
+ "OKX_OPTIONS_MAX_DTE_DAYS": "2",
+ "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
+ "OKX_TRADE_MODE": "options",
+ "MAX_ACTIVE_HEDGE_PLANS": "1",
+ "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
+ "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
+ "HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
+ "HEDGE_PLAN_BUDGET_BUFFER": "0.95",
+ "HEDGE_PLAN_OPTION_PRIMARY": "true",
+ "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
+ "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
+}
+
+
+def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
+ if key == "OKX_TRADE_MODE":
+ # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
+ file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
+ if file_val:
+ from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
+
+ return normalize_okx_trade_mode(file_val) or file_val
+ try:
+ from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
+
+ return get_okx_trade_mode()
+ except Exception:
+ pass
+ if key in file_values:
+ file_val = str(file_values.get(key) or "").strip()
+ if file_val:
+ return file_val
+ runtime = os.getenv(key)
+ if runtime is not None and str(runtime).strip() != "":
+ return str(runtime).strip()
+ if schema_default:
+ return schema_default
+ return _RUNTIME_ENV_DEFAULTS.get(key, "")
+
+
+def _env_truthy(raw: str) -> bool:
+ return str(raw or "").strip().lower() in ("1", "true", "yes", "on")
+
+
+def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
+ out: dict[str, dict[str, Any]] = {}
+ for group in parse_env_example_schema(example_path):
+ for field in group.get("fields") or []:
+ out[field["key"]] = dict(field)
+ return out
+
+
+def _build_field(
+ key: str,
+ label: str,
+ note: str,
+ schema: dict[str, dict[str, Any]],
+ values: dict[str, str],
+) -> dict[str, Any]:
+ meta = schema.get(key) or {}
+ schema_default = meta.get("default") or ""
+ val = _effective_env_value(key, values, schema_default)
+ # 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
+ if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
+ manual = _effective_env_value(
+ "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
+ )
+ if _env_truthy(manual):
+ val = "false"
+ masked = _mask_value(key, val)
+ ftype = meta.get("type") or _field_type(key, val or schema_default)
+ options = select_options_for(key)
+ if options:
+ ftype = "select"
+ val = normalize_select_value(key, val) or val
+ masked = _mask_value(key, val)
+ out: dict[str, Any] = {
+ "key": key,
+ "label": label,
+ "note": note or meta.get("note") or "",
+ "default": val,
+ "type": ftype,
+ "sensitive": meta.get("sensitive", _is_sensitive(key)),
+ "restart_required": meta.get("restart_required", _restart_required(key)),
+ "hot_reload": meta.get("hot_reload", _hot_reload(key)),
+ "current": masked["value"] if not _is_sensitive(key) else "",
+ "masked": masked["masked"],
+ "tail": masked.get("tail") or "",
+ "has_value": masked["has_value"],
+ }
+ if options:
+ cur = (out["current"] or out["default"] or "").strip()
+ opt_vals = {o["value"] for o in options}
+ if cur and cur not in opt_vals:
+ options = [{"value": cur, "label": cur}] + options
+ out["options"] = options
+ return out
+
+
+def _okx_mode_for_env_ui() -> str:
+ try:
+ from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
+
+ return get_okx_trade_mode()
+ except Exception:
+ return "options"
+
+
+def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
+ fields = list(_OPTIONS_SECTION["fields"])
+ if mode != "options":
+ fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
+ return fields
+
+
+def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
+ if mode == "perp_options":
+ return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
+ if mode == "options_options":
+ return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
+ return []
+
+
+def ui_sections_for_exchange(
+ exchange_key: str,
+ *,
+ mode: str | None = None,
+) -> list[dict[str, Any]]:
+ ex = (exchange_key or "").strip().lower()
+ sections: list[dict[str, Any]] = []
+ live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
+ sections.append({"title": "交易所与实盘", "fields": live_fields})
+ sections.extend(_SHARED_SECTIONS)
+ if ex in _MODE_SECTION.get("exchanges", frozenset()):
+ from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
+
+ m = normalize_okx_trade_mode(mode) if mode else ""
+ if not m:
+ m = _okx_mode_for_env_ui()
+ sections.append(_MODE_SECTION)
+ sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
+ hedge_fields = _hedge_fields_for_mode(m)
+ if hedge_fields:
+ title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期"
+ sections.append({"title": title, "fields": hedge_fields})
+ return sections
+
+
+def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
+ """可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
+ keys: set[str] = set()
+ for sec in ui_sections_for_exchange(exchange_key):
+ for item in sec["fields"]:
+ keys.add(item[0])
+ ex = (exchange_key or "").strip().lower()
+ if ex == "okx":
+ keys.add("OKX_TRADE_MODE")
+ # 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
+ for item in _HEDGE_PLAN_SECTION["fields"]:
+ keys.add(item[0])
+ for item in _OPTIONS_SECTION["fields"]:
+ keys.add(item[0])
+ return frozenset(keys)
+
+
+def build_env_ui_payload(
+ exchange_key: str,
+ example_path: str,
+ env_path: str,
+) -> list[dict[str, Any]]:
+ schema = _schema_field_map(example_path)
+ env_lines = read_env_lines(env_path)
+ values = env_get_all(env_lines)
+ groups: list[dict[str, Any]] = []
+ for sec in ui_sections_for_exchange(
+ exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
+ ):
+ fields = [
+ _build_field(key, label, note, schema, values)
+ for key, label, note in sec["fields"]
+ ]
+ fields = _mark_compound_budget_hidden(fields)
+ groups.append({
+ "title": sec["title"],
+ "fields": fields,
+ "has_restart": any(f.get("restart_required") for f in fields),
+ })
+ return groups
+
+
+def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
+ compound_on = True
+ for f in fields:
+ if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
+ compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
+ break
+ if not compound_on:
+ return fields
+ out: list[dict[str, Any]] = []
+ for f in fields:
+ if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
+ item = dict(f)
+ item["hidden"] = True
+ out.append(item)
+ else:
+ out.append(f)
+ return out
+
+
+def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
+ allowed = ui_allowed_keys(exchange_key)
+ return {k: v for k, v in (updates or {}).items() if k in allowed}
+
+
+def validate_env_ui_updates(
+ exchange_key: str,
+ example_path: str,
+ updates: dict[str, str],
+) -> tuple[dict[str, str], list[str]]:
+ from lib.env.env_schema import validate_env_updates
+
+ schema = _schema_field_map(example_path)
+ groups: list[dict[str, Any]] = []
+ for sec in ui_sections_for_exchange(exchange_key):
+ fields: list[dict[str, Any]] = []
+ for key, _label, _note in sec["fields"]:
+ if key in schema:
+ field = dict(schema[key])
+ opts = select_options_for(key)
+ if opts:
+ field["type"] = "select"
+ field["options"] = opts
+ fields.append(field)
+ else:
+ default = ""
+ fields.append(
+ {
+ "key": key,
+ "type": _field_type(key, default),
+ "sensitive": _is_sensitive(key),
+ "restart_required": _restart_required(key),
+ "hot_reload": _hot_reload(key),
+ "options": select_options_for(key),
+ }
+ )
+ groups.append({"title": sec["title"], "fields": fields})
+ return validate_env_updates(groups, updates)
+
+
+def coerce_hedge_partial_close_with_manual(
+ clean: dict[str, str],
+ *,
+ env_path: str = "",
+) -> dict[str, str]:
+ """手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
+ out = dict(clean or {})
+ manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
+ if manual is None and env_path:
+ try:
+ from lib.env.env_file_lib import env_get_all, read_env_lines
+
+ file_vals = env_get_all(read_env_lines(env_path))
+ manual = _effective_env_value(
+ "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
+ )
+ except Exception:
+ manual = "true"
+ if _env_truthy(str(manual or "")):
+ out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
+ return out
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index 094b298..acd878d 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -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,
diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py
index 89ca8c5..5dd66e8 100644
--- a/lib/hedge_plan/hedge_plan_register.py
+++ b/lib/hedge_plan/hedge_plan_register.py
@@ -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"):
diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py
index fd86ed6..3103f02 100644
--- a/lib/instance/instance_embed_context_lib.py
+++ b/lib/instance/instance_embed_context_lib.py
@@ -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": []}
diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py
index d0b5644..8369887 100644
--- a/lib/instance/instance_settings_register.py
+++ b/lib/instance/instance_settings_register.py
@@ -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/", 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/", 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
diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html
index ff3eec2..1058e24 100644
--- a/lib/instance/templates/embed_boot_scripts.html
+++ b/lib/instance/templates/embed_boot_scripts.html
@@ -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){
diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html
index 1568111..7d9d409 100644
--- a/lib/instance/templates/index.html
+++ b/lib/instance/templates/index.html
@@ -1,1851 +1,2102 @@
-{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ pwa_app_name }}
-
-
-
-
-
-{% macro period_stats_pane(period_key, s) %}
-{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %}
-{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %}
-{% set loss_sum = s.loss_sum_u %}
-{% set pnl_total = profit_sum + loss_sum %}
-{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %}
-{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %}
-{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %}
-
-
{{ s.range_label }}
-
- {% if s.closed_count %}
-
-
- {% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U
- 净盈亏
-
-
-
- {% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %}
-
-
{{ s.win_count }}胜 {{ s.loss_count }}负
-
-
- {{ s.opens_count }} / {{ s.closed_count }}
- 开单 / 平仓
-
-
-
-
盈亏构成
-
-
- 盈利 {{ funds_fmt(profit_sum) }}U
- 亏损 {{ funds_fmt(loss_sum) }}U
-
-
-
-
-
- 最大回撤
- {{ funds_fmt(s.max_drawdown_u) }}U
-
-
- 连续亏损
- {{ s.consecutive_losses }} 笔
-
-
- 最长连亏日
- {{ s.max_loss_streak_days }} 天
-
-
- 最大亏损日
- {% if s.worst_day %}{{ s.worst_day }} ({{ funds_fmt(s.worst_day_pnl) }}U){% else %}—{% endif %}
-
-
-
- {% else %}
-
当前区间暂无平仓数据
- {% endif %}
-
-
- 详细指标
-
-
-
-
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
-
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
-
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
-
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
-
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
-
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
-
当前连续亏损笔数
{{ s.consecutive_losses }}
-
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
-
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
-
-
- {% if period_key == 'all' %}
-
-
按月统计
- {% if s.monthly_rows %}
-
-
-
-
- | 月份 |
- 开单 |
- 平仓 |
- 胜率 |
- 净盈亏 |
- 最大回撤 |
-
-
-
- {% for m in s.monthly_rows %}
- {% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %}
-
- | {{ m.month_key }} |
- {{ m.opens_count }} |
- {{ m.closed_count }} |
- {% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %} |
- {% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }} |
- {{ funds_fmt(m.max_drawdown_u) }} |
-
- {% endfor %}
-
-
-
- {% else %}
-
暂无按月平仓数据
- {% endif %}
-
- {% endif %}
-
-{% endmacro %}
-
-
-
-
-
数据看板
-
账户流水
-
关键位监控
- {% if options_nav_visible and display.show_nav_options %}
-
期权
- {% endif %}
- {% if options_nav_visible and display.show_nav_options_review %}
-
期权复盘
- {% endif %}
- {% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
-
对冲计划
- {% endif %}
- {% if display.show_nav_risk_policy %}
-
风控说明
- {% endif %}
-
系统说明
- {% if display.show_nav_env_config %}
-
env配置
- {% endif %}
-
系统设置
-
- {% include 'nav_spot_tickers.html' %}
-
- {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %}
-
- {% include 'instance_header_panel.html' %}
- {% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options_review') %}
- {% include 'instance_top_bar.html' %}
- {% endif %}
-
-
- {% if page == 'dashboard' %}
- {% include 'dashboard_panel.html' %}
- {% elif page == 'account_ledger' %}
- {% include 'account_ledger_panel.html' %}
- {% elif page == 'key_monitor' %}
- {% include 'key_monitor_panel.html' %}
- {% elif page == 'options' %}
- {% include 'options_panel.html' %}
- {% elif page == 'options_review' %}
- {% include 'options_review_panel.html' %}
- {% elif page == 'hedge_plan' %}
- {% include 'hedge_plan_panel.html' %}
- {% endif %}
-
-
-
- {% if page == 'env_config' %}
- {% include 'env_config_panel.html' %}
- {% endif %}
-
- {% if page == 'risk_policy' %}
- {% include 'risk_policy_panel.html' %}
- {% endif %}
-
- {% if page == 'system_guide' %}
- {% include 'system_guide_panel.html' %}
- {% endif %}
-
- {% if page == 'settings' %}
- {% include 'settings_panel.html' %}
- {% endif %}
-
-
-
-
-
-
![screenshot]()
-
-
-
-
-
-
-
![detail-image]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ pwa_app_name }}
+
+
+
+
+
+{% macro period_stats_pane(period_key, s) %}
+{% set win_pct = s.win_rate_pct if s.win_rate_pct is not none else 0 %}
+{% set profit_sum = (s.net_pnl_u + s.loss_sum_u) if s.closed_count else 0 %}
+{% set loss_sum = s.loss_sum_u %}
+{% set pnl_total = profit_sum + loss_sum %}
+{% set profit_bar_w = (profit_sum / pnl_total * 100) if pnl_total > 0 else 0 %}
+{% set loss_bar_w = (loss_sum / pnl_total * 100) if pnl_total > 0 else 0 %}
+{% set net_cls = 'pos-pnl-profit' if s.net_pnl_u > 0 else ('pos-pnl-loss' if s.net_pnl_u < 0 else '') %}
+
+
{{ s.range_label }}
+
+ {% if s.closed_count %}
+
+
+ {% if s.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(s.net_pnl_u) }}U
+ 净盈亏
+
+
+
+ {% if s.win_rate_pct is not none %}{{ win_pct|round(0)|int }}%{% else %}—{% endif %}
+
+
{{ s.win_count }}胜 {{ s.loss_count }}负
+
+
+ {{ s.opens_count }} / {{ s.closed_count }}
+ 开单 / 平仓
+
+
+
+
盈亏构成
+
+
+ 盈利 {{ funds_fmt(profit_sum) }}U
+ 亏损 {{ funds_fmt(loss_sum) }}U
+
+
+
+
+
+ 最大回撤
+ {{ funds_fmt(s.max_drawdown_u) }}U
+
+
+ 连续亏损
+ {{ s.consecutive_losses }} 笔
+
+
+ 最长连亏日
+ {{ s.max_loss_streak_days }} 天
+
+
+ 最大亏损日
+ {% if s.worst_day %}{{ s.worst_day }} ({{ funds_fmt(s.worst_day_pnl) }}U){% else %}—{% endif %}
+
+
+
+ {% else %}
+
当前区间暂无平仓数据
+ {% endif %}
+
+
+ 详细指标
+
+
+
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
+
+
+ {% if period_key == 'all' %}
+
+
按月统计
+ {% if s.monthly_rows %}
+
+
+
+
+ | 月份 |
+ 开单 |
+ 平仓 |
+ 胜率 |
+ 净盈亏 |
+ 最大回撤 |
+
+
+
+ {% for m in s.monthly_rows %}
+ {% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %}
+
+ | {{ m.month_key }} |
+ {{ m.opens_count }} |
+ {{ m.closed_count }} |
+ {% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %} |
+ {% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }} |
+ {{ funds_fmt(m.max_drawdown_u) }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
暂无按月平仓数据
+ {% endif %}
+
+ {% endif %}
+
+{% endmacro %}
+
+
+
+
数据看板
+
账户流水
+
关键位监控
+
实盘下单
+ {% if not intraday_discipline and display.show_nav_strategy %}
+
策略交易
+ {% endif %}
+ {% if not intraday_discipline and display.show_nav_strategy_records %}
+
策略交易记录
+ {% endif %}
+ {% if display.show_nav_records %}
+
交易记录与复盘
+ {% endif %}
+ {% if display.show_nav_stats %}
+
统计分析
+ {% endif %}
+ {% if options_nav_visible and display.show_nav_options %}
+
期权
+ {% endif %}
+ {% if options_nav_visible and display.show_nav_options_review %}
+
期权复盘
+ {% endif %}
+ {% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
+
对冲计划
+ {% endif %}
+ {% if display.show_nav_risk_policy %}
+
风控说明
+ {% endif %}
+
系统说明
+ {% if display.show_nav_env_config %}
+
env配置
+ {% endif %}
+
系统设置
+
+ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %}
+
+ {% include 'instance_header_panel.html' %}
+ {% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
+ {% include 'instance_top_bar.html' %}
+ {% endif %}
+
+
+ {% if page == 'dashboard' %}
+ {% include 'dashboard_panel.html' %}
+ {% elif page == 'account_ledger' %}
+ {% include 'account_ledger_panel.html' %}
+ {% elif page == 'key_monitor' %}
+ {% include 'key_monitor_panel.html' %}
+ {% elif page == 'trade' %}
+
+
+
+
实盘下单监控
+ {% if focus_order_id %}
+
放大查看K线(100根)
+ {% else %}
+
暂无持仓可放大
+ {% endif %}
+
+ {% include order_rule_tips_tpl %}
+ {% include 'order_monitor_open_form.html' %}
+
+
+
实时持仓
+ {% if ui_orphan_recovery_enabled %}
+ {% if not order and orphan_live_positions %}
+ {% set o = orphan_live_positions[0] %}
+
+ 检测到交易所仍有 {{ o.symbol }} {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录).
+ {% if o.recoverable_monitor_id %}
+
+ {% else %}
+ 未找到可恢复的监控记录,需在服务器数据库处理.
+ {% endif %}
+
+ {% else %}
+
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
挂止盈止损
+
将先撤销该合约已有 TP/SL,再按下列价格重挂.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %}
+ {% include 'strategy_trading_page.html' %}
+ {% elif page == 'strategy_records' %}
+ {% include 'strategy_records_page.html' %}
+ {% elif page == 'options' %}
+ {% include 'options_panel.html' %}
+ {% elif page == 'options_review' %}
+ {% include 'options_review_panel.html' %}
+ {% elif page == 'hedge_plan' %}
+ {% include 'hedge_plan_panel.html' %}
+ {% endif %}
+
+
+
+ {% if page == 'records' %}
+ {% include 'records_panel.html' %}
+ {% endif %}
+
+ {% if page == 'env_config' %}
+ {% include 'env_config_panel.html' %}
+ {% endif %}
+
+ {% if page == 'risk_policy' %}
+ {% include 'risk_policy_panel.html' %}
+ {% endif %}
+
+ {% if page == 'system_guide' %}
+ {% include 'system_guide_panel.html' %}
+ {% endif %}
+
+ {% if page == 'settings' %}
+ {% include 'settings_panel.html' %}
+ {% endif %}
+
+ {% if page == 'stats' %}
+
+
+
数据统计
+
+
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
+ {{ stats_bundle.total_opens_all }} 次
+
+
+
+
+ {% for seg in stats_bundle.segments %}
+
+
+
+
+
+
+
+ {{ period_stats_pane("day", seg.day) }}
+ {{ period_stats_pane("week", seg.week) }}
+ {{ period_stats_pane("month", seg.month) }}
+ {{ period_stats_pane("all", seg.all) }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
![screenshot]()
+
+
+
+
+
+
+
![detail-image]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/instance/templates/instance_header_panel.html b/lib/instance/templates/instance_header_panel.html
index f2cb7a4..4f3a675 100644
--- a/lib/instance/templates/instance_header_panel.html
+++ b/lib/instance/templates/instance_header_panel.html
@@ -38,11 +38,13 @@
{% include 'instance_header_stats.html' %}