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:
parent
80324b57e3
commit
02a3a68267
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
lib/utils.ts
80
lib/utils.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue