Files
crypto_monitor/manual_trading_hub/static/chart.js
T
2026-06-26 19:41:17 +08:00

3387 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 直推尾部 K 线;「自动」控制价格轴与视口跟随。
*/
(function () {
const CHART_WATCH_HEARTBEAT_MS = 25000;
const CHART_SSE_FALLBACK_MS = 15000;
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 CHART_TZ_OFFSET_SEC = 8 * 60 * 60;
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
function utcSecToBjDate(utcSec) {
return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000);
}
function formatChartTimeBj(utcSec, withDate) {
const d = utcSecToBjDate(utcSec);
const h = pad2(d.getUTCHours());
const mi = pad2(d.getUTCMinutes());
if (!withDate) return h + ":" + mi;
return (
d.getUTCFullYear() +
"-" +
pad2(d.getUTCMonth() + 1) +
"-" +
pad2(d.getUTCDate()) +
" " +
h +
":" +
mi
);
}
function chartLocalizationBj() {
return {
locale: "zh-CN",
dateFormat: "yyyy-MM-dd",
timeFormatter: function (time) {
if (typeof time === "number") return formatChartTimeBj(time, true);
if (time && typeof time === "object" && time.year) {
return time.year + "-" + pad2(time.month) + "-" + pad2(time.day);
}
return "";
},
tickMarkFormatter: function (time, tickMarkType) {
if (typeof time !== "number") {
if (time && typeof time === "object" && time.year) {
return time.year + "-" + pad2(time.month) + "-" + pad2(time.day);
}
return "";
}
const d = utcSecToBjDate(time);
if (tickMarkType === 0) return String(d.getUTCFullYear());
if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1);
if (tickMarkType === 2) return pad2(d.getUTCDate());
return formatChartTimeBj(time, false);
},
};
}
function buildChartLocalization() {
const loc = chartLocalizationBj();
loc.priceFormatter = function (p) {
return fmtPrice(p);
};
return loc;
}
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 elVolRankBtn = document.getElementById("market-vol-rank-btn");
const elFsVolRankBtn = document.getElementById("market-fs-vol-rank-btn");
const elVolRankSheet = document.getElementById("market-vol-rank-sheet");
const elVolRankAnchor = document.getElementById("market-vol-rank-anchor");
const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs");
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 elPrevCloseLine = document.getElementById("market-prev-close-line");
const elPrevHlLines = document.getElementById("market-prev-hl-lines");
const elDaySplit = document.getElementById("market-day-split");
const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line";
const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines";
const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split";
const BJ_OFFSET_SEC = 8 * 60 * 60;
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 yesterdayPriceLines = [];
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 loadBoolPref(key, defaultValue) {
try {
const raw = localStorage.getItem(key);
if (raw === "1" || raw === "true") return true;
if (raw === "0" || raw === "false") return false;
} catch (_) {}
return !!defaultValue;
}
function saveBoolPref(key, on) {
try {
localStorage.setItem(key, on ? "1" : "0");
} catch (_) {}
}
function loadDaySplitPref() {
return loadBoolPref(DAY_SPLIT_STORAGE_KEY, false);
}
function saveDaySplitPref(on) {
saveBoolPref(DAY_SPLIT_STORAGE_KEY, on);
}
function loadPrevCloseLinePref() {
return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false);
}
function savePrevCloseLinePref(on) {
saveBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, on);
}
function loadPrevHlLinesPref() {
return loadBoolPref(PREV_HL_LINES_STORAGE_KEY, false);
}
function savePrevHlLinesPref(on) {
saveBoolPref(PREV_HL_LINES_STORAGE_KEY, on);
}
function chartResetHour() {
return chartMeta && chartMeta.volume_rank_reset_hour != null
? Number(chartMeta.volume_rank_reset_hour)
: 8;
}
function utcSecToBjParts(utcSec) {
const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000);
return {
y: d.getUTCFullYear(),
m: d.getUTCMonth(),
d: d.getUTCDate(),
h: d.getUTCHours(),
};
}
function tradingDayKeyFromUtcSec(utcSec, resetHour) {
const p = utcSecToBjParts(utcSec);
let y = p.y;
let m = p.m;
let d = p.d;
if (p.h < resetHour) {
const prev = new Date(Date.UTC(y, m, d) - 86400000);
y = prev.getUTCFullYear();
m = prev.getUTCMonth();
d = prev.getUTCDate();
}
return (
y +
"-" +
String(m + 1).padStart(2, "0") +
"-" +
String(d).padStart(2, "0")
);
}
function prevTradingDayKey(tdKey) {
const parts = String(tdKey || "").split("-");
if (parts.length !== 3) return "";
const dt = new Date(Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])));
const prev = new Date(dt.getTime() - 86400000);
return (
prev.getUTCFullYear() +
"-" +
String(prev.getUTCMonth() + 1).padStart(2, "0") +
"-" +
String(prev.getUTCDate()).padStart(2, "0")
);
}
function computePrevTradingDayOhlc(candles, resetHour) {
if (!candles || !candles.length) return null;
const curTd = tradingDayKeyFromUtcSec(candles[candles.length - 1].time, resetHour);
const prevTd = prevTradingDayKey(curTd);
if (!prevTd) return null;
const dayCandles = candles
.filter(function (c) {
return c && tradingDayKeyFromUtcSec(c.time, resetHour) === prevTd;
})
.sort(function (a, b) {
return a.time - b.time;
});
if (!dayCandles.length) return null;
let hi = null;
let lo = null;
dayCandles.forEach(function (c) {
if (!hi || c.high > hi) hi = c.high;
if (!lo || c.low < lo) lo = c.low;
});
const last = dayCandles[dayCandles.length - 1];
return {
close: last.close,
high: hi,
low: lo,
tradingDay: prevTd,
};
}
function syncPrevDayLineUi() {
const closeOn = !!(elPrevCloseLine && elPrevCloseLine.checked);
const hlOn = !!(elPrevHlLines && elPrevHlLines.checked);
savePrevCloseLinePref(closeOn);
savePrevHlLinesPref(hlOn);
updateYesterdayPriceLines();
}
function applyTradingDaySplit(enabled) {
if (window.HubChartDraw && typeof window.HubChartDraw.setTradingDaySplit === "function") {
window.HubChartDraw.setTradingDaySplit(enabled);
}
}
function syncTradingDaySplitUi() {
const on = !!(elDaySplit && elDaySplit.checked);
saveDaySplitPref(on);
applyTradingDaySplit(on);
}
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());
applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref());
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");
}
mountVolRankSheet(chartFullscreen);
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: buildChartLocalization(),
});
}
}
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 },
localization: buildChartLocalization(),
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 clearYesterdayPriceLines() {
if (candleSeries) {
yesterdayPriceLines.forEach(function (m) {
try {
candleSeries.removePriceLine(m);
} catch (e) {}
});
}
yesterdayPriceLines = [];
}
function updateYesterdayPriceLines() {
clearYesterdayPriceLines();
if (!candleSeries || !lastCandles.length) return;
const showClose = !!(elPrevCloseLine && elPrevCloseLine.checked);
const showHl = !!(elPrevHlLines && elPrevHlLines.checked);
if (!showClose && !showHl) return;
const stats = computePrevTradingDayOhlc(lastCandles, chartResetHour());
if (!stats) return;
if (showClose && stats.close != null && Number.isFinite(Number(stats.close))) {
const px = Number(roundToTick(stats.close));
if (Number.isFinite(px)) {
yesterdayPriceLines.push(
candleSeries.createPriceLine({
price: px,
color: "#a78bfa",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "昨收",
})
);
}
}
if (showHl) {
if (stats.high != null && Number.isFinite(Number(stats.high))) {
const hiPx = Number(roundToTick(stats.high));
if (Number.isFinite(hiPx)) {
yesterdayPriceLines.push(
candleSeries.createPriceLine({
price: hiPx,
color: "#ffb84d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "昨高",
})
);
}
}
if (stats.low != null && Number.isFinite(Number(stats.low))) {
const loPx = Number(roundToTick(stats.low));
if (Number.isFinite(loPx)) {
yesterdayPriceLines.push(
candleSeries.createPriceLine({
price: loPx,
color: "#4cd97f",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "昨低",
})
);
}
}
}
}
function viewKey(exKey, sym, tf) {
const ex = String(exKey || "").trim().toLowerCase();
const s = normalizeMarketSymbol(sym);
const t = String(tf || "").trim();
return ex + "|" + s + "|" + t;
}
function lookupSeriesMapEntry(map, vKey) {
if (!map || !vKey) return null;
if (map[vKey]) return map[vKey];
const parts = String(vKey).split("|");
if (parts.length === 3) {
const norm = viewKey(parts[0], parts[1], parts[2]);
if (norm !== vKey && map[norm]) return map[norm];
}
return null;
}
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();
updateYesterdayPriceLines();
}
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 = {};
clearYesterdayPriceLines();
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 prevLastTime = prevLen ? lastCandles[prevLen - 1].time : null;
const merged = mergeCandles(lastCandles, aligned, { prepend: false });
if (
prevLen > 0 &&
merged.length > 0 &&
merged[0].time !== oldestTime &&
merged.length <= prevLen
) {
return false;
}
let patchStart = 0;
if (prevLastTime != null) {
patchStart = merged.findIndex(function (b) {
return b.time >= prevLastTime;
});
if (patchStart < 0) return false;
}
try {
for (let i = patchStart; i < merged.length; i++) {
const bar = merged[i];
candleSeries.update(bar);
volumeSeries.update(buildVolumeBar(bar));
}
} catch (_) {
return false;
}
lastCandles = merged;
indexCandles(lastCandles);
readIndicatorState();
if (indicatorState.ema || indicatorState.macd || indicatorState.rsi) {
try {
updateIndicators();
} catch (indErr) {}
}
updateVisibleRangeMarkers();
updateYesterdayPriceLines();
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();
updateYesterdayPriceLines();
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);
let tailPatched = false;
if (!autoFollow) {
try {
tailPatched = applyTailCandlePatch(aligned);
} catch (_) {
tailPatched = false;
}
}
if (!autoFollow && tailPatched) {
/* 手动模式:增量 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()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return "";
return viewKey(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 handleChartStreamEvent(st) {
if (!st || st.polling) return;
const vKey = currentViewSeriesKey();
if (!vKey) return;
const tails = st.tails || {};
const series = st.series || {};
const tailPack = lookupSeriesMapEntry(tails, vKey);
if (tailPack && tailPack.candles && tailPack.candles.length) {
if (
applyIncomingTailCandles(tailPack.candles, {
price_tick: tailPack.price_tick,
series_version: tailPack.series_version,
chart_version: st.chart_version,
updated_at: tailPack.updated_at || st.updated_at,
})
) {
return;
}
}
const seriesEntry = lookupSeriesMapEntry(series, vKey);
const sVer = seriesEntry ? Number(seriesEntry.series_version) || 0 : 0;
const seriesChanged = sVer > 0 && sVer !== localSeriesVersion;
if (seriesChanged) {
if (lastCandles.length && vKey === lastViewKey) {
void refreshChartTail();
} else if (!lastCandles.length && !chartDataLoading) {
void loadChart(false);
}
return;
}
if (tailPack && lastCandles.length && vKey === lastViewKey && !chartDataLoading) {
void refreshChartTail();
return;
}
if (posContext) updateLivePosPnl();
const ver = Number(st.chart_version) || 0;
if (ver && ver !== localChartVersion) {
localChartVersion = ver;
if (lastCandles.length && vKey === lastViewKey && !chartDataLoading) {
void refreshChartTail();
}
}
}
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 {
handleChartStreamEvent(JSON.parse(ev.data || "{}"));
} 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();
const tick = function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
if (lastCandles.length) {
void refreshChartTail();
} else if (!chartDataLoading) {
void loadChart(false);
}
};
refreshTimer = setInterval(tick, CHART_SSE_FALLBACK_MS);
tick();
}
function stopAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (chartSseReconnectTimer) {
clearTimeout(chartSseReconnectTimer);
chartSseReconnectTimer = null;
}
}
function stopChartLive() {
stopAutoRefresh();
stopChartWatchHeartbeat();
closeChartStream();
void postChartUnwatch();
}
function mountVolRankSheet(forFullscreen) {
if (!elVolRankSheet) return;
const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor;
if (!anchor || elVolRankSheet.parentElement === anchor) return;
anchor.appendChild(elVolRankSheet);
}
function setVolRankBtnActive(btn, on) {
if (!btn) return;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-expanded", on ? "true" : "false");
}
function setVolRankSheetOpen(open) {
const on = !!open;
if (elVolRankSheet) {
elVolRankSheet.classList.toggle("hidden", !on);
elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true");
}
setVolRankBtnActive(elVolRankBtn, on);
setVolRankBtnActive(elFsVolRankBtn, on);
if (on) void loadVolumeRank();
}
function bindVolRankPanel() {
function toggleVolRankSheet() {
const open = elVolRankSheet && elVolRankSheet.classList.contains("hidden");
setVolRankSheetOpen(open);
}
if (elVolRankBtn) elVolRankBtn.addEventListener("click", toggleVolRankSheet);
if (elFsVolRankBtn) elFsVolRankBtn.addEventListener("click", toggleVolRankSheet);
document.addEventListener("pointerdown", function (ev) {
if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return;
const t = ev.target;
if (elVolRankSheet.contains(t)) return;
if (elVolRankBtn && elVolRankBtn.contains(t)) return;
if (elFsVolRankBtn && elFsVolRankBtn.contains(t)) return;
setVolRankSheetOpen(false);
});
}
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 (!row.symbol) return;
if (elSymbol) elSymbol.value = row.symbol;
if (elFsSymbol) elFsSymbol.value = row.symbol;
setVolRankSheetOpen(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();
}
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();
bindVolRankPanel();
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 = "";
if (elVolRankSheet && !elVolRankSheet.classList.contains("hidden")) {
void loadVolumeRank();
}
loadChart(false);
});
}
if (elSymbol) {
elSymbol.addEventListener("keydown", function (e) {
if (e.key === "Enter") loadChart(false);
});
elSymbol.addEventListener("change", function () {
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();
});
});
if (elPrevCloseLine) {
elPrevCloseLine.checked = loadPrevCloseLinePref();
elPrevCloseLine.addEventListener("change", syncPrevDayLineUi);
}
if (elPrevHlLines) {
elPrevHlLines.checked = loadPrevHlLinesPref();
elPrevHlLines.addEventListener("change", syncPrevDayLineUi);
}
if (elDaySplit) {
elDaySplit.checked = loadDaySplitPref();
elDaySplit.addEventListener("change", syncTradingDaySplitUi);
applyTradingDaySplit(elDaySplit.checked);
}
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();
}
})();