diff --git "a/frontend/app.js" "b/frontend/app.js"
new file mode 100644--- /dev/null
+++ "b/frontend/app.js"
@@ -0,0 +1,5134 @@
+(() => {
+const API_BASE = (window.location.origin && window.location.origin !== 'null')
+ ? window.location.origin
+ : '';
+ function setBootPhase(phase) {
+ if (document.body && document.body.dataset) {
+ document.body.dataset.bootPhase = phase;
+ }
+ }
+ setBootPhase('script-start');
+ function buildWebSocketUrl(apiBase, path) {
+ const fallbackOrigin = (window.location.origin && window.location.origin !== 'null')
+ ? window.location.origin
+ : 'http://127.0.0.1';
+ const resolvedUrl = new URL(path, apiBase || fallbackOrigin);
+ resolvedUrl.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return resolvedUrl.toString();
+ }
+ const HEADER_VISIBILITY_KEY = 'aiforecast_header_visibility';
+ window.__AIFORECAST_API_BASE = API_BASE;
+
+ /* ── DOM refs ──────────────────────────────── */
+ /* ── DOM refs ──────────────────────────────── */
+ const symbolSearch = document.getElementById('symbolSearch');
+ const searchResults = document.getElementById('searchResults');
+ const timeframeSelect = document.getElementById('timeframeSelect');
+ const horizonInput = document.getElementById('horizonInput');
+ const refreshBtn = document.getElementById('refreshBtn');
+ const fitBtn = document.getElementById('fitBtn');
+ const zoomOutBtn = document.getElementById('zoomOutBtn');
+ const zoomInBtn = document.getElementById('zoomInBtn');
+ const panLeftBtn = document.getElementById('panLeftBtn');
+ const panRightBtn = document.getElementById('panRightBtn');
+ const statusText = document.getElementById('statusText');
+ const statusDot = document.getElementById('statusDot');
+ const statusPill = document.getElementById('statusPill');
+ const chartGauges = document.getElementById('chartGauges');
+ const terminalLoader = document.getElementById('terminalLoader');
+ const loaderText = document.getElementById('loaderText');
+ const analysisPanel = document.getElementById('analysisPanel');
+ const marketStatusBar = document.getElementById('marketStatusBar');
+ const indicatorSelect = document.getElementById('indicatorSelect');
+ const kronosToggle = document.getElementById('kronosToggle');
+ const timesfmToggle = document.getElementById('timesfmToggle');
+ const chronosToggle = document.getElementById('chronosToggle');
+ const themeToggleBtn = document.getElementById('themeToggleBtn');
+ const cursorTrailCanvas = document.getElementById('cursorTrailCanvas');
+ const cursorCore = document.getElementById('cursorCore');
+ const headerEl = document.querySelector('.hdr');
+ const headerControlsEl = document.querySelector('.ctrls');
+ const headerToggleBtn = document.getElementById('headerToggleBtn');
+ const toggleMarketBtn = document.getElementById('toggleMarketBtn');
+ const chartLogoOverlay = document.querySelector('.chart-logo-overlay');
+ const ambientUfoScene = document.querySelector('.ambient-ufo-scene');
+ const ambientUfoCraft = ambientUfoScene?.querySelector('.ambient-ufo-craft') || null;
+ const ambientUfoImpact = ambientUfoScene?.querySelector('.ambient-ufo-impact') || null;
+
+ function setupChartNavigationCluster() {
+ const controlButtons = [zoomOutBtn, zoomInBtn, panLeftBtn, panRightBtn].filter(Boolean);
+ if (!controlButtons.length || !zoomOutBtn?.parentElement) {
+ return;
+ }
+ controlButtons.forEach((button) => button.classList.add('chart-nav-btn'));
+ let cluster = zoomOutBtn.parentElement.querySelector('.chart-nav-cluster');
+ if (!cluster) {
+ cluster = document.createElement('div');
+ cluster.className = 'chart-nav-cluster';
+ cluster.setAttribute('aria-label', 'Dieu huong bieu do');
+ zoomOutBtn.insertAdjacentElement('beforebegin', cluster);
+ }
+ controlButtons.forEach((button) => cluster.appendChild(button));
+ }
+
+ setupChartNavigationCluster();
+
+ function reportFrontendStartupError(source, errorLike) {
+ const message = String(errorLike?.message || errorLike || 'Unknown frontend error');
+ const detail = String(errorLike?.stack || message);
+ window.__AIFORECAST_BOOT_ERROR = detail;
+ console.error(source, errorLike);
+ if (statusText) statusText.textContent = `Loi giao dien: ${message}`;
+ if (statusDot) statusDot.className = 'dot error';
+ if (statusPill) statusPill.className = 'status-pill error';
+ if (terminalLoader) terminalLoader.classList.remove('active');
+ }
+ window.addEventListener('error', (event) => {
+ reportFrontendStartupError('[window-error]', event?.error || event?.message || event);
+ });
+
+ window.addEventListener('unhandledrejection', (event) => {
+ reportFrontendStartupError('[unhandledrejection]', event?.reason || event);
+ });
+
+ function escapeHtml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ }
+
+ /* ── State ─────────────────────────────────── */
+ let currentSymbol = 'XAUUSD';
+ let currentInterval = '1d';
+ let ws = null;
+ let chartFetchController = null;
+ let analysisFetchController = null;
+ let analysisRequestPromise = null;
+ let analysisRequestKey = null;
+ let analysisRetryTimer = null;
+ let activeChartContext = { symbol: null, interval: null };
+ let activeForecastContext = { symbol: null, interval: null, modelSignature: null, ready: false };
+ const symbolMap = new Map();
+ const symbolMetaMap = new Map();
+ let currentPriceFormat = { precision: 2, minMove: 0.01 };
+ const timeframeMap = {
+ '1m': '1 Phút', '5m': '5 Phút', '15m': '15 Phút',
+ '1h': '1 Giờ', '4h': '4 Giờ', '1d': '1 Ngày', '1w': '1 Tuần'
+ };
+ const timeframeSecondsMap = {
+ '1m': 60,
+ '5m': 300,
+ '15m': 900,
+ '1h': 3600,
+ '4h': 14400,
+ '1d': 86400,
+ '1w': 604800,
+ };
+
+ /* ── Indicator Calculation Helpers ────────── */
+ /* ── WebSocket Management ────────────────────── */
+ const THEME_SUN_ICON = `
+ `;
+ const THEME_MOON_ICON = `
+ `;
+
+ function setLogoOverlayLayoutMode(preset = 1) {
+ const layoutMode = preset > 1 ? 'multi' : 'single';
+ if (chartLogoOverlay) chartLogoOverlay.dataset.layoutMode = layoutMode;
+ if (ambientUfoScene) ambientUfoScene.dataset.layoutMode = layoutMode;
+ if (ambientUfoScene) ambientUfoScene.dispatchEvent(new Event('ufo-layout-change'));
+ }
+
+ (() => {
+ if (!ambientUfoScene || !ambientUfoCraft || !ambientUfoImpact) return;
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+
+ const motion = {
+ x: 0,
+ y: 0,
+ vx: 126,
+ vy: 88,
+ lastTs: 0,
+ started: false,
+ impactUntil: 0,
+ impactX: 0,
+ impactY: 0,
+ };
+
+ function sceneBounds() {
+ const rect = ambientUfoScene.getBoundingClientRect();
+ const craftW = ambientUfoCraft.offsetWidth || 44;
+ const craftH = ambientUfoCraft.offsetHeight || 20;
+ return {
+ width: Math.max(0, rect.width),
+ height: Math.max(0, rect.height),
+ craftW,
+ craftH,
+ padding: 10,
+ };
+ }
+
+ function resetUfoMotion() {
+ const bounds = sceneBounds();
+ const isMultiPane = Boolean(window.Workspace && Workspace.layoutPreset > 1);
+ motion.x = Math.max(bounds.padding, bounds.width * (isMultiPane ? 0.84 : 0.58));
+ motion.y = Math.max(bounds.padding, bounds.height * (isMultiPane ? 0.06 : 0.22));
+ motion.vx = isMultiPane ? -168 : (motion.vx >= 0 ? 126 : -126);
+ motion.vy = isMultiPane ? 108 : (motion.vy >= 0 ? 88 : -88);
+ motion.started = true;
+ }
+
+ function triggerUfoImpact(cx, cy, ts) {
+ motion.impactX = cx;
+ motion.impactY = cy;
+ motion.impactUntil = ts + 220;
+ }
+
+ function renderImpact(ts) {
+ if (!motion.impactUntil || ts >= motion.impactUntil) {
+ ambientUfoImpact.style.opacity = '0';
+ return;
+ }
+
+ const remaining = (motion.impactUntil - ts) / 220;
+ const scale = 0.72 + (1 - remaining) * 0.9;
+ ambientUfoImpact.style.opacity = String(Math.max(0, remaining) * 0.52);
+ ambientUfoImpact.style.transform = `translate3d(${motion.impactX - 14}px, ${motion.impactY - 14}px, 0) scale(${scale})`;
+ }
+
+ function stepAmbientUfo(ts) {
+ const bounds = sceneBounds();
+ if (!bounds.width || !bounds.height) {
+ motion.lastTs = ts;
+ window.requestAnimationFrame(stepAmbientUfo);
+ return;
+ }
+
+ if (!motion.started) {
+ resetUfoMotion();
+ motion.lastTs = ts;
+ }
+
+ const dt = Math.min(0.032, Math.max(0.008, (ts - (motion.lastTs || ts)) / 1000 || 0.016));
+ motion.lastTs = ts;
+ motion.x += motion.vx * dt;
+ motion.y += motion.vy * dt;
+
+ const minX = bounds.padding;
+ const minY = bounds.padding;
+ const maxX = Math.max(minX, bounds.width - bounds.craftW - bounds.padding);
+ const maxY = Math.max(minY, bounds.height - bounds.craftH - bounds.padding);
+
+ let collided = false;
+ let impactCx = motion.x + bounds.craftW * 0.5;
+ let impactCy = motion.y + bounds.craftH * 0.5;
+
+ if (motion.x <= minX) {
+ motion.x = minX;
+ motion.vx = Math.abs(motion.vx);
+ collided = true;
+ impactCx = minX + 2;
+ } else if (motion.x >= maxX) {
+ motion.x = maxX;
+ motion.vx = -Math.abs(motion.vx);
+ collided = true;
+ impactCx = maxX + bounds.craftW - 2;
+ }
+
+ if (motion.y <= minY) {
+ motion.y = minY;
+ motion.vy = Math.abs(motion.vy);
+ collided = true;
+ impactCy = minY + 2;
+ } else if (motion.y >= maxY) {
+ motion.y = maxY;
+ motion.vy = -Math.abs(motion.vy);
+ collided = true;
+ impactCy = maxY + bounds.craftH - 2;
+ }
+
+ if (collided) {
+ triggerUfoImpact(impactCx, impactCy, ts);
+ }
+
+ const tilt = Math.max(-16, Math.min(16, motion.vx * 0.05 + motion.vy * 0.025));
+ const driftScale = 0.94 + ((Math.abs(motion.vx) + Math.abs(motion.vy)) / 260) * 0.08;
+ ambientUfoCraft.style.transform = `translate3d(${motion.x}px, ${motion.y}px, 0) rotate(${tilt}deg) scale(${driftScale})`;
+ renderImpact(ts);
+ window.requestAnimationFrame(stepAmbientUfo);
+ }
+
+ window.addEventListener('resize', () => {
+ motion.started = false;
+ });
+ ambientUfoScene.addEventListener('ufo-layout-change', () => {
+ motion.started = false;
+ });
+
+ window.requestAnimationFrame(stepAmbientUfo);
+ })();
+
+ (() => {
+ if (!cursorTrailCanvas || !cursorCore || window.matchMedia('(pointer: coarse)').matches) return;
+
+ const ctx = cursorTrailCanvas.getContext('2d');
+ if (!ctx) return;
+
+ const interactiveSelector = 'button, input, select, .search-item, .compact-gauge-card, .explorer-symbol-card, .explorer-cat-item, .dash-close, .dash-col';
+ const trail = [];
+ const pointer = {
+ x: window.innerWidth / 2,
+ y: window.innerHeight / 2,
+ lastX: window.innerWidth / 2,
+ lastY: window.innerHeight / 2,
+ velocity: 0,
+ lastMoveTs: 0,
+ };
+ let rafId = 0;
+ let dpr = 1;
+
+ function resizeTrailCanvas() {
+ dpr = Math.max(1, window.devicePixelRatio || 1);
+ cursorTrailCanvas.width = Math.round(window.innerWidth * dpr);
+ cursorTrailCanvas.height = Math.round(window.innerHeight * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ }
+
+ function spawnEmber(x, y, vx, vy, speed) {
+ trail.push({
+ x,
+ y,
+ vx,
+ vy,
+ speed,
+ life: 1,
+ radius: Math.min(44, 10 + speed * 0.12),
+ stretch: Math.min(3.4, 1 + speed * 0.008),
+ });
+ }
+
+ function drawTrail(now) {
+ ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
+
+ const idleMs = now - pointer.lastMoveTs;
+ if (idleMs > 90) {
+ pointer.velocity *= 0.9;
+ }
+
+ for (let i = trail.length - 1; i >= 0; i -= 1) {
+ const ember = trail[i];
+ ember.life -= 0.055;
+ ember.x += ember.vx;
+ ember.y += ember.vy;
+ ember.radius *= 0.985;
+ ember.vx *= 0.94;
+ ember.vy *= 0.94;
+
+ if (ember.life <= 0.02 || ember.radius <= 1.5) {
+ trail.splice(i, 1);
+ continue;
+ }
+
+ const alpha = ember.life * 0.85;
+ const angle = Math.atan2(ember.vy, ember.vx || 0.0001);
+ ctx.save();
+ ctx.translate(ember.x, ember.y);
+ ctx.rotate(angle);
+ ctx.scale(ember.stretch, 1);
+
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, ember.radius);
+ gradient.addColorStop(0, `rgba(235, 255, 255, ${alpha})`);
+ gradient.addColorStop(0.22, `rgba(124, 246, 255, ${alpha * 0.95})`);
+ gradient.addColorStop(0.5, `rgba(38, 196, 255, ${alpha * 0.48})`);
+ gradient.addColorStop(1, 'rgba(38, 196, 255, 0)');
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(0, 0, ember.radius, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ if (trail.length > 0 || pointer.velocity > 0.12) {
+ rafId = requestAnimationFrame(drawTrail);
+ } else {
+ rafId = 0;
+ document.body.classList.remove('cursor-active');
+ }
+ }
+
+ function ensureTrailLoop() {
+ if (!rafId) {
+ rafId = requestAnimationFrame(drawTrail);
+ }
+ }
+
+ resizeTrailCanvas();
+ window.addEventListener('resize', resizeTrailCanvas);
+
+ document.addEventListener('mousemove', (event) => {
+ const now = performance.now();
+ const dx = event.clientX - pointer.lastX;
+ const dy = event.clientY - pointer.lastY;
+ const dt = Math.max(16, now - (pointer.lastMoveTs || now));
+ const speed = Math.sqrt(dx * dx + dy * dy) / dt * 26;
+
+ pointer.x = event.clientX;
+ pointer.y = event.clientY;
+ pointer.lastMoveTs = now;
+ pointer.lastX = event.clientX;
+ pointer.lastY = event.clientY;
+ pointer.velocity = pointer.velocity * 0.35 + speed * 0.65;
+
+ const emberCount = Math.max(1, Math.min(7, Math.round(pointer.velocity / 3.2)));
+ for (let i = 0; i < emberCount; i += 1) {
+ const ratio = i / emberCount;
+ const jitterX = (Math.random() - 0.5) * 8;
+ const jitterY = (Math.random() - 0.5) * 8;
+ spawnEmber(
+ event.clientX - dx * ratio + jitterX,
+ event.clientY - dy * ratio + jitterY,
+ -dx * 0.08 * (0.5 + Math.random()),
+ -dy * 0.08 * (0.5 + Math.random()),
+ pointer.velocity
+ );
+ }
+
+ cursorCore.style.transform = `translate3d(${pointer.x}px, ${pointer.y}px, 0) translate(-50%, -50%)`;
+ document.body.classList.add('cursor-active');
+ ensureTrailLoop();
+ }, { passive: true });
+
+ document.addEventListener('mouseleave', () => {
+ trail.length = 0;
+ pointer.velocity = 0;
+ document.body.classList.remove('cursor-active');
+ });
+
+ document.addEventListener('mousedown', () => {
+ document.body.classList.add('cursor-press');
+ });
+
+ document.addEventListener('mouseup', () => {
+ document.body.classList.remove('cursor-press');
+ });
+
+ document.addEventListener('mouseover', (event) => {
+ const target = event.target instanceof Element ? event.target.closest(interactiveSelector) : null;
+ document.body.classList.toggle('cursor-hover', Boolean(target));
+ });
+ })();
+
+ function connectWS(symbol, interval = currentInterval || timeframeSelect.value) {
+ if (window.Workspace && Workspace.layoutPreset > 1) {
+ if (ws) {
+ ws.close();
+ ws = null;
+ }
+ return;
+ }
+ if (ws) {
+ ws.close();
+ ws = null;
+ }
+
+ const wsUrl = buildWebSocketUrl(
+ API_BASE,
+ `/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`,
+ );
+
+ console.log(`[WS] Connecting to ${wsUrl}`);
+ const socket = new WebSocket(wsUrl);
+ ws = socket;
+
+ socket.onmessage = (event) => {
+ if (ws !== socket) return;
+ let data;
+ try {
+ data = JSON.parse(event.data);
+ } catch (error) {
+ console.warn('[WS] Invalid message payload', error);
+ return;
+ }
+ if (data.error) return;
+
+ // Handle ping from server (P2)
+ if (data.type === 'ping') {
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
+ }
+ return;
+ }
+
+ // Update current candle if price changed
+ const lastCandle = lastCandleData;
+ if (lastCandle && data.price && shouldMutateRealtimeCandle(symbol)) {
+ const update = {
+ time: lastCandle.time,
+ open: lastCandle.open,
+ high: Math.max(lastCandle.high, data.price),
+ low: Math.min(lastCandle.low, data.price),
+ close: data.price
+ };
+ candleSeries.update(update);
+ lastCandleData = update;
+ }
+
+ };
+
+ socket.onclose = () => {
+ console.log('[WS] Disconnected');
+ if (ws === socket) {
+ ws = null;
+ }
+ // Reconnect after 5s if still active
+ setTimeout(() => {
+ const activeInterval = currentInterval || timeframeSelect.value;
+ if (
+ (!window.Workspace || Workspace.layoutPreset === 1)
+ && currentSymbol === symbol
+ && activeInterval === interval
+ && !ws
+ ) {
+ connectWS(symbol, interval);
+ }
+ }, 5000);
+ };
+
+ socket.onerror = (error) => {
+ console.warn('[WS] Socket error', error);
+ };
+ }
+
+ let lastCandleData = null;
+
+ /* ── Market Status & Overview ────────────────── */
+ async function refreshMarketStatus() {
+ try {
+ const data = await apiRequest('/api/market-status');
+ if (!marketStatusBar) {
+ return;
+ }
+ const segmentMarkup = data.markets.map(m => `
+
+ `).join('');
+ marketStatusBar.innerHTML = `
+
+
${segmentMarkup}
+
${segmentMarkup}
+
+ `;
+ window.requestAnimationFrame(() => syncHeaderMenuScale());
+ } catch (e) {
+ console.warn('Market status fetch failed', e);
+ }
+ }
+
+
+ /* ── Search Logic ───────────────────────────── */
+ let searchTimeout = null;
+ symbolSearch.oninput = (e) => {
+ clearTimeout(searchTimeout);
+ const q = e.target.value.trim();
+ if (!q) {
+ searchResults.classList.remove('visible');
+ return;
+ }
+ searchTimeout = setTimeout(async () => {
+ try {
+ const data = await apiRequest(`/api/search?q=${encodeURIComponent(q)}`);
+ renderSearchResults(data.results);
+ } catch (e) {
+ console.error('Search failed', e);
+ }
+ }, 300);
+ };
+
+ function renderSearchResults(results) {
+ if (!results.length) {
+ searchResults.classList.remove('visible');
+ return;
+ }
+ searchResults.innerHTML = results.map(r => `
+
+
+ ${escapeHtml(r.symbol)}
+ ${escapeHtml(r.label)}
+
+
+ ${escapeHtml(r.category)}
+
+
+ `).join('');
+ searchResults.classList.add('visible');
+ }
+
+ if (searchResults) {
+ searchResults.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const item = target ? target.closest('.search-item[data-symbol]') : null;
+ if (!item) return;
+ const symbol = item.dataset.symbol;
+ if (symbol) {
+ switchSymbol(symbol);
+ }
+ });
+ }
+
+ /* ── Switch Logic ───────────────────────────── */
+ async function switchSymbol(symbol, options = {}) {
+ if (window.Workspace && Workspace.layoutPreset > 1 && !options.forcePrimary) {
+ applySymbolToActivePane(symbol);
+ return;
+ }
+ currentSymbol = symbol;
+ symbolSearch.value = symbol;
+ searchResults.classList.remove('visible');
+
+ // Update labels immediately for better UX responsiveness
+ const sLabel = symbolMap.get(symbol) || symbol;
+ updateStatus(`${sLabel} · ${currentInterval} — Đang nạp...`, 'loading');
+
+ // 2. Load new data with a real context reset
+ await refreshChart({ forceContextReset: true });
+ connectWS(symbol);
+ }
+
+ /* ── Chart init ────────────────────────────── */
+ const chartEl = document.getElementById('chart');
+ const MODEL_CONFIG = window.AIFORECAST_MODEL_CONFIG;
+ if (!MODEL_CONFIG) {
+ throw new Error('AIFORECAST_MODEL_CONFIG is missing');
+ }
+
+ const CHART_RIGHT_OFFSET = 50;
+ const CHART_DEFAULT_BAR_SPACING = 6;
+ const CHART_MIN_BAR_SPACING = 2;
+ const CHART_MAX_BAR_SPACING = 42;
+ const CHART_ZOOM_FACTOR = 1.04;
+ const CHART_PAN_STEP_RATIO = 0.16;
+ const CHART_VIEWPORT_STORAGE_KEY = 'aiforecast_chart_viewports_v3';
+
+ function loadPersistedChartViewportState() {
+ try {
+ const raw = window.localStorage?.getItem(CHART_VIEWPORT_STORAGE_KEY);
+ if (!raw) {
+ return { sharedBarSpacing: CHART_DEFAULT_BAR_SPACING, panes: {} };
+ }
+ const parsed = JSON.parse(raw);
+ return {
+ sharedBarSpacing: Number(parsed?.sharedBarSpacing),
+ panes: parsed?.panes && typeof parsed.panes === 'object' ? parsed.panes : {},
+ };
+ } catch (error) {
+ console.warn('[chart] load persisted viewport failed', error);
+ return { sharedBarSpacing: CHART_DEFAULT_BAR_SPACING, panes: {} };
+ }
+ }
+
+ const persistedChartViewportState = loadPersistedChartViewportState();
+ const persistedChartViewportEntries = persistedChartViewportState.panes;
+ let sharedChartBarSpacing = Number.isFinite(persistedChartViewportState.sharedBarSpacing)
+ ? persistedChartViewportState.sharedBarSpacing
+ : CHART_DEFAULT_BAR_SPACING;
+ let chartViewportPersistTimer = null;
+ const chartViewportTrackingRegistry = new WeakMap();
+ const FORECAST_PALETTE = {
+ up: {
+ line: '#16a34a',
+ lineSoft: 'rgba(22, 163, 74, 0.34)',
+ band: 'rgba(34, 197, 94, 0.34)',
+ },
+ down: {
+ line: '#f87171',
+ lineSoft: 'rgba(248, 113, 113, 0.32)',
+ band: 'rgba(248, 113, 113, 0.28)',
+ },
+ flat: {
+ line: '#eab308',
+ lineSoft: 'rgba(234, 179, 8, 0.34)',
+ band: 'rgba(250, 204, 21, 0.30)',
+ },
+ };
+ const FORECAST_MODEL_ORDER = MODEL_CONFIG.modelOrder;
+ const AI_MODEL_LABELS = MODEL_CONFIG.modelLabels;
+ const DEFAULT_AI_MODEL_SELECTION = MODEL_CONFIG.defaultSelection;
+ const AI_MODEL_TONE_PALETTE = MODEL_CONFIG.modelTonePalette;
+ const AI_MODEL_SERIES_STYLE = MODEL_CONFIG.modelSeriesStyle;
+
+ function createEmptyForecastModelLines() {
+ return MODEL_CONFIG.createEmptyLines();
+ }
+
+ const LIVE_PRICE_ONLY_SYMBOLS = new Set([
+ 'USDX',
+ 'EURX',
+ 'GBPX',
+ 'CHFX',
+ 'JPYX',
+ 'CADX',
+ 'AUDX',
+ 'NZDX',
+ ]);
+
+ function shouldMutateRealtimeCandle(symbol) {
+ return !LIVE_PRICE_ONLY_SYMBOLS.has(String(symbol || '').toUpperCase());
+ }
+
+ function normalizePaneAiModels(selection = null) {
+ return MODEL_CONFIG.normalizeSelection(selection);
+ }
+
+ function getPaneAiModelSignature(selection = null) {
+ return MODEL_CONFIG.signature(selection);
+ }
+
+ function clampChartBarSpacing(value) {
+ const numericValue = Number(value);
+ if (!Number.isFinite(numericValue)) {
+ return CHART_DEFAULT_BAR_SPACING;
+ }
+ return Math.max(CHART_MIN_BAR_SPACING, Math.min(CHART_MAX_BAR_SPACING, numericValue));
+ }
+ sharedChartBarSpacing = clampChartBarSpacing(sharedChartBarSpacing);
+
+ function schedulePersistChartViewportState() {
+ if (chartViewportPersistTimer) {
+ clearTimeout(chartViewportPersistTimer);
+ }
+ chartViewportPersistTimer = setTimeout(() => {
+ chartViewportPersistTimer = null;
+ try {
+ window.localStorage?.setItem(
+ CHART_VIEWPORT_STORAGE_KEY,
+ JSON.stringify({
+ sharedBarSpacing,
+ panes: persistedChartViewportEntries,
+ }),
+ );
+ } catch (error) {
+ console.warn('[chart] persist viewport failed', error);
+ }
+ }, 120);
+ }
+
+ function normalizeVisibleLogicalRange(range) {
+ const from = Number(range?.from);
+ const to = Number(range?.to);
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from > to) {
+ return null;
+ }
+ return { from, to };
+ }
+
+ function buildChartViewportContext(symbol, interval) {
+ if (!symbol || !interval) return null;
+ return { symbol, interval };
+ }
+
+ function readChartViewportSnapshot(chartInstance) {
+ if (!chartInstance || typeof chartInstance.timeScale !== 'function') {
+ return null;
+ }
+ const timeScaleApi = chartInstance.timeScale();
+ const logicalRange = normalizeVisibleLogicalRange(
+ typeof timeScaleApi.getVisibleLogicalRange === 'function'
+ ? timeScaleApi.getVisibleLogicalRange()
+ : null,
+ );
+ if (!logicalRange) {
+ return null;
+ }
+ const timeScaleOptions = typeof timeScaleApi.options === 'function'
+ ? timeScaleApi.options()
+ : {};
+ return {
+ logicalRange,
+ barSpacing: clampChartBarSpacing(timeScaleOptions?.barSpacing ?? sharedChartBarSpacing),
+ rightOffset: CHART_RIGHT_OFFSET,
+ };
+ }
+
+ function persistChartViewportSnapshot(chartKey, chartInstance, context) {
+ if (!chartKey || !context?.symbol || !context?.interval) {
+ return;
+ }
+ const snapshot = readChartViewportSnapshot(chartInstance);
+ if (!snapshot) {
+ return;
+ }
+ sharedChartBarSpacing = clampChartBarSpacing(snapshot.barSpacing);
+ persistedChartViewportEntries[chartKey] = {
+ symbol: context.symbol,
+ interval: context.interval,
+ logicalRange: snapshot.logicalRange,
+ barSpacing: sharedChartBarSpacing,
+ updatedAt: Date.now(),
+ };
+ schedulePersistChartViewportState();
+ }
+
+ function getPersistedChartViewportSnapshot(chartKey, context) {
+ if (!chartKey || !context?.symbol || !context?.interval) {
+ return null;
+ }
+ const entry = persistedChartViewportEntries[chartKey];
+ if (!entry || entry.symbol !== context.symbol || entry.interval !== context.interval) {
+ return null;
+ }
+ const logicalRange = normalizeVisibleLogicalRange(entry.logicalRange);
+ if (!logicalRange) {
+ return null;
+ }
+ return {
+ logicalRange,
+ barSpacing: clampChartBarSpacing(entry.barSpacing ?? sharedChartBarSpacing),
+ rightOffset: CHART_RIGHT_OFFSET,
+ };
+ }
+
+ function alignChartViewportToReservedSpace(chartKey, chartInstance, context, options = {}) {
+ if (!chartInstance || typeof chartInstance.timeScale !== 'function') {
+ return false;
+ }
+ const timeScaleApi = chartInstance.timeScale();
+ const snapshot = readChartViewportSnapshot(chartInstance);
+ const logicalRange = normalizeVisibleLogicalRange(snapshot?.logicalRange);
+ const anchorTo = resolveChartRightEdgeAnchor(chartKey);
+ const rightOffset = resolveChartRightOffset(chartKey);
+ const barSpacing = clampChartBarSpacing(options.barSpacing ?? sharedChartBarSpacing);
+
+ applyChartViewport(chartInstance, {
+ rightOffset,
+ barSpacing,
+ });
+
+ if (
+ logicalRange &&
+ Number.isFinite(anchorTo) &&
+ typeof timeScaleApi.setVisibleLogicalRange === 'function'
+ ) {
+ const span = Math.max(1, logicalRange.to - logicalRange.from);
+ timeScaleApi.setVisibleLogicalRange({
+ from: anchorTo - span,
+ to: anchorTo,
+ });
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ return true;
+ }
+
+ if (options.allowFitFallback !== false) {
+ fitChartWithOffset(chartInstance, rightOffset);
+ }
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ return false;
+ }
+
+ function restoreOrFitChartViewport(chartKey, chartInstance, context) {
+ const persistedSnapshot = getPersistedChartViewportSnapshot(chartKey, context);
+ if (persistedSnapshot && chartInstance && typeof chartInstance.timeScale === 'function') {
+ sharedChartBarSpacing = clampChartBarSpacing(persistedSnapshot.barSpacing);
+ applyChartViewport(chartInstance, {
+ rightOffset: resolveChartRightOffset(chartKey),
+ barSpacing: sharedChartBarSpacing,
+ });
+ const timeScaleApi = chartInstance.timeScale();
+ if (typeof timeScaleApi.setVisibleLogicalRange === 'function') {
+ timeScaleApi.setVisibleLogicalRange(persistedSnapshot.logicalRange);
+ }
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ return true;
+ }
+ return alignChartViewportToReservedSpace(chartKey, chartInstance, context, {
+ allowFitFallback: true,
+ });
+ }
+
+ function registerChartViewportTracking(chartInstance, chartKey, getContext) {
+ if (!chartInstance || typeof chartInstance.timeScale !== 'function') {
+ return () => {};
+ }
+ const existingUnsubscribe = chartViewportTrackingRegistry.get(chartInstance);
+ if (existingUnsubscribe) {
+ return existingUnsubscribe;
+ }
+ const timeScaleApi = chartInstance.timeScale();
+ const handleRangeChange = () => {
+ const context = typeof getContext === 'function' ? getContext() : null;
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ };
+ if (typeof timeScaleApi.subscribeVisibleLogicalRangeChange === 'function') {
+ timeScaleApi.subscribeVisibleLogicalRangeChange(handleRangeChange);
+ }
+ const unsubscribe = () => {
+ if (typeof timeScaleApi.unsubscribeVisibleLogicalRangeChange === 'function') {
+ timeScaleApi.unsubscribeVisibleLogicalRangeChange(handleRangeChange);
+ }
+ chartViewportTrackingRegistry.delete(chartInstance);
+ };
+ chartViewportTrackingRegistry.set(chartInstance, unsubscribe);
+ return unsubscribe;
+ }
+
+ function buildSharedRightPriceScaleOptions() {
+ return {
+ borderColor: 'rgba(40, 80, 140, 0.25)',
+ autoScale: true,
+ scaleMargins: { top: 0.08, bottom: 0.08 },
+ };
+ }
+
+ function buildSharedTimeScaleOptions(overrides = {}) {
+ return {
+ borderColor: 'rgba(40, 80, 140, 0.25)',
+ timeVisible: true,
+ secondsVisible: false,
+ fixLeftEdge: false,
+ fixRightEdge: false,
+ rightOffset: CHART_RIGHT_OFFSET,
+ barSpacing: sharedChartBarSpacing,
+ ...overrides,
+ };
+ }
+
+ function getPaneStateForChartKey(chartKey) {
+ if (!chartKey || !window.Workspace || typeof Workspace.getPane !== 'function') {
+ return null;
+ }
+ return Workspace.getPane(chartKey) || null;
+ }
+
+ function resolveReservedFutureBars(pane) {
+ return Math.max(0, Number(pane?.horizon ?? 0) || 0);
+ }
+
+ function resolveLoadedFutureBars(pane) {
+ return Math.max(0, Number(pane?.loadedFutureBars ?? 0) || 0);
+ }
+
+ function resolveHistoricalBarCount(pane) {
+ return Math.max(0, Number(pane?.historicalBarCount ?? 0) || 0);
+ }
+
+ function resolveChartRightOffset(chartKey) {
+ return CHART_RIGHT_OFFSET;
+ }
+
+ function resolveChartRightEdgeAnchor(chartKey) {
+ const pane = getPaneStateForChartKey(chartKey);
+ if (!pane) {
+ return null;
+ }
+ const historicalBarCount = resolveHistoricalBarCount(pane);
+ if (historicalBarCount <= 0) {
+ return null;
+ }
+ const reservedFutureBars = resolveReservedFutureBars(pane);
+ return (historicalBarCount - 1) + reservedFutureBars + CHART_RIGHT_OFFSET;
+ }
+
+ function resolveIntervalStepSeconds(interval) {
+ return timeframeSecondsMap[String(interval || '').toLowerCase()] || timeframeSecondsMap['1d'];
+ }
+
+ function buildReservedFutureWhitespacePoints(lastTime, interval, horizon) {
+ const numericLastTime = Number(lastTime);
+ const safeHorizon = Math.max(0, Number(horizon) || 0);
+ if (!Number.isFinite(numericLastTime) || safeHorizon <= 0) {
+ return [];
+ }
+ const stepSeconds = resolveIntervalStepSeconds(interval);
+ return Array.from({ length: safeHorizon }, (_, index) => ({
+ time: Math.round(numericLastTime + (stepSeconds * (index + 1))),
+ }));
+ }
+
+ function normalizeFutureTimesFromPoints(points = []) {
+ return Array.isArray(points)
+ ? points
+ .slice(1)
+ .map((point) => Number(point?.time))
+ .filter(Number.isFinite)
+ : [];
+ }
+
+ function refreshPaneReservedFutureSpace(pane, explicitFutureTimes = null) {
+ const reserveSeries = pane?.forecastSeries?.reserve;
+ if (!reserveSeries || typeof reserveSeries.setData !== 'function') {
+ return [];
+ }
+ const futureTimes = Array.isArray(explicitFutureTimes) && explicitFutureTimes.length
+ ? explicitFutureTimes.map((time) => Number(time)).filter(Number.isFinite)
+ : buildReservedFutureWhitespacePoints(
+ pane?.lastCandleData?.time,
+ pane?.interval,
+ pane?.horizon,
+ ).map((point) => point.time);
+ const reservePoints = futureTimes.map((time) => ({ time }));
+ reserveSeries.setData(reservePoints);
+ pane.loadedFutureBars = futureTimes.length;
+ return reservePoints;
+ }
+
+ function getAllOpenChartTargets() {
+ const targets = new Map();
+ if (window.Workspace?.panes) {
+ Workspace.panes.forEach((pane) => {
+ if (!pane?.chartInstance) {
+ return;
+ }
+ targets.set(pane.chartInstance, {
+ chartKey: pane.paneId,
+ chartInstance: pane.chartInstance,
+ context: buildChartViewportContext(pane.symbol, pane.interval),
+ });
+ });
+ } else if (typeof chart !== 'undefined' && chart) {
+ targets.set(chart, {
+ chartKey: 'pane-0',
+ chartInstance: chart,
+ context: buildChartViewportContext(currentSymbol, currentInterval),
+ });
+ }
+ return [...targets.values()];
+ }
+
+ function getAllOpenChartInstances() {
+ const instances = new Set();
+ if (typeof chart !== 'undefined' && chart) {
+ instances.add(chart);
+ }
+ if (window.Workspace?.panes) {
+ Workspace.panes.forEach((pane) => {
+ if (pane?.chartInstance) {
+ instances.add(pane.chartInstance);
+ }
+ });
+ }
+ return [...instances];
+ }
+
+ function applyChartViewport(chartInstance, options = {}) {
+ if (!chartInstance || typeof chartInstance.timeScale !== 'function') return;
+ try {
+ const rightOffset = Number.isFinite(Number(options.rightOffset))
+ ? Number(options.rightOffset)
+ : CHART_RIGHT_OFFSET;
+ const barSpacing = clampChartBarSpacing(options.barSpacing ?? sharedChartBarSpacing);
+ const timeScaleApi = chartInstance.timeScale();
+ chartInstance.applyOptions({
+ timeScale: buildSharedTimeScaleOptions({
+ rightOffset,
+ barSpacing,
+ }),
+ });
+ if (options.fitContent && typeof timeScaleApi.fitContent === 'function') {
+ timeScaleApi.fitContent();
+ }
+ if (options.scrollToOffset && typeof timeScaleApi.scrollToPosition === 'function') {
+ timeScaleApi.scrollToPosition(rightOffset, false);
+ }
+ } catch (error) {
+ console.warn('[chart] applyChartViewport failed', error);
+ }
+ }
+
+ function applyChartRightOffset(chartInstance, offset = CHART_RIGHT_OFFSET) {
+ applyChartViewport(chartInstance, { rightOffset: offset });
+ }
+
+ function fitChartWithOffset(chartInstance, offset = CHART_RIGHT_OFFSET) {
+ applyChartViewport(chartInstance, { fitContent: true, rightOffset: offset });
+ }
+
+ function fitAllOpenChartsWithOffset(offset = CHART_RIGHT_OFFSET) {
+ getAllOpenChartTargets().forEach(({ chartKey, chartInstance, context }) => {
+ const targetOffset = chartKey ? resolveChartRightOffset(chartKey) : offset;
+ fitChartWithOffset(chartInstance, targetOffset);
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ });
+ }
+
+ function buildAnchoredZoomRange(chartKey, snapshot, nextBarSpacing) {
+ const logicalRange = normalizeVisibleLogicalRange(snapshot?.logicalRange);
+ if (!logicalRange) {
+ return null;
+ }
+ const anchorTo = Number.isFinite(logicalRange.to)
+ ? logicalRange.to
+ : resolveChartRightEdgeAnchor(chartKey);
+ if (!Number.isFinite(anchorTo)) {
+ return null;
+ }
+ const currentSpan = Math.max(1, logicalRange.to - logicalRange.from);
+ const currentBarSpacing = clampChartBarSpacing(snapshot?.barSpacing ?? sharedChartBarSpacing);
+ const targetBarSpacing = clampChartBarSpacing(nextBarSpacing);
+ const nextSpan = Math.max(1, currentSpan * (currentBarSpacing / targetBarSpacing));
+ return {
+ from: anchorTo - nextSpan,
+ to: anchorTo,
+ };
+ }
+
+ function setSharedChartBarSpacing(nextBarSpacing) {
+ const targetBarSpacing = clampChartBarSpacing(nextBarSpacing);
+ getAllOpenChartTargets().forEach(({ chartKey, chartInstance, context }) => {
+ const snapshot = readChartViewportSnapshot(chartInstance);
+ const zoomRange = buildAnchoredZoomRange(chartKey, snapshot, targetBarSpacing);
+ applyChartViewport(chartInstance, {
+ rightOffset: resolveChartRightOffset(chartKey),
+ barSpacing: targetBarSpacing,
+ });
+ if (zoomRange && typeof chartInstance.timeScale === 'function') {
+ const timeScaleApi = chartInstance.timeScale();
+ if (typeof timeScaleApi.setVisibleLogicalRange === 'function') {
+ timeScaleApi.setVisibleLogicalRange(zoomRange);
+ }
+ }
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ });
+ sharedChartBarSpacing = targetBarSpacing;
+ schedulePersistChartViewportState();
+ return sharedChartBarSpacing;
+ }
+
+ function zoomAllCharts(direction) {
+ const zoomFactor = direction === 'out' ? (1 / CHART_ZOOM_FACTOR) : CHART_ZOOM_FACTOR;
+ return setSharedChartBarSpacing(sharedChartBarSpacing * zoomFactor);
+ }
+
+ function panAllCharts(direction) {
+ const stepDirection = direction === 'left' ? -1 : 1;
+ getAllOpenChartTargets().forEach(({ chartKey, chartInstance, context }) => {
+ const snapshot = readChartViewportSnapshot(chartInstance);
+ const logicalRange = normalizeVisibleLogicalRange(snapshot?.logicalRange);
+ if (!logicalRange || typeof chartInstance.timeScale !== 'function') {
+ return;
+ }
+ const span = Math.max(1, logicalRange.to - logicalRange.from);
+ const shift = Math.max(1, span * CHART_PAN_STEP_RATIO) * stepDirection;
+ const timeScaleApi = chartInstance.timeScale();
+ if (typeof timeScaleApi.setVisibleLogicalRange === 'function') {
+ timeScaleApi.setVisibleLogicalRange({
+ from: logicalRange.from + shift,
+ to: logicalRange.to + shift,
+ });
+ }
+ persistChartViewportSnapshot(chartKey, chartInstance, context);
+ });
+ }
+
+ function getForecastSegmentColor(diff, epsilon = 0.0001) {
+ if (diff > epsilon) return FORECAST_PALETTE.up.line;
+ if (diff < -epsilon) return FORECAST_PALETTE.down.line;
+ return FORECAST_PALETTE.flat.line;
+ }
+
+ function getForecastTone(points) {
+ if (!Array.isArray(points) || points.length < 2) return 'flat';
+ const first = Number(points[0]?.value ?? 0);
+ const last = Number(points[points.length - 1]?.value ?? first);
+ const baseline = Math.max(Math.abs(first), 1);
+ const delta = last - first;
+ const epsilon = baseline * 0.0006;
+ if (delta > epsilon) return 'up';
+ if (delta < -epsilon) return 'down';
+ return 'flat';
+ }
+
+ function getForecastModelPalette(modelKey) {
+ return AI_MODEL_TONE_PALETTE[modelKey] || AI_MODEL_TONE_PALETTE.kronos;
+ }
+
+ function resolveForecastModelSeriesStyle(modelKey, points = []) {
+ const baseStyle = AI_MODEL_SERIES_STYLE[modelKey] || AI_MODEL_SERIES_STYLE.kronos;
+ const palette = getForecastModelPalette(modelKey);
+ const tone = getForecastTone(points);
+ return {
+ color: palette[tone] || palette.flat,
+ lineWidth: baseStyle.lineWidth,
+ lineStyle: baseStyle.lineStyle,
+ };
+ }
+
+ function buildForecastModelSeriesOptions(modelKey, points = [], priceFormat = null, visible = null) {
+ const style = resolveForecastModelSeriesStyle(modelKey, points);
+ return {
+ color: style.color,
+ visible: visible ?? (Array.isArray(points) && points.length > 1),
+ lineWidth: style.lineWidth,
+ lineStyle: style.lineStyle,
+ priceLineVisible: false,
+ lastValueVisible: false,
+ crosshairMarkerVisible: true,
+ ...(priceFormat ? { priceFormat } : {}),
+ };
+ }
+
+ function formatPanePriceValue(pane, value) {
+ const precision = Math.max(
+ 0,
+ Math.min(8, Number(pane?.priceFormat?.precision ?? currentPriceFormat?.precision ?? 2))
+ );
+ return Number(value).toFixed(precision);
+ }
+
+ function ensurePaneForecastHoverEl(pane) {
+ if (!pane?.containerEl) return null;
+ if (pane.forecastHoverEl && pane.forecastHoverEl.isConnected) {
+ return pane.forecastHoverEl;
+ }
+ const hoverEl = document.createElement('div');
+ hoverEl.className = 'pane-forecast-hover hidden';
+ hoverEl.setAttribute('aria-hidden', 'true');
+ pane.containerEl.appendChild(hoverEl);
+ pane.forecastHoverEl = hoverEl;
+ return hoverEl;
+ }
+
+ function hidePaneForecastHover(pane) {
+ const hoverEl = ensurePaneForecastHoverEl(pane);
+ if (!hoverEl) return;
+ hoverEl.classList.add('hidden');
+ hoverEl.textContent = '';
+ hoverEl.style.color = '';
+ hoverEl.style.borderColor = '';
+ }
+
+ function showPaneForecastHover(pane, modelKey, value) {
+ const hoverEl = ensurePaneForecastHoverEl(pane);
+ if (!hoverEl) return;
+ const points = pane?.cachedForecastModelLines?.[modelKey] || [];
+ const style = resolveForecastModelSeriesStyle(modelKey, points);
+ const label = AI_MODEL_LABELS[modelKey] || modelKey;
+ hoverEl.textContent = Number.isFinite(Number(value))
+ ? `${label} · ${formatPanePriceValue(pane, Number(value))}`
+ : label;
+ hoverEl.style.color = style.color;
+ hoverEl.style.borderColor = style.color;
+ hoverEl.classList.remove('hidden');
+ }
+
+ function resolveForecastHoverCandidate(pane, param) {
+ if (!pane?.forecastSeries?.models || !param?.seriesData || !param?.point) return null;
+ const pointX = Number(param.point.x);
+ const pointY = Number(param.point.y);
+ const chartWidth = Number(pane?.chartEl?.clientWidth ?? 0);
+ const chartHeight = Number(pane?.chartEl?.clientHeight ?? 0);
+ if (
+ !Number.isFinite(pointX)
+ || !Number.isFinite(pointY)
+ || pointX < 0
+ || pointY < 0
+ || pointX > chartWidth
+ || pointY > chartHeight
+ ) {
+ return null;
+ }
+
+ let closestCandidate = null;
+ Object.entries(pane.forecastSeries.models).forEach(([modelKey, series]) => {
+ if (!series || !pane.aiModels?.[modelKey]) return;
+ const pointData = param.seriesData.get(series);
+ if (!pointData) return;
+ const seriesValue = Number(pointData.value ?? pointData.close ?? pointData.price);
+ if (!Number.isFinite(seriesValue) || typeof series.priceToCoordinate !== 'function') return;
+ const seriesY = Number(series.priceToCoordinate(seriesValue));
+ if (!Number.isFinite(seriesY)) return;
+ const distancePx = Math.abs(seriesY - pointY);
+ if (!closestCandidate || distancePx < closestCandidate.distancePx) {
+ closestCandidate = {
+ modelKey,
+ value: seriesValue,
+ distancePx,
+ };
+ }
+ });
+
+ return closestCandidate && closestCandidate.distancePx <= 12
+ ? closestCandidate
+ : null;
+ }
+
+ function bindPaneForecastHover(pane) {
+ if (!pane?.chartInstance || pane._forecastHoverBound) return;
+ ensurePaneForecastHoverEl(pane);
+ pane.chartInstance.subscribeCrosshairMove((param) => {
+ if (!param?.time) {
+ hidePaneForecastHover(pane);
+ return;
+ }
+ const hoverCandidate = resolveForecastHoverCandidate(pane, param);
+ if (!hoverCandidate) {
+ hidePaneForecastHover(pane);
+ return;
+ }
+ showPaneForecastHover(pane, hoverCandidate.modelKey, hoverCandidate.value);
+ });
+ pane._forecastHoverBound = true;
+ }
+
+ function buildForecastCandleSeriesOptions(tone = 'flat') {
+ const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat;
+ return {
+ upColor: tone === 'flat' ? 'rgba(234, 179, 8, 0.50)' : 'rgba(22, 163, 74, 0.50)',
+ downColor: tone === 'flat' ? 'rgba(250, 204, 21, 0.50)' : 'rgba(248, 113, 113, 0.50)',
+ borderVisible: false,
+ wickUpColor: tone === 'flat' ? 'rgba(234, 179, 8, 0.50)' : 'rgba(22, 163, 74, 0.50)',
+ wickDownColor: tone === 'flat' ? 'rgba(250, 204, 21, 0.50)' : 'rgba(248, 113, 113, 0.50)',
+ priceLineVisible: false,
+ lastValueVisible: false,
+ visible: false,
+ };
+ }
+
+ function normalizeForecastCandles(candles) {
+ if (!Array.isArray(candles)) return [];
+ return candles
+ .map((candle) => {
+ const time = Number(candle?.time);
+ const open = Number(candle?.open);
+ const high = Number(candle?.high);
+ const low = Number(candle?.low);
+ const close = Number(candle?.close);
+ if (![time, open, high, low, close].every(Number.isFinite)) return null;
+ const upper = Math.max(open, high, low, close);
+ const lower = Math.min(open, high, low, close);
+ return {
+ time,
+ open,
+ high: upper,
+ low: lower,
+ close,
+ };
+ })
+ .filter(Boolean);
+ }
+
+ function buildForecastClosePath(anchorPoint, candles) {
+ const futurePoints = normalizeForecastCandles(candles).map((candle) => ({
+ time: candle.time,
+ value: candle.close,
+ }));
+ return anchorPoint ? [anchorPoint, ...futurePoints] : futurePoints;
+ }
+
+ function buildForecastLineFromRows(rows, fallbackActualPoint = null) {
+ const points = Array.isArray(rows)
+ ? rows
+ .filter((row) => row && row.time !== undefined && row.p50 !== undefined)
+ .map((row) => ({
+ time: Number(row.time),
+ value: Number(row.p50),
+ }))
+ .filter((point) => Number.isFinite(point.time) && Number.isFinite(point.value))
+ : [];
+
+ if (!fallbackActualPoint) {
+ return points;
+ }
+ if (!points.length) {
+ return [fallbackActualPoint];
+ }
+ if (points[0].time === fallbackActualPoint.time) {
+ return [{ ...points[0], value: fallbackActualPoint.value }, ...points.slice(1)];
+ }
+ return [fallbackActualPoint, ...points];
+ }
+
+ function extractForecastModelLines(payload, fallbackActualPoint = null, requestedModels = null) {
+ const requested = normalizePaneAiModels(requestedModels || payload?.model_selection?.requested);
+ const forecastModels = payload?.forecast_models && typeof payload.forecast_models === 'object'
+ ? payload.forecast_models
+ : {};
+ const lines = createEmptyForecastModelLines();
+
+ Object.keys(lines).forEach((modelKey) => {
+ const modelRows = Array.isArray(forecastModels?.[modelKey]?.forecast)
+ ? forecastModels[modelKey].forecast
+ : [];
+ lines[modelKey] = buildForecastLineFromRows(modelRows, fallbackActualPoint);
+ });
+
+ if (!Object.values(lines).some((points) => points.length) && Array.isArray(payload?.forecast)) {
+ const fallbackLine = buildForecastLineFromRows(payload.forecast, fallbackActualPoint);
+ const enabledModels = FORECAST_MODEL_ORDER.filter((modelKey) => requested[modelKey]);
+ if (enabledModels.length === 1) {
+ lines[enabledModels[0]] = fallbackLine;
+ }
+ }
+
+ return lines;
+ }
+
+ window.normalizeForecastCandles = normalizeForecastCandles;
+ window.buildForecastLineFromRows = buildForecastLineFromRows;
+ window.extractForecastModelLines = extractForecastModelLines;
+ window.buildForecastModelSeriesOptions = buildForecastModelSeriesOptions;
+
+ const chart = LightweightCharts.createChart(chartEl, {
+ layout: {
+ background: { type: 'solid', color: 'transparent' },
+ textColor: 'rgba(100, 150, 200, 0.85)',
+ fontSize: 11,
+ fontFamily: "'Space Mono', 'Courier New', monospace",
+ },
+ grid: {
+ vertLines: { visible: false },
+ horzLines: { visible: false },
+ },
+ rightPriceScale: buildSharedRightPriceScaleOptions(),
+ timeScale: buildSharedTimeScaleOptions(),
+ crosshair: {
+ mode: LightweightCharts.CrosshairMode.Normal,
+ vertLine: {
+ color: 'rgba(34, 211, 238, 0.35)',
+ width: 1,
+ labelBackgroundColor: '#040d1e',
+ },
+ horzLine: {
+ color: 'rgba(34, 211, 238, 0.35)',
+ width: 1,
+ labelBackgroundColor: '#040d1e',
+ },
+ },
+ watermark: {
+ visible: true,
+ fontSize: 64,
+ horzAlign: 'center',
+ vertAlign: 'center',
+ color: 'rgba(34, 211, 238, 0.04)',
+ text: 'AI Forecast',
+ },
+ handleScroll: true,
+ handleScale: true,
+ });
+
+ /* ── Series ────────────────────────────────── */
+ const candleSeries = chart.addCandlestickSeries({
+ upColor: '#1dba8a',
+ downColor: '#e05560',
+ borderVisible: false,
+ wickUpColor: '#1dba8a',
+ wickDownColor: '#e05560',
+ });
+
+ const forecastCandleSeries = chart.addCandlestickSeries(buildForecastCandleSeriesOptions());
+ const forecastReserveSeries = chart.addLineSeries({
+ color: 'rgba(0, 0, 0, 0)',
+ lineWidth: 1,
+ crosshairMarkerVisible: false,
+ priceLineVisible: false,
+ lastValueVisible: false,
+ });
+
+ const p50Series = chart.addLineSeries({
+ color: '#66d9ff',
+ lineWidth: MODEL_CONFIG.defaultLineWidth,
+ title: 'Dự báo AI',
+ priceLineVisible: false,
+ lastValueVisible: false,
+ visible: false,
+ });
+
+ const p10Series = chart.addLineSeries({
+ color: 'rgba(14, 165, 233, 0.35)',
+ lineWidth: MODEL_CONFIG.defaultLineWidth,
+ lineStyle: LightweightCharts.LineStyle.Dashed,
+ priceLineVisible: false,
+ lastValueVisible: false,
+ visible: false,
+ });
+
+ let forecastSegmentSeries = [];
+
+ function clearForecastSegments() {
+ if (!forecastSegmentSeries.length) return;
+ for (const series of forecastSegmentSeries) {
+ try {
+ chart.removeSeries(series);
+ } catch (e) {
+ console.warn('[forecastSegments] remove failed', e);
+ }
+ }
+ forecastSegmentSeries = [];
+ }
+
+ function buildForecastSegmentSeries(points) {
+ clearForecastSegments();
+ if (!Array.isArray(points) || points.length < 2) return;
+
+ const EPSILON = 0.0001;
+ for (let i = 1; i < points.length; i += 1) {
+ const prev = points[i - 1];
+ const curr = points[i];
+ const diff = (curr?.value ?? 0) - (prev?.value ?? 0);
+ // Tăng: xanh lá rõ hÆ¡n, Giảm: đỠdịu hÆ¡n, Äi ngang: và ng
+ const color = getForecastSegmentColor(diff, EPSILON);
+ const segSeries = chart.addLineSeries({
+ color,
+ lineWidth: MODEL_CONFIG.defaultLineWidth,
+ priceLineVisible: false,
+ lastValueVisible: false,
+ crosshairMarkerVisible: false,
+ priceFormat: buildSeriesPriceFormat(),
+ });
+ segSeries.setData([prev, curr]);
+ forecastSegmentSeries.push(segSeries);
+ }
+ }
+
+ const p90Series = chart.addLineSeries({
+ color: 'rgba(14, 165, 233, 0.35)',
+ lineWidth: MODEL_CONFIG.defaultLineWidth,
+ lineStyle: LightweightCharts.LineStyle.Dashed,
+ priceLineVisible: false,
+ lastValueVisible: false,
+ visible: false,
+ });
+ const kronosSeries = chart.addLineSeries({
+ ...buildForecastModelSeriesOptions('kronos'),
+ title: 'Kronos',
+ visible: false,
+ });
+ const timesfmSeries = chart.addLineSeries({
+ ...buildForecastModelSeriesOptions('timesfm'),
+ title: 'TimesFM',
+ visible: false,
+ });
+ const chronosSeries = chart.addLineSeries({
+ ...buildForecastModelSeriesOptions('chronos'),
+ title: 'Chronos',
+ visible: false,
+ });
+ const primaryForecastModelSeries = {
+ kronos: kronosSeries,
+ timesfm: timesfmSeries,
+ chronos: chronosSeries,
+ };
+
+ /* ── Indicator Series ──────────────────────── */
+ const bbMiddleSeries = chart.addLineSeries({ color: 'rgba(255, 255, 255, 0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false });
+ const bbUpperSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false });
+ const bbLowerSeries = chart.addLineSeries({ color: 'rgba(34, 211, 238, 0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: false });
+
+ let rsiSeries = null;
+ setBootPhase('after-chart-init');
+
+ /* ── Status helpers ────────────────────────── */
+ /* ── UI Sync & Loader helpers ──────────────── */
+ function clearAllOverlays() {
+ // Unify state by hiding all primary overlays
+ terminalLoader.classList.remove('active');
+ analysisPanel.classList.remove('active');
+ searchResults.classList.remove('visible');
+ }
+
+ function showLoader(msg = 'Đang tải dữ liệu') {
+ loaderText.textContent = msg;
+ terminalLoader.classList.add('active');
+ const cEl = document.getElementById('chart');
+ if (cEl) cEl.classList.add('loading');
+ }
+
+ function hideLoader() {
+ terminalLoader.classList.remove('active');
+ const cEl = document.getElementById('chart');
+ if (cEl) cEl.classList.remove('loading');
+ }
+
+ function updateStatus(text, mode = 'normal') {
+ const el = document.getElementById('marketStatus');
+ if (el) {
+ el.textContent = text;
+ el.className = `market-status ${mode}`;
+ } else {
+ statusText.textContent = text;
+ statusDot.className = 'dot ' + mode;
+ statusPill.className = 'status-pill ' + mode;
+ }
+ }
+
+ window.updateStatus = updateStatus;
+
+ function setGlobalStatusVisibility(visible) {
+ const statusWrap = statusPill ? statusPill.closest('.status-wrap') : null;
+ if (!statusWrap) return;
+ statusWrap.style.display = visible ? '' : 'none';
+ }
+
+ function setGlobalCompactGaugesVisibility(visible) {
+ const container = document.getElementById('chartGauges');
+ if (!container) return;
+ container.style.display = visible ? '' : 'none';
+ if (!visible) {
+ container.innerHTML = '';
+ container.classList.remove('combo-active');
+ }
+ }
+
+ function dimChartForRefresh() {
+ // Show loading placeholder on chart
+ const chartContainer = document.getElementById('chart-container');
+ if (chartContainer) {
+ chartContainer.style.opacity = '0.5';
+ }
+ }
+
+ function formatPrice(value) {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
+ const precision = Math.max(0, Math.min(8, Number(currentPriceFormat?.precision ?? 2)));
+ return Number(value).toLocaleString('en-US', {
+ minimumFractionDigits: precision,
+ maximumFractionDigits: precision,
+ });
+ }
+
+ function getSymbolMeta(symbol) {
+ return symbolMetaMap.get(symbol) || null;
+ }
+
+ function countPriceDecimals(value) {
+ const num = Number(value);
+ if (!Number.isFinite(num)) return 0;
+ if (Math.abs(num - Math.trunc(num)) < 1e-10) return 0;
+ const normalized = num.toFixed(8).replace(/0+$/, '');
+ const parts = normalized.split('.');
+ return parts[1] ? parts[1].length : 0;
+ }
+
+ function inferObservedPrecision(values) {
+ const numericValues = (Array.isArray(values) ? values : [])
+ .map(v => Number(v))
+ .filter(v => Number.isFinite(v));
+ if (!numericValues.length) return null;
+
+ let maxDecimals = 0;
+ for (const value of numericValues) {
+ maxDecimals = Math.max(maxDecimals, countPriceDecimals(value));
+ }
+
+ const uniqueValues = [...new Set(
+ numericValues.map(value => Number(value.toFixed(8)))
+ )].sort((a, b) => a - b);
+
+ let minPositiveDiff = null;
+ for (let i = 1; i < uniqueValues.length; i += 1) {
+ const diff = Number((uniqueValues[i] - uniqueValues[i - 1]).toFixed(8));
+ if (diff > 0 && (minPositiveDiff === null || diff < minPositiveDiff)) {
+ minPositiveDiff = diff;
+ }
+ }
+
+ if (minPositiveDiff !== null) {
+ maxDecimals = Math.max(maxDecimals, countPriceDecimals(minPositiveDiff));
+ }
+
+ return maxDecimals;
+ }
+
+ function buildPriceFallback(symbol, values = []) {
+ const meta = getSymbolMeta(symbol);
+ const category = String(meta?.category || '')
+ .normalize('NFD')
+ .replace(/\p{Diacritic}/gu, '')
+ .toLowerCase();
+ const latestPrice = [...(Array.isArray(values) ? values : [])]
+ .reverse()
+ .map(v => Number(v))
+ .find(v => Number.isFinite(v) && v > 0);
+
+ if (category === 'cap tien') {
+ if (symbol === 'USDVND') return { preferredPrecision: 0, minPrecision: 0, maxPrecision: 2 };
+ if (symbol === 'DXY') return { preferredPrecision: 3, minPrecision: 2, maxPrecision: 4 };
+ if (symbol.includes('JPY')) return { preferredPrecision: 3, minPrecision: 3, maxPrecision: 5 };
+ return { preferredPrecision: 5, minPrecision: 5, maxPrecision: 6 };
+ }
+
+ if (category === 'crypto') {
+ if (latestPrice >= 1000) return { preferredPrecision: 2, minPrecision: 2, maxPrecision: 4 };
+ if (latestPrice >= 100) return { preferredPrecision: 3, minPrecision: 2, maxPrecision: 5 };
+ if (latestPrice >= 1) return { preferredPrecision: 4, minPrecision: 2, maxPrecision: 6 };
+ if (latestPrice >= 0.01) return { preferredPrecision: 6, minPrecision: 4, maxPrecision: 8 };
+ return { preferredPrecision: 8, minPrecision: 6, maxPrecision: 8 };
+ }
+
+ if (category === 'co phieu vn') {
+ return { preferredPrecision: 0, minPrecision: 0, maxPrecision: 2 };
+ }
+
+ if (category === 'chi so') {
+ return { preferredPrecision: 2, minPrecision: 0, maxPrecision: 2 };
+ }
+
+ if (category === 'trai phieu') {
+ return { preferredPrecision: 3, minPrecision: 2, maxPrecision: 4 };
+ }
+
+ if (category === 'etf' || category === 'co phieu my') {
+ return { preferredPrecision: 2, minPrecision: 2, maxPrecision: 4 };
+ }
+
+ return { preferredPrecision: 2, minPrecision: 0, maxPrecision: 4 };
+ }
+
+ function resolvePriceFormat(symbol, candles = []) {
+ const priceValues = [];
+ for (const candle of Array.isArray(candles) ? candles : []) {
+ for (const key of ['open', 'high', 'low', 'close']) {
+ const value = Number(candle?.[key]);
+ if (Number.isFinite(value)) priceValues.push(value);
+ }
+ }
+
+ const observedPrecision = inferObservedPrecision(priceValues);
+ const fallback = buildPriceFallback(symbol, priceValues);
+
+ let precision = observedPrecision;
+ if (precision === null || precision === undefined) {
+ precision = fallback.preferredPrecision ?? 2;
+ }
+
+ if (fallback.minPrecision !== undefined) {
+ precision = Math.max(precision, fallback.minPrecision);
+ }
+ if (fallback.maxPrecision !== undefined) {
+ precision = Math.min(precision, fallback.maxPrecision);
+ }
+
+ precision = Math.max(0, Math.min(8, precision));
+ const minMove = precision === 0 ? 1 : Number((1 / (10 ** precision)).toFixed(8));
+ return { precision, minMove };
+ }
+
+ function buildSeriesPriceFormat() {
+ return {
+ type: 'price',
+ precision: currentPriceFormat.precision,
+ minMove: currentPriceFormat.minMove,
+ };
+ }
+
+ function applyPriceFormatToSeries(series) {
+ if (!series || typeof series.applyOptions !== 'function') return;
+ try {
+ series.applyOptions({ priceFormat: buildSeriesPriceFormat() });
+ } catch (e) {
+ console.warn('[priceFormat] apply failed', e);
+ }
+ }
+
+ function syncChartPriceFormat(symbol, candles = []) {
+ currentPriceFormat = resolvePriceFormat(symbol, candles);
+ applyPriceFormatToSeries(candleSeries);
+ applyPriceFormatToSeries(forecastCandleSeries);
+ applyPriceFormatToSeries(p50Series);
+ applyPriceFormatToSeries(p10Series);
+ applyPriceFormatToSeries(p90Series);
+ applyPriceFormatToSeries(kronosSeries);
+ applyPriceFormatToSeries(timesfmSeries);
+ applyPriceFormatToSeries(chronosSeries);
+ applyPriceFormatToSeries(bbMiddleSeries);
+ applyPriceFormatToSeries(bbUpperSeries);
+ applyPriceFormatToSeries(bbLowerSeries);
+ if (rsiSeries) applyPriceFormatToSeries(rsiSeries);
+ for (const series of forecastSegmentSeries) {
+ applyPriceFormatToSeries(series);
+ }
+ }
+
+ function formatPct(value) {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
+ const num = Number(value);
+ const sign = num > 0 ? '+' : '';
+ return `${sign}${num.toFixed(2)}%`;
+ }
+
+ function formatRR(value) {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
+ return `${Number(value).toFixed(2)}R`;
+ }
+
+ /* ── Shared Gauge Logic (v6.0) ── */
+ function buildGaugeSvg(rawScore, w = 320, h = 220, showValue = true) {
+ const score = Math.max(-1, Math.min(1, rawScore));
+ const displayValue = Math.round((score + 1) * 50);
+ const angle = score * 135;
+ const cx = w / 2, cy = h * 0.65, r = (w / 2) * 0.62;
+ const strokeW = w > 150 ? 16 : 8;
+
+ function arc(s, e, col) {
+ const sa = (s - 90) * Math.PI / 180, ea = (e - 90) * Math.PI / 180;
+ const x1 = cx + r * Math.cos(sa), y1 = cy + r * Math.sin(sa), x2 = cx + r * Math.cos(ea), y2 = cy + r * Math.sin(ea);
+ return ``;
+ }
+
+ const na = (angle - 90) * Math.PI / 180, nl = r + 2;
+ const nx = cx + nl * Math.cos(na), ny = cy + nl * Math.sin(na);
+
+ // Color logic: Red -> Yellow -> Green
+ let needleColor = '#facc15'; // Yellow (Neutral/Default)
+ if (displayValue > 70) needleColor = '#22c55e'; // Green
+ else if (displayValue < 40) needleColor = '#ef4444'; // Red
+
+ let valueHtml = '';
+ if (showValue) {
+ valueHtml = `
+
+
${displayValue}
+ ${w > 150 ? '
Chỉ số
' : ''}
+
+ `;
+ }
+
+ return `
+ ${valueHtml}
+
+ `;
+ }
+
+ function gaugeToRawScore(gauge) {
+ const value = Number(gauge);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(-1, Math.min(1, (value - 50) / 50));
+ }
+
+ function getSignalClass(signal) {
+ if (!signal) return 'neutral';
+ if (signal.includes('Mua mạnh')) return 'strong-buy';
+ if (signal.includes('Mua')) return 'buy';
+ if (signal.includes('Bán mạnh')) return 'strong-sell';
+ if (signal.includes('Bán')) return 'sell';
+ return 'neutral';
+ }
+
+ function clampNumber(value, min, max) {
+ return Math.max(min, Math.min(max, Number(value)));
+ }
+
+ function roundTo(value, digits = 1, fallback = 0) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return fallback;
+ return Number(numeric.toFixed(digits));
+ }
+
+ function meanOfNumbers(values, digits = null, fallback = 0) {
+ const finiteValues = (Array.isArray(values) ? values : [])
+ .map((value) => Number(value))
+ .filter((value) => Number.isFinite(value));
+ if (!finiteValues.length) {
+ return fallback;
+ }
+ const meanValue = finiteValues.reduce((sum, value) => sum + value, 0) / finiteValues.length;
+ return digits === null ? meanValue : roundTo(meanValue, digits, fallback);
+ }
+
+ function gaugeToSignalFrontend(gauge, interval = '1h') {
+ const thresholds = {
+ '1m': [80, 62, 38, 20],
+ '5m': [80, 62, 38, 20],
+ '15m': [77, 60, 40, 23],
+ '1h': [77, 60, 40, 23],
+ '4h': [75, 58, 42, 25],
+ '1d': [75, 58, 42, 25],
+ '1w': [72, 56, 44, 28],
+ };
+ const [strongBuy, buy, sell, strongSell] = thresholds[interval] || thresholds['1h'];
+ if (gauge >= strongBuy) return 'Mua mạnh';
+ if (gauge >= buy) return 'Mua';
+ if (gauge > sell) return 'Trung lập';
+ if (gauge > strongSell) return 'Bán';
+ return 'Bán mạnh';
+ }
+
+ function gaugeToNormalizedScoreFrontend(gauge) {
+ return roundTo(clampNumber((Number(gauge) - 50) / 50, -1, 1), 4, 0);
+ }
+
+ function cloneForecastRows(rows) {
+ return Array.isArray(rows) ? rows.map((row) => ({ ...row })) : [];
+ }
+
+ function getSingleModelForecastRows(payload, modelKey) {
+ if (Array.isArray(payload?.forecast_models?.[modelKey]?.forecast)) {
+ return cloneForecastRows(payload.forecast_models[modelKey].forecast);
+ }
+ if (Array.isArray(payload?.forecast)) {
+ return cloneForecastRows(payload.forecast);
+ }
+ return [];
+ }
+
+ function getSingleModelAiScore(payload, modelKey) {
+ const modelScore = payload?.analysis?.ai_models?.models?.[modelKey];
+ if (modelScore && Number.isFinite(Number(modelScore.gauge))) {
+ return {
+ gauge: Number(modelScore.gauge),
+ signal: modelScore.signal || '--',
+ confidence_pct: Number(modelScore.confidence_pct ?? 0),
+ certainty: Number(modelScore.certainty ?? 0),
+ path_consistency: Number(payload?.analysis?.ai_gauge?.path_consistency ?? 50),
+ monotonicity: Number(payload?.analysis?.ai_gauge?.monotonicity ?? 50),
+ max_adverse_excursion_pct: Number(payload?.analysis?.ai_gauge?.max_adverse_excursion_pct ?? 0),
+ forecast_return_pct: Number(payload?.analysis?.ai_gauge?.forecast_return_pct ?? 0),
+ weighted_return_pct: Number(payload?.analysis?.ai_gauge?.weighted_return_pct ?? 0),
+ };
+ }
+ if (payload?.analysis?.ai_gauge && Number.isFinite(Number(payload.analysis.ai_gauge.gauge))) {
+ return payload.analysis.ai_gauge;
+ }
+ return null;
+ }
+
+ function buildCombinedForecastRowsForModels(modelPayloadMap = {}, activeModelKeys = []) {
+ if (!activeModelKeys.length) {
+ return [];
+ }
+ if (activeModelKeys.length === 1) {
+ return getSingleModelForecastRows(modelPayloadMap[activeModelKeys[0]], activeModelKeys[0]);
+ }
+ const modelRowsList = activeModelKeys
+ .map((modelKey) => getSingleModelForecastRows(modelPayloadMap[modelKey], modelKey))
+ .filter((rows) => rows.length);
+ if (!modelRowsList.length) {
+ return [];
+ }
+ const rowCount = Math.min(...modelRowsList.map((rows) => rows.length));
+ const combinedRows = [];
+ for (let index = 0; index < rowCount; index += 1) {
+ const sampleRow = modelRowsList[0][index] || {};
+ combinedRows.push({
+ time: Number(sampleRow.time ?? 0),
+ p10: roundTo(meanOfNumbers(modelRowsList.map((rows) => rows[index]?.p10), null, 0), 6, 0),
+ p50: roundTo(meanOfNumbers(modelRowsList.map((rows) => rows[index]?.p50), null, 0), 6, 0),
+ p90: roundTo(meanOfNumbers(modelRowsList.map((rows) => rows[index]?.p90), null, 0), 6, 0),
+ ...(sampleRow.is_actual ? { is_actual: true } : {}),
+ });
+ }
+ return combinedRows;
+ }
+
+ function buildCombinedAiScoreForModels(modelPayloadMap = {}, activeModelKeys = [], interval = '1h') {
+ const aiScores = activeModelKeys
+ .map((modelKey) => getSingleModelAiScore(modelPayloadMap[modelKey], modelKey))
+ .filter(Boolean);
+ if (!aiScores.length) {
+ return null;
+ }
+ const gauge = meanOfNumbers(aiScores.map((score) => score.gauge), 1, 50);
+ return {
+ gauge,
+ normalized_score: gaugeToNormalizedScoreFrontend(gauge),
+ certainty: meanOfNumbers(aiScores.map((score) => score.certainty), 1, 0),
+ confidence_pct: meanOfNumbers(aiScores.map((score) => score.confidence_pct), 1, 0),
+ path_consistency: meanOfNumbers(aiScores.map((score) => score.path_consistency), 1, 50),
+ monotonicity: meanOfNumbers(aiScores.map((score) => score.monotonicity), 1, 50),
+ max_adverse_excursion_pct: meanOfNumbers(aiScores.map((score) => score.max_adverse_excursion_pct), 2, 0),
+ forecast_return_pct: meanOfNumbers(aiScores.map((score) => score.forecast_return_pct), 2, 0),
+ weighted_return_pct: meanOfNumbers(aiScores.map((score) => score.weighted_return_pct), 2, 0),
+ direction_label: gauge >= 58 ? 'bullish' : gauge <= 42 ? 'bearish' : 'neutral',
+ signal: gaugeToSignalFrontend(gauge, interval),
+ formula: 'mean(enabled_models)',
+ };
+ }
+
+ function buildFrontendSummaryScore(technicalScore, aiScore, interval = '1h') {
+ if (!technicalScore || !aiScore) {
+ return null;
+ }
+ const techGauge = Number(technicalScore.gauge ?? 50);
+ const aiGauge = Number(aiScore.gauge ?? 50);
+ const aiCertainty = clampNumber(Number(aiScore.certainty ?? 50) / 100, 0, 1);
+ const technicalWeight = 1 - aiCertainty;
+ const aiWeight = aiCertainty;
+ const finalGauge = clampNumber((aiGauge * aiWeight) + (techGauge * technicalWeight), 5, 95);
+ const techDelta = techGauge - 50;
+ const aiDelta = aiGauge - 50;
+ const techDirection = Math.abs(techDelta) < 2 ? 0 : (techDelta > 0 ? 1 : -1);
+ const aiDirection = Math.abs(aiDelta) < 2 ? 0 : (aiDelta > 0 ? 1 : -1);
+ const distance = Math.abs(finalGauge - 50);
+ const conviction = distance >= 25 ? 'Rất mạnh' : distance >= 15 ? 'Mạnh' : distance >= 8 ? 'Trung bình' : 'Yếu';
+ let bias = 'neutral';
+ if (finalGauge >= 58) bias = 'bullish';
+ else if (finalGauge <= 42) bias = 'bearish';
+ return {
+ gauge: roundTo(finalGauge, 1, 50),
+ normalized_score: gaugeToNormalizedScoreFrontend(finalGauge),
+ signal: gaugeToSignalFrontend(finalGauge, interval),
+ conviction,
+ bias,
+ agreement: techDirection !== 0 && techDirection === aiDirection,
+ buy: Number(technicalScore.buy ?? 0),
+ sell: Number(technicalScore.sell ?? 0),
+ neutral: Number(technicalScore.neutral ?? 0),
+ buy_weight: Number(technicalScore.buy_weight ?? 0),
+ sell_weight: Number(technicalScore.sell_weight ?? 0),
+ neutral_weight: Number(technicalScore.neutral_weight ?? 0),
+ components: {
+ technical: roundTo(techGauge, 1, 50),
+ oscillators: roundTo(Number(technicalScore.components?.oscillators ?? 50), 1, 50),
+ moving_averages: roundTo(Number(technicalScore.components?.moving_averages ?? 50), 1, 50),
+ ai_forecast: roundTo(aiGauge, 1, 50),
+ ai_weight: roundTo(aiWeight, 2, 0),
+ technical_weight: roundTo(technicalWeight, 2, 0),
+ certainty_pct: roundTo(aiCertainty * 100, 1, 0),
+ formula: 'ai_gauge * certainty + technical_gauge * (1 - certainty)',
+ },
+ };
+ }
+
+ function buildFrontendDashboardPayload(lastClose, forecastRows, technicalScore, aiScore, summary) {
+ const finalForecastPrice = Array.isArray(forecastRows) && forecastRows.length
+ ? Number(forecastRows[forecastRows.length - 1]?.p50 ?? lastClose)
+ : Number(lastClose ?? 0);
+ return {
+ technical: {
+ gauge: roundTo(technicalScore.gauge, 1, 50),
+ normalized_score: roundTo(technicalScore.normalized_score, 4, 0),
+ signal: technicalScore.signal || '--',
+ buy: Number(technicalScore.buy ?? 0),
+ sell: Number(technicalScore.sell ?? 0),
+ neutral: Number(technicalScore.neutral ?? 0),
+ buy_weight: Number(technicalScore.buy_weight ?? 0),
+ sell_weight: Number(technicalScore.sell_weight ?? 0),
+ neutral_weight: Number(technicalScore.neutral_weight ?? 0),
+ },
+ ai: {
+ gauge: roundTo(aiScore.gauge, 1, 50),
+ normalized_score: roundTo(aiScore.normalized_score, 4, 0),
+ signal: aiScore.signal || '--',
+ forecast_return_pct: roundTo(aiScore.forecast_return_pct, 2, 0),
+ weighted_return_pct: roundTo(aiScore.weighted_return_pct, 2, 0),
+ confidence_pct: roundTo(aiScore.confidence_pct, 1, 0),
+ certainty: roundTo(aiScore.certainty, 1, 0),
+ path_consistency: roundTo(aiScore.path_consistency, 1, 50),
+ monotonicity: roundTo(aiScore.monotonicity, 1, 50),
+ max_adverse_excursion_pct: roundTo(aiScore.max_adverse_excursion_pct, 2, 0),
+ current_price: roundTo(lastClose, 6, 0),
+ forecast_price: roundTo(finalForecastPrice, 6, 0),
+ },
+ summary: {
+ gauge: roundTo(summary.gauge, 1, 50),
+ normalized_score: roundTo(summary.normalized_score, 4, 0),
+ signal: summary.signal || '--',
+ conviction: summary.conviction || 'Yếu',
+ bias: summary.bias || 'neutral',
+ agreement: Boolean(summary.agreement),
+ buy_weight: Number(summary.buy_weight ?? 0),
+ sell_weight: Number(summary.sell_weight ?? 0),
+ neutral_weight: Number(summary.neutral_weight ?? 0),
+ components: summary.components || {},
+ },
+ };
+ }
+
+ function buildPendingForecastModelState(modelKey, requested, status = 'pending', error = null) {
+ return {
+ enabled: Boolean(requested),
+ available: true,
+ success: false,
+ skipped: !requested,
+ pending: requested && status === 'pending',
+ error: requested ? error : null,
+ forecast: [],
+ model: {
+ model_key: modelKey,
+ name: AI_MODEL_LABELS[modelKey] || modelKey,
+ },
+ };
+ }
+
+ function buildCombinedForecastPayloadForPane(requestedModels, modelPayloadMap = {}, progress = {}) {
+ const normalizedRequested = normalizePaneAiModels(requestedModels);
+ const activeModelKeys = FORECAST_MODEL_ORDER.filter((modelKey) => Boolean(modelPayloadMap?.[modelKey]?.analysis));
+ const basePayload = activeModelKeys.length ? modelPayloadMap[activeModelKeys[0]] : null;
+ if (!basePayload) {
+ return null;
+ }
+
+ const enabledModelKeys = FORECAST_MODEL_ORDER.filter((modelKey) => normalizedRequested[modelKey]);
+ const pendingModels = (Array.isArray(progress.pending) ? progress.pending : [])
+ .filter((modelKey) => normalizedRequested[modelKey]);
+ const failedModels = (Array.isArray(progress.failed) ? progress.failed : [])
+ .filter((modelKey) => normalizedRequested[modelKey]);
+ const complete = (
+ activeModelKeys.length === enabledModelKeys.length
+ && pendingModels.length === 0
+ && failedModels.length === 0
+ );
+ const combinedForecastRows = buildCombinedForecastRowsForModels(modelPayloadMap, activeModelKeys);
+ const technicalScore = basePayload.analysis?.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0, components: {} };
+ const combinedAiScore = buildCombinedAiScoreForModels(
+ modelPayloadMap,
+ activeModelKeys,
+ basePayload.interval || '1h',
+ );
+ const summary = combinedAiScore
+ ? buildFrontendSummaryScore(technicalScore, combinedAiScore, basePayload.interval || '1h')
+ : null;
+ const dashboard = combinedAiScore && summary
+ ? buildFrontendDashboardPayload(
+ Number(basePayload.last_close ?? 0),
+ combinedForecastRows,
+ technicalScore,
+ combinedAiScore,
+ summary,
+ )
+ : (basePayload.analysis?.dashboard || {});
+ const aiModelBreakdown = Object.fromEntries(
+ activeModelKeys.map((modelKey) => {
+ const modelPayload = modelPayloadMap[modelKey];
+ const modelState = modelPayload?.analysis?.ai_models?.models?.[modelKey];
+ const modelAiGauge = modelPayload?.analysis?.ai_gauge || {};
+ return [
+ modelKey,
+ {
+ gauge: roundTo(modelState?.gauge ?? modelAiGauge.gauge ?? 50, 1, 50),
+ signal: modelState?.signal || modelAiGauge.signal || '--',
+ confidence_pct: roundTo(modelState?.confidence_pct ?? modelAiGauge.confidence_pct ?? 0, 1, 0),
+ certainty: roundTo(modelState?.certainty ?? modelAiGauge.certainty ?? 0, 1, 0),
+ },
+ ];
+ }),
+ );
+ const forecastModels = Object.fromEntries(
+ FORECAST_MODEL_ORDER.map((modelKey) => {
+ const modelPayload = modelPayloadMap[modelKey];
+ if (modelPayload?.forecast_models?.[modelKey]) {
+ return [modelKey, modelPayload.forecast_models[modelKey]];
+ }
+ if (!normalizedRequested[modelKey]) {
+ return [modelKey, buildPendingForecastModelState(modelKey, false, 'disabled')];
+ }
+ const error = failedModels.includes(modelKey) ? `Model ${AI_MODEL_LABELS[modelKey] || modelKey} failed` : null;
+ return [modelKey, buildPendingForecastModelState(modelKey, true, failedModels.includes(modelKey) ? 'failed' : 'pending', error)];
+ }),
+ );
+ const modelComponents = Object.fromEntries(
+ activeModelKeys.map((modelKey) => {
+ const modelPayload = modelPayloadMap[modelKey];
+ return [
+ modelKey,
+ modelPayload?.forecast_models?.[modelKey]?.model
+ || modelPayload?.model?.components?.[modelKey]
+ || modelPayload?.model
+ || { name: AI_MODEL_LABELS[modelKey] || modelKey },
+ ];
+ }),
+ );
+ const devices = Object.fromEntries(
+ FORECAST_MODEL_ORDER.map((modelKey) => {
+ const modelPayload = modelPayloadMap[modelKey];
+ const device = modelPayload?.forecast_models?.[modelKey]?.ai_runtime?.device
+ || modelPayload?.ai_runtime?.devices?.[modelKey]
+ || modelPayload?.ai_runtime?.device;
+ if (device) {
+ return [modelKey, device];
+ }
+ if (!normalizedRequested[modelKey]) {
+ return [modelKey, 'not_requested'];
+ }
+ if (failedModels.includes(modelKey)) {
+ return [modelKey, 'error'];
+ }
+ return [modelKey, 'loading'];
+ }),
+ );
+ const firstModelKey = activeModelKeys[0] || null;
+ return {
+ ...basePayload,
+ forecast: combinedForecastRows,
+ forecast_models: forecastModels,
+ model_selection: {
+ ...(basePayload.model_selection || {}),
+ requested: normalizedRequested,
+ active: activeModelKeys,
+ independent_forecasts: true,
+ independent_analysis: true,
+ combination_mode: 'mean_of_enabled_models',
+ },
+ model: {
+ ...(basePayload.model || {}),
+ name: activeModelKeys.length === 1
+ ? (modelComponents[firstModelKey]?.name || basePayload.model?.name || firstModelKey)
+ : 'mean_of_enabled_models',
+ active_models: activeModelKeys,
+ components: modelComponents,
+ },
+ display: {
+ ...(basePayload.display || {}),
+ mode: activeModelKeys.length === 1
+ ? (basePayload.display?.mode || 'single_future_ohlc4_line')
+ : 'multi_model_ohlc4_line',
+ render_models: activeModelKeys,
+ combination_mode: 'mean_of_enabled_models',
+ },
+ ai_runtime: {
+ ...(basePayload.ai_runtime || {}),
+ active_models: activeModelKeys,
+ devices,
+ },
+ analysis: {
+ ...(basePayload.analysis || {}),
+ ai_gauge: combinedAiScore || basePayload.analysis?.ai_gauge || null,
+ summary: summary || basePayload.analysis?.summary || null,
+ dashboard,
+ ai_models: {
+ requested: normalizedRequested,
+ active: activeModelKeys,
+ pending: pendingModels,
+ failed: failedModels,
+ complete,
+ ready_count: activeModelKeys.length,
+ requested_count: enabledModelKeys.length,
+ formula: 'mean(enabled_models)',
+ models: aiModelBreakdown,
+ },
+ },
+ generated_at: Math.floor(Date.now() / 1000),
+ };
+ }
+
+ function getAiAggregationState(payload) {
+ const requested = normalizePaneAiModels(
+ payload?.analysis?.ai_models?.requested || payload?.model_selection?.requested,
+ );
+ const enabledKeys = FORECAST_MODEL_ORDER.filter((modelKey) => requested[modelKey]);
+ const readyKeys = Array.isArray(payload?.analysis?.ai_models?.active)
+ ? payload.analysis.ai_models.active.filter((modelKey) => requested[modelKey])
+ : Object.keys(payload?.analysis?.ai_models?.models || {}).filter((modelKey) => requested[modelKey]);
+ const failedKeys = Array.isArray(payload?.analysis?.ai_models?.failed)
+ ? payload.analysis.ai_models.failed.filter((modelKey) => requested[modelKey])
+ : [];
+ const pendingKeys = Array.isArray(payload?.analysis?.ai_models?.pending)
+ ? payload.analysis.ai_models.pending.filter((modelKey) => requested[modelKey])
+ : enabledKeys.filter((modelKey) => !readyKeys.includes(modelKey) && !failedKeys.includes(modelKey));
+ const completeFlag = payload?.analysis?.ai_models?.complete;
+ const complete = completeFlag === true
+ || (enabledKeys.length > 0 && readyKeys.length === enabledKeys.length && pendingKeys.length === 0 && failedKeys.length === 0);
+ return {
+ requested,
+ enabledKeys,
+ readyKeys,
+ failedKeys,
+ pendingKeys,
+ complete,
+ readyCount: readyKeys.length,
+ requestedCount: enabledKeys.length,
+ };
+ }
+
+ function buildAiAggregationMessage(aggregation) {
+ if (!aggregation.requestedCount) {
+ return 'Cần bật ít nhất 1 model AI';
+ }
+ if (aggregation.failedKeys.length) {
+ const failedLabels = aggregation.failedKeys
+ .map((modelKey) => AI_MODEL_LABELS[modelKey] || modelKey)
+ .join(', ');
+ return `Đang tổng hợp ${aggregation.readyCount}/${aggregation.requestedCount} model • lỗi: ${failedLabels}`;
+ }
+ if (!aggregation.complete) {
+ return `Đang tổng hợp ${aggregation.readyCount}/${aggregation.requestedCount} model AI`;
+ }
+ return `Trung bình cộng ${aggregation.requestedCount} model AI`;
+ }
+
+ function getGaugePresentationState(payload) {
+ const analysis = payload?.analysis;
+ const dashboard = analysis?.dashboard || {};
+ const technical = dashboard.technical || analysis?.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
+ const ai = dashboard.ai || analysis?.ai_gauge || { gauge: 50, signal: '--' };
+ const summary = dashboard.summary || analysis?.summary || { gauge: 50, signal: '--' };
+ const aggregation = getAiAggregationState(payload);
+ const aiReady = aggregation.complete && Number.isFinite(Number(ai?.gauge));
+ const summaryReady = aggregation.complete && Number.isFinite(Number(summary?.gauge));
+ const comboActive = Boolean(
+ aiReady
+ && summaryReady
+ && (
+ summary.agreement
+ || (
+ Math.abs((technical.gauge ?? 50) - 50) > 6
+ && Math.abs((ai.gauge ?? 50) - 50) > 6
+ && Math.sign((technical.gauge ?? 50) - 50) === Math.sign((ai.gauge ?? 50) - 50)
+ )
+ )
+ );
+ return {
+ technical,
+ ai,
+ summary,
+ aggregation,
+ aiReady,
+ summaryReady,
+ comboActive,
+ message: buildAiAggregationMessage(aggregation),
+ };
+ }
+
+ function buildCompactGaugeCardMarkup({
+ title,
+ gauge,
+ signal,
+ delay = '0s',
+ hero = false,
+ comboStrong = false,
+ state = 'ready',
+ message = '',
+ dataAction = '',
+ }) {
+ const stateLabel = state === 'error' ? 'Lỗi AI' : state === 'loading' ? 'Đang tổng hợp' : (signal || '--');
+ const signalClass = state === 'ready' ? getSignalClass(signal) : 'neutral';
+ const dataActionAttr = dataAction ? ` data-action="${dataAction}"` : '';
+ const heroClass = hero ? ' hero' : '';
+ const comboClass = comboStrong ? ' combo-strong' : '';
+ const content = state === 'ready'
+ ? `${buildGaugeSvg(gaugeToRawScore(gauge), 80, 50, false)}
`
+ : `
+
+ `;
+ const caption = state === 'ready' ? stateLabel : message;
+ return `
+
+
${title}
+ ${content}
+
${escapeHtml(caption || stateLabel)}
+
+ `;
+ }
+
+ function buildHeroGaugePendingMarkup(message, detailLabel = 'Trạng thái') {
+ return `
+
+
+
+ ${detailLabel}:
+ ${escapeHtml(message)}
+
+
+ Công thức:
+ mean(enabled_models)
+
+
+ Đang tổng hợp
+ `;
+ }
+
+ window.buildCombinedForecastPayloadForPane = buildCombinedForecastPayloadForPane;
+
+ function renderCompactGauges(symbol, interval, payload) {
+ const container = chartGauges;
+ if (!container) {
+ return;
+ }
+
+ if (window.Workspace && Workspace.layoutPreset > 1) {
+ container.innerHTML = '';
+ container.classList.remove('combo-active');
+ container.style.display = 'none';
+ return;
+ }
+
+ container.style.display = '';
+ if (!payload?.analysis) {
+ container.innerHTML = '';
+ container.classList.remove('combo-active');
+ return;
+ }
+
+ const gaugeState = getGaugePresentationState(payload);
+ const aiCardState = gaugeState.aiReady ? 'ready' : (gaugeState.aggregation.failedKeys.length ? 'error' : 'loading');
+ const summaryCardState = gaugeState.summaryReady ? 'ready' : (gaugeState.aggregation.failedKeys.length ? 'error' : 'loading');
+
+ container.classList.toggle('combo-active', gaugeState.comboActive);
+ container.innerHTML = `
+ ${buildCompactGaugeCardMarkup({
+ title: 'Kỹ thuật',
+ gauge: gaugeState.technical.gauge,
+ signal: gaugeState.technical.signal,
+ delay: '0s',
+ dataAction: 'refresh-analysis',
+ })}
+ ${buildCompactGaugeCardMarkup({
+ title: 'Dự báo AI',
+ gauge: gaugeState.ai.gauge,
+ signal: gaugeState.ai.signal,
+ delay: '0.08s',
+ state: aiCardState,
+ message: gaugeState.message,
+ dataAction: 'refresh-analysis',
+ })}
+ ${buildCompactGaugeCardMarkup({
+ title: 'Tổng kết',
+ gauge: gaugeState.summary.gauge,
+ signal: gaugeState.summary.signal,
+ delay: '0.16s',
+ hero: true,
+ comboStrong: gaugeState.comboActive,
+ state: summaryCardState,
+ message: gaugeState.message,
+ dataAction: 'refresh-analysis',
+ })}
+ `;
+ }
+
+ if (chartGauges) {
+ chartGauges.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const card = target ? target.closest('[data-action="refresh-analysis"]') : null;
+ if (!card || !refreshBtn) return;
+ refreshBtn.click();
+ });
+ }
+
+ /* ── State for cached analysis data ──────────── */
+ let lastAnalysisPayload = null;
+ let lastAnalysisSymbol = null;
+ let lastAnalysisInterval = null;
+
+ /* ── Fullscreen Dashboard v6.0 — 3 Gauge Hero ──── */
+ function renderAnalysisPanel(symbol, interval, payload) {
+ // Cache the payload for toggle reuse
+ if (payload?.analysis) {
+ lastAnalysisPayload = payload;
+ lastAnalysisSymbol = symbol;
+ lastAnalysisInterval = interval;
+ }
+
+ if (!payload?.analysis) {
+ analysisPanel.innerHTML = `
+
+
+
Nhấn "Phân tích" để hiển thị bảng phân tích kỹ thuật
+
+ `;
+ return;
+ }
+
+ const a = payload.analysis;
+ if (!a.oscillators && !a.moving_averages) {
+ analysisPanel.innerHTML = '';
+ return;
+ }
+
+ const osc = a.oscillators || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
+ const ma = a.moving_averages || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
+ const gaugeState = getGaugePresentationState(payload);
+ const technicals = a.technicals || gaugeState.technical || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
+ const aiGauge = gaugeState.ai || a.ai_gauge || { gauge: 50, signal: '--', confidence_pct: 0, certainty: 0, path_consistency: 50 };
+ const summary = gaugeState.summary || a.summary || { sell: 0, neutral: 0, buy: 0, signal: '--' };
+ const dashboard = a.dashboard || {};
+ const pivots = (a.pivot_points || {}).data || [];
+ const comboActive = gaugeState.comboActive;
+ const aiReady = gaugeState.aiReady;
+ const summaryReady = gaugeState.summaryReady;
+ const aiStatusMessage = gaugeState.message;
+
+ const forecastRows = payload.forecast || [];
+ const lastClose = payload.last_close || 0;
+ const aiCurrentPrice = dashboard.ai?.current_price ?? lastClose;
+ const forecastEnd = dashboard.ai?.forecast_price ?? (forecastRows.length > 1 ? (forecastRows[forecastRows.length - 1]?.p50 ?? lastClose) : lastClose);
+ const forecastPctChange = dashboard.ai?.forecast_return_pct ?? (lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0);
+
+ // ── Big SVG Gauge builder (Refactored to buildGaugeSvg) ──
+
+ function signalClass(signal) {
+ return getSignalClass(signal);
+ }
+
+ function actCls(act) {
+ return act === 'Mua' ? 'dt-act-buy' : act === 'Bán' ? 'dt-act-sell' : 'dt-act-neut';
+ }
+
+ const oscRows = osc.data.map(d => `| ${d.name} | ${d.value !== null ? d.value : '—'} | ${d.action} |
`).join('');
+ const maRows = ma.data.map(d => `| ${d.name} | ${d.value !== null ? d.value : '—'} | ${d.action} |
`).join('');
+ const pivotRows = pivots.map(p => `| ${p.level} | ${p.classic ?? '—'} | ${p.fibonacci ?? '—'} | ${p.camarilla ?? '—'} | ${p.woodie ?? '—'} | ${p.dm ?? '—'} |
`).join('');
+
+ const sLabel = symbolMap.get(symbol) || symbol;
+ const tLabel = timeframeMap[interval] || interval;
+
+ analysisPanel.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
PHÂN TÍCH KỸ THUẬT
+
+ ${buildGaugeSvg(gaugeToRawScore(technicals.gauge))}
+
+
${technicals.signal}
+
+ Bán${technicals.sell}
+ Trung lập${technicals.neutral}
+ Mua${technicals.buy}
+
+
+
+
+
+
DỰ BÁO AI
+ ${aiReady ? `
+
+ ${buildGaugeSvg(gaugeToRawScore(aiGauge.gauge))}
+
+
+
+ Close hiện tại:
+ ${formatPrice(aiCurrentPrice)}
+
+
+ OHLC4 dự kiến:
+ ${formatPrice(forecastEnd)}
+
+
+ Biến động vs close:
+ ${forecastPctChange >= 0 ? '↑' : '↓'} ${Math.abs(forecastPctChange).toFixed(2)}%
+
+
+ Độ chắc chắn:
+ ${Number(aiGauge.certainty ?? 0).toFixed(1)}%
+
+
+ Độ ổn định đường đi:
+ ${Number(aiGauge.path_consistency ?? 0).toFixed(1)}%
+
+
+
${aiGauge.signal}
+ ` : buildHeroGaugePendingMarkup(aiStatusMessage, 'AI')}
+
+
+
+
+
Tổng kết
+ ${summaryReady ? `
+
+ ${buildGaugeSvg(gaugeToRawScore(summary.gauge))}
+
+
${summary.signal}
+ ` : buildHeroGaugePendingMarkup(aiStatusMessage, 'Tổng hợp')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Mức | CL | FB | CM | WD | DM |
+ ${pivotRows}
+
+
+
+
+
+
+
+ Cảnh báo — Thông tin phân tích kỹ thuật này không phải lời khuyên đầu tư. Hãy luôn quản lý rủi ro.
+
+
+
+
+ `;
+
+ // Close logic
+ const closeBtn = document.getElementById('dashCloseBtn');
+ if (closeBtn) closeBtn.onclick = () => analysisPanel.classList.remove('active');
+
+ const tablesRow = analysisPanel.querySelector('.dash-tables-row');
+ const focusCols = Array.from(analysisPanel.querySelectorAll('.dash-col[data-focus-panel]'));
+ focusCols.forEach((col) => {
+ col.onclick = () => {
+ const key = col.getAttribute('data-focus-panel');
+ const alreadyFocused = col.classList.contains('is-focus');
+ focusCols.forEach((item) => item.classList.remove('is-focus'));
+ tablesRow?.classList.remove('has-focus', 'focus-osc', 'focus-ma', 'focus-pivots');
+ if (!alreadyFocused && key && tablesRow) {
+ col.classList.add('is-focus');
+ tablesRow.classList.add('has-focus', `focus-${key}`);
+ requestAnimationFrame(() => col.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
+ }
+ };
+ });
+ }
+
+
+
+ /* ── Clear chart immediately on switch ─────── */
+ function cancelPendingAnalysis(releaseState = true) {
+ if (analysisRetryTimer) {
+ clearTimeout(analysisRetryTimer);
+ analysisRetryTimer = null;
+ }
+ if (analysisFetchController) {
+ analysisFetchController.abort();
+ analysisFetchController = null;
+ }
+ if (releaseState) {
+ analysisRequestPromise = null;
+ analysisRequestKey = null;
+ }
+ }
+
+ function hasCachedAnalysisFor(symbol, interval) {
+ return Boolean(
+ lastAnalysisPayload &&
+ lastAnalysisSymbol === symbol &&
+ lastAnalysisInterval === interval
+ );
+ }
+
+ function hasLiveForecastFor(symbol, interval) {
+ return Boolean(
+ activeForecastContext.ready &&
+ activeForecastContext.symbol === symbol &&
+ activeForecastContext.interval === interval
+ );
+ }
+
+ function setPrimaryForecastVisibility(visible) {
+ p50Series.applyOptions({ visible });
+ p10Series.applyOptions({ visible: false });
+ p90Series.applyOptions({ visible: false });
+ forecastCandleSeries.applyOptions({ visible: false });
+ }
+
+ function clearForecastVisuals() {
+ forecastCandleSeries.setData([]);
+ forecastReserveSeries.setData([]);
+ p50Series.setData([]);
+ p10Series.setData([]);
+ p90Series.setData([]);
+ FORECAST_MODEL_ORDER.forEach((modelKey) => {
+ const series = primaryForecastModelSeries[modelKey];
+ if (!series) return;
+ series.setData([]);
+ series.applyOptions(buildForecastModelSeriesOptions(modelKey, [], buildSeriesPriceFormat(), false));
+ });
+ clearForecastSegments();
+ setPrimaryForecastVisibility(false);
+ const rootPane = window.Workspace && typeof Workspace.getPane === 'function'
+ ? Workspace.getPane('pane-0')
+ : null;
+ if (rootPane) {
+ rootPane.loadedFutureBars = 0;
+ hidePaneForecastHover(rootPane);
+ }
+ activeForecastContext = { symbol: null, interval: null, modelSignature: null, ready: false };
+ }
+
+ function clearPrimaryForecastCandlesOnly() {
+ p50Series.setData([]);
+ p10Series.setData([]);
+ p90Series.setData([]);
+ FORECAST_MODEL_ORDER.forEach((modelKey) => {
+ const series = primaryForecastModelSeries[modelKey];
+ if (series) {
+ series.setData([]);
+ }
+ });
+ clearForecastSegments();
+ forecastCandleSeries.setData([]);
+ setPrimaryForecastVisibility(false);
+ forecastCandleSeries.applyOptions({
+ ...buildForecastCandleSeriesOptions(),
+ visible: false,
+ priceFormat: buildSeriesPriceFormat(),
+ });
+ FORECAST_MODEL_ORDER.forEach((modelKey) => {
+ const series = primaryForecastModelSeries[modelKey];
+ if (series) {
+ series.applyOptions(buildForecastModelSeriesOptions(modelKey, [], buildSeriesPriceFormat(), false));
+ }
+ });
+ }
+
+ function commitForecastVisuals(symbol, interval, forecastLine = []) {
+ const points = Array.isArray(forecastLine) ? forecastLine : [];
+ const tone = getForecastTone(points);
+ const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat;
+ const chartViewportContext = buildChartViewportContext(symbol, interval);
+ const rootPane = window.Workspace && typeof Workspace.getPane === 'function'
+ ? Workspace.getPane('pane-0')
+ : null;
+ forecastCandleSeries.setData([]);
+ forecastCandleSeries.applyOptions({
+ ...buildForecastCandleSeriesOptions(tone),
+ visible: false,
+ priceFormat: buildSeriesPriceFormat(),
+ });
+
+ p50Series.setData([]);
+ p10Series.setData([]);
+ p90Series.setData([]);
+ clearForecastSegments();
+ p50Series.applyOptions({
+ color: palette.line,
+ lineWidth: MODEL_CONFIG.defaultLineWidth,
+ priceFormat: buildSeriesPriceFormat(),
+ visible: points.length > 0,
+ });
+ setPrimaryForecastVisibility(points.length > 0);
+ p50Series.setData(points);
+ if (rootPane) {
+ refreshPaneReservedFutureSpace(rootPane, normalizeFutureTimesFromPoints(points));
+ }
+ alignChartViewportToReservedSpace('pane-0', chart, chartViewportContext, {
+ allowFitFallback: true,
+ });
+ activeForecastContext = { symbol, interval, ready: true };
+ }
+
+ function clearPaneForecastSegments(pane) {
+ if (!pane?.chartInstance) return;
+ const segments = Array.isArray(pane.forecastSeries?.segments) ? pane.forecastSeries.segments : [];
+ if (!segments.length) {
+ if (pane?.forecastSeries) pane.forecastSeries.segments = [];
+ return;
+ }
+ for (const series of segments) {
+ try {
+ pane.chartInstance.removeSeries(series);
+ } catch (error) {
+ console.warn(`[Pane ${pane.paneId}] clear forecast segment failed`, error);
+ }
+ }
+ pane.forecastSeries.segments = [];
+ }
+
+ function clearPaneForecastVisuals(pane) {
+ if (!pane?.forecastSeries) return;
+ clearPaneForecastSegments(pane);
+ hidePaneForecastHover(pane);
+ if (pane.forecastSeries.p50) {
+ pane.forecastSeries.p50.setData([]);
+ pane.forecastSeries.p50.applyOptions({
+ visible: false,
+ color: FORECAST_PALETTE.flat.line,
+ });
+ }
+ if (pane.forecastSeries.p10) {
+ pane.forecastSeries.p10.setData([]);
+ pane.forecastSeries.p10.applyOptions({ color: FORECAST_PALETTE.flat.band, visible: false });
+ }
+ if (pane.forecastSeries.p90) {
+ pane.forecastSeries.p90.setData([]);
+ pane.forecastSeries.p90.applyOptions({ color: FORECAST_PALETTE.flat.band, visible: false });
+ }
+ if (pane.forecastSeries.candles) {
+ pane.forecastSeries.candles.setData([]);
+ pane.forecastSeries.candles.applyOptions({ ...buildForecastCandleSeriesOptions(), visible: false });
+ }
+ if (pane.forecastSeries.reserve) {
+ pane.forecastSeries.reserve.setData([]);
+ }
+ Object.entries(pane.forecastSeries.models || {}).forEach(([modelKey, series]) => {
+ if (!series) return;
+ series.setData([]);
+ series.applyOptions(buildForecastModelSeriesOptions(
+ modelKey,
+ [],
+ {
+ type: 'price',
+ precision: pane.priceFormat?.precision ?? 2,
+ minMove: pane.priceFormat?.minMove ?? 0.01,
+ },
+ false,
+ ));
+ });
+ pane.cachedForecastModelLines = createEmptyForecastModelLines();
+ pane.loadedFutureBars = 0;
+ pane.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false };
+ }
+
+ function clearPaneForecastCandlesOnly(pane) {
+ if (!pane?.forecastSeries) return;
+ clearPaneForecastVisuals(pane);
+ }
+
+ function resetPendingPaneAI(pane) {
+ if (!pane) return;
+ if (pane.analysisFetchController) {
+ pane.analysisFetchController.abort();
+ pane.analysisFetchController = null;
+ }
+ if (pane.analysisRetryTimer) {
+ clearTimeout(pane.analysisRetryTimer);
+ pane.analysisRetryTimer = null;
+ }
+ pane.analysisRequestPromise = null;
+ pane.analysisRequestKey = null;
+ clearPaneForecastCandlesOnly(pane);
+ }
+
+ function renderPaneForecastVisuals(pane, forecastModelLines = {}, visibleModelSelection = null) {
+ if (!pane?.forecastSeries || !pane?.chartInstance) return;
+ clearPaneForecastSegments(pane);
+ const selectedModels = normalizePaneAiModels(visibleModelSelection || pane.aiModels);
+ const priceFormat = {
+ type: 'price',
+ precision: pane.priceFormat?.precision ?? 2,
+ minMove: pane.priceFormat?.minMove ?? 0.01,
+ };
+ pane.cachedForecastModelLines = Object.fromEntries(
+ FORECAST_MODEL_ORDER.map((modelKey) => [
+ modelKey,
+ Array.isArray(forecastModelLines?.[modelKey]) ? forecastModelLines[modelKey] : [],
+ ])
+ );
+
+ if (pane.forecastSeries.p10) {
+ pane.forecastSeries.p10.setData([]);
+ pane.forecastSeries.p10.applyOptions({ color: FORECAST_PALETTE.flat.band, visible: false });
+ }
+ if (pane.forecastSeries.p90) {
+ pane.forecastSeries.p90.setData([]);
+ pane.forecastSeries.p90.applyOptions({ color: FORECAST_PALETTE.flat.band, visible: false });
+ }
+
+ if (pane.forecastSeries.candles) {
+ pane.forecastSeries.candles.setData([]);
+ pane.forecastSeries.candles.applyOptions({
+ ...buildForecastCandleSeriesOptions(),
+ visible: false,
+ priceFormat,
+ });
+ }
+
+ if (pane.forecastSeries.p50) {
+ pane.forecastSeries.p50.setData([]);
+ pane.forecastSeries.p50.applyOptions({
+ color: FORECAST_PALETTE.flat.line,
+ visible: false,
+ });
+ }
+
+ const allPoints = [];
+ let reserveFutureTimes = [];
+ Object.entries(pane.forecastSeries.models || {}).forEach(([modelKey, series]) => {
+ const points = Array.isArray(pane.cachedForecastModelLines?.[modelKey]) ? pane.cachedForecastModelLines[modelKey] : [];
+ if (!series) return;
+ series.setData(points);
+ const visible = Boolean(selectedModels[modelKey]) && points.length > 1;
+ series.applyOptions(buildForecastModelSeriesOptions(modelKey, points, priceFormat, visible));
+ if (visible) {
+ allPoints.push(...points);
+ const futureTimes = normalizeFutureTimesFromPoints(points);
+ if (futureTimes.length > reserveFutureTimes.length) {
+ reserveFutureTimes = futureTimes;
+ }
+ }
+ });
+
+ if (!allPoints.length) {
+ refreshPaneReservedFutureSpace(pane);
+ hidePaneForecastHover(pane);
+ alignChartViewportToReservedSpace(
+ pane.paneId,
+ pane.chartInstance,
+ buildChartViewportContext(pane.symbol, pane.interval),
+ { allowFitFallback: true },
+ );
+ return;
+ }
+
+ refreshPaneReservedFutureSpace(pane, reserveFutureTimes);
+ alignChartViewportToReservedSpace(
+ pane.paneId,
+ pane.chartInstance,
+ buildChartViewportContext(pane.symbol, pane.interval),
+ { allowFitFallback: true },
+ );
+ }
+
+ function createPaneForecastPayloadCacheEntry(payload, requestMeta = {}) {
+ return {
+ payload,
+ symbol: requestMeta.symbol ?? payload?.symbol ?? null,
+ interval: requestMeta.interval ?? payload?.interval ?? null,
+ horizon: requestMeta.horizon ?? payload?.horizon ?? null,
+ modelSignature: requestMeta.modelSignature ?? getPaneAiModelSignature(requestMeta.models ?? payload?.model_selection?.requested),
+ };
+ }
+
+ function getCachedPaneAnalysisPayload(pane, requestMeta = {}) {
+ const modelSignature = requestMeta.modelSignature ?? getPaneAiModelSignature(requestMeta.models);
+ const cacheEntry = pane?.analysisPayloadCache?.[modelSignature];
+ if (!cacheEntry) return null;
+ const entry = cacheEntry?.payload ? cacheEntry : createPaneForecastPayloadCacheEntry(cacheEntry, requestMeta);
+ if (
+ entry.symbol !== requestMeta.symbol
+ || entry.interval !== requestMeta.interval
+ || entry.horizon !== requestMeta.horizon
+ ) {
+ return null;
+ }
+ return entry.payload;
+ }
+
+ function deriveForecastPayloadForSelection(sourcePayload, selectedModels) {
+ const normalizedModels = normalizePaneAiModels(selectedModels);
+ const selectedKeys = Object.entries(normalizedModels)
+ .filter(([, enabled]) => enabled)
+ .map(([modelKey]) => modelKey);
+ if (selectedKeys.length !== 1) {
+ return null;
+ }
+ const modelKey = selectedKeys[0];
+ const modelPayload = sourcePayload?.forecast_models?.[modelKey];
+ if (!modelPayload || !modelPayload.analysis || !Array.isArray(modelPayload.forecast)) {
+ return null;
+ }
+ const summary = modelPayload.analysis?.dashboard?.summary || modelPayload.analysis?.summary || {};
+ return {
+ ...sourcePayload,
+ analysis: modelPayload.analysis,
+ forecast: modelPayload.forecast,
+ ai_runtime: modelPayload.ai_runtime || sourcePayload.ai_runtime,
+ model: modelPayload.model || sourcePayload.model,
+ model_diagnostics: modelPayload.model_diagnostics || sourcePayload.model_diagnostics,
+ ensemble: modelPayload.ensemble || sourcePayload.ensemble,
+ verdict: summary.signal || sourcePayload.verdict || null,
+ model_selection: {
+ ...(sourcePayload.model_selection || {}),
+ requested: normalizedModels,
+ active: selectedKeys,
+ combination_mode: 'mean_of_enabled_models',
+ },
+ };
+ }
+
+ function findPaneAnalysisPayloadForSelection(pane, requestMeta = {}) {
+ const exactPayload = getCachedPaneAnalysisPayload(pane, requestMeta);
+ if (exactPayload) {
+ return exactPayload;
+ }
+ const normalizedModels = normalizePaneAiModels(requestMeta.models);
+ const seenPayloads = new Set();
+ const candidates = [];
+ if (pane?.lastAnalysis?.payload && pane?.lastAnalysis?.complete === true) {
+ candidates.push(pane.lastAnalysis.payload);
+ }
+ Object.values(pane?.analysisPayloadCache || {}).forEach((entry) => {
+ const payload = entry?.payload || entry;
+ if (payload && !seenPayloads.has(payload)) {
+ candidates.push(payload);
+ seenPayloads.add(payload);
+ }
+ });
+ for (const payload of candidates) {
+ const payloadSymbol = payload?.symbol ?? null;
+ const payloadInterval = payload?.interval ?? null;
+ const payloadHorizon = payload?.horizon ?? null;
+ if (
+ payloadSymbol !== requestMeta.symbol
+ || payloadInterval !== requestMeta.interval
+ || payloadHorizon !== requestMeta.horizon
+ ) {
+ continue;
+ }
+ const derivedPayload = deriveForecastPayloadForSelection(payload, normalizedModels);
+ if (derivedPayload) {
+ const modelSignature = requestMeta.modelSignature ?? getPaneAiModelSignature(normalizedModels);
+ pane.analysisPayloadCache[modelSignature] = createPaneForecastPayloadCacheEntry(
+ derivedPayload,
+ { ...requestMeta, models: normalizedModels, modelSignature },
+ );
+ return derivedPayload;
+ }
+ }
+ return null;
+ }
+
+ function applyForecastPayloadToPane(pane, payload, requestMeta = {}) {
+ if (!pane || !payload) return null;
+ const normalizedModels = normalizePaneAiModels(requestMeta.models ?? pane.aiModels);
+ const modelSignature = requestMeta.modelSignature ?? getPaneAiModelSignature(normalizedModels);
+ const symbol = requestMeta.symbol ?? pane.symbol;
+ const interval = requestMeta.interval ?? pane.interval;
+ const horizon = requestMeta.horizon ?? pane.horizon ?? payload?.horizon ?? 10;
+ const isCompletePayload = requestMeta.complete !== false;
+ if (isCompletePayload) {
+ pane.analysisPayloadCache[modelSignature] = createPaneForecastPayloadCacheEntry(
+ payload,
+ { symbol, interval, horizon, modelSignature, models: normalizedModels },
+ );
+ }
+ pane.lastAnalysis = {
+ payload,
+ symbol,
+ interval,
+ horizon,
+ modelSignature,
+ complete: isCompletePayload,
+ };
+ const actualPoint = (
+ Number.isFinite(Number(pane.lastCandleData?.time))
+ && Number.isFinite(Number(pane.lastCandleData?.close))
+ )
+ ? {
+ time: Number(pane.lastCandleData.time),
+ value: Number(pane.lastCandleData.close),
+ }
+ : null;
+ const forecastModelLines = extractForecastModelLines(payload, actualPoint, normalizedModels);
+ renderPaneForecastVisuals(pane, forecastModelLines, normalizedModels);
+ pane.forecastContext = {
+ symbol,
+ interval,
+ horizon,
+ modelSignature,
+ ready: Object.values(pane.cachedForecastModelLines || {}).some((points) => Array.isArray(points) && points.length > 1),
+ };
+ if (typeof renderPaneAnalysisUI === 'function') {
+ renderPaneAnalysisUI(pane);
+ }
+ if (typeof isPaneActiveForSharedUi === 'function' && isPaneActiveForSharedUi(pane)) {
+ syncActivePaneGlobals(pane);
+ renderCompactGauges(symbol, interval, payload);
+ if (analysisPanel?.classList.contains('active') || pane.analysisOpen) {
+ renderAnalysisPanel(symbol, interval, payload);
+ setTimeout(updateDashboardScale, 10);
+ }
+ const selectedKeys = Object.entries(normalizedModels)
+ .filter(([, enabled]) => enabled)
+ .map(([modelKey]) => modelKey);
+ const visibleLines = selectedKeys
+ .map((modelKey) => pane.cachedForecastModelLines?.[modelKey] || [])
+ .find((points) => Array.isArray(points) && points.length > 1) || [];
+ const currentPrice = Number(pane.lastCandleData?.close ?? payload?.last_close ?? 0);
+ const lastForecastVal = Number(
+ visibleLines.length
+ ? visibleLines[visibleLines.length - 1]?.value
+ : payload?.analysis?.dashboard?.ai?.forecast_price
+ ?? payload?.analysis?.ai_gauge?.forecast_price
+ ?? currentPrice,
+ );
+ const isBull = lastForecastVal >= currentPrice;
+ const pctChange = currentPrice > 0 ? ((lastForecastVal - currentPrice) / currentPrice) * 100 : 0;
+ const pctLabel = `${pctChange >= 0 ? '+' : ''}${pctChange.toFixed(2)}%`;
+ const trend = isBull ? 'TANG' : 'GIAM';
+ const source = payload.source || 'N/A';
+ if (typeof updateStatus === 'function') {
+ updateStatus(
+ `${symbol} - ${source} - ${interval} - ${formatPrice(currentPrice)} => ${formatPrice(lastForecastVal)} (${trend} ${pctLabel})`,
+ 'ok',
+ );
+ }
+ }
+ return payload;
+ }
+
+ window.clearPaneForecastVisuals = clearPaneForecastVisuals;
+ window.clearPaneForecastCandlesOnly = clearPaneForecastCandlesOnly;
+ window.renderPaneForecastVisuals = renderPaneForecastVisuals;
+ window.getCachedPaneAnalysisPayload = getCachedPaneAnalysisPayload;
+ window.findPaneAnalysisPayloadForSelection = findPaneAnalysisPayloadForSelection;
+ window.applyForecastPayloadToPane = applyForecastPayloadToPane;
+
+ function scheduleAnalysisRetry(symbol, interval) {
+ if (analysisRetryTimer) return;
+ analysisRetryTimer = setTimeout(() => {
+ analysisRetryTimer = null;
+ if (currentSymbol === symbol && timeframeSelect.value === interval) {
+ fetchAIAnalysis(symbol, interval, { force: true, background: true });
+ }
+ }, 15000);
+ }
+
+ function resetChartContext() {
+ cancelPendingAnalysis();
+ candleSeries.setData([]);
+ clearForecastVisuals();
+
+ // Clear indicators
+ bbMiddleSeries.setData([]);
+ bbUpperSeries.setData([]);
+ bbLowerSeries.setData([]);
+ if (rsiSeries) rsiSeries.setData([]);
+
+ // Hide dashboard and clear cached data on symbol switch
+ analysisPanel.classList.remove('active');
+ const gContainer = document.getElementById('chartGauges');
+ if (gContainer) gContainer.innerHTML = '';
+
+ lastAnalysisPayload = null;
+ lastAnalysisSymbol = null;
+ lastAnalysisInterval = null;
+ lastCandleData = null;
+ activeChartContext = { symbol: null, interval: null };
+ chart.priceScale('right').applyOptions({ autoScale: true });
+ const pane0 = window.Workspace && typeof Workspace.getPane === 'function'
+ ? Workspace.getPane('pane-0')
+ : null;
+ if (pane0) {
+ pane0.historicalBarCount = 0;
+ pane0.loadedFutureBars = 0;
+ }
+
+ const symbol = currentSymbol;
+ const interval = timeframeSelect.value;
+ if (symbol && interval) {
+ fetch(`${API_BASE}/api/switch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ symbol, interval }),
+ }).catch(e => console.warn('[switch] failed', e));
+ }
+ }
+
+ /* ── API helper ────────────────────────────── */
+ async function apiRequest(path, requestOptions = {}) {
+ const sep = path.includes('?') ? '&' : '?';
+ const url = `${API_BASE}${path}${sep}_t=${Date.now()}`;
+
+ const options = { method: 'GET' };
+ if (requestOptions.signal) options.signal = requestOptions.signal;
+
+ const resp = await fetch(url, options);
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
+ throw new Error(typeof err.detail === 'object'
+ ? err.detail.message || JSON.stringify(err.detail)
+ : (err.detail || 'Lỗi kết nối'));
+ }
+ return resp.json();
+ }
+
+ /* ── Load symbol list metadata ─────────────── */
+ async function loadSymbols() {
+ try {
+ const data = await apiRequest('/api/symbols');
+ allSymbolsData = data; // Cache globally (BUG FIX)
+ data.symbols.forEach(s => {
+ symbolMap.set(s.symbol, s.label);
+ symbolMetaMap.set(s.symbol, s);
+ });
+ } catch (e) {
+ updateStatus('Lỗi nạp metadata: ' + e.message, 'error');
+ }
+ }
+
+ /* ── Main refresh ──────────────────────────── */
+
+ async function refreshChart(options = {}) {
+ const symbol = currentSymbol;
+ const interval = timeframeSelect.value;
+ const forceContextReset = Boolean(options.forceContextReset);
+ currentInterval = interval;
+ if (!symbol) return;
+
+ const contextChanged =
+ forceContextReset ||
+ activeChartContext.symbol !== symbol ||
+ activeChartContext.interval !== interval;
+
+ if (contextChanged) {
+ resetChartContext();
+ }
+
+ if (chartFetchController) chartFetchController.abort();
+ chartFetchController = new AbortController();
+ const chartSignal = chartFetchController.signal;
+
+ refreshBtn.disabled = true;
+ updateStatus(`${symbol} | ${interval} - Đang nạp dữ liệu...`, 'loading');
+ showLoader('Đang nạp dữ liệu...');
+ dimChartForRefresh();
+
+ const sLabel = symbolMap.get(symbol) || symbol;
+ const tLabel = timeframeMap[interval] || interval;
+ const isDark = document.body.classList.contains('dark-theme');
+ chart.applyOptions({
+ watermark: {
+ text: `${sLabel} | ${tLabel}`,
+ color: isDark ? 'rgba(34, 211, 238, 0.27)' : 'rgba(15, 23, 42, 0.12)',
+ fontSize: 72,
+ },
+ });
+ syncChartPriceFormat(symbol);
+
+ try {
+ const [histData, indData] = await Promise.all([
+ apiRequest(`/api/historical/${encodeURIComponent(symbol)}?interval=${interval}&limit=${CHART_HISTORY_LIMIT}`, { signal: chartSignal }),
+ apiRequest(`/api/indicators/${encodeURIComponent(symbol)}?interval=${interval}&limit=${CHART_HISTORY_LIMIT}`, { signal: chartSignal })
+ ]);
+
+ syncChartPriceFormat(symbol, histData.data);
+ if (histData.data.length > 0) {
+ candleSeries.setData(histData.data);
+ lastCandleData = histData.data[histData.data.length - 1];
+ }
+ const pane0 = window.Workspace && typeof Workspace.getPane === 'function'
+ ? Workspace.getPane('pane-0')
+ : null;
+ if (pane0) {
+ pane0.symbol = symbol;
+ pane0.interval = interval;
+ pane0.historicalBarCount = histData.data.length;
+ pane0.lastCandleData = lastCandleData;
+ refreshPaneReservedFutureSpace(pane0);
+ }
+
+ const type = indicatorSelect.value;
+ const indicators = indData.indicators || {};
+ const series = indicators.series || {};
+
+ if (series.bb_upper) bbUpperSeries.setData(series.bb_upper);
+ if (series.bb_mid) bbMiddleSeries.setData(series.bb_mid);
+ if (series.bb_lower) bbLowerSeries.setData(series.bb_lower);
+
+ restoreOrFitChartViewport(
+ 'pane-0',
+ chart,
+ buildChartViewportContext(symbol, interval),
+ );
+ activeChartContext = { symbol, interval };
+ if (pane0) {
+ pane0.priceFormat = currentPriceFormat;
+ if (pane0.priceEl && lastCandleData) {
+ pane0.priceEl.textContent = Number(lastCandleData.close).toFixed(currentPriceFormat.precision);
+ }
+ renderPaneAnalysisUI(pane0);
+ if (typeof pane0.fetchAI === 'function') {
+ pane0.fetchAI({ force: true });
+ }
+ }
+ hideLoader();
+ updateStatus(
+ hasLiveForecastFor(symbol, interval)
+ ? `${symbol} | ${interval} - Dang lam moi AI...`
+ : `${symbol} | ${interval} - Dang nap AI...`,
+ 'loading'
+ );
+ } catch (e) {
+ if (e.name === 'AbortError') return;
+ console.error('Stage 1 Fetch Error:', e);
+ updateStatus('Loi nap du lieu: ' + e.message, 'error');
+ } finally {
+ refreshBtn.disabled = false;
+ hideLoader();
+ const chartContainer = document.getElementById('chart-container');
+ if (chartContainer) chartContainer.style.opacity = '1';
+ }
+ }
+
+ async function fetchAIAnalysis(symbol, interval, options = {}) {
+ const targetPane = getToolbarTargetPane();
+ if (!targetPane || typeof targetPane.fetchAI !== 'function') {
+ return null;
+ }
+ if (targetPane.symbol !== symbol || targetPane.interval !== interval) {
+ return null;
+ }
+ return targetPane.fetchAI({
+ force: Boolean(options.force),
+ preserveForecastVisuals: Boolean(options.background),
+ });
+ }
+
+ /* Event listeners */
+
+
+ /* ── Product Explorer Logic ────────────────── */
+ const explorerOverlay = document.getElementById('explorerOverlay');
+ const closeExplorerBtn = document.getElementById('closeExplorerBtn');
+ const explorerCats = document.getElementById('explorerCats');
+ const explorerGrid = document.getElementById('explorerGrid');
+ const explorerSearchInput = document.getElementById('explorerSearchInput');
+ let allSymbolsData = null;
+ let currentExplorerCat = null;
+ const CHART_HISTORY_LIMIT = 500;
+
+ let explorerHtmlCache = {}; // Category -> HTML string
+
+ async function openExplorer() {
+ explorerOverlay.classList.add('active');
+
+ if (!allSymbolsData) {
+ try {
+ allSymbolsData = await apiRequest('/api/symbols');
+ renderExplorerCats();
+ if (allSymbolsData.categories.length > 0) {
+ selectExplorerCat(allSymbolsData.categories[0]);
+ }
+ } catch (e) {
+ console.error('Failed to load explorer symbols', e);
+ }
+ } else {
+ // Ensure UI is initialized if loadSymbols was already called
+ if (explorerCats.children.length === 0) {
+ renderExplorerCats();
+ if (allSymbolsData.categories.length > 0) {
+ selectExplorerCat(allSymbolsData.categories[0]);
+ }
+ }
+ }
+ explorerSearchInput.focus();
+ }
+
+ function closeExplorer() {
+ explorerOverlay.classList.remove('active');
+ }
+
+ function renderExplorerCats() {
+ explorerCats.innerHTML = allSymbolsData.categories.map(cat => `
+
+ ${escapeHtml(cat)}
+
+ `).join('');
+ }
+
+ function selectExplorerCat(cat) {
+ currentExplorerCat = cat;
+ renderExplorerCats();
+ renderExplorerGrid();
+ }
+
+ function renderExplorerGrid() {
+ const search = explorerSearchInput.value.toLowerCase();
+ if (!search && explorerHtmlCache[currentExplorerCat]) {
+ explorerGrid.innerHTML = explorerHtmlCache[currentExplorerCat];
+ return;
+ }
+ const symbols = allSymbolsData.symbols_by_category[currentExplorerCat] || [];
+ const filtered = search
+ ? symbols.filter(s => s.symbol.toLowerCase().includes(search) || s.label.toLowerCase().includes(search))
+ : symbols;
+ let html = '';
+ for (let s of filtered) {
+ html += `
+
+
${escapeHtml(s.symbol)}
+
${escapeHtml(s.label)}
+
${escapeHtml(s.sources.join(' · '))}
+
+ `;
+ }
+ explorerGrid.innerHTML = html;
+ if (!search) explorerHtmlCache[currentExplorerCat] = html;
+ }
+
+ function explorerSelectSymbol(sym) {
+ if (!sym) return;
+ if (window.Workspace && Workspace.layoutPreset > 1) {
+ applySymbolToActivePane(sym);
+ } else {
+ switchSymbol(sym);
+ }
+ closeExplorer();
+ }
+
+ let explorerSearchTimeout = null;
+ explorerSearchInput.oninput = () => {
+ clearTimeout(explorerSearchTimeout);
+ explorerSearchTimeout = setTimeout(renderExplorerGrid, 150);
+ };
+ if (explorerCats) {
+ explorerCats.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const item = target ? target.closest('.explorer-cat-item[data-explorer-cat]') : null;
+ if (!item) return;
+ const category = item.dataset.explorerCat;
+ if (category) {
+ selectExplorerCat(category);
+ }
+ });
+ }
+ if (explorerGrid) {
+ explorerGrid.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const card = target ? target.closest('.explorer-symbol-card[data-symbol]') : null;
+ if (!card) return;
+ const symbol = card.dataset.symbol;
+ if (symbol) {
+ explorerSelectSymbol(symbol);
+ }
+ });
+ }
+ if (closeExplorerBtn) {
+ closeExplorerBtn.addEventListener('click', closeExplorer);
+ }
+ if (toggleMarketBtn) {
+ toggleMarketBtn.addEventListener('click', openExplorer);
+ }
+ window.switchSymbol = switchSymbol;
+ window.selectExplorerCat = selectExplorerCat;
+ window.explorerSelectSymbol = explorerSelectSymbol;
+
+ function setPaneAnalysisOpen(pane, isOpen) {
+ if (!pane) return;
+ pane.analysisOpen = Boolean(isOpen);
+ if (pane.analysisOverlayEl) pane.analysisOverlayEl.classList.toggle('active', pane.analysisOpen);
+ if (pane.analysisButtonEl) pane.analysisButtonEl.classList.toggle('active', pane.analysisOpen);
+ }
+
+ function buildPaneAnalysisMarkup(pane) {
+ const payload = pane?.lastAnalysis?.payload;
+ if (pane?.analysisFetchController && !payload) {
+ return `AI đang phân tích ${pane.symbol} ${pane.interval}...
`;
+ }
+ if (!payload || !payload.analysis) {
+ return `Chưa có dữ liệu phân tích cho ${pane?.symbol || '--'}.
`;
+ }
+
+ const analysis = payload.analysis;
+ const summary = analysis.dashboard?.summary || analysis.summary || {};
+ const technical = analysis.dashboard?.technical || analysis.technicals || {};
+ const ai = analysis.dashboard?.ai || analysis.ai_gauge || {};
+ const aiModels = analysis.ai_models || {};
+ const forecast = Array.isArray(payload.forecast) ? payload.forecast : [];
+ const lastPoint = forecast.length ? forecast[forecast.length - 1] : null;
+ const precision = pane.priceFormat?.precision ?? 2;
+ const modelBreakdown = Object.entries(aiModels.models || {})
+ .map(([modelKey, modelState]) => {
+ const label = AI_MODEL_LABELS[modelKey] || modelKey;
+ const gauge = Number(modelState?.gauge ?? 50).toFixed(1);
+ const signal = modelState?.signal || '--';
+ return `${label}: ${gauge} (${signal})`;
+ })
+ .join(' | ');
+
+ return `
+
+
${pane.symbol} AI
+
+ ${pane.interval.toUpperCase()} • H${pane.horizon || 10}
+ ${payload.verdict || summary.signal || 'NEUTRAL'}
+
+
+
+
+
Tổng quan
+
${summary.narrative || summary.reason || analysis.summary_text || 'AI đang theo dõi diễn biến hiện tại của chart này.'}
+
+
+
Dự báo
+
${lastPoint ? `P50: ${Number(lastPoint.p50 ?? 0).toFixed(precision)} | P10: ${Number(lastPoint.p10 ?? 0).toFixed(precision)} | P90: ${Number(lastPoint.p90 ?? 0).toFixed(precision)}` : 'Chưa có dải dự báo.'}
+
+
+
Kỹ thuật
+
Điểm xu hướng: ${Math.round(Number(technical.score ?? technical.trend_score ?? 50))}
+
+
+
Điểm AI
+
Độ tin cậy: ${Math.round(Number(ai.score ?? ai.confidence ?? summary.confidence ?? 50))}
+
+
+
Model AI
+
${modelBreakdown || 'Chưa có dữ liệu model.'}
+
+
+ `;
+ }
+
+ function renderPaneAnalysisUI(pane) {
+ if (!pane) return;
+ if (pane.analysisButtonEl) {
+ let state = 'idle';
+ if (pane.analysisFetchController) state = 'loading';
+ else if (pane.lastAnalysis && pane.lastAnalysis.payload) state = 'ready';
+ else if (pane.error) state = 'error';
+ pane.analysisButtonEl.dataset.state = state;
+ pane.analysisButtonEl.setAttribute('aria-label', `Phân tích ${pane.symbol} ${pane.interval}`);
+ }
+ if (pane.analysisOverlayEl) {
+ pane.analysisOverlayEl.innerHTML = buildPaneAnalysisMarkup(pane);
+ pane.analysisOverlayEl.classList.toggle('active', Boolean(pane.analysisOpen));
+ }
+ }
+
+ window.renderPaneAnalysisUI = renderPaneAnalysisUI;
+
+ function bindPaneAnalysisButton(pane) {
+ if (!pane?.analysisButtonEl) return;
+ pane.analysisButtonEl.onclick = (event) => {
+ event.stopPropagation();
+ if (window.Workspace && typeof Workspace.setActivePane === 'function') {
+ Workspace.setActivePane(pane.paneId);
+ }
+ const willOpen = !pane.analysisOpen;
+ if (window.Workspace && Workspace.panes) {
+ Workspace.panes.forEach((otherPane) => {
+ if (otherPane !== pane) {
+ setPaneAnalysisOpen(otherPane, false);
+ renderPaneAnalysisUI(otherPane);
+ }
+ });
+ }
+ setPaneAnalysisOpen(pane, willOpen);
+ renderPaneAnalysisUI(pane);
+ if (willOpen && (!pane.lastAnalysis || !pane.lastAnalysis.payload) && typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: true });
+ }
+ };
+ }
+
+ timeframeSelect.onchange = () => {
+ const nextInterval = timeframeSelect.value;
+ if (window.Workspace && Workspace.layoutPreset > 1) {
+ const activePaneId = Workspace.activePaneId;
+ const promises = [];
+ currentInterval = nextInterval;
+
+ Workspace.panes.forEach((pane) => {
+ resetPendingPaneAI(pane);
+ pane.interval = nextInterval;
+ pane.analysisPayloadCache = {};
+ pane.cachedForecastModelLines = createEmptyForecastModelLines();
+ pane.lastAnalysis = { payload: null, symbol: null, interval: null, horizon: null, modelSignature: null };
+ pane.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false };
+ if (pane.paneHeaderEl) {
+ const intEl = pane.paneHeaderEl.querySelector('.pane-interval');
+ if (intEl) intEl.textContent = nextInterval;
+ }
+ StreamManager.unsubscribe(pane.paneId);
+ promises.push(loadPaneData(pane).then(() => connectPaneWS(pane)));
+ });
+
+ Promise.allSettled(promises).then(() => {
+ if (activePaneId && Workspace.panes.has(activePaneId)) {
+ syncToolbarToPane(activePaneId);
+ }
+ });
+ Workspace.save();
+ return;
+ }
+ refreshChart({ forceContextReset: true });
+ connectWS(currentSymbol);
+ };
+
+ horizonInput.onchange = () => {
+ const horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value, 10) || 10));
+ if (window.Workspace && Workspace.panes) {
+ Workspace.panes.forEach((pane) => {
+ pane.horizon = horizon;
+ if (pane.lastCandleData) {
+ refreshPaneReservedFutureSpace(pane);
+ if (pane.chartInstance) {
+ alignChartViewportToReservedSpace(
+ pane.paneId,
+ pane.chartInstance,
+ buildChartViewportContext(pane.symbol, pane.interval),
+ { allowFitFallback: true },
+ );
+ }
+ }
+ if (typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: true });
+ }
+ });
+ Workspace.save();
+ }
+ };
+
+ indicatorSelect.onchange = () => {
+ const type = indicatorSelect.value;
+ if (window.Workspace && window.Workspace.panes) {
+ window.Workspace.panes.forEach((pane) => {
+ applyIndicatorModeToPane(pane, type);
+ });
+ if (window.Workspace && Workspace.save) {
+ Workspace.save();
+ }
+ }
+ };
+
+ // "Phân tÃch" button: toggle dashboard ON/OFF without reloading chart
+ refreshBtn.onclick = () => {
+ const isActive = analysisPanel.classList.contains('active');
+ if (!isActive) {
+ // Show dashboard — use cached data if available, or fetch fresh
+ if (lastAnalysisPayload && lastAnalysisSymbol === currentSymbol && lastAnalysisInterval === timeframeSelect.value) {
+ renderAnalysisPanel(lastAnalysisSymbol, lastAnalysisInterval, lastAnalysisPayload);
+ analysisPanel.classList.add('active');
+ setTimeout(updateDashboardScale, 10);
+ } else {
+ // No cached data: fetch from API without reloading chart
+ analysisPanel.classList.add('active');
+ analysisPanel.innerHTML = `
+
+
+
Hệ thống AI đang khởi tạo dữ liệu...
+
+ `;
+ fetchAIAnalysis(currentSymbol, timeframeSelect.value);
+ setTimeout(updateDashboardScale, 10);
+ }
+ } else {
+ // Hide dashboard
+ analysisPanel.classList.remove('active');
+ }
+ };
+
+ if (fitBtn) {
+ fitBtn.onclick = () => {
+ fitAllOpenChartsWithOffset();
+ };
+ }
+ if (zoomOutBtn) {
+ zoomOutBtn.onclick = () => {
+ zoomAllCharts('out');
+ };
+ }
+ if (zoomInBtn) {
+ zoomInBtn.onclick = () => {
+ zoomAllCharts('in');
+ };
+ }
+ if (panLeftBtn) {
+ panLeftBtn.onclick = () => {
+ panAllCharts('left');
+ };
+ }
+ if (panRightBtn) {
+ panRightBtn.onclick = () => {
+ panAllCharts('right');
+ };
+ }
+
+ document.addEventListener('keydown', (e) => {
+ const target = e.target;
+ const isTyping =
+ target instanceof HTMLElement &&
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
+
+ if (e.key === 'Escape') {
+ analysisPanel.classList.remove('active');
+ searchResults.classList.remove('visible');
+ closeExplorer();
+ return;
+ }
+
+ if (isTyping) {
+ return;
+ }
+
+ if (e.key.toLowerCase() === 'm') {
+ openExplorer();
+ return;
+ }
+
+ if (e.key.toLowerCase() === 'a') {
+ refreshBtn.click();
+ }
+ });
+
+ /* ── Click out to close search & explorer ──── */
+ document.addEventListener('click', (e) => {
+ if (!symbolSearch.contains(e.target) && !searchResults.contains(e.target)) {
+ searchResults.classList.remove('visible');
+ }
+ if (e.target === explorerOverlay) {
+ closeExplorer();
+ }
+ });
+
+ /* ── Header visibility ───────────────────── */
+ function applyHeaderVisibility(mode) {
+ if (!headerEl || !headerToggleBtn) return;
+ const collapsed = mode === 'collapsed';
+ headerEl.classList.toggle('collapsed', collapsed);
+ headerToggleBtn.setAttribute('aria-expanded', String(!collapsed));
+ headerToggleBtn.title = collapsed ? 'Hiện menu' : 'Ẩn menu';
+ }
+
+ function resolveInitialHeaderVisibility() {
+ const saved = localStorage.getItem(HEADER_VISIBILITY_KEY);
+ if (saved === 'collapsed' || saved === 'expanded') {
+ return saved;
+ }
+ return window.innerWidth <= 768 ? 'collapsed' : 'expanded';
+ }
+
+ const HEADER_MENU_DENSITY_ORDER = ['wide', 'compact', 'tight', 'stacked'];
+
+ function estimateHeaderMenuDensity() {
+ const width = headerEl?.clientWidth || window.innerWidth;
+ if (width <= 760) return 'stacked';
+ if (width <= 1180) return 'tight';
+ if (width <= 1480) return 'compact';
+ return 'wide';
+ }
+
+ function headerMenuHasOverflow() {
+ if (!headerEl || !headerControlsEl) return false;
+ const headerOverflow = headerEl.scrollWidth > (headerEl.clientWidth + 4);
+ const controlsOverflow = headerControlsEl.scrollWidth > (headerControlsEl.clientWidth + 4);
+ return headerOverflow || controlsOverflow;
+ }
+
+ function syncHeaderMenuScale() {
+ if (!headerEl || !headerControlsEl) return;
+ if (headerEl.classList.contains('collapsed')) {
+ headerEl.dataset.menuDensity = 'collapsed';
+ return;
+ }
+ let density = estimateHeaderMenuDensity();
+ let densityIndex = HEADER_MENU_DENSITY_ORDER.indexOf(density);
+ if (densityIndex < 0) densityIndex = 0;
+ for (let index = densityIndex; index < HEADER_MENU_DENSITY_ORDER.length; index += 1) {
+ density = HEADER_MENU_DENSITY_ORDER[index];
+ headerEl.dataset.menuDensity = density;
+ if (density === 'tight' || density === 'stacked') {
+ break;
+ }
+ if (!headerMenuHasOverflow()) {
+ break;
+ }
+ }
+ }
+
+ function toggleHeaderVisibility(forceMode = null) {
+ const nextMode = forceMode || (headerEl?.classList.contains('collapsed') ? 'expanded' : 'collapsed');
+ headerEl?.classList.add('menu-transitioning');
+ localStorage.setItem(HEADER_VISIBILITY_KEY, nextMode);
+ applyHeaderVisibility(nextMode);
+ window.clearTimeout(toggleHeaderVisibility._animTimer);
+ toggleHeaderVisibility._animTimer = window.setTimeout(() => {
+ headerEl?.classList.remove('menu-transitioning');
+ }, 480);
+ window.requestAnimationFrame(() => {
+ syncHeaderMenuScale();
+ updateDashboardScale();
+ if (chart && chartEl) {
+ chart.applyOptions({
+ width: chartEl.clientWidth,
+ height: chartEl.clientHeight,
+ });
+ }
+ });
+ }
+
+ if (headerToggleBtn) {
+ headerToggleBtn.addEventListener('click', () => toggleHeaderVisibility());
+ }
+ if (typeof ResizeObserver === 'function' && headerEl) {
+ const headerScaleObserver = new ResizeObserver(() => {
+ window.requestAnimationFrame(() => syncHeaderMenuScale());
+ });
+ headerScaleObserver.observe(headerEl);
+ if (headerControlsEl) {
+ headerScaleObserver.observe(headerControlsEl);
+ }
+ if (marketStatusBar) {
+ headerScaleObserver.observe(marketStatusBar);
+ }
+ }
+ window.addEventListener('resize', () => {
+ window.requestAnimationFrame(() => syncHeaderMenuScale());
+ });
+
+ /* ── Dashboard Auto-Scale ────────────────── */
+ function updateDashboardScale() {
+ const panel = document.getElementById('analysisPanel');
+ if (!panel || !panel.classList.contains('active')) return;
+ const body = panel.querySelector('.dash-body');
+ const scaler = panel.querySelector('.dash-body-scaler');
+ if (!body || !scaler) return;
+
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+
+ // Target width for 1:1 scale (Full HD standard)
+ const targetWidth = 1400;
+ const targetHeight = 850;
+
+ let scaleW = windowWidth / targetWidth;
+ let scaleH = (windowHeight - 80) / targetHeight; // 80px for header
+ let scale = Math.min(1, scaleW, scaleH);
+
+ if (scale < 0.62) scale = 0.62;
+
+ body.style.transform = `scale(${scale})`;
+ body.style.width = scale < 1 ? `${targetWidth}px` : '100%';
+ body.style.maxWidth = `${targetWidth}px`;
+ body.style.margin = '0 auto';
+ }
+
+ /* ── Resize handler ────────────────────────── */
+ const ro = new ResizeObserver(() => {
+ chart.applyOptions({
+ width: chartEl.clientWidth,
+ height: chartEl.clientHeight,
+ });
+ updateDashboardScale();
+ });
+ ro.observe(chartEl);
+ window.addEventListener('resize', updateDashboardScale);
+
+ /* ── Theme Management ── */
+ function applyTheme(theme) {
+ const isDark = theme === 'dark';
+ document.body.classList.toggle('dark-theme', isDark);
+ if (themeToggleBtn) {
+ themeToggleBtn.innerHTML = isDark ? THEME_MOON_ICON : THEME_SUN_ICON;
+ themeToggleBtn.setAttribute('aria-label', isDark ? 'Chế độ tối' : 'Chế độ sáng');
+ themeToggleBtn.title = isDark ? 'Đang ở Dark mode' : 'Đang ở Light mode';
+ }
+
+ if (chart) {
+ chart.applyOptions({
+ layout: {
+ textColor: isDark ? 'rgba(100, 150, 200, 0.85)' : '#475569',
+ },
+ watermark: {
+ color: isDark ? 'rgba(34, 211, 238, 0.27)' : 'rgba(15, 23, 42, 0.12)',
+ }
+ });
+ // No full refreshChart() here to keep it smooth (BUG-FIX)
+ }
+ }
+
+ document.getElementById('themeToggleBtn').onclick = () => {
+ const newTheme = document.body.classList.contains('dark-theme') ? 'light' : 'dark';
+ localStorage.setItem('aiforecast_theme', newTheme);
+ applyTheme(newTheme);
+ };
+
+ /* â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
+ MULTI-PANE WORKSPACE ENGINE
+ â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• */
+ const workspaceGrid = document.getElementById('workspaceGrid');
+ const layoutSwitcher = document.getElementById('layoutSwitcher');
+
+ function normalizeIndicatorMode(mode) {
+ const normalized = String(mode || 'none').toLowerCase();
+ return ['none', 'bb', 'rsi', 'both'].includes(normalized) ? normalized : 'none';
+ }
+
+ function applyIndicatorModeToPane(pane, mode) {
+ if (!pane) return;
+ const indicatorMode = normalizeIndicatorMode(mode);
+ const showBb = indicatorMode === 'bb' || indicatorMode === 'both';
+ pane.indicatorMode = indicatorMode;
+ if (pane.indicatorSeries) {
+ if (pane.indicatorSeries.bbUpper) pane.indicatorSeries.bbUpper.applyOptions({ visible: showBb });
+ if (pane.indicatorSeries.bbMid) pane.indicatorSeries.bbMid.applyOptions({ visible: showBb });
+ if (pane.indicatorSeries.bbLower) pane.indicatorSeries.bbLower.applyOptions({ visible: showBb });
+ }
+ }
+
+ function syncActivePaneGlobals(pane) {
+ if (!pane) return;
+ currentSymbol = pane.symbol;
+ currentInterval = pane.interval;
+ lastCandleData = pane.lastCandleData || null;
+ if (pane.priceFormat) {
+ currentPriceFormat = pane.priceFormat;
+ }
+ activeChartContext = { symbol: pane.symbol, interval: pane.interval };
+ activeForecastContext = {
+ symbol: pane.symbol,
+ interval: pane.interval,
+ modelSignature: getPaneAiModelSignature(pane.aiModels),
+ ready: Boolean(pane.forecastContext?.ready),
+ };
+ }
+
+ function isPaneActiveForSharedUi(pane) {
+ if (!pane) return false;
+ if (!window.Workspace || Workspace.layoutPreset === 1) return pane.paneId === 'pane-0';
+ return Workspace.activePaneId === pane.paneId;
+ }
+
+ function getToolbarTargetPane() {
+ if (!window.Workspace || !Workspace.panes) return null;
+ if (Workspace.layoutPreset > 1) {
+ return Workspace.getActivePane();
+ }
+ return Workspace.getPane('pane-0') || Workspace.getActivePane();
+ }
+
+ function getSharedAiModelSelection() {
+ const pane0 = window.Workspace && typeof Workspace.getPane === 'function'
+ ? Workspace.getPane('pane-0')
+ : null;
+ const activePane = window.Workspace && typeof Workspace.getActivePane === 'function'
+ ? Workspace.getActivePane()
+ : null;
+ return normalizePaneAiModels(pane0?.aiModels || activePane?.aiModels || DEFAULT_AI_MODEL_SELECTION);
+ }
+
+ function syncAiModelToggleUi(modelsOrPane = null) {
+ const models = modelsOrPane && typeof modelsOrPane === 'object' && !('paneId' in modelsOrPane)
+ ? normalizePaneAiModels(modelsOrPane)
+ : normalizePaneAiModels(modelsOrPane?.aiModels || getSharedAiModelSelection());
+ FORECAST_MODEL_ORDER.map((modelKey) => [
+ ({
+ kronos: kronosToggle,
+ timesfm: timesfmToggle,
+ chronos: chronosToggle,
+ })[modelKey],
+ models[modelKey],
+ ]).forEach(([button, enabled]) => {
+ if (!button) return;
+ button.dataset.active = enabled ? 'true' : 'false';
+ button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
+ });
+ }
+
+ function applySharedAiModelSelection(nextModels) {
+ if (!window.Workspace || !Workspace.panes) return;
+ Workspace.panes.forEach((pane) => {
+ const requestMeta = {
+ symbol: pane.symbol,
+ interval: pane.interval,
+ horizon: pane.horizon || 10,
+ models: nextModels,
+ modelSignature: getPaneAiModelSignature(nextModels),
+ };
+ pane.aiModels = nextModels;
+ if (typeof renderPaneForecastVisuals === 'function') {
+ renderPaneForecastVisuals(pane, pane.cachedForecastModelLines, nextModels);
+ }
+ const cachedPayload = typeof findPaneAnalysisPayloadForSelection === 'function'
+ ? findPaneAnalysisPayloadForSelection(pane, requestMeta)
+ : null;
+ if (cachedPayload && typeof applyForecastPayloadToPane === 'function') {
+ applyForecastPayloadToPane(pane, cachedPayload, requestMeta);
+ return;
+ }
+ pane.forecastContext = {
+ symbol: pane.symbol,
+ interval: pane.interval,
+ horizon: pane.horizon || 10,
+ modelSignature: requestMeta.modelSignature,
+ ready: Object.entries(nextModels).some(([key, enabled]) => enabled && (pane.cachedForecastModelLines?.[key]?.length || 0) > 1),
+ };
+ renderPaneAnalysisUI(pane);
+ if (typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: true, preserveForecastVisuals: true });
+ }
+ });
+ }
+
+ function handleAiModelToggle(modelKey) {
+ const currentModels = getSharedAiModelSelection();
+ const rawNextModels = {
+ ...currentModels,
+ [modelKey]: !Boolean(currentModels?.[modelKey]),
+ };
+ if (!Object.values(rawNextModels).some(Boolean)) {
+ if (typeof updateStatus === 'function') {
+ updateStatus('Cần bật ít nhất 1 AI model để dự báo.', 'warning');
+ }
+ syncAiModelToggleUi(currentModels);
+ return;
+ }
+ const nextModels = normalizePaneAiModels(rawNextModels);
+ syncAiModelToggleUi(nextModels);
+ applySharedAiModelSelection(nextModels);
+ Workspace.save();
+ }
+
+ // Register pane-0 (already in DOM) into Workspace
+ Workspace.init(workspaceGrid);
+ setLogoOverlayLayoutMode(Workspace.layoutPreset || 1);
+ const pane0 = Workspace.createPane('pane-0', 'XAUUSD', '1d');
+ {
+ const container = workspaceGrid.querySelector('.chart-pane[data-pane-id="pane-0"]');
+ pane0.containerEl = container;
+ pane0.chartEl = container.querySelector('.pane-chart');
+ pane0.loaderEl = container.querySelector('.pane-loader');
+ pane0.gaugesEl = container.querySelector('.pane-gauges');
+ pane0.paneHeaderEl = container.querySelector('.pane-header-mini');
+ pane0.priceEl = container.querySelector('.pane-price');
+ pane0.analysisButtonEl = container.querySelector('.pane-analysis-btn');
+ pane0.analysisOverlayEl = container.querySelector('.pane-analysis-overlay');
+ pane0.forecastHoverEl = ensurePaneForecastHoverEl(pane0);
+ // Bind existing chart instance to pane-0
+ pane0.chartInstance = chart;
+ pane0.candleSeries = candleSeries;
+ pane0.forecastSeries = {
+ candles: forecastCandleSeries,
+ reserve: forecastReserveSeries,
+ p50: p50Series,
+ p10: p10Series,
+ p90: p90Series,
+ segments: forecastSegmentSeries,
+ models: primaryForecastModelSeries,
+ };
+ pane0.indicatorSeries = { bbUpper: bbUpperSeries, bbMid: bbMiddleSeries, bbLower: bbLowerSeries, rsi: rsiSeries };
+ pane0.horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value, 10) || 10));
+ pane0.indicatorMode = normalizeIndicatorMode(indicatorSelect.value);
+ pane0.aiModels = normalizePaneAiModels(pane0.aiModels);
+ applyIndicatorModeToPane(pane0, pane0.indicatorMode);
+ syncAiModelToggleUi(pane0);
+ bindPaneForecastHover(pane0);
+ pane0._viewportTrackingCleanup = registerChartViewportTracking(
+ chart,
+ pane0.paneId,
+ () => buildChartViewportContext(pane0.symbol, pane0.interval),
+ );
+ bindPaneAnalysisButton(pane0);
+ renderPaneAnalysisUI(pane0);
+ if (pane0.gaugesEl && Workspace.layoutPreset === 1) {
+ pane0.gaugesEl.innerHTML = '';
+ pane0.gaugesEl.style.display = 'none';
+ }
+ container.addEventListener('click', () => Workspace.setActivePane('pane-0'));
+ }
+ setBootPhase('after-pane0-init');
+
+ // Chart creation helper for new panes
+ function createPaneChart(pane) {
+ const isDark = document.body.classList.contains('dark-theme');
+ const chartInstance = LightweightCharts.createChart(pane.chartEl, {
+ layout: {
+ background: { type: 'solid', color: 'transparent' },
+ textColor: isDark ? 'rgba(100, 150, 200, 0.85)' : '#475569',
+ fontSize: 10,
+ fontFamily: "'Space Mono', 'Courier New', monospace",
+ },
+ grid: { vertLines: { visible: false }, horzLines: { visible: false } },
+ rightPriceScale: buildSharedRightPriceScaleOptions(),
+ timeScale: buildSharedTimeScaleOptions(),
+ crosshair: {
+ mode: LightweightCharts.CrosshairMode.Normal,
+ vertLine: { color: 'rgba(34, 211, 238, 0.35)', width: 1 },
+ horzLine: { color: 'rgba(34, 211, 238, 0.35)', width: 1 },
+ },
+ watermark: {
+ visible: true,
+ fontSize: 32,
+ horzAlign: 'center',
+ vertAlign: 'center',
+ color: isDark ? 'rgba(34, 211, 238, 0.06)' : 'rgba(15, 23, 42, 0.06)',
+ text: pane.symbol,
+ },
+ handleScroll: true,
+ handleScale: true,
+ });
+
+ pane.chartInstance = chartInstance;
+ pane.candleSeries = chartInstance.addCandlestickSeries({
+ upColor: '#1dba8a', downColor: '#e05560',
+ borderVisible: false, wickUpColor: '#1dba8a', wickDownColor: '#e05560',
+ });
+ pane.forecastSeries.candles = chartInstance.addCandlestickSeries(buildForecastCandleSeriesOptions());
+ pane.forecastSeries.reserve = chartInstance.addLineSeries({
+ color: 'rgba(0, 0, 0, 0)',
+ lineWidth: 1,
+ crosshairMarkerVisible: false,
+ priceLineVisible: false,
+ lastValueVisible: false,
+ });
+ pane.forecastSeries.p50 = chartInstance.addLineSeries({ color: '#7dd3fc', lineWidth: MODEL_CONFIG.defaultLineWidth, lineStyle: 0, priceLineVisible: false, lastValueVisible: false, visible: false });
+ pane.forecastSeries.p10 = chartInstance.addLineSeries({ color: 'rgba(125,211,252,0.4)', lineWidth: MODEL_CONFIG.defaultLineWidth, lineStyle: 2, priceLineVisible: false, lastValueVisible: false, visible: false });
+ pane.forecastSeries.p90 = chartInstance.addLineSeries({ color: 'rgba(125,211,252,0.4)', lineWidth: MODEL_CONFIG.defaultLineWidth, lineStyle: 2, priceLineVisible: false, lastValueVisible: false, visible: false });
+ pane.forecastSeries.models = Object.fromEntries(
+ FORECAST_MODEL_ORDER.map((modelKey) => [
+ modelKey,
+ chartInstance.addLineSeries({
+ ...buildForecastModelSeriesOptions(modelKey),
+ visible: false,
+ }),
+ ])
+ );
+ const indicatorMode = normalizeIndicatorMode(pane.indicatorMode);
+ const showBb = indicatorMode === 'bb' || indicatorMode === 'both';
+ pane.indicatorSeries.bbMid = chartInstance.addLineSeries({ color: 'rgba(255,255,255,0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: showBb });
+ pane.indicatorSeries.bbUpper = chartInstance.addLineSeries({ color: 'rgba(34,211,238,0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: showBb });
+ pane.indicatorSeries.bbLower = chartInstance.addLineSeries({ color: 'rgba(34,211,238,0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: showBb });
+ bindPaneForecastHover(pane);
+ pane._viewportTrackingCleanup = registerChartViewportTracking(
+ chartInstance,
+ pane.paneId,
+ () => buildChartViewportContext(pane.symbol, pane.interval),
+ );
+
+ // ResizeObserver
+ const paneRo = new ResizeObserver(() => {
+ chartInstance.applyOptions({ width: pane.chartEl.clientWidth, height: pane.chartEl.clientHeight });
+ });
+ paneRo.observe(pane.chartEl);
+ pane._resizeObserver = paneRo;
+
+ return chartInstance;
+ }
+
+ // Load data for a specific pane
+ async function loadPaneData(pane) {
+ if (!pane || !pane.chartInstance) return;
+ const { symbol, interval } = pane;
+
+ // Show loader
+ if (pane.loaderEl) pane.loaderEl.classList.remove('hidden');
+ renderPaneAnalysisUI(pane);
+
+ // Abort previous
+ if (pane.fetchController) pane.fetchController.abort();
+ resetPendingPaneAI(pane);
+ pane.fetchController = new AbortController();
+ const signal = pane.fetchController.signal;
+
+ const sLabel = symbolMap.get(symbol) || symbol;
+ const tLabel = timeframeMap[interval] || interval;
+ const isDark = document.body.classList.contains('dark-theme');
+
+ pane.chartInstance.applyOptions({
+ watermark: {
+ text: `${sLabel} | ${tLabel}`,
+ color: isDark ? 'rgba(34, 211, 238, 0.15)' : 'rgba(15, 23, 42, 0.08)',
+ fontSize: Workspace.layoutPreset === 1 ? 72 : 28,
+ },
+ });
+
+ try {
+ const [histData, indData] = await Promise.all([
+ DataCoordinator.fetchHistorical(symbol, interval, CHART_HISTORY_LIMIT, signal),
+ DataCoordinator.fetchIndicators(symbol, interval, CHART_HISTORY_LIMIT, signal),
+ ]);
+
+ // Apply price format
+ const pf = resolvePriceFormat(symbol, histData.data);
+ pane.priceFormat = pf;
+ const pfOpts = { priceFormat: { type: 'price', precision: pf.precision, minMove: pf.minMove } };
+ pane.candleSeries.applyOptions(pfOpts);
+ if (pane.forecastSeries.candles) pane.forecastSeries.candles.applyOptions(pfOpts);
+ if (pane.indicatorSeries.bbUpper) pane.indicatorSeries.bbUpper.applyOptions(pfOpts);
+ if (pane.indicatorSeries.bbMid) pane.indicatorSeries.bbMid.applyOptions(pfOpts);
+ if (pane.indicatorSeries.bbLower) pane.indicatorSeries.bbLower.applyOptions(pfOpts);
+
+ if (histData.data.length > 0) {
+ pane.candleSeries.setData(histData.data);
+ pane.lastCandleData = histData.data[histData.data.length - 1];
+ }
+ pane.historicalBarCount = histData.data.length;
+ refreshPaneReservedFutureSpace(pane);
+
+ // Indicators
+ const series = (indData.indicators || {}).series || {};
+ if (series.bb_upper) pane.indicatorSeries.bbUpper.setData(series.bb_upper);
+ if (series.bb_mid) pane.indicatorSeries.bbMid.setData(series.bb_mid);
+ if (series.bb_lower) pane.indicatorSeries.bbLower.setData(series.bb_lower);
+ applyIndicatorModeToPane(pane, pane.indicatorMode);
+
+ restoreOrFitChartViewport(
+ pane.paneId,
+ pane.chartInstance,
+ buildChartViewportContext(symbol, interval),
+ );
+ pane.chartContext = { symbol, interval };
+ if (isPaneActiveForSharedUi(pane)) {
+ syncActivePaneGlobals(pane);
+ }
+
+ // Start AI fetch for this pane
+ if (typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: true });
+ }
+
+ // Update pane header with last price
+ if (pane.lastCandleData && pane.priceEl) {
+ const price = pane.lastCandleData.close;
+ const precision = pane.priceFormat.precision;
+ pane.priceEl.textContent = Number(price).toFixed(precision);
+ }
+
+ } catch (e) {
+ if (e.name === 'AbortError') return;
+ console.error(`[Pane ${pane.paneId}] Data load error:`, e);
+ pane.error = e.message;
+ renderPaneAnalysisUI(pane);
+ } finally {
+ if (pane.loaderEl) pane.loaderEl.classList.add('hidden');
+ }
+ }
+
+ // Connect WS for a pane
+ function connectPaneWS(pane) {
+ StreamManager.subscribe(pane.paneId, pane.symbol, pane.interval, (data) => {
+ if (!pane.lastCandleData || !data.price) return;
+ if (shouldMutateRealtimeCandle(pane.symbol)) {
+ const update = {
+ time: pane.lastCandleData.time,
+ open: pane.lastCandleData.open,
+ high: Math.max(pane.lastCandleData.high, data.price),
+ low: Math.min(pane.lastCandleData.low, data.price),
+ close: data.price,
+ };
+ if (pane.candleSeries) pane.candleSeries.update(update);
+ pane.lastCandleData = update;
+ }
+
+ if (isPaneActiveForSharedUi(pane)) {
+ syncActivePaneGlobals(pane);
+ }
+
+ // Update mini header price
+ if (pane.priceEl) {
+ pane.priceEl.textContent = Number(data.price).toFixed(pane.priceFormat.precision);
+ }
+ });
+ }
+
+ // Destroy a pane (cleanup resources)
+ function destroyPaneResources(pane) {
+ clearPaneForecastVisuals(pane);
+ StreamManager.unsubscribe(pane.paneId);
+ if (pane.fetchController) { pane.fetchController.abort(); pane.fetchController = null; }
+ if (pane._viewportTrackingCleanup) { pane._viewportTrackingCleanup(); pane._viewportTrackingCleanup = null; }
+ if (pane._resizeObserver) { pane._resizeObserver.disconnect(); pane._resizeObserver = null; }
+ if (pane.chartInstance && pane.paneId !== 'pane-0') {
+ try { pane.chartInstance.remove(); } catch (_) {}
+ pane.chartInstance = null;
+ }
+ }
+
+ function inferWorkspaceLayoutPreset() {
+ if (workspaceGrid) {
+ for (const preset of [8, 4, 2, 1]) {
+ if (workspaceGrid.classList.contains(`layout-${preset}`)) {
+ return preset;
+ }
+ }
+ }
+
+ if (layoutSwitcher) {
+ const activeButton = layoutSwitcher.querySelector('button[data-layout].active');
+ const activePreset = parseInt(activeButton?.dataset?.layout || '', 10);
+ if ([1, 2, 4, 8].includes(activePreset)) {
+ return activePreset;
+ }
+ }
+
+ const paneCount = Workspace?.panes?.size;
+ if ([1, 2, 4, 8].includes(paneCount)) {
+ return paneCount;
+ }
+
+ return [1, 2, 4, 8].includes(Workspace?.layoutPreset) ? Workspace.layoutPreset : 1;
+ }
+
+ function syncWorkspaceLayoutPreset() {
+ const inferredPreset = inferWorkspaceLayoutPreset();
+ Workspace.layoutPreset = inferredPreset;
+ return inferredPreset;
+ }
+
+ // Switch layout handler
+ async function switchLayout(preset, options = {}) {
+ const normalizedPreset = parseInt(preset, 10);
+ if (![1, 2, 4, 8].includes(normalizedPreset)) return;
+
+ try {
+ const currentPreset = syncWorkspaceLayoutPreset();
+ const isPresetFullyApplied = (
+ currentPreset === normalizedPreset
+ && Workspace.panes.size === normalizedPreset
+ && workspaceGrid.classList.contains(`layout-${normalizedPreset}`)
+ && Boolean(layoutSwitcher?.querySelector(`button[data-layout="${normalizedPreset}"].active`))
+ );
+ if (isPresetFullyApplied) return;
+
+ const prevPreset = currentPreset;
+ const restoredPanes = Array.isArray(options.panes) ? options.panes : null;
+ const activePaneSnapshot = prevPreset > 1 && typeof Workspace.getActivePane === 'function'
+ ? (() => {
+ const activePane = Workspace.getActivePane();
+ if (!activePane) return null;
+ return {
+ symbol: activePane.symbol,
+ interval: activePane.interval,
+ horizon: Math.max(5, Math.min(300, parseInt(activePane.horizon, 10) || 10)),
+ indicatorMode: normalizeIndicatorMode(activePane.indicatorMode),
+ aiModels: normalizePaneAiModels(activePane.aiModels),
+ };
+ })()
+ : null;
+
+ // Destroy extra panes (keep pane-0)
+ for (const [id, pane] of Workspace.panes) {
+ if (id !== 'pane-0') {
+ destroyPaneResources(pane);
+ }
+ }
+
+ // Clear grid (keep pane-0 DOM)
+ const pane0Container = workspaceGrid.querySelector('[data-pane-id="pane-0"]');
+ workspaceGrid.innerHTML = '';
+ if (pane0Container) workspaceGrid.appendChild(pane0Container);
+
+ // Clear panes map except pane-0
+ for (const [id] of Workspace.panes) {
+ if (id !== 'pane-0') Workspace.panes.delete(id);
+ }
+
+ // Update layout
+ Workspace.layoutPreset = normalizedPreset;
+ [1, 2, 4, 8].forEach(n => workspaceGrid.classList.remove(`layout-${n}`));
+ workspaceGrid.classList.add(`layout-${normalizedPreset}`);
+
+ // Update switcher buttons
+ layoutSwitcher.querySelectorAll('button[data-layout]').forEach(btn => {
+ btn.classList.toggle('active', parseInt(btn.dataset.layout) === normalizedPreset);
+ });
+ const layoutMenuCurrent = document.getElementById('layoutMenuCurrent');
+ const layoutMenuBtn = document.getElementById('layoutMenuBtn');
+ if (layoutMenuCurrent) layoutMenuCurrent.textContent = String(normalizedPreset);
+ if (layoutMenuBtn) layoutMenuBtn.setAttribute('aria-expanded', 'false');
+ layoutSwitcher.classList.remove('open');
+
+ // Update pane-0 watermark size
+ const pane0 = Workspace.getPane('pane-0');
+ if (pane0 && pane0.chartInstance) {
+ pane0.chartInstance.applyOptions({
+ watermark: { fontSize: normalizedPreset === 1 ? 72 : 28 },
+ });
+ pane0.chartInstance.applyOptions({
+ width: pane0.chartEl.clientWidth,
+ height: pane0.chartEl.clientHeight,
+ });
+ }
+
+ // Show/hide global overlays based on mode
+ setLogoOverlayLayoutMode(normalizedPreset);
+ setGlobalStatusVisibility(normalizedPreset === 1);
+ setGlobalCompactGaugesVisibility(normalizedPreset === 1);
+
+ const realStrengthSymbols = ['DXY', 'EURX', 'GBPX', 'CHFX', 'JPYX', 'CADX', 'AUDX', 'NZDX'];
+
+ // Create new panes for multi-chart mode
+ if (normalizedPreset > 1) {
+ const defaultSymbols = normalizedPreset === 8
+ ? realStrengthSymbols
+ : ['EURUSD', 'GBPUSD', 'USDJPY', 'BTCUSD', 'XAGUSD', 'DXY', 'USDCHF'];
+ const loadPromises = [];
+ const restoredPane0 = restoredPanes && restoredPanes.length > 0
+ ? (restoredPanes.find((paneState) => paneState.id === 'pane-0') || restoredPanes[0])
+ : null;
+ const sharedInterval = restoredPane0?.interval || timeframeSelect.value || pane0?.interval || '1d';
+ const sharedHorizon = Math.max(5, Math.min(300, parseInt(restoredPane0?.horizon, 10) || parseInt(horizonInput.value, 10) || 10));
+ const sharedIndicatorMode = normalizeIndicatorMode(restoredPane0?.indicator || indicatorSelect.value);
+ const pane0AiModels = getSharedAiModelSelection();
+
+ if (ws) {
+ ws.close();
+ ws = null;
+ }
+
+ if (pane0) {
+ const pane0Symbol = restoredPane0?.symbol || defaultSymbols[0] || pane0.symbol;
+ pane0.symbol = pane0Symbol;
+ pane0.interval = sharedInterval;
+ pane0.horizon = sharedHorizon;
+ pane0.indicatorMode = sharedIndicatorMode;
+ pane0.aiModels = pane0AiModels;
+ applyIndicatorModeToPane(pane0, pane0.indicatorMode);
+ currentSymbol = pane0Symbol;
+ currentInterval = sharedInterval;
+ symbolSearch.value = pane0Symbol;
+ if (pane0.paneHeaderEl) {
+ const symEl = pane0.paneHeaderEl.querySelector('.pane-symbol');
+ const intEl = pane0.paneHeaderEl.querySelector('.pane-interval');
+ if (symEl) symEl.textContent = pane0Symbol;
+ if (intEl) intEl.textContent = sharedInterval;
+ }
+ StreamManager.unsubscribe(pane0.paneId);
+ if (pane0.candleSeries) pane0.candleSeries.setData([]);
+ clearForecastVisuals();
+ clearPaneForecastVisuals(pane0);
+ pane0.lastCandleData = null;
+ pane0.lastAnalysis = { payload: null, symbol: null, interval: null, horizon: null, modelSignature: null };
+ pane0.analysisPayloadCache = {};
+ pane0.cachedForecastModelLines = createEmptyForecastModelLines();
+ pane0.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false };
+ pane0.error = null;
+ renderPaneAnalysisUI(pane0);
+ loadPromises.push(loadPaneData(pane0).then(() => connectPaneWS(pane0)));
+ }
+
+ for (let i = 1; i < normalizedPreset; i++) {
+ const paneId = `pane-${i}`;
+ const restoredPaneState = restoredPanes?.find((paneState) => paneState.id === paneId) || null;
+ const sym = restoredPaneState?.symbol || defaultSymbols[i % defaultSymbols.length];
+ const paneInterval = sharedInterval;
+ const pane = Workspace.createPane(paneId, sym, paneInterval);
+ pane.indicatorMode = sharedIndicatorMode;
+ pane.aiModels = pane0AiModels;
+
+ // Build DOM
+ const container = document.createElement('div');
+ container.className = 'chart-pane';
+ container.dataset.paneId = paneId;
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ pane.containerEl = container;
+ pane.chartEl = container.querySelector('.pane-chart');
+ pane.loaderEl = container.querySelector('.pane-loader');
+ pane.gaugesEl = container.querySelector('.pane-gauges');
+ pane.paneHeaderEl = container.querySelector('.pane-header-mini');
+ pane.priceEl = container.querySelector('.pane-price');
+ pane.analysisButtonEl = container.querySelector('.pane-analysis-btn');
+ pane.analysisOverlayEl = container.querySelector('.pane-analysis-overlay');
+ pane.forecastHoverEl = ensurePaneForecastHoverEl(pane);
+ pane.horizon = sharedHorizon;
+ bindPaneAnalysisButton(pane);
+ renderPaneAnalysisUI(pane);
+
+ container.addEventListener('click', () => {
+ Workspace.setActivePane(paneId);
+ syncToolbarToPane(paneId);
+ });
+
+ workspaceGrid.appendChild(container);
+
+ // Create chart instance
+ createPaneChart(pane);
+
+ // Load data + WS
+ loadPromises.push(
+ loadPaneData(pane).then(() => connectPaneWS(pane))
+ );
+ }
+
+ // Load all panes in parallel (throttled by DataCoordinator)
+ await Promise.allSettled(loadPromises);
+ } else if (pane0) {
+ const singlePaneState = activePaneSnapshot || {
+ symbol: pane0.symbol,
+ interval: pane0.interval,
+ horizon: Math.max(5, Math.min(300, parseInt(pane0.horizon, 10) || 10)),
+ indicatorMode: normalizeIndicatorMode(pane0.indicatorMode),
+ aiModels: normalizePaneAiModels(pane0.aiModels),
+ };
+ StreamManager.unsubscribe(pane0.paneId);
+ clearPaneForecastVisuals(pane0);
+ if (pane0.gaugesEl) {
+ pane0.gaugesEl.innerHTML = '';
+ pane0.gaugesEl.style.display = 'none';
+ }
+ pane0.symbol = singlePaneState.symbol;
+ pane0.interval = singlePaneState.interval;
+ pane0.horizon = singlePaneState.horizon;
+ pane0.indicatorMode = singlePaneState.indicatorMode;
+ pane0.aiModels = singlePaneState.aiModels;
+ pane0.lastCandleData = null;
+ pane0.lastAnalysis = { payload: null, symbol: null, interval: null, horizon: null, modelSignature: null };
+ pane0.analysisPayloadCache = {};
+ pane0.cachedForecastModelLines = createEmptyForecastModelLines();
+ pane0.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false };
+ pane0.error = null;
+ currentSymbol = pane0.symbol;
+ currentInterval = pane0.interval;
+ symbolSearch.value = pane0.symbol;
+ timeframeSelect.value = pane0.interval;
+ horizonInput.value = String(pane0.horizon);
+ indicatorSelect.value = pane0.indicatorMode;
+ syncAiModelToggleUi(pane0);
+ syncActivePaneGlobals(pane0);
+ renderPaneAnalysisUI(pane0);
+ await refreshChart({ forceContextReset: true });
+ connectWS(pane0.symbol, pane0.interval);
+ }
+
+ // Ensure active pane is valid
+ if (!Workspace.panes.has(Workspace.activePaneId)) {
+ Workspace.setActivePane('pane-0');
+ }
+ Workspace.setActivePane(Workspace.activePaneId);
+ if (Workspace.activePaneId && Workspace.panes.has(Workspace.activePaneId)) {
+ syncToolbarToPane(Workspace.activePaneId);
+ }
+ if (pane0?.gaugesEl) {
+ pane0.gaugesEl.style.display = normalizedPreset === 1 ? 'none' : '';
+ if (normalizedPreset === 1) {
+ pane0.gaugesEl.innerHTML = '';
+ }
+ }
+
+ // Resize pane-0 chart after layout change
+ requestAnimationFrame(() => {
+ if (pane0 && pane0.chartInstance) {
+ pane0.chartInstance.applyOptions({
+ width: pane0.chartEl.clientWidth,
+ height: pane0.chartEl.clientHeight,
+ });
+ }
+ });
+
+ Workspace.save();
+ } catch (error) {
+ console.error('[Layout] Switch failed:', error);
+ reportFrontendStartupError('switchLayout', error);
+ throw error;
+ }
+ }
+
+ // Sync toolbar controls to the active pane's state
+ function syncToolbarToPane(paneId) {
+ const pane = Workspace.getPane(paneId);
+ if (!pane) return;
+
+ // Update toolbar to reflect pane state
+ symbolSearch.value = pane.symbol;
+ // Don't trigger change events — just update display
+ const tfOptions = timeframeSelect.options;
+ for (let i = 0; i < tfOptions.length; i++) {
+ if (tfOptions[i].value === pane.interval) {
+ timeframeSelect.selectedIndex = i;
+ break;
+ }
+ }
+
+ horizonInput.value = String(Math.max(5, Math.min(300, parseInt(pane.horizon, 10) || 10)));
+ indicatorSelect.value = normalizeIndicatorMode(pane.indicatorMode);
+ syncAiModelToggleUi(getSharedAiModelSelection());
+ syncActivePaneGlobals(pane);
+ }
+
+ if (kronosToggle) {
+ kronosToggle.addEventListener('click', () => handleAiModelToggle('kronos'));
+ }
+ if (timesfmToggle) {
+ timesfmToggle.addEventListener('click', () => handleAiModelToggle('timesfm'));
+ }
+ if (chronosToggle) {
+ chronosToggle.addEventListener('click', () => handleAiModelToggle('chronos'));
+ }
+
+ // Apply symbol to active pane (for multi-pane mode)
+ function applySymbolToActivePane(symbol) {
+ const pane = Workspace.getActivePane();
+ if (!pane) return;
+
+ if (Workspace.layoutPreset === 1) {
+ // Single pane mode — use original switchSymbol
+ switchSymbol(symbol);
+ return;
+ }
+
+ // Multi-pane mode: update the active pane
+ pane.symbol = symbol;
+ syncActivePaneGlobals(pane);
+ symbolSearch.value = symbol;
+
+ // Update pane header
+ if (pane.paneHeaderEl) {
+ const symEl = pane.paneHeaderEl.querySelector('.pane-symbol');
+ if (symEl) symEl.textContent = symbol;
+ }
+
+ // Disconnect old WS, clear data
+ resetPendingPaneAI(pane);
+ StreamManager.unsubscribe(pane.paneId);
+ if (pane.candleSeries) pane.candleSeries.setData([]);
+ if (typeof clearPaneForecastVisuals === 'function') {
+ clearPaneForecastVisuals(pane);
+ } else {
+ if (pane.forecastSeries?.candles) pane.forecastSeries.candles.setData([]);
+ if (pane.forecastSeries?.p50) pane.forecastSeries.p50.setData([]);
+ if (pane.forecastSeries?.p10) pane.forecastSeries.p10.setData([]);
+ if (pane.forecastSeries?.p90) pane.forecastSeries.p90.setData([]);
+ }
+ pane.lastCandleData = null;
+ pane.lastAnalysis = { payload: null, symbol: null, interval: null, horizon: null, modelSignature: null };
+ pane.analysisPayloadCache = {};
+ pane.cachedForecastModelLines = createEmptyForecastModelLines();
+ pane.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false };
+ pane.error = null;
+ syncActivePaneGlobals(pane);
+ renderPaneAnalysisUI(pane);
+
+ // Reload
+ loadPaneData(pane).then(() => connectPaneWS(pane));
+ Workspace.save();
+ }
+
+ // Wire layout switcher buttons
+ if (layoutSwitcher) {
+ layoutSwitcher.addEventListener('click', (e) => {
+ const menuBtn = e.target.closest('#layoutMenuBtn');
+ if (menuBtn) {
+ e.stopPropagation();
+ const willOpen = !layoutSwitcher.classList.contains('open');
+ layoutSwitcher.classList.toggle('open', willOpen);
+ menuBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
+ return;
+ }
+ const btn = e.target.closest('button[data-layout]');
+ if (!btn) return;
+ e.stopPropagation();
+ const preset = parseInt(btn.dataset.layout);
+ if (!isNaN(preset)) switchLayout(preset);
+ });
+ }
+
+ document.addEventListener('click', (event) => {
+ if (!layoutSwitcher || layoutSwitcher.contains(event.target)) return;
+ layoutSwitcher.classList.remove('open');
+ const layoutMenuBtn = document.getElementById('layoutMenuBtn');
+ if (layoutMenuBtn) layoutMenuBtn.setAttribute('aria-expanded', 'false');
+ });
+
+ // Wire Workspace active pane change callback
+ Workspace._onActivePaneChange = (paneId) => {
+ syncToolbarToPane(paneId);
+ };
+ setBootPhase('before-bootstrap');
+
+ async function refreshWorkspacePanes() {
+ if (!window.Workspace || !Workspace.panes || Workspace.layoutPreset <= 1) {
+ await refreshChart();
+ return;
+ }
+
+ const refreshTasks = [];
+ Workspace.panes.forEach((pane) => {
+ refreshTasks.push(
+ loadPaneData(pane).then(() => connectPaneWS(pane))
+ );
+ });
+
+ await Promise.allSettled(refreshTasks);
+ if (Workspace.activePaneId && Workspace.panes.has(Workspace.activePaneId)) {
+ syncToolbarToPane(Workspace.activePaneId);
+ }
+ }
+
+ // Add click handler to pane-0 for multi-pane mode
+ {
+ const p0Container = workspaceGrid.querySelector('[data-pane-id="pane-0"]');
+ if (p0Container) {
+ p0Container.addEventListener('click', () => {
+ Workspace.setActivePane('pane-0');
+ syncToolbarToPane('pane-0');
+ });
+ }
+ }
+
+ /* ── Bootstrap ── */
+ (async () => {
+ try {
+ setBootPhase('bootstrap-start');
+ if (statusText) statusText.textContent = 'Đang khởi tạo giao diện...';
+ if (statusDot) statusDot.className = 'dot loading';
+ if (statusPill) statusPill.className = 'status-pill loading';
+
+ // Restore theme preference (Default: light)
+ const savedTheme = localStorage.getItem('aiforecast_theme') || 'light';
+ applyTheme(savedTheme);
+ applyHeaderVisibility(resolveInitialHeaderVisibility());
+ syncHeaderMenuScale();
+
+ await loadSymbols();
+ await refreshMarketStatus();
+
+ // Start polling
+ setInterval(refreshMarketStatus, 60000); // 1m
+
+ const restoredWorkspace = Workspace.restore();
+ if (
+ restoredWorkspace?.layoutPreset === 8 &&
+ Array.isArray(restoredWorkspace.panes) &&
+ restoredWorkspace.panes.some((pane) => pane?.symbol === 'USDX') &&
+ !restoredWorkspace.panes.some((pane) => pane?.symbol === 'DXY')
+ ) {
+ const symbols = new Set(restoredWorkspace.panes.map((pane) => pane?.symbol));
+ const usdxStrengthLayout = ['USDX', 'EURX', 'GBPX', 'CHFX', 'JPYX', 'CADX', 'AUDX', 'NZDX'];
+ if (usdxStrengthLayout.every((symbol) => symbols.has(symbol))) {
+ restoredWorkspace.panes = restoredWorkspace.panes.map((pane) => (
+ pane?.symbol === 'USDX'
+ ? { ...pane, symbol: 'DXY' }
+ : pane
+ ));
+ }
+ }
+ const restoredPane0State = restoredWorkspace?.panes?.find((pane) => pane.id === 'pane-0')
+ || restoredWorkspace?.panes?.[0]
+ || null;
+
+ if (restoredPane0State?.interval) {
+ timeframeSelect.value = restoredPane0State.interval;
+ }
+ if (restoredPane0State?.horizon) {
+ horizonInput.value = String(
+ Math.max(5, Math.min(300, parseInt(restoredPane0State.horizon, 10) || 10))
+ );
+ }
+ if (
+ restoredPane0State?.indicator &&
+ Array.from(indicatorSelect.options || []).some((option) => option.value === restoredPane0State.indicator)
+ ) {
+ indicatorSelect.value = restoredPane0State.indicator;
+ }
+ if (pane0) {
+ pane0.aiModels = normalizePaneAiModels(DEFAULT_AI_MODEL_SELECTION);
+ syncAiModelToggleUi(pane0.aiModels);
+ }
+
+ if (restoredWorkspace?.layoutPreset > 1) {
+ await switchLayout(restoredWorkspace.layoutPreset, { panes: restoredWorkspace.panes || [] });
+ if (restoredWorkspace.activePaneId && Workspace.panes.has(restoredWorkspace.activePaneId)) {
+ Workspace.setActivePane(restoredWorkspace.activePaneId);
+ syncToolbarToPane(restoredWorkspace.activePaneId);
+ }
+ } else if (restoredPane0State?.symbol) {
+ await switchSymbol(restoredPane0State.symbol);
+ } else {
+ await switchSymbol('XAUUSD');
+ }
+
+ if (typeof indicatorSelect.onchange === 'function') {
+ indicatorSelect.onchange();
+ }
+
+ // Setup auto-refresh (P1-10)
+ let autoRefreshTimer = null;
+ function scheduleAutoRefresh() {
+ if (autoRefreshTimer) clearTimeout(autoRefreshTimer);
+
+ // Interval logic: intraday refresh nhanh hÆ¡n, daily/weekly refresh cháºm hÆ¡n để tránh tải thừa.
+ const intv = timeframeSelect.value;
+ let delay = 900000; // 15m default for 1d
+ if (intv === '1m' || intv === '5m') delay = 60000;
+ else if (intv === '15m' || intv === '30m') delay = 180000;
+ else if (intv === '1h' || intv === '4h') delay = 600000;
+ else if (intv === '1w') delay = 1800000;
+
+ autoRefreshTimer = setTimeout(async () => {
+ if (!document.hidden) {
+ console.log('[AutoRefresh] Triggering...');
+ if (window.Workspace && Workspace.layoutPreset > 1) {
+ await refreshWorkspacePanes();
+ } else {
+ await refreshChart();
+ }
+ }
+ scheduleAutoRefresh();
+ }, delay);
+ }
+ scheduleAutoRefresh();
+ setBootPhase('bootstrap-ready');
+ } catch (error) {
+ setBootPhase('bootstrap-error');
+ window.__AIFORECAST_BOOT_ERROR = String(error?.stack || error);
+ console.error('[bootstrap] startup failed', error);
+ try {
+ hideLoader();
+ } catch (_) {}
+ try {
+ updateStatus(`Loi khoi tao giao dien: ${error?.message || error}`, 'error');
+ } catch (_) {}
+ }
+ })();
+
+ function renderPaneCompactGauges(pane) {
+ if (Workspace.layoutPreset === 1 && pane?.paneId === 'pane-0') {
+ if (pane?.gaugesEl) {
+ pane.gaugesEl.innerHTML = '';
+ pane.gaugesEl.style.display = 'none';
+ }
+ return;
+ }
+ const payload = pane?.lastAnalysis?.payload;
+ if (!pane?.gaugesEl || !payload?.analysis) {
+ if (pane?.gaugesEl) pane.gaugesEl.innerHTML = '';
+ return;
+ }
+ pane.gaugesEl.style.display = '';
+
+ const gaugeState = getGaugePresentationState(payload);
+ const aiCardState = gaugeState.aiReady ? 'ready' : (gaugeState.aggregation.failedKeys.length ? 'error' : 'loading');
+ const summaryCardState = gaugeState.summaryReady ? 'ready' : (gaugeState.aggregation.failedKeys.length ? 'error' : 'loading');
+
+ pane.gaugesEl.innerHTML = `
+ ${buildCompactGaugeCardMarkup({
+ title: 'Kỹ thuật',
+ gauge: gaugeState.technical.gauge,
+ signal: gaugeState.technical.signal,
+ delay: '0s',
+ })}
+ ${buildCompactGaugeCardMarkup({
+ title: 'Dự báo AI',
+ gauge: gaugeState.ai.gauge,
+ signal: gaugeState.ai.signal,
+ delay: '0.08s',
+ state: aiCardState,
+ message: gaugeState.message,
+ })}
+ ${buildCompactGaugeCardMarkup({
+ title: 'Tổng kết',
+ gauge: gaugeState.summary.gauge,
+ signal: gaugeState.summary.signal,
+ delay: '0.16s',
+ hero: true,
+ comboStrong: gaugeState.comboActive,
+ state: summaryCardState,
+ message: gaugeState.message,
+ })}
+ `;
+
+ pane.gaugesEl.querySelectorAll('.compact-gauge-card').forEach((card) => {
+ card.addEventListener('click', (event) => {
+ event.stopPropagation();
+ if (pane.analysisButtonEl) pane.analysisButtonEl.click();
+ });
+ });
+ }
+
+ function paneActCls(act) {
+ return act === 'Mua' ? 'dt-act-buy' : act === 'Bán' ? 'dt-act-sell' : 'dt-act-neut';
+ }
+
+ buildPaneAnalysisMarkup = function buildPaneAnalysisMarkupOverride(pane) {
+ const payload = pane?.lastAnalysis?.payload;
+ if (pane?.analysisFetchController && !payload) {
+ return `AI đang phân tích ${pane.symbol} ${pane.interval}...
`;
+ }
+ if (!payload?.analysis) {
+ return `Chưa có dữ liệu phân tích cho ${pane?.symbol || '--'}.
`;
+ }
+
+ const a = payload.analysis;
+ if (!a.oscillators && !a.moving_averages) {
+ return `Đang tính toán phân tích kỹ thuật cho ${pane.symbol}...
`;
+ }
+
+ const osc = a.oscillators || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
+ const ma = a.moving_averages || { sell: 0, neutral: 0, buy: 0, signal: '--', data: [] };
+ const gaugeState = getGaugePresentationState(payload);
+ const technicals = a.technicals || gaugeState.technical || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 };
+ const aiGauge = gaugeState.ai || a.ai_gauge || { gauge: 50, signal: '--', certainty: 0, path_consistency: 0 };
+ const summary = gaugeState.summary || a.summary || { signal: '--', gauge: 50 };
+ const dashboard = a.dashboard || {};
+ const pivots = (a.pivot_points || {}).data || [];
+ const comboActive = gaugeState.comboActive;
+ const aiReady = gaugeState.aiReady;
+ const summaryReady = gaugeState.summaryReady;
+ const aiStatusMessage = gaugeState.message;
+ const forecastRows = payload.forecast || [];
+ const lastClose = payload.last_close || 0;
+ const aiCurrentPrice = dashboard.ai?.current_price ?? lastClose;
+ const forecastEnd = dashboard.ai?.forecast_price ?? (forecastRows.length > 1 ? (forecastRows[forecastRows.length - 1]?.p50 ?? lastClose) : lastClose);
+ const forecastPctChange = dashboard.ai?.forecast_return_pct ?? (lastClose > 0 ? ((forecastEnd - lastClose) / lastClose) * 100 : 0);
+ const oscRows = osc.data.map(d => `| ${d.name} | ${d.value !== null ? d.value : '—'} | ${d.action} |
`).join('');
+ const maRows = ma.data.map(d => `| ${d.name} | ${d.value !== null ? d.value : '—'} | ${d.action} |
`).join('');
+ const pivotRows = pivots.map(p => `| ${p.level} | ${p.classic ?? '—'} | ${p.fibonacci ?? '—'} | ${p.camarilla ?? '—'} | ${p.woodie ?? '—'} | ${p.dm ?? '—'} |
`).join('');
+ const sLabel = symbolMap.get(pane.symbol) || pane.symbol;
+ const tLabel = timeframeMap[pane.interval] || pane.interval;
+ const paneFormatPrice = (value) => {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '--';
+ const precision = Math.max(0, Math.min(8, Number(pane.priceFormat?.precision ?? 2)));
+ return Number(value).toLocaleString('en-US', {
+ minimumFractionDigits: precision,
+ maximumFractionDigits: precision,
+ });
+ };
+
+ return `
+
+
+
${sLabel} · ${tLabel}
+
+ ${payload.source || 'N/A'}
+ ${payload.verdict || summary.signal || 'NEUTRAL'}
+
+
+
+
+
PHÂN TÍCH KỸ THUẬT
+
${buildGaugeSvg(gaugeToRawScore(technicals.gauge), 220, 150, true)}
+
${technicals.signal}
+
+ Bán${technicals.sell}
+ Trung lập${technicals.neutral}
+ Mua${technicals.buy}
+
+
+
+
DỰ BÁO AI
+ ${aiReady ? `
+
${buildGaugeSvg(gaugeToRawScore(aiGauge.gauge), 220, 150, true)}
+
+
Close hiện tại:${paneFormatPrice(aiCurrentPrice)}
+
OHLC4 dự kiến:${paneFormatPrice(forecastEnd)}
+
Biến động vs close:${forecastPctChange >= 0 ? '↑' : '↓'} ${Math.abs(forecastPctChange).toFixed(2)}%
+
Độ chắc chắn:${Number(aiGauge.certainty ?? 0).toFixed(1)}%
+
Độ ổn định:${Number(aiGauge.path_consistency ?? 0).toFixed(1)}%
+
+
${aiGauge.signal}
+ ` : buildHeroGaugePendingMarkup(aiStatusMessage, 'AI')}
+
+
+
Tổng kết
+ ${summaryReady ? `
+
${buildGaugeSvg(gaugeToRawScore(summary.gauge), 220, 150, true)}
+
${summary.signal}
+ ` : buildHeroGaugePendingMarkup(aiStatusMessage, 'Tổng hợp')}
+
+
+
+
+
+
+
+
+
+ | Mức | CL | FB | CM | WD | DM |
+ ${pivotRows}
+
+
+
+
+
+ Cảnh báo — Thông tin phân tích kỹ thuật này không phải lời khuyên đầu tư. Hãy luôn quản lý rủi ro.
+
+
+ `;
+ };
+
+ renderPaneAnalysisUI = function renderPaneAnalysisUIOverride(pane) {
+ if (!pane) return;
+ if (pane.analysisButtonEl) {
+ let state = 'idle';
+ if (pane.analysisFetchController) state = 'loading';
+ else if (pane.lastAnalysis && pane.lastAnalysis.payload) state = 'ready';
+ else if (pane.error) state = 'error';
+ pane.analysisButtonEl.dataset.state = state;
+ pane.analysisButtonEl.setAttribute('aria-label', `Phân tích ${pane.symbol} ${pane.interval}`);
+ }
+ renderPaneCompactGauges(pane);
+ if (pane.analysisOverlayEl) {
+ pane.analysisOverlayEl.innerHTML = buildPaneAnalysisMarkup(pane);
+ pane.analysisOverlayEl.classList.toggle('active', Boolean(pane.analysisOpen));
+ }
+ };
+ window.renderPaneAnalysisUI = renderPaneAnalysisUI;
+ window.renderPaneCompactGauges = renderPaneCompactGauges;
+
+ bindPaneAnalysisButton = function bindPaneAnalysisButtonOverride(pane) {
+ if (!pane?.analysisButtonEl) return;
+ pane.analysisButtonEl.onclick = (event) => {
+ event.stopPropagation();
+ if (window.Workspace && typeof Workspace.setActivePane === 'function') {
+ Workspace.setActivePane(pane.paneId);
+ }
+ const willOpen = !pane.analysisOpen;
+ setPaneAnalysisOpen(pane, willOpen);
+ renderPaneAnalysisUI(pane);
+ if (willOpen && (!pane.lastAnalysis || !pane.lastAnalysis.payload) && typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: true });
+ }
+ };
+ };
+
+ refreshBtn.onclick = () => {
+ const pane = window.Workspace && typeof Workspace.getActivePane === 'function'
+ ? Workspace.getActivePane()
+ : null;
+ if (!pane) return;
+ const willOpen = !pane.analysisOpen;
+ setPaneAnalysisOpen(pane, willOpen);
+ renderPaneAnalysisUI(pane);
+ if (willOpen && typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: !pane.lastAnalysis?.payload });
+ }
+ };
+
+ if (window.Workspace && Workspace.panes) {
+ Workspace.panes.forEach((pane) => {
+ bindPaneAnalysisButton(pane);
+ renderPaneAnalysisUI(pane);
+ });
+ }
+
+ function clearFullscreenPaneSelection() {
+ if (!window.Workspace || !Workspace.panes) return;
+ Workspace.panes.forEach((pane) => {
+ pane.analysisOpen = false;
+ if (pane.analysisButtonEl) pane.analysisButtonEl.classList.remove('active');
+ });
+ }
+
+ function openPaneFullscreenAnalysis(pane, options = {}) {
+ if (!pane) return;
+ if (window.Workspace && typeof Workspace.setActivePane === 'function') {
+ Workspace.setActivePane(pane.paneId);
+ }
+
+ const showPayload = (payload) => {
+ if (!payload) return;
+ clearFullscreenPaneSelection();
+ pane.analysisOpen = true;
+ if (pane.analysisButtonEl) pane.analysisButtonEl.classList.add('active');
+ renderAnalysisPanel(pane.symbol, pane.interval, payload);
+ analysisPanel.classList.add('active');
+ setTimeout(updateDashboardScale, 10);
+ };
+
+ if (pane.lastAnalysis?.payload && !options.force) {
+ showPayload(pane.lastAnalysis.payload);
+ return;
+ }
+
+ analysisPanel.classList.add('active');
+ analysisPanel.innerHTML = `
+
+
+
AI đang khởi tạo dữ liệu cho ${pane.symbol} ${pane.interval}...
+
+ `;
+
+ if (typeof pane.fetchAI === 'function') {
+ pane.fetchAI({ force: true }).then((payload) => {
+ if (payload) showPayload(payload);
+ });
+ }
+ }
+
+ bindPaneAnalysisButton = function bindPaneAnalysisButtonFullscreen(pane) {
+ if (!pane?.analysisButtonEl) return;
+ pane.analysisButtonEl.onclick = (event) => {
+ event.stopPropagation();
+ openPaneFullscreenAnalysis(pane);
+ };
+ };
+
+ refreshBtn.onclick = () => {
+ const pane = window.Workspace && typeof Workspace.getActivePane === 'function'
+ ? Workspace.getActivePane()
+ : null;
+ if (!pane) return;
+ openPaneFullscreenAnalysis(pane);
+ };
+
+ renderPaneAnalysisUI = function renderPaneAnalysisUIFullscreen(pane) {
+ if (!pane) return;
+ if (pane.analysisButtonEl) {
+ let state = 'idle';
+ if (pane.analysisFetchController) state = 'loading';
+ else if (pane.lastAnalysis && pane.lastAnalysis.payload) state = 'ready';
+ else if (pane.error) state = 'error';
+ pane.analysisButtonEl.dataset.state = state;
+ pane.analysisButtonEl.setAttribute('aria-label', `Phân tích ${pane.symbol} ${pane.interval}`);
+ pane.analysisButtonEl.classList.toggle('active', Boolean(pane.analysisOpen));
+ }
+ renderPaneCompactGauges(pane);
+ };
+ window.renderPaneAnalysisUI = renderPaneAnalysisUI;
+
+ document.addEventListener('click', (event) => {
+ const closeBtn = event.target.closest('#dashCloseBtn');
+ if (!closeBtn) return;
+ clearFullscreenPaneSelection();
+ });
+
+ if (window.Workspace && Workspace.panes) {
+ Workspace.panes.forEach((pane) => {
+ bindPaneAnalysisButton(pane);
+ renderPaneAnalysisUI(pane);
+ if (pane.gaugesEl) {
+ pane.gaugesEl.querySelectorAll('.compact-gauge-card').forEach((card) => {
+ card.onclick = (event) => {
+ event.stopPropagation();
+ openPaneFullscreenAnalysis(pane);
+ };
+ });
+ }
+ });
+ }
+
+})();
+
+