diff --git "a/frontend/index.html" "b/frontend/index.html" --- "a/frontend/index.html" +++ "b/frontend/index.html" @@ -4,13 +4,10 @@ - Super AI Analysis · AI phân tích biểu đồ nến số 1 thế giới hiện tại được nghiên cứu bởi đại học Thanh Hoa! - - - - + SuperAI Forecast | Trading Terminal + + + @@ -20,10 +17,10 @@ - + - + .hdr[data-menu-density="wide"] { + gap: 16px; + } - + .hdr[data-menu-density="compact"] { + gap: 10px; + padding: 0 14px 0 12px; + } + + .hdr[data-menu-density="compact"] .market-status-shell { + flex-basis: 210px; + min-width: 210px; + max-width: 280px; + } + + .hdr[data-menu-density="compact"] .ctrl-label { + font-size: 0.66rem; + letter-spacing: 0.12em; + } + + .hdr[data-menu-density="compact"] .ctrl-unit.ctrl-unit-search { + width: 168px; + } + + .hdr[data-menu-density="compact"] #timeframeSelect { + width: 96px; + } + + .hdr[data-menu-density="compact"] #horizonInput { + width: 76px; + } + + .hdr[data-menu-density="compact"] #indicatorSelect { + width: 122px !important; + } + + .hdr[data-menu-density="compact"] .ai-model-switches { + gap: 6px; + } + + .hdr[data-menu-density="compact"] .ai-model-toggle { + min-width: 74px; + padding: 0 10px; + font-size: 0.8rem; + } + + .hdr[data-menu-density="compact"] .layout-menu-button { + min-width: 70px; + padding: 0 10px; + gap: 6px; + } + + .hdr[data-menu-density="tight"], + .hdr[data-menu-density="stacked"] { + height: auto; + min-height: 0; + flex-wrap: wrap; + align-items: flex-start; + padding: 12px 14px; + gap: 8px; + } + + .hdr[data-menu-density="tight"] .hdr-divider, + .hdr[data-menu-density="stacked"] .hdr-divider { + display: none; + } + + .hdr[data-menu-density="tight"] .logo, + .hdr[data-menu-density="stacked"] .logo { + flex: 1 1 auto; + min-width: 0; + } + + .hdr[data-menu-density="tight"] .market-status-shell, + .hdr[data-menu-density="stacked"] .market-status-shell { + order: 3; + width: 100%; + max-width: none; + min-width: 0; + margin-left: 0; + } + + .hdr[data-menu-density="tight"] .market-status-bar, + .hdr[data-menu-density="stacked"] .market-status-bar { + padding: 0 8px; + } + + .hdr[data-menu-density="tight"] .ctrls, + .hdr[data-menu-density="stacked"] .ctrls { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px; + } + + .hdr[data-menu-density="tight"] .ctrl-label, + .hdr[data-menu-density="stacked"] .ctrl-label { + display: none; + } + + .hdr[data-menu-density="tight"] .ctrl-unit, + .hdr[data-menu-density="tight"] .layout-menu, + .hdr[data-menu-density="stacked"] .ctrl-unit, + .hdr[data-menu-density="stacked"] .layout-menu { + flex: 1 1 132px; + min-width: 132px; + } + + .hdr[data-menu-density="tight"] .ctrl-unit.ctrl-unit-search, + .hdr[data-menu-density="stacked"] .ctrl-unit.ctrl-unit-search { + flex: 2 1 220px; + width: auto; + } + + .hdr[data-menu-density="tight"] .omnibox, + .hdr[data-menu-density="stacked"] .omnibox { + width: 100%; + } + + .hdr[data-menu-density="tight"] #timeframeSelect, + .hdr[data-menu-density="tight"] #horizonInput, + .hdr[data-menu-density="tight"] #indicatorSelect, + .hdr[data-menu-density="stacked"] #timeframeSelect, + .hdr[data-menu-density="stacked"] #horizonInput, + .hdr[data-menu-density="stacked"] #indicatorSelect { + width: 100% !important; + } + + .hdr[data-menu-density="tight"] .ai-model-switches, + .hdr[data-menu-density="stacked"] .ai-model-switches { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .hdr[data-menu-density="tight"] .ai-model-toggle, + .hdr[data-menu-density="stacked"] .ai-model-toggle { + width: 100%; + min-width: 0; + } + + .hdr[data-menu-density="tight"] .layout-menu-button, + .hdr[data-menu-density="stacked"] .layout-menu-button { + width: 100%; + min-width: 0; + } + + .hdr[data-menu-density="tight"] .btn-icon, + .hdr[data-menu-density="tight"] .btn-theme, + .hdr[data-menu-density="stacked"] .btn-icon, + .hdr[data-menu-density="stacked"] .btn-theme { + flex: 0 0 var(--ctrl-h); + } + + .hdr[data-menu-density="tight"] .hdr-toggle, + .hdr[data-menu-density="stacked"] .hdr-toggle { + margin-left: auto; + align-self: flex-start; + } + + .hdr[data-menu-density="stacked"] { + padding: 12px 10px; + } + + .hdr[data-menu-density="stacked"] .ctrl-unit, + .hdr[data-menu-density="stacked"] .layout-menu { + flex: 1 1 calc(50% - 4px); + min-width: calc(50% - 4px); + } + + .hdr[data-menu-density="stacked"] .ctrl-unit.ctrl-unit-search, + .hdr[data-menu-density="stacked"] .ctrl-unit.ctrl-unit-ai-models { + flex-basis: 100%; + min-width: 100%; + } + + + + + @@ -3152,7 +3731,7 @@
- +
@@ -3236,8 +3815,6 @@
- -
@@ -3245,7 +3822,7 @@
-
+
+ AI model +
+ + + +
+
+
+ + + + + + + +
+ +
+
+
- +
@@ -3384,6 +4014,15 @@
AI FORECAST
+
@@ -3406,14 +4045,14 @@
- +
- +
@@ -3446,3130 +4085,21 @@
- - - - - + + + + + + - 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 technicals = a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 }; - const aiGauge = a.ai_gauge || { gauge: 50, signal: '--', confidence_pct: 0, certainty: 0, path_consistency: 50 }; - const summary = a.summary || { sell: 0, neutral: 0, buy: 0, signal: '--' }; - const dashboard = a.dashboard || {}; - const pivots = (a.pivot_points || {}).data || []; - const comboActive = Boolean(summary.agreement || (Math.abs((technicals.gauge ?? 50) - 50) > 6 && Math.abs((aiGauge.gauge ?? 50) - 50) > 6 && Math.sign((technicals.gauge ?? 50) - 50) === Math.sign((aiGauge.gauge ?? 50) - 50))); - - 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
-
- ${buildGaugeSvg(gaugeToRawScore(aiGauge.gauge))} -
-
-
- Hiện tại: - ${formatPrice(aiCurrentPrice)} -
-
- Dự kiến: - ${formatPrice(forecastEnd)} -
-
- Biến động: - ${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}
-
- - -
-
⚡ TỔNG KẾT
-
- ${buildGaugeSvg(gaugeToRawScore(summary.gauge))} -
-
${summary.signal}
-
- -
- - -
- - -
-
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([]); - p50Series.setData([]); - p10Series.setData([]); - p90Series.setData([]); - clearForecastSegments(); - setPrimaryForecastVisibility(false); - activeForecastContext = { symbol: null, interval: null, ready: false }; - } - - function clearPrimaryForecastCandlesOnly() { - p50Series.setData([]); - p10Series.setData([]); - p90Series.setData([]); - clearForecastSegments(); - forecastCandleSeries.setData([]); - setPrimaryForecastVisibility(false); - forecastCandleSeries.applyOptions({ - ...buildForecastCandleSeriesOptions(), - visible: false, - priceFormat: buildSeriesPriceFormat(), - }); - } - - function commitForecastVisuals(symbol, interval, forecastLine = []) { - const points = Array.isArray(forecastLine) ? forecastLine : []; - const tone = getForecastTone(points); - const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat; - forecastCandleSeries.setData([]); - forecastCandleSeries.applyOptions({ - ...buildForecastCandleSeriesOptions(tone), - visible: false, - priceFormat: buildSeriesPriceFormat(), - }); - - p50Series.setData([]); - p10Series.setData([]); - p90Series.setData([]); - clearForecastSegments(); - p50Series.applyOptions({ - color: palette.line, - lineWidth: 2, - priceFormat: buildSeriesPriceFormat(), - visible: points.length > 0, - }); - setPrimaryForecastVisibility(points.length > 0); - p50Series.setData(points); - fitChartWithOffset(chart, getForecastRightOffset(points)); - 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); - 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 }); - } - pane.forecastContext = { symbol: null, interval: 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, forecastLine = []) { - if (!pane?.forecastSeries || !pane?.chartInstance) return; - - const points = Array.isArray(forecastLine) ? forecastLine : []; - const tone = getForecastTone(points); - const palette = FORECAST_PALETTE[tone] || FORECAST_PALETTE.flat; - clearPaneForecastSegments(pane); - - if (pane.forecastSeries.p10) { - pane.forecastSeries.p10.setData([]); - pane.forecastSeries.p10.applyOptions({ color: palette.band, visible: false }); - } - if (pane.forecastSeries.p90) { - pane.forecastSeries.p90.setData([]); - pane.forecastSeries.p90.applyOptions({ color: palette.band, visible: false }); - } - - if (pane.forecastSeries.candles) { - pane.forecastSeries.candles.setData([]); - pane.forecastSeries.candles.applyOptions({ - ...buildForecastCandleSeriesOptions(tone), - visible: false, - priceFormat: { - type: 'price', - precision: pane.priceFormat?.precision ?? 2, - minMove: pane.priceFormat?.minMove ?? 0.01, - }, - }); - } - - if (!points.length || !pane.forecastSeries.p50) { - fitChartWithOffset(pane.chartInstance); - return; - } - - pane.forecastSeries.p50.setData(points); - pane.forecastSeries.p50.applyOptions({ - color: palette.line, - visible: true, - lineWidth: 2, - lineStyle: 0, - priceLineVisible: false, - lastValueVisible: false, - crosshairMarkerVisible: true, - priceFormat: { - type: 'price', - precision: pane.priceFormat?.precision ?? 2, - minMove: pane.priceFormat?.minMove ?? 0.01, - }, - }); - fitChartWithOffset(pane.chartInstance, getForecastRightOffset(points)); - } - - window.clearPaneForecastVisuals = clearPaneForecastVisuals; - window.clearPaneForecastCandlesOnly = clearPaneForecastCandlesOnly; - window.renderPaneForecastVisuals = renderPaneForecastVisuals; - - 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 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} - Dang nap du lieu...`, 'loading'); - showLoader('Dang nap du lieu...'); - 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 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); - - fitChartWithOffset(chart); - activeChartContext = { symbol, interval }; - if (window.Workspace && typeof Workspace.getPane === 'function') { - const pane0 = Workspace.getPane('pane-0'); - if (pane0) { - pane0.symbol = symbol; - pane0.interval = interval; - pane0.lastCandleData = lastCandleData; - 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 = {}) { - // DISABLED: Handled per-pane by Workspace.PaneState.fetchAI - return null; - if (currentSymbol !== symbol || timeframeSelect.value !== interval) return null; - - const panel = document.getElementById('analysisPanel'); - const horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value) || 24)); - const requestKey = `${symbol}|${interval}|${horizon}`; - if (!options.force && analysisRequestPromise && analysisRequestKey === requestKey) { - return analysisRequestPromise; - } - - if (!hasCachedAnalysisFor(symbol, interval)) { - panel.innerHTML = ` -
-
-

