diff --git a/MARKET_SUPPORT.md b/MARKET_SUPPORT.md index 5400bc6..db5fb83 100644 --- a/MARKET_SUPPORT.md +++ b/MARKET_SUPPORT.md @@ -1,153 +1,26 @@ -# ๐ŸŒ Market Support & Limitations +# Market support -OpenStock supports stocks from multiple exchanges worldwide, but there are important limitations to be aware of based on our data providers (Finnhub & TradingView). +What OpenStock can show depends on two free data sources. This table was tested against both on the free plans (September 2026). Paid Finnhub or TradingView plans unlock more. -## โœ… Fully Supported Markets +| Market | Charts and widgets (TradingView embeds) | Our quotes, watchlist prices, alerts (Finnhub free) | +|---|---|---| +| ๐Ÿ‡บ๐Ÿ‡ธ United States (NYSE, Nasdaq, AMEX) | Live | Yes | +| Crypto (Binance pairs, e.g. `BINANCE:BTCUSDT`) | Live | Yes | +| Forex (e.g. `FX:EURUSD`) | Live | No | +| ๐Ÿ‡จ๐Ÿ‡ฆ Canada (TSX) | Delayed | No | +| ๐Ÿ‡ฆ๐Ÿ‡บ Australia (ASX) | Delayed | No | +| ๐Ÿ‡ฎ๐Ÿ‡ณ India, through BSE (`.BO`; `.NS` symbols are charted on BSE) | End of day | No | +| ๐Ÿ‡ฉ๐Ÿ‡ช Germany (Xetra) | End of day | No | +| India NSE, London (LSE), Tokyo (TSE), Hong Kong (HKEX) | Blocked in embeds | No | +| Index tickers such as NIFTY, FTSE 100, DAX, Nikkei 225, Hang Seng | Blocked in embeds | No | -### Americas -- **US**: NASDAQ, NYSE, AMEX (symbols like `AAPL`, `MSFT`) -- **Canada**: TSX (symbols like `RY.TO`) -- **Brazil**: BMFBOVESPA (symbols like `VALE3.SA`) -- **Mexico**: BMV (symbols like `ASUR.MX`) -- **Argentina**: BCBA (symbols like `BMA.BA`) +## What that means in the app -### Europe -- **UK**: LSE (symbols like `BARC.L`) -- **France**: EURONEXT (symbols like `AIR.PA`) -- **Germany**: XETRA (symbols like `SAP.DE`) -- **Sweden**: OMXSTO (symbols like `ERIC-B.ST`) -- **Spain**: BMAD (symbols like `REPSOL.MC`) -- **Italy**: EURONEXT Milan (symbols like `ENI.MI`) -- **Belgium**: EURONEXT (symbols like `GIB.BR`) -- **Denmark**: OMXCOPENHAGEN (symbols like `ISH.CO`) -- **Finland**: OMXHEX (symbols like `NOKIA.HE`) -- **Greece**: ATHEX (symbols like `ETE.ATH`) -- **Ireland**: ISE (symbols like `RY.IR`) -- **Netherlands**: EURONEXT (symbols like `ING.AS`) -- **Norway**: OMXOSLO (symbols like `EQNR.OL`) -- **Poland**: WSE (symbols like `PZU.WA`) -- **Portugal**: EURONEXT (symbols like `BCP.LI`) -- **Switzerland**: SIX (symbols like `NESN.SW`) +- **Dashboard:** pick a market (US, India, Germany, Canada, Australia, Crypto, Forex). US and crypto tiles use our own cached quotes; the others use TradingView quote tiles. +- **Stock pages:** symbols Finnhub can price get the full header (live price, day range, market cap). Others get TradingView's quote panel. +- **Alerts:** US stocks and crypto only, because the alert checker needs a quote source it can call every five minutes. +- **Search:** Finnhub search returns listings worldwide; blocked exchanges will show TradingView's "only available on TradingView" message in charts. -### Asia-Pacific -- **Taiwan**: TWSE (symbols like `2330.TW`), TPEX (symbols like `6488.TWO`) -- **Hong Kong**: HKEX (symbols like `0700.HK`) -- **Japan**: TSE (symbols like `7203.T`) -- **South Korea**: KRX (symbols like `005930.KS`), KOSDAQ (symbols like `010000.KQ`) -- **Singapore**: SGX (symbols like `U11.SI`) -- **Australia**: ASX (symbols like `CBA.AX`) -- **New Zealand**: NZX (symbols like `FBU.NZ`) -- **India**: NSE (symbols like `INFY.NS`), BSE (symbols like `INFY.BO`) -- **Thailand**: SET (symbols like `ADVANC.BK`) -- **Malaysia**: KLSE (symbols like `1023.KL`) -- **Philippines**: PSE (symbols like `JFC.PH`) -- **Indonesia**: IDX (symbols like `BBCA.JK`) +## Adding a market -### Middle East & Africa -- **Israel**: TASE (symbols like `TEVA.TA`) -- **South Africa**: JSE (symbols like `NPN.JO`) -- **Saudi Arabia**: TASI (limited support) -- **UAE**: ADX (limited support) - -## โš ๏ธ Known Limitations - -### TradingView Widget Limitations - -TradingView's free tier embeddable widgets have several restrictions: - -1. **International Markets**: Some symbols, especially from emerging markets (India NSE, Vietnam, etc.), may show: - - "This symbol is only available on TradingView" error - - Missing charts or company profile data - - Empty technical analysis indicators - -2. **Affected Markets**: - - India (NSE/BSE): Free tier support is limited - - Vietnam, Philippines, Indonesia: Partial or no support - - Emerging market stocks: Often require paid subscription - -3. **Why This Happens**: - - TradingView's free widget tier has limited symbol availability - - Some exchanges require commercial licensing - - High-volume markets get priority in free tier - -### Finnhub API Limitations - -1. **Free Tier**: - - Supports basic quote and company data for most exchanges - - Real-time data delayed by 15+ minutes for non-US stocks - - Rate limited to 60 API calls per minute - - No access to historical minute-level bars - -2. **Market-Specific**: - - India NSE/BSE: Available but with delays - - Chinese A-shares: Not available in free tier - - Forex: Not available - - Cryptocurrencies: Not available - -## ๐Ÿ”ง Troubleshooting - -### "This symbol is only available on TradingView" - -**What this means**: TradingView's embedded widgets don't support this symbol. - -**What you can still do**: -- โœ… Search for the stock using Finnhub data -- โœ… View company profile from Finnhub -- โœ… Add to watchlist (data updates available) -- โœ… See market news from Finnhub -- โŒ View interactive TradingView charts -- โŒ See technical analysis indicators from TradingView - -**Solutions**: -1. **For Personal Use**: Upgrade to Finnhub/TradingView paid plans -2. **For Self-Hosted Deployments**: - - Upgrade API keys in your `.env` file - - Consider alternative chart libraries (e.g., Lightweight Charts, Chart.js) - -### Charts Don't Load - -**Possible causes**: -- Symbol not supported on TradingView -- Network connectivity issue -- TradingView API rate limiting - -**Troubleshooting**: -1. Check browser console for errors (F12) -2. Verify symbol exists on Finnhub search -3. Try a US stock (e.g., AAPL) to confirm basic functionality -4. Check your internet connection - -## ๐Ÿš€ Future Improvements - -The OpenStock community is working on: - -- [ ] Fallback chart libraries for unsupported symbols -- [ ] Market availability checker before displaying widgets -- [ ] Alternative data sources for emerging markets -- [ ] Forex support -- [ ] Cryptocurrency support -- [ ] Custom indicators and drawing tools -- [ ] Paper trading features - -## ๐Ÿ’ก Contributing - -If you discover: -- A market that should be supported -- An exchange with incorrect symbol mapping -- Alternative data providers we should consider - -Please [open an issue](https://github.com/Open-Dev-Society/OpenStock/issues) with: -- Exchange name and country -- Example stock symbols -- Expected vs. actual behavior -- Links to Finnhub/TradingView documentation - -## ๐Ÿ“š References - -- [Finnhub API Documentation](https://finnhub.io/docs/api) -- [TradingView Widget Documentation](https://www.tradingview.com/pine-script-docs/) -- [Supported Finnhub Exchanges](https://finnhub.io/docs/api/symbol-lookup) - ---- - -**Disclaimer**: Nothing here is financial advice. Market data availability depends on provider terms and your subscription tier. Always verify current data before making investment decisions. OpenStock is community-built and not a brokerage. \ No newline at end of file +Add an entry to `lib/markets.ts` with symbols from an exchange that renders in TradingView embeds (open one symbol in a quote widget first). To price and alert on more exchanges, you need a data plan that covers them. diff --git a/__tests__/market-clock.test.ts b/__tests__/market-clock.test.ts new file mode 100644 index 0000000..240042f --- /dev/null +++ b/__tests__/market-clock.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { formatSpan, sessionOf } from '@/lib/market-session'; + +const at = (h: number, m = 0) => h * 60 + m; + +describe('NYSE session', () => { + it('knows each weekday phase', () => { + expect(sessionOf(2, at(3)).phase).toBe('closed'); + expect(sessionOf(2, at(4)).phase).toBe('pre'); + expect(sessionOf(2, at(9, 30)).phase).toBe('open'); + expect(sessionOf(2, at(15, 59)).phase).toBe('open'); + expect(sessionOf(2, at(16)).phase).toBe('after'); + expect(sessionOf(2, at(20)).phase).toBe('closed'); + }); + + it('counts down to the right open', () => { + expect(sessionOf(2, at(9)).line).toBe('Pre-market ยท opens in 30m'); + expect(sessionOf(2, at(14)).line).toBe('Market open ยท closes in 2h 00m'); + // Friday night and Saturday wait for Monday 9:30 + expect(sessionOf(5, at(21)).line).toBe('Market closed ยท opens in 2d 12h'); + expect(sessionOf(6, at(12)).line).toBe('Market closed ยท opens in 1d 21h'); + }); + + it('formats spans', () => { + expect(formatSpan(45)).toBe('45m'); + expect(formatSpan(125)).toBe('2h 05m'); + }); +}); + +import { getMarket, hasFinnhubQuotes, isMarketOpen } from '@/lib/markets'; + +describe('markets', () => { + it('knows which symbols Finnhub free can price', () => { + expect(hasFinnhubQuotes('AAPL')).toBe(true); + expect(hasFinnhubQuotes('BRK.B')).toBe(true); + expect(hasFinnhubQuotes('BINANCE:BTCUSDT')).toBe(true); + expect(hasFinnhubQuotes('RELIANCE.NS')).toBe(false); + expect(hasFinnhubQuotes('BARC.L')).toBe(false); + expect(hasFinnhubQuotes('7203.T')).toBe(false); + expect(hasFinnhubQuotes('FX:EURUSD')).toBe(false); + }); + + it('opens each market in its own time zone', () => { + // Wed 2026-09-23 10:00 in Mumbai = 04:30 UTC + expect(isMarketOpen(getMarket('in'), new Date('2026-09-23T04:30:00Z'))).toBe(true); + expect(isMarketOpen(getMarket('us'), new Date('2026-09-23T04:30:00Z'))).toBe(false); + // Saturday: stocks and FX closed, crypto open + expect(isMarketOpen(getMarket('de'), new Date('2026-09-26T10:00:00Z'))).toBe(false); + expect(isMarketOpen(getMarket('fx'), new Date('2026-09-26T10:00:00Z'))).toBe(false); + expect(isMarketOpen(getMarket('crypto'), new Date('2026-09-26T10:00:00Z'))).toBe(true); + // FX reopens Sunday 17:00 New York (21:00 UTC in September) + expect(isMarketOpen(getMarket('fx'), new Date('2026-09-27T21:30:00Z'))).toBe(true); + }); +}); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 443a6c5..58ee746 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -46,8 +46,8 @@ describe('formatSymbolForTradingView', () => { expect(formatSymbolForTradingView('005930.KS')).toBe('KRX:005930'); }); - it('maps India NSE (.NS) to NSE prefix', () => { - expect(formatSymbolForTradingView('RELIANCE.NS')).toBe('NSE:RELIANCE'); + it('charts India NSE (.NS) listings on BSE, since NSE is blocked in free TradingView embeds', () => { + expect(formatSymbolForTradingView('RELIANCE.NS')).toBe('BSE:RELIANCE'); }); it('maps India BSE (.BO) to BSE prefix', () => { diff --git a/app/(root)/dashboard/page.tsx b/app/(root)/dashboard/page.tsx new file mode 100644 index 0000000..4c488f5 --- /dev/null +++ b/app/(root)/dashboard/page.tsx @@ -0,0 +1,88 @@ +import { Suspense } from "react"; +import { cookies } from "next/headers"; +import TradingViewWidget from "@/components/TradingViewWidget"; +import Panel from "@/components/Panel"; +import IndexPulse, { PulseSkeleton } from "@/components/IndexPulse"; +import DataFreshness from "@/components/DataFreshness"; +import MarketSwitcher from "@/components/MarketSwitcher"; +import { + CRYPTO_HEATMAP_CONFIG, + STOCK_HEATMAP_CONFIG, + marketOverviewConfig, + marketQuotesConfig, + timelineConfig, +} from "@/lib/constants"; +import { MARKETS, MARKET_COOKIE, getMarket, isMarketOpen, marketForCountry } from "@/lib/markets"; +import { getSession } from "@/lib/better-auth/auth"; + +const scriptUrl = `https://s3.tradingview.com/external-embedding/embed-widget-`; + +// Widgets come from TradingView (live, no Finnhub quota) and render immediately; the tiles stream in. +export default async function Dashboard({ searchParams }: { searchParams: Promise<{ market?: string }> }) { + const { market: requested } = await searchParams; + // Explicit choice, then the remembered one, then the market of the user's country + const saved = (await cookies()).get(MARKET_COOKIE)?.value; + const market = getMarket(requested ?? saved ?? marketForCountry((await getSession())?.user.country)); + const open = isMarketOpen(market); + const native = market.pulse.some((p) => p.finnhub); + const heatmap = market.heatmap === 'stocks' + ? { script: 'stock-heatmap.js', config: STOCK_HEATMAP_CONFIG, title: 'S&P 500 heatmap' } + : market.heatmap === 'crypto' + ? { script: 'crypto-coins-heatmap.js', config: CRYPTO_HEATMAP_CONFIG, title: 'Crypto heatmap' } + : null; + + return ( + <> +
+
+

