From 02a3a68267946b57ceed3f6be3856dafcfd7f502 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Sat, 28 Mar 2026 15:28:12 -0400 Subject: [PATCH 1/2] fix: add international exchange mappings for TradingView widgets Expand formatSymbolForTradingView to map 40+ Finnhub exchange suffixes to their TradingView prefix equivalents. Previously only .SS, .SZ, and .HK were handled, causing international symbols like 2330.TW to fail on the stock details page. Add comprehensive test suite for utility functions including formatSymbolForTradingView, formatMarketCapValue, formatChangePercent, getChangeColorClass, and calculateNewsDistribution. Closes #24 --- __tests__/utils.test.ts | 204 ++++++++++++++++++++++++++++++++++++++++ lib/utils.ts | 86 ++++++++++++++--- 2 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 __tests__/utils.test.ts 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..6064925 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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 From 2d7c30eafbdef566644a6d64540ba05db070a899 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Sun, 29 Mar 2026 10:00:10 -0400 Subject: [PATCH 2/2] fix: correct docstring for formatMarketCapValue sub-million format --- lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils.ts b/lib/utils.ts index 6064925..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';