AI dang tinh toan...

-
- `; - } - - if (analysisRetryTimer) { - clearTimeout(analysisRetryTimer); - analysisRetryTimer = null; - } - if (analysisFetchController) { - analysisFetchController.abort(); - } - - clearPrimaryForecastCandlesOnly(); - - const controller = new AbortController(); - analysisFetchController = controller; - analysisRequestKey = requestKey; - - const requestPromise = (async () => { - try { - const fData = await apiRequest( - `/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}`, - { signal: controller.signal } - ); - - if (currentSymbol !== symbol || timeframeSelect.value !== interval) return null; - - const forecastPoints = Array.isArray(fData.forecast) ? fData.forecast : []; - const hasAnalysis = Boolean(fData.analysis); - const hadForecast = hasLiveForecastFor(symbol, interval); - const fallbackActualPoint = lastCandleData && lastCandleData.time - ? { - time: lastCandleData.time, - value: ( - Number(lastCandleData.open ?? 0) - + Number(lastCandleData.high ?? 0) - + Number(lastCandleData.low ?? 0) - + Number(lastCandleData.close ?? 0) - ) / 4, - } - : null; - const forecastLine = buildForecastLineFromRows(forecastPoints, fallbackActualPoint); - const hasForecast = forecastLine.length > 1; - - if (!hasForecast || !lastCandleData || !lastCandleData.time) { - if (!hadForecast) { - clearForecastVisuals(); - } - if (!hasCachedAnalysisFor(symbol, interval) && hasAnalysis) { - renderAnalysisPanel(symbol, interval, fData); - setTimeout(updateDashboardScale, 10); - renderCompactGauges(symbol, interval, fData); - } - updateStatus( - hadForecast - ? `${symbol} | ${interval} - AI chua xong, giu ket qua gan nhat` - : `${symbol} | ${interval} - AI chua tao duoc du bao`, - 'warning' - ); - scheduleAnalysisRetry(symbol, interval); - return fData; - } - - commitForecastVisuals(symbol, interval, forecastLine); - - renderAnalysisPanel(symbol, interval, fData); - setTimeout(updateDashboardScale, 10); - renderCompactGauges(symbol, interval, fData); - - const currentPrice = lastCandleData?.close || 0; - const forecastBaseVal = Number(forecastLine[0]?.value ?? currentPrice); - const lastForecastVal = Number(forecastLine[forecastLine.length - 1]?.value ?? forecastBaseVal); - const isBull = lastForecastVal >= forecastBaseVal; - const pctChange = currentPrice > 0 ? ((lastForecastVal - currentPrice) / currentPrice) * 100 : 0; - const pctLabel = (pctChange >= 0 ? '+' : '') + pctChange.toFixed(2) + '%'; - const trend = isBull ? 'TANG' : 'GIAM'; - const source = fData.source || 'N/A'; - - updateStatus( - `${symbol} - ${source} - ${interval} - ${formatPrice(currentPrice)} => ${formatPrice(lastForecastVal)} (${trend} ${pctLabel})`, - 'ok' - ); - return fData; - } catch (e) { - if (e.name === 'AbortError') return null; - console.error('Stage 2 Fetch Error:', e); - if (!hasCachedAnalysisFor(symbol, interval)) { - panel.innerHTML = `

