diff --git "a/frontend/app.js" "b/frontend/app.js" new file mode 100644--- /dev/null +++ "b/frontend/app.js" @@ -0,0 +1,5134 @@ +(() => { +const API_BASE = (window.location.origin && window.location.origin !== 'null') + ? window.location.origin + : ''; + function setBootPhase(phase) { + if (document.body && document.body.dataset) { + document.body.dataset.bootPhase = phase; + } + } + setBootPhase('script-start'); + function buildWebSocketUrl(apiBase, path) { + const fallbackOrigin = (window.location.origin && window.location.origin !== 'null') + ? window.location.origin + : 'http://127.0.0.1'; + const resolvedUrl = new URL(path, apiBase || fallbackOrigin); + resolvedUrl.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return resolvedUrl.toString(); + } + const HEADER_VISIBILITY_KEY = 'aiforecast_header_visibility'; + window.__AIFORECAST_API_BASE = API_BASE; + + /* ── DOM refs ──────────────────────────────── */ + /* ── DOM refs ──────────────────────────────── */ + const symbolSearch = document.getElementById('symbolSearch'); + const searchResults = document.getElementById('searchResults'); + const timeframeSelect = document.getElementById('timeframeSelect'); + const horizonInput = document.getElementById('horizonInput'); + const refreshBtn = document.getElementById('refreshBtn'); + const fitBtn = document.getElementById('fitBtn'); + const zoomOutBtn = document.getElementById('zoomOutBtn'); + const zoomInBtn = document.getElementById('zoomInBtn'); + const panLeftBtn = document.getElementById('panLeftBtn'); + const panRightBtn = document.getElementById('panRightBtn'); + const statusText = document.getElementById('statusText'); + const statusDot = document.getElementById('statusDot'); + const statusPill = document.getElementById('statusPill'); + const chartGauges = document.getElementById('chartGauges'); + const terminalLoader = document.getElementById('terminalLoader'); + const loaderText = document.getElementById('loaderText'); + const analysisPanel = document.getElementById('analysisPanel'); + const marketStatusBar = document.getElementById('marketStatusBar'); + const indicatorSelect = document.getElementById('indicatorSelect'); + const kronosToggle = document.getElementById('kronosToggle'); + const timesfmToggle = document.getElementById('timesfmToggle'); + const chronosToggle = document.getElementById('chronosToggle'); + const themeToggleBtn = document.getElementById('themeToggleBtn'); + const cursorTrailCanvas = document.getElementById('cursorTrailCanvas'); + const cursorCore = document.getElementById('cursorCore'); + const headerEl = document.querySelector('.hdr'); + const headerControlsEl = document.querySelector('.ctrls'); + const headerToggleBtn = document.getElementById('headerToggleBtn'); + const toggleMarketBtn = document.getElementById('toggleMarketBtn'); + const chartLogoOverlay = document.querySelector('.chart-logo-overlay'); + const ambientUfoScene = document.querySelector('.ambient-ufo-scene'); + const ambientUfoCraft = ambientUfoScene?.querySelector('.ambient-ufo-craft') || null; + const ambientUfoImpact = ambientUfoScene?.querySelector('.ambient-ufo-impact') || null; + + function setupChartNavigationCluster() { + const controlButtons = [zoomOutBtn, zoomInBtn, panLeftBtn, panRightBtn].filter(Boolean); + if (!controlButtons.length || !zoomOutBtn?.parentElement) { + return; + } + controlButtons.forEach((button) => button.classList.add('chart-nav-btn')); + let cluster = zoomOutBtn.parentElement.querySelector('.chart-nav-cluster'); + if (!cluster) { + cluster = document.createElement('div'); + cluster.className = 'chart-nav-cluster'; + cluster.setAttribute('aria-label', 'Dieu huong bieu do'); + zoomOutBtn.insertAdjacentElement('beforebegin', cluster); + } + controlButtons.forEach((button) => cluster.appendChild(button)); + } + + setupChartNavigationCluster(); + + function reportFrontendStartupError(source, errorLike) { + const message = String(errorLike?.message || errorLike || 'Unknown frontend error'); + const detail = String(errorLike?.stack || message); + window.__AIFORECAST_BOOT_ERROR = detail; + console.error(source, errorLike); + if (statusText) statusText.textContent = `Loi giao dien: ${message}`; + if (statusDot) statusDot.className = 'dot error'; + if (statusPill) statusPill.className = 'status-pill error'; + if (terminalLoader) terminalLoader.classList.remove('active'); + } + window.addEventListener('error', (event) => { + reportFrontendStartupError('[window-error]', event?.error || event?.message || event); + }); + + window.addEventListener('unhandledrejection', (event) => { + reportFrontendStartupError('[unhandledrejection]', event?.reason || event); + }); + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + /* ── State ─────────────────────────────────── */ + let currentSymbol = 'XAUUSD'; + let currentInterval = '1d'; + let ws = null; + let chartFetchController = null; + let analysisFetchController = null; + let analysisRequestPromise = null; + let analysisRequestKey = null; + let analysisRetryTimer = null; + let activeChartContext = { symbol: null, interval: null }; + let activeForecastContext = { symbol: null, interval: null, modelSignature: null, ready: false }; + const symbolMap = new Map(); + const symbolMetaMap = new Map(); + let currentPriceFormat = { precision: 2, minMove: 0.01 }; + const timeframeMap = { + '1m': '1 Phút', '5m': '5 Phút', '15m': '15 Phút', + '1h': '1 Giờ', '4h': '4 Giờ', '1d': '1 Ngày', '1w': '1 Tuần' + }; + const timeframeSecondsMap = { + '1m': 60, + '5m': 300, + '15m': 900, + '1h': 3600, + '4h': 14400, + '1d': 86400, + '1w': 604800, + }; + + /* ── Indicator Calculation Helpers ────────── */ + /* ── WebSocket Management ────────────────────── */ + const THEME_SUN_ICON = ` + `; + const THEME_MOON_ICON = ` + `; + + function setLogoOverlayLayoutMode(preset = 1) { + const layoutMode = preset > 1 ? 'multi' : 'single'; + if (chartLogoOverlay) chartLogoOverlay.dataset.layoutMode = layoutMode; + if (ambientUfoScene) ambientUfoScene.dataset.layoutMode = layoutMode; + if (ambientUfoScene) ambientUfoScene.dispatchEvent(new Event('ufo-layout-change')); + } + + (() => { + if (!ambientUfoScene || !ambientUfoCraft || !ambientUfoImpact) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const motion = { + x: 0, + y: 0, + vx: 126, + vy: 88, + lastTs: 0, + started: false, + impactUntil: 0, + impactX: 0, + impactY: 0, + }; + + function sceneBounds() { + const rect = ambientUfoScene.getBoundingClientRect(); + const craftW = ambientUfoCraft.offsetWidth || 44; + const craftH = ambientUfoCraft.offsetHeight || 20; + return { + width: Math.max(0, rect.width), + height: Math.max(0, rect.height), + craftW, + craftH, + padding: 10, + }; + } + + function resetUfoMotion() { + const bounds = sceneBounds(); + const isMultiPane = Boolean(window.Workspace && Workspace.layoutPreset > 1); + motion.x = Math.max(bounds.padding, bounds.width * (isMultiPane ? 0.84 : 0.58)); + motion.y = Math.max(bounds.padding, bounds.height * (isMultiPane ? 0.06 : 0.22)); + motion.vx = isMultiPane ? -168 : (motion.vx >= 0 ? 126 : -126); + motion.vy = isMultiPane ? 108 : (motion.vy >= 0 ? 88 : -88); + motion.started = true; + } + + function triggerUfoImpact(cx, cy, ts) { + motion.impactX = cx; + motion.impactY = cy; + motion.impactUntil = ts + 220; + } + + function renderImpact(ts) { + if (!motion.impactUntil || ts >= motion.impactUntil) { + ambientUfoImpact.style.opacity = '0'; + return; + } + + const remaining = (motion.impactUntil - ts) / 220; + const scale = 0.72 + (1 - remaining) * 0.9; + ambientUfoImpact.style.opacity = String(Math.max(0, remaining) * 0.52); + ambientUfoImpact.style.transform = `translate3d(${motion.impactX - 14}px, ${motion.impactY - 14}px, 0) scale(${scale})`; + } + + function stepAmbientUfo(ts) { + const bounds = sceneBounds(); + if (!bounds.width || !bounds.height) { + motion.lastTs = ts; + window.requestAnimationFrame(stepAmbientUfo); + return; + } + + if (!motion.started) { + resetUfoMotion(); + motion.lastTs = ts; + } + + const dt = Math.min(0.032, Math.max(0.008, (ts - (motion.lastTs || ts)) / 1000 || 0.016)); + motion.lastTs = ts; + motion.x += motion.vx * dt; + motion.y += motion.vy * dt; + + const minX = bounds.padding; + const minY = bounds.padding; + const maxX = Math.max(minX, bounds.width - bounds.craftW - bounds.padding); + const maxY = Math.max(minY, bounds.height - bounds.craftH - bounds.padding); + + let collided = false; + let impactCx = motion.x + bounds.craftW * 0.5; + let impactCy = motion.y + bounds.craftH * 0.5; + + if (motion.x <= minX) { + motion.x = minX; + motion.vx = Math.abs(motion.vx); + collided = true; + impactCx = minX + 2; + } else if (motion.x >= maxX) { + motion.x = maxX; + motion.vx = -Math.abs(motion.vx); + collided = true; + impactCx = maxX + bounds.craftW - 2; + } + + if (motion.y <= minY) { + motion.y = minY; + motion.vy = Math.abs(motion.vy); + collided = true; + impactCy = minY + 2; + } else if (motion.y >= maxY) { + motion.y = maxY; + motion.vy = -Math.abs(motion.vy); + collided = true; + impactCy = maxY + bounds.craftH - 2; + } + + if (collided) { + triggerUfoImpact(impactCx, impactCy, ts); + } + + const tilt = Math.max(-16, Math.min(16, motion.vx * 0.05 + motion.vy * 0.025)); + const driftScale = 0.94 + ((Math.abs(motion.vx) + Math.abs(motion.vy)) / 260) * 0.08; + ambientUfoCraft.style.transform = `translate3d(${motion.x}px, ${motion.y}px, 0) rotate(${tilt}deg) scale(${driftScale})`; + renderImpact(ts); + window.requestAnimationFrame(stepAmbientUfo); + } + + window.addEventListener('resize', () => { + motion.started = false; + }); + ambientUfoScene.addEventListener('ufo-layout-change', () => { + motion.started = false; + }); + + window.requestAnimationFrame(stepAmbientUfo); + })(); + + (() => { + if (!cursorTrailCanvas || !cursorCore || window.matchMedia('(pointer: coarse)').matches) return; + + const ctx = cursorTrailCanvas.getContext('2d'); + if (!ctx) return; + + const interactiveSelector = 'button, input, select, .search-item, .compact-gauge-card, .explorer-symbol-card, .explorer-cat-item, .dash-close, .dash-col'; + const trail = []; + const pointer = { + x: window.innerWidth / 2, + y: window.innerHeight / 2, + lastX: window.innerWidth / 2, + lastY: window.innerHeight / 2, + velocity: 0, + lastMoveTs: 0, + }; + let rafId = 0; + let dpr = 1; + + function resizeTrailCanvas() { + dpr = Math.max(1, window.devicePixelRatio || 1); + cursorTrailCanvas.width = Math.round(window.innerWidth * dpr); + cursorTrailCanvas.height = Math.round(window.innerHeight * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + function spawnEmber(x, y, vx, vy, speed) { + trail.push({ + x, + y, + vx, + vy, + speed, + life: 1, + radius: Math.min(44, 10 + speed * 0.12), + stretch: Math.min(3.4, 1 + speed * 0.008), + }); + } + + function drawTrail(now) { + ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); + + const idleMs = now - pointer.lastMoveTs; + if (idleMs > 90) { + pointer.velocity *= 0.9; + } + + for (let i = trail.length - 1; i >= 0; i -= 1) { + const ember = trail[i]; + ember.life -= 0.055; + ember.x += ember.vx; + ember.y += ember.vy; + ember.radius *= 0.985; + ember.vx *= 0.94; + ember.vy *= 0.94; + + if (ember.life <= 0.02 || ember.radius <= 1.5) { + trail.splice(i, 1); + continue; + } + + const alpha = ember.life * 0.85; + const angle = Math.atan2(ember.vy, ember.vx || 0.0001); + ctx.save(); + ctx.translate(ember.x, ember.y); + ctx.rotate(angle); + ctx.scale(ember.stretch, 1); + + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, ember.radius); + gradient.addColorStop(0, `rgba(235, 255, 255, ${alpha})`); + gradient.addColorStop(0.22, `rgba(124, 246, 255, ${alpha * 0.95})`); + gradient.addColorStop(0.5, `rgba(38, 196, 255, ${alpha * 0.48})`); + gradient.addColorStop(1, 'rgba(38, 196, 255, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, ember.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + if (trail.length > 0 || pointer.velocity > 0.12) { + rafId = requestAnimationFrame(drawTrail); + } else { + rafId = 0; + document.body.classList.remove('cursor-active'); + } + } + + function ensureTrailLoop() { + if (!rafId) { + rafId = requestAnimationFrame(drawTrail); + } + } + + resizeTrailCanvas(); + window.addEventListener('resize', resizeTrailCanvas); + + document.addEventListener('mousemove', (event) => { + const now = performance.now(); + const dx = event.clientX - pointer.lastX; + const dy = event.clientY - pointer.lastY; + const dt = Math.max(16, now - (pointer.lastMoveTs || now)); + const speed = Math.sqrt(dx * dx + dy * dy) / dt * 26; + + pointer.x = event.clientX; + pointer.y = event.clientY; + pointer.lastMoveTs = now; + pointer.lastX = event.clientX; + pointer.lastY = event.clientY; + pointer.velocity = pointer.velocity * 0.35 + speed * 0.65; + + const emberCount = Math.max(1, Math.min(7, Math.round(pointer.velocity / 3.2))); + for (let i = 0; i < emberCount; i += 1) { + const ratio = i / emberCount; + const jitterX = (Math.random() - 0.5) * 8; + const jitterY = (Math.random() - 0.5) * 8; + spawnEmber( + event.clientX - dx * ratio + jitterX, + event.clientY - dy * ratio + jitterY, + -dx * 0.08 * (0.5 + Math.random()), + -dy * 0.08 * (0.5 + Math.random()), + pointer.velocity + ); + } + + cursorCore.style.transform = `translate3d(${pointer.x}px, ${pointer.y}px, 0) translate(-50%, -50%)`; + document.body.classList.add('cursor-active'); + ensureTrailLoop(); + }, { passive: true }); + + document.addEventListener('mouseleave', () => { + trail.length = 0; + pointer.velocity = 0; + document.body.classList.remove('cursor-active'); + }); + + document.addEventListener('mousedown', () => { + document.body.classList.add('cursor-press'); + }); + + document.addEventListener('mouseup', () => { + document.body.classList.remove('cursor-press'); + }); + + document.addEventListener('mouseover', (event) => { + const target = event.target instanceof Element ? event.target.closest(interactiveSelector) : null; + document.body.classList.toggle('cursor-hover', Boolean(target)); + }); + })(); + + function connectWS(symbol, interval = currentInterval || timeframeSelect.value) { + if (window.Workspace && Workspace.layoutPreset > 1) { + if (ws) { + ws.close(); + ws = null; + } + return; + } + if (ws) { + ws.close(); + ws = null; + } + + const wsUrl = buildWebSocketUrl( + API_BASE, + `/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`, + ); + + console.log(`[WS] Connecting to ${wsUrl}`); + const socket = new WebSocket(wsUrl); + ws = socket; + + socket.onmessage = (event) => { + if (ws !== socket) return; + let data; + try { + data = JSON.parse(event.data); + } catch (error) { + console.warn('[WS] Invalid message payload', error); + return; + } + if (data.error) return; + + // Handle ping from server (P2) + if (data.type === 'ping') { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'pong', ts: Date.now() })); + } + return; + } + + // Update current candle if price changed + const lastCandle = lastCandleData; + if (lastCandle && data.price && shouldMutateRealtimeCandle(symbol)) { + const update = { + time: lastCandle.time, + open: lastCandle.open, + high: Math.max(lastCandle.high, data.price), + low: Math.min(lastCandle.low, data.price), + close: data.price + }; + candleSeries.update(update); + lastCandleData = update; + } + + }; + + socket.onclose = () => { + console.log('[WS] Disconnected'); + if (ws === socket) { + ws = null; + } + // Reconnect after 5s if still active + setTimeout(() => { + const activeInterval = currentInterval || timeframeSelect.value; + if ( + (!window.Workspace || Workspace.layoutPreset === 1) + && currentSymbol === symbol + && activeInterval === interval + && !ws + ) { + connectWS(symbol, interval); + } + }, 5000); + }; + + socket.onerror = (error) => { + console.warn('[WS] Socket error', error); + }; + } + + let lastCandleData = null; + + /* ── Market Status & Overview ────────────────── */ + async function refreshMarketStatus() { + try { + const data = await apiRequest('/api/market-status'); + if (!marketStatusBar) { + return; + } + const segmentMarkup = data.markets.map(m => ` +
+
+ ${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); + }; + }); + } + }); + } + +})(); + +