Markets

+

{market.name} ยท {open ? 'open now' : 'closed now'} ยท {market.feed.toLowerCase()} data

+
+
+ + isMarketOpen(m)).map((m) => m.id)} /> + +
+
+
+

Pulse

+

{native ? 'Our shared quotes via Finnhub' : `${market.feed} quotes via TradingView`}

+
+ {native && } +
+ {/* Keyed so switching markets swaps the tiles instead of reusing old widgets */} + }> + + +
+ +
+
+ + + + {heatmap ? ( + + + + ) : ( + + + + )} +
+ +
+ {heatmap && ( + + + + )} + + + +
+
+ + ); +} diff --git a/app/(root)/page.tsx b/app/(root)/page.tsx deleted file mode 100644 index d2a5d93..0000000 --- a/app/(root)/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import TradingViewWidget from "@/components/TradingViewWidget"; -import { - HEATMAP_WIDGET_CONFIG, - MARKET_DATA_WIDGET_CONFIG, - MARKET_OVERVIEW_WIDGET_CONFIG, - TOP_STORIES_WIDGET_CONFIG -} from "@/lib/constants"; - -const Home = () => { - const scriptUrl = `https://s3.tradingview.com/external-embedding/embed-widget-`; - - return ( -
-
-
- -
-
- -
-
-
-
- -
-
- -
- -
-
-

Upvote us on Peerlist ๐Ÿš€

- - OpenStock - -
-
- ) -} - -export default Home; \ No newline at end of file diff --git a/components/IndexPulse.tsx b/components/IndexPulse.tsx new file mode 100644 index 0000000..0ef9990 --- /dev/null +++ b/components/IndexPulse.tsx @@ -0,0 +1,67 @@ +import Link from "next/link"; +import { LineChart } from "lucide-react"; +import ChangePill from "@/components/ChangePill"; +import TradingViewWidget from "@/components/TradingViewWidget"; +import { getLiveQuotes } from "@/lib/actions/finnhub.actions"; +import { singleQuoteConfig } from "@/lib/constants"; +import { getMarket, type Market } from "@/lib/markets"; + +const SINGLE_QUOTE = 'https://s3.tradingview.com/external-embedding/embed-widget-single-quote.js'; + +const formatAmount = (value: number) => + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 }); + +// Four headline tiles for a market. Markets Finnhub can price get native tiles from our shared quote cache; +// the rest use TradingView's quote tile, which is free for those exchanges. +export default async function IndexPulse({ market = getMarket('us') }: { market?: Market }) { + const native = market.pulse.flatMap((p) => (p.finnhub ? [p.finnhub] : [])); + const quotes = native.length ? await getLiveQuotes(native) : {}; + + return ( +
+ {market.pulse.map((tile) => { + if (!tile.finnhub) { + return ( +
+ +
+ ); + } + const quote = quotes[tile.finnhub]; + const isCrypto = tile.finnhub.startsWith('BINANCE:'); + return ( + +
+ + {tile.label} + {tile.finnhub.replace('BINANCE:', '')} +
+

+ {quote?.c ? ( + isCrypto ? <>{formatAmount(quote.c)} USDT : <>${formatAmount(quote.c)} + ) : 'โ€”'} +

+
+ {quote?.c ? ( + <> + + {(quote.d ?? 0) > 0 ? '+' : ''}{formatAmount(quote.d ?? 0)} vs prev close + + ) : ( + Quote unavailable + )} +
+ + ); + })} +
+ ); +} + +export const PulseSkeleton = () => ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+); diff --git a/components/MarketSwitcher.tsx b/components/MarketSwitcher.tsx new file mode 100644 index 0000000..d63cc39 --- /dev/null +++ b/components/MarketSwitcher.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Link from "next/link"; +import { MARKETS, MARKET_COOKIE, type MarketId } from "@/lib/markets"; +import { cn } from "@/lib/utils"; + +// Market chips for the dashboard. The choice is remembered in a cookie so the server renders it next time. +export default function MarketSwitcher({ active, openIds }: { active: MarketId; openIds: MarketId[] }) { + const remember = (id: MarketId) => { + document.cookie = `${MARKET_COOKIE}=${id}; path=/; max-age=31536000; samesite=lax`; + }; + + return ( + + ); +} diff --git a/lib/constants.ts b/lib/constants.ts index 6f41a45..3f81315 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,9 +1,14 @@ -export const NAV_ITEMS = [ - { href: '/', label: 'Dashboard' }, - { href: '/search', label: 'Search' }, - { href: '/watchlist', label: 'Watchlist' }, - { href: '/api-docs', label: 'API Docs' }, -]; +// Community links, used across the app, landing page and emails +export const REPO_URL = 'https://github.com/Open-Dev-Society/OpenStock'; +// GitHub account that receives sponsorships. Switch to 'Open-Dev-Society' once the org's +// GitHub Sponsors listing is live (github.com/sponsors/Open-Dev-Society); every link follows. +export const SPONSOR_GITHUB_ACCOUNT = 'ravixalgorithm'; +export const SPONSOR_URL = `https://github.com/sponsors/${SPONSOR_GITHUB_ACCOUNT}`; +// Opens GitHub's checkout with the amount and frequency already picked +export const sponsorCheckoutUrl = (amount?: number, frequency: 'recurring' | 'one-time' = 'recurring') => + `${SPONSOR_URL}/sponsorships?frequency=${frequency}${amount ? `&amount=${amount}` : ''}`; +export const DISCORD_URL = 'https://discord.gg/JkJ8kfxgxB'; +export const GOOD_FIRST_ISSUES_URL = `${REPO_URL}/issues?q=is%3Aopen+label%3A%22good+first+issue%22`; // Sign-up form select options export const INVESTMENT_GOALS = [ @@ -37,66 +42,40 @@ export const CONDITION_OPTIONS = [ { value: 'less', label: 'Less than (<)' }, ]; -// TradingView Charts -export const MARKET_OVERVIEW_WIDGET_CONFIG = { - colorTheme: 'dark', // dark mode - dateRange: '12M', // last 12 months - locale: 'en', // language - largeChartUrl: '', // link to a large chart if needed - isTransparent: true, // makes background transparent - showFloatingTooltip: true, // show tooltip on hover - plotLineColorGrowing: '#0FEDBE', // line color when price goes up - plotLineColorFalling: '#0FEDBE', // line color when price falls - gridLineColor: 'rgba(240, 243, 250, 0)', // grid line color - scaleFontColor: '#DBDBDB', // font color for scale - belowLineFillColorGrowing: 'rgba(41, 98, 255, 0.12)', // fill under line when growing - belowLineFillColorFalling: 'rgba(41, 98, 255, 0.12)', // fill under line when falling - belowLineFillColorGrowingBottom: 'rgba(41, 98, 255, 0)', - belowLineFillColorFallingBottom: 'rgba(41, 98, 255, 0)', - symbolActiveColor: 'rgba(15, 237, 190, 0.05)', // highlight color for active symbol - tabs: [ - { - title: 'Financial', - symbols: [ - { s: 'NYSE:JPM', d: 'JPMorgan Chase' }, - { s: 'NYSE:WFC', d: 'Wells Fargo Co New' }, - { s: 'NYSE:BAC', d: 'Bank Amer Corp' }, - { s: 'NYSE:HSBC', d: 'Hsbc Hldgs Plc' }, - { s: 'NYSE:C', d: 'Citigroup Inc' }, - { s: 'NYSE:MA', d: 'Mastercard Incorporated' }, - ], - }, - { - title: 'Technology', - symbols: [ - { s: 'NASDAQ:AAPL', d: 'Apple' }, - { s: 'NASDAQ:GOOGL', d: 'Alphabet' }, - { s: 'NASDAQ:MSFT', d: 'Microsoft' }, - { s: 'NASDAQ:META', d: 'Meta Platforms' }, - { s: 'NYSE:ORCL', d: 'Oracle Corp' }, - { s: 'NASDAQ:INTC', d: 'Intel Corp' }, - ], - }, - { - title: 'Services', - symbols: [ - { s: 'NASDAQ:AMZN', d: 'Amazon' }, - { s: 'NYSE:BABA', d: 'Alibaba Group Hldg Ltd' }, - { s: 'NYSE:T', d: 'At&t Inc' }, - { s: 'NYSE:WMT', d: 'Walmart' }, - { s: 'NYSE:V', d: 'Visa' }, - ], - }, - ], - support_host: 'https://www.tradingview.com', // TradingView host - backgroundColor: '#141414', // background color - width: '100%', // full width - height: 600, // height in px - showSymbolLogo: true, // show logo next to symbols - showChart: true, // display mini chart -}; +// TradingView widgets, built per market from lib/markets.ts groups +type SymbolGroup = { title: string; symbols: [symbol: string, name: string][] }; -export const HEATMAP_WIDGET_CONFIG = { +export const marketOverviewConfig = (groups: SymbolGroup[]) => ({ + colorTheme: 'dark', + dateRange: '12M', + locale: 'en', + largeChartUrl: '', + isTransparent: true, + showFloatingTooltip: true, + plotLineColorGrowing: '#47d9bb', // brand teal (oklch 0.80 0.13 176) + plotLineColorFalling: '#47d9bb', + gridLineColor: 'rgba(240, 243, 250, 0)', + scaleFontColor: '#adaba3', // --muted + belowLineFillColorGrowing: 'rgba(71, 217, 187, 0.12)', + belowLineFillColorFalling: 'rgba(71, 217, 187, 0.12)', + belowLineFillColorGrowingBottom: 'rgba(71, 217, 187, 0)', + belowLineFillColorFallingBottom: 'rgba(71, 217, 187, 0)', + symbolActiveColor: 'rgba(71, 217, 187, 0.08)', + tabs: groups.map(({ title, symbols }) => ({ title, symbols: symbols.map(([s, d]) => ({ s, d })) })), + support_host: 'https://www.tradingview.com', + showSymbolLogo: true, + showChart: true, +}); + +export const marketQuotesConfig = (groups: SymbolGroup[]) => ({ + locale: 'en', + showSymbolLogo: true, + colorTheme: 'dark', + isTransparent: true, + symbolsGroups: groups.map(({ title, symbols }) => ({ name: title, symbols: symbols.map(([name, displayName]) => ({ name, displayName })) })), +}); + +export const STOCK_HEATMAP_CONFIG = { dataSource: 'SPX500', blockSize: 'market_cap_basic', blockColor: 'change', @@ -111,73 +90,45 @@ export const HEATMAP_WIDGET_CONFIG = { isZoomEnabled: true, hasSymbolTooltip: true, isMonoSize: false, - width: '100%', - height: '600', }; -export const TOP_STORIES_WIDGET_CONFIG = { +export const CRYPTO_HEATMAP_CONFIG = { + dataSource: 'Crypto', + blockSize: 'market_cap_calc', + blockColor: 'change', + locale: 'en', + symbolUrl: '', + colorTheme: 'dark', + hasTopBar: false, + isDataSetEnabled: false, + isZoomEnabled: true, + hasSymbolTooltip: true, + isMonoSize: false, + isTransparent: true, +}; + +export const timelineConfig = (market: 'stock' | 'crypto' | 'forex') => ({ displayMode: 'regular', feedMode: 'market', colorTheme: 'dark', isTransparent: true, locale: 'en', - market: 'stock', - width: '100%', - height: '600', -}; + market, +}); -export const MARKET_DATA_WIDGET_CONFIG = { - title: 'Stocks', - width: '100%', - height: 600, - locale: 'en', - showSymbolLogo: true, - colorTheme: 'dark', - isTransparent: false, - backgroundColor: '#0F0F0F', - symbolsGroups: [ - { - name: 'Financial', - symbols: [ - { name: 'NYSE:JPM', displayName: 'JPMorgan Chase' }, - { name: 'NYSE:WFC', displayName: 'Wells Fargo Co New' }, - { name: 'NYSE:BAC', displayName: 'Bank Amer Corp' }, - { name: 'NYSE:HSBC', displayName: 'Hsbc Hldgs Plc' }, - { name: 'NYSE:C', displayName: 'Citigroup Inc' }, - { name: 'NYSE:MA', displayName: 'Mastercard Incorporated' }, - ], - }, - { - name: 'Technology', - symbols: [ - { name: 'NASDAQ:AAPL', displayName: 'Apple' }, - { name: 'NASDAQ:GOOGL', displayName: 'Alphabet' }, - { name: 'NASDAQ:MSFT', displayName: 'Microsoft' }, - { name: 'NASDAQ:FB', displayName: 'Meta Platforms' }, - { name: 'NYSE:ORCL', displayName: 'Oracle Corp' }, - { name: 'NASDAQ:INTC', displayName: 'Intel Corp' }, - ], - }, - { - name: 'Services', - symbols: [ - { name: 'NASDAQ:AMZN', displayName: 'Amazon' }, - { name: 'NYSE:BABA', displayName: 'Alibaba Group Hldg Ltd' }, - { name: 'NYSE:T', displayName: 'At&t Inc' }, - { name: 'NYSE:WMT', displayName: 'Walmart' }, - { name: 'NYSE:V', displayName: 'Visa' }, - ], - }, - ], -}; - -export const SYMBOL_INFO_WIDGET_CONFIG = (symbol: string) => ({ - symbol: symbol.toUpperCase(), +export const symbolInfoConfig = (symbol: string) => ({ + symbol, colorTheme: 'dark', isTransparent: true, locale: 'en', width: '100%', - height: 170, +}); + +export const singleQuoteConfig = (symbol: string) => ({ + symbol, + colorTheme: 'dark', + isTransparent: true, + locale: 'en', }); export const CANDLE_CHART_WIDGET_CONFIG = (symbol: string) => ({ @@ -196,34 +147,8 @@ export const CANDLE_CHART_WIDGET_CONFIG = (symbol: string) => ({ symbol: symbol.toUpperCase(), theme: 'dark', timezone: 'exchange', - backgroundColor: '#141414', - gridColor: '#141414', - watchlist: [], - withdateranges: false, - compareSymbols: [], - studies: [], - width: '100%', - height: 600, -}); - -export const BASELINE_WIDGET_CONFIG = (symbol: string) => ({ - allow_symbol_change: false, - calendar: false, - details: false, - hide_side_toolbar: true, - hide_top_toolbar: false, - hide_legend: false, - hide_volume: false, - hotlist: false, - interval: 'D', - locale: 'en', - save_image: false, - style: 10, - symbol: symbol.toUpperCase(), - theme: 'dark', - timezone: 'exchange', - backgroundColor: '#141414', - gridColor: '#141414', + backgroundColor: '#1f1e1a', // --card + gridColor: 'rgba(255, 255, 255, 0.04)', watchlist: [], withdateranges: false, compareSymbols: [], @@ -235,7 +160,7 @@ export const BASELINE_WIDGET_CONFIG = (symbol: string) => ({ export const TECHNICAL_ANALYSIS_WIDGET_CONFIG = (symbol: string) => ({ symbol: symbol.toUpperCase(), colorTheme: 'dark', - isTransparent: 'true', + isTransparent: true, locale: 'en', width: '100%', height: 400, @@ -243,10 +168,11 @@ export const TECHNICAL_ANALYSIS_WIDGET_CONFIG = (symbol: string) => ({ largeChartUrl: '', }); +// embed-widget-company-profile.js was retired by TradingView (403); symbol-profile.js takes the same config export const COMPANY_PROFILE_WIDGET_CONFIG = (symbol: string) => ({ symbol: symbol.toUpperCase(), colorTheme: 'dark', - isTransparent: 'true', + isTransparent: true, locale: 'en', width: '100%', height: 440, @@ -255,7 +181,7 @@ export const COMPANY_PROFILE_WIDGET_CONFIG = (symbol: string) => ({ export const COMPANY_FINANCIALS_WIDGET_CONFIG = (symbol: string) => ({ symbol: symbol.toUpperCase(), colorTheme: 'dark', - isTransparent: 'true', + isTransparent: true, locale: 'en', width: '100%', height: 464, @@ -263,66 +189,18 @@ export const COMPANY_FINANCIALS_WIDGET_CONFIG = (symbol: string) => ({ largeChartUrl: '', }); -export const POPULAR_STOCK_SYMBOLS = [ - // Tech Giants (the big technology companies) - 'AAPL', - 'MSFT', - 'GOOGL', - 'AMZN', - 'TSLA', - 'META', - 'NVDA', - 'NFLX', - 'ORCL', - 'CRM', - - // Growing Tech Companies - 'ADBE', - 'INTC', - 'AMD', - 'PYPL', - 'UBER', - 'ZOOM', - 'SPOT', - 'SQ', - 'SHOP', - 'ROKU', - - // Newer Tech Companies - 'SNOW', - 'PLTR', - 'COIN', - 'RBLX', - 'DDOG', - 'CRWD', - 'NET', - 'OKTA', - 'TWLO', - 'ZM', - - // Consumer & Delivery Apps - 'DOCU', - 'PTON', - 'PINS', - 'SNAP', - 'LYFT', - 'DASH', - 'ABNB', - 'RIVN', - 'LCID', - 'NIO', - - // International Companies - 'XPEV', - 'LI', - 'BABA', - 'JD', - 'PDD', - 'TME', - 'BILI', - 'DIDI', - 'GRAB', - 'SE', +// Default list in the search palette (static on purpose, see searchStocks) +export const POPULAR_STOCKS = [ + { symbol: 'AAPL', name: 'Apple Inc', exchange: 'NASDAQ' }, + { symbol: 'MSFT', name: 'Microsoft Corp', exchange: 'NASDAQ' }, + { symbol: 'NVDA', name: 'NVIDIA Corp', exchange: 'NASDAQ' }, + { symbol: 'GOOGL', name: 'Alphabet Inc', exchange: 'NASDAQ' }, + { symbol: 'AMZN', name: 'Amazon.com Inc', exchange: 'NASDAQ' }, + { symbol: 'META', name: 'Meta Platforms Inc', exchange: 'NASDAQ' }, + { symbol: 'TSLA', name: 'Tesla Inc', exchange: 'NASDAQ' }, + { symbol: 'NFLX', name: 'Netflix Inc', exchange: 'NASDAQ' }, + { symbol: 'AMD', name: 'Advanced Micro Devices Inc', exchange: 'NASDAQ' }, + { symbol: 'JPM', name: 'JPMorgan Chase & Co', exchange: 'NYSE' }, ]; export const NO_MARKET_NEWS = diff --git a/lib/markets.ts b/lib/markets.ts new file mode 100644 index 0000000..d275817 --- /dev/null +++ b/lib/markets.ts @@ -0,0 +1,167 @@ +// Markets OpenStock can show for free. Coverage was tested against the free data sources: +// - Finnhub free: US stocks and crypto only (every other exchange returns 403). +// - TradingView embeds: US, crypto, forex live; TSX + ASX delayed; BSE + XETRA end-of-day. +// NSE, LSE, Tokyo, Hong Kong and TVC indices (NIFTY, FTSE, DAX, N225, HSI) are blocked in embeds. +// Adding a market = adding an entry here with symbols from an exchange that renders in embeds. + +import { isInternationalSymbol } from "@/lib/utils"; + +export type MarketId = 'us' | 'in' | 'de' | 'ca' | 'au' | 'crypto' | 'fx'; + +type Hours = { open: number; close: number }; // minutes of the local day + +export type Market = { + id: MarketId; + name: string; + short: string; + timeZone: string; + hours: Hours | '24/7' | '24/5'; + feed: 'Live' | 'Delayed' | 'End of day'; + // Tiles on the dashboard. `finnhub` set = native tile with our own cached quote; otherwise a TradingView quote. + pulse: { symbol: string; label: string; finnhub?: string }[]; + groups: { title: string; symbols: [symbol: string, name: string][] }[]; + heatmap?: 'stocks' | 'crypto'; + news: 'stock' | 'crypto' | 'forex'; +}; + +const hm = (h: number, m = 0) => h * 60 + m; + +export const MARKETS: Market[] = [ + { + id: 'us', name: 'United States', short: 'US', timeZone: 'America/New_York', hours: { open: hm(9, 30), close: hm(16) }, feed: 'Live', + pulse: [ + { symbol: 'AMEX:SPY', label: 'S&P 500', finnhub: 'SPY' }, + { symbol: 'NASDAQ:QQQ', label: 'Nasdaq 100', finnhub: 'QQQ' }, + { symbol: 'AMEX:DIA', label: 'Dow 30', finnhub: 'DIA' }, + { symbol: 'AMEX:IWM', label: 'Russell 2000', finnhub: 'IWM' }, + ], + groups: [ + { title: 'Financial', symbols: [['NYSE:JPM', 'JPMorgan Chase'], ['NYSE:BAC', 'Bank of America'], ['NYSE:WFC', 'Wells Fargo'], ['NYSE:GS', 'Goldman Sachs'], ['NYSE:MA', 'Mastercard'], ['NYSE:V', 'Visa']] }, + { title: 'Technology', symbols: [['NASDAQ:AAPL', 'Apple'], ['NASDAQ:MSFT', 'Microsoft'], ['NASDAQ:NVDA', 'NVIDIA'], ['NASDAQ:GOOGL', 'Alphabet'], ['NASDAQ:META', 'Meta'], ['NASDAQ:AMD', 'AMD']] }, + { title: 'Consumer', symbols: [['NASDAQ:AMZN', 'Amazon'], ['NASDAQ:TSLA', 'Tesla'], ['NYSE:WMT', 'Walmart'], ['NASDAQ:COST', 'Costco'], ['NYSE:KO', 'Coca-Cola'], ['NASDAQ:NFLX', 'Netflix']] }, + ], + heatmap: 'stocks', news: 'stock', + }, + { + id: 'in', name: 'India', short: 'IN', timeZone: 'Asia/Kolkata', hours: { open: hm(9, 15), close: hm(15, 30) }, feed: 'End of day', + pulse: [ + { symbol: 'BSE:SENSEX', label: 'Sensex' }, + { symbol: 'BSE:RELIANCE', label: 'Reliance' }, + { symbol: 'BSE:TCS', label: 'TCS' }, + { symbol: 'BSE:HDFCBANK', label: 'HDFC Bank' }, + ], + groups: [ + { title: 'Banks', symbols: [['BSE:HDFCBANK', 'HDFC Bank'], ['BSE:ICICIBANK', 'ICICI Bank'], ['BSE:SBIN', 'State Bank of India'], ['BSE:KOTAKBANK', 'Kotak Mahindra'], ['BSE:AXISBANK', 'Axis Bank']] }, + { title: 'Technology', symbols: [['BSE:TCS', 'TCS'], ['BSE:INFY', 'Infosys'], ['BSE:WIPRO', 'Wipro'], ['BSE:HCLTECH', 'HCLTech'], ['BSE:TECHM', 'Tech Mahindra']] }, + { title: 'Industry', symbols: [['BSE:RELIANCE', 'Reliance'], ['BSE:LT', 'Larsen & Toubro'], ['BSE:ONGC', 'ONGC'], ['BSE:NTPC', 'NTPC'], ['BSE:TATASTEEL', 'Tata Steel']] }, + ], + news: 'stock', + }, + { + id: 'de', name: 'Germany', short: 'DE', timeZone: 'Europe/Berlin', hours: { open: hm(9), close: hm(17, 30) }, feed: 'End of day', + pulse: [ + { symbol: 'XETR:SAP', label: 'SAP' }, + { symbol: 'XETR:SIE', label: 'Siemens' }, + { symbol: 'XETR:ALV', label: 'Allianz' }, + { symbol: 'XETR:DTE', label: 'Deutsche Telekom' }, + ], + groups: [ + { title: 'Industry', symbols: [['XETR:SIE', 'Siemens'], ['XETR:BAS', 'BASF'], ['XETR:BMW', 'BMW'], ['XETR:MBG', 'Mercedes-Benz'], ['XETR:VOW3', 'Volkswagen']] }, + { title: 'Finance', symbols: [['XETR:ALV', 'Allianz'], ['XETR:MUV2', 'Munich Re'], ['XETR:DBK', 'Deutsche Bank'], ['XETR:CBK', 'Commerzbank']] }, + { title: 'Technology', symbols: [['XETR:SAP', 'SAP'], ['XETR:IFX', 'Infineon'], ['XETR:DTE', 'Deutsche Telekom'], ['XETR:ADS', 'Adidas']] }, + ], + news: 'stock', + }, + { + id: 'ca', name: 'Canada', short: 'CA', timeZone: 'America/Toronto', hours: { open: hm(9, 30), close: hm(16) }, feed: 'Delayed', + pulse: [ + { symbol: 'TSX:RY', label: 'Royal Bank' }, + { symbol: 'TSX:SHOP', label: 'Shopify' }, + { symbol: 'TSX:ENB', label: 'Enbridge' }, + { symbol: 'TSX:TD', label: 'TD Bank' }, + ], + groups: [ + { title: 'Banks', symbols: [['TSX:RY', 'Royal Bank'], ['TSX:TD', 'TD Bank'], ['TSX:BNS', 'Scotiabank'], ['TSX:BMO', 'Bank of Montreal']] }, + { title: 'Energy', symbols: [['TSX:ENB', 'Enbridge'], ['TSX:CNQ', 'Canadian Natural'], ['TSX:SU', 'Suncor']] }, + { title: 'Growth', symbols: [['TSX:SHOP', 'Shopify'], ['TSX:CNR', 'CN Rail'], ['TSX:CP', 'CPKC']] }, + ], + news: 'stock', + }, + { + id: 'au', name: 'Australia', short: 'AU', timeZone: 'Australia/Sydney', hours: { open: hm(10), close: hm(16) }, feed: 'Delayed', + pulse: [ + { symbol: 'ASX:CBA', label: 'CommBank' }, + { symbol: 'ASX:BHP', label: 'BHP' }, + { symbol: 'ASX:CSL', label: 'CSL' }, + { symbol: 'ASX:NAB', label: 'NAB' }, + ], + groups: [ + { title: 'Banks', symbols: [['ASX:CBA', 'CommBank'], ['ASX:NAB', 'NAB'], ['ASX:WBC', 'Westpac'], ['ASX:ANZ', 'ANZ'], ['ASX:MQG', 'Macquarie']] }, + { title: 'Resources', symbols: [['ASX:BHP', 'BHP'], ['ASX:FMG', 'Fortescue'], ['ASX:RIO', 'Rio Tinto']] }, + { title: 'Consumer & health', symbols: [['ASX:CSL', 'CSL'], ['ASX:WES', 'Wesfarmers'], ['ASX:WOW', 'Woolworths']] }, + ], + news: 'stock', + }, + { + id: 'crypto', name: 'Crypto', short: 'Crypto', timeZone: 'UTC', hours: '24/7', feed: 'Live', + pulse: [ + { symbol: 'BINANCE:BTCUSDT', label: 'Bitcoin', finnhub: 'BINANCE:BTCUSDT' }, + { symbol: 'BINANCE:ETHUSDT', label: 'Ethereum', finnhub: 'BINANCE:ETHUSDT' }, + { symbol: 'BINANCE:SOLUSDT', label: 'Solana', finnhub: 'BINANCE:SOLUSDT' }, + { symbol: 'BINANCE:BNBUSDT', label: 'BNB', finnhub: 'BINANCE:BNBUSDT' }, + ], + groups: [ + { title: 'Majors', symbols: [['BINANCE:BTCUSDT', 'Bitcoin'], ['BINANCE:ETHUSDT', 'Ethereum'], ['BINANCE:SOLUSDT', 'Solana'], ['BINANCE:BNBUSDT', 'BNB'], ['BINANCE:XRPUSDT', 'XRP']] }, + { title: 'More', symbols: [['BINANCE:ADAUSDT', 'Cardano'], ['BINANCE:DOGEUSDT', 'Dogecoin'], ['BINANCE:AVAXUSDT', 'Avalanche'], ['BINANCE:LINKUSDT', 'Chainlink'], ['BINANCE:DOTUSDT', 'Polkadot']] }, + ], + heatmap: 'crypto', news: 'crypto', + }, + { + id: 'fx', name: 'Forex', short: 'FX', timeZone: 'America/New_York', hours: '24/5', feed: 'Live', + pulse: [ + { symbol: 'FX:EURUSD', label: 'EUR / USD' }, + { symbol: 'FX:GBPUSD', label: 'GBP / USD' }, + { symbol: 'FX:USDJPY', label: 'USD / JPY' }, + { symbol: 'FX:AUDUSD', label: 'AUD / USD' }, + ], + groups: [ + { title: 'Majors', symbols: [['FX:EURUSD', 'EUR / USD'], ['FX:GBPUSD', 'GBP / USD'], ['FX:USDJPY', 'USD / JPY'], ['FX:USDCHF', 'USD / CHF']] }, + { title: 'Commodity FX', symbols: [['FX:AUDUSD', 'AUD / USD'], ['FX:USDCAD', 'USD / CAD'], ['FX:NZDUSD', 'NZD / USD']] }, + ], + news: 'forex', + }, +]; + +export const DEFAULT_MARKET: MarketId = 'us'; +export const MARKET_COOKIE = 'openstock-market'; + +export const getMarket = (id?: string | null) => MARKETS.find((m) => m.id === id) ?? MARKETS[0]; + +const COUNTRY_MARKET: Record = { IN: 'in', DE: 'de', CA: 'ca', AU: 'au' }; +export const marketForCountry = (country?: string | null): MarketId => COUNTRY_MARKET[country ?? ''] ?? DEFAULT_MARKET; + +// Finnhub free can price (and so alert on) US listings and Binance pairs only. +export const hasFinnhubQuotes = (symbol: string) => { + const upper = symbol.toUpperCase(); + if (upper.startsWith('BINANCE:')) return true; + return !upper.includes(':') && !isInternationalSymbol(upper); +}; + +// Is the market trading right now, in its own time zone? Holidays aren't modelled. +export function isMarketOpen(market: Market, date = new Date()) { + if (market.hours === '24/7') return true; + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { timeZone: market.timeZone, weekday: 'short', hour: 'numeric', minute: 'numeric', hourCycle: 'h23' }) + .formatToParts(date).map((p) => [p.type, p.value]), + ); + const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(parts.weekday); + const minutes = Number(parts.hour) * 60 + Number(parts.minute); + if (market.hours === '24/5') { + // FX: Sunday 17:00 to Friday 17:00, New York time + if (day === 6) return false; + if (day === 0) return minutes >= hm(17); + if (day === 5) return minutes < hm(17); + return true; + } + return day >= 1 && day <= 5 && minutes >= market.hours.open && minutes < market.hours.close; +} diff --git a/lib/utils.ts b/lib/utils.ts index e910ae2..0539659 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -108,10 +108,10 @@ export const getChangeColorClass = (changePercent?: number) => { return changePercent > 0 ? 'text-green-500' : 'text-red-500'; }; -export const formatPrice = (price: number) => { +export const formatPrice = (price: number, currency = 'USD') => { return new Intl.NumberFormat('en-US', { style: 'currency', - currency: 'USD', + currency, minimumFractionDigits: 2, }).format(price); }; @@ -173,7 +173,7 @@ const FINNHUB_TO_TRADINGVIEW_EXCHANGE: Record = { '.AX': 'ASX', // Australian Securities Exchange '.NZ': 'NZX', // New Zealand '.BO': 'BSE', // Bombay Stock Exchange - '.NS': 'NSE', // National Stock Exchange of India + '.NS': 'BSE', // NSE listing: NSE is blocked in free TradingView embeds, the same ticker renders on BSE '.BK': 'SET', // Stock Exchange of Thailand '.JK': 'IDX', // Indonesia Stock Exchange '.KL': 'MYX', // Bursa Malaysia @@ -212,6 +212,12 @@ const FINNHUB_TO_TRADINGVIEW_EXCHANGE: Record = { '.JO': 'JSE', // Johannesburg Stock Exchange }; +// Listed outside the US (has a known exchange suffix like .L, .T, .NS). Class shares such as BRK.B are not. +export function isInternationalSymbol(symbol: string): boolean { + const upper = symbol.toUpperCase(); + return Object.keys(FINNHUB_TO_TRADINGVIEW_EXCHANGE).some((suffix) => upper.endsWith(suffix.toUpperCase())); +} + export function formatSymbolForTradingView(symbol: string): string { if (!symbol) return ''; const upperSymbol = symbol.toUpperCase();