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