Use hub exchange instances for calculator contract precision.

Load enabled instances from settings, fetch market info via /api/hub/market, and apply exchange-specific amount and price precision in trend and roll calculators.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-23 18:13:02 +08:00
parent d938bc6c59
commit 5e507d0b66
14 changed files with 1140 additions and 204 deletions
+25 -2
View File
@@ -831,7 +831,8 @@ class TrendCalculatorBody(BaseModel):
add_upper: float = Field(gt=0)
take_profit: float = Field(gt=0)
dca_legs: int = Field(default=5, ge=1, le=20)
contract_size: float = Field(default=1.0, gt=0)
exchange_id: str = "0"
base: str = "ETH"
class RollAddLegBody(BaseModel):
@@ -848,6 +849,25 @@ class RollCalculatorBody(BaseModel):
take_profit: float = Field(gt=0)
add_legs: list[RollAddLegBody] = Field(default_factory=list, max_length=3)
legs_done: int = Field(default=0, ge=0, le=3)
exchange_id: str = "0"
base: str = "ETH"
@app.get("/api/calculator/exchanges")
def api_calculator_exchanges():
from hub_calculator_market_lib import list_calculator_exchanges
return {"ok": True, "data": list_calculator_exchanges()}
@app.get("/api/calculator/market")
def api_calculator_market(exchange_id: str = "0", base: str = "ETH"):
from hub_calculator_market_lib import get_calculator_market
data, err = get_calculator_market(exchange_id, base)
if err:
return JSONResponse({"ok": False, "msg": err}, status_code=400)
return {"ok": True, "data": data}
@app.post("/api/calculator/trend")
@@ -864,7 +884,8 @@ def api_calculator_trend(body: TrendCalculatorBody):
add_upper=body.add_upper,
take_profit=body.take_profit,
dca_legs=body.dca_legs,
contract_size=body.contract_size,
exchange_id=body.exchange_id,
base=body.base,
)
if err:
return JSONResponse({"ok": False, "msg": err}, status_code=400)
@@ -884,6 +905,8 @@ def api_calculator_roll(body: RollCalculatorBody):
take_profit=body.take_profit,
add_legs=[leg.model_dump() for leg in body.add_legs],
legs_done=body.legs_done,
exchange_id=body.exchange_id,
base=body.base,
)
if err:
return JSONResponse({"ok": False, "msg": err}, status_code=400)
+24
View File
@@ -6845,6 +6845,8 @@ body.funds-fullscreen-open {
.calc-field input,
.calc-field select {
width: 100%;
box-sizing: border-box;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
@@ -6854,6 +6856,28 @@ body.funds-fullscreen-open {
font-family: var(--mono);
}
.calc-field-span2 {
grid-column: 1 / -1;
}
.calc-market-info {
padding: 0.55rem 0.55rem 0.55rem 0.75rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.82rem;
line-height: 1.45;
color: var(--muted, #9aa4b2);
}
.calc-market-info strong {
color: var(--text, #e8ecf1);
}
.calc-market-err {
color: #f87171;
}
.calc-actions {
margin-top: 12px;
}
+197 -22
View File
@@ -6,6 +6,8 @@
if (!page) return;
let inited = false;
const marketCache = {};
let calculatorExchanges = [];
function $(id) {
return document.getElementById(id);
@@ -16,7 +18,7 @@
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/\"/g, "&quot;");
}
function num(id) {
@@ -26,6 +28,12 @@
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);
@@ -47,16 +55,160 @@
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 '<span class="calc-market-err">' + esc(err) + "</span>";
}
if (!market) return "—";
const inst = market.exchange_name ? esc(market.exchange_name) + " · " : "";
const parts = [
inst + "<strong>" + esc(market.display_symbol || market.base || "") + "</strong> 永续",
"合约 " + 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 = '<option value="">无已启用交易所</option>';
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) {
function renderTrendTable(rows, dec) {
if (!rows || !rows.length) {
return '<p class="calc-empty">无档位数据</p>';
}
const px = dec.price != null ? dec.price : 4;
const amt = dec.amount != null ? dec.amount : 4;
let html =
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
"<th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利</th><th>止损金额</th><th>盈亏比</th>" +
@@ -68,13 +220,13 @@
esc(r.label) +
"</td>" +
"<td>" +
fmt(r.price, 4) +
fmt(r.price, px) +
"</td>" +
"<td>" +
fmt(r.contracts, 4) +
fmt(r.contracts, amt) +
"</td>" +
"<td>" +
fmt(r.avg_entry, 4) +
fmt(r.avg_entry, px) +
"</td>" +
'<td class="' +
pnlClass(r.profit_u) +
@@ -96,9 +248,13 @@
function renderTrendResult(data) {
const box = $("calc-trend-result");
if (!box) return;
const dec = decimalsFromMarket(data);
box.classList.remove("hidden");
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>合约</span><strong>" +
esc((data.market && data.market.display_symbol) || "—") +
"</strong></div>" +
"<div><span>计划保证金</span><strong>" +
fmt(data.plan_margin_u, 2) +
"U</strong></div>" +
@@ -106,10 +262,10 @@
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>总张数</span><strong>" +
fmt(data.target_contracts, 4) +
fmt(data.target_contracts, dec.amount) +
"</strong></div>" +
"<div><span>首仓张数</span><strong>" +
fmt(data.first_contracts, 4) +
fmt(data.first_contracts, dec.amount) +
"</strong></div>" +
'<div><span>首仓止盈盈利</span><strong class="' +
pnlClass(data.first_profit_u) +
@@ -120,16 +276,19 @@
(data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") +
"</strong></div>" +
"</div>" +
renderTrendTable(data.rows);
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 =
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
"<th>阶段</th><th>入场/加仓价</th><th>统一止损</th><th>本次张数</th><th>累计张数</th><th>均价</th><th>止损亏损</th><th>止盈盈利</th><th>盈亏比</th>" +
"<th>阶段</th><th>入场/加仓价</th><th>统一止损</th><th>本次张数</th><th>累计张数</th><th>均价</th><th>打到止损总亏</th><th>止盈盈利</th><th>盈亏比</th>" +
"</tr></thead><tbody>";
(data.rows || []).forEach(function (r) {
const tag = r.already_done ? ' <span class="calc-done-tag">已完成</span>' : "";
@@ -140,19 +299,19 @@
tag +
"</td>" +
"<td>" +
fmt(r.entry_or_add_price, 4) +
fmt(r.entry_or_add_price, px) +
"</td>" +
"<td>" +
fmt(r.stop_loss, 4) +
fmt(r.stop_loss, px) +
"</td>" +
"<td>" +
fmt(r.add_contracts, 4) +
fmt(r.add_contracts, amt) +
"</td>" +
"<td>" +
fmt(r.total_contracts, 4) +
fmt(r.total_contracts, amt) +
"</td>" +
"<td>" +
fmt(r.avg_entry, 4) +
fmt(r.avg_entry, px) +
"</td>" +
'<td class="calc-pnl-loss">' +
fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) +
@@ -170,17 +329,20 @@
table += "</tbody></table></div>";
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>合约</span><strong>" +
esc((data.market && data.market.display_symbol) || "—") +
"</strong></div>" +
"<div><span>单次风险预算</span><strong>" +
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>首仓张数(自动)</span><strong>" +
fmt(data.first_contracts, 4) +
fmt(data.first_contracts, amt) +
"</strong></div>" +
"<div><span>最终累计张数</span><strong>" +
fmt(data.final_contracts, 4) +
fmt(data.final_contracts, amt) +
"</strong></div>" +
"<div><span>最终均价</span><strong>" +
fmt(data.final_avg_entry, 4) +
fmt(data.final_avg_entry, px) +
"</strong></div>" +
'<div><span>最终止盈盈利</span><strong class="' +
pnlClass(data.final_profit_at_tp_u) +
@@ -209,6 +371,7 @@
}
function rollLegRowHtml(index) {
const step = (marketCache["calc-roll"] && marketCache["calc-roll"].price_tick) || "any";
return (
'<div class="calc-roll-leg" data-leg-index="' +
index +
@@ -217,8 +380,12 @@
index +
"</div>" +
'<div class="calc-roll-leg-grid">' +
'<label class="calc-field"><span>加仓价</span><input type="number" class="calc-roll-leg-add" min="0" step="any" required /></label>' +
'<label class="calc-field"><span>新统一止损</span><input type="number" class="calc-roll-leg-stop" min="0" step="any" required /></label>' +
'<label class="calc-field"><span>加仓价</span><input type="number" class="calc-roll-leg-add" min="0" step="' +
esc(step) +
'" required /></label>' +
'<label class="calc-field"><span>新统一止损</span><input type="number" class="calc-roll-leg-stop" min="0" step="' +
esc(step) +
'" required /></label>' +
"</div>" +
'<button type="button" class="ghost danger calc-roll-leg-remove">删除</button>' +
"</div>"
@@ -303,6 +470,8 @@
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"),
@@ -311,7 +480,6 @@
add_upper: num("calc-trend-add-upper"),
take_profit: num("calc-trend-tp"),
dca_legs: num("calc-trend-dca-legs") || 5,
contract_size: num("calc-trend-contract-size") || 1,
};
try {
const r = await fetch("/api/calculator/trend", {
@@ -335,6 +503,8 @@
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"),
@@ -361,9 +531,10 @@
}
}
function bindOnce() {
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");
@@ -374,10 +545,14 @@
syncTrendAddLabel();
}
bindRollLegsUI();
bindMarket("calc-trend");
bindMarket("calc-roll");
}
window.hubCalculatorPage = {
init: bindOnce,
init: function () {
bindOnce();
},
destroy: function () {},
};
})();
+22 -4
View File
@@ -707,6 +707,17 @@
<p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%。</p>
<form id="calc-trend-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-trend-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-trend-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-trend-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-trend-capital" type="number" min="0.01" step="any" value="1000" required />
@@ -746,10 +757,6 @@
<span>补仓档数</span>
<input id="calc-trend-dca-legs" type="number" min="1" max="20" step="1" value="5" />
</label>
<label class="calc-field">
<span>合约乘数</span>
<input id="calc-trend-contract-size" type="number" min="0.0001" step="any" value="1" title="USDT 线性合约默认 1" />
</label>
</div>
<div class="calc-actions">
<button type="submit" class="primary">计算</button>
@@ -763,6 +770,17 @@
<p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变。最多 3 次滚仓。</p>
<form id="calc-roll-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-roll-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-roll-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-roll-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-roll-capital" type="number" min="0.01" step="any" value="1000" required />