(() => { 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 => `
${m.market}
`).join(''); marketStatusBar.innerHTML = `
${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} ${arc(-135, -81, '#ef4444')}${arc(-81, -27, '#fca5a5')}${arc(-27, 27, 'var(--bdr-muted)')}${arc(27, 81, '#bbf7d0')}${arc(81, 135, '#22c55e')} ${w > 150 ? ` BÁN MUA ` : ''} `; } 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 = '

Đang tính toán...

'; 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 = `
${sLabel} · ${payload.source || 'N/A'} · ${tLabel} · Bảng Phân tích Tổng hợp
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')}
Chỉ báo kỹ thuật
${oscRows}
Trung bình trượt
${maRows}
Điểm xoay
${pivotRows}
MứcCLFBCMWDDM
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 = `
${sym} · ${paneInterval} --
`; 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')}
Chỉ báo kỹ thuật
${oscRows}
Trung bình trượt
${maRows}
Điểm xoay
${pivotRows}
MứcCLFBCMWDDM
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); }; }); } }); } })();