/** * Shared pure utility functions used across screener, portfolio, and safe-buys pages. * All functions are stateless and framework-agnostic. */ // ── Signal ordering ─────────────────────────────────────────────────────────── export type Signal = | '✅ Strong Buy' | '⚡ Momentum' | '🔄 Neutral' | '⚠️ Speculation' | '❌ Avoid'; const SIGNAL_ORDER: Record = { '✅ Strong Buy': 0, '⚡ Momentum': 1, '🔄 Neutral': 2, '⚠️ Speculation': 3, '❌ Avoid': 4, }; /** Returns sort order for a signal string (lower = stronger). Unknown signals → 5. */ export function sigOrd(signal: string | null | undefined): number { return SIGNAL_ORDER[signal ?? ''] ?? 5; } /** Sorts an array of screener result rows by signal strength (strongest first). */ export function sorted(arr: T[]): T[] { return [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal)); } // ── Verdict label helpers ───────────────────────────────────────────────────── /** * Converts a long verdict label into a short display string. * e.g. "🟢 BUY (High Conviction)" → "Strong" */ export function verdictShort(label: string | null | undefined): string { if (!label) return '—'; if (label.includes('High Conviction')) return 'Strong'; if (label.includes('Speculative')) return 'Speculative'; if (label.includes('BUY')) return 'Buy'; if (label.includes('Efficient')) return 'Efficient'; if (label.includes('Attractive')) return 'Attractive'; if (label.includes('Neutral')) return 'Hold'; if (label.includes('REJECT')) return 'Reject'; if (label.includes('Avoid')) return 'Avoid'; return label.replace(/[🟢🟡🔴]/u, '').trim(); } /** * Returns a CSS colour class ('green' | 'yellow' | 'red') based on * the emoji prefix of a verdict label. */ export function vClass(label: string | null | undefined): 'green' | 'yellow' | 'red' { if (label?.startsWith('🟢')) return 'green'; if (label?.startsWith('🟡')) return 'yellow'; return 'red'; } // ── Number formatters ───────────────────────────────────────────────────────── /** Formats a P/E ratio — e.g. 22.5 → "22.5x", null → "—" */ export function fmtPE(v: number | null | undefined): string { return v != null ? v + 'x' : '—'; } /** Full currency format — e.g. 1234.5 → "$1,234.50" */ export function fmt(n: number | null | undefined): string { return n != null ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n) : '—'; } /** Compact currency format (no cents) — e.g. 1234.5 → "$1,235" */ export function fmtShort(n: number | null | undefined): string { return n != null ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }).format(n) : '—'; } /** * Returns 'green' for non-negative G/L percentage, 'red' otherwise. * Accepts string (e.g. "12.5") or number. */ export function glClass(pct: string | number | null | undefined): 'green' | 'red' { return parseFloat(String(pct ?? 0)) >= 0 ? 'green' : 'red'; } /** * Returns a CSS colour class for a portfolio advice string based on its emoji prefix. * 🟢 → 'green', 🟡 → 'yellow', 🟠 → 'orange', 🔴 → 'red', else 'gray'. */ export function advClass(advice: string | null | undefined): 'green' | 'yellow' | 'orange' | 'red' | 'gray' { if (advice?.includes('🟢')) return 'green'; if (advice?.includes('🟡')) return 'yellow'; if (advice?.includes('🟠')) return 'orange'; if (advice?.includes('🔴')) return 'red'; return 'gray'; }