diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts new file mode 100644 index 0000000..443a6c5 --- /dev/null +++ b/__tests__/utils.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { + formatSymbolForTradingView, + formatMarketCapValue, + formatChangePercent, + getChangeColorClass, + calculateNewsDistribution, +} from '@/lib/utils'; + +describe('formatSymbolForTradingView', () => { + it('returns empty string for empty input', () => { + expect(formatSymbolForTradingView('')).toBe(''); + }); + + it('returns uppercase US symbol as-is', () => { + expect(formatSymbolForTradingView('AAPL')).toBe('AAPL'); + expect(formatSymbolForTradingView('aapl')).toBe('AAPL'); + }); + + // Asia-Pacific exchanges + it('maps Taiwan (.TW) to TWSE prefix', () => { + expect(formatSymbolForTradingView('2330.TW')).toBe('TWSE:2330'); + }); + + it('maps Taiwan OTC (.TWO) to TPEX prefix', () => { + expect(formatSymbolForTradingView('6488.TWO')).toBe('TPEX:6488'); + }); + + it('maps Tokyo (.T) to TSE prefix', () => { + expect(formatSymbolForTradingView('7203.T')).toBe('TSE:7203'); + }); + + it('maps Hong Kong (.HK) to HKEX prefix', () => { + expect(formatSymbolForTradingView('0700.HK')).toBe('HKEX:0700'); + }); + + it('maps Shanghai (.SS) to SSE prefix', () => { + expect(formatSymbolForTradingView('600519.SS')).toBe('SSE:600519'); + }); + + it('maps Shenzhen (.SZ) to SZSE prefix', () => { + expect(formatSymbolForTradingView('000858.SZ')).toBe('SZSE:000858'); + }); + + it('maps Korea (.KS) to KRX prefix', () => { + expect(formatSymbolForTradingView('005930.KS')).toBe('KRX:005930'); + }); + + it('maps India NSE (.NS) to NSE prefix', () => { + expect(formatSymbolForTradingView('RELIANCE.NS')).toBe('NSE:RELIANCE'); + }); + + it('maps India BSE (.BO) to BSE prefix', () => { + expect(formatSymbolForTradingView('RELIANCE.BO')).toBe('BSE:RELIANCE'); + }); + + it('maps Australia (.AX) to ASX prefix', () => { + expect(formatSymbolForTradingView('BHP.AX')).toBe('ASX:BHP'); + }); + + it('maps Singapore (.SI) to SGX prefix', () => { + expect(formatSymbolForTradingView('D05.SI')).toBe('SGX:D05'); + }); + + // European exchanges + it('maps London (.L) to LSE prefix', () => { + expect(formatSymbolForTradingView('SHEL.L')).toBe('LSE:SHEL'); + }); + + it('maps Germany/Xetra (.DE) to XETR prefix', () => { + expect(formatSymbolForTradingView('SAP.DE')).toBe('XETR:SAP'); + }); + + it('maps Paris (.PA) to EURONEXT prefix', () => { + expect(formatSymbolForTradingView('MC.PA')).toBe('EURONEXT:MC'); + }); + + it('maps Amsterdam (.AS) to EURONEXT prefix', () => { + expect(formatSymbolForTradingView('ASML.AS')).toBe('EURONEXT:ASML'); + }); + + it('maps Milan (.MI) to MIL prefix', () => { + expect(formatSymbolForTradingView('ENI.MI')).toBe('MIL:ENI'); + }); + + it('maps Madrid (.MC) to BME prefix', () => { + expect(formatSymbolForTradingView('SAN.MC')).toBe('BME:SAN'); + }); + + it('maps Swiss (.SW) to SIX prefix', () => { + expect(formatSymbolForTradingView('NESN.SW')).toBe('SIX:NESN'); + }); + + it('maps Stockholm (.ST) to OMXSTO prefix', () => { + expect(formatSymbolForTradingView('ERIC-B.ST')).toBe('OMXSTO:ERIC-B'); + }); + + // Americas + it('maps Toronto (.TO) to TSX prefix', () => { + expect(formatSymbolForTradingView('RY.TO')).toBe('TSX:RY'); + }); + + it('maps Brazil (.SA) to BMFBOVESPA prefix', () => { + expect(formatSymbolForTradingView('VALE3.SA')).toBe('BMFBOVESPA:VALE3'); + }); + + // Middle East & Africa + it('maps Tel Aviv (.TA) to TASE prefix', () => { + expect(formatSymbolForTradingView('TEVA.TA')).toBe('TASE:TEVA'); + }); + + it('maps Johannesburg (.JO) to JSE prefix', () => { + expect(formatSymbolForTradingView('NPN.JO')).toBe('JSE:NPN'); + }); + + // Case insensitivity + it('handles lowercase input', () => { + expect(formatSymbolForTradingView('2330.tw')).toBe('TWSE:2330'); + expect(formatSymbolForTradingView('shel.l')).toBe('LSE:SHEL'); + }); + + // Longer suffix matched before shorter (.TWO before .TW) + it('prioritizes longer suffixes over shorter ones', () => { + // .TWO should match before .TW + expect(formatSymbolForTradingView('6488.TWO')).toBe('TPEX:6488'); + // But .TW still works for regular TW symbols + expect(formatSymbolForTradingView('2330.TW')).toBe('TWSE:2330'); + }); +}); + +describe('formatMarketCapValue', () => { + it('returns N/A for invalid inputs', () => { + expect(formatMarketCapValue(0)).toBe('N/A'); + expect(formatMarketCapValue(-100)).toBe('N/A'); + expect(formatMarketCapValue(NaN)).toBe('N/A'); + expect(formatMarketCapValue(Infinity)).toBe('N/A'); + }); + + it('formats trillions', () => { + expect(formatMarketCapValue(3.1e12)).toBe('$3.10T'); + }); + + it('formats billions', () => { + expect(formatMarketCapValue(900e9)).toBe('$900.00B'); + }); + + it('formats millions', () => { + expect(formatMarketCapValue(25e6)).toBe('$25.00M'); + }); + + it('formats sub-million values', () => { + expect(formatMarketCapValue(999999.99)).toBe('$999999.99'); + }); +}); + +describe('formatChangePercent', () => { + it('returns empty string for undefined/null', () => { + expect(formatChangePercent(undefined)).toBe(''); + expect(formatChangePercent(null as any)).toBe(''); + }); + + it('formats positive change with + sign', () => { + expect(formatChangePercent(2.5)).toBe('+2.50%'); + }); + + it('formats negative change', () => { + expect(formatChangePercent(-1.23)).toBe('-1.23%'); + }); + + it('formats zero change', () => { + expect(formatChangePercent(0)).toBe('0.00%'); + }); +}); + +describe('getChangeColorClass', () => { + it('returns gray for zero or undefined', () => { + expect(getChangeColorClass(0)).toBe('text-gray-400'); + expect(getChangeColorClass(undefined)).toBe('text-gray-400'); + }); + + it('returns green for positive', () => { + expect(getChangeColorClass(1.5)).toBe('text-green-500'); + }); + + it('returns red for negative', () => { + expect(getChangeColorClass(-0.5)).toBe('text-red-500'); + }); +}); + +describe('calculateNewsDistribution', () => { + it('returns 3 items per symbol for 1-2 symbols', () => { + expect(calculateNewsDistribution(1).itemsPerSymbol).toBe(3); + expect(calculateNewsDistribution(2).itemsPerSymbol).toBe(3); + }); + + it('returns 2 items per symbol for exactly 3', () => { + expect(calculateNewsDistribution(3).itemsPerSymbol).toBe(2); + }); + + it('returns 1 item per symbol for 4+', () => { + expect(calculateNewsDistribution(5).itemsPerSymbol).toBe(1); + expect(calculateNewsDistribution(10).itemsPerSymbol).toBe(1); + }); +}); diff --git a/lib/utils.ts b/lib/utils.ts index 183dab7..e910ae2 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -25,7 +25,7 @@ export function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -// Formatted string like "$3.10T", "$900.00B", "$25.00M" or "$999,999.99" +// Formatted string like "$3.10T", "$900.00B", "$25.00M" or "$999999.99" export function formatMarketCapValue(marketCapUsd: number): string { if (!Number.isFinite(marketCapUsd) || marketCapUsd <= 0) return 'N/A'; @@ -154,24 +154,80 @@ export const getFormattedTodayDate = () => new Date().toLocaleDateString('en-US' timeZone: 'UTC', }); +/** + * Maps Finnhub exchange suffixes to TradingView exchange prefixes. + * Finnhub symbols use a dot-suffix convention (e.g. "2330.TW"), + * while TradingView uses a colon-prefix convention (e.g. "TWSE:2330"). + */ +const FINNHUB_TO_TRADINGVIEW_EXCHANGE: Record = { + // Asia-Pacific + '.TW': 'TWSE', // Taiwan Stock Exchange + '.TWO': 'TPEX', // Taiwan OTC Exchange + '.T': 'TSE', // Tokyo Stock Exchange + '.HK': 'HKEX', // Hong Kong + '.SS': 'SSE', // Shanghai + '.SZ': 'SZSE', // Shenzhen + '.KS': 'KRX', // Korea Exchange + '.KQ': 'KRX', // KOSDAQ (Korea) + '.SI': 'SGX', // Singapore + '.AX': 'ASX', // Australian Securities Exchange + '.NZ': 'NZX', // New Zealand + '.BO': 'BSE', // Bombay Stock Exchange + '.NS': 'NSE', // National Stock Exchange of India + '.BK': 'SET', // Stock Exchange of Thailand + '.JK': 'IDX', // Indonesia Stock Exchange + '.KL': 'MYX', // Bursa Malaysia + + // Europe + '.L': 'LSE', // London Stock Exchange + '.IL': 'LSE', // London (IOB international) + '.DE': 'XETR', // Deutsche Boerse (Xetra) + '.F': 'FWB', // Frankfurt Stock Exchange + '.PA': 'EURONEXT', // Euronext Paris + '.AS': 'EURONEXT', // Euronext Amsterdam + '.BR': 'EURONEXT', // Euronext Brussels + '.LS': 'EURONEXT', // Euronext Lisbon + '.MI': 'MIL', // Borsa Italiana (Milan) + '.MC': 'BME', // Bolsa de Madrid + '.ST': 'OMXSTO', // Stockholm (Nasdaq Nordic) + '.HE': 'OMXHEX', // Helsinki (Nasdaq Nordic) + '.CO': 'OMXCOP', // Copenhagen (Nasdaq Nordic) + '.OL': 'OSL', // Oslo Stock Exchange + '.SW': 'SIX', // SIX Swiss Exchange + '.VI': 'VIE', // Vienna Stock Exchange + '.WA': 'GPW', // Warsaw Stock Exchange + '.PR': 'PSE', // Prague Stock Exchange + '.AT': 'ATHEX', // Athens Stock Exchange + '.IS': 'BIST', // Borsa Istanbul + + // Americas + '.TO': 'TSX', // Toronto Stock Exchange + '.V': 'TSXV', // TSX Venture Exchange + '.SA': 'BMFBOVESPA', // B3 (Brazil) + '.MX': 'BMV', // Bolsa Mexicana de Valores + '.BA': 'BCBA', // Buenos Aires Stock Exchange + + // Middle East & Africa + '.TA': 'TASE', // Tel Aviv Stock Exchange + '.JO': 'JSE', // Johannesburg Stock Exchange +}; + export function formatSymbolForTradingView(symbol: string): string { if (!symbol) return ''; const upperSymbol = symbol.toUpperCase(); - - // Shanghai - if (upperSymbol.endsWith('.SS')) { - return `SSE:${upperSymbol.slice(0, -3)}`; + + // Check for known exchange suffixes, trying longer suffixes first + // to avoid ".TWO" matching ".TW" prematurely + const suffixes = Object.keys(FINNHUB_TO_TRADINGVIEW_EXCHANGE) + .sort((a, b) => b.length - a.length); + + for (const suffix of suffixes) { + if (upperSymbol.endsWith(suffix.toUpperCase())) { + const ticker = upperSymbol.slice(0, -suffix.length); + const exchange = FINNHUB_TO_TRADINGVIEW_EXCHANGE[suffix]; + return `${exchange}:${ticker}`; + } } - - // Shenzhen - if (upperSymbol.endsWith('.SZ')) { - return `SZSE:${upperSymbol.slice(0, -3)}`; - } - - // Hong Kong - if (upperSymbol.endsWith('.HK')) { - return `HKEX:${upperSymbol.slice(0, -3)}`; - } - + return upperSymbol; } \ No newline at end of file