Merge pull request #65 from Bortlesboat/fix/international-stock-symbols

fix: support 40+ international exchanges in TradingView symbol mapping
This commit is contained in:
Algorithm 2026-03-30 11:41:57 +05:30 committed by GitHub
commit fe59884827
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 276 additions and 16 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

@ -25,7 +25,7 @@ export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); 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 { export function formatMarketCapValue(marketCapUsd: number): string {
if (!Number.isFinite(marketCapUsd) || marketCapUsd <= 0) return 'N/A'; if (!Number.isFinite(marketCapUsd) || marketCapUsd <= 0) return 'N/A';
@ -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;