/** * 中控策略计算器:趋势回调 / 滚仓历史测算 */ (function () { const page = document.getElementById("page-calculator"); if (!page) return; let inited = false; const marketCache = {}; let calculatorExchanges = []; function $(id) { return document.getElementById(id); } function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """); } function num(id) { const el = $(id); if (!el) return null; const n = Number(el.value); return Number.isFinite(n) ? n : null; } function text(id) { const el = $(id); if (!el) return ""; return String(el.value || "").trim(); } function fmt(v, digits) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return esc(v); if (digits != null) return n.toFixed(digits); return String(n); } /** 去掉尾部多余 0,用于乘数/精度展示 */ function fmtTrim(v, maxDigits) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return esc(v); let s = maxDigits != null ? n.toFixed(maxDigits) : String(n); if (s.includes(".")) s = s.replace(/\.?0+$/, ""); return s; } function fmtU(v) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return "—"; return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; } function pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return ""; return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss"; } function decimalsFromMarket(data) { if (!data || !data.market) return { price: 4, amount: 4 }; return { price: Number(data.market.price_decimals), amount: Number(data.market.amount_decimals), }; } function fmtMarketInfo(market, err) { if (err) { return '' + esc(err) + ""; } if (!market) return "—"; const inst = market.exchange_name ? esc(market.exchange_name) + " · " : ""; const parts = [ inst + "" + esc(market.display_symbol || market.base || "") + " 永续", "合约 " + esc(market.exchange_symbol || ""), "乘数 " + fmtTrim(market.contract_size, 8), "价格精度 " + fmtTrim(market.price_tick != null ? market.price_tick : Math.pow(10, -(market.price_decimals || 0))), "张数精度 " + fmtTrim(Math.pow(10, -(market.amount_decimals || 0))), ]; if (market.min_amount != null) { parts.push("最小张数 " + fmtTrim(market.min_amount, market.amount_decimals)); } return parts.join(" · "); } function applyMarketSteps(prefix, market) { const pxStep = market && market.price_tick != null && Number(market.price_tick) > 0 ? String(market.price_tick) : market && market.price_decimals != null ? String(Math.pow(10, -Number(market.price_decimals))) : "any"; const amtStep = market && market.amount_decimals != null ? String(Math.pow(10, -Number(market.amount_decimals))) : "any"; page.querySelectorAll("#" + prefix + "-form input[type='number']").forEach(function (el) { if (el.classList.contains("calc-roll-leg-add") || el.classList.contains("calc-roll-leg-stop")) { el.step = pxStep; return; } if (el.id === prefix + "-capital" || el.id === prefix + "-risk" || el.id === prefix + "-leverage") { return; } if (el.id === prefix + "-dca-legs" || el.id === prefix + "-legs-done") { return; } el.step = pxStep; }); page.querySelectorAll(".calc-roll-leg-add, .calc-roll-leg-stop").forEach(function (el) { el.step = pxStep; }); void amtStep; } async function refreshMarket(prefix) { const exchangeEl = $(prefix + "-exchange"); const baseEl = $(prefix + "-base"); const infoEl = $(prefix + "-market-info"); if (!exchangeEl || !baseEl || !infoEl) return null; const exchangeId = exchangeEl.value || (calculatorExchanges[0] && calculatorExchanges[0].id) || "0"; const base = text(prefix + "-base") || "ETH"; const cacheKey = exchangeId + ":" + base.toUpperCase(); infoEl.innerHTML = "加载合约信息…"; try { const r = await fetch( "/api/calculator/market?exchange_id=" + encodeURIComponent(exchangeId) + "&base=" + encodeURIComponent(base), { credentials: "same-origin" } ); const j = await r.json(); if (!j.ok) { infoEl.innerHTML = fmtMarketInfo(null, j.msg || "加载失败"); marketCache[prefix] = null; return null; } marketCache[prefix] = j.data; marketCache[cacheKey] = j.data; infoEl.innerHTML = fmtMarketInfo(j.data, null); applyMarketSteps(prefix, j.data); return j.data; } catch (err) { infoEl.innerHTML = fmtMarketInfo(null, String(err)); marketCache[prefix] = null; return null; } } function fillExchangeSelect(selectEl, selectedId) { if (!selectEl) return; selectEl.innerHTML = ""; if (!calculatorExchanges.length) { selectEl.innerHTML = ''; return; } calculatorExchanges.forEach(function (ex) { const opt = document.createElement("option"); opt.value = String(ex.id); opt.textContent = ex.name || ex.key || ex.id; selectEl.appendChild(opt); }); const want = selectedId != null ? String(selectedId) : String(calculatorExchanges[0].id); if ([].some.call(selectEl.options, function (o) { return o.value === want; })) { selectEl.value = want; } } async function loadCalculatorExchanges() { try { const r = await fetch("/api/calculator/exchanges", { credentials: "same-origin" }); const j = await r.json(); calculatorExchanges = (j.ok && j.data) || []; } catch (_err) { calculatorExchanges = []; } fillExchangeSelect($("calc-trend-exchange")); fillExchangeSelect($("calc-roll-exchange")); } function fmtRefreshTime() { const d = new Date(); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); const s = String(d.getSeconds()).padStart(2, "0"); return h + ":" + m + ":" + s; } async function refreshPage() { const btn = $("calc-btn-refresh"); const status = $("calc-refresh-status"); const trendId = $("calc-trend-exchange") && $("calc-trend-exchange").value; const rollId = $("calc-roll-exchange") && $("calc-roll-exchange").value; if (btn) btn.disabled = true; if (status) status.textContent = "刷新中…"; Object.keys(marketCache).forEach(function (k) { delete marketCache[k]; }); try { await loadCalculatorExchanges(); fillExchangeSelect($("calc-trend-exchange"), trendId); fillExchangeSelect($("calc-roll-exchange"), rollId); await Promise.all([refreshMarket("calc-trend"), refreshMarket("calc-roll")]); if (status) status.textContent = "已刷新 " + fmtRefreshTime(); } catch (err) { if (status) status.textContent = "刷新失败"; } finally { if (btn) btn.disabled = false; } } function bindMarket(prefix) { const exchangeEl = $(prefix + "-exchange"); const baseEl = $(prefix + "-base"); if (!exchangeEl || !baseEl) return; const run = function () { void refreshMarket(prefix); }; if (!exchangeEl._calcMarketBound) { exchangeEl._calcMarketBound = true; exchangeEl.addEventListener("change", run); } if (!baseEl._calcMarketBound) { baseEl._calcMarketBound = true; baseEl.addEventListener("change", run); baseEl.addEventListener("blur", run); } run(); } function syncTrendAddLabel() { const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long"; const lab = $("calc-trend-add-label"); if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价"; } function renderTrendTable(rows, dec) { if (!rows || !rows.length) { return '

无档位数据

'; } const px = dec.price != null ? dec.price : 4; const amt = dec.amount != null ? dec.amount : 4; let html = '
' + "" + ""; rows.forEach(function (r) { html += "" + "" + "" + "" + "" + '" + "" + "" + ""; }); html += "
档位触发价张数加仓后均价止盈盈利止损金额盈亏比
" + esc(r.label) + "" + fmt(r.price, px) + "" + fmt(r.contracts, amt) + "" + fmt(r.avg_entry, px) + "' + fmtU(r.profit_u) + "" + fmtU(r.risk_u) + "" + (r.rr != null ? fmt(r.rr, 2) + ":1" : "—") + "
"; return html; } function renderTrendResult(data) { const box = $("calc-trend-result"); if (!box) return; const dec = decimalsFromMarket(data); box.classList.remove("hidden"); box.innerHTML = '
' + "
合约" + esc((data.market && data.market.display_symbol) || "—") + "
" + "
计划保证金" + fmt(data.plan_margin_u, 2) + "U
" + "
止损预算" + fmt(data.risk_budget_u, 2) + "U
" + "
总张数" + fmt(data.target_contracts, dec.amount) + "
" + "
首仓张数" + fmt(data.first_contracts, dec.amount) + "
" + '
首仓止盈盈利' + fmtU(data.first_profit_u) + "
" + "
首仓盈亏比" + (data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") + "
" + "
" + renderTrendTable(data.rows, dec); } function renderRollResult(data) { const box = $("calc-roll-result"); if (!box) return; const dec = decimalsFromMarket(data); const px = dec.price != null ? dec.price : 4; const amt = dec.amount != null ? dec.amount : 4; box.classList.remove("hidden"); let table = '
' + "" + ""; (data.rows || []).forEach(function (r) { const tag = r.already_done ? ' 已完成' : ""; table += "" + "" + "" + "" + "" + "" + "" + '" + '" + "" + ""; }); table += "
阶段入场/加仓价统一止损本次张数累计张数均价打到止损总亏止盈盈利盈亏比
" + esc(r.label) + tag + "" + fmt(r.entry_or_add_price, px) + "" + fmt(r.stop_loss, px) + "" + fmt(r.add_contracts, amt) + "" + fmt(r.total_contracts, amt) + "" + fmt(r.avg_entry, px) + "' + fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) + "' + fmtU(r.profit_at_tp_u) + "" + (r.rr != null ? fmt(r.rr, 2) + ":1" : "—") + "
"; box.innerHTML = '
' + "
合约" + esc((data.market && data.market.display_symbol) || "—") + "
" + "
单次风险预算" + fmt(data.risk_budget_u, 2) + "U
" + "
首仓张数(自动)" + fmt(data.first_contracts, amt) + "
" + "
最终累计张数" + fmt(data.final_contracts, amt) + "
" + "
最终均价" + fmt(data.final_avg_entry, px) + "
" + '
最终止盈盈利' + fmtU(data.final_profit_at_tp_u) + "
" + "
最终盈亏比" + (data.final_rr != null ? fmt(data.final_rr, 2) + ":1" : "—") + "
" + "
" + table; } const MAX_ROLL_LEGS = 3; let rollLegCount = 0; function maxRollLegsAllowed() { const done = num("calc-roll-legs-done") || 0; return Math.max(0, MAX_ROLL_LEGS - done); } function syncRollAddBtn() { const btn = $("calc-roll-add-leg"); if (!btn) return; btn.disabled = rollLegCount >= maxRollLegsAllowed(); } function rollLegRowHtml(index) { const step = (marketCache["calc-roll"] && marketCache["calc-roll"].price_tick) || "any"; return ( '
' + '
滚仓 ' + index + "
" + '
' + '' + '' + "
" + '' + "
" ); } function renumberRollLegs() { const list = $("calc-roll-legs-list"); if (!list) return; const rows = list.querySelectorAll(".calc-roll-leg"); rollLegCount = rows.length; rows.forEach(function (row, i) { row.setAttribute("data-leg-index", String(i + 1)); const title = row.querySelector(".calc-roll-leg-title"); if (title) title.textContent = "滚仓 " + (i + 1); }); syncRollAddBtn(); } function addRollLegRow() { if (rollLegCount >= maxRollLegsAllowed()) return; const list = $("calc-roll-legs-list"); if (!list) return; list.insertAdjacentHTML("beforeend", rollLegRowHtml(rollLegCount + 1)); rollLegCount += 1; syncRollAddBtn(); } function collectRollLegs() { const legs = []; document.querySelectorAll(".calc-roll-leg").forEach(function (row) { const addEl = row.querySelector(".calc-roll-leg-add"); const stopEl = row.querySelector(".calc-roll-leg-stop"); const ap = addEl && addEl.value !== "" ? Number(addEl.value) : null; const sl = stopEl && stopEl.value !== "" ? Number(stopEl.value) : null; if (ap == null || sl == null || !Number.isFinite(ap) || !Number.isFinite(sl)) return; legs.push({ add_price: ap, new_stop_loss: sl }); }); return legs; } function bindRollLegsUI() { const addBtn = $("calc-roll-add-leg"); const list = $("calc-roll-legs-list"); const doneInput = $("calc-roll-legs-done"); if (addBtn && !addBtn._bound) { addBtn._bound = true; addBtn.addEventListener("click", addRollLegRow); } if (list && !list._bound) { list._bound = true; list.addEventListener("click", function (e) { const btn = e.target.closest(".calc-roll-leg-remove"); if (!btn) return; const row = btn.closest(".calc-roll-leg"); if (row) row.remove(); renumberRollLegs(); }); } if (doneInput && !doneInput._bound) { doneInput._bound = true; doneInput.addEventListener("change", function () { while (rollLegCount > maxRollLegsAllowed()) { const rows = list && list.querySelectorAll(".calc-roll-leg"); if (rows && rows.length) rows[rows.length - 1].remove(); rollLegCount = list ? list.querySelectorAll(".calc-roll-leg").length : 0; } syncRollAddBtn(); }); } syncRollAddBtn(); } function showErr(boxId, msg) { const box = $(boxId); if (!box) return; box.classList.remove("hidden"); box.innerHTML = '

' + esc(msg || "计算失败") + "

"; } async function submitTrend(e) { e.preventDefault(); const body = { direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long", exchange_id: ($("calc-trend-exchange") && $("calc-trend-exchange").value) || "0", base: text("calc-trend-base") || "ETH", capital_usdt: num("calc-trend-capital"), risk_percent: num("calc-trend-risk"), leverage: num("calc-trend-leverage"), entry_price: num("calc-trend-entry"), stop_loss: num("calc-trend-sl"), add_upper: num("calc-trend-add-upper"), take_profit: num("calc-trend-tp"), dca_legs: num("calc-trend-dca-legs") || 5, }; try { const r = await fetch("/api/calculator/trend", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (!j.ok) { showErr("calc-trend-result", j.msg || "计算失败"); return; } renderTrendResult(j.data); } catch (err) { showErr("calc-trend-result", String(err)); } } async function submitRoll(e) { e.preventDefault(); const body = { direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long", exchange_id: ($("calc-roll-exchange") && $("calc-roll-exchange").value) || "0", base: text("calc-roll-base") || "ETH", capital_usdt: num("calc-roll-capital"), risk_percent: num("calc-roll-risk"), entry_price: num("calc-roll-entry"), stop_loss: num("calc-roll-sl"), take_profit: num("calc-roll-tp"), add_legs: collectRollLegs(), legs_done: num("calc-roll-legs-done") || 0, }; try { const r = await fetch("/api/calculator/roll", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (!j.ok) { showErr("calc-roll-result", j.msg || "计算失败"); return; } renderRollResult(j.data); } catch (err) { showErr("calc-roll-result", String(err)); } } function renderPerpOptionsResult(data) { const box = $("calc-po-result"); if (!box) return; if ((data.calc_mode || "size") === "points") { renderPerpOptionsPointsResult(data); return; } const a = data.case_a || {}; const b = data.case_b || {}; const capitalHint = data.capital_ok ? "资金充足(参考)" : "保证金高于交易资金(仅提示)"; box.classList.remove("hidden"); box.innerHTML = '
' + "
标的" + esc(data.base || "—") + " · 永续 " + fmtTrim(data.perp_coins, 4) + " 币
" + "
单币权利金" + fmt(data.prem_per_coin, 4) + "U
" + "
永续毛收益' + fmtU(data.perp_gross_u) + "
" + "
永续手续费" + fmt(data.perp_fee_u, 4) + "U
" + "
权利金预算" + fmt(data.premium_budget_u, 4) + "U
" + "
期权开仓" + fmtTrim(data.opt_coins, 6) + " 币 / " + fmtTrim(data.opt_sheets, 4) + " 张
" + "
永续保证金" + fmt(data.perp_margin_u, 2) + "U
" + "
开仓参考" + esc(capitalHint) + "
" + "
" + '
' + '
' + "

情景 A · 永续方向对

" + '
' + "
永续盈亏' + fmtU(a.perp_pnl_u) + "
" + "
权利金(全亏)" + fmt(a.premium_u, 4) + "U
" + "
手续费" + fmt(a.fee_u, 4) + "U
" + "
净利' + fmtU(a.net_u) + "
" + "
" + '
' + "

情景 B · 期权方向对

" + '
' + "
期权内在" + fmtU(b.opt_intrinsic_u) + "
" + "
权利金" + fmt(b.premium_u, 4) + "U
" + "
期权净利' + fmtU(b.opt_net_u) + "
" + "
永续盈亏' + fmtU(b.perp_pnl_u) + "
" + "
组合净利' + fmtU(b.portfolio_net_u) + "
" + "
"; } function renderPerpOptionsPointsResult(data) { const box = $("calc-po-result"); if (!box) return; const a = data.case_a || {}; const b = data.case_b || {}; const capitalHint = data.capital_ok ? "资金充足(参考)" : "保证金高于交易资金(仅提示)"; let caseB = '
' + "

情景 B · 期权方向对

" + '
' + "
所需点数(期权净利=目标)" + fmt(b.move_points_opt_net, 4) + " · " + fmt(b.move_pct_opt_net, 4) + "%
" + "
期权净利' + fmtU(b.opt_net_u) + "
" + "
此时组合净利' + fmtU(b.portfolio_net_at_opt_target_u) + "
"; if (b.move_points_portfolio != null) { caseB += "
所需点数(组合净利=目标)" + fmt(b.move_points_portfolio, 4) + " · " + fmt(b.move_pct_portfolio, 4) + "%
" + "
组合净利' + fmtU(b.portfolio_net_u) + "
" + "
此时期权净利' + fmtU(b.opt_net_at_portfolio_target_u) + "
"; } else if (b.portfolio_error) { caseB += '
组合达目标' + esc(b.portfolio_error) + "
"; } caseB += "
"; box.classList.remove("hidden"); box.innerHTML = '
' + "
标的" + esc(data.base || "—") + " · 比例 " + esc(data.ratio_label || "—") + "
" + "
仓位永续 " + fmtTrim(data.perp_coins, 4) + " 币 / 期权 " + fmtTrim(data.opt_coins, 4) + " 币(" + fmtTrim(data.opt_sheets, 2) + " 张)
" + "
单币权利金" + fmt(data.prem_per_coin, 4) + "U
" + "
权利金总额" + fmt(data.premium_total_u, 4) + "U
" + "
目标盈利" + fmt(data.target_profit_u, 2) + "U
" + "
永续保证金" + fmt(data.perp_margin_u, 2) + "U
" + "
开仓参考" + esc(capitalHint) + "
" + "
" + '
' + '
' + "

情景 A · 永续方向对

" + '
' + "
所需波动点数" + fmt(a.move_points, 4) + " · " + fmt(a.move_pct, 4) + "%
" + "
永续盈亏' + fmtU(a.perp_pnl_u) + "
" + "
权利金(全亏)" + fmt(a.premium_u, 4) + "U
" + "
手续费" + fmt(a.fee_u, 4) + "U
" + "
净利' + fmtU(a.net_u) + "
" + "
" + caseB + "
"; } async function submitPerpOptions(e) { e.preventDefault(); const calcMode = ($("calc-po-calc-mode") && $("calc-po-calc-mode").value) || "size"; const body = { calc_mode: calcMode, base: ($("calc-po-base") && $("calc-po-base").value) || "ETH", spot: num("calc-po-spot"), capital_usdt: num("calc-po-capital"), target_profit_u: num("calc-po-target"), move_mode: ($("calc-po-move-mode") && $("calc-po-move-mode").value) || "points", move_value: num("calc-po-move"), perp_leverage: num("calc-po-perp-lev"), option_leverage: num("calc-po-opt-lev"), ct_mult: num("calc-po-ct-mult") || 0.01, ratio_perp: num("calc-po-ratio-perp") || 1, ratio_opt: num("calc-po-ratio-opt") || 2, }; try { const r = await fetch("/api/calculator/perp-options", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (!j.ok) { showErr("calc-po-result", j.msg || "计算失败"); return; } renderPerpOptionsResult(j.data); } catch (err) { showErr("calc-po-result", String(err)); } } function syncPoMoveLabel() { const mode = ($("calc-po-move-mode") && $("calc-po-move-mode").value) || "points"; const lab = $("calc-po-move-label"); if (lab) lab.textContent = mode === "pct" ? "波动率 %" : "波动点数"; } function syncPoCalcMode() { const mode = ($("calc-po-calc-mode") && $("calc-po-calc-mode").value) || "size"; const points = mode === "points"; page.querySelectorAll(".calc-po-size-only").forEach(function (el) { el.classList.toggle("hidden", points); }); page.querySelectorAll(".calc-po-points-only").forEach(function (el) { el.classList.toggle("hidden", !points); }); const moveInput = $("calc-po-move"); if (moveInput) { if (points) moveInput.removeAttribute("required"); else moveInput.setAttribute("required", "required"); } } function applyCalcTab(tab) { const t = tab === "roll" || tab === "po" ? tab : "trend"; const layout = page.querySelector(".calc-layout"); if (layout) layout.setAttribute("data-calc-tab", t); page.querySelectorAll(".calc-m-tab").forEach(function (btn) { const on = (btn.getAttribute("data-calc-tab") || "") === t; btn.classList.toggle("is-active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); try { sessionStorage.setItem("hub_calc_tab", t); } catch (e) { /* ignore */ } } function bindCalcTabs() { page.querySelectorAll(".calc-m-tab").forEach(function (btn) { btn.addEventListener("click", function () { applyCalcTab(btn.getAttribute("data-calc-tab") || "trend"); }); }); let saved = "trend"; try { saved = sessionStorage.getItem("hub_calc_tab") || "trend"; } catch (e) { saved = "trend"; } applyCalcTab(saved); } async function bindOnce() { if (inited) return; inited = true; await loadCalculatorExchanges(); const trendForm = $("calc-trend-form"); const rollForm = $("calc-roll-form"); const poForm = $("calc-po-form"); const dirSel = $("calc-trend-direction"); const poMode = $("calc-po-move-mode"); const poCalcMode = $("calc-po-calc-mode"); if (trendForm) trendForm.addEventListener("submit", submitTrend); if (rollForm) rollForm.addEventListener("submit", submitRoll); if (poForm) poForm.addEventListener("submit", submitPerpOptions); if (dirSel) { dirSel.addEventListener("change", syncTrendAddLabel); syncTrendAddLabel(); } if (poMode) { poMode.addEventListener("change", syncPoMoveLabel); syncPoMoveLabel(); } if (poCalcMode) { poCalcMode.addEventListener("change", syncPoCalcMode); syncPoCalcMode(); } bindRollLegsUI(); bindMarket("calc-trend"); bindMarket("calc-roll"); bindCalcTabs(); const refreshBtn = $("calc-btn-refresh"); if (refreshBtn) { refreshBtn.addEventListener("click", function () { void refreshPage(); }); } } window.hubCalculatorPage = { init: function () { if (inited) { void refreshPage(); return; } void bindOnce(); }, refresh: refreshPage, destroy: function () {}, }; })();