/** * 中控策略计算器:趋势回调 / 滚仓历史测算 */ (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); } 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 || ""), "乘数 " + fmt(market.contract_size, 8), "价格精度 " + fmt(market.price_tick != null ? market.price_tick : Math.pow(10, -(market.price_decimals || 0))), "张数精度 " + fmt(Math.pow(10, -(market.amount_decimals || 0))), ]; if (market.min_amount != null) { parts.push("最小张数 " + fmt(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 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)); } } async function bindOnce() { if (inited) return; inited = true; await loadCalculatorExchanges(); const trendForm = $("calc-trend-form"); const rollForm = $("calc-roll-form"); const dirSel = $("calc-trend-direction"); if (trendForm) trendForm.addEventListener("submit", submitTrend); if (rollForm) rollForm.addEventListener("submit", submitRoll); if (dirSel) { dirSel.addEventListener("change", syncTrendAddLabel); syncTrendAddLabel(); } bindRollLegsUI(); bindMarket("calc-trend"); bindMarket("calc-roll"); } window.hubCalculatorPage = { init: function () { bindOnce(); }, destroy: function () {}, }; })();