93b84da72e
Co-authored-by: Cursor <cursoragent@cursor.com>
2993 lines
93 KiB
JavaScript
2993 lines
93 KiB
JavaScript
/**
|
||
* 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 直推尾部 K 线;「自动」控制价格轴与视口跟随。
|
||
*/
|
||
(function () {
|
||
const CHART_WATCH_HEARTBEAT_MS = 25000;
|
||
const CHART_SSE_FALLBACK_MS = 60000;
|
||
const DEFAULT_VISIBLE_BARS = 200;
|
||
const CHART_LOAD_LEFT_THRESHOLD = 25;
|
||
const CHART_INITIAL_LIMITS = {
|
||
"1m": 2000,
|
||
"5m": 2000,
|
||
"15m": 2000,
|
||
"1h": 1000,
|
||
"2h": 1000,
|
||
"4h": 1000,
|
||
"1d": 500,
|
||
"1w": 500,
|
||
};
|
||
const CHART_CHUNK_LIMITS = {
|
||
"1m": 500,
|
||
"5m": 500,
|
||
"15m": 500,
|
||
"1h": 300,
|
||
"2h": 300,
|
||
"4h": 300,
|
||
"1d": 200,
|
||
"1w": 150,
|
||
};
|
||
const CHART_MEMORY_CAPS = {
|
||
"1m": 5000,
|
||
"5m": 5000,
|
||
"15m": 5000,
|
||
"1h": 1000,
|
||
"2h": 1000,
|
||
"4h": 1000,
|
||
"1d": 1000,
|
||
"1w": 500,
|
||
};
|
||
const RIGHT_OFFSET_BARS = 10;
|
||
const CANDLE_SCALE_BOTTOM = 0.26;
|
||
const VOLUME_SCALE_TOP = 0.73;
|
||
const VOLUME_SCALE_BOTTOM = 0.06;
|
||
const PANEL_VOL_H = 0.12;
|
||
const PANEL_MACD_H = 0.14;
|
||
const PANEL_RSI_H = 0.14;
|
||
const SWING_LOOKBACK = 4;
|
||
const MAX_DIV_MARKERS = 4;
|
||
const TF_MS = {
|
||
"1m": 60_000,
|
||
"5m": 5 * 60_000,
|
||
"15m": 15 * 60_000,
|
||
"1h": 60 * 60_000,
|
||
"2h": 2 * 60 * 60_000,
|
||
"4h": 4 * 60 * 60_000,
|
||
"1d": 24 * 60 * 60_000,
|
||
"1w": 7 * 24 * 60 * 60_000,
|
||
};
|
||
const TF_BY_MINUTES = {
|
||
"1": "1m",
|
||
"5": "5m",
|
||
"15": "15m",
|
||
"60": "1h",
|
||
"120": "2h",
|
||
"240": "4h",
|
||
"1440": "1d",
|
||
"10080": "1w",
|
||
};
|
||
const TF_MINUTE_KEYS = Object.keys(TF_BY_MINUTES).sort(function (a, b) {
|
||
return b.length - a.length;
|
||
});
|
||
const TF_CN_LABEL = {
|
||
"1m": "1分钟",
|
||
"5m": "5分钟",
|
||
"15m": "15分钟",
|
||
"1h": "1小时",
|
||
"2h": "2小时",
|
||
"4h": "4小时",
|
||
"1d": "日线",
|
||
"1w": "周线",
|
||
};
|
||
const TF_DIGIT_TIMEOUT_MS = 650;
|
||
const chartHost = document.getElementById("market-chart");
|
||
if (!chartHost) return;
|
||
|
||
const elDrawToolbar = document.getElementById("market-draw-toolbar");
|
||
const elDrawCanvas = document.getElementById("market-draw-canvas");
|
||
const elChartMain = chartHost.closest(".market-chart-main");
|
||
let drawAttached = false;
|
||
|
||
const elExchange = document.getElementById("market-exchange");
|
||
const elSymbol = document.getElementById("market-symbol");
|
||
const elVolRankMeta = document.getElementById("market-vol-rank-meta");
|
||
const elVolRankList = document.getElementById("market-vol-rank-list");
|
||
const elVolRankDetails = document.getElementById("market-vol-rank");
|
||
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 elBarCountdown = document.getElementById("market-bar-countdown");
|
||
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 elAmp = document.getElementById("mkt-amp");
|
||
const elPriceTag = document.getElementById("market-price-tag");
|
||
const elPriceTagValue = document.getElementById("market-price-tag-value");
|
||
const elPriceTagTime = document.getElementById("market-price-tag-time");
|
||
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");
|
||
const elPosPanel = document.getElementById("market-pos-panel");
|
||
const elPosSide = document.getElementById("mkt-pos-side");
|
||
const elPosEntry = document.getElementById("mkt-pos-entry");
|
||
const elPosSl = document.getElementById("mkt-pos-sl");
|
||
const elPosTp = document.getElementById("mkt-pos-tp");
|
||
const elPosSize = document.getElementById("mkt-pos-size");
|
||
const elPosPnl = document.getElementById("mkt-pos-pnl");
|
||
const elPosOrders = document.getElementById("market-pos-orders");
|
||
const elPosClear = document.getElementById("market-pos-clear");
|
||
const elChartWrap = document.getElementById("market-chart-wrap");
|
||
const elFsBtn = document.getElementById("market-chart-fullscreen");
|
||
const elFsExit = document.getElementById("market-chart-fs-exit");
|
||
const elIndEma = document.getElementById("market-ind-ema");
|
||
const elIndMacd = document.getElementById("market-ind-macd");
|
||
const elIndRsi = document.getElementById("market-ind-rsi");
|
||
const elFsToolbar = document.getElementById("market-fs-toolbar");
|
||
const elFsExchange = document.getElementById("market-fs-exchange");
|
||
const elFsSymbol = document.getElementById("market-fs-symbol");
|
||
const elFsTf = document.getElementById("market-fs-timeframe");
|
||
const elFsLoad = document.getElementById("market-fs-load");
|
||
const elDivLegend = document.getElementById("market-div-legend");
|
||
|
||
const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext";
|
||
const EMA_FAST = 21;
|
||
const EMA_SLOW = 55;
|
||
|
||
let chartFullscreen = false;
|
||
const indicatorState = { ema: false, macd: false, rsi: false };
|
||
const indSeries = {
|
||
ema21: null,
|
||
ema55: null,
|
||
macdLine: null,
|
||
macdSignal: null,
|
||
macdHist: null,
|
||
rsi: null,
|
||
rsi30: null,
|
||
rsi70: null,
|
||
};
|
||
let divergenceMarkers = [];
|
||
|
||
let chart = null;
|
||
let candleSeries = null;
|
||
let volumeSeries = null;
|
||
let priceTick = null;
|
||
let priceAutoScale = true;
|
||
let rangeMarkers = [];
|
||
let positionLines = [];
|
||
let posContext = null;
|
||
let posPnlTimer = null;
|
||
const SL_DRAG_HIT_PX = 12;
|
||
let slDrag = null;
|
||
let currentPriceLine = null;
|
||
let lastCandles = [];
|
||
let candleByTime = {};
|
||
let chartMeta = null;
|
||
let loadToken = 0;
|
||
let marketInited = false;
|
||
let refreshTimer = null;
|
||
let chartWatchTimer = null;
|
||
let chartEventSource = null;
|
||
let chartSseReconnectTimer = null;
|
||
let localChartVersion = 0;
|
||
let localSeriesVersion = 0;
|
||
let lastViewKey = "";
|
||
let currentTf = "1d";
|
||
let exhaustedLeft = false;
|
||
let loadingLeft = false;
|
||
let chartDataLoading = false;
|
||
let chartViewEpoch = 0;
|
||
let rangeUiTimer = null;
|
||
let loadOlderTimer = null;
|
||
let chartRangeUserLocked = false;
|
||
let chartRangeLockTimer = null;
|
||
let suppressRangeUserLock = false;
|
||
const CHART_TAIL_REFRESH_LIMIT = 30;
|
||
let priceTagTimer = null;
|
||
let tfDigitBuf = "";
|
||
let tfDigitTimer = null;
|
||
let tfHintTimer = null;
|
||
|
||
function escHtml(s) {
|
||
return String(s || "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function normalizeMarketSymbol(sym) {
|
||
const s = String(sym || "").trim().toUpperCase();
|
||
const m = s.match(/^([A-Z0-9]+)\/([A-Z0-9]+)(?::([A-Z0-9]+))?$/);
|
||
if (!m) return s;
|
||
return m[1] + "/" + m[2];
|
||
}
|
||
|
||
function loadPosContextFromStorage() {
|
||
try {
|
||
const raw = sessionStorage.getItem(HUB_MARKET_POS_CTX_KEY);
|
||
if (!raw) return null;
|
||
return JSON.parse(raw);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function posContextMatches(ctx, exKey, sym) {
|
||
if (!ctx) return false;
|
||
const ctxSym = normalizeMarketSymbol(ctx.symbol || "");
|
||
const ctxEx = String(ctx.exchange_key || "").trim();
|
||
return ctxSym === normalizeMarketSymbol(sym) && ctxEx === String(exKey || "").trim();
|
||
}
|
||
|
||
function clearPosPanel() {
|
||
if (elPosPanel) elPosPanel.classList.add("hidden");
|
||
if (elPosSide) {
|
||
elPosSide.textContent = "";
|
||
elPosSide.className = "market-pos-side";
|
||
}
|
||
["entry", "sl", "tp", "size"].forEach(function (k) {
|
||
const el = { entry: elPosEntry, sl: elPosSl, tp: elPosTp, size: elPosSize }[k];
|
||
if (el) el.textContent = "—";
|
||
});
|
||
if (elPosPnl) {
|
||
elPosPnl.textContent = "—";
|
||
elPosPnl.className = "market-pos-pnl";
|
||
}
|
||
if (elPosOrders) elPosOrders.innerHTML = "";
|
||
syncChartWrapLayout();
|
||
}
|
||
|
||
function ensureDrawLayer() {
|
||
if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return;
|
||
window.HubChartDraw.attach({
|
||
chart: chart,
|
||
series: candleSeries,
|
||
hostEl: chartHost,
|
||
mainEl: elChartMain,
|
||
canvasEl: elDrawCanvas,
|
||
toolbarEl: elDrawToolbar,
|
||
getCandles: function () {
|
||
return lastCandles;
|
||
},
|
||
});
|
||
window.HubChartDraw.setViewKey(currentChartViewKey());
|
||
drawAttached = true;
|
||
}
|
||
|
||
function syncDrawViewKey() {
|
||
if (window.HubChartDraw && drawAttached) {
|
||
window.HubChartDraw.setViewKey(currentChartViewKey());
|
||
}
|
||
}
|
||
|
||
function resizeChart() {
|
||
if (!chart || !chartHost) return;
|
||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||
updatePriceTag();
|
||
if (window.HubChartDraw && drawAttached) {
|
||
window.HubChartDraw.resize();
|
||
}
|
||
}
|
||
|
||
let resizeChartRaf = 0;
|
||
function scheduleChartResize() {
|
||
if (resizeChartRaf) cancelAnimationFrame(resizeChartRaf);
|
||
resizeChartRaf = requestAnimationFrame(function () {
|
||
resizeChartRaf = 0;
|
||
syncChartWrapLayout();
|
||
});
|
||
}
|
||
|
||
function syncChartWrapLayout() {
|
||
const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
|
||
if (wrap && elPosPanel && !chartFullscreen) {
|
||
wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden"));
|
||
}
|
||
resizeChart();
|
||
}
|
||
|
||
function readIndicatorState() {
|
||
indicatorState.ema = !!(elIndEma && elIndEma.checked);
|
||
indicatorState.macd = !!(elIndMacd && elIndMacd.checked);
|
||
indicatorState.rsi = !!(elIndRsi && elIndRsi.checked);
|
||
}
|
||
|
||
function emaArray(values, period) {
|
||
const result = new Array(values.length).fill(null);
|
||
const k = 2 / (period + 1);
|
||
let ema = null;
|
||
for (let i = 0; i < values.length; i++) {
|
||
const v = values[i];
|
||
if (v == null || !Number.isFinite(v)) continue;
|
||
if (ema == null) {
|
||
if (i < period - 1) continue;
|
||
let sum = 0;
|
||
let ok = true;
|
||
for (let j = i - period + 1; j <= i; j++) {
|
||
const x = values[j];
|
||
if (x == null || !Number.isFinite(x)) {
|
||
ok = false;
|
||
break;
|
||
}
|
||
sum += x;
|
||
}
|
||
if (!ok) continue;
|
||
ema = sum / period;
|
||
} else {
|
||
ema = v * k + ema * (1 - k);
|
||
}
|
||
result[i] = ema;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function buildEmaSeries(candles, period) {
|
||
const closes = candles.map(function (c) {
|
||
return Number(c.close);
|
||
});
|
||
const vals = emaArray(closes, period);
|
||
const out = [];
|
||
for (let i = 0; i < candles.length; i++) {
|
||
if (vals[i] == null) continue;
|
||
out.push({ time: candles[i].time, value: vals[i] });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function buildMacdData(candles) {
|
||
const closes = candles.map(function (c) {
|
||
return Number(c.close);
|
||
});
|
||
const ema12 = emaArray(closes, 12);
|
||
const ema26 = emaArray(closes, 26);
|
||
const macd = new Array(closes.length).fill(null);
|
||
for (let i = 0; i < closes.length; i++) {
|
||
if (ema12[i] == null || ema26[i] == null) continue;
|
||
macd[i] = ema12[i] - ema26[i];
|
||
}
|
||
const signal = emaArray(macd, 9);
|
||
const macdLine = [];
|
||
const signalLine = [];
|
||
const histData = [];
|
||
for (let i = 0; i < candles.length; i++) {
|
||
const t = candles[i].time;
|
||
if (macd[i] != null) macdLine.push({ time: t, value: macd[i] });
|
||
if (signal[i] != null) signalLine.push({ time: t, value: signal[i] });
|
||
if (macd[i] != null && signal[i] != null) {
|
||
const h = macd[i] - signal[i];
|
||
histData.push({
|
||
time: t,
|
||
value: h,
|
||
color: h >= 0 ? "rgba(0, 255, 157, 0.55)" : "rgba(255, 77, 109, 0.55)",
|
||
});
|
||
}
|
||
}
|
||
return { macdLine, signalLine, histData };
|
||
}
|
||
|
||
function buildRsiSeries(candles, period) {
|
||
const out = [];
|
||
if (!candles || candles.length < period + 1) return out;
|
||
let avgGain = 0;
|
||
let avgLoss = 0;
|
||
for (let i = 1; i <= period; i++) {
|
||
const ch = Number(candles[i].close) - Number(candles[i - 1].close);
|
||
if (ch >= 0) avgGain += ch;
|
||
else avgLoss -= ch;
|
||
}
|
||
avgGain /= period;
|
||
avgLoss /= period;
|
||
let rsi = 50;
|
||
if (avgLoss <= 0) rsi = 100;
|
||
else if (avgGain <= 0) rsi = 0;
|
||
else rsi = 100 - 100 / (1 + avgGain / avgLoss);
|
||
out.push({ time: candles[period].time, value: rsi });
|
||
|
||
for (let i = period + 1; i < candles.length; i++) {
|
||
const ch = Number(candles[i].close) - Number(candles[i - 1].close);
|
||
const gain = ch > 0 ? ch : 0;
|
||
const loss = ch < 0 ? -ch : 0;
|
||
avgGain = (avgGain * (period - 1) + gain) / period;
|
||
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||
if (avgLoss <= 0) rsi = 100;
|
||
else if (avgGain <= 0) rsi = 0;
|
||
else rsi = 100 - 100 / (1 + avgGain / avgLoss);
|
||
out.push({ time: candles[i].time, value: rsi });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function createLineSeries(opts) {
|
||
if (!chart) return null;
|
||
const base = {
|
||
lineWidth: 1,
|
||
priceLineVisible: false,
|
||
lastValueVisible: false,
|
||
};
|
||
const o = Object.assign(base, opts || {});
|
||
if (typeof chart.addLineSeries === "function") return chart.addLineSeries(o);
|
||
if (
|
||
typeof chart.addSeries === "function" &&
|
||
window.LightweightCharts &&
|
||
window.LightweightCharts.LineSeries
|
||
) {
|
||
return chart.addSeries(window.LightweightCharts.LineSeries, o);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function createHistSeries(opts) {
|
||
if (!chart) return null;
|
||
const base = { priceLineVisible: false, lastValueVisible: false };
|
||
const o = Object.assign(base, opts || {});
|
||
if (typeof chart.addHistogramSeries === "function") return chart.addHistogramSeries(o);
|
||
if (
|
||
typeof chart.addSeries === "function" &&
|
||
window.LightweightCharts &&
|
||
window.LightweightCharts.HistogramSeries
|
||
) {
|
||
return chart.addSeries(window.LightweightCharts.HistogramSeries, o);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function clearIndicatorSeries() {
|
||
if (!chart) return;
|
||
[indSeries.rsi30, indSeries.rsi70].forEach(function (pl) {
|
||
if (pl && indSeries.rsi) {
|
||
try {
|
||
indSeries.rsi.removePriceLine(pl);
|
||
} catch (e) {}
|
||
}
|
||
});
|
||
indSeries.rsi30 = null;
|
||
indSeries.rsi70 = null;
|
||
Object.keys(indSeries).forEach(function (k) {
|
||
if (k === "rsi30" || k === "rsi70") return;
|
||
if (indSeries[k]) {
|
||
try {
|
||
chart.removeSeries(indSeries[k]);
|
||
} catch (e) {}
|
||
indSeries[k] = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function findSwings(values, lookback) {
|
||
const lows = [];
|
||
const highs = [];
|
||
const lb = lookback || SWING_LOOKBACK;
|
||
for (let i = lb; i < values.length - lb; i++) {
|
||
const v = values[i];
|
||
if (v == null || !Number.isFinite(v)) continue;
|
||
let isLow = true;
|
||
let isHigh = true;
|
||
for (let j = 1; j <= lb; j++) {
|
||
const lv = values[i - j];
|
||
const rv = values[i + j];
|
||
if (lv == null || rv == null || v > lv || v > rv) isLow = false;
|
||
if (lv == null || rv == null || v < lv || v < rv) isHigh = false;
|
||
}
|
||
if (isLow) lows.push({ i: i, v: v });
|
||
if (isHigh) highs.push({ i: i, v: v });
|
||
}
|
||
return { lows, highs };
|
||
}
|
||
|
||
function detectDivergences(candles, indicatorByIndex, sourceLabel) {
|
||
const markers = [];
|
||
if (!candles.length || !indicatorByIndex.length) return markers;
|
||
|
||
const closes = candles.map(function (c) {
|
||
return Number(c.close);
|
||
});
|
||
const priceSw = findSwings(closes, SWING_LOOKBACK);
|
||
const indSw = findSwings(indicatorByIndex, SWING_LOOKBACK);
|
||
|
||
function pushMarker(idx, kind, label) {
|
||
const c = candles[idx];
|
||
if (!c || c.time == null) return;
|
||
const bull = kind === "bull";
|
||
markers.push({
|
||
time: c.time,
|
||
position: bull ? "belowBar" : "aboveBar",
|
||
color: bull ? "#00ff9d" : "#ff4d6d",
|
||
shape: bull ? "arrowUp" : "arrowDown",
|
||
text: label,
|
||
});
|
||
}
|
||
|
||
const pLows = priceSw.lows;
|
||
const iLows = indSw.lows;
|
||
if (pLows.length >= 2 && iLows.length >= 2) {
|
||
const p1 = pLows[pLows.length - 2];
|
||
const p2 = pLows[pLows.length - 1];
|
||
const i1 = iLows[iLows.length - 2];
|
||
const i2 = iLows[iLows.length - 1];
|
||
if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) {
|
||
if (p2.v < p1.v && i2.v > i1.v) {
|
||
pushMarker(p2.i, "bull", sourceLabel + "底背离");
|
||
}
|
||
}
|
||
}
|
||
|
||
const pHighs = priceSw.highs;
|
||
const iHighs = indSw.highs;
|
||
if (pHighs.length >= 2 && iHighs.length >= 2) {
|
||
const p1 = pHighs[pHighs.length - 2];
|
||
const p2 = pHighs[pHighs.length - 1];
|
||
const i1 = iHighs[iHighs.length - 2];
|
||
const i2 = iHighs[iHighs.length - 1];
|
||
if (Math.abs(p1.i - i1.i) < 30 && Math.abs(p2.i - i2.i) < 30) {
|
||
if (p2.v > p1.v && i2.v < i1.v) {
|
||
pushMarker(p2.i, "bear", sourceLabel + "顶背离");
|
||
}
|
||
}
|
||
}
|
||
|
||
return markers.slice(-MAX_DIV_MARKERS);
|
||
}
|
||
|
||
function buildRsiByIndex(candles, period) {
|
||
const series = buildRsiSeries(candles, period);
|
||
const byIdx = new Array(candles.length).fill(null);
|
||
let si = 0;
|
||
for (let i = 0; i < candles.length; i++) {
|
||
if (si < series.length && series[si].time === candles[i].time) {
|
||
byIdx[i] = series[si].value;
|
||
si++;
|
||
}
|
||
}
|
||
return { series, byIdx };
|
||
}
|
||
|
||
function buildMacdByIndex(candles) {
|
||
const closes = candles.map(function (c) {
|
||
return Number(c.close);
|
||
});
|
||
const ema12 = emaArray(closes, 12);
|
||
const ema26 = emaArray(closes, 26);
|
||
const macd = new Array(closes.length).fill(null);
|
||
for (let i = 0; i < closes.length; i++) {
|
||
if (ema12[i] == null || ema26[i] == null) continue;
|
||
macd[i] = ema12[i] - ema26[i];
|
||
}
|
||
return macd;
|
||
}
|
||
|
||
function panelLayout() {
|
||
const rsiOn = indicatorState.rsi;
|
||
const macdOn = indicatorState.macd;
|
||
if (!rsiOn && !macdOn) {
|
||
return {
|
||
candle: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM },
|
||
volume: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM },
|
||
macd: null,
|
||
rsi: null,
|
||
};
|
||
}
|
||
|
||
const gap = 0.02;
|
||
let stackBottom = gap;
|
||
let rsiMargins = null;
|
||
let macdMargins = null;
|
||
|
||
if (rsiOn) {
|
||
rsiMargins = {
|
||
top: 1 - stackBottom - PANEL_RSI_H,
|
||
bottom: stackBottom,
|
||
};
|
||
stackBottom += PANEL_RSI_H;
|
||
}
|
||
if (macdOn) {
|
||
macdMargins = {
|
||
top: 1 - stackBottom - PANEL_MACD_H,
|
||
bottom: stackBottom,
|
||
};
|
||
stackBottom += PANEL_MACD_H;
|
||
}
|
||
|
||
const volBottom = stackBottom;
|
||
const volTop = 1 - volBottom - PANEL_VOL_H;
|
||
const candleBottom = Math.max(CANDLE_SCALE_BOTTOM, 1 - volTop + 0.01);
|
||
|
||
return {
|
||
candle: { top: 0.06, bottom: candleBottom },
|
||
volume: { top: volTop, bottom: volBottom },
|
||
macd: macdMargins,
|
||
rsi: rsiMargins,
|
||
};
|
||
}
|
||
|
||
function applyScaleLayout() {
|
||
if (!chart) return;
|
||
const L = panelLayout();
|
||
chart.priceScale("right").applyOptions({
|
||
scaleMargins: L.candle,
|
||
});
|
||
if (volumeSeries && volumeSeries.priceScale) {
|
||
volumeSeries.priceScale().applyOptions({
|
||
scaleMargins: L.volume,
|
||
borderColor: "#2a4058",
|
||
});
|
||
}
|
||
if (indSeries.macdLine && indSeries.macdLine.priceScale) {
|
||
indSeries.macdLine.priceScale().applyOptions({
|
||
scaleMargins: L.macd,
|
||
borderColor: "#2a4058",
|
||
autoScale: true,
|
||
});
|
||
}
|
||
if (indSeries.rsi && indSeries.rsi.priceScale) {
|
||
indSeries.rsi.priceScale().applyOptions({
|
||
scaleMargins: L.rsi,
|
||
borderColor: "#2a4058",
|
||
autoScale: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateDivergenceLegend(rsiDiv, macdDiv) {
|
||
if (!elDivLegend) return;
|
||
const parts = [];
|
||
if (indicatorState.rsi && rsiDiv.length) {
|
||
parts.push("RSI " + rsiDiv.map(function (m) { return m.text; }).join(" · "));
|
||
}
|
||
if (indicatorState.macd && macdDiv.length) {
|
||
parts.push("MACD " + macdDiv.map(function (m) { return m.text; }).join(" · "));
|
||
}
|
||
if (!parts.length) {
|
||
elDivLegend.textContent = "";
|
||
elDivLegend.classList.add("hidden");
|
||
return;
|
||
}
|
||
elDivLegend.textContent = parts.join(" | ");
|
||
elDivLegend.classList.remove("hidden");
|
||
}
|
||
|
||
function applyCandleDivergenceMarkers() {
|
||
if (!candleSeries || !candleSeries.setMarkers) return;
|
||
const sorted = divergenceMarkers
|
||
.slice()
|
||
.sort(function (a, b) {
|
||
return a.time > b.time ? 1 : a.time < b.time ? -1 : 0;
|
||
});
|
||
candleSeries.setMarkers(sorted);
|
||
}
|
||
|
||
function updateIndicators() {
|
||
if (!chart || !lastCandles.length) return;
|
||
readIndicatorState();
|
||
clearIndicatorSeries();
|
||
divergenceMarkers = [];
|
||
|
||
if (indicatorState.ema) {
|
||
const pf = tickToPriceFormat(priceTick);
|
||
indSeries.ema21 = createLineSeries({
|
||
color: "#f0c040",
|
||
title: "EMA21",
|
||
priceScaleId: "right",
|
||
priceFormat: pf,
|
||
});
|
||
indSeries.ema55 = createLineSeries({
|
||
color: "#c878ff",
|
||
title: "EMA55",
|
||
priceScaleId: "right",
|
||
priceFormat: pf,
|
||
});
|
||
if (indSeries.ema21) indSeries.ema21.setData(buildEmaSeries(lastCandles, EMA_FAST));
|
||
if (indSeries.ema55) indSeries.ema55.setData(buildEmaSeries(lastCandles, EMA_SLOW));
|
||
}
|
||
|
||
let rsiDiv = [];
|
||
let macdDiv = [];
|
||
|
||
if (indicatorState.macd) {
|
||
const macd = buildMacdData(lastCandles);
|
||
const macdByIdx = buildMacdByIndex(lastCandles);
|
||
indSeries.macdLine = createLineSeries({
|
||
color: "#5b9cf5",
|
||
title: "MACD",
|
||
priceScaleId: "macd",
|
||
priceLineVisible: false,
|
||
lastValueVisible: false,
|
||
});
|
||
indSeries.macdSignal = createLineSeries({
|
||
color: "#ffb84d",
|
||
title: "Signal",
|
||
priceScaleId: "macd",
|
||
priceLineVisible: false,
|
||
lastValueVisible: false,
|
||
});
|
||
indSeries.macdHist = createHistSeries({
|
||
priceScaleId: "macd",
|
||
priceLineVisible: false,
|
||
lastValueVisible: false,
|
||
});
|
||
if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine);
|
||
if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine);
|
||
if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData);
|
||
macdDiv = detectDivergences(lastCandles, macdByIdx, "MACD");
|
||
divergenceMarkers = divergenceMarkers.concat(macdDiv);
|
||
}
|
||
|
||
if (indicatorState.rsi) {
|
||
const rsiPack = buildRsiByIndex(lastCandles, 14);
|
||
indSeries.rsi = createLineSeries({
|
||
color: "#8fc8ff",
|
||
title: "RSI(14)",
|
||
priceScaleId: "rsi",
|
||
priceFormat: { type: "price", precision: 1, minMove: 0.1 },
|
||
priceLineVisible: false,
|
||
lastValueVisible: true,
|
||
});
|
||
if (indSeries.rsi) {
|
||
indSeries.rsi.setData(rsiPack.series);
|
||
try {
|
||
indSeries.rsi30 = indSeries.rsi.createPriceLine({
|
||
price: 30,
|
||
color: "rgba(255, 77, 109, 0.75)",
|
||
lineWidth: 1,
|
||
lineStyle: 2,
|
||
axisLabelVisible: true,
|
||
title: "30",
|
||
});
|
||
indSeries.rsi70 = indSeries.rsi.createPriceLine({
|
||
price: 70,
|
||
color: "rgba(0, 255, 157, 0.75)",
|
||
lineWidth: 1,
|
||
lineStyle: 2,
|
||
axisLabelVisible: true,
|
||
title: "70",
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
rsiDiv = detectDivergences(lastCandles, rsiPack.byIdx, "RSI");
|
||
divergenceMarkers = divergenceMarkers.concat(rsiDiv);
|
||
}
|
||
|
||
updateDivergenceLegend(rsiDiv, macdDiv);
|
||
applyCandleDivergenceMarkers();
|
||
applyScaleLayout();
|
||
scheduleChartResize();
|
||
}
|
||
|
||
function syncFsToolbarFromMain() {
|
||
if (!chartFullscreen) return;
|
||
if (elFsExchange && elExchange) elFsExchange.value = elExchange.value;
|
||
if (elFsSymbol && elSymbol) elFsSymbol.value = elSymbol.value;
|
||
if (elFsTf && elTf) elFsTf.value = elTf.value;
|
||
}
|
||
|
||
function syncMainFromFsToolbar() {
|
||
if (elExchange && elFsExchange) elExchange.value = elFsExchange.value;
|
||
if (elSymbol && elFsSymbol) elSymbol.value = elFsSymbol.value.trim().toUpperCase();
|
||
if (elTf && elFsTf) elTf.value = elFsTf.value;
|
||
updateExchangeDisplay();
|
||
updateHeaderLabels(elSymbol && elSymbol.value, elTf && elTf.value);
|
||
}
|
||
|
||
function isMarketPageActive() {
|
||
const page = document.getElementById("page-market");
|
||
return !!(page && !page.classList.contains("hidden"));
|
||
}
|
||
|
||
function isTypingInField(target) {
|
||
if (!target) return false;
|
||
const tag = (target.tagName || "").toLowerCase();
|
||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||
return !!target.isContentEditable;
|
||
}
|
||
|
||
function canUseTfKeyboard(e) {
|
||
if (!isMarketPageActive()) return false;
|
||
if (e.altKey || e.ctrlKey || e.metaKey) return false;
|
||
if (isTypingInField(e.target)) return false;
|
||
return true;
|
||
}
|
||
|
||
function canExtendTfDigitBuffer(buf) {
|
||
if (!buf) return false;
|
||
return TF_MINUTE_KEYS.some(function (k) {
|
||
return k.length > buf.length && k.indexOf(buf) === 0;
|
||
});
|
||
}
|
||
|
||
function shouldCommitTfBufferNow(buf) {
|
||
const tf = resolveTfFromDigitBuffer(buf);
|
||
if (!tf) return false;
|
||
return !canExtendTfDigitBuffer(buf);
|
||
}
|
||
|
||
function resolveTfFromDigitBuffer(buf) {
|
||
if (!buf) return null;
|
||
return TF_BY_MINUTES[buf] || null;
|
||
}
|
||
|
||
function flashTfSwitchHint(tf) {
|
||
const label = TF_CN_LABEL[tf] || tf;
|
||
const text = "周期 → " + label + "(" + tf + ")";
|
||
if (elTfLabel) elTfLabel.textContent = tf;
|
||
if (elBarCountdown) {
|
||
if (tfHintTimer) clearTimeout(tfHintTimer);
|
||
elBarCountdown.textContent = text;
|
||
elBarCountdown.classList.add("market-tf-key-hint");
|
||
tfHintTimer = setTimeout(function () {
|
||
tfHintTimer = null;
|
||
elBarCountdown.classList.remove("market-tf-key-hint");
|
||
tickLiveClock();
|
||
}, 1200);
|
||
return;
|
||
}
|
||
if (elStatus) {
|
||
if (tfHintTimer) clearTimeout(tfHintTimer);
|
||
const prevClass = elStatus.className;
|
||
const prevText = elStatus.textContent;
|
||
elStatus.className = "market-status";
|
||
elStatus.textContent = text;
|
||
tfHintTimer = setTimeout(function () {
|
||
tfHintTimer = null;
|
||
elStatus.className = prevClass;
|
||
elStatus.textContent = prevText;
|
||
}, 1200);
|
||
}
|
||
}
|
||
|
||
function applyTimeframe(tf, fromKeyboard) {
|
||
if (!tf || !TF_MS[tf]) return false;
|
||
const cur = (elTf && elTf.value) || currentTf;
|
||
if (cur === tf) return false;
|
||
if (elTf) elTf.value = tf;
|
||
if (elFsTf) elFsTf.value = tf;
|
||
currentTf = tf;
|
||
lastViewKey = "";
|
||
tickLiveClock();
|
||
updateHeaderLabels(
|
||
elSymbol && elSymbol.value.trim().toUpperCase(),
|
||
tf
|
||
);
|
||
syncFsToolbarFromMain();
|
||
if (fromKeyboard) flashTfSwitchHint(tf);
|
||
loadChart(false);
|
||
return true;
|
||
}
|
||
|
||
function commitTfDigitBuffer() {
|
||
const buf = tfDigitBuf;
|
||
tfDigitBuf = "";
|
||
if (tfDigitTimer) {
|
||
clearTimeout(tfDigitTimer);
|
||
tfDigitTimer = null;
|
||
}
|
||
const tf = resolveTfFromDigitBuffer(buf);
|
||
if (tf) applyTimeframe(tf, true);
|
||
}
|
||
|
||
function handleTfDigitKey(digit) {
|
||
if (!digit) return;
|
||
if (tfDigitBuf && !canExtendTfDigitBuffer(tfDigitBuf)) {
|
||
tfDigitBuf = "";
|
||
}
|
||
tfDigitBuf += digit;
|
||
if (shouldCommitTfBufferNow(tfDigitBuf)) {
|
||
commitTfDigitBuffer();
|
||
return;
|
||
}
|
||
if (!canExtendTfDigitBuffer(tfDigitBuf)) {
|
||
tfDigitBuf = digit;
|
||
if (shouldCommitTfBufferNow(tfDigitBuf)) {
|
||
commitTfDigitBuffer();
|
||
return;
|
||
}
|
||
}
|
||
if (tfDigitTimer) clearTimeout(tfDigitTimer);
|
||
tfDigitTimer = setTimeout(commitTfDigitBuffer, TF_DIGIT_TIMEOUT_MS);
|
||
}
|
||
|
||
function isChartFullscreenKey(e) {
|
||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false;
|
||
return e.code === "KeyF" || e.key === "f" || e.key === "F";
|
||
}
|
||
|
||
function onChartFullscreenKey(e) {
|
||
if (!isMarketPageActive() || !isChartFullscreenKey(e)) return;
|
||
if (isTypingInField(e.target)) return;
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
toggleChartFullscreen();
|
||
}
|
||
|
||
function focusMarketChartArea() {
|
||
const wrap = elChartWrap;
|
||
if (!wrap) return;
|
||
if (!wrap.hasAttribute("tabindex")) wrap.setAttribute("tabindex", "-1");
|
||
try {
|
||
wrap.focus({ preventScroll: true });
|
||
} catch (err) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function onMarketKeydown(e) {
|
||
if (!isMarketPageActive()) return;
|
||
|
||
if (e.key === "Escape" && chartFullscreen) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setChartFullscreen(false);
|
||
return;
|
||
}
|
||
|
||
if (!canUseTfKeyboard(e)) return;
|
||
if (e.key >= "0" && e.key <= "9") {
|
||
e.preventDefault();
|
||
handleTfDigitKey(e.key);
|
||
return;
|
||
}
|
||
if (e.key === "Enter" && tfDigitBuf) {
|
||
e.preventDefault();
|
||
commitTfDigitBuffer();
|
||
}
|
||
}
|
||
|
||
function populateFsExchangeOptions() {
|
||
if (!elFsExchange || !elExchange) return;
|
||
elFsExchange.innerHTML = elExchange.innerHTML;
|
||
elFsExchange.value = elExchange.value;
|
||
}
|
||
|
||
function setChartFullscreen(on) {
|
||
chartFullscreen = !!on;
|
||
const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
|
||
if (wrap) wrap.classList.toggle("is-fullscreen", chartFullscreen);
|
||
document.body.classList.toggle("market-chart-fs-open", chartFullscreen);
|
||
if (elFsToolbar) elFsToolbar.classList.toggle("hidden", !chartFullscreen);
|
||
if (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏";
|
||
if (elFsExit) {
|
||
if (chartFullscreen) elFsExit.classList.remove("hidden");
|
||
else elFsExit.classList.add("hidden");
|
||
}
|
||
if (chartFullscreen) {
|
||
populateFsExchangeOptions();
|
||
syncFsToolbarFromMain();
|
||
}
|
||
scheduleChartResize();
|
||
}
|
||
|
||
function toggleChartFullscreen() {
|
||
setChartFullscreen(!chartFullscreen);
|
||
}
|
||
|
||
function showHubToast(msg, isErr) {
|
||
const t = document.getElementById("toast");
|
||
if (!t) return;
|
||
t.textContent = msg;
|
||
t.classList.toggle("err", !!isErr);
|
||
t.classList.add("show");
|
||
clearTimeout(showHubToast._hideTimer);
|
||
showHubToast._hideTimer = setTimeout(function () {
|
||
t.classList.remove("show");
|
||
}, 3500);
|
||
}
|
||
|
||
function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) {
|
||
const e = Number(entry);
|
||
const m = Number(mark);
|
||
const c = Math.abs(Number(contracts));
|
||
let mult = Number(contractSize);
|
||
if (!Number.isFinite(mult) || mult <= 0) mult = 1;
|
||
if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) {
|
||
return null;
|
||
}
|
||
const diff =
|
||
(side || "long").toLowerCase() === "long" ? m - e : e - m;
|
||
return Math.round(diff * c * mult * 100) / 100;
|
||
}
|
||
|
||
function formatPosPnlText(ctx) {
|
||
const upnl = ctx && ctx.unrealized_pnl;
|
||
if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
|
||
const n = Number(upnl);
|
||
let text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||
const notional = ctx.notional_usdt;
|
||
const entry = Number(ctx.entry);
|
||
const contracts = Math.abs(Number(ctx.contracts));
|
||
const cs =
|
||
ctx.contract_size != null && Number(ctx.contract_size) > 0
|
||
? Number(ctx.contract_size)
|
||
: 1;
|
||
let pctBase = null;
|
||
if (notional != null && Math.abs(Number(notional)) > 1e-8) {
|
||
pctBase = Math.abs(Number(notional));
|
||
} else if (
|
||
Number.isFinite(entry) &&
|
||
entry > 0 &&
|
||
Number.isFinite(contracts) &&
|
||
contracts > 0
|
||
) {
|
||
pctBase = entry * contracts * cs;
|
||
}
|
||
if (pctBase != null && pctBase > 1e-8) {
|
||
const pct = (n / pctBase) * 100;
|
||
text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
|
||
} else if (ctx.plan_margin != null && Number(ctx.plan_margin) > 1e-8) {
|
||
const pct = (n / Number(ctx.plan_margin)) * 100;
|
||
text += " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%)";
|
||
}
|
||
return { text: text, cls: n > 0 ? "pnl-up" : n < 0 ? "pnl-down" : "" };
|
||
}
|
||
|
||
function findTrendFloatingPnl(row, sym, side) {
|
||
const hm = row.hub_monitor;
|
||
if (!hm || !Array.isArray(hm.trends)) return null;
|
||
for (let i = 0; i < hm.trends.length; i++) {
|
||
const t = hm.trends[i];
|
||
const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || "");
|
||
if (ts !== sym) continue;
|
||
if ((t.direction || "").toLowerCase() !== side) continue;
|
||
const fp = t.floating_pnl;
|
||
if (fp != null && Number.isFinite(Number(fp))) return Number(fp);
|
||
if (t.plan_margin_capital != null && Number(t.plan_margin_capital) > 0) {
|
||
/* 保留 plan_margin 供百分比 */
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findTrendPlan(row, sym, side) {
|
||
const hm = row.hub_monitor;
|
||
if (!hm || !Array.isArray(hm.trends)) return null;
|
||
for (let i = 0; i < hm.trends.length; i++) {
|
||
const t = hm.trends[i];
|
||
const ts = normalizeMarketSymbol(t.exchange_symbol || t.symbol || "");
|
||
if (ts !== sym) continue;
|
||
if ((t.direction || "").toLowerCase() !== side) continue;
|
||
return t;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function applyTrendPlanFields(row, sym, side) {
|
||
if (!posContext) return;
|
||
const t = findTrendPlan(row, sym, side);
|
||
if (!t) return;
|
||
const m = t.plan_margin_capital;
|
||
if (m != null && Number.isFinite(Number(m)) && Number(m) > 0) {
|
||
posContext.plan_margin = Number(m);
|
||
}
|
||
const lev = t.leverage;
|
||
if (lev != null && Number.isFinite(Number(lev)) && Number(lev) > 0) {
|
||
posContext.leverage = Number(lev);
|
||
}
|
||
}
|
||
|
||
/** U 本位线性永续:(标记价-开仓价)×张数×contractSize(空头取反) */
|
||
function calcContractsUpnl(ctx, markPx) {
|
||
if (!ctx || markPx == null || !Number.isFinite(Number(markPx))) return null;
|
||
return estimateLinearSwapUpnl(
|
||
ctx.side,
|
||
ctx.entry,
|
||
markPx,
|
||
ctx.contracts,
|
||
ctx.contract_size
|
||
);
|
||
}
|
||
|
||
function latestChartMarkPrice() {
|
||
if (!lastCandles || !lastCandles.length) return null;
|
||
const bar = lastCandles[lastCandles.length - 1];
|
||
const c = bar && bar.close != null ? Number(bar.close) : null;
|
||
return c != null && Number.isFinite(c) && c > 0 ? c : null;
|
||
}
|
||
|
||
function updateLivePosPnl(markOverride) {
|
||
if (!posContext) return false;
|
||
const mark =
|
||
markOverride != null && Number.isFinite(Number(markOverride))
|
||
? Number(markOverride)
|
||
: latestChartMarkPrice() ||
|
||
(posContext.mark_price != null && Number.isFinite(Number(posContext.mark_price))
|
||
? Number(posContext.mark_price)
|
||
: null);
|
||
if (mark == null) return false;
|
||
const live = calcContractsUpnl(posContext, mark);
|
||
if (live != null) {
|
||
posContext.unrealized_pnl = live;
|
||
posContext.mark_price = mark;
|
||
renderPosPnlDisplay(posContext);
|
||
return true;
|
||
}
|
||
if (
|
||
posContext.unrealized_pnl != null &&
|
||
Number.isFinite(Number(posContext.unrealized_pnl))
|
||
) {
|
||
posContext.mark_price = mark;
|
||
renderPosPnlDisplay(posContext);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function syncPosTpslFromAgentPosition(p) {
|
||
if (!posContext || !p) return;
|
||
const et = p.exchange_tpsl;
|
||
if (et && typeof et === "object") {
|
||
if (et.sl && et.sl.trigger_price != null) {
|
||
posContext.stop_loss = Number(et.sl.trigger_price);
|
||
}
|
||
if (et.tp && et.tp.trigger_price != null) {
|
||
posContext.take_profit = Number(et.tp.trigger_price);
|
||
posContext.tp_monitored = false;
|
||
}
|
||
}
|
||
const cond = Array.isArray(p.conditional_orders) ? p.conditional_orders : [];
|
||
for (let i = 0; i < cond.length; i++) {
|
||
const o = cond[i];
|
||
const lbl = String(o.label || "");
|
||
const px =
|
||
o.trigger_price != null && Number.isFinite(Number(o.trigger_price))
|
||
? Number(o.trigger_price)
|
||
: null;
|
||
if (px == null) continue;
|
||
if (/^止损/.test(lbl)) posContext.stop_loss = px;
|
||
else if (/^止盈/.test(lbl) && !/止盈止损/.test(lbl)) {
|
||
posContext.take_profit = px;
|
||
posContext.tp_monitored = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderPosPnlDisplay(ctx) {
|
||
if (!elPosPnl) return;
|
||
const p = formatPosPnlText(ctx);
|
||
elPosPnl.textContent = p.text;
|
||
elPosPnl.className = "market-pos-pnl " + p.cls;
|
||
}
|
||
|
||
function paintPosPnl(ctx) {
|
||
if (ctx === posContext && updateLivePosPnl()) return;
|
||
renderPosPnlDisplay(ctx);
|
||
}
|
||
|
||
function stopPosPnlPoll() {
|
||
if (posPnlTimer) {
|
||
clearInterval(posPnlTimer);
|
||
posPnlTimer = null;
|
||
}
|
||
}
|
||
|
||
function startPosPnlPoll() {
|
||
stopPosPnlPoll();
|
||
if (!posContext || !posContext.exchange_id) return;
|
||
refreshPosPnlFromBoard();
|
||
posPnlTimer = setInterval(function () {
|
||
if (!updateLivePosPnl()) refreshPosPnlFromBoard();
|
||
}, 2000);
|
||
}
|
||
|
||
async function refreshPosPnlFromBoard() {
|
||
if (!posContext || !posContext.exchange_id) return;
|
||
try {
|
||
const r = await fetch("/api/monitor/board/snapshot", { credentials: "same-origin" });
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
const rows = data.rows || [];
|
||
const sym = normalizeMarketSymbol(posContext.symbol || "");
|
||
const side = (posContext.side || "long").toLowerCase();
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i];
|
||
const ex = row.exchange || {};
|
||
if (ex.id !== posContext.exchange_id) continue;
|
||
applyTrendPlanFields(row, sym, side);
|
||
const positions = (row.agent && row.agent.positions) || [];
|
||
for (let j = 0; j < positions.length; j++) {
|
||
const p = positions[j];
|
||
if ((p.side || "").toLowerCase() !== side) continue;
|
||
if (normalizeMarketSymbol(p.symbol || "") !== sym) continue;
|
||
if (p.entry_price != null && Number.isFinite(Number(p.entry_price))) {
|
||
posContext.entry = Number(p.entry_price);
|
||
}
|
||
if (p.contract_size != null && Number.isFinite(Number(p.contract_size))) {
|
||
posContext.contract_size = Number(p.contract_size);
|
||
}
|
||
if (p.contracts != null && Number.isFinite(Number(p.contracts))) {
|
||
posContext.contracts = Number(p.contracts);
|
||
}
|
||
if (p.mark_price != null && Number.isFinite(Number(p.mark_price))) {
|
||
posContext.mark_price = Number(p.mark_price);
|
||
}
|
||
if (p.notional_usdt != null && Number.isFinite(Number(p.notional_usdt))) {
|
||
posContext.notional_usdt = Number(p.notional_usdt);
|
||
}
|
||
syncPosTpslFromAgentPosition(p);
|
||
if (elPosSl && posContext.stop_loss != null) {
|
||
elPosSl.textContent = fmtPrice(posContext.stop_loss);
|
||
}
|
||
if (elPosTp && posContext.take_profit != null && !posContext.tp_monitored) {
|
||
elPosTp.textContent = fmtPrice(posContext.take_profit);
|
||
}
|
||
const markForPnl =
|
||
latestChartMarkPrice() ||
|
||
(p.mark_price != null && Number.isFinite(Number(p.mark_price))
|
||
? Number(p.mark_price)
|
||
: null);
|
||
if (!updateLivePosPnl(markForPnl)) {
|
||
let upnl =
|
||
p.unrealized_pnl != null && Number.isFinite(Number(p.unrealized_pnl))
|
||
? Number(p.unrealized_pnl)
|
||
: findTrendFloatingPnl(row, sym, side);
|
||
if (upnl != null) {
|
||
posContext.unrealized_pnl = upnl;
|
||
renderPosPnlDisplay(posContext);
|
||
}
|
||
}
|
||
updatePositionLines();
|
||
try {
|
||
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
applyTrendPlanFields(row, sym, side);
|
||
if (!updateLivePosPnl()) {
|
||
const trendUpnl = findTrendFloatingPnl(row, sym, side);
|
||
if (trendUpnl != null) {
|
||
posContext.unrealized_pnl = trendUpnl;
|
||
renderPosPnlDisplay(posContext);
|
||
}
|
||
}
|
||
try {
|
||
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
|
||
} catch (_) {}
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function resolveTpForPlace(ctx) {
|
||
if (!ctx) return null;
|
||
const tp = ctx.take_profit;
|
||
if (tp != null && Number(tp) > 0) return Number(tp);
|
||
const orders = ctx.orders || [];
|
||
for (let i = 0; i < orders.length; i++) {
|
||
const o = orders[i];
|
||
const lbl = String(o.label || "");
|
||
if (/止盈/.test(lbl) && o.price != null && Number(o.price) > 0) return Number(o.price);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function placeTpslFromChart(newSl) {
|
||
if (!posContext || !posContext.exchange_id) {
|
||
showHubToast("缺少交易所信息,无法挂单", true);
|
||
return;
|
||
}
|
||
const sl = roundToTick(newSl);
|
||
if (sl == null || !Number.isFinite(sl) || sl <= 0) {
|
||
showHubToast("止损价无效", true);
|
||
return;
|
||
}
|
||
const tp = resolveTpForPlace(posContext);
|
||
if (tp == null || tp <= 0) {
|
||
showHubToast("未找到有效止盈价,请先在监控区用「委托」填写止盈", true);
|
||
return;
|
||
}
|
||
const sym = normalizeMarketSymbol(posContext.symbol || "");
|
||
const side = posContext.side || "long";
|
||
const contracts = posContext.contracts;
|
||
const oldSl = posContext.stop_loss;
|
||
if (
|
||
!confirm(
|
||
"确认 " +
|
||
sym +
|
||
" " +
|
||
side +
|
||
"\n先撤销全部条件单,再挂止损 " +
|
||
fmtPrice(sl) +
|
||
"、止盈 " +
|
||
fmtPrice(tp) +
|
||
(oldSl != null ? "\n(原止损 " + fmtPrice(oldSl) + ")" : "")
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
try {
|
||
const r = await fetch(
|
||
"/api/orders/" + encodeURIComponent(posContext.exchange_id) + "/place-tpsl",
|
||
{
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
symbol: sym,
|
||
side: side,
|
||
stop_loss: sl,
|
||
take_profit: tp,
|
||
contracts: contracts > 0 ? contracts : null,
|
||
}),
|
||
}
|
||
);
|
||
const j = await r.json();
|
||
const pl = j.payload || {};
|
||
const ok = j.ok && pl.ok !== false;
|
||
showHubToast(
|
||
ok ? "止损已更新(已撤旧条件单并重新挂单)" : pl.error || JSON.stringify(j),
|
||
!ok
|
||
);
|
||
if (ok) {
|
||
posContext.stop_loss = sl;
|
||
try {
|
||
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(posContext));
|
||
} catch (_) {}
|
||
if (elPosSl) elPosSl.textContent = fmtPrice(sl);
|
||
updatePositionLines();
|
||
fetch("/api/monitor/board/refresh", { method: "POST", credentials: "same-origin" });
|
||
}
|
||
} catch (e) {
|
||
showHubToast(String(e.message || e), true);
|
||
}
|
||
}
|
||
|
||
function slLineCoordinate() {
|
||
if (!candleSeries || !posContext) return null;
|
||
const px =
|
||
slDrag && slDrag.active && slDrag.previewSl != null
|
||
? slDrag.previewSl
|
||
: posContext.stop_loss;
|
||
if (px == null || !Number.isFinite(Number(px))) return null;
|
||
return candleSeries.priceToCoordinate(roundToTick(px));
|
||
}
|
||
|
||
function clientYToChartPrice(clientY) {
|
||
if (!candleSeries || !chartHost) return null;
|
||
const rect = chartHost.getBoundingClientRect();
|
||
const y = clientY - rect.top;
|
||
const p = candleSeries.coordinateToPrice(y);
|
||
if (p == null || !Number.isFinite(Number(p))) return null;
|
||
return roundToTick(p);
|
||
}
|
||
|
||
function isPointerNearSlLine(clientY) {
|
||
const coord = slLineCoordinate();
|
||
if (coord == null || !chartHost) return false;
|
||
const rect = chartHost.getBoundingClientRect();
|
||
return Math.abs(clientY - rect.top - coord) <= SL_DRAG_HIT_PX;
|
||
}
|
||
|
||
function onSlLineHover(e) {
|
||
if (!chartHost || (slDrag && slDrag.active)) return;
|
||
if (!posContext || posContext.stop_loss == null) {
|
||
chartHost.style.cursor = "";
|
||
return;
|
||
}
|
||
chartHost.style.cursor = isPointerNearSlLine(e.clientY) ? "ns-resize" : "";
|
||
}
|
||
|
||
function onSlDragStart(e) {
|
||
if (!posContext || posContext.stop_loss == null || !candleSeries) return;
|
||
if (e.button !== 0) return;
|
||
if (!isPointerNearSlLine(e.clientY)) return;
|
||
e.preventDefault();
|
||
slDrag = {
|
||
active: true,
|
||
moved: false,
|
||
startSl: Number(posContext.stop_loss),
|
||
previewSl: Number(posContext.stop_loss),
|
||
};
|
||
if (chartHost) chartHost.style.cursor = "ns-resize";
|
||
updatePositionLines();
|
||
}
|
||
|
||
function onSlDragMove(e) {
|
||
if (!slDrag || !slDrag.active) return;
|
||
const p = clientYToChartPrice(e.clientY);
|
||
if (p == null || p <= 0) return;
|
||
slDrag.previewSl = p;
|
||
if (Math.abs(p - slDrag.startSl) > 1e-12) slDrag.moved = true;
|
||
if (elPosSl) elPosSl.textContent = fmtPrice(p);
|
||
updatePositionLines();
|
||
}
|
||
|
||
function onSlDragEnd() {
|
||
if (!slDrag || !slDrag.active) {
|
||
slDrag = null;
|
||
if (chartHost) chartHost.style.cursor = "";
|
||
return;
|
||
}
|
||
const preview = slDrag.previewSl;
|
||
const moved = slDrag.moved;
|
||
slDrag = null;
|
||
if (chartHost) chartHost.style.cursor = "";
|
||
updatePositionLines();
|
||
if (!moved || preview == null) return;
|
||
placeTpslFromChart(preview);
|
||
}
|
||
|
||
function bindSlDrag() {
|
||
if (!chartHost) return;
|
||
chartHost.addEventListener("mousedown", onSlDragStart);
|
||
chartHost.addEventListener("mousemove", onSlLineHover);
|
||
document.addEventListener("mousemove", onSlDragMove);
|
||
document.addEventListener("mouseup", onSlDragEnd);
|
||
}
|
||
|
||
function renderPosPanel(ctx) {
|
||
if (!elPosPanel || !ctx) {
|
||
clearPosPanel();
|
||
return;
|
||
}
|
||
elPosPanel.classList.remove("hidden");
|
||
if (elPosSide) {
|
||
const isShort = (ctx.side || "").toLowerCase() === "short";
|
||
elPosSide.textContent = isShort ? "空" : "多";
|
||
elPosSide.className = "market-pos-side " + (isShort ? "side-short" : "side-long");
|
||
}
|
||
if (elPosEntry) elPosEntry.textContent = ctx.entry != null ? fmtPrice(ctx.entry) : "—";
|
||
if (elPosSl) elPosSl.textContent = ctx.stop_loss != null ? fmtPrice(ctx.stop_loss) : "—";
|
||
if (elPosTp) {
|
||
if (ctx.tp_monitored) {
|
||
elPosTp.textContent =
|
||
ctx.take_profit != null
|
||
? "程序监控 · " + fmtPrice(ctx.take_profit)
|
||
: "程序监控";
|
||
elPosTp.classList.add("market-pos-tp-monitored");
|
||
} else {
|
||
elPosTp.textContent = ctx.take_profit != null ? fmtPrice(ctx.take_profit) : "—";
|
||
elPosTp.classList.remove("market-pos-tp-monitored");
|
||
}
|
||
}
|
||
if (elPosSize) elPosSize.textContent = ctx.contracts != null ? String(ctx.contracts) : "—";
|
||
paintPosPnl(ctx);
|
||
if (elPosOrders) {
|
||
const orders = Array.isArray(ctx.orders) ? ctx.orders : [];
|
||
if (!orders.length) {
|
||
elPosOrders.innerHTML = '<span class="market-pos-orders-empty">暂无委托单</span>';
|
||
} else {
|
||
elPosOrders.innerHTML = orders
|
||
.map(function (o) {
|
||
const price = o.price != null ? fmtPrice(o.price) : "—";
|
||
const amt = o.amount != null ? String(o.amount) : "";
|
||
return (
|
||
'<span class="market-pos-order">' +
|
||
'<span class="market-pos-order-kind">' +
|
||
escHtml(o.kind || "") +
|
||
"</span>" +
|
||
'<span class="market-pos-order-label">' +
|
||
escHtml(o.label || "") +
|
||
"</span>" +
|
||
'<span class="market-pos-order-price">' +
|
||
price +
|
||
"</span>" +
|
||
(amt ? '<span class="market-pos-order-amt">×' + escHtml(amt) + "</span>" : "") +
|
||
"</span>"
|
||
);
|
||
})
|
||
.join("");
|
||
}
|
||
}
|
||
scheduleChartResize();
|
||
}
|
||
|
||
function clearPositionLines() {
|
||
positionLines.forEach(function (m) {
|
||
try {
|
||
candleSeries.removePriceLine(m);
|
||
} catch (e) {}
|
||
});
|
||
positionLines = [];
|
||
}
|
||
|
||
function updatePositionLines() {
|
||
clearPositionLines();
|
||
if (!candleSeries || !posContext) return;
|
||
const slPrice =
|
||
slDrag && slDrag.active && slDrag.previewSl != null
|
||
? slDrag.previewSl
|
||
: posContext.stop_loss;
|
||
const slTitle =
|
||
slDrag && slDrag.active
|
||
? "止损 " + fmtPrice(slPrice)
|
||
: slPrice != null
|
||
? "止损 ⟷"
|
||
: "止损";
|
||
const specs = [
|
||
{ price: posContext.entry, color: "#5b9cf5", title: "入场", lineWidth: 1 },
|
||
{
|
||
price: slPrice,
|
||
color: "#ff4d6d",
|
||
title: slTitle,
|
||
lineWidth: slPrice != null ? 2 : 1,
|
||
},
|
||
];
|
||
if (posContext.take_profit != null) {
|
||
specs.push({
|
||
price: posContext.take_profit,
|
||
color: "#00ff9d",
|
||
title: posContext.tp_monitored ? "止盈(程序)" : "止盈",
|
||
});
|
||
}
|
||
specs.forEach(function (s) {
|
||
if (s.price == null || !Number.isFinite(Number(s.price))) return;
|
||
const px = roundToTick(s.price);
|
||
if (px == null || !Number.isFinite(Number(px))) return;
|
||
positionLines.push(
|
||
candleSeries.createPriceLine({
|
||
price: Number(px),
|
||
color: s.color,
|
||
lineWidth: s.lineWidth != null ? s.lineWidth : 1,
|
||
lineStyle: 2,
|
||
axisLabelVisible: true,
|
||
title: s.title,
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
function clearPosContext() {
|
||
posContext = null;
|
||
slDrag = null;
|
||
stopPosPnlPoll();
|
||
try {
|
||
sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY);
|
||
} catch (e) {}
|
||
clearPosPanel();
|
||
clearPositionLines();
|
||
if (chartHost) chartHost.style.cursor = "";
|
||
}
|
||
|
||
function applyPosContext(ctx) {
|
||
posContext = ctx;
|
||
renderPosPanel(ctx);
|
||
updatePositionLines();
|
||
startPosPnlPoll();
|
||
}
|
||
|
||
function syncPosContextForView(exKey, sym) {
|
||
const stored = loadPosContextFromStorage();
|
||
if (stored && posContextMatches(stored, exKey, sym)) {
|
||
applyPosContext(stored);
|
||
return;
|
||
}
|
||
clearPosContext();
|
||
}
|
||
|
||
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 decimalsFromTick(tick) {
|
||
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
|
||
const minMove = Number(tick);
|
||
if (minMove >= 1) return 0;
|
||
const raw = String(minMove);
|
||
const sci = raw.match(/e-(\d+)/i);
|
||
if (sci) return Math.min(12, parseInt(sci[1], 10));
|
||
const fixed = minMove.toFixed(12);
|
||
const frac = fixed.split(".")[1] || "";
|
||
const trimmed = frac.replace(/0+$/, "");
|
||
if (trimmed.length) return Math.min(12, trimmed.length);
|
||
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
|
||
}
|
||
|
||
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
|
||
|
||
function tickToPriceFormat(tick) {
|
||
try {
|
||
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
|
||
return { type: "price", precision: 2, minMove: 0.01 };
|
||
}
|
||
const minMove = Number(tick);
|
||
let prec = decimalsFromTick(minMove);
|
||
if (prec == null || prec < 0) prec = 4;
|
||
prec = Math.min(12, Math.max(0, Math.floor(prec)));
|
||
return { type: "price", precision: prec, minMove: minMove };
|
||
} catch (e) {
|
||
return SAFE_PRICE_FORMAT;
|
||
}
|
||
}
|
||
|
||
function roundToTick(v) {
|
||
if (v == null || Number.isNaN(Number(v))) return v;
|
||
const n = Number(v);
|
||
const tick = priceTick;
|
||
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
|
||
const t = Number(tick);
|
||
const rounded = Math.round(n / t) * t;
|
||
const dec = decimalsFromTick(t);
|
||
if (dec == null) return rounded;
|
||
return parseFloat(rounded.toFixed(dec));
|
||
}
|
||
|
||
function alignCandlesToTick(candles) {
|
||
if (!Array.isArray(candles) || !candles.length) return candles || [];
|
||
if (priceTick == null || !Number.isFinite(Number(priceTick)) || Number(priceTick) <= 0) {
|
||
return candles;
|
||
}
|
||
return candles.map(function (c) {
|
||
return {
|
||
time: c.time,
|
||
open: roundToTick(c.open),
|
||
high: roundToTick(c.high),
|
||
low: roundToTick(c.low),
|
||
close: roundToTick(c.close),
|
||
volume: c.volume,
|
||
};
|
||
});
|
||
}
|
||
|
||
function applyPriceFormatToSeries(series, pf) {
|
||
if (!series || !series.applyOptions) return;
|
||
try {
|
||
series.applyOptions({ priceFormat: pf });
|
||
} catch (e) {
|
||
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
|
||
}
|
||
}
|
||
|
||
function applyChartPriceFormat() {
|
||
let pf = SAFE_PRICE_FORMAT;
|
||
try {
|
||
pf = tickToPriceFormat(priceTick);
|
||
} catch (e) {
|
||
pf = SAFE_PRICE_FORMAT;
|
||
}
|
||
applyPriceFormatToSeries(candleSeries, pf);
|
||
applyPriceFormatToSeries(indSeries.ema21, pf);
|
||
applyPriceFormatToSeries(indSeries.ema55, pf);
|
||
if (chart) {
|
||
chart.applyOptions({
|
||
localization: {
|
||
priceFormatter: function (p) {
|
||
return fmtPrice(p);
|
||
},
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
function fmtPrice(v) {
|
||
if (v == null || Number.isNaN(Number(v))) return "-";
|
||
const aligned = roundToTick(v);
|
||
const n = Number(aligned);
|
||
if (n === 0) return "0";
|
||
const dec = decimalsFromTick(priceTick);
|
||
if (dec != null) return n.toFixed(dec);
|
||
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 fmtAmplitude(bar) {
|
||
if (!bar) return "-";
|
||
const o = Number(bar.open);
|
||
const h = Number(bar.high);
|
||
const l = Number(bar.low);
|
||
if (!o || o <= 0 || !Number.isFinite(h) || !Number.isFinite(l)) return "-";
|
||
return (((h - l) / o) * 100).toFixed(2) + "%";
|
||
}
|
||
|
||
function barRemainMs(tf) {
|
||
const period = TF_MS[tf] || TF_MS["1d"];
|
||
const now = Date.now();
|
||
const barOpen = Math.floor(now / period) * period;
|
||
return Math.max(0, barOpen + period - now);
|
||
}
|
||
|
||
function fmtBarCountdown(ms) {
|
||
const total = Math.max(0, Math.floor(ms / 1000));
|
||
const h = Math.floor(total / 3600);
|
||
const m = Math.floor((total % 3600) / 60);
|
||
const s = total % 60;
|
||
const pad = function (n) {
|
||
return n < 10 ? "0" + n : String(n);
|
||
};
|
||
if (h > 0) return h + ":" + pad(m) + ":" + pad(s);
|
||
return pad(m) + ":" + pad(s);
|
||
}
|
||
|
||
function paintOhlcv(bar) {
|
||
if (!bar) {
|
||
["o", "h", "l", "c", "v", "amp"].forEach(function (k) {
|
||
const el = { o: elO, h: elH, l: elL, c: elC, v: elV, amp: elAmp }[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);
|
||
if (elAmp) elAmp.textContent = fmtAmplitude(bar);
|
||
}
|
||
|
||
function latestCandle() {
|
||
return lastCandles.length ? lastCandles[lastCandles.length - 1] : null;
|
||
}
|
||
|
||
function showLatestOhlcv() {
|
||
paintOhlcv(latestCandle());
|
||
updateCurrentPriceLine();
|
||
updatePriceTag();
|
||
}
|
||
|
||
function clearCurrentPriceLine() {
|
||
if (currentPriceLine && candleSeries) {
|
||
try {
|
||
candleSeries.removePriceLine(currentPriceLine);
|
||
} catch (e) {}
|
||
}
|
||
currentPriceLine = null;
|
||
}
|
||
|
||
function updateCurrentPriceLine() {
|
||
clearCurrentPriceLine();
|
||
if (!candleSeries) return;
|
||
const bar = latestCandle();
|
||
if (!bar || bar.close == null) return;
|
||
const up = Number(bar.close) >= Number(bar.open);
|
||
currentPriceLine = candleSeries.createPriceLine({
|
||
price: Number(roundToTick(bar.close)),
|
||
color: up ? "#00ff9d" : "#ff4d6d",
|
||
lineWidth: 1,
|
||
lineStyle: 2,
|
||
axisLabelVisible: false,
|
||
title: "",
|
||
});
|
||
}
|
||
|
||
function tickLiveClock() {
|
||
const cd = fmtBarCountdown(barRemainMs(currentTf));
|
||
if (elPriceTagTime && elPriceTag && !elPriceTag.classList.contains("hidden")) {
|
||
elPriceTagTime.textContent = cd;
|
||
}
|
||
if (elBarCountdown) elBarCountdown.textContent = "距收盘 " + cd;
|
||
}
|
||
|
||
function updatePriceTag() {
|
||
if (!elPriceTag || !candleSeries || !chart) return;
|
||
try {
|
||
tickLiveClock();
|
||
const bar = latestCandle();
|
||
if (!bar || bar.close == null) {
|
||
elPriceTag.classList.add("hidden");
|
||
elPriceTag.setAttribute("aria-hidden", "true");
|
||
return;
|
||
}
|
||
let y = null;
|
||
try {
|
||
y = candleSeries.priceToCoordinate(Number(bar.close));
|
||
} catch (e) {
|
||
y = null;
|
||
}
|
||
const hostH = chartHost.clientHeight || 0;
|
||
if (y == null || y < 8 || y > hostH - 8) {
|
||
elPriceTag.classList.add("hidden");
|
||
elPriceTag.setAttribute("aria-hidden", "true");
|
||
return;
|
||
}
|
||
const up = Number(bar.close) >= Number(bar.open);
|
||
elPriceTag.classList.remove("hidden", "is-up", "is-down");
|
||
elPriceTag.classList.add(up ? "is-up" : "is-down");
|
||
elPriceTag.setAttribute("aria-hidden", "false");
|
||
elPriceTag.style.left = "auto";
|
||
elPriceTag.style.right = "0";
|
||
elPriceTag.style.top = y + "px";
|
||
if (elPriceTagValue) elPriceTagValue.textContent = fmtPrice(bar.close);
|
||
} catch (e) {
|
||
elPriceTag.classList.add("hidden");
|
||
elPriceTag.setAttribute("aria-hidden", "true");
|
||
}
|
||
}
|
||
|
||
function startPriceTagTimer() {
|
||
stopPriceTagTimer();
|
||
tickLiveClock();
|
||
priceTagTimer = setInterval(tickLiveClock, 1000);
|
||
}
|
||
|
||
function stopPriceTagTimer() {
|
||
if (priceTagTimer) clearInterval(priceTagTimer);
|
||
priceTagTimer = null;
|
||
}
|
||
|
||
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 chartThemePalette() {
|
||
const light = document.documentElement.getAttribute("data-theme") === "light";
|
||
return light
|
||
? {
|
||
bg: "#f0f4f9",
|
||
text: "#4a6078",
|
||
border: "#b8c8d8",
|
||
up: "#0a8f5c",
|
||
down: "#c93552",
|
||
volUp: "rgba(10, 143, 92, 0.45)",
|
||
volDown: "rgba(201, 53, 82, 0.45)",
|
||
}
|
||
: {
|
||
bg: "#0a1018",
|
||
text: "#b8d4e8",
|
||
border: "#2a4058",
|
||
up: "#00ff9d",
|
||
down: "#ff4d6d",
|
||
volUp: "rgba(0, 255, 157, 0.5)",
|
||
volDown: "rgba(255, 77, 109, 0.5)",
|
||
};
|
||
}
|
||
|
||
function applyChartTheme() {
|
||
if (!chart) return;
|
||
const p = chartThemePalette();
|
||
chart.applyOptions({
|
||
layout: { background: { color: p.bg }, textColor: p.text },
|
||
rightPriceScale: { borderColor: p.border },
|
||
timeScale: { borderColor: p.border },
|
||
});
|
||
if (candleSeries) {
|
||
candleSeries.applyOptions({
|
||
upColor: p.up,
|
||
downColor: p.down,
|
||
wickUpColor: p.up,
|
||
wickDownColor: p.down,
|
||
});
|
||
}
|
||
if (volumeSeries && lastCandles.length) {
|
||
volumeSeries.setData(buildVolumeData(lastCandles));
|
||
}
|
||
}
|
||
|
||
function buildVolumeData(candles) {
|
||
const p = chartThemePalette();
|
||
return (candles || []).map(function (c) {
|
||
const up = Number(c.close) >= Number(c.open);
|
||
return {
|
||
time: c.time,
|
||
value: Number(c.volume) || 0,
|
||
color: up ? p.volUp : p.volDown,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildVolumeBar(candle) {
|
||
const p = chartThemePalette();
|
||
const up = Number(candle.close) >= Number(candle.open);
|
||
return {
|
||
time: candle.time,
|
||
value: Number(candle.volume) || 0,
|
||
color: up ? p.volUp : p.volDown,
|
||
};
|
||
}
|
||
|
||
function ensureChart() {
|
||
if (chart && candleSeries && volumeSeries) return true;
|
||
if (!window.LightweightCharts) {
|
||
if (elStatus) {
|
||
elStatus.className = "market-status err";
|
||
elStatus.textContent = "图表库加载失败";
|
||
}
|
||
return false;
|
||
}
|
||
const tp = chartThemePalette();
|
||
chart = LightweightCharts.createChart(chartHost, {
|
||
layout: { background: { color: tp.bg }, textColor: tp.text },
|
||
grid: {
|
||
vertLines: { visible: false },
|
||
horzLines: { visible: false },
|
||
},
|
||
rightPriceScale: { borderColor: tp.border, autoScale: true },
|
||
timeScale: {
|
||
borderColor: tp.border,
|
||
timeVisible: true,
|
||
secondsVisible: false,
|
||
rightOffset: RIGHT_OFFSET_BARS,
|
||
},
|
||
crosshair: {
|
||
mode: LightweightCharts.CrosshairMode
|
||
? LightweightCharts.CrosshairMode.Normal
|
||
: 0,
|
||
},
|
||
});
|
||
|
||
const candleOpts = {
|
||
upColor: tp.up,
|
||
downColor: tp.down,
|
||
borderVisible: false,
|
||
wickUpColor: tp.up,
|
||
wickDownColor: tp.down,
|
||
lastValueVisible: false,
|
||
priceLineVisible: false,
|
||
priceFormat: SAFE_PRICE_FORMAT,
|
||
};
|
||
|
||
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: "",
|
||
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;
|
||
|
||
applyScaleLayout();
|
||
applyChartPriceFormat();
|
||
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 (range) {
|
||
if (!chartDataLoading && range && !suppressRangeUserLock) {
|
||
markChartRangeUserAdjusted();
|
||
}
|
||
scheduleRangeUiUpdate();
|
||
if (
|
||
!range ||
|
||
chartDataLoading ||
|
||
loadingLeft ||
|
||
exhaustedLeft ||
|
||
!lastCandles.length ||
|
||
!lastViewKey
|
||
) {
|
||
return;
|
||
}
|
||
if (currentChartViewKey() !== lastViewKey) return;
|
||
scheduleLoadOlderOnRange(range);
|
||
});
|
||
|
||
window.addEventListener("resize", function () {
|
||
scheduleChartResize();
|
||
});
|
||
scheduleChartResize();
|
||
ensureDrawLayer();
|
||
return true;
|
||
}
|
||
|
||
function clearMarkers() {
|
||
rangeMarkers.forEach(function (m) {
|
||
try {
|
||
candleSeries.removePriceLine(m);
|
||
} catch (e) {}
|
||
});
|
||
rangeMarkers = [];
|
||
}
|
||
|
||
function viewKey(exKey, sym, tf) {
|
||
return (exKey || "") + "|" + (sym || "") + "|" + (tf || "");
|
||
}
|
||
|
||
function chartInitialLimit(tf) {
|
||
return CHART_INITIAL_LIMITS[tf] || 200;
|
||
}
|
||
|
||
function chartChunkLimit(tf) {
|
||
return CHART_CHUNK_LIMITS[tf] || 200;
|
||
}
|
||
|
||
function chartMemoryCap(tf) {
|
||
return CHART_MEMORY_CAPS[tf] || 1000;
|
||
}
|
||
|
||
function resetChartHistoryState() {
|
||
exhaustedLeft = false;
|
||
loadingLeft = false;
|
||
}
|
||
|
||
function currentChartViewKey() {
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || currentTf || "1d";
|
||
if (!exKey || !sym) return "";
|
||
return viewKey(exKey, sym, tf);
|
||
}
|
||
|
||
function isVisibleRangeValidForCandles(range, candleCount) {
|
||
if (!range || candleCount <= 0) return false;
|
||
const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS;
|
||
if (range.from < -2 || range.to < 0) return false;
|
||
if (range.to > maxTo + 8) return false;
|
||
if (range.from > candleCount - 1) return false;
|
||
return true;
|
||
}
|
||
|
||
function markChartRangeUserAdjusted() {
|
||
chartRangeUserLocked = true;
|
||
if (chartRangeLockTimer) clearTimeout(chartRangeLockTimer);
|
||
chartRangeLockTimer = setTimeout(function () {
|
||
chartRangeLockTimer = null;
|
||
chartRangeUserLocked = false;
|
||
}, 30000);
|
||
}
|
||
|
||
function clampVisibleLogicalRange(range, candleCount) {
|
||
if (!range || candleCount <= 0) return null;
|
||
const maxTo = candleCount - 1 + RIGHT_OFFSET_BARS;
|
||
const from = Math.max(-2, Math.min(range.from, candleCount - 1));
|
||
const to = Math.max(0, Math.min(range.to, maxTo + 8));
|
||
if (to <= from) return null;
|
||
return { from: from, to: to };
|
||
}
|
||
|
||
function restoreVisibleLogicalRange(range, candleCount) {
|
||
const clamped = clampVisibleLogicalRange(range, candleCount);
|
||
if (!chart || !clamped || !isVisibleRangeValidForCandles(clamped, candleCount)) return false;
|
||
suppressRangeUserLock = true;
|
||
chart.timeScale().setVisibleLogicalRange(clamped);
|
||
suppressRangeUserLock = false;
|
||
return true;
|
||
}
|
||
|
||
function applyPreservedVisibleRange(range, candleCount) {
|
||
if (!chart || !range || !candleCount) return;
|
||
function applyOnce() {
|
||
if (!chart || !lastCandles.length) return;
|
||
applyChartRightGap();
|
||
restoreVisibleLogicalRange(range, lastCandles.length);
|
||
updateVisibleRangeMarkers();
|
||
}
|
||
applyOnce();
|
||
requestAnimationFrame(applyOnce);
|
||
setTimeout(applyOnce, 0);
|
||
}
|
||
|
||
function shouldLoadOlderOnRange(range) {
|
||
if (!range || !lastCandles.length) return false;
|
||
const n = lastCandles.length;
|
||
const maxTo = n - 1 + RIGHT_OFFSET_BARS;
|
||
if (range.from >= CHART_LOAD_LEFT_THRESHOLD) return false;
|
||
// 缩小图表时 from 会变小,但 to 仍靠近最新 — 不应触发左拖补历史
|
||
if (range.to >= maxTo - 30) return false;
|
||
return true;
|
||
}
|
||
|
||
function scheduleRangeUiUpdate() {
|
||
if (rangeUiTimer) clearTimeout(rangeUiTimer);
|
||
rangeUiTimer = setTimeout(function () {
|
||
rangeUiTimer = null;
|
||
updateVisibleRangeMarkers();
|
||
updatePriceTag();
|
||
}, 120);
|
||
}
|
||
|
||
function scheduleLoadOlderOnRange(range) {
|
||
if (!shouldLoadOlderOnRange(range)) return;
|
||
if (loadOlderTimer) clearTimeout(loadOlderTimer);
|
||
loadOlderTimer = setTimeout(function () {
|
||
loadOlderTimer = null;
|
||
if (!chart) return;
|
||
const cur = chart.timeScale().getVisibleLogicalRange();
|
||
if (!shouldLoadOlderOnRange(cur)) return;
|
||
void loadOlderCandles();
|
||
}, 280);
|
||
}
|
||
|
||
function tailVisibleLogicalRange(candleCount) {
|
||
const n = Math.max(0, Number(candleCount) || 0);
|
||
if (n <= 0) return null;
|
||
const visible = Math.min(DEFAULT_VISIBLE_BARS, n);
|
||
return {
|
||
from: Math.max(0, n - visible),
|
||
to: n - 1 + RIGHT_OFFSET_BARS,
|
||
};
|
||
}
|
||
|
||
function clearChartSeriesData() {
|
||
lastCandles = [];
|
||
candleByTime = {};
|
||
if (candleSeries) candleSeries.setData([]);
|
||
if (volumeSeries) volumeSeries.setData([]);
|
||
}
|
||
|
||
function mergeCandles(existing, incoming, opts) {
|
||
opts = opts || {};
|
||
const prepend = !!opts.prepend;
|
||
const byTime = {};
|
||
(existing || []).forEach(function (c) {
|
||
if (c && c.time != null) byTime[c.time] = c;
|
||
});
|
||
(incoming || []).forEach(function (c) {
|
||
if (c && c.time != null) byTime[c.time] = c;
|
||
});
|
||
let merged = Object.keys(byTime)
|
||
.map(function (t) {
|
||
return Number(t);
|
||
})
|
||
.sort(function (a, b) {
|
||
return a - b;
|
||
})
|
||
.map(function (t) {
|
||
return byTime[t];
|
||
});
|
||
const cap = chartMemoryCap(currentTf);
|
||
if (merged.length > cap) {
|
||
merged = prepend ? merged.slice(0, cap) : merged.slice(-cap);
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
/** 尾部静默刷新:仅 update 变更 K 线,不 setData,避免视口跳动 */
|
||
function applyTailCandlePatch(incoming) {
|
||
if (!candleSeries || !volumeSeries || !incoming || !incoming.length) return false;
|
||
const aligned = alignCandlesToTick(incoming);
|
||
const prevLen = lastCandles.length;
|
||
const oldestTime = prevLen ? lastCandles[0].time : null;
|
||
const merged = mergeCandles(lastCandles, aligned, { prepend: false });
|
||
if (
|
||
prevLen > 0 &&
|
||
merged.length > 0 &&
|
||
merged[0].time !== oldestTime &&
|
||
merged.length <= prevLen
|
||
) {
|
||
return false;
|
||
}
|
||
aligned.forEach(function (bar) {
|
||
candleSeries.update(bar);
|
||
volumeSeries.update(buildVolumeBar(bar));
|
||
});
|
||
if (merged.length > prevLen) {
|
||
for (let i = prevLen; i < merged.length; i++) {
|
||
const bar = merged[i];
|
||
candleSeries.update(bar);
|
||
volumeSeries.update(buildVolumeBar(bar));
|
||
}
|
||
}
|
||
lastCandles = merged;
|
||
indexCandles(lastCandles);
|
||
readIndicatorState();
|
||
if (indicatorState.ema || indicatorState.macd || indicatorState.rsi) {
|
||
try {
|
||
updateIndicators();
|
||
} catch (indErr) {}
|
||
}
|
||
updateVisibleRangeMarkers();
|
||
showLatestOhlcv();
|
||
return true;
|
||
}
|
||
|
||
function applyCandlesToChart(candles, rangeShift, opts) {
|
||
opts = opts || {};
|
||
let savedRange = null;
|
||
if (opts.preserveRange && chart) {
|
||
savedRange = chart.timeScale().getVisibleLogicalRange();
|
||
}
|
||
lastCandles = alignCandlesToTick(candles);
|
||
indexCandles(lastCandles);
|
||
candleSeries.setData(lastCandles);
|
||
volumeSeries.setData(buildVolumeData(lastCandles));
|
||
if (!opts.skipRightGap) {
|
||
applyChartRightGap();
|
||
}
|
||
if (rangeShift && chart) {
|
||
const range = chart.timeScale().getVisibleLogicalRange();
|
||
if (range) {
|
||
suppressRangeUserLock = true;
|
||
chart.timeScale().setVisibleLogicalRange({
|
||
from: range.from + rangeShift,
|
||
to: range.to + rangeShift,
|
||
});
|
||
suppressRangeUserLock = false;
|
||
}
|
||
} else if (savedRange) {
|
||
restoreVisibleLogicalRange(savedRange, lastCandles.length);
|
||
}
|
||
if (!opts.skipAutoScale) {
|
||
applyPriceAutoScale();
|
||
}
|
||
updateVisibleRangeMarkers();
|
||
try {
|
||
updateIndicators();
|
||
} catch (indErr) {}
|
||
showLatestOhlcv();
|
||
}
|
||
|
||
async function fetchChartChunk(params) {
|
||
const qs = new URLSearchParams({
|
||
exchange_key: params.exchange_key,
|
||
symbol: params.symbol,
|
||
timeframe: params.timeframe,
|
||
limit: String(params.limit),
|
||
});
|
||
if (params.before_ms) qs.set("before_ms", String(params.before_ms));
|
||
if (params.refresh) qs.set("refresh", "1");
|
||
if (params.tail) qs.set("tail", "1");
|
||
const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
throw new Error(data.detail || data.msg || "请求失败");
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function loadOlderCandles() {
|
||
if (chartDataLoading || loadingLeft || exhaustedLeft || !lastCandles.length) return;
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || "1d";
|
||
if (!exKey || !sym) return;
|
||
const vKey = viewKey(exKey, sym, tf);
|
||
if (!lastViewKey || vKey !== lastViewKey) return;
|
||
loadingLeft = true;
|
||
const beforeMs = Number(lastCandles[0].time) * 1000;
|
||
try {
|
||
const data = await fetchChartChunk({
|
||
exchange_key: exKey,
|
||
symbol: sym,
|
||
timeframe: tf,
|
||
limit: chartChunkLimit(tf),
|
||
before_ms: beforeMs,
|
||
});
|
||
if (data.exhausted) exhaustedLeft = true;
|
||
const incoming = alignCandlesToTick(data.candles || []);
|
||
if (!incoming.length) return;
|
||
const prevLen = lastCandles.length;
|
||
const merged = mergeCandles(lastCandles, incoming, { prepend: true });
|
||
const shift = merged.length - prevLen;
|
||
applyCandlesToChart(merged, shift);
|
||
if (elStatus && !elStatus.classList.contains("err")) {
|
||
elStatus.textContent =
|
||
"已加载 " +
|
||
lastCandles.length +
|
||
" 根(向左 +" +
|
||
incoming.length +
|
||
(exhaustedLeft ? " · 已到最早" : "") +
|
||
")";
|
||
}
|
||
} catch (e) {
|
||
if (elStatus) {
|
||
elStatus.className = "market-status warn";
|
||
elStatus.textContent = "加载更早 K 线失败:" + String(e.message || e);
|
||
}
|
||
} finally {
|
||
loadingLeft = false;
|
||
}
|
||
}
|
||
|
||
function applyIncomingTailCandles(incoming, meta) {
|
||
meta = meta || {};
|
||
const vKey = currentViewSeriesKey();
|
||
if (!vKey || !lastCandles.length || chartDataLoading) return false;
|
||
if (!lastViewKey || vKey !== lastViewKey) return false;
|
||
const epochAtStart = chartViewEpoch;
|
||
const autoFollow = priceAutoScale;
|
||
let savedRange = null;
|
||
if (chart) savedRange = chart.timeScale().getVisibleLogicalRange();
|
||
if (!incoming || !incoming.length) return false;
|
||
if (meta.price_tick != null) {
|
||
priceTick = meta.price_tick;
|
||
try {
|
||
applyChartPriceFormat();
|
||
} catch (fmtErr) {
|
||
priceTick = null;
|
||
applyChartPriceFormat();
|
||
}
|
||
}
|
||
const aligned = alignCandlesToTick(incoming);
|
||
if (!autoFollow && applyTailCandlePatch(aligned)) {
|
||
/* 手动模式:增量 update,不触碰时间轴 */
|
||
} else {
|
||
const merged = mergeCandles(lastCandles, aligned, { prepend: false });
|
||
applyCandlesToChart(merged, 0, {
|
||
preserveRange: false,
|
||
skipAutoScale: !autoFollow,
|
||
skipRightGap: !autoFollow,
|
||
});
|
||
if (epochAtStart !== chartViewEpoch) return false;
|
||
const n = lastCandles.length;
|
||
if (autoFollow) {
|
||
applyDefaultVisibleRange();
|
||
} else if (savedRange) {
|
||
applyPreservedVisibleRange(savedRange, n);
|
||
}
|
||
}
|
||
if (epochAtStart !== chartViewEpoch) return false;
|
||
scheduleRangeUiUpdate();
|
||
if (posContext) {
|
||
updateLivePosPnl();
|
||
refreshPosPnlFromBoard();
|
||
}
|
||
if (meta.series_version != null) {
|
||
localSeriesVersion = Number(meta.series_version) || localSeriesVersion;
|
||
}
|
||
if (meta.chart_version != null) {
|
||
localChartVersion = Number(meta.chart_version) || localChartVersion;
|
||
}
|
||
if (elUpdated) elUpdated.textContent = "数据 " + (meta.updated_at || "--");
|
||
tickLiveClock();
|
||
if (window.HubChartDraw && drawAttached) window.HubChartDraw.redraw();
|
||
return true;
|
||
}
|
||
|
||
async function refreshChartTail() {
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || "1d";
|
||
const vKey = viewKey(exKey, sym, tf);
|
||
if (!exKey || !sym || !lastCandles.length || chartDataLoading) return;
|
||
if (!lastViewKey || vKey !== lastViewKey) return;
|
||
const myToken = loadToken;
|
||
const epochAtStart = chartViewEpoch;
|
||
try {
|
||
const data = await fetchChartChunk({
|
||
exchange_key: exKey,
|
||
symbol: sym,
|
||
timeframe: tf,
|
||
limit: CHART_TAIL_REFRESH_LIMIT,
|
||
tail: true,
|
||
});
|
||
if (myToken !== loadToken) return;
|
||
if (vKey !== lastViewKey) return;
|
||
if (epochAtStart !== chartViewEpoch) return;
|
||
if (!data.ok || !data.candles || !data.candles.length) return;
|
||
applyIncomingTailCandles(data.candles, {
|
||
price_tick: data.price_tick,
|
||
series_version: data.series_version,
|
||
chart_version: data.chart_version,
|
||
updated_at: data.updated_at,
|
||
});
|
||
} catch (_) {}
|
||
}
|
||
|
||
function applyChartRightGap() {
|
||
if (!chart) return;
|
||
chart.timeScale().applyOptions({
|
||
rightOffset: RIGHT_OFFSET_BARS,
|
||
fixRightEdge: false,
|
||
});
|
||
}
|
||
|
||
function applyDefaultVisibleRange() {
|
||
if (!chart || !lastCandles.length) return;
|
||
function applyOnce() {
|
||
if (!chart || !lastCandles.length) return;
|
||
const r = tailVisibleLogicalRange(lastCandles.length);
|
||
if (!r) return;
|
||
applyChartRightGap();
|
||
restoreVisibleLogicalRange(r, lastCandles.length);
|
||
updateVisibleRangeMarkers();
|
||
}
|
||
applyOnce();
|
||
requestAnimationFrame(applyOnce);
|
||
setTimeout(applyOnce, 0);
|
||
}
|
||
|
||
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(roundToTick(hi.high)),
|
||
color: "#ffb84d",
|
||
lineWidth: 1,
|
||
lineStyle: 2,
|
||
axisLabelVisible: true,
|
||
title: "高点",
|
||
})
|
||
);
|
||
rangeMarkers.push(
|
||
candleSeries.createPriceLine({
|
||
price: Number(roundToTick(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 currentViewSeriesKey() {
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || "1d";
|
||
if (!exKey || !sym) return "";
|
||
return exKey + "|" + sym + "|" + tf;
|
||
}
|
||
|
||
function postChartWatch() {
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || "1d";
|
||
if (!exKey || !sym) return Promise.resolve();
|
||
return fetch("/api/chart/watch", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
|
||
}).catch(function () {});
|
||
}
|
||
|
||
function postChartUnwatch() {
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || "1d";
|
||
if (!exKey || !sym) return Promise.resolve();
|
||
return fetch("/api/chart/unwatch", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
|
||
}).catch(function () {});
|
||
}
|
||
|
||
function closeChartStream() {
|
||
if (chartEventSource) {
|
||
chartEventSource.close();
|
||
chartEventSource = null;
|
||
}
|
||
}
|
||
|
||
function connectChartStream() {
|
||
closeChartStream();
|
||
const page = document.getElementById("page-market");
|
||
if (!page || page.classList.contains("hidden")) return;
|
||
chartEventSource = new EventSource("/api/chart/stream");
|
||
chartEventSource.addEventListener("chart", function (ev) {
|
||
try {
|
||
const st = JSON.parse(ev.data || "{}");
|
||
if (st.polling) return;
|
||
const ver = Number(st.chart_version) || 0;
|
||
const series = st.series || {};
|
||
const vKey = currentViewSeriesKey();
|
||
const tails = st.tails || {};
|
||
const tailPack = vKey && tails[vKey] ? tails[vKey] : null;
|
||
if (tailPack && tailPack.candles && tailPack.candles.length) {
|
||
if (
|
||
applyIncomingTailCandles(tailPack.candles, {
|
||
price_tick: tailPack.price_tick,
|
||
series_version: tailPack.series_version,
|
||
chart_version: ver,
|
||
updated_at: tailPack.updated_at || st.updated_at,
|
||
})
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
const sVer = vKey && series[vKey] ? Number(series[vKey].series_version) || 0 : 0;
|
||
const seriesChanged = vKey && sVer > 0 && sVer !== localSeriesVersion;
|
||
if (seriesChanged) {
|
||
refreshChartTail();
|
||
} else if (posContext) {
|
||
updateLivePosPnl();
|
||
} else if (ver !== localChartVersion) {
|
||
localChartVersion = ver;
|
||
}
|
||
} catch (_) {}
|
||
});
|
||
chartEventSource.onerror = function () {
|
||
closeChartStream();
|
||
if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer);
|
||
chartSseReconnectTimer = setTimeout(function () {
|
||
const p = document.getElementById("page-market");
|
||
if (p && !p.classList.contains("hidden")) connectChartStream();
|
||
}, 8000);
|
||
};
|
||
}
|
||
|
||
function startChartWatchHeartbeat() {
|
||
stopChartWatchHeartbeat();
|
||
void postChartWatch();
|
||
chartWatchTimer = setInterval(function () {
|
||
const page = document.getElementById("page-market");
|
||
if (!page || page.classList.contains("hidden")) return;
|
||
void postChartWatch();
|
||
}, CHART_WATCH_HEARTBEAT_MS);
|
||
}
|
||
|
||
function stopChartWatchHeartbeat() {
|
||
if (chartWatchTimer) clearInterval(chartWatchTimer);
|
||
chartWatchTimer = null;
|
||
}
|
||
|
||
function startAutoRefresh() {
|
||
stopAutoRefresh();
|
||
refreshTimer = setInterval(function () {
|
||
const page = document.getElementById("page-market");
|
||
if (!page || page.classList.contains("hidden")) return;
|
||
refreshChartTail();
|
||
}, CHART_SSE_FALLBACK_MS);
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (refreshTimer) clearInterval(refreshTimer);
|
||
refreshTimer = null;
|
||
if (chartSseReconnectTimer) {
|
||
clearTimeout(chartSseReconnectTimer);
|
||
chartSseReconnectTimer = null;
|
||
}
|
||
}
|
||
|
||
function stopChartLive() {
|
||
stopAutoRefresh();
|
||
stopChartWatchHeartbeat();
|
||
closeChartStream();
|
||
void postChartUnwatch();
|
||
}
|
||
|
||
function renderVolumeRank(data) {
|
||
if (!elVolRankMeta || !elVolRankList) return;
|
||
elVolRankList.innerHTML = "";
|
||
if (!data || !data.ok || !data.items || !data.items.length) {
|
||
elVolRankMeta.textContent =
|
||
(data && data.msg) ||
|
||
"暂无排名数据(请 pm2 restart 四实例与 manual-trading-hub 后重试)";
|
||
return;
|
||
}
|
||
const resetHour = data.reset_hour != null ? data.reset_hour : 8;
|
||
const rankDate = data.rank_date || "—";
|
||
const updated = data.updated_at || "—";
|
||
const total = data.total_symbols != null ? data.total_symbols : "";
|
||
const count = data.items.length;
|
||
const expect = data.expected_count != null ? data.expected_count : 20;
|
||
let meta =
|
||
"昨日成交 Top" +
|
||
expect +
|
||
" · 交易日 " +
|
||
rankDate +
|
||
" · 每早 " +
|
||
resetHour +
|
||
":00 更新 · 显示 " +
|
||
count +
|
||
"/" +
|
||
expect +
|
||
" 条";
|
||
if (total) meta += " · 全市场 " + total + " 个";
|
||
if (data.stale) meta += " · 数据不完整,正在重拉…";
|
||
meta += " · " + updated;
|
||
elVolRankMeta.textContent = meta;
|
||
const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
data.items.forEach(function (row) {
|
||
const li = document.createElement("li");
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "market-vol-rank-item";
|
||
if (row.symbol && row.symbol.toUpperCase() === curSym) {
|
||
btn.classList.add("is-active");
|
||
}
|
||
btn.dataset.symbol = row.symbol || "";
|
||
btn.innerHTML =
|
||
'<span class="market-vol-rank-no">' +
|
||
(row.rank || "") +
|
||
'</span><span class="market-vol-rank-sym">' +
|
||
(row.symbol || "") +
|
||
'</span><span class="market-vol-rank-vol">' +
|
||
(row.volume_label || "") +
|
||
"</span>";
|
||
btn.addEventListener("click", function () {
|
||
if (!elSymbol || !row.symbol) return;
|
||
elSymbol.value = row.symbol;
|
||
if (elVolRankDetails) elVolRankDetails.open = false;
|
||
loadChart(false);
|
||
});
|
||
li.appendChild(btn);
|
||
elVolRankList.appendChild(li);
|
||
});
|
||
}
|
||
|
||
async function loadVolumeRank(forceRefresh) {
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
if (!exKey || !elVolRankMeta) return;
|
||
elVolRankMeta.textContent = "加载排名…";
|
||
if (elVolRankList) elVolRankList.innerHTML = "";
|
||
try {
|
||
let url = "/api/chart/volume-rank?exchange_key=" + encodeURIComponent(exKey);
|
||
if (forceRefresh) url += "&refresh=1";
|
||
const r = await fetch(url, { credentials: "same-origin" });
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
|
||
}
|
||
renderVolumeRank(data);
|
||
const expect = data.expected_count != null ? data.expected_count : 20;
|
||
if (!forceRefresh && data.ok && data.items && data.items.length < expect) {
|
||
void loadVolumeRank(true);
|
||
}
|
||
} catch (e) {
|
||
renderVolumeRank({ ok: false, msg: String(e.message || e) });
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|
||
populateFsExchangeOptions();
|
||
readQuery();
|
||
applyDefaults();
|
||
updateExchangeDisplay();
|
||
void loadVolumeRank();
|
||
}
|
||
|
||
async function loadChart(force, options) {
|
||
options = options || {};
|
||
const autoTick = !!options.autoTick;
|
||
if (autoTick) {
|
||
return refreshChartTail();
|
||
}
|
||
localSeriesVersion = 0;
|
||
void postChartWatch();
|
||
if (!ensureChart()) return;
|
||
const exKey = (elExchange && elExchange.value) || "";
|
||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||
const tf = (elTf && elTf.value) || "1d";
|
||
currentTf = tf;
|
||
if (!exKey || !sym) {
|
||
if (elStatus) {
|
||
elStatus.className = "market-status err";
|
||
elStatus.textContent = "请选择交易所并输入币种";
|
||
}
|
||
return;
|
||
}
|
||
const myToken = ++loadToken;
|
||
const vKey = viewKey(exKey, sym, tf);
|
||
const resetView = !!force || vKey !== lastViewKey;
|
||
chartDataLoading = true;
|
||
if (resetView) {
|
||
chartViewEpoch += 1;
|
||
chartRangeUserLocked = false;
|
||
if (chartRangeLockTimer) {
|
||
clearTimeout(chartRangeLockTimer);
|
||
chartRangeLockTimer = null;
|
||
}
|
||
resetChartHistoryState();
|
||
lastViewKey = "";
|
||
clearChartSeriesData();
|
||
}
|
||
if (elStatus) {
|
||
elStatus.className = "market-status";
|
||
elStatus.textContent = "加载中…";
|
||
}
|
||
updateHeaderLabels(sym, tf);
|
||
|
||
try {
|
||
const data = await fetchChartChunk({
|
||
exchange_key: exKey,
|
||
symbol: sym,
|
||
timeframe: tf,
|
||
limit: chartInitialLimit(tf),
|
||
refresh: !!force,
|
||
});
|
||
if (myToken !== loadToken) return;
|
||
if (!data.ok || !data.candles || !data.candles.length) {
|
||
throw new Error(data.msg || "无 K 线");
|
||
}
|
||
|
||
priceTick = data.price_tick;
|
||
try {
|
||
applyChartPriceFormat();
|
||
} catch (fmtErr) {
|
||
priceTick = null;
|
||
applyChartPriceFormat();
|
||
}
|
||
applyCandlesToChart(alignCandlesToTick(data.candles), 0);
|
||
lastViewKey = vKey;
|
||
ensureDrawLayer();
|
||
syncDrawViewKey();
|
||
if (resetView) {
|
||
applyDefaultVisibleRange();
|
||
}
|
||
syncPosContextForView(exKey, sym);
|
||
if (posContext) {
|
||
updateLivePosPnl();
|
||
refreshPosPnlFromBoard();
|
||
}
|
||
scheduleChartResize();
|
||
|
||
const limit = data.limit || lastCandles.length;
|
||
let hint =
|
||
"已加载 " +
|
||
lastCandles.length +
|
||
" 根(首屏 " +
|
||
limit +
|
||
")· 库 " +
|
||
(data.from_cache || 0) +
|
||
" / 新拉 " +
|
||
(data.fetched || 0) +
|
||
(data.cleared ? " · 清库 " + data.cleared : "") +
|
||
" · 左拖加载更多 · 后台 " +
|
||
(data.chart_poll_interval_sec || 5) +
|
||
"s";
|
||
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 || "--");
|
||
if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion;
|
||
if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion;
|
||
tickLiveClock();
|
||
} catch (e) {
|
||
if (myToken !== loadToken) return;
|
||
if (elStatus) {
|
||
elStatus.className = "market-status err";
|
||
elStatus.textContent = String(e.message || e);
|
||
}
|
||
} finally {
|
||
if (myToken === loadToken) chartDataLoading = false;
|
||
}
|
||
}
|
||
|
||
function bind() {
|
||
bindSlDrag();
|
||
if (elRefresh) {
|
||
elRefresh.addEventListener("click", function () {
|
||
loadChart(true);
|
||
});
|
||
}
|
||
if (elTf) {
|
||
elTf.addEventListener("change", function () {
|
||
tfDigitBuf = "";
|
||
if (tfDigitTimer) {
|
||
clearTimeout(tfDigitTimer);
|
||
tfDigitTimer = null;
|
||
}
|
||
currentTf = (elTf && elTf.value) || "1d";
|
||
lastViewKey = "";
|
||
tickLiveClock();
|
||
syncFsToolbarFromMain();
|
||
loadChart(false);
|
||
});
|
||
}
|
||
if (elExchange) {
|
||
elExchange.addEventListener("change", function () {
|
||
updateExchangeDisplay();
|
||
syncFsToolbarFromMain();
|
||
lastViewKey = "";
|
||
void loadVolumeRank();
|
||
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();
|
||
if (priceAutoScale) applyDefaultVisibleRange();
|
||
});
|
||
}
|
||
if (elPosClear) {
|
||
elPosClear.addEventListener("click", function () {
|
||
clearPosContext();
|
||
});
|
||
}
|
||
if (elFsBtn) {
|
||
elFsBtn.addEventListener("click", function () {
|
||
toggleChartFullscreen();
|
||
});
|
||
}
|
||
if (elFsExit) {
|
||
elFsExit.addEventListener("click", function () {
|
||
setChartFullscreen(false);
|
||
});
|
||
}
|
||
[elIndEma, elIndMacd, elIndRsi].forEach(function (el) {
|
||
if (!el) return;
|
||
el.addEventListener("change", function () {
|
||
updateIndicators();
|
||
});
|
||
});
|
||
const pageMarket = document.getElementById("page-market");
|
||
const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean);
|
||
fsKeyTargets.forEach(function (el) {
|
||
el.addEventListener("keydown", onChartFullscreenKey, true);
|
||
});
|
||
window.addEventListener("keydown", onMarketKeydown, true);
|
||
if (elChartWrap) {
|
||
if (!elChartWrap.hasAttribute("tabindex")) elChartWrap.setAttribute("tabindex", "-1");
|
||
elChartWrap.addEventListener("mousedown", focusMarketChartArea);
|
||
}
|
||
if (elFsExchange) {
|
||
elFsExchange.addEventListener("change", function () {
|
||
syncMainFromFsToolbar();
|
||
loadChart(false);
|
||
});
|
||
}
|
||
if (elFsTf) {
|
||
elFsTf.addEventListener("change", function () {
|
||
currentTf = elFsTf.value || "1d";
|
||
lastViewKey = "";
|
||
syncMainFromFsToolbar();
|
||
tickLiveClock();
|
||
loadChart(false);
|
||
});
|
||
}
|
||
if (elFsSymbol) {
|
||
elFsSymbol.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter") {
|
||
syncMainFromFsToolbar();
|
||
loadChart(false);
|
||
}
|
||
});
|
||
}
|
||
if (elFsLoad) {
|
||
elFsLoad.addEventListener("click", function () {
|
||
syncMainFromFsToolbar();
|
||
loadChart(false);
|
||
});
|
||
}
|
||
}
|
||
|
||
window.hubMarketChart = {
|
||
init: async function () {
|
||
if (!marketInited) {
|
||
marketInited = true;
|
||
await loadMeta();
|
||
bind();
|
||
} else {
|
||
readQuery();
|
||
}
|
||
focusMarketChartArea();
|
||
connectChartStream();
|
||
startChartWatchHeartbeat();
|
||
startAutoRefresh();
|
||
await loadChart(false);
|
||
startPriceTagTimer();
|
||
},
|
||
openWith: async function (exKey, sym, tf) {
|
||
if (!marketInited) {
|
||
await this.init();
|
||
}
|
||
if (elExchange && exKey) elExchange.value = exKey;
|
||
if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase();
|
||
if (tf && elTf) elTf.value = tf;
|
||
lastViewKey = "";
|
||
localSeriesVersion = 0;
|
||
updateExchangeDisplay();
|
||
connectChartStream();
|
||
startChartWatchHeartbeat();
|
||
startAutoRefresh();
|
||
await loadChart(false);
|
||
startPriceTagTimer();
|
||
},
|
||
reload: function (force) {
|
||
loadChart(!!force);
|
||
},
|
||
startAutoRefresh: startAutoRefresh,
|
||
stopAutoRefresh: stopAutoRefresh,
|
||
stopChartLive: stopChartLive,
|
||
stopPriceTagTimer: stopPriceTagTimer,
|
||
};
|
||
|
||
document.addEventListener("hub-theme-change", function () {
|
||
applyChartTheme();
|
||
});
|
||
|
||
if (
|
||
document.getElementById("page-market") &&
|
||
!document.getElementById("page-market").classList.contains("hidden")
|
||
) {
|
||
window.hubMarketChart.init();
|
||
}
|
||
})();
|