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 %} -
-
- 详细指标 -
-
开单次数
{{ s.opens_count }}
-
平仓笔数
{{ s.closed_count }}
-
胜率
{% 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 '') %} - - - - - - - - - {% endfor %} - -
月份开单平仓胜率净盈亏最大回撤
{{ 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) }}
-
- {% else %} -

暂无按月平仓数据

- {% endif %} -
- {% endif %} -
-{% endmacro %} -
-
-

加密货币|OKX 期权与对冲

-
-
- - {% 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 %} - - -
- - -
-
-
-
详情
-
- - -
-
-
- - -
-
- - - - - - - - - - - - - - - - - - - - +{# 三所共用 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 %} +
+
+ 详细指标 +
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% 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 '') %} + + + + + + + + + {% endfor %} + +
月份开单平仓胜率净盈亏最大回撤
{{ 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) }}
+
+ {% else %} +

暂无按月平仓数据

+ {% endif %} +
+ {% endif %} +
+{% endmacro %} +
+
+

加密货币|交易监控 + AI复盘一体化

+
+
+ + + + + {% 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 %} +
+ {% for o in order %} +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {% include 'force_close_order_badge.html' %} + {{ '做多' if o.direction == 'long' else '做空' }} +
+
+ + 平仓 +
+
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + + {% if intraday_discipline %} + {% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
+
+ 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 张数 + {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} +
+
+ 盈利金额 + +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
+
+ {% else %} +
暂无持仓
+ {% endfor %} +
+
+ +
+
+

挂止盈止损

+

将先撤销该合约已有 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 %} + + {% endfor %} +
+
+ {% endif %} +
+ + +
+
+
+
详情
+
+ + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + \ 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' %}
-