Files
crypto_okx_hub/static/cond_orders.js
T

775 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function (global) {
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function leftTriggerSide(optType) {
return String(optType || "P").toUpperCase() === "C" ? "lte" : "gte";
}
function triggerHintText(optType, px) {
const put = String(optType || "P").toUpperCase() !== "C";
const price = px && String(px).trim() !== "" ? String(px) : "触发价";
if (put) {
return "看跌左侧单:指数上穿 " + price + " 后,在偏离区内开最近实值 Put。";
}
return "看涨左侧单:指数下穿 " + price + " 后,在偏离区内开最近实值 Call。";
}
function syncTriggerHint() {
const hint = document.getElementById("cf-trigger-hint");
const typeEl = document.getElementById("cf-opt-type");
const pxEl = document.getElementById("cf-trigger-px");
if (!hint || !typeEl) return;
hint.textContent = triggerHintText(typeEl.value, pxEl && pxEl.value);
hint.className = "cond-trigger-hint " + (typeEl.value === "C" ? "is-call" : "is-put");
}
function statusLabel(o) {
const st = o.status || "";
const reason = o.end_reason || "";
if (st === "done") {
const map = {
leave_up: "结束·上破",
leave_down: "结束·下破",
cancel: "已取消",
timeout: "时间取消",
closed: "已完成",
};
return map[reason] || "已结束";
}
const map2 = { armed: "待命", triggering: "触发中", open: "持仓中" };
return map2[st] || st || "—";
}
function statusClass(o) {
const st = o.status || "";
if (st === "open") return "st-open";
if (st === "triggering") return "st-trig";
if (st === "armed") return "st-armed";
return "st-done";
}
function isHubPhone() {
return (
document.documentElement.classList.contains("hub-phone-html") ||
!!(document.body && document.body.classList.contains("hub-phone"))
);
}
function orderTriggerText(o) {
const side = o.trigger_side === "lte" ? "下穿≤" : "上穿≥";
const px = o.trigger_px != null ? String(o.trigger_px) : "";
return side + px;
}
function orderExitText(o) {
const bits = [];
if (o.profit_exit_enabled) bits.push("翻倍×" + (o.profit_exit_mult || ""));
if (o.target_index != null) bits.push("目标" + o.target_index);
return bits.join(" · ");
}
function orderActionHtml(o) {
const canCancel = o.status === "armed" || o.status === "triggering";
const canExit = o.status === "open";
let actions = "";
if (canCancel) {
actions +=
'<button type="button" class="btn btn-sm btn-danger" data-cancel="' +
encodeURIComponent(o.hub_order_id || "") +
'" data-iid="' +
encodeURIComponent(String(o.instance_id || "")) +
'">取消</button>';
}
if (canExit) {
actions +=
'<button type="button" class="btn btn-sm" data-exit="' +
encodeURIComponent(o.hub_order_id || "") +
'" data-iid="' +
encodeURIComponent(String(o.instance_id || "")) +
'" data-target="' +
encodeURIComponent(o.target_index != null ? String(o.target_index) : "") +
'" data-pe="' +
(o.profit_exit_enabled ? "1" : "0") +
'" data-mult="' +
encodeURIComponent(String(o.profit_exit_mult || "1")) +
'">改出场</button>';
}
return actions;
}
function bindOrderActions(box) {
if (!box) return;
box.querySelectorAll("[data-cancel]").forEach(function (btn) {
btn.addEventListener("click", async function () {
if (!confirm("确认取消该条件单?")) return;
const oid = decodeURIComponent(btn.getAttribute("data-cancel") || "");
const iid = decodeURIComponent(btn.getAttribute("data-iid") || "");
const rr = await fetch("/api/cond_orders/" + encodeURIComponent(oid) + "/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance_id: Number(iid) }),
});
const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已取消" : "失败"));
requestCondRefresh();
});
});
box.querySelectorAll("[data-exit]").forEach(function (btn) {
btn.addEventListener("click", async function () {
const oid = decodeURIComponent(btn.getAttribute("data-exit") || "");
const iid = decodeURIComponent(btn.getAttribute("data-iid") || "");
const curT = decodeURIComponent(btn.getAttribute("data-target") || "");
const curM = decodeURIComponent(btn.getAttribute("data-mult") || "1");
const pe = btn.getAttribute("data-pe") === "1";
const t = prompt("目标指数(留空清除)", curT);
if (t === null) return;
const peOn = confirm("是否启用翻倍出场?\n当前:" + (pe ? "开" : "关"));
let mult = curM;
if (peOn) {
const m = prompt("翻倍倍数", curM);
if (m === null) return;
mult = m;
}
const rr = await fetch("/api/cond_orders/" + encodeURIComponent(oid) + "/exit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instance_id: Number(iid),
target_index: t === "" ? "" : t,
profit_exit_enabled: peOn,
profit_exit_mult: mult,
}),
});
const dd = await rr.json();
alert(dd.msg || (dd.ok ? "已更新" : "失败"));
requestCondRefresh();
});
});
}
function isActiveOrder(o) {
const st = String((o && o.status) || "");
return st === "armed" || st === "triggering" || st === "open";
}
function formatOpenTime(o) {
const raw = String((o && o.opened_at) || "").trim();
if (!raw) return "—";
return raw.replace("T", " ").slice(0, 19);
}
function renderPhoneOrderCard(o) {
const exit = orderExitText(o);
const meta = [];
if (o.deviation_pct != null) meta.push("偏离 " + o.deviation_pct + "%");
if (o.min_expiry_hours != null) meta.push("到期 " + o.min_expiry_hours + "h");
if (exit) meta.push(exit);
const actions = orderActionHtml(o);
return (
'<article class="hub-phone-cond">' +
'<div class="hub-phone-cond-head">' +
"<strong>" +
escapeHtml(o.instance_name || "") +
"</strong>" +
'<span class="cond-st ' +
statusClass(o) +
'">' +
escapeHtml(statusLabel(o)) +
"</span></div>" +
'<div class="hub-phone-cond-main">' +
escapeHtml((o.underlying || "") + " " + (o.opt_type === "C" ? "Call" : "Put")) +
" · " +
escapeHtml(orderTriggerText(o)) +
"</div>" +
(meta.length ? '<div class="hub-phone-cond-meta muted">' + escapeHtml(meta.join(" · ")) + "</div>" : "") +
'<div class="hub-phone-cond-meta muted">开仓 ' +
escapeHtml(formatOpenTime(o)) +
"</div>" +
(o.message ? '<div class="hub-phone-cond-msg muted">' + escapeHtml(o.message) + "</div>" : "") +
(actions ? '<div class="hub-phone-cond-actions">' + actions + "</div>" : "") +
"</article>"
);
}
function instanceMode(it) {
const m = String((it && it.mode) || "").toLowerCase();
return m === "sim" || m === "live" ? m : "";
}
var candidatesById = {};
function fmtTradingU(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
return n.toFixed(2) + "U";
}
function openInstanceTab(instanceId, tab) {
if (!instanceId) return;
const q = tab ? "?tab=" + encodeURIComponent(tab) : "";
fetch("/api/instances/" + encodeURIComponent(instanceId) + "/open_url" + q)
.then(function (r) {
return r.json();
})
.then(function (d) {
const url =
d && d.url
? d.url
: "/desk?id=" + encodeURIComponent(instanceId);
window.open(url, "_blank", "noopener,noreferrer");
})
.catch(function () {
window.open(
"/desk?id=" + encodeURIComponent(instanceId),
"_blank",
"noopener,noreferrer"
);
});
}
function patchCandidateFunds(instanceId, tradingUsdt, fundingUsdt) {
const id = String(instanceId);
const it = candidatesById[id];
if (it) {
if (tradingUsdt != null) it.trading_usdt = tradingUsdt;
if (fundingUsdt != null) it.funding_usdt = fundingUsdt;
}
const row = document.querySelector(
'.cond-inst-row[data-inst-id="' + id.replace(/"/g, "") + '"]'
);
if (!row) return;
const tradeEl = row.querySelector(".cond-inst-funds--trading");
const fundEl = row.querySelector(".cond-inst-funds--funding");
if (tradeEl) tradeEl.textContent = "交易 " + fmtTradingU(tradingUsdt);
if (fundEl) fundEl.textContent = "资金 " + fmtTradingU(fundingUsdt);
}
function transferInternal(instanceId, direction) {
const id = String(instanceId || "");
const it = candidatesById[id];
if (!it) return;
if (!it.online) {
window.alert("实例离线,无法划转");
return;
}
if (it.transfer_blocked) {
window.alert(it.transfer_block_msg || "当前不可划转");
return;
}
const toTrading = direction !== "to_funding";
const fromAcct = toTrading ? "funding" : "trading";
const toAcct = toTrading ? "trading" : "funding";
const srcBal = Number(toTrading ? it.funding_usdt : it.trading_usdt);
const defAmt =
!Number.isNaN(srcBal) && srcBal > 0
? String(Math.round(srcBal * 10000) / 10000)
: "";
const label = toTrading ? "资金户 → 交易户" : "交易户 → 资金户";
const raw = window.prompt(label + "(USDT)。本页划转,条件单表单不会丢失。", defAmt);
if (raw == null) return;
const amount = Number(String(raw).trim());
if (!(amount > 0)) {
window.alert("请输入大于 0 的数量");
return;
}
const btns = document.querySelectorAll(
'.cond-inst-xfer[data-inst-id="' + id.replace(/"/g, "") + '"]'
);
const labels = [];
btns.forEach(function (btn) {
labels.push(btn.textContent);
btn.disabled = true;
btn.textContent = "划转中…";
});
fetch("/api/instances/" + encodeURIComponent(id) + "/transfer", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
amount: amount,
from: fromAcct,
to: toAcct,
ccy: "USDT",
}),
})
.then(function (r) {
return r.json().then(function (d) {
return { http: r.status, d: d };
});
})
.then(function (pack) {
const d = pack.d || {};
if (!d.ok) {
window.alert(d.msg || "划转失败");
return;
}
patchCandidateFunds(id, d.trading_usdt, d.funding_usdt);
const st = document.getElementById("cf-status");
if (st) {
st.textContent =
(it.name || id) +
" " +
label +
" " +
fmtTradingU(amount) +
"(交易 " +
fmtTradingU(d.trading_usdt) +
" / 资金 " +
fmtTradingU(d.funding_usdt) +
")";
}
})
.catch(function () {
window.alert("划转请求失败");
})
.finally(function () {
const blocked = !!(it.transfer_blocked || !it.online);
btns.forEach(function (btn, i) {
btn.disabled = blocked;
btn.textContent = labels[i] || btn.getAttribute("data-label") || "划转";
});
});
}
function renderCandidateCard(it, checkedIds) {
const disabled = !it.selectable;
const tip = disabled
? it.has_position
? "已有持仓"
: it.cond_order_locked
? "条件单占用"
: it.online
? "不可选"
: "离线"
: "可选";
const id = String(it.id);
const checked = !disabled && checkedIds.indexOf(id) >= 0;
const xferBlocked = !!(it.transfer_blocked || !it.online);
const xferTitle = xferBlocked
? it.transfer_block_msg || (!it.online ? "离线" : "不可划转")
: "本页双向划转,表单不丢";
const dis = xferBlocked ? " disabled" : "";
return (
'<div class="cond-inst-row' +
(disabled ? " is-off" : "") +
'" data-inst-id="' +
escapeHtml(id) +
'">' +
'<div class="cond-inst' +
(disabled ? " is-off" : "") +
'">' +
'<label class="cond-inst-head" title="' +
escapeHtml(tip) +
'">' +
'<input type="checkbox" name="cf-inst" value="' +
encodeURIComponent(id) +
'"' +
(disabled ? " disabled" : "") +
(checked ? " checked" : "") +
">" +
'<span class="cond-inst-name">' +
escapeHtml(it.name || "") +
"</span>" +
'<span class="cond-inst-tag">' +
escapeHtml(tip) +
"</span>" +
"</label>" +
'<div class="cond-inst-body">' +
'<span class="cond-inst-funds cond-inst-funds--trading" title="交易账户可用">交易 ' +
escapeHtml(fmtTradingU(it.trading_usdt)) +
"</span>" +
'<span class="cond-inst-funds cond-inst-funds--funding muted" title="资金账户可用">资金 ' +
escapeHtml(fmtTradingU(it.funding_usdt)) +
"</span>" +
'<span class="cond-inst-actions">' +
'<button type="button" class="btn btn-sm cond-inst-xfer" data-inst-id="' +
escapeHtml(id) +
'" data-dir="to_trading" data-label="划入交易" title="' +
escapeHtml(xferTitle) +
'"' +
dis +
">划入交易</button>" +
'<button type="button" class="btn btn-secondary btn-sm cond-inst-xfer" data-inst-id="' +
escapeHtml(id) +
'" data-dir="to_funding" data-label="划入资金" title="' +
escapeHtml(xferTitle) +
'"' +
dis +
">划入资金</button>" +
'<button type="button" class="btn btn-secondary btn-sm cond-inst-open" data-inst-id="' +
escapeHtml(id) +
'" title="新标签打开实例">打开</button>' +
"</span>" +
"</div>" +
"</div>" +
"</div>"
);
}
function renderCandidateGroup(mode, items, checkedIds) {
if (!items.length) return "";
const label =
mode === "sim" || mode === "live"
? '<div class="cond-inst-group-label"><span class="overview-mode-tag overview-mode-tag--' +
mode +
'">' +
mode +
"</span></div>"
: "";
return (
'<div class="cond-inst-group">' +
label +
'<div class="cond-inst-grid">' +
items
.map(function (it) {
return renderCandidateCard(it, checkedIds);
})
.join("") +
"</div></div>"
);
}
async function loadCandidates() {
const box = document.getElementById("cf-instances");
if (!box) return;
const checkedIds = Array.prototype.slice
.call(box.querySelectorAll('input[name="cf-inst"]:checked'))
.map(function (el) {
return decodeURIComponent(el.value || "");
});
const r = await fetch("/api/cond_orders/candidates");
const d = await r.json();
const list = d.instances || [];
if (!list.length) {
box.innerHTML = '<span class="muted">暂无启用实例</span>';
return;
}
const sim = [];
const live = [];
const other = [];
candidatesById = {};
list.forEach(function (it) {
const m = instanceMode(it);
candidatesById[String(it.id)] = it;
if (m === "sim") sim.push(it);
else if (m === "live") live.push(it);
else other.push(it);
});
box.innerHTML =
renderCandidateGroup("sim", sim, checkedIds) +
renderCandidateGroup("live", live, checkedIds) +
renderCandidateGroup("", other, checkedIds);
box.querySelectorAll(".cond-inst-xfer").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
transferInternal(
btn.getAttribute("data-inst-id") || "",
btn.getAttribute("data-dir") || "to_trading"
);
});
});
box.querySelectorAll(".cond-inst-open").forEach(function (btn) {
btn.addEventListener("click", function (ev) {
ev.preventDefault();
ev.stopPropagation();
openInstanceTab(btn.getAttribute("data-inst-id") || "", "options");
});
});
}
function renderDesktopRows(list) {
return list
.map(function (o) {
return (
"<tr>" +
"<td>" +
escapeHtml(o.instance_name || "") +
"</td>" +
'<td><span class="cond-st ' +
statusClass(o) +
'">' +
escapeHtml(statusLabel(o)) +
"</span></td>" +
"<td>" +
escapeHtml((o.underlying || "") + " " + (o.opt_type || "")) +
"</td>" +
"<td>" +
escapeHtml(orderTriggerText(o).replace("≤", " ≤ ").replace("≥", " ≥ ")) +
"</td>" +
"<td>" +
escapeHtml(o.deviation_pct != null ? o.deviation_pct + "%" : "—") +
"</td>" +
"<td>" +
escapeHtml(o.min_expiry_hours != null ? String(o.min_expiry_hours) : "—") +
"</td>" +
"<td>" +
escapeHtml(orderExitText(o) || "—") +
"</td>" +
'<td class="cond-td-time">' +
escapeHtml(formatOpenTime(o)) +
"</td>" +
'<td class="muted">' +
escapeHtml(o.message || "") +
"</td>" +
'<td><div class="overview-actions">' +
orderActionHtml(o) +
"</div></td>" +
"</tr>"
);
})
.join("");
}
var PAGE_SIZE = 10;
var listPages = { active: 1, done: 1 };
var lastActive = [];
var lastDone = [];
function pageInfo(list, page) {
const total = list.length;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
const p = Math.min(Math.max(1, page || 1), pages);
const start = (p - 1) * PAGE_SIZE;
return { page: p, pages: pages, total: total, slice: list.slice(start, start + PAGE_SIZE) };
}
function renderPager(key, info) {
if (info.total <= PAGE_SIZE) return "";
return (
'<div class="cond-pager">' +
'<button type="button" class="btn btn-sm" data-cond-page="' +
key +
'" data-dir="-1"' +
(info.page <= 1 ? " disabled" : "") +
">上一页</button>" +
'<span class="muted">第 ' +
info.page +
" / " +
info.pages +
" 页 · 共 " +
info.total +
" 条</span>" +
'<button type="button" class="btn btn-sm" data-cond-page="' +
key +
'" data-dir="1"' +
(info.page >= info.pages ? " disabled" : "") +
">下一页</button></div>"
);
}
function renderDesktopTable(list, emptyText) {
if (!list.length) {
return '<div class="cond-empty">' + escapeHtml(emptyText) + "</div>";
}
return (
'<div class="overview-table-wrap cond-table-wrap"><table class="overview-table"><thead><tr>' +
"<th>客户端</th><th>状态</th><th>标的</th><th>触发</th><th>偏离</th><th>到期h</th><th>出场</th><th>开仓时间</th><th>说明</th><th>操作</th>" +
"</tr></thead><tbody>" +
renderDesktopRows(list) +
"</tbody></table></div>"
);
}
function renderPhoneList(list, emptyText) {
if (!list.length) {
return '<div class="cond-empty">' + escapeHtml(emptyText) + "</div>";
}
return '<div class="hub-phone-cond-list">' + list.map(renderPhoneOrderCard).join("") + "</div>";
}
function fillOrderBox(box, list, emptyText, key) {
if (!box) return;
const info = pageInfo(list, listPages[key] || 1);
listPages[key] = info.page;
const body = isHubPhone()
? renderPhoneList(info.slice, emptyText)
: renderDesktopTable(info.slice, emptyText);
box.innerHTML = body + renderPager(key, info);
bindOrderActions(box);
box.querySelectorAll("[data-cond-page]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.disabled) return;
const k = btn.getAttribute("data-cond-page");
const dir = Number(btn.getAttribute("data-dir") || 0);
listPages[k] = (listPages[k] || 1) + dir;
paintCachedOrders();
});
});
}
function paintCachedOrders() {
fillOrderBox(document.getElementById("cond-list-active"), lastActive, "暂无进行中的条件单", "active");
fillOrderBox(document.getElementById("cond-list-done"), lastDone, "暂无已结束的条件单", "done");
}
let condVersion = 0;
let condLoading = false;
let condEventSource = null;
let condReconnectTimer = null;
function setCondStatus(msg) {
const el = document.getElementById("cond-status");
if (el) el.textContent = msg || "";
}
async function loadOrders(opts) {
opts = opts || {};
const activeBox = document.getElementById("cond-list-active");
const doneBox = document.getElementById("cond-list-done");
if (!activeBox && !doneBox) return;
const r = await fetch("/api/cond_orders", { credentials: "same-origin" });
const d = await r.json();
const list = d.orders || [];
condVersion = Number(d.cond_version) || condVersion;
lastActive = [];
lastDone = [];
list.forEach(function (o) {
if (isActiveOrder(o)) lastActive.push(o);
else lastDone.push(o);
});
paintCachedOrders();
if (!opts.silent) {
const live = condEventSource && condEventSource.readyState === 1;
const sec = Number(d.poll_interval_sec) || 10;
setCondStatus(
(live ? "SSE 已连接 · " : "") +
"进行中 " +
lastActive.length +
" · 已结束 " +
lastDone.length +
(d.updated_at ? " · " + d.updated_at : "") +
(d.refreshing ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉")
);
}
}
async function createOrder() {
const status = document.getElementById("cf-status");
const ids = Array.prototype.slice
.call(document.querySelectorAll('input[name="cf-inst"]:checked'))
.map(function (el) {
return Number(decodeURIComponent(el.value));
});
if (!ids.length) {
alert("请选择至少一台可选客户端");
return;
}
const optType = document.getElementById("cf-opt-type").value;
const body = {
instance_ids: ids,
underlying: document.getElementById("cf-underlying").value,
opt_type: optType,
trigger_side: leftTriggerSide(optType),
trigger_px: document.getElementById("cf-trigger-px").value,
deviation_pct: document.getElementById("cf-deviation").value,
min_expiry_hours: document.getElementById("cf-expiry-h").value,
target_index: document.getElementById("cf-target").value,
profit_exit_enabled: document.getElementById("cf-pe").checked,
profit_exit_mult: document.getElementById("cf-pe-mult").value,
};
if (status) status.textContent = "下发中…";
const r = await fetch("/api/cond_orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json();
if (status) status.textContent = d.msg || "";
alert(d.msg || (d.ok ? "已下发" : "失败"));
if (d.ok) {
const tog = document.getElementById("cond-add-toggle");
if (tog) tog.checked = false;
}
requestCondRefresh();
}
async function refresh(opts) {
opts = opts || {};
if (condLoading) return;
condLoading = true;
if (!opts.silent) setCondStatus("读取缓存…");
try {
await Promise.all([loadCandidates(), loadOrders({ silent: !!opts.silent })]);
} finally {
condLoading = false;
}
}
async function requestCondRefresh() {
try {
await fetch("/api/cond_orders/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
refresh({ silent: true });
}
function closeCondStream() {
if (condEventSource) {
condEventSource.close();
condEventSource = null;
}
if (condReconnectTimer) {
clearTimeout(condReconnectTimer);
condReconnectTimer = null;
}
}
function connectCondStream() {
if (typeof EventSource === "undefined") return;
closeCondStream();
condEventSource = new EventSource("/api/cond_orders/stream");
condEventSource.addEventListener("cond", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.cond_version) || 0;
if (ver && ver !== condVersion) {
refresh({ silent: true });
} else if (st.refreshing) {
setCondStatus("后台并行拉取条件单…");
}
} catch (_) {}
});
condEventSource.onerror = function () {
closeCondStream();
setCondStatus("SSE 断开,8s 后重连…");
condReconnectTimer = setTimeout(function () {
connectCondStream();
refresh({ silent: true });
}, 8000);
};
}
function boot() {
const btn = document.getElementById("btn-cond-refresh");
if (btn) btn.addEventListener("click", function () {
requestCondRefresh();
});
const create = document.getElementById("btn-cond-create");
if (create) create.addEventListener("click", createOrder);
const typeEl = document.getElementById("cf-opt-type");
const pxEl = document.getElementById("cf-trigger-px");
if (typeEl) typeEl.addEventListener("change", syncTriggerHint);
if (pxEl) pxEl.addEventListener("input", syncTriggerHint);
syncTriggerHint();
// 读缓存秒开 + SSE;不再前端定时现场拉实例
refresh();
connectCondStream();
}
global.HubCondOrders = {
boot: boot,
refresh: refresh,
requestRefresh: requestCondRefresh,
};
})(window);