Files
crypto_monitor/manual_trading_hub/static/chart.js
T

468 lines
14 KiB
JavaScript

/**
* 中控行情区:K 线 + 成交量;默认最新 OHLCV;60s 自动刷新;价格轴「自动」。
*/
(function () {
const AUTO_REFRESH_MS = 60000;
const chartHost = document.getElementById("market-chart");
if (!chartHost) return;
const elExchange = document.getElementById("market-exchange");
const elSymbol = document.getElementById("market-symbol");
const elTf = document.getElementById("market-timeframe");
const elRefresh = document.getElementById("market-refresh");
const elStatus = document.getElementById("market-status");
const elUpdated = document.getElementById("market-updated");
const elO = document.getElementById("mkt-o");
const elH = document.getElementById("mkt-h");
const elL = document.getElementById("mkt-l");
const elC = document.getElementById("mkt-c");
const elV = document.getElementById("mkt-v");
const elExLabel = document.getElementById("mkt-exchange-label");
const elExBadge = document.getElementById("market-exchange-badge");
const elSymLabel = document.getElementById("mkt-symbol-label");
const elTfLabel = document.getElementById("mkt-tf-label");
const elPriceAuto = document.getElementById("market-price-auto");
let chart = null;
let candleSeries = null;
let volumeSeries = null;
let priceTick = null;
let priceAutoScale = true;
let rangeMarkers = [];
let lastCandles = [];
let candleByTime = {};
let chartMeta = null;
let loadToken = 0;
let marketInited = false;
let refreshTimer = null;
function fmtVol(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(2) + "K";
return n.toFixed(2);
}
function fmtPrice(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n === 0) return "0";
const tick = priceTick;
if (tick && tick > 0) {
let decimals = tick >= 1 ? 0 : Math.max(0, Math.min(12, Math.round(-Math.log10(tick))));
let text = n.toFixed(decimals);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
let text = n.toFixed(d);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
function exchangeLabel() {
if (!elExchange) return "";
const opt = elExchange.options[elExchange.selectedIndex];
if (opt && opt.textContent) return opt.textContent.trim();
return (elExchange.value || "").trim().toUpperCase();
}
function updateExchangeDisplay() {
const label = exchangeLabel();
if (elExLabel) elExLabel.textContent = label;
if (elExBadge) {
elExBadge.textContent = label;
elExBadge.setAttribute("aria-hidden", label ? "false" : "true");
}
}
function updateHeaderLabels(sym, tf) {
if (elSymLabel) elSymLabel.textContent = sym || "—";
if (elTfLabel) elTfLabel.textContent = tf || "—";
updateExchangeDisplay();
}
function paintOhlcv(bar) {
if (!bar) {
["o", "h", "l", "c", "v"].forEach(function (k) {
const el = { o: elO, h: elH, l: elL, c: elC, v: elV }[k];
if (el) el.textContent = "-";
});
return;
}
if (elO) elO.textContent = fmtPrice(bar.open);
if (elH) elH.textContent = fmtPrice(bar.high);
if (elL) elL.textContent = fmtPrice(bar.low);
if (elC) elC.textContent = fmtPrice(bar.close);
if (elV) elV.textContent = fmtVol(bar.volume);
}
function latestCandle() {
return lastCandles.length ? lastCandles[lastCandles.length - 1] : null;
}
function showLatestOhlcv() {
paintOhlcv(latestCandle());
}
function applyPriceAutoScale() {
if (!chart) return;
chart.priceScale("right").applyOptions({ autoScale: priceAutoScale });
if (elPriceAuto) elPriceAuto.classList.toggle("is-on", priceAutoScale);
}
function indexCandles(candles) {
candleByTime = {};
(candles || []).forEach(function (c) {
if (c && c.time != null) candleByTime[c.time] = c;
});
}
function candleAtTime(t) {
if (t == null) return null;
return candleByTime[t] || null;
}
function buildVolumeData(candles) {
return (candles || []).map(function (c) {
const up = Number(c.close) >= Number(c.open);
return {
time: c.time,
value: Number(c.volume) || 0,
color: up ? "rgba(0, 255, 157, 0.5)" : "rgba(255, 77, 109, 0.5)",
};
});
}
function ensureChart() {
if (chart && candleSeries && volumeSeries) return true;
if (!window.LightweightCharts) {
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = "图表库加载失败";
}
return false;
}
chart = LightweightCharts.createChart(chartHost, {
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
grid: {
vertLines: { visible: false },
horzLines: { visible: false },
},
rightPriceScale: { borderColor: "#2a4058", autoScale: true },
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
crosshair: {
mode: LightweightCharts.CrosshairMode
? LightweightCharts.CrosshairMode.Normal
: 0,
},
});
const candleOpts = {
upColor: "#00ff9d",
downColor: "#ff4d6d",
borderVisible: false,
wickUpColor: "#00ff9d",
wickDownColor: "#ff4d6d",
};
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(candleOpts);
} else if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts);
}
if (!candleSeries) return false;
const volOpts = {
priceFormat: { type: "volume" },
priceScaleId: "volume",
lastValueVisible: false,
};
if (typeof chart.addHistogramSeries === "function") {
volumeSeries = chart.addHistogramSeries(volOpts);
} else if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.HistogramSeries
) {
volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts);
}
if (!volumeSeries) return false;
chart.priceScale("right").applyOptions({
scaleMargins: { top: 0.06, bottom: 0.28 },
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: 0.78, bottom: 0 },
});
applyPriceAutoScale();
chart.subscribeCrosshairMove(function (param) {
if (!param || param.time == null) {
showLatestOhlcv();
return;
}
const bar = candleAtTime(param.time);
if (!bar) {
showLatestOhlcv();
return;
}
paintOhlcv(bar);
});
chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
updateVisibleRangeMarkers();
});
window.addEventListener("resize", function () {
if (!chart) return;
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
});
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
return true;
}
function clearMarkers() {
rangeMarkers.forEach(function (m) {
try {
candleSeries.removePriceLine(m);
} catch (e) {}
});
rangeMarkers = [];
}
function updateVisibleRangeMarkers() {
clearMarkers();
if (!candleSeries || !chart || !lastCandles.length) return;
const range = chart.timeScale().getVisibleLogicalRange();
if (!range) return;
const from = Math.max(0, Math.floor(range.from));
const to = Math.min(lastCandles.length - 1, Math.ceil(range.to));
if (to < from) return;
let hi = null;
let lo = null;
for (let i = from; i <= to; i++) {
const c = lastCandles[i];
if (!c) continue;
if (!hi || c.high > hi.high) hi = c;
if (!lo || c.low < lo.low) lo = c;
}
if (!hi || !lo) return;
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(hi.high),
color: "#ffb84d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "高点",
})
);
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(lo.low),
color: "#4cd97f",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "低点",
})
);
}
function readQuery() {
const qs = new URLSearchParams(window.location.search);
const ex = qs.get("exchange_key") || qs.get("exchange") || "";
const sym = qs.get("symbol") || "";
const tf = qs.get("timeframe") || "";
if (ex && elExchange) elExchange.value = ex;
if (sym && elSymbol) elSymbol.value = sym;
if (tf && elTf) elTf.value = tf;
}
function applyDefaults() {
if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT";
if (elTf && !elTf.value) elTf.value = "1d";
}
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
loadChart(false);
}, AUTO_REFRESH_MS);
}
function stopAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
async function loadMeta() {
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
chartMeta = await r.json();
if (!elExchange || !chartMeta.exchanges) return;
elExchange.innerHTML = "";
chartMeta.exchanges.forEach(function (ex) {
const opt = document.createElement("option");
opt.value = ex.key || ex.id;
opt.textContent = ex.name || ex.key;
elExchange.appendChild(opt);
});
readQuery();
applyDefaults();
updateExchangeDisplay();
}
async function loadChart(force) {
if (!ensureChart()) return;
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) {
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = "请选择交易所并输入币种";
}
return;
}
const myToken = ++loadToken;
if (elStatus) {
elStatus.className = "market-status";
elStatus.textContent = "加载中…";
}
updateHeaderLabels(sym, tf);
const qs = new URLSearchParams({
exchange_key: exKey,
symbol: sym,
timeframe: tf,
});
if (force) qs.set("refresh", "1");
try {
const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
const data = await r.json();
if (myToken !== loadToken) return;
if (!r.ok) {
throw new Error(data.detail || data.msg || "请求失败");
}
if (!data.ok || !data.candles || !data.candles.length) {
throw new Error(data.msg || "无 K 线");
}
priceTick = data.price_tick;
lastCandles = data.candles;
indexCandles(lastCandles);
candleSeries.setData(lastCandles);
volumeSeries.setData(buildVolumeData(lastCandles));
chart.timeScale().fitContent();
applyPriceAutoScale();
updateVisibleRangeMarkers();
showLatestOhlcv();
const limit = data.limit || lastCandles.length;
let hint =
"已加载 " +
data.candles.length +
" 根(目标 " +
limit +
")· 库 " +
(data.from_cache || 0) +
" / 新拉 " +
(data.fetched || 0) +
")· 每 60s 刷新";
if (data.stale && data.stale_message) {
hint += " · 缓存:" + data.stale_message;
}
if (elStatus) {
elStatus.className = data.stale ? "market-status warn" : "market-status";
elStatus.textContent = hint;
}
if (elUpdated) elUpdated.textContent = data.updated_at || "--";
} catch (e) {
if (myToken !== loadToken) return;
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = String(e.message || e);
}
}
}
function bind() {
if (elRefresh) {
elRefresh.addEventListener("click", function () {
loadChart(true);
});
}
if (elTf) {
elTf.addEventListener("change", function () {
loadChart(false);
});
}
if (elExchange) {
elExchange.addEventListener("change", function () {
updateExchangeDisplay();
loadChart(false);
});
}
if (elSymbol) {
elSymbol.addEventListener("keydown", function (e) {
if (e.key === "Enter") loadChart(false);
});
}
const btnLoad = document.getElementById("market-load");
if (btnLoad) {
btnLoad.addEventListener("click", function () {
loadChart(false);
});
}
if (elPriceAuto) {
elPriceAuto.addEventListener("click", function () {
priceAutoScale = !priceAutoScale;
applyPriceAutoScale();
});
}
}
window.hubMarketChart = {
init: async function () {
if (!marketInited) {
marketInited = true;
await loadMeta();
bind();
}
startAutoRefresh();
await loadChart(false);
},
reload: function (force) {
loadChart(!!force);
},
startAutoRefresh: startAutoRefresh,
stopAutoRefresh: stopAutoRefresh,
};
if (
document.getElementById("page-market") &&
!document.getElementById("page-market").classList.contains("hidden")
) {
window.hubMarketChart.init();
}
})();