ba681c7a58
新增行情区单图与周期切换,K 线优先读 hub_kline.db,不足时经各实例 /api/hub/ohlcv 补齐;无后台定时更新。含回滚标签说明与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
9.1 KiB
JavaScript
300 lines
9.1 KiB
JavaScript
/**
|
|
* 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)。
|
|
*/
|
|
(function () {
|
|
const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"];
|
|
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 elSymLabel = document.getElementById("mkt-symbol-label");
|
|
const elTfLabel = document.getElementById("mkt-tf-label");
|
|
|
|
let chart = null;
|
|
let candleSeries = null;
|
|
let priceTick = null;
|
|
let rangeMarkers = [];
|
|
let lastCandles = [];
|
|
let chartMeta = null;
|
|
let loadToken = 0;
|
|
let marketInited = false;
|
|
|
|
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 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 = bar.open != null ? String(bar.open) : "-";
|
|
if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-";
|
|
if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-";
|
|
if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-";
|
|
if (elV) elV.textContent = fmtVol(bar.volume);
|
|
}
|
|
|
|
function ensureChart() {
|
|
if (chart && candleSeries) 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: { color: "#1a2838" }, horzLines: { color: "#1a2838" } },
|
|
rightPriceScale: { borderColor: "#2a4058" },
|
|
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
|
|
crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 },
|
|
});
|
|
const opts = {
|
|
upColor: "#00ff9d",
|
|
downColor: "#ff4d6d",
|
|
borderVisible: false,
|
|
wickUpColor: "#00ff9d",
|
|
wickDownColor: "#ff4d6d",
|
|
};
|
|
if (typeof chart.addCandlestickSeries === "function") {
|
|
candleSeries = chart.addCandlestickSeries(opts);
|
|
} else if (
|
|
typeof chart.addSeries === "function" &&
|
|
window.LightweightCharts &&
|
|
window.LightweightCharts.CandlestickSeries
|
|
) {
|
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
|
}
|
|
if (!candleSeries) return false;
|
|
|
|
chart.subscribeCrosshairMove(function (param) {
|
|
if (!param || !param.time || !param.seriesData) return;
|
|
const d = param.seriesData.get(candleSeries);
|
|
if (!d) return;
|
|
paintOhlcv({
|
|
open: d.open,
|
|
high: d.high,
|
|
low: d.low,
|
|
close: d.close,
|
|
volume: d.volume,
|
|
});
|
|
});
|
|
|
|
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 addRangeMarkers(data) {
|
|
clearMarkers();
|
|
if (!candleSeries || !data) return;
|
|
const hi = data.range_high;
|
|
const lo = data.range_low;
|
|
if (hi && hi.price != null) {
|
|
rangeMarkers.push(
|
|
candleSeries.createPriceLine({
|
|
price: Number(hi.price),
|
|
color: "#ffb84d",
|
|
lineWidth: 1,
|
|
lineStyle: 2,
|
|
axisLabelVisible: true,
|
|
title: "区间高",
|
|
})
|
|
);
|
|
}
|
|
if (lo && lo.price != null) {
|
|
rangeMarkers.push(
|
|
candleSeries.createPriceLine({
|
|
price: Number(lo.price),
|
|
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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
async function loadChart(force) {
|
|
if (!ensureChart()) return;
|
|
const exKey = (elExchange && elExchange.value) || "";
|
|
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
|
const tf = (elTf && elTf.value) || "5m";
|
|
if (!exKey || !sym) {
|
|
if (elStatus) {
|
|
elStatus.className = "market-status err";
|
|
elStatus.textContent = "请选择交易所并输入币种";
|
|
}
|
|
return;
|
|
}
|
|
const myToken = ++loadToken;
|
|
if (elStatus) {
|
|
elStatus.className = "market-status";
|
|
elStatus.textContent = "加载中…";
|
|
}
|
|
if (elSymLabel) elSymLabel.textContent = sym;
|
|
if (elTfLabel) elTfLabel.textContent = 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;
|
|
candleSeries.setData(data.candles);
|
|
chart.timeScale().fitContent();
|
|
addRangeMarkers(data);
|
|
|
|
const ohlcv = data.ohlcv || {};
|
|
paintOhlcv({
|
|
open: ohlcv.open,
|
|
high: ohlcv.high,
|
|
low: ohlcv.low,
|
|
close: ohlcv.close,
|
|
volume: ohlcv.volume,
|
|
});
|
|
|
|
let hint =
|
|
"已加载 " +
|
|
data.candles.length +
|
|
" 根(库 " +
|
|
(data.from_cache || 0) +
|
|
" / 新拉 " +
|
|
(data.fetched || 0) +
|
|
")· 保留 " +
|
|
(data.retention_days || 15) +
|
|
" 天";
|
|
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 () {
|
|
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);
|
|
});
|
|
}
|
|
|
|
window.hubMarketChart = {
|
|
init: async function () {
|
|
if (!marketInited) {
|
|
marketInited = true;
|
|
await loadMeta();
|
|
bind();
|
|
}
|
|
await loadChart(false);
|
|
},
|
|
reload: function (force) {
|
|
loadChart(!!force);
|
|
},
|
|
};
|
|
|
|
if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) {
|
|
window.hubMarketChart.init();
|
|
}
|
|
})();
|