Merge pull request #65 from Bortlesboat/fix/international-stock-symbols
fix: support 40+ international exchanges in TradingView symbol mapping
This commit is contained in:
commit
fe59884827
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
lib/utils.ts
88
lib/utils.ts
|
|
@ -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,24 +154,80 @@ 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);
|
||||||
|
|
||||||
|
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;
|
return upperSymbol;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue