| import { Panel } from './Panel'; |
| import { t } from '@/services/i18n'; |
| import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; |
| import { getHydratedData } from '@/services/bootstrap'; |
| import { toApiUrl } from '@/services/runtime'; |
|
|
| interface WeekData { |
| date: string; |
| bullish: number; |
| bearish: number; |
| neutral: number; |
| spread: number; |
| } |
|
|
| interface AAIIData { |
| seededAt: string; |
| fallback?: boolean; |
| source: string; |
| latest: WeekData; |
| previous: WeekData | null; |
| avg8w: { bullish: number; bearish: number; neutral: number; spread: number } | null; |
| historicalAvg: { bullish: number; bearish: number; neutral: number }; |
| extremes: { spreadBelow20: number; bullishAbove50: number; bearishAbove50: number }; |
| weeks: WeekData[]; |
| } |
|
|
| function spreadColor(spread: number): string { |
| if (spread <= -20) return '#e74c3c'; |
| if (spread <= -10) return '#e67e22'; |
| if (spread < 0) return '#f39c12'; |
| if (spread < 10) return '#95a5a6'; |
| if (spread < 20) return '#27ae60'; |
| return '#2ecc71'; |
| } |
|
|
| function sentimentLabel(spread: number): string { |
| if (spread <= -20) return 'Extreme Bearish'; |
| if (spread <= -10) return 'Bearish'; |
| if (spread < 0) return 'Mildly Bearish'; |
| if (spread < 10) return 'Neutral'; |
| if (spread < 20) return 'Bullish'; |
| return 'Extreme Bullish'; |
| } |
|
|
| function renderBar(pct: number, color: string, label: string, value: string): string { |
| return `<div style="margin:4px 0"> |
| <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-dim);margin-bottom:2px"> |
| <span>${escapeHtml(label)}</span> |
| <span style="color:${color};font-weight:600">${escapeHtml(value)}</span> |
| </div> |
| <div style="height:6px;background:rgba(255,255,255,0.08);border-radius:3px"> |
| <div style="width:${Math.min(pct, 100)}%;height:100%;background:${color};border-radius:3px;transition:width 0.3s"></div> |
| </div> |
| </div>`; |
| } |
|
|
| function renderSpreadBar(spread: number): string { |
| const color = spreadColor(spread); |
| const clamped = Math.max(-60, Math.min(60, spread)); |
| const center = 50; |
| const barWidth = Math.abs(clamped) / 60 * 50; |
| const leftPct = clamped >= 0 ? center : center - barWidth; |
|
|
| return `<div style="margin:8px 0"> |
| <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-dim);margin-bottom:3px"> |
| <span>Bull-Bear Spread</span> |
| <span style="color:${color};font-weight:700">${clamped >= 0 ? '+' : ''}${spread.toFixed(1)}%</span> |
| </div> |
| <div style="position:relative;height:10px;background:rgba(255,255,255,0.06);border-radius:4px"> |
| <div style="position:absolute;top:0;bottom:0;left:50%;width:1px;background:rgba(255,255,255,0.2)"></div> |
| <div style="position:absolute;top:0;bottom:0;left:${leftPct.toFixed(1)}%;width:${barWidth.toFixed(1)}%;background:${color};border-radius:3px;transition:all 0.3s"></div> |
| </div> |
| <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);margin-top:2px"> |
| <span>Bearish</span> |
| <span>Bullish</span> |
| </div> |
| </div>`; |
| } |
|
|
| function renderSparkChart(weeks: WeekData[]): string { |
| if (weeks.length < 2) return ''; |
| const data = [...weeks].reverse(); |
| const W = 280, H = 60, PAD = 4; |
| const spreads = data.map(w => w.spread); |
| const maxAbs = Math.max(Math.abs(Math.min(...spreads)), Math.abs(Math.max(...spreads)), 20); |
| const stepX = (W - PAD * 2) / (data.length - 1); |
| const midY = H / 2; |
| const scaleY = (midY - PAD) / maxAbs; |
|
|
| const points = data.map((w, i) => { |
| const x = (PAD + i * stepX).toFixed(1); |
| const y = (midY - w.spread * scaleY).toFixed(1); |
| return `${x},${y}`; |
| }); |
| const polyline = points.join(' '); |
|
|
| const bars = data.map((w, i) => { |
| const x = PAD + i * stepX - 1; |
| const barH = Math.abs(w.spread) * scaleY; |
| const y = w.spread >= 0 ? midY - barH : midY; |
| const fill = w.spread >= 0 ? 'rgba(39,174,96,0.25)' : 'rgba(231,76,60,0.25)'; |
| return `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="2" height="${barH.toFixed(1)}" fill="${fill}" rx="0.5"/>`; |
| }).join(''); |
|
|
| const zeroLine = `<line x1="${PAD}" y1="${midY}" x2="${W - PAD}" y2="${midY}" stroke="rgba(255,255,255,0.15)" stroke-width="0.5" stroke-dasharray="3,3"/>`; |
| const contrarian = midY + 20 * scaleY; |
| const contrarianLine = `<line x1="${PAD}" y1="${contrarian.toFixed(1)}" x2="${W - PAD}" y2="${contrarian.toFixed(1)}" stroke="rgba(231,76,60,0.3)" stroke-width="0.5" stroke-dasharray="2,4"/>`; |
| const contrarianLabel = `<text x="${W - PAD}" y="${(contrarian - 2).toFixed(1)}" text-anchor="end" font-size="7" fill="rgba(231,76,60,0.5)" font-family="system-ui,sans-serif">-20 buy signal</text>`; |
|
|
| return `<div style="margin:8px 0"> |
| <div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:4px">52-Week Spread History</div> |
| <svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" style="display:block"> |
| ${bars} |
| ${zeroLine} |
| ${contrarianLine} |
| ${contrarianLabel} |
| <polyline points="${polyline}" fill="none" stroke="rgba(255,255,255,0.6)" stroke-width="1.2" stroke-linejoin="round"/> |
| </svg> |
| </div>`; |
| } |
|
|
| export class AAIISentimentPanel extends Panel { |
| private data: AAIIData | null = null; |
|
|
| constructor() { |
| super({ |
| id: 'aaii-sentiment', |
| title: 'AAII Investor Sentiment', |
| showCount: false, |
| infoTooltip: 'Weekly AAII survey: individual investors report 6-month market outlook as bullish, neutral, or bearish. Spread below -20 is a historical contrarian buy signal.', |
| }); |
| } |
|
|
| public async fetchData(): Promise<boolean> { |
| |
| |
| |
| const hydrated = getHydratedData('aaiiSentiment') as AAIIData | undefined; |
| if (hydrated?.latest) { |
| this.data = hydrated; |
| this.renderPanel(); |
| return true; |
| } |
| |
| |
| try { |
| const resp = await fetch(toApiUrl('/api/bootstrap?keys=aaiiSentiment'), { |
| signal: AbortSignal.timeout(5_000), |
| }); |
| if (resp.ok) { |
| const { data } = (await resp.json()) as { data: { aaiiSentiment?: AAIIData } }; |
| if (data.aaiiSentiment?.latest) { |
| this.data = data.aaiiSentiment; |
| this.renderPanel(); |
| return true; |
| } |
| } |
| } catch { } |
| |
| |
| this.showError('AAII sentiment data unavailable', () => { void this.fetchData(); }, 300); |
| return false; |
| } |
|
|
| private renderPanel(): void { |
| if (!this.data?.latest) { |
| this.showError(t('common.noDataShort'), () => void this.fetchData()); |
| return; |
| } |
|
|
| const d = this.data; |
| const { latest, previous, avg8w, historicalAvg, extremes, weeks } = d; |
| const color = spreadColor(latest.spread); |
| const label = sentimentLabel(latest.spread); |
|
|
| const prevSpread = previous?.spread; |
| const spreadDelta = prevSpread != null ? latest.spread - prevSpread : null; |
| const deltaStr = spreadDelta != null |
| ? `<span style="color:${spreadDelta >= 0 ? '#2ecc71' : '#e74c3c'};font-size:10px;margin-left:4px">${spreadDelta >= 0 ? '+' : ''}${spreadDelta.toFixed(1)} vs prev</span>` |
| : ''; |
|
|
| const contrarianSignal = latest.spread <= -20 |
| ? `<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;margin:8px 0;border-radius:4px;border:1px solid #2ecc71;background:rgba(46,204,113,0.08);font-size:10px;color:#2ecc71"> |
| ⓘ Contrarian buy signal active: spread at ${latest.spread.toFixed(1)}% (threshold: -20%) |
| </div>` |
| : latest.bearish >= 50 |
| ? `<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;margin:8px 0;border-radius:4px;border:1px solid #e67e22;background:rgba(230,126,34,0.08);font-size:10px;color:#e67e22"> |
| ⚠ Extreme bearish reading: ${latest.bearish.toFixed(1)}% bearish (avg: ${historicalAvg.bearish}%) |
| </div>` |
| : ''; |
|
|
| const avgSection = avg8w ? ` |
| <div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.06)"> |
| <div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:4px">8-Week Moving Average</div> |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;text-align:center"> |
| <div><div style="font-size:14px;font-weight:600;color:#2ecc71">${avg8w.bullish}%</div><div style="font-size:9px;color:var(--text-dim)">Bull</div></div> |
| <div><div style="font-size:14px;font-weight:600;color:#95a5a6">${avg8w.neutral}%</div><div style="font-size:9px;color:var(--text-dim)">Neutral</div></div> |
| <div><div style="font-size:14px;font-weight:600;color:#e74c3c">${avg8w.bearish}%</div><div style="font-size:9px;color:var(--text-dim)">Bear</div></div> |
| </div> |
| </div>` : ''; |
|
|
| const extremeSection = (extremes.spreadBelow20 > 0 || extremes.bearishAbove50 > 0) |
| ? `<div style="margin-top:6px;font-size:10px;color:var(--text-dim)"> |
| 52w extremes: ${extremes.spreadBelow20} contrarian signals, ${extremes.bearishAbove50} extreme bear, ${extremes.bullishAbove50} extreme bull |
| </div>` : ''; |
|
|
| const fallbackBadge = d.fallback |
| ? '<span style="display:inline-block;padding:1px 5px;border-radius:3px;background:rgba(230,126,34,0.15);color:#e67e22;font-size:9px;margin-left:4px">(fallback data)</span>' |
| : ''; |
| const dateStr = latest.date ? `<div style="font-size:9px;color:var(--text-dim);text-align:right;margin-top:4px">Survey: ${escapeHtml(latest.date)}${d.source !== 'xls' ? ` (${escapeHtml(d.source)})` : ''}${fallbackBadge}</div>` : ''; |
|
|
| const html = ` |
| <div style="padding:12px 14px"> |
| <div style="text-align:center;margin-bottom:8px"> |
| <div style="font-size:11px;font-weight:600;color:${color};letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(label)}</div> |
| ${deltaStr ? `<div style="margin-top:2px">${deltaStr}</div>` : ''} |
| </div> |
| |
| ${contrarianSignal} |
| |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;text-align:center;padding:8px;background:rgba(255,255,255,0.03);border-radius:6px;margin-bottom:8px"> |
| <div> |
| <div style="font-size:22px;font-weight:700;color:#2ecc71">${latest.bullish.toFixed(1)}%</div> |
| <div style="font-size:10px;color:var(--text-dim)">Bullish</div> |
| <div style="font-size:9px;color:var(--text-dim)">avg ${historicalAvg.bullish}%</div> |
| </div> |
| <div> |
| <div style="font-size:22px;font-weight:700;color:#95a5a6">${latest.neutral.toFixed(1)}%</div> |
| <div style="font-size:10px;color:var(--text-dim)">Neutral</div> |
| <div style="font-size:9px;color:var(--text-dim)">avg ${historicalAvg.neutral}%</div> |
| </div> |
| <div> |
| <div style="font-size:22px;font-weight:700;color:#e74c3c">${latest.bearish.toFixed(1)}%</div> |
| <div style="font-size:10px;color:var(--text-dim)">Bearish</div> |
| <div style="font-size:9px;color:var(--text-dim)">avg ${historicalAvg.bearish}%</div> |
| </div> |
| </div> |
| |
| ${renderBar(latest.bullish, '#2ecc71', 'Bullish', `${latest.bullish.toFixed(1)}%`)} |
| ${renderBar(latest.neutral, '#95a5a6', 'Neutral', `${latest.neutral.toFixed(1)}%`)} |
| ${renderBar(latest.bearish, '#e74c3c', 'Bearish', `${latest.bearish.toFixed(1)}%`)} |
| |
| ${renderSpreadBar(latest.spread)} |
| |
| ${renderSparkChart(weeks)} |
| |
| ${avgSection} |
| ${extremeSection} |
| ${dateStr} |
| </div>`; |
|
|
| this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); |
| } |
| } |
|
|