Khong the ket noi AI: ${e.message}

`; - } - updateStatus( - hasLiveForecastFor(symbol, interval) - ? `${symbol} | ${interval} - AI loi tam thoi, giu ket qua gan nhat` - : 'AI Offline', - 'warning' - ); - scheduleAnalysisRetry(symbol, interval); - return null; - } finally { - if (analysisFetchController === controller) { - analysisFetchController = null; - } - } - })(); - - analysisRequestPromise = requestPromise; - try { - return await requestPromise; - } finally { - if (analysisRequestPromise === requestPromise) { - analysisRequestPromise = null; - analysisRequestKey = null; - } - } - } - - /* 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 => ` -
- ${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 += ` -
-
${s.symbol}
-
${s.label}
-
${s.sources.join(' · ')}
-
- `; - } - explorerGrid.innerHTML = html; - if (!search) explorerHtmlCache[currentExplorerCat] = html; - } - - function explorerSelectSymbol(sym) { - switchSymbol(sym); - closeExplorer(); - } - - let explorerSearchTimeout = null; - explorerSearchInput.oninput = () => { - clearTimeout(explorerSearchTimeout); - explorerSearchTimeout = setTimeout(renderExplorerGrid, 150); - }; - closeExplorerBtn.onclick = closeExplorer; - toggleMarketBtn.onclick = openExplorer; - - 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 forecast = Array.isArray(payload.forecast) ? payload.forecast : []; - const lastPoint = forecast.length ? forecast[forecast.length - 1] : null; - const precision = pane.priceFormat?.precision ?? 2; - - 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.'}
-
-
- Technical -
Trend score: ${Math.round(Number(technical.score ?? technical.trend_score ?? 50))}
-
-
- AI Score -
Confidence: ${Math.round(Number(ai.score ?? ai.confidence ?? summary.confidence ?? 50))}
-
-
- `; - } - - 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; - 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 (typeof pane.fetchAI === 'function') { - pane.fetchAI({ force: true }); - } - }); - Workspace.save(); - } - }; - indicatorSelect.onchange = () => { - const type = indicatorSelect.value; - const isBbVisible = (type === 'bb' || type === 'both'); - - // Update global chart - if (typeof bbUpperSeries !== 'undefined') { - bbUpperSeries.applyOptions({ visible: isBbVisible }); - bbMiddleSeries.applyOptions({ visible: isBbVisible }); - bbLowerSeries.applyOptions({ visible: isBbVisible }); - } - - // Update all Workspace panes - if (window.Workspace && window.Workspace.panes) { - window.Workspace.panes.forEach(pane => { - if (pane.indicatorSeries) { - if (pane.indicatorSeries.bbUpper) pane.indicatorSeries.bbUpper.applyOptions({ visible: isBbVisible }); - if (pane.indicatorSeries.bbMid) pane.indicatorSeries.bbMid.applyOptions({ visible: isBbVisible }); - if (pane.indicatorSeries.bbLower) pane.indicatorSeries.bbLower.applyOptions({ visible: isBbVisible }); - } - }); - } - }; - - // "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'); - } - }; - - refreshBtn.onclick = () => { - const pane = window.Workspace && typeof Workspace.getActivePane === 'function' - ? Workspace.getActivePane() - : null; - if (!pane) return; - 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 && typeof pane.fetchAI === 'function') { - pane.fetchAI({ force: !pane.lastAnalysis?.payload }); - } - }; - - fitBtn.onclick = () => { - fitChartWithOffset(chart); - if (window.Workspace && window.Workspace.panes) { - window.Workspace.panes.forEach(pane => { - if (pane.chartInstance) { - fitChartWithOffset(pane.chartInstance); - } - }); - } - }; - - 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'; - } - - 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(() => { - updateDashboardScale(); - if (chart && chartEl) { - chart.applyOptions({ - width: chartEl.clientWidth, - height: chartEl.clientHeight, - }); - } - }); - } - - if (headerToggleBtn) { - headerToggleBtn.addEventListener('click', () => toggleHeaderVisibility()); - } - - /* ── 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'); - - // Register pane-0 (already in DOM) into Workspace - Workspace.init(workspaceGrid); - 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'); - // Bind existing chart instance to pane-0 - pane0.chartInstance = chart; - pane0.candleSeries = candleSeries; - pane0.forecastSeries = { - candles: forecastCandleSeries, - p50: p50Series, - p10: p10Series, - p90: p90Series, - segments: forecastSegmentSeries, - }; - pane0.indicatorSeries = { bbUpper: bbUpperSeries, bbMid: bbMiddleSeries, bbLower: bbLowerSeries, rsi: rsiSeries }; - pane0.horizon = Math.max(5, Math.min(300, parseInt(horizonInput.value, 10) || 10)); - 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')); - } - - // 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: { - borderColor: 'rgba(40, 80, 140, 0.25)', - autoScale: true, - scaleMargins: { top: 0.08, bottom: 0.08 }, - }, - timeScale: { - borderColor: 'rgba(40, 80, 140, 0.25)', - timeVisible: true, - secondsVisible: false, - rightOffset: CHART_RIGHT_OFFSET, - }, - 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.p50 = chartInstance.addLineSeries({ color: '#7dd3fc', lineWidth: 2, lineStyle: 0, priceLineVisible: false, lastValueVisible: false, visible: false }); - pane.forecastSeries.p10 = chartInstance.addLineSeries({ color: 'rgba(125,211,252,0.4)', lineWidth: 1, lineStyle: 2, priceLineVisible: false, lastValueVisible: false, visible: false }); - pane.forecastSeries.p90 = chartInstance.addLineSeries({ color: 'rgba(125,211,252,0.4)', lineWidth: 1, lineStyle: 2, priceLineVisible: false, lastValueVisible: false, visible: false }); - const isBbVisible = (document.getElementById('indicatorSelect') && (document.getElementById('indicatorSelect').value === 'bb' || document.getElementById('indicatorSelect').value === 'both')); - pane.indicatorSeries.bbMid = chartInstance.addLineSeries({ color: 'rgba(255,255,255,0.2)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: isBbVisible }); - pane.indicatorSeries.bbUpper = chartInstance.addLineSeries({ color: 'rgba(34,211,238,0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: isBbVisible }); - pane.indicatorSeries.bbLower = chartInstance.addLineSeries({ color: 'rgba(34,211,238,0.3)', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, visible: isBbVisible }); - - // 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]; - if (pane.paneId === 'pane-0') { - lastCandleData = pane.lastCandleData; - } - } - - // 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); - - fitChartWithOffset(pane.chartInstance); - pane.chartContext = { symbol, interval }; - if (pane.paneId === 'pane-0') { - currentPriceFormat = pane.priceFormat; - activeChartContext = { symbol, interval }; - currentSymbol = symbol; - currentInterval = interval; - } - - // 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 (pane.paneId === 'pane-0') { - lastCandleData = update; - } - } - - // 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._resizeObserver) { pane._resizeObserver.disconnect(); pane._resizeObserver = null; } - if (pane.chartInstance && pane.paneId !== 'pane-0') { - try { pane.chartInstance.remove(); } catch (_) {} - pane.chartInstance = null; - } - } - - // Switch layout handler - async function switchLayout(preset, options = {}) { - if (preset === Workspace.layoutPreset) return; - const prevPreset = Workspace.layoutPreset; - const restoredPanes = Array.isArray(options.panes) ? options.panes : 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 = preset; - [1, 2, 4, 8].forEach(n => workspaceGrid.classList.remove(`layout-${n}`)); - workspaceGrid.classList.add(`layout-${preset}`); - - // Update switcher buttons - layoutSwitcher.querySelectorAll('button[data-layout]').forEach(btn => { - btn.classList.toggle('active', parseInt(btn.dataset.layout) === preset); - }); - const layoutMenuCurrent = document.getElementById('layoutMenuCurrent'); - const layoutMenuBtn = document.getElementById('layoutMenuBtn'); - if (layoutMenuCurrent) layoutMenuCurrent.textContent = String(preset); - 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: preset === 1 ? 72 : 28 }, - }); - pane0.chartInstance.applyOptions({ - width: pane0.chartEl.clientWidth, - height: pane0.chartEl.clientHeight, - }); - } - - // Show/hide global overlays based on mode - const logoOverlay = document.querySelector('.chart-logo-overlay'); - if (logoOverlay) logoOverlay.style.display = preset === 1 ? '' : 'none'; - setGlobalStatusVisibility(preset === 1); - setGlobalCompactGaugesVisibility(preset === 1); - - const realStrengthSymbols = ['DXY', 'EURX', 'GBPX', 'CHFX', 'JPYX', 'CADX', 'AUDX', 'NZDX']; - - // Create new panes for multi-chart mode - if (preset > 1) { - const defaultSymbols = preset === 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; - - if (ws) { - ws.close(); - ws = null; - } - - if (pane0) { - const pane0Symbol = restoredPane0?.symbol || defaultSymbols[0] || pane0.symbol; - const sharedInterval = restoredPane0?.interval || timeframeSelect.value || pane0.interval || '1d'; - pane0.symbol = pane0Symbol; - pane0.interval = sharedInterval; - pane0.horizon = Math.max(5, Math.min(300, parseInt(restoredPane0?.horizon, 10) || parseInt(horizonInput.value, 10) || 10)); - 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 }; - pane0.forecastContext = { symbol: null, interval: null, ready: false }; - pane0.error = null; - renderPaneAnalysisUI(pane0); - loadPromises.push(loadPaneData(pane0).then(() => connectPaneWS(pane0))); - } - - for (let i = 1; i < preset; i++) { - const paneId = `pane-${i}`; - const restoredPaneState = restoredPanes?.find((paneState) => paneState.id === paneId) || null; - const sym = restoredPaneState?.symbol || defaultSymbols[i % defaultSymbols.length]; - const paneInterval = restoredPaneState?.interval || timeframeSelect.value || '1d'; - const pane = Workspace.createPane(paneId, sym, paneInterval); - - // Build DOM - const container = document.createElement('div'); - container.className = 'chart-pane'; - container.dataset.paneId = paneId; - container.innerHTML = ` -
- ${sym} - · - 1d - -- -
- -
- -
-
- `; - - 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.horizon = Math.max(5, Math.min(300, parseInt(restoredPaneState?.horizon, 10) || parseInt(horizonInput.value, 10) || 10)); - 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) { - StreamManager.unsubscribe(pane0.paneId); - clearPaneForecastVisuals(pane0); - if (pane0.gaugesEl) { - pane0.gaugesEl.innerHTML = ''; - pane0.gaugesEl.style.display = 'none'; - } - currentSymbol = pane0.symbol; - currentInterval = pane0.interval; - symbolSearch.value = pane0.symbol; - timeframeSelect.value = pane0.interval; - 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 (pane0?.gaugesEl) { - pane0.gaugesEl.style.display = preset === 1 ? 'none' : ''; - if (preset === 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(); - } - - // 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; - } - } - - // Update active pane globals for backward compat - currentSymbol = pane.symbol; - currentInterval = pane.interval; - } - - // 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; - currentSymbol = symbol; - 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 }; - pane.error = null; - 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'); - }); - - // Override explorer symbol selection for multi-pane - window._originalExplorerSelectSymbol = window.explorerSelectSymbol; - window.explorerSelectSymbol = function(sym) { - if (Workspace.layoutPreset > 1) { - applySymbolToActivePane(sym); - closeExplorer(); - } else { - switchSymbol(sym); - closeExplorer(); - } - }; - - // Wire Workspace active pane change callback - Workspace._onActivePaneChange = (paneId) => { - syncToolbarToPane(paneId); - }; - - 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 () => { - // Restore theme preference (Default: light) - const savedTheme = localStorage.getItem('aiforecast_theme') || 'light'; - applyTheme(savedTheme); - applyHeaderVisibility(resolveInitialHeaderVisibility()); - - 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 (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(); - })(); - - 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 a = payload.analysis; - const dashboard = a.dashboard || {}; - const technical = dashboard.technical || a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 }; - const ai = dashboard.ai || a.ai_gauge || { gauge: 50, signal: '--' }; - const summary = dashboard.summary || a.summary || { gauge: 50, signal: '--' }; - const comboActive = Boolean(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))); - - pane.gaugesEl.innerHTML = ` -
-
Kỹ thuật
-
${buildGaugeSvg(gaugeToRawScore(technical.gauge), 80, 50, false)}
-
${technical.signal}
-
-
-
Dự báo AI
-
${buildGaugeSvg(gaugeToRawScore(ai.gauge), 80, 50, false)}
-
${ai.signal}
-
-
-
Tổng kết
-
${buildGaugeSvg(gaugeToRawScore(summary.gauge), 80, 50, false)}
-
${summary.signal}
-
- `; - - 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 technicals = a.technicals || { gauge: 50, signal: '--', buy: 0, sell: 0, neutral: 0 }; - const aiGauge = a.ai_gauge || { gauge: 50, signal: '--', certainty: 0, path_consistency: 0 }; - const summary = a.summary || { signal: '--', gauge: 50 }; - const dashboard = a.dashboard || {}; - const pivots = (a.pivot_points || {}).data || []; - const comboActive = Boolean(summary.agreement || (Math.abs((technicals.gauge ?? 50) - 50) > 6 && Math.abs((aiGauge.gauge ?? 50) - 50) > 6 && Math.sign((technicals.gauge ?? 50) - 50) === Math.sign((aiGauge.gauge ?? 50) - 50))); - 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
-
${buildGaugeSvg(gaugeToRawScore(aiGauge.gauge), 220, 150, true)}
-
-
Hiện tại:${paneFormatPrice(aiCurrentPrice)}
-
Dự ki���n:${paneFormatPrice(forecastEnd)}
-
Biến động:${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}
-
-
-
⚡ TỔNG KẾT
-
${buildGaugeSvg(gaugeToRawScore(summary.gauge), 220, 150, true)}
-
${summary.signal}
-
-
-
-
-
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); - }; - }); - } - }); - } - - - -