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
This commit is contained in:
Bortlesboat 2026-03-28 15:28:12 -04:00
parent 80324b57e3
commit 02a3a68267
No known key found for this signature in database
GPG Key ID: A2B96F4BB60D03A1
2 changed files with 275 additions and 15 deletions

204
__tests__/utils.test.ts Normal file
View File

@ -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);
});
});

View File

@ -154,23 +154,79 @@ export const getFormattedTodayDate = () => new Date().toLocaleDateString('en-US'
timeZone: 'UTC', 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<string, string> = {
// 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 { export function formatSymbolForTradingView(symbol: string): string {
if (!symbol) return ''; if (!symbol) return '';
const upperSymbol = symbol.toUpperCase(); const upperSymbol = symbol.toUpperCase();
// Shanghai // Check for known exchange suffixes, trying longer suffixes first
if (upperSymbol.endsWith('.SS')) { // to avoid ".TWO" matching ".TW" prematurely
return `SSE:${upperSymbol.slice(0, -3)}`; const suffixes = Object.keys(FINNHUB_TO_TRADINGVIEW_EXCHANGE)
} .sort((a, b) => b.length - a.length);
// Shenzhen for (const suffix of suffixes) {
if (upperSymbol.endsWith('.SZ')) { if (upperSymbol.endsWith(suffix.toUpperCase())) {
return `SZSE:${upperSymbol.slice(0, -3)}`; const ticker = upperSymbol.slice(0, -suffix.length);
const exchange = FINNHUB_TO_TRADINGVIEW_EXCHANGE[suffix];
return `${exchange}:${ticker}`;
} }
// Hong Kong
if (upperSymbol.endsWith('.HK')) {
return `HKEX:${upperSymbol.slice(0, -3)}`;
} }
return upperSymbol; return upperSymbol;