const THEME_STORAGE_KEY = 'iris-theme';
const LEGACY_THEME_STORAGE_KEY = 'iris-theme-preference';
function getPreferredTheme() {
let savedTheme = null;
try {
savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme !== 'light' && savedTheme !== 'dark') {
savedTheme = localStorage.getItem(LEGACY_THEME_STORAGE_KEY);
}
} catch (error) {
savedTheme = null;
}
if (savedTheme === 'light' || savedTheme === 'dark') {
return savedTheme;
}
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
function applyTheme(theme) {
const normalizedTheme = theme === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', normalizedTheme);
}
(function initializeThemeImmediately() {
applyTheme(getPreferredTheme());
})();
document.addEventListener('DOMContentLoaded', () => {
// Force en-US locale for all formatting.
const LOCALE = 'en-US';
const TIMEFRAME_TO_QUERY = {
'1D': { period: '1d', interval: '2m' },
'5D': { period: '5d', interval: '15m' },
'1M': { period: '1mo', interval: '60m' },
'6M': { period: '6mo', interval: '1d' },
'1Y': { period: '1y', interval: '1d' },
'5Y': { period: '5y', interval: '1wk' },
};
const themeToggle = document.getElementById('theme-toggle');
const timeframeButtons = Array.from(document.querySelectorAll('.timeframe-btn'));
const form = document.getElementById('analyze-form');
const input = document.getElementById('ticker-input');
const btnText = document.querySelector('.btn-text');
const spinner = document.getElementById('loading-spinner');
const analyzeBtn = document.getElementById('analyze-btn');
const errorMsg = document.getElementById('error-message');
// Dashboard Elements
const dashboard = document.getElementById('results-dashboard');
const resTicker = document.getElementById('res-ticker');
const resTime = document.getElementById('res-time');
const engineIndicator = document.getElementById('engine-light');
const lightStatusText = document.getElementById('light-status');
const currentPriceEl = document.getElementById('current-price');
const predictedPriceEl = document.getElementById('predicted-price');
const trendLabelEl = document.getElementById('trend-label');
const sentimentScoreEl = document.getElementById('sentiment-score');
const sentimentDescEl = document.getElementById('sentiment-desc');
const headlinesList = document.getElementById('headlines-list');
const feedbackOpenBtn = document.getElementById('feedback-open');
const feedbackModal = document.getElementById('feedback-modal');
const feedbackCancelBtn = document.getElementById('feedback-cancel');
const feedbackSubmitBtn = document.getElementById('feedback-submit');
const feedbackMessageEl = document.getElementById('feedback-message');
const chartContainer = document.getElementById('advanced-chart');
const chartPlaceholder = document.getElementById('chart-placeholder');
let lwChart = null;
let currentTicker = '';
let latestPredictedPrice = null;
let latestAnalyzeHistory = [];
let latestAnalyzeTimeframe = '6M';
let currentHorizon = '1D';
let latestTrajectory = [];
let latestTrajectoryUpper = [];
let latestTrajectoryLower = [];
let cachedHorizons = {};
let cachedLlmByHorizon = {};
let latestAnalyzeResponse = null;
const HORIZON_LABELS = {
'1D': '1 Day',
'5D': '5 Days',
'1M': '1 Month',
'6M': '6 Months',
'1Y': '1 Year',
'5Y': '5 Years',
};
const DASHBOARD_STATE_STORAGE_KEY = 'iris-dashboard-state-v1';
const predictedPriceLabelEl = document.getElementById('predicted-price-label');
const accuracyValueEl = document.getElementById('accuracy-value');
const accuracyRowEl = document.getElementById('accuracy-row');
const chartForecastConfidenceEl = document.getElementById('chart-forecast-confidence');
function updateAccuracyDisplay(confidence) {
const pct = Number.isFinite(Number(confidence)) ? Number(confidence) : null;
if (pct === null || pct <= 0) {
if (accuracyRowEl) accuracyRowEl.classList.add('hidden');
if (chartForecastConfidenceEl) {
chartForecastConfidenceEl.textContent = '';
chartForecastConfidenceEl.classList.add('hidden');
}
return;
}
const formatted = pct.toFixed(1) + '%';
let colorClass = 'accuracy-high';
if (pct < 60) colorClass = 'accuracy-low';
else if (pct < 75) colorClass = 'accuracy-medium';
if (accuracyValueEl) {
accuracyValueEl.textContent = formatted;
accuracyValueEl.className = 'value ' + colorClass;
}
if (accuracyRowEl) accuracyRowEl.classList.remove('hidden');
if (chartForecastConfidenceEl) {
chartForecastConfidenceEl.textContent = formatted + ' confidence';
chartForecastConfidenceEl.title = `Model confidence: ${formatted} — Based on out-of-bag R² score and prediction consistency across decision trees. Longer horizons apply a decay factor since forecasts further out are inherently less reliable.`;
chartForecastConfidenceEl.className = 'chart-forecast-confidence ' + colorClass;
chartForecastConfidenceEl.classList.remove('hidden');
}
}
// --- Prediction reasoning tooltip ---
let activeTooltip = null;
let activeTooltipTarget = null;
function showReasoningTooltip(targetEl, modelName, price, signal, reasoning) {
hideReasoningTooltip();
const tooltip = document.createElement('div');
const safeSignal = String(signal || '').trim();
const VALID_SIGNALS = ['STRONG BUY', 'BUY', 'HOLD', 'SELL', 'STRONG SELL'];
const isGradedSignal = safeSignal && VALID_SIGNALS.includes(safeSignal.toUpperCase());
const signalHtml = !safeSignal
? ''
: isGradedSignal
? `${safeSignal}`
: `${safeSignal}`;
tooltip.className = 'prediction-tooltip';
tooltip.innerHTML = `
${modelName}
${price || 'N/A'}
${signalHtml}
${reasoning || 'No reasoning available.'}
`;
// Append to dashboard (not the card) to avoid overflow clipping.
const anchor = document.getElementById('results-dashboard') || document.body;
anchor.style.position = 'relative';
anchor.appendChild(tooltip);
requestAnimationFrame(() => {
const tRect = targetEl.getBoundingClientRect();
const aRect = anchor.getBoundingClientRect();
const tipW = tooltip.offsetWidth;
const tipH = tooltip.offsetHeight;
const viewH = window.innerHeight;
// Try left first.
let left = tRect.left - aRect.left - tipW - 12;
let top = tRect.top - aRect.top + (tRect.height / 2) - (tipH / 2);
// Fallback above.
if (left < 0) {
left = tRect.left - aRect.left + (tRect.width / 2) - (tipW / 2);
top = tRect.top - aRect.top - tipH - 8;
}
// Fallback below.
if (top < 0) {
left = tRect.left - aRect.left + (tRect.width / 2) - (tipW / 2);
top = tRect.bottom - aRect.top + 8;
}
left = Math.max(4, Math.min(left, aRect.width - tipW - 4));
top = Math.max(4, top);
const tooltipBottomAbs = aRect.top + top + tipH;
if (tooltipBottomAbs > viewH - 20) {
const availableH = viewH - (aRect.top + top) - 20;
tooltip.style.maxHeight = `${Math.max(120, availableH)}px`;
} else {
tooltip.style.maxHeight = '';
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.classList.add('is-visible');
});
activeTooltip = tooltip;
activeTooltipTarget = targetEl;
}
function hideReasoningTooltip() {
if (activeTooltip) {
activeTooltip.classList.remove('is-visible');
const el = activeTooltip;
setTimeout(() => el.remove(), 200);
activeTooltip = null;
activeTooltipTarget = null;
}
}
// --- Analysis state / flow elements ---
const errorBanner = document.getElementById('error-banner');
const errorBannerMsg = document.getElementById('error-banner-msg');
const errorBannerChips = document.getElementById('error-banner-chips');
const errorBannerRetry = document.getElementById('error-banner-retry');
const errorBannerDismiss= document.getElementById('error-banner-dismiss');
const analysisProgress = document.getElementById('analysis-progress');
const analysisSkeleton = document.getElementById('analysis-skeleton');
let _retryTicker = null;
let _progressTimers = [];
let _rateLimitTimer = null;
function _showErrorBanner(message, suggestions, showRetry, bannerType) {
if (!errorBanner) return;
// Apply visual variant: 'error' (red, default) | 'warning' (yellow) | 'muted' (gray)
errorBanner.dataset.bannerType = bannerType || 'error';
if (errorBannerMsg) errorBannerMsg.textContent = message;
if (errorBannerChips) {
errorBannerChips.innerHTML = '';
if (Array.isArray(suggestions) && suggestions.length) {
suggestions.forEach((s) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'suggestion-chip';
chip.textContent = s;
chip.addEventListener('click', () => {
_hideErrorBanner();
input.value = s;
_triggerValidation(s);
});
errorBannerChips.appendChild(chip);
});
}
}
if (errorBannerRetry) errorBannerRetry.classList.toggle('hidden', !showRetry);
errorBanner.classList.remove('hidden');
requestAnimationFrame(() => errorBanner.classList.add('is-visible'));
}
function _hideErrorBanner() {
if (!errorBanner) return;
errorBanner.classList.remove('is-visible');
setTimeout(() => errorBanner.classList.add('hidden'), 300);
}
function _clearProgressTimers() {
_progressTimers.forEach((t) => clearTimeout(t));
_progressTimers = [];
}
function _advanceProgress(step) {
if (!analysisProgress) return;
analysisProgress.querySelectorAll('.progress-step').forEach((el, i) => {
const n = i + 1;
el.className = n < step ? 'progress-step is-done'
: n === step ? 'progress-step is-active'
: 'progress-step';
});
}
function _showProgress() {
if (!analysisProgress) return;
_advanceProgress(1);
analysisProgress.classList.remove('hidden');
_progressTimers.push(setTimeout(() => _advanceProgress(2), 1000));
_progressTimers.push(setTimeout(() => _advanceProgress(3), 3000));
_progressTimers.push(setTimeout(() => _advanceProgress(4), 5000));
}
function _hideProgress() {
_clearProgressTimers();
if (!analysisProgress) return;
analysisProgress.classList.add('hidden');
analysisProgress.querySelectorAll('.progress-step').forEach((el) => {
el.className = 'progress-step';
});
}
function _showSkeleton() { if (analysisSkeleton) analysisSkeleton.classList.remove('hidden'); }
function _hideSkeleton() { if (analysisSkeleton) analysisSkeleton.classList.add('hidden'); }
function _startRateLimitCountdown(seconds) {
const endTime = Date.now() + seconds * 1000;
analyzeBtn.disabled = true;
function tick() {
const remaining = Math.ceil((endTime - Date.now()) / 1000);
if (remaining <= 0) {
if (btnText) btnText.textContent = 'Analyze Risk';
analyzeBtn.disabled = !_validatedTicker;
return;
}
if (btnText) btnText.textContent = `Wait ${remaining}s...`;
_rateLimitTimer = setTimeout(tick, 500);
}
tick();
}
if (errorBannerDismiss) {
errorBannerDismiss.addEventListener('click', _hideErrorBanner);
}
if (errorBannerRetry) {
errorBannerRetry.addEventListener('click', () => {
if (_retryTicker) {
_hideErrorBanner();
loadTickerData(_retryTicker, false);
}
});
}
// --- Validation UI elements ---
const inputWrapper = document.getElementById('ticker-input-wrapper');
const clearBtn = document.getElementById('ticker-clear');
const valIndicator = document.getElementById('ticker-val-indicator');
const validationHint = document.getElementById('validation-hint');
const validationMsgEl = document.getElementById('validation-msg');
const suggestionChips = document.getElementById('suggestion-chips');
// --- Validation state ---
let _validatedTicker = null; // non-null only when remote validation passed
let _debounceTimer = null;
let _abortController = null;
function _setInputState(state) {
// state: '' | 'error' | 'validating' | 'valid' | 'warn'
input.className = state ? `ticker-input--${state}` : '';
if (valIndicator) {
valIndicator.classList.toggle('is-spinning', state === 'validating');
valIndicator.classList.toggle('hidden', state !== 'validating');
}
}
function _showValidationHint(text, type) {
if (!validationMsgEl || !validationHint) return;
validationMsgEl.textContent = text;
validationMsgEl.className = `validation-msg validation-msg--${type}`;
validationHint.classList.remove('hidden');
}
function _clearValidationHint() {
if (validationHint) validationHint.classList.add('hidden');
if (validationMsgEl) validationMsgEl.textContent = '';
if (suggestionChips) suggestionChips.innerHTML = '';
}
function _renderSuggestions(suggestions) {
if (!suggestionChips) return;
suggestionChips.innerHTML = '';
if (!Array.isArray(suggestions) || !suggestions.length) return;
suggestions.forEach((s) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'suggestion-chip';
chip.textContent = s;
chip.addEventListener('click', () => {
input.value = s;
_triggerValidation(s);
});
suggestionChips.appendChild(chip);
});
}
function _buildValidationSuccessMessage(result, fallbackTicker) {
const companyLabel = String(result?.company_name || fallbackTicker || '').trim();
if (result?.source === 'local_db' && !result?.warning) {
return companyLabel
? `✓ Verified in SEC database: ${companyLabel}`
: '✓ Verified in SEC database';
}
return `✓ ${companyLabel || fallbackTicker || 'Ticker verified'}`;
}
// Route a failed remote-validation result to the right visual treatment
function _routeValidationError(result, val) {
const code = result.code || '';
const err = result.error || 'Validation failed.';
if (code) console.debug('[IRIS-AI] Validation rejected 鈥?code:', code, '鈥?ticker:', val);
if (code === 'API_TIMEOUT' || code === 'API_ERROR') {
// Service degraded: yellow warning banner with retry option
_setInputState('warn');
_clearValidationHint();
_showErrorBanner(err, [], true, 'warning');
} else if (code === 'TICKER_NOT_FOUND' || code === 'TICKER_DELISTED') {
// Not found / delisted: error banner with suggestions
_setInputState('error');
_clearValidationHint();
_showErrorBanner(err, result.suggestions || [], false);
} else if (code === 'RATE_LIMITED') {
// Rate limited: gray countdown banner
_setInputState('');
_clearValidationHint();
const match = err.match(/(\d+)/);
_startRateLimitCountdown(match ? parseInt(match[1], 10) : 30);
} else {
// Format / reserved-word / unknown: inline hint below input
_setInputState('error');
_showValidationHint(err, 'error');
_renderSuggestions(result.suggestions || []);
}
}
async function _triggerValidation(rawValue) {
// Sanitise input first (mirrors Python sanitize_ticker_input)
const sanitize = (window.TickerValidation || {}).sanitizeTicker;
const val = sanitize ? sanitize(rawValue) : String(rawValue || '').trim().toUpperCase();
// Sync input field to sanitised form
if (input && input.value !== val && val) input.value = val;
// Cancel previous in-flight remote request
if (_abortController) _abortController.abort();
_abortController = new AbortController();
// Instant format check
const fmt = (window.TickerValidation || {}).validateTickerFormat;
if (!fmt) return;
const fmtResult = fmt(val);
if (!fmtResult.valid) {
_validatedTicker = null;
analyzeBtn.disabled = true;
if (fmtResult.code) console.debug('[IRIS-AI] Format check failed 鈥?code:', fmtResult.code, '鈥?input:', val);
_setInputState('error');
_showValidationHint(fmtResult.error, 'error');
_renderSuggestions([]);
if (clearBtn) clearBtn.classList.toggle('hidden', !val);
return;
}
// Format OK 鈫?remote check
_validatedTicker = null;
analyzeBtn.disabled = true;
_setInputState('validating');
_clearValidationHint();
if (clearBtn) clearBtn.classList.remove('hidden');
const { signal } = _abortController;
const remoteCheck = (window.TickerValidation || {}).validateTickerRemote;
if (!remoteCheck) return;
const result = await remoteCheck(val, signal);
if (!result) return; // cancelled by a newer keystroke
if (result.valid) {
_validatedTicker = val;
analyzeBtn.disabled = false;
const isSecVerified = result.source === 'local_db' && !result.warning;
const hasWarning = !!(result.warning && !isSecVerified);
_setInputState(hasWarning ? 'warn' : 'valid');
_showValidationHint(
hasWarning ? `\u26A0 ${result.warning}` : _buildValidationSuccessMessage(result, val),
hasWarning ? 'warn' : 'success'
);
_renderSuggestions([]);
} else {
_routeValidationError(result, val);
}
}
if (input) {
input.addEventListener('input', () => {
// Auto-uppercase + strip leading $ / # as user types
let pos = input.selectionStart || 0;
let v = input.value.toUpperCase().replace(/^[\$#]+/, '');
if (v !== input.value) {
const removed = input.value.length - v.length;
input.value = v;
pos = Math.max(0, pos - removed);
}
try { input.setSelectionRange(pos, pos); } catch (_) {}
if (clearBtn) clearBtn.classList.toggle('hidden', !input.value);
const val = input.value.trim();
clearTimeout(_debounceTimer);
// Instant format check for immediate feedback
const fmt = (window.TickerValidation || {}).validateTickerFormat;
if (fmt) {
const fmtResult = fmt(val);
if (!fmtResult.valid) {
if (_abortController) _abortController.abort();
_validatedTicker = null;
analyzeBtn.disabled = true;
_setInputState(val ? 'error' : '');
if (val) {
_showValidationHint(fmtResult.error, 'error');
} else {
_clearValidationHint();
_setInputState('');
}
_renderSuggestions([]);
return;
}
}
// Format OK 鈥?debounce the remote call
_validatedTicker = null;
analyzeBtn.disabled = true;
_setInputState('validating');
_clearValidationHint();
_debounceTimer = setTimeout(() => _triggerValidation(val), 500);
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
input.value = '';
_validatedTicker = null;
analyzeBtn.disabled = true;
clearTimeout(_debounceTimer);
if (_abortController) _abortController.abort();
_setInputState('');
_clearValidationHint();
clearBtn.classList.add('hidden');
input.focus();
});
}
// --- End ticker validation ---
// --- Ticker autocomplete ---
const dropdown = document.getElementById('ticker-dropdown');
const acLiveRegion = document.getElementById('ticker-ac-live');
let _acDebounceTimer = null;
let _acAbortController = null;
let _acActiveIndex = -1;
let _acResults = [];
function _hideDropdown() {
if (!dropdown) return;
dropdown.classList.add('hidden');
if (input) input.setAttribute('aria-expanded', 'false');
if (input) input.removeAttribute('aria-activedescendant');
_acActiveIndex = -1;
_acResults = [];
}
function _highlightItem(index) {
if (!dropdown) return;
const items = dropdown.querySelectorAll('.ticker-dropdown-item');
_acActiveIndex = Math.max(-1, Math.min(index, items.length - 1));
items.forEach((el, i) => {
const active = i === _acActiveIndex;
el.setAttribute('aria-selected', active ? 'true' : 'false');
if (active) {
el.scrollIntoView({ block: 'nearest' });
if (input) input.setAttribute('aria-activedescendant', el.id);
}
});
if (_acActiveIndex === -1 && input) input.removeAttribute('aria-activedescendant');
}
function _selectItem(ticker) {
_hideDropdown();
if (input) input.value = ticker;
_triggerValidation(ticker);
if (acLiveRegion) acLiveRegion.textContent = ticker + ' selected';
}
function _renderDropdown(results) {
if (!dropdown) return;
dropdown.innerHTML = '';
_acResults = results || [];
if (!_acResults.length) {
_hideDropdown();
return;
}
_acResults.forEach((item, i) => {
const li = document.createElement('li');
li.id = 'ac-item-' + i;
li.setAttribute('role', 'option');
li.className = 'ticker-dropdown-item';
li.setAttribute('aria-selected', 'false');
const tickerSpan = document.createElement('span');
tickerSpan.className = 'ticker-dropdown-ticker';
tickerSpan.textContent = item.ticker;
const nameSpan = document.createElement('span');
nameSpan.className = 'ticker-dropdown-name';
nameSpan.textContent = item.name || '';
li.appendChild(tickerSpan);
li.appendChild(nameSpan);
// mousedown prevents blur before click registers
li.addEventListener('mousedown', (e) => {
e.preventDefault();
_selectItem(item.ticker);
});
dropdown.appendChild(li);
});
dropdown.classList.remove('hidden');
if (input) input.setAttribute('aria-expanded', 'true');
_acActiveIndex = -1;
}
async function _fetchAutocomplete(query) {
if (_acAbortController) _acAbortController.abort();
_acAbortController = new AbortController();
try {
const resp = await fetch(
'/api/tickers/search?q=' + encodeURIComponent(query) + '&limit=8',
{ signal: _acAbortController.signal }
);
if (!resp.ok) { _hideDropdown(); return; }
const data = await resp.json();
_renderDropdown(data.results || []);
} catch (err) {
if (err.name !== 'AbortError') _hideDropdown();
}
}
if (input) {
// Autocomplete on input 鈥?200 ms debounce
input.addEventListener('input', () => {
clearTimeout(_acDebounceTimer);
const q = input.value.trim();
if (q.length < 1) { _hideDropdown(); return; }
_acDebounceTimer = setTimeout(() => _fetchAutocomplete(q), 200);
});
// Keyboard navigation inside the dropdown
input.addEventListener('keydown', (e) => {
if (!dropdown || dropdown.classList.contains('hidden')) return;
const items = dropdown.querySelectorAll('.ticker-dropdown-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
_highlightItem(Math.min(_acActiveIndex + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_highlightItem(Math.max(_acActiveIndex - 1, 0));
} else if (e.key === 'Enter' && _acActiveIndex >= 0) {
e.preventDefault();
_selectItem(_acResults[_acActiveIndex].ticker);
} else if (e.key === 'Escape') {
_hideDropdown();
}
});
// Hide when focus leaves the input
input.addEventListener('blur', () => {
setTimeout(_hideDropdown, 150);
});
}
// Hide dropdown on click outside
document.addEventListener('click', (e) => {
if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
_hideDropdown();
}
});
// --- End ticker autocomplete ---
let historyRequestId = 0;
const usdFormatter = new Intl.NumberFormat(LOCALE, { style: 'currency', currency: 'USD' });
const headlineDateFormatter = new Intl.DateTimeFormat(LOCALE, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
function formatHeadlineDate(raw) {
if (!raw) return '';
let d;
const num = Number(raw);
if (!isNaN(num) && num > 1e9) {
d = new Date(num * 1000); // Unix seconds 鈫?ms
} else {
d = new Date(raw);
}
if (isNaN(d.getTime())) return '';
return headlineDateFormatter.format(d);
}
function extractDomain(url) {
if (!url) return '';
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
const chartTooltip = document.createElement('div');
chartTooltip.className = 'chart-hover-tooltip';
chartTooltip.style.position = 'absolute';
chartTooltip.style.pointerEvents = 'none';
chartTooltip.style.display = 'none';
chartTooltip.style.zIndex = '12';
chartTooltip.style.padding = '8px 10px';
chartTooltip.style.borderRadius = '8px';
chartTooltip.style.border = '1px solid var(--panel-border)';
chartTooltip.style.background = 'var(--panel-bg)';
chartTooltip.style.color = 'var(--text-main)';
chartTooltip.style.fontSize = '0.82rem';
chartTooltip.style.fontWeight = '600';
chartTooltip.style.lineHeight = '1.35';
if (chartContainer) {
chartContainer.appendChild(chartTooltip);
chartContainer.addEventListener('mouseleave', () => {
chartTooltip.style.display = 'none';
});
}
// Chart loading overlay.
let chartLoadingOverlay = null;
if (chartContainer) {
chartLoadingOverlay = document.createElement('div');
chartLoadingOverlay.className = 'chart-loading-overlay';
chartLoadingOverlay.innerHTML = `
Loading chart data\u2026
`;
chartContainer.appendChild(chartLoadingOverlay);
}
function showChartLoading(message) {
if (!chartLoadingOverlay) return;
const textEl = chartLoadingOverlay.querySelector('.chart-loading-text');
if (textEl) textEl.textContent = message || 'Loading chart data\u2026';
chartLoadingOverlay.classList.add('is-active');
}
function hideChartLoading() {
if (chartLoadingOverlay) {
chartLoadingOverlay.classList.remove('is-active');
}
}
function getChartDimensions() {
if (!chartContainer) {
return { width: 0, height: 300 };
}
const styles = window.getComputedStyle(chartContainer);
const padX = (parseFloat(styles.paddingLeft) || 0) + (parseFloat(styles.paddingRight) || 0);
const padY = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.paddingBottom) || 0);
const width = Math.max(0, chartContainer.clientWidth - padX);
const rawHeight = chartContainer.clientHeight - padY;
const height = Math.max(220, Number.isFinite(rawHeight) ? rawHeight : 300);
return { width, height };
}
function resizeChartToContainer() {
if (!lwChart || !chartContainer) return;
const { width, height } = getChartDimensions();
if (!width || !height) return;
if (typeof lwChart.resize === 'function') {
lwChart.resize(width, height);
} else {
lwChart.applyOptions({ width, height });
}
}
// Headlines card height sync: keep right-column headlines card aligned with
// the combined height of the left-column cards.
function syncHeadlinesCardHeight() {
const headlinesCard = document.querySelector('.headlines-card');
if (!headlinesCard) return;
// Preserve mobile behavior where CSS intentionally removes the cap.
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) {
headlinesCard.style.removeProperty('--headlines-card-height');
headlinesCard.style.removeProperty('max-height');
return;
}
const grid = document.querySelector('.dashboard-3col');
if (!grid) return;
const gridStyle = window.getComputedStyle(grid);
const gap = parseFloat(gridStyle.rowGap) || parseFloat(gridStyle.gap) || 16;
const riskCard = document.querySelector('.engine-light-card');
const priceCard = document.querySelector('.price-card');
const sentCard = document.querySelector('.sentiment-card');
const llmCard = document.querySelector('.llm-card');
const row1Height = Math.max(
riskCard?.offsetHeight || 0,
priceCard?.offsetHeight || 0
);
const row2Height = Math.max(
sentCard?.offsetHeight || 0,
llmCard?.offsetHeight || 0
);
if (row1Height > 0 && row2Height > 0) {
const targetHeight = row1Height + gap + row2Height;
headlinesCard.style.setProperty('--headlines-card-height', `${targetHeight}px`);
headlinesCard.style.maxHeight = `${targetHeight}px`;
}
}
function syncThemeToggleState() {
if (!themeToggle) return;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
themeToggle.setAttribute('aria-pressed', isDark ? 'true' : 'false');
}
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme);
try {
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
localStorage.setItem(LEGACY_THEME_STORAGE_KEY, nextTheme);
} catch (error) {
// Ignore storage failures and continue with in-memory theme.
}
syncThemeToggleState();
});
syncThemeToggleState();
}
function getActiveTimeframe() {
const activeBtn = timeframeButtons.find((btn) => btn.classList.contains('active'));
const key = String(activeBtn?.dataset?.timeframe || '6M').toUpperCase();
return TIMEFRAME_TO_QUERY[key] ? key : '6M';
}
function setActiveTimeframe(timeframeKey) {
const normalized = String(timeframeKey || '').toUpperCase();
timeframeButtons.forEach((btn) => {
const btnKey = String(btn.dataset.timeframe || '').toUpperCase();
btn.classList.toggle('active', btnKey === normalized);
});
}
function resolveTimeframeFromMeta(meta) {
const period = String(meta?.period || '').toLowerCase();
const interval = String(meta?.interval || '').toLowerCase();
const match = Object.entries(TIMEFRAME_TO_QUERY).find(
([, value]) => value.period === period && value.interval === interval
);
return match ? match[0] : getActiveTimeframe();
}
async function fetchHistoryData(ticker, timeframeKey) {
const normalizedTicker = String(ticker || '').trim().toUpperCase();
if (!normalizedTicker) return [];
const activeTimeframe = TIMEFRAME_TO_QUERY[timeframeKey] ? timeframeKey : '6M';
const mapped = TIMEFRAME_TO_QUERY[activeTimeframe];
const params = new URLSearchParams({
period: mapped.period,
interval: mapped.interval,
});
const response = await fetch(`/api/history/${encodeURIComponent(normalizedTicker)}?${params.toString()}`);
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || 'Failed to fetch history data');
}
const history = normalizeHistoryPoints(body.data);
if (history.length > 0) {
return history;
}
const providerMessage = String(body?.message || '').trim();
throw new Error(
providerMessage || `No history data returned for ${normalizedTicker} (${mapped.period}, ${mapped.interval}).`
);
}
async function fetchAnalyzeHistoryData(ticker, timeframeKey) {
const normalizedTicker = String(ticker || '').trim().toUpperCase();
if (!normalizedTicker) return { history: [], predicted: null };
const activeTimeframe = TIMEFRAME_TO_QUERY[timeframeKey] ? timeframeKey : '6M';
const mapped = TIMEFRAME_TO_QUERY[activeTimeframe];
const params = new URLSearchParams({ ticker: normalizedTicker });
params.set('timeframe', activeTimeframe);
params.set('period', mapped.period);
params.set('interval', mapped.interval);
const response = await fetch(`/api/analyze?${params.toString()}`);
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || 'Failed to fetch fallback history data');
}
const history = normalizeHistoryPoints(body?.market?.history);
if (!history.length) {
throw new Error(`No fallback history data returned for ${normalizedTicker} (${mapped.period}, ${mapped.interval}).`);
}
const predicted = Number(body?.market?.predicted_price_next_session);
return { history, predicted: Number.isFinite(predicted) ? predicted : null };
}
async function refreshChartForTimeframe(ticker, timeframeKey, useLoadingState = true) {
const normalizedTicker = String(ticker || '').trim().toUpperCase();
if (!normalizedTicker) return;
const requestedTimeframe = TIMEFRAME_TO_QUERY[timeframeKey] ? timeframeKey : '6M';
const requestId = ++historyRequestId;
if (useLoadingState) {
setLoading(true);
}
try {
const history = await fetchHistoryData(normalizedTicker, requestedTimeframe);
if (requestId !== historyRequestId) return;
latestAnalyzeHistory = history;
latestAnalyzeTimeframe = requestedTimeframe;
renderChart(
history,
latestPredictedPrice,
latestTrajectory,
latestTrajectoryUpper,
latestTrajectoryLower,
);
} catch (error) {
if (requestId !== historyRequestId) return;
console.error(error);
try {
const fallback = await fetchAnalyzeHistoryData(normalizedTicker, requestedTimeframe);
if (requestId !== historyRequestId) return;
latestAnalyzeHistory = fallback.history;
latestAnalyzeTimeframe = requestedTimeframe;
if (Number.isFinite(fallback.predicted)) {
latestPredictedPrice = fallback.predicted;
}
renderChart(
fallback.history,
latestPredictedPrice,
latestTrajectory,
latestTrajectoryUpper,
latestTrajectoryLower,
);
errorMsg.classList.add('hidden');
return;
} catch (fallbackError) {
if (requestId !== historyRequestId) return;
console.error(fallbackError);
}
if (Array.isArray(latestAnalyzeHistory) && latestAnalyzeHistory.length > 0) {
renderChart(
latestAnalyzeHistory,
latestPredictedPrice,
latestTrajectory,
latestTrajectoryUpper,
latestTrajectoryLower,
);
errorMsg.classList.add('hidden');
return;
}
if (chartContainer) {
chartPlaceholder.classList.remove('hidden');
chartPlaceholder.textContent = "Chart not available.";
chartTooltip.style.display = 'none';
}
errorMsg.textContent = error.message || 'Failed to fetch chart data';
errorMsg.classList.remove('hidden');
} finally {
if (useLoadingState) {
setLoading(false);
}
}
}
function applyTrendBadge(trend) {
const normalizedTrend = String(trend || '').trim();
trendLabelEl.textContent = normalizedTrend || 'UNKNOWN';
if (normalizedTrend.includes('UPTREND')) {
trendLabelEl.style.color = 'var(--status-green)';
trendLabelEl.style.border = '1px solid var(--status-green-glow)';
} else if (normalizedTrend.includes('DOWNTREND')) {
trendLabelEl.style.color = 'var(--status-red)';
trendLabelEl.style.border = '1px solid var(--status-red-glow)';
} else {
trendLabelEl.style.color = 'var(--text-main)';
trendLabelEl.style.border = '1px solid var(--panel-border)';
}
}
function renderInvestmentSignalBadge(signalValue) {
const signalStr = String(signalValue || '').trim();
let existingSignalBadge = document.getElementById('investment-signal-badge');
if (!existingSignalBadge && trendLabelEl?.parentElement) {
existingSignalBadge = document.createElement('div');
existingSignalBadge.id = 'investment-signal-badge';
trendLabelEl.parentElement.appendChild(existingSignalBadge);
}
if (!existingSignalBadge) return;
if (signalStr) {
const signalClass = 'signal-' + signalStr.toLowerCase().replace(/\s+/g, '-');
existingSignalBadge.className = 'signal-badge ' + signalClass;
existingSignalBadge.textContent = signalStr;
} else {
existingSignalBadge.className = 'signal-badge signal-hold';
existingSignalBadge.textContent = 'HOLD';
}
}
function _renderLlmInsights(llmInsights, horizonKey) {
const llmContainer = document.getElementById('llm-insights-container');
if (!llmContainer) return;
llmContainer.innerHTML = '';
const horizonLabel = HORIZON_LABELS[horizonKey] || '';
const modelOrder = [
['chatgpt52', 'ChatGPT 5.2'],
['deepseek_v3', 'DeepSeek V3'],
['gemini_v3_pro', 'Gemini V3 Pro'],
];
for (const [key, modelName] of modelOrder) {
const report = (llmInsights && typeof llmInsights === 'object' && llmInsights[key])
? llmInsights[key]
: { error: 'Model unavailable', status: 'unavailable' };
if (report?.status === 'unavailable' || report?.error) {
const errDiv = document.createElement('div');
errDiv.className = 'llm-report-item';
errDiv.style.cssText = 'padding:8px;background:rgba(255,255,255,0.03);border-radius:5px;display:flex;justify-content:space-between;align-items:center;min-height:44px;';
const rawReason = String(report?.error || '').toLowerCase();
const reason = rawReason.includes('not configured') || rawReason.includes('not set')
? 'API key not set'
: 'Temporarily unavailable';
errDiv.innerHTML = `
${modelName}
${reason}`;
llmContainer.appendChild(errDiv);
continue;
}
const div = document.createElement('div');
div.className = 'llm-report-item';
div.style.padding = '8px';
div.style.background = 'rgba(255, 255, 255, 0.05)';
div.style.borderRadius = '5px';
div.style.display = 'flex';
div.style.justifyContent = 'space-between';
div.style.alignItems = 'center';
const llmTrend = String(report?.signals?.trend_label || '').toUpperCase().trim();
// Strip non-ASCII characters (e.g., emoji that can render as garbled symbols on CJK systems).
const cleanTrend = llmTrend.replace(/[^\x20-\x7E]/g, '').trim();
const llmSignal = String(report?.signals?.investment_signal || '').trim();
const llmPrice = Number(
report?.market?.predicted_price_horizon ?? report?.market?.predicted_price_next_session
);
const reasoning = String(report?.reasoning || '').trim();
let priceClass = 'llm-price-flat';
let trendClass = 'llm-trend-flat';
let arrow = '';
if (cleanTrend.includes('UPTREND')) {
priceClass = 'llm-price-up';
trendClass = 'llm-trend-up';
arrow = '\u2191 ';
} else if (cleanTrend.includes('DOWNTREND')) {
priceClass = 'llm-price-down';
trendClass = 'llm-trend-down';
arrow = '\u2193 ';
}
let signalHtml = '';
if (llmSignal) {
const sc = 'signal-' + llmSignal.toLowerCase().replace(/\s+/g, '-');
signalHtml = `${llmSignal}
`;
}
div.innerHTML = `
${modelName}
${isFinite(llmPrice) ? usdFormatter.format(llmPrice) : 'N/A'}
${arrow}${cleanTrend || 'N/A'}
${signalHtml}
`;
if (reasoning) {
div.classList.add('prediction-result');
div.addEventListener('mouseenter', () => {
const priceStr = isFinite(llmPrice) ? usdFormatter.format(llmPrice) : 'N/A';
const tooltipModel = horizonLabel ? `${modelName} (${horizonLabel})` : modelName;
showReasoningTooltip(div, tooltipModel, priceStr, llmSignal, reasoning);
});
div.addEventListener('mouseleave', hideReasoningTooltip);
}
llmContainer.appendChild(div);
}
requestAnimationFrame(() => syncHeadlinesCardHeight());
}
function updateLlmForHorizon(ticker, horizonKey, forceRefresh = false) {
const normalizedTicker = String(ticker || '').trim().toUpperCase();
const normalizedHorizon = String(horizonKey || '').trim().toUpperCase() || '1D';
if (!normalizedTicker) return;
const cachedLlm = cachedLlmByHorizon[normalizedHorizon];
const llmContainer = document.getElementById('llm-insights-container');
if (cachedLlm) {
_renderLlmInsights(cachedLlm, normalizedHorizon);
if (!forceRefresh) {
return;
}
}
const renderLlmSkeletons = () => {
if (!llmContainer) return;
const skeletonRows = ['ChatGPT 5.2', 'DeepSeek V3', 'Gemini V3 Pro'].map((name) => `
${name}
Loading...
`).join('');
llmContainer.innerHTML = skeletonRows;
};
if (llmContainer && !cachedLlm) {
renderLlmSkeletons();
}
const runLlmFetch = (isRetry = false) => {
const llmAbort = new AbortController();
const llmTimeout = setTimeout(() => llmAbort.abort(), 60000);
fetch(`/api/llm-predict?ticker=${encodeURIComponent(normalizedTicker)}&horizon=${encodeURIComponent(normalizedHorizon)}`, {
signal: llmAbort.signal,
})
.then((resp) => {
clearTimeout(llmTimeout);
return resp.ok ? resp.json() : null;
})
.then((llmData) => {
if (llmData && llmData.models) {
cachedLlmByHorizon[normalizedHorizon] = llmData.models;
if (currentHorizon === normalizedHorizon) {
_renderLlmInsights(llmData.models, normalizedHorizon);
}
} else if (currentHorizon === normalizedHorizon && llmContainer) {
llmContainer.innerHTML = 'LLM insights unavailable for this timeframe.
';
}
})
.catch((err) => {
clearTimeout(llmTimeout);
if (currentHorizon !== normalizedHorizon || !llmContainer) return;
if (isRetry) {
llmContainer.innerHTML = 'LLM predictions still unavailable.
';
return;
}
const isTimeout = err && err.name === 'AbortError';
const msg = isTimeout
? 'LLM predictions took too long.'
: 'Failed to load LLM predictions.';
llmContainer.innerHTML = `
`;
const retryBtn = llmContainer.querySelector('.llm-retry-btn');
if (retryBtn) {
retryBtn.addEventListener('click', () => {
renderLlmSkeletons();
delete cachedLlmByHorizon[normalizedHorizon];
runLlmFetch(true);
});
}
});
};
runLlmFetch(false);
}
async function loadTickerData(ticker, keepDashboardVisible = false) {
const normalizedTicker = String(ticker || '').trim().toUpperCase();
if (!normalizedTicker) return;
const activeTimeframe = getActiveTimeframe();
const mapped = TIMEFRAME_TO_QUERY[activeTimeframe];
const params = new URLSearchParams({ ticker: normalizedTicker });
params.set('timeframe', activeTimeframe);
params.set('period', mapped.period);
params.set('interval', mapped.interval);
params.set('horizon', currentHorizon);
_retryTicker = normalizedTicker;
// Allow slower networks while backend work stays bounded.
const timeoutCtrl = new AbortController();
const timeoutId = setTimeout(() => timeoutCtrl.abort(), 60000);
setLoading(true);
_hideErrorBanner();
errorMsg.classList.add('hidden');
_showProgress();
_showSkeleton();
showChartLoading('Analyzing ticker\u2026');
if (!keepDashboardVisible) {
dashboard.classList.add('hidden');
var recSec = document.getElementById('recommended-section');
if (recSec) recSec.classList.add('hidden');
}
try {
const response = await fetch(`/api/analyze?${params.toString()}`, {
signal: timeoutCtrl.signal,
});
clearTimeout(timeoutId);
if (response.status === 422) {
const body = await response.json().catch(() => ({}));
_showErrorBanner(
body.error || 'Invalid ticker. Please check the symbol.',
body.suggestions || [],
false
);
_setInputState('error');
_validatedTicker = null;
analyzeBtn.disabled = true;
return;
}
if (response.status === 429) {
_showErrorBanner(
"You're sending requests too quickly. Please wait a moment and try again.",
[],
false
);
_startRateLimitCountdown(10);
return;
}
const data = await response.json();
if (!response.ok) {
_showErrorBanner(
data.error || 'Something went wrong on our end. Please try again in a few seconds.',
[],
true
);
return;
}
// Success
dashboard.classList.remove('hidden');
currentTicker = String(data?.meta?.symbol || normalizedTicker).toUpperCase();
input.value = currentTicker;
_validatedTicker = currentTicker;
latestAnalyzeResponse = data;
cachedLlmByHorizon = {};
const initialLlm = data?.llm_insights;
const initialHorizon = String(data?.meta?.risk_horizon || currentHorizon || '1D').trim().toUpperCase();
if (initialLlm && typeof initialLlm === 'object' && Object.keys(initialLlm).length > 0) {
cachedLlmByHorizon[initialHorizon] = initialLlm;
}
latestPredictedPrice = Number(data?.market?.predicted_price_horizon ?? data?.market?.predicted_price_next_session);
latestTrajectory = Array.isArray(data?.market?.prediction_trajectory) ? data.market.prediction_trajectory : [];
latestTrajectoryUpper = Array.isArray(data?.market?.prediction_trajectory_upper) ? data.market.prediction_trajectory_upper : [];
latestTrajectoryLower = Array.isArray(data?.market?.prediction_trajectory_lower) ? data.market.prediction_trajectory_lower : [];
cachedHorizons = data?.all_horizons && typeof data.all_horizons === 'object' ? data.all_horizons : {};
latestAnalyzeHistory = normalizeHistoryPoints(data?.market?.history);
latestAnalyzeTimeframe = getActiveTimeframe();
try { updateDashboard(data); } catch (renderErr) { console.error('[IRIS] updateDashboard error:', renderErr); }
requestAnimationFrame(() => {
requestAnimationFrame(() => {
syncHeadlinesCardHeight();
});
});
if (currentHorizon && currentTicker) {
updateLlmForHorizon(currentTicker, currentHorizon, true);
}
await refreshChartForTimeframe(currentTicker, getActiveTimeframe(), false);
if (typeof window._irisLoadRecommendations === 'function') { window._irisLoadRecommendations(currentTicker); }
_saveDashboardState();
} catch (error) {
clearTimeout(timeoutId);
console.error(error);
if (!keepDashboardVisible) {
dashboard.classList.add('hidden');
}
if (error.name === 'AbortError') {
_showErrorBanner(
'The analysis is taking longer than expected. Please try again.',
[],
true
);
} else {
_showErrorBanner(
'Something went wrong on our end. Please try again in a few seconds.',
[],
true
);
}
} finally {
_hideSkeleton();
_hideProgress();
hideChartLoading();
setLoading(false);
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!_validatedTicker) return;
await loadTickerData(_validatedTicker, false);
});
// Expose analysis function so recommendation cards can invoke it directly
window._irisAnalyzeTicker = function (ticker) {
const sym = String(ticker || '').trim().toUpperCase();
if (!sym) return;
_validatedTicker = sym;
loadTickerData(sym, false);
};
function setActiveHorizon(horizonKey) {
const normalized = String(horizonKey || '').trim().toUpperCase();
currentHorizon = HORIZON_LABELS[normalized] ? normalized : '1D';
const label = HORIZON_LABELS[currentHorizon] || '1 Day';
if (predictedPriceLabelEl) {
predictedPriceLabelEl.textContent = `Predicted (${label})`;
}
}
function applyPredictionState(snapshot) {
if (!snapshot || typeof snapshot !== 'object') {
return false;
}
latestPredictedPrice = Number(snapshot.predicted_price);
latestTrajectory = Array.isArray(snapshot.prediction_trajectory) ? snapshot.prediction_trajectory : [];
latestTrajectoryUpper = Array.isArray(snapshot.prediction_trajectory_upper) ? snapshot.prediction_trajectory_upper : [];
latestTrajectoryLower = Array.isArray(snapshot.prediction_trajectory_lower) ? snapshot.prediction_trajectory_lower : [];
if (predictedPriceEl) {
predictedPriceEl.textContent = Number.isFinite(latestPredictedPrice)
? usdFormatter.format(latestPredictedPrice)
: 'N/A';
}
const trend = String(snapshot.trend_label || '').replace(/[^\x20-\x7E]/g, '').trim();
applyTrendBadge(trend);
predictedPriceEl.classList.remove('price-up', 'price-down');
if (trend.includes('UPTREND')) {
predictedPriceEl.classList.add('price-up');
} else if (trend.includes('DOWNTREND')) {
predictedPriceEl.classList.add('price-down');
}
renderInvestmentSignalBadge(snapshot.investment_signal || '');
updateAccuracyDisplay(snapshot.model_confidence ?? null);
const priceCard = document.querySelector('.price-card');
if (priceCard) {
priceCard._irisReasoning = String(snapshot?.iris_reasoning?.summary || '');
}
return true;
}
function _saveDashboardState() {
if (!currentTicker || !latestAnalyzeResponse) {
return;
}
const snapshot = {
currentTicker,
validatedTicker: _validatedTicker,
activeTimeframe: getActiveTimeframe(),
currentHorizon,
analyzeData: latestAnalyzeResponse,
latestPredictedPrice: Number.isFinite(latestPredictedPrice) ? latestPredictedPrice : null,
latestTrajectory: Array.isArray(latestTrajectory) ? latestTrajectory : [],
latestTrajectoryUpper: Array.isArray(latestTrajectoryUpper) ? latestTrajectoryUpper : [],
latestTrajectoryLower: Array.isArray(latestTrajectoryLower) ? latestTrajectoryLower : [],
latestAnalyzeHistory: Array.isArray(latestAnalyzeHistory) ? latestAnalyzeHistory : [],
latestAnalyzeTimeframe,
cachedHorizons: cachedHorizons && typeof cachedHorizons === 'object' ? cachedHorizons : {},
cachedLlmByHorizon: cachedLlmByHorizon && typeof cachedLlmByHorizon === 'object' ? cachedLlmByHorizon : {},
savedAt: Date.now(),
};
try {
sessionStorage.setItem(DASHBOARD_STATE_STORAGE_KEY, JSON.stringify(snapshot));
} catch (error) {
console.debug('[IRIS] Unable to persist dashboard state:', error);
}
}
function _restoreDashboardState() {
let rawSnapshot = null;
try {
rawSnapshot = sessionStorage.getItem(DASHBOARD_STATE_STORAGE_KEY);
} catch (error) {
rawSnapshot = null;
}
if (!rawSnapshot) {
return;
}
let snapshot = null;
try {
snapshot = JSON.parse(rawSnapshot);
} catch (error) {
try {
sessionStorage.removeItem(DASHBOARD_STATE_STORAGE_KEY);
} catch (_) {}
return;
}
const analyzeData = snapshot && typeof snapshot === 'object' ? snapshot.analyzeData : null;
const restoredTicker = String(snapshot?.currentTicker || analyzeData?.meta?.symbol || '').trim().toUpperCase();
if (!analyzeData || !restoredTicker) {
return;
}
latestAnalyzeResponse = analyzeData;
currentTicker = restoredTicker;
_validatedTicker = String(snapshot?.validatedTicker || restoredTicker).trim().toUpperCase();
cachedHorizons = snapshot?.cachedHorizons && typeof snapshot.cachedHorizons === 'object'
? snapshot.cachedHorizons
: (analyzeData?.all_horizons && typeof analyzeData.all_horizons === 'object' ? analyzeData.all_horizons : {});
cachedLlmByHorizon = snapshot?.cachedLlmByHorizon && typeof snapshot.cachedLlmByHorizon === 'object'
? snapshot.cachedLlmByHorizon
: {};
latestPredictedPrice = Number(snapshot?.latestPredictedPrice);
if (!Number.isFinite(latestPredictedPrice)) {
latestPredictedPrice = Number(analyzeData?.market?.predicted_price_horizon ?? analyzeData?.market?.predicted_price_next_session);
}
latestTrajectory = Array.isArray(snapshot?.latestTrajectory)
? snapshot.latestTrajectory
: (Array.isArray(analyzeData?.market?.prediction_trajectory) ? analyzeData.market.prediction_trajectory : []);
latestTrajectoryUpper = Array.isArray(snapshot?.latestTrajectoryUpper)
? snapshot.latestTrajectoryUpper
: (Array.isArray(analyzeData?.market?.prediction_trajectory_upper) ? analyzeData.market.prediction_trajectory_upper : []);
latestTrajectoryLower = Array.isArray(snapshot?.latestTrajectoryLower)
? snapshot.latestTrajectoryLower
: (Array.isArray(analyzeData?.market?.prediction_trajectory_lower) ? analyzeData.market.prediction_trajectory_lower : []);
latestAnalyzeHistory = normalizeHistoryPoints(
Array.isArray(snapshot?.latestAnalyzeHistory) ? snapshot.latestAnalyzeHistory : analyzeData?.market?.history
);
latestAnalyzeTimeframe = String(snapshot?.latestAnalyzeTimeframe || '').trim().toUpperCase()
|| resolveTimeframeFromMeta(analyzeData?.meta || {});
updateDashboard(analyzeData);
const restoredTimeframe = TIMEFRAME_TO_QUERY[String(snapshot?.activeTimeframe || '').trim().toUpperCase()]
? String(snapshot.activeTimeframe).trim().toUpperCase()
: resolveTimeframeFromMeta(analyzeData?.meta || {});
setActiveTimeframe(restoredTimeframe);
const restoredHorizon = HORIZON_LABELS[String(snapshot?.currentHorizon || '').trim().toUpperCase()]
? String(snapshot.currentHorizon).trim().toUpperCase()
: String(analyzeData?.meta?.risk_horizon || currentHorizon).trim().toUpperCase();
setActiveHorizon(restoredHorizon);
const restoredPrediction = cachedHorizons[restoredHorizon];
if (restoredPrediction) {
applyPredictionState(restoredPrediction);
}
input.value = currentTicker;
analyzeBtn.disabled = false;
if (clearBtn) clearBtn.classList.remove('hidden');
_setInputState('valid');
_clearValidationHint();
dashboard.classList.remove('hidden');
if (Array.isArray(latestAnalyzeHistory) && latestAnalyzeHistory.length > 0) {
renderChart(
latestAnalyzeHistory,
latestPredictedPrice,
latestTrajectory,
latestTrajectoryUpper,
latestTrajectoryLower,
);
}
if (typeof window._irisLoadRecommendations === 'function') {
window._irisLoadRecommendations(currentTicker);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
syncHeadlinesCardHeight();
});
});
}
timeframeButtons.forEach((btn) => {
btn.addEventListener('click', async () => {
const timeframeKey = String(btn.dataset.timeframe || '').toUpperCase();
if (!TIMEFRAME_TO_QUERY[timeframeKey]) return;
const ticker = currentTicker || input.value.trim().toUpperCase();
if (!ticker) {
errorMsg.textContent = 'Enter a ticker first.';
errorMsg.classList.remove('hidden');
return;
}
currentTicker = ticker;
setActiveTimeframe(timeframeKey);
const priceCard = document.querySelector('.price-card');
const newHorizon = timeframeKey;
const horizonChanged = Boolean(HORIZON_LABELS[newHorizon] && newHorizon !== currentHorizon);
// Step 1: instant prediction panel update from precomputed cache.
if (horizonChanged) {
setActiveHorizon(newHorizon);
const cached = cachedHorizons[newHorizon];
if (cached && typeof cached === 'object') {
applyPredictionState(cached);
} else {
// Fallback for older reports without all_horizons.
if (priceCard) {
priceCard.classList.add('prediction-updating');
}
try {
const predResp = await fetch(
`/api/predict?ticker=${encodeURIComponent(ticker)}&horizon=${encodeURIComponent(newHorizon)}`
);
if (predResp.ok) {
const pred = await predResp.json();
latestPredictedPrice = Number(pred?.predicted_price);
latestTrajectory = Array.isArray(pred?.prediction_trajectory) ? pred.prediction_trajectory : [];
latestTrajectoryUpper = Array.isArray(pred?.prediction_trajectory_upper) ? pred.prediction_trajectory_upper : [];
latestTrajectoryLower = Array.isArray(pred?.prediction_trajectory_lower) ? pred.prediction_trajectory_lower : [];
if (predictedPriceEl && Number.isFinite(latestPredictedPrice)) {
predictedPriceEl.textContent = usdFormatter.format(latestPredictedPrice);
}
const trend = String(pred?.trend_label || '').replace(/[^\x20-\x7E]/g, '').trim();
applyTrendBadge(trend);
predictedPriceEl.classList.remove('price-up', 'price-down');
if (trend.includes('UPTREND')) {
predictedPriceEl.classList.add('price-up');
} else if (trend.includes('DOWNTREND')) {
predictedPriceEl.classList.add('price-down');
}
renderInvestmentSignalBadge(pred?.investment_signal || '');
updateAccuracyDisplay(pred?.model_confidence ?? null);
if (priceCard) {
priceCard._irisReasoning = String(pred?.iris_reasoning?.summary || '');
}
cachedHorizons[newHorizon] = {
predicted_price: Number.isFinite(latestPredictedPrice) ? latestPredictedPrice : null,
trend_label: trend,
prediction_trajectory: latestTrajectory,
prediction_trajectory_upper: latestTrajectoryUpper,
prediction_trajectory_lower: latestTrajectoryLower,
investment_signal: String(pred?.investment_signal || ''),
iris_reasoning: pred?.iris_reasoning || {},
model_confidence: pred?.model_confidence ?? null,
};
applyPredictionState(cachedHorizons[newHorizon]);
}
} catch (err) {
console.warn('Prediction update failed:', err);
} finally {
if (priceCard) {
priceCard.classList.remove('prediction-updating');
}
}
}
updateLlmForHorizon(currentTicker, currentHorizon);
}
// Step 2: chart history refresh (network).
btn.classList.add('is-loading');
showChartLoading('Updating chart\u2026');
timeframeButtons.forEach((b) => { b.disabled = true; });
try {
await refreshChartForTimeframe(currentTicker, timeframeKey, false);
if (Array.isArray(latestAnalyzeHistory) && latestAnalyzeHistory.length > 0) {
renderChart(
latestAnalyzeHistory,
latestPredictedPrice,
latestTrajectory,
latestTrajectoryUpper,
latestTrajectoryLower,
);
}
} catch (error) {
console.error('Timeframe switch error:', error);
} finally {
btn.classList.remove('is-loading');
hideChartLoading();
timeframeButtons.forEach((b) => { b.disabled = false; });
_saveDashboardState();
}
});
});
setActiveTimeframe(getActiveTimeframe());
setActiveHorizon(currentHorizon);
_restoreDashboardState();
window.addEventListener('pagehide', () => {
_saveDashboardState();
});
// Mobile: tap to toggle prediction tooltips.
document.addEventListener('touchstart', (e) => {
const target = e.target.closest('.prediction-result');
if (!target) {
hideReasoningTooltip();
return;
}
if (activeTooltip && activeTooltipTarget === target) {
hideReasoningTooltip();
}
});
window.addEventListener('resize', () => {
resizeChartToContainer();
syncHeadlinesCardHeight();
});
function openFeedbackModal() {
if (!feedbackModal) return;
feedbackModal.classList.remove('hidden');
if (feedbackMessageEl) {
feedbackMessageEl.focus();
}
}
function closeFeedbackModal() {
if (!feedbackModal) return;
feedbackModal.classList.add('hidden');
}
if (feedbackOpenBtn) {
feedbackOpenBtn.addEventListener('click', () => {
openFeedbackModal();
});
}
if (feedbackCancelBtn) {
feedbackCancelBtn.addEventListener('click', () => {
closeFeedbackModal();
});
}
if (feedbackModal) {
feedbackModal.addEventListener('click', (event) => {
if (event.target === feedbackModal) {
closeFeedbackModal();
}
});
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && feedbackModal && !feedbackModal.classList.contains('hidden')) {
closeFeedbackModal();
}
});
function getFeedbackContext() {
const tickerFromHeader = String(resTicker?.textContent || '').trim().toUpperCase();
const tickerFromInput = String(input?.value || '').trim().toUpperCase();
const ticker = tickerFromHeader || currentTicker || tickerFromInput || '';
const timeframe = getActiveTimeframe();
const statusFromLight = String(lightStatusText?.textContent || '').trim();
const statusFromTrend = String(trendLabelEl?.textContent || '').trim();
const status = statusFromLight || statusFromTrend || 'UNKNOWN';
return { ticker, timeframe, status };
}
if (feedbackSubmitBtn) {
feedbackSubmitBtn.addEventListener('click', async () => {
const message = String(feedbackMessageEl?.value || '').trim();
if (!message) {
alert('Please enter feedback before submitting.');
return;
}
const payload = {
message,
context: getFeedbackContext(),
};
try {
feedbackSubmitBtn.disabled = true;
const response = await fetch('/api/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Failed to send feedback.');
}
const destination = String(body.saved_to || '').trim();
alert(destination ? `Feedback sent! Saved to ${destination}` : 'Feedback sent!');
if (feedbackMessageEl) {
feedbackMessageEl.value = '';
}
closeFeedbackModal();
} catch (error) {
console.error(error);
alert(error.message || 'Failed to send feedback.');
} finally {
feedbackSubmitBtn.disabled = false;
}
});
}
function setLoading(isLoading) {
if (isLoading) {
btnText.classList.add('hidden');
spinner.classList.remove('hidden');
analyzeBtn.disabled = true;
timeframeButtons.forEach((btn) => { btn.disabled = true; });
} else {
btnText.classList.remove('hidden');
spinner.classList.add('hidden');
// Only re-enable if the current input is still validated
analyzeBtn.disabled = !_validatedTicker;
timeframeButtons.forEach((btn) => { btn.disabled = false; });
}
}
function normalizeHistoryPoints(rawHistory) {
if (!Array.isArray(rawHistory)) return [];
const normalized = [];
rawHistory.forEach((point) => {
if (!point || typeof point !== 'object') return;
const rawTime = point.time;
const rawValue = Number(point.value);
if (!Number.isFinite(rawValue)) return;
let normalizedTime = null;
if (typeof rawTime === 'number' && Number.isFinite(rawTime)) {
const absVal = Math.abs(rawTime);
if (absVal >= 1e12) {
normalizedTime = Math.round(rawTime / 1000); // milliseconds -> seconds
} else if (absVal >= 1e8) {
normalizedTime = Math.round(rawTime); // seconds
} else {
normalizedTime = null; // likely invalid epoch (e.g., 1, 2, 3...)
}
} else if (typeof rawTime === 'string') {
const trimmed = rawTime.trim();
if (!trimmed) return;
if (/^\d+$/.test(trimmed)) {
const numericTime = Number(trimmed);
const absVal = Math.abs(numericTime);
if (absVal >= 1e12) {
normalizedTime = Math.round(numericTime / 1000);
} else if (absVal >= 1e8) {
normalizedTime = Math.round(numericTime);
} else {
normalizedTime = null;
}
} else {
normalizedTime = trimmed; // legacy business-day string
}
}
if (normalizedTime === null) return;
const volume = point.volume !== undefined ? Number(point.volume) : 0;
const openVal = point.open !== undefined ? Number(point.open) : rawValue;
const highVal = point.high !== undefined ? Number(point.high) : rawValue;
const lowVal = point.low !== undefined ? Number(point.low) : rawValue;
const closeVal = point.close !== undefined ? Number(point.close) : rawValue;
normalized.push({
time: normalizedTime,
value: rawValue,
volume: volume,
open: openVal,
high: highVal,
low: lowVal,
close: closeVal
});
});
// Ensure ascending, unique timestamps for Lightweight Charts stability.
const deduped = new Map();
normalized.forEach((point) => {
deduped.set(String(point.time), point);
});
return Array.from(deduped.values()).sort((a, b) => {
const toSortable = (t) => {
if (typeof t === 'number' && Number.isFinite(t)) return t;
const parsed = Date.parse(String(t));
return Number.isFinite(parsed) ? Math.round(parsed / 1000) : 0;
};
return toSortable(a.time) - toSortable(b.time);
});
}
function readCrosshairPrice(param, series) {
if (!param || !series) return null;
const readFromContainer = (container) => {
if (!container || typeof container.get !== 'function') return null;
const entry = container.get(series);
if (typeof entry === 'number' && Number.isFinite(entry)) return entry;
if (entry && typeof entry === 'object') {
if (Number.isFinite(entry.value)) return entry.value;
if (Number.isFinite(entry.close)) return entry.close;
if (Number.isFinite(entry.open)) return entry.open;
}
return null;
};
const fromSeriesData = readFromContainer(param.seriesData);
if (Number.isFinite(fromSeriesData)) return fromSeriesData;
const fromSeriesPrices = readFromContainer(param.seriesPrices);
if (Number.isFinite(fromSeriesPrices)) return fromSeriesPrices;
return null;
}
function formatCrosshairTime(timeValue) {
let dt = null;
if (typeof timeValue === 'number' && Number.isFinite(timeValue)) {
dt = new Date(timeValue * 1000);
} else if (typeof timeValue === 'string') {
dt = new Date(timeValue);
} else if (timeValue && typeof timeValue === 'object') {
if (
Number.isFinite(timeValue.year) &&
Number.isFinite(timeValue.month) &&
Number.isFinite(timeValue.day)
) {
dt = new Date(Date.UTC(timeValue.year, timeValue.month - 1, timeValue.day));
}
}
if (!(dt instanceof Date) || Number.isNaN(dt.getTime())) {
return String(timeValue ?? '');
}
return new Intl.DateTimeFormat(LOCALE, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit', // P2: Detailed timestamps
timeZoneName: 'short', // P2: Detailed timestamps
hour12: false,
}).format(dt);
}
function renderChart(history, predictedPrice, trajectory, trajectoryUpper = [], trajectoryLower = []) {
if (lwChart) {
lwChart.remove();
lwChart = null;
}
chartTooltip.style.display = 'none';
if (!Array.isArray(history) || history.length === 0) {
if (chartContainer) {
chartPlaceholder.classList.remove('hidden');
chartPlaceholder.textContent = "Chart not available.";
}
return;
}
chartPlaceholder.classList.add('hidden');
const { width: chartWidth, height: chartHeight } = getChartDimensions();
const lastValue = Number(history[history.length - 1]?.value);
const isUptrend = Number.isFinite(predictedPrice) && Number.isFinite(lastValue)
? predictedPrice >= lastValue
: true;
const lineColor = isUptrend ? '#10b981' : '#ef4444';
const topColor = isUptrend ? 'rgba(16, 185, 129, 0.4)' : 'rgba(239, 68, 68, 0.4)';
const bottomColor = isUptrend ? 'rgba(16, 185, 129, 0.0)' : 'rgba(239, 68, 68, 0.0)';
const cssVars = window.getComputedStyle(document.documentElement);
const chartTextColor = cssVars.getPropertyValue('--text-muted').trim() || '#9ca3af';
const chartGridColor = cssVars.getPropertyValue('--chart-border').trim() || 'rgba(255, 255, 255, 0.08)';
const crosshairColor = cssVars.getPropertyValue('--panel-border').trim() || 'rgba(148, 163, 184, 0.35)';
const labelBg = cssVars.getPropertyValue('--panel-bg').trim() || 'rgba(15, 23, 42, 0.9)';
lwChart = LightweightCharts.createChart(chartContainer, {
width: chartWidth || chartContainer.clientWidth,
height: chartHeight || 300,
layout: {
background: { type: 'solid', color: 'transparent' },
textColor: chartTextColor,
},
grid: {
vertLines: { color: chartGridColor },
horzLines: { color: chartGridColor },
},
rightPriceScale: {
visible: true,
autoScale: true,
borderVisible: false,
minimumWidth: 68,
scaleMargins: { top: 0.05, bottom: 0.15 },
},
timeScale: {
borderVisible: false,
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
tickMarkFormatter: (time, tickMarkType) => {
const d = new Date(time * 1000);
const o = { timeZone: 'UTC' };
// TickMarkType: 0=Year 1=Month 2=DayOfMonth 3=Time 4=TimeWithSeconds
if (tickMarkType === 0)
return d.toLocaleDateString(LOCALE, { ...o, year: 'numeric' });
if (tickMarkType === 1)
return d.toLocaleDateString(LOCALE, { ...o, month: 'short' });
if (tickMarkType === 2)
return d.toLocaleDateString(LOCALE, { ...o, month: 'short', day: 'numeric' });
if (tickMarkType === 3 || tickMarkType === 4)
return d.toLocaleTimeString(LOCALE, { ...o, hour: '2-digit', minute: '2-digit', hour12: false });
return d.toLocaleDateString(LOCALE, o);
},
},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
vertLine: {
width: 1,
color: crosshairColor,
style: LightweightCharts.LineStyle.Dashed,
labelBackgroundColor: labelBg,
},
horzLine: {
width: 1,
color: crosshairColor,
style: LightweightCharts.LineStyle.Dashed,
labelBackgroundColor: labelBg,
},
},
});
let mainSeries;
if (typeof lwChart.addCandlestickSeries === 'function') {
mainSeries = lwChart.addCandlestickSeries({
upColor: '#26a69a',
downColor: '#ef5350',
borderVisible: false,
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
priceFormat: {
type: 'price',
precision: 2,
minMove: 0.01,
},
});
} else {
// Version 5+ syntax
mainSeries = lwChart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: '#26a69a',
downColor: '#ef5350',
borderVisible: false,
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
priceFormat: {
type: 'price',
precision: 2,
minMove: 0.01,
},
});
}
mainSeries.setData(history);
let volumeSeries;
const volumeOptions = {
color: '#26a69a',
priceFormat: { type: 'volume' },
priceScaleId: 'volume_scale',
};
if (typeof lwChart.addHistogramSeries === 'function') {
volumeSeries = lwChart.addHistogramSeries(volumeOptions);
} else {
volumeSeries = lwChart.addSeries(LightweightCharts.HistogramSeries, volumeOptions);
}
volumeSeries.priceScale().applyOptions({
scaleMargins: {
top: 0.82,
bottom: 0,
},
drawTicks: false,
borderVisible: false,
visible: false,
});
const volumeData = history.map((p, index) => {
let color = '#26a69a'; // green for up
if (index > 0 && p.value < history[index - 1].value) {
color = '#ef5350'; // red for down
}
return {
time: p.time,
value: p.volume || 0,
color: color
};
});
if (volumeData.some((d) => d.value > 0)) {
volumeSeries.setData(volumeData);
}
lwChart.subscribeCrosshairMove((param) => {
if (!param || !param.point || !param.time) {
chartTooltip.style.display = 'none';
return;
}
if (!chartContainer || param.point.x < 0 || param.point.y < 0) {
chartTooltip.style.display = 'none';
return;
}
const priceAtCursor = readCrosshairPrice(param, mainSeries);
if (!Number.isFinite(priceAtCursor)) {
chartTooltip.style.display = 'none';
return;
}
const timeLabel = formatCrosshairTime(param.time);
chartTooltip.innerHTML = `${timeLabel}
${usdFormatter.format(priceAtCursor)}
`;
chartTooltip.style.display = 'block';
const containerRect = chartContainer.getBoundingClientRect();
const tooltipWidth = chartTooltip.offsetWidth || 120;
const tooltipHeight = chartTooltip.offsetHeight || 44;
const x = Math.min(
Math.max(6, param.point.x + 12),
containerRect.width - tooltipWidth - 6
);
const y = Math.min(
Math.max(6, param.point.y + 12),
containerRect.height - tooltipHeight - 6
);
chartTooltip.style.left = `${x}px`;
chartTooltip.style.top = `${y}px`;
});
// Dynamic subtitle: explains both functions of the selected timeframe.
const HORIZON_SUBTITLE_MAP = {
'1D': 'Showing last 1 day of price history \u2022 Predicting next session ahead',
'5D': 'Showing last 5 days of price history \u2022 Predicting 5 days ahead',
'1M': 'Showing last 1 month of price history \u2022 Predicting 1 month ahead',
'6M': 'Showing last 6 months of price history \u2022 Predicting 6 months ahead',
'1Y': 'Showing last 1 year of price history \u2022 Predicting 1 year ahead',
'5Y': 'Showing last 5 years of price history \u2022 Predicting 5 years ahead',
};
let chartSubtitleEl = document.getElementById('chart-horizon-subtitle');
if (!chartSubtitleEl) {
chartSubtitleEl = document.createElement('p');
chartSubtitleEl.id = 'chart-horizon-subtitle';
chartSubtitleEl.className = 'chart-timeframe-hint';
chartSubtitleEl.style.textAlign = 'center';
chartSubtitleEl.style.marginTop = '0.4rem';
const chartContainer = document.getElementById('advanced-chart');
if (chartContainer && chartContainer.parentNode) {
chartContainer.parentNode.insertBefore(chartSubtitleEl, chartContainer.nextSibling);
}
}
if (chartSubtitleEl) {
chartSubtitleEl.textContent = HORIZON_SUBTITLE_MAP[currentHorizon] || '';
}
const lastDataPoint = history[history.length - 1];
if (Number.isFinite(predictedPrice) && lastDataPoint) {
const lastTime = lastDataPoint.time;
const isUpForecast = predictedPrice >= lastDataPoint.value;
// Mark the "Today / Historical →" boundary on the main price series.
if (typeof mainSeries.setMarkers === 'function') {
mainSeries.setMarkers([{
time: lastDataPoint.time,
position: 'aboveBar',
color: 'rgba(148, 163, 184, 0.9)',
shape: 'arrowDown',
text: 'Today',
}]);
}
const forecastColor = isUpForecast ? '#06b6d4' : '#f97316';
const trajPoints = Array.isArray(trajectory) && trajectory.length > 0 ? trajectory : [predictedPrice];
const forecastData = [];
const upperPoints = Array.isArray(trajectoryUpper) ? trajectoryUpper : [];
const lowerPoints = Array.isArray(trajectoryLower) ? trajectoryLower : [];
const upperData = [];
const lowerData = [];
let stepSeconds = 24 * 60 * 60;
if (typeof lastTime === 'number' && Number.isFinite(lastTime)) {
if (history.length >= 2) {
const prevTime = history[history.length - 2].time;
if (typeof prevTime === 'number' && Number.isFinite(prevTime) && lastTime > prevTime) {
stepSeconds = Math.max(60, Math.round(lastTime - prevTime));
}
}
}
forecastData.push({ time: lastDataPoint.time, value: lastDataPoint.value });
upperData.push({ time: lastDataPoint.time, value: Number(lastDataPoint.value) });
lowerData.push({ time: lastDataPoint.time, value: Number(lastDataPoint.value) });
const HORIZON_DAYS = {
'1D': 1, '5D': 5, '1M': 21, '6M': 126, '1Y': 252, '5Y': 1260,
};
const totalDays = HORIZON_DAYS[currentHorizon] || 1;
for (let i = 0; i < trajPoints.length; i++) {
const dayOffset = trajPoints.length === 1
? totalDays
: Math.round(((i + 1) / trajPoints.length) * totalDays);
if (typeof lastTime === 'number' && Number.isFinite(lastTime)) {
const timeValue = lastTime + (dayOffset * stepSeconds);
forecastData.push({
time: timeValue,
value: trajPoints[i],
});
const fallbackUpper = Number(trajPoints[i]) * 1.02;
const fallbackLower = Number(trajPoints[i]) * 0.98;
upperData.push({
time: timeValue,
value: Number.isFinite(Number(upperPoints[i])) ? Number(upperPoints[i]) : fallbackUpper,
});
lowerData.push({
time: timeValue,
value: Number.isFinite(Number(lowerPoints[i])) ? Number(lowerPoints[i]) : fallbackLower,
});
} else {
const d = new Date(lastTime);
let added = 0;
while (added < dayOffset) {
d.setDate(d.getDate() + 1);
if (d.getDay() !== 0 && d.getDay() !== 6) added++;
}
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const timeValue = `${y}-${m}-${dd}`;
forecastData.push({ time: timeValue, value: trajPoints[i] });
const fallbackUpper = Number(trajPoints[i]) * 1.02;
const fallbackLower = Number(trajPoints[i]) * 0.98;
upperData.push({
time: timeValue,
value: Number.isFinite(Number(upperPoints[i])) ? Number(upperPoints[i]) : fallbackUpper,
});
lowerData.push({
time: timeValue,
value: Number.isFinite(Number(lowerPoints[i])) ? Number(lowerPoints[i]) : fallbackLower,
});
}
}
let forecastSeries;
const forecastOpts = {
color: forecastColor,
lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.LargeDashed,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 5,
priceLineVisible: false,
lastValueVisible: true,
};
if (typeof lwChart.addLineSeries === 'function') {
forecastSeries = lwChart.addLineSeries(forecastOpts);
} else {
forecastSeries = lwChart.addSeries(LightweightCharts.LineSeries, forecastOpts);
}
forecastSeries.setData(forecastData);
const upperBandOpts = {
color: isUpForecast ? 'rgba(6, 182, 212, 0.25)' : 'rgba(249, 115, 22, 0.25)',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
};
const lowerBandOpts = {
color: isUpForecast ? 'rgba(6, 182, 212, 0.25)' : 'rgba(249, 115, 22, 0.25)',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
};
let upperBandSeries;
let lowerBandSeries;
if (typeof lwChart.addLineSeries === 'function') {
upperBandSeries = lwChart.addLineSeries(upperBandOpts);
lowerBandSeries = lwChart.addLineSeries(lowerBandOpts);
} else {
upperBandSeries = lwChart.addSeries(LightweightCharts.LineSeries, upperBandOpts);
lowerBandSeries = lwChart.addSeries(LightweightCharts.LineSeries, lowerBandOpts);
}
upperBandSeries.setData(upperData);
lowerBandSeries.setData(lowerData);
const lastForecast = forecastData[forecastData.length - 1];
if (lastForecast && typeof forecastSeries.setMarkers === 'function') {
const horizonText = HORIZON_LABELS[currentHorizon] || 'Predicted';
forecastSeries.setMarkers([
{
time: lastForecast.time,
position: isUpForecast ? 'aboveBar' : 'belowBar',
color: forecastColor,
shape: 'circle',
text: `${horizonText}: $${predictedPrice.toFixed(2)}`,
},
]);
}
}
// Extend right offset to give space for the forecast
const HORIZON_OFFSET = {
'1D': 2, '5D': 4, '1M': 6, '6M': 10, '1Y': 12, '5Y': 14,
};
lwChart.applyOptions({
timeScale: { rightOffset: HORIZON_OFFSET[currentHorizon] || 2 },
});
lwChart.timeScale().fitContent();
}
function updateDashboard(data) {
// Meta
resTicker.textContent = data?.meta?.symbol || '??';
const date = new Date(data?.meta?.generated_at);
const modeStr = (data?.meta?.mode || 'live').toUpperCase();
resTime.textContent = `Updated: ${date.toLocaleString(LOCALE)} (${modeStr} MODE)`;
setActiveTimeframe(resolveTimeframeFromMeta(data?.meta || {}));
// Sync horizon state from response
const respHorizon = data?.meta?.risk_horizon || currentHorizon;
setActiveHorizon(respHorizon);
// Prices
const currentPrice = Number(data?.market?.current_price);
const predictedPrice = Number(data?.market?.predicted_price_horizon ?? data?.market?.predicted_price_next_session);
currentPriceEl.textContent = isFinite(currentPrice) ? usdFormatter.format(currentPrice) : 'N/A';
predictedPriceEl.textContent = isFinite(predictedPrice) ? usdFormatter.format(predictedPrice) : 'N/A';
// Trend
const trend = (data?.signals?.trend_label || '').replace(/[^\x20-\x7E]/g, '').trim();
applyTrendBadge(trend);
// Model confidence / accuracy
const modelConf = data?.signals?.model_confidence
?? data?.all_horizons?.[currentHorizon]?.model_confidence;
updateAccuracyDisplay(modelConf);
// Apply contrarian colour to predicted price:
// uptrend 鈫?red (overbought risk), downtrend 鈫?green (opportunity signal)
predictedPriceEl.classList.remove('price-up', 'price-down');
if (trend.includes('UPTREND')) {
predictedPriceEl.classList.add('price-up');
} else if (trend.includes('DOWNTREND')) {
predictedPriceEl.classList.add('price-down');
}
// Investment signal badge
renderInvestmentSignalBadge(data?.signals?.investment_signal || '');
// Make IRIS prediction card hoverable for reasoning.
const priceCard = document.querySelector('.price-card');
if (priceCard) {
priceCard._irisReasoning = data?.signals?.iris_reasoning?.summary || '';
priceCard.classList.add('prediction-result');
priceCard.onmouseenter = () => {
const priceStr = predictedPriceEl.textContent;
const signal = document.getElementById('investment-signal-badge')?.textContent || '';
showReasoningTooltip(priceCard, 'IRIS Model', priceStr, signal, priceCard._irisReasoning);
};
priceCard.onmouseleave = hideReasoningTooltip;
}
// Check Engine Light
engineIndicator.className = 'engine-indicator'; // Reset classes
const lightString = data?.signals?.check_engine_light || '';
if (lightString.includes('GREEN')) {
engineIndicator.classList.add('status-green');
lightStatusText.textContent = "SAFE TO PROCEED";
} else if (lightString.includes('RED')) {
engineIndicator.classList.add('status-red');
lightStatusText.textContent = "RISK DETECTED";
} else {
engineIndicator.classList.add('status-yellow');
lightStatusText.textContent = "NEUTRAL (NOISE)";
}
// Risk Indicator hover explanation.
const riskCard = document.querySelector('.engine-light-card');
if (riskCard) {
const lightStr = String(data?.signals?.check_engine_light || '');
const sentVal = Number(data?.signals?.sentiment_score ?? 0);
const trendStr = String(data?.signals?.trend_label || '').replace(/[^\x20-\x7E]/g, '').trim();
let riskExplanation = '';
if (lightStr.includes('GREEN')) {
riskExplanation =
'GREEN indicates low immediate risk. ' +
`Sentiment is ${sentVal >= 0 ? 'positive or neutral' : 'slightly negative but still within a safe band'} (${sentVal.toFixed(2)}), ` +
`and the trend model shows ${trendStr.toLowerCase() || 'stable movement'}. ` +
'No major red flags were detected in recent news or price action.';
} else if (lightStr.includes('RED')) {
riskExplanation =
'RED indicates elevated risk. ' +
`${sentVal < -0.05 ? `Negative news sentiment (${sentVal.toFixed(2)}) is reducing confidence. ` : ''}` +
`${trendStr.includes('DOWNTREND') ? 'The trend model is projecting downside movement. ' : ''}` +
'Review position size and risk controls. This is a caution signal, not an automatic sell instruction.';
} else {
riskExplanation =
'YELLOW indicates mixed or neutral risk. ' +
`Sentiment is close to neutral (${sentVal.toFixed(2)}), and trend direction is ${trendStr.toLowerCase() || 'unclear'}. ` +
'The model does not currently detect a strong bullish or bearish setup.';
}
riskCard.classList.add('prediction-result');
riskCard.onmouseenter = () => {
const statusText = lightStatusText?.textContent || 'UNKNOWN';
showReasoningTooltip(riskCard, 'Risk Indicator', statusText, '', riskExplanation);
};
riskCard.onmouseleave = hideReasoningTooltip;
}
// Sentiment
const sentiment = Number(data?.signals?.sentiment_score ?? 0);
sentimentScoreEl.textContent = isFinite(sentiment) ? sentiment.toFixed(2) : '0.00';
sentimentScoreEl.className = '';
if (sentiment > 0.05) {
sentimentScoreEl.classList.add('score-positive');
sentimentDescEl.textContent = 'Positive Sentiment';
} else if (sentiment < -0.05) {
sentimentScoreEl.classList.add('score-negative');
sentimentDescEl.textContent = 'Negative Sentiment';
} else {
sentimentScoreEl.classList.add('score-neutral');
sentimentDescEl.textContent = 'Neutral Sentiment';
}
// Sentiment card hover explanation.
const sentCard = document.querySelector('.sentiment-card');
if (sentCard) {
const sentVal = Number(data?.signals?.sentiment_score ?? 0);
const headlineCount = Array.isArray(data?.evidence?.headlines_used) ? data.evidence.headlines_used.length : 0;
const horizonLabel = HORIZON_LABELS[currentHorizon] || '1 Day';
let sentExplanation = '';
if (sentVal > 0.15) {
sentExplanation =
`Strongly positive sentiment (${sentVal.toFixed(2)}). ` +
`Analyzed ${headlineCount} recent headline${headlineCount !== 1 ? 's' : ''} over the ${horizonLabel.toLowerCase()} lookback window. ` +
'Coverage is mostly favorable, such as strong results, upgrades, or positive product and industry developments.';
} else if (sentVal > 0.05) {
sentExplanation =
`Mildly positive sentiment (${sentVal.toFixed(2)}). ` +
`Based on ${headlineCount} headline${headlineCount !== 1 ? 's' : ''} analyzed with FinBERT. ` +
'News flow leans positive, but conviction is not strong.';
} else if (sentVal > -0.05) {
sentExplanation =
`Neutral sentiment (${sentVal.toFixed(2)}). ` +
`FinBERT analyzed ${headlineCount} headline${headlineCount !== 1 ? 's' : ''}. ` +
'Positive and negative signals are roughly balanced, or current headlines are mostly factual.';
} else if (sentVal > -0.15) {
sentExplanation =
`Mildly negative sentiment (${sentVal.toFixed(2)}). ` +
`Based on ${headlineCount} headline${headlineCount !== 1 ? 's' : ''} over the ${horizonLabel.toLowerCase()} window. ` +
'Some cautionary developments are present in the recent news cycle.';
} else {
sentExplanation =
`Strongly negative sentiment (${sentVal.toFixed(2)}). ` +
`Analyzed ${headlineCount} headline${headlineCount !== 1 ? 's' : ''}. ` +
'A large share of recent coverage is bearish and may materially increase near-term risk.';
}
sentCard.classList.add('prediction-result');
sentCard.onmouseenter = () => {
const scoreText = sentimentScoreEl?.textContent || '0.00';
const descText = sentimentDescEl?.textContent || 'Sentiment';
showReasoningTooltip(sentCard, 'Sentiment Analysis', scoreText, descText, sentExplanation);
};
sentCard.onmouseleave = hideReasoningTooltip;
}
// LLM Insights
const llmHorizon = String(data?.meta?.risk_horizon || currentHorizon || '1D').trim().toUpperCase();
const llmData = data?.llm_insights;
if (llmData && typeof llmData === 'object' && Object.keys(llmData).length > 0) {
cachedLlmByHorizon[llmHorizon] = llmData;
_renderLlmInsights(llmData, llmHorizon);
} else {
const cachedLlm = cachedLlmByHorizon[llmHorizon] || cachedLlmByHorizon[currentHorizon];
_renderLlmInsights(cachedLlm || {}, llmHorizon);
}
// Headlines
headlinesList.innerHTML = '';
const headlines = data?.evidence?.headlines_used || [];
if (headlines && headlines.length > 0) {
headlines.forEach((headline) => {
const title = typeof headline === 'string'
? headline.trim()
: String(headline?.title || headline?.text || '').trim();
if (!title) return;
// Enforce: only display headlines with a valid clickable URL
const url = typeof headline === 'string' ? '' : String(headline?.url || '').trim();
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) return;
const publishedAt = typeof headline === 'string' ? '' : String(headline?.published_at || '').trim();
const dateLabel = formatHeadlineDate(publishedAt);
const domain = extractDomain(url);
const li = document.createElement('li');
const category = String(typeof headline === 'string' ? 'financial' : (headline?.category || 'financial')).trim().toLowerCase();
const catClass = category === 'geopolitical' ? ' headline-item--geo'
: category === 'macro' ? ' headline-item--macro'
: '';
li.className = 'headline-item' + catClass;
// Title 鈥?always a clickable link
const titleEl = document.createElement('a');
titleEl.className = 'headline-title';
titleEl.textContent = title;
titleEl.href = url;
titleEl.target = '_blank';
titleEl.rel = 'noopener noreferrer';
// Meta row 鈥?date + dot + source domain
const metaEl = document.createElement('div');
metaEl.className = 'headline-meta';
if (dateLabel) {
const dateSpan = document.createElement('span');
dateSpan.className = 'headline-date';
dateSpan.textContent = dateLabel;
metaEl.appendChild(dateSpan);
}
if (dateLabel && domain) {
const dot = document.createElement('span');
dot.className = 'headline-dot';
metaEl.appendChild(dot);
}
if (domain) {
const srcSpan = document.createElement('span');
srcSpan.className = 'headline-source';
srcSpan.textContent = domain;
metaEl.appendChild(srcSpan);
}
if (category === 'geopolitical' || category === 'macro') {
const tagEl = document.createElement('span');
tagEl.className = 'headline-tag';
tagEl.textContent = category === 'macro' ? 'Macro' : 'Geopolitical';
metaEl.appendChild(tagEl);
}
li.appendChild(titleEl);
if (metaEl.hasChildNodes()) li.appendChild(metaEl);
headlinesList.appendChild(li);
});
}
// Show scroll hint if list overflows its capped height
const hintEl = headlinesList.parentElement?.querySelector('.headlines-scroll-hint');
if (hintEl) {
const overflows = headlinesList.scrollHeight > headlinesList.clientHeight;
hintEl.classList.toggle('visible', overflows);
}
if (!headlinesList.children.length) {
const li = document.createElement('li');
li.className = 'headline-item headline-item--empty';
li.textContent = 'No linked headlines available for this ticker.';
headlinesList.appendChild(li);
}
}
});
/* 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€
Recommended For You 鈥?Stock Recommendations
鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ */
(function initRecommendations() {
'use strict';
const recSection = document.getElementById('recommended-section');
const recScroll = document.getElementById('rec-scroll');
const recSubtitle = document.getElementById('rec-subtitle');
const recPrevBtn = document.getElementById('rec-prev');
const recNextBtn = document.getElementById('rec-next');
if (!recSection || !recScroll) return;
// 鈹€鈹€ Sparkline drawing (lightweight canvas, no library) 鈹€鈹€
function drawSparkline(canvas, dataPoints, isPositive) {
if (!canvas || !dataPoints || dataPoints.length < 2) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const min = Math.min(...dataPoints);
const max = Math.max(...dataPoints);
const range = max - min || 1;
const pad = 2;
const color = isPositive ? '#22c55e' : '#ef4444';
// Area fill
ctx.beginPath();
dataPoints.forEach(function (v, i) {
var x = (i / (dataPoints.length - 1)) * w;
var y = pad + ((max - v) / range) * (h - pad * 2);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, isPositive ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fill();
// Line stroke
ctx.beginPath();
dataPoints.forEach(function (v, i) {
var x = (i / (dataPoints.length - 1)) * w;
var y = pad + ((max - v) / range) * (h - pad * 2);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();
}
// 鈹€鈹€ Show loading skeleton 鈹€鈹€
function showRecSkeleton() {
recScroll.innerHTML = '';
for (var i = 0; i < 5; i++) {
var skel = document.createElement('div');
skel.className = 'rec-card-skeleton';
skel.innerHTML =
'' +
'' +
'' +
'';
recScroll.appendChild(skel);
}
recSection.classList.remove('hidden');
}
// 鈹€鈹€ Build one recommendation card 鈹€鈹€
function buildRecCard(item) {
var pctVal = item.price_change_pct || 0;
var isPos = pctVal > 0;
var isNeg = pctVal < 0;
var dirClass = isPos ? 'rec-positive' : isNeg ? 'rec-negative' : '';
var badgeCls = isPos ? 'rec-badge-positive' : isNeg ? 'rec-badge-negative' : 'rec-badge-neutral';
var chCls = isPos ? 'rec-ch-positive' : isNeg ? 'rec-ch-negative' : 'rec-ch-neutral';
var sign = isPos ? '+' : '';
var card = document.createElement('div');
card.className = 'rec-card ' + dirClass;
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', 'Analyze ' + item.symbol);
card.innerHTML =
'' +
'' + item.symbol + '' +
'' + sign + pctVal.toFixed(2) + '%' +
'
' +
'' + (item.name || item.symbol) + '
' +
'' +
'' +
'$' + (item.current_price || 0).toFixed(2) + '' +
'' + sign + (item.price_change || 0).toFixed(2) + '' +
'
';
// Click -> run analysis for this ticker
function triggerAnalysis() {
var tickerInput = document.getElementById('ticker-input');
if (tickerInput) tickerInput.value = item.symbol;
window.scrollTo({ top: 0, behavior: 'smooth' });
if (typeof window._irisAnalyzeTicker === 'function') {
window._irisAnalyzeTicker(item.symbol);
}
}
card.addEventListener('click', triggerAnalysis);
card.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); triggerAnalysis(); }
});
return card;
}
// 鈹€鈹€ Fetch and render recommendations 鈹€鈹€
function fetchRecommendations(ticker) {
if (!ticker) return;
showRecSkeleton();
if (recSubtitle) recSubtitle.textContent = 'Related stocks based on ' + ticker + '\'s sector';
fetch('/api/related/' + encodeURIComponent(ticker))
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (data) {
if (!data.related || data.related.length === 0) {
recSection.classList.add('hidden');
return;
}
recScroll.innerHTML = '';
data.related.forEach(function (item) {
var card = buildRecCard(item);
recScroll.appendChild(card);
// Draw sparkline after card is in DOM (needs layout dimensions)
setTimeout(function () {
var canvas = card.querySelector('.rec-sparkline canvas');
if (canvas && item.sparkline && item.sparkline.length >= 2) {
drawSparkline(canvas, item.sparkline, (item.price_change_pct || 0) >= 0);
}
}, 50);
});
recSection.classList.remove('hidden');
updateRecNav();
})
.catch(function (err) {
console.warn('Recommendations fetch failed:', err);
recSection.classList.add('hidden');
});
}
// 鈹€鈹€ Scroll navigation 鈹€鈹€
function updateRecNav() {
if (!recPrevBtn || !recNextBtn) return;
recPrevBtn.disabled = recScroll.scrollLeft <= 5;
recNextBtn.disabled = recScroll.scrollLeft + recScroll.clientWidth >= recScroll.scrollWidth - 5;
}
if (recPrevBtn) {
recPrevBtn.addEventListener('click', function () {
recScroll.scrollBy({ left: -200, behavior: 'smooth' });
setTimeout(updateRecNav, 400);
});
}
if (recNextBtn) {
recNextBtn.addEventListener('click', function () {
recScroll.scrollBy({ left: 200, behavior: 'smooth' });
setTimeout(updateRecNav, 400);
});
}
recScroll.addEventListener('scroll', updateRecNav);
// 鈹€鈹€ Expose globally so the main analysis callback can trigger it 鈹€鈹€
window._irisLoadRecommendations = fetchRecommendations;
})();