mirror of
https://github.com/Open-Dev-Society/OpenStock.git
synced 2026-10-02 04:54:32 +08:00
feat(markets): multimarket dashboard and coverage-aware stock pages
Tested free coverage: Finnhub prices US stocks and crypto only; TradingView embeds are live for US/crypto/forex, delayed for TSX/ASX, end of day for BSE/XETRA. - Market registry and switcher (US, India, Germany, Canada, Australia, Crypto, Forex) with session hours; defaults to the user's country. - Stock pages fall back to TradingView for symbols Finnhub can't price; alerts are limited to US stocks and crypto, enforced server-side. - .NS symbols chart on BSE (NSE is blocked in free embeds). - company-profile widget was retired by TradingView; switched to symbol-profile. - MARKET_SUPPORT.md rewritten from the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0aadaaa4db
commit
1203a25c6f
+20
-147
@@ -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.
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Markets</h1>
|
||||
<p className="page-sub">{market.name} · {open ? 'open now' : 'closed now'} · {market.feed.toLowerCase()} data</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MarketSwitcher active={market.id} openIds={MARKETS.filter((m) => isMarketOpen(m)).map((m) => m.id)} />
|
||||
|
||||
<section className="hatch">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2 className="section-title">Pulse</h2>
|
||||
<p className="section-sub">{native ? 'Our shared quotes via Finnhub' : `${market.feed} quotes via TradingView`}</p>
|
||||
</div>
|
||||
{native && <DataFreshness />}
|
||||
</div>
|
||||
{/* Keyed so switching markets swaps the tiles instead of reusing old widgets */}
|
||||
<Suspense key={market.id} fallback={<PulseSkeleton />}>
|
||||
<IndexPulse market={market} />
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
<div key={market.id} className="flex flex-col gap-3">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
|
||||
<Panel title="Leaders" sub="12-month trend by group">
|
||||
<TradingViewWidget scriptUrl={`${scriptUrl}market-overview.js`} config={marketOverviewConfig(market.groups)} height={560} />
|
||||
</Panel>
|
||||
{heatmap ? (
|
||||
<Panel title={heatmap.title} sub="Sized by market cap, coloured by today’s move">
|
||||
<TradingViewWidget scriptUrl={`${scriptUrl}${heatmap.script}`} config={heatmap.config} height={560} allowExpand />
|
||||
</Panel>
|
||||
) : (
|
||||
<Panel title="Quotes" sub={`${market.name} leaders by group`}>
|
||||
<TradingViewWidget scriptUrl={`${scriptUrl}market-quotes.js`} config={marketQuotesConfig(market.groups)} height={560} />
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
{heatmap && (
|
||||
<Panel title="Quotes" sub={`${market.name} leaders by group`}>
|
||||
<TradingViewWidget scriptUrl={`${scriptUrl}market-quotes.js`} config={marketQuotesConfig(market.groups)} height={560} />
|
||||
</Panel>
|
||||
)}
|
||||
<Panel title="Top stories" sub="Newest first" className={heatmap ? '' : 'xl:col-span-2'}>
|
||||
<TradingViewWidget scriptUrl={`${scriptUrl}timeline.js`} config={timelineConfig(market.news)} height={560} />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen home-wrapper">
|
||||
<section className="grid w-full gap-8 home-section">
|
||||
<div className="md:col-span-1 xl:col-span-1">
|
||||
<TradingViewWidget
|
||||
title="Market Overview"
|
||||
scriptUrl={`${scriptUrl}market-overview.js`}
|
||||
config={MARKET_OVERVIEW_WIDGET_CONFIG}
|
||||
className="custom-chart"
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
<div className="md-col-span xl:col-span-2">
|
||||
<TradingViewWidget
|
||||
title="Stock Heatmap"
|
||||
scriptUrl={`${scriptUrl}stock-heatmap.js`}
|
||||
config={HEATMAP_WIDGET_CONFIG}
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid w-full gap-8 home-section">
|
||||
<div className="h-full md:col-span-1 xl:col-span-2">
|
||||
<TradingViewWidget
|
||||
scriptUrl={`${scriptUrl}market-quotes.js`}
|
||||
config={MARKET_DATA_WIDGET_CONFIG}
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full md:col-span-1 xl:col-span-1">
|
||||
<TradingViewWidget
|
||||
scriptUrl={`${scriptUrl}timeline.js`}
|
||||
config={TOP_STORIES_WIDGET_CONFIG}
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<div className="w-full flex flex-col items-center justify-center mt-8 gap-4">
|
||||
<h2 className="text-xl font-semibold text-gray-200">Upvote us on Peerlist 🚀</h2>
|
||||
<a href="https://peerlist.io/ravixalgorithm/project/openstock" target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src="https://peerlist.io/api/v1/projects/embed/PRJH8OED7MBL9MGB9HRMKAKLM66KNN?showUpvote=true&theme=light"
|
||||
alt="OpenStock"
|
||||
style={{ width: "auto", height: "72px" }}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -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 (
|
||||
<div className="bento">
|
||||
{market.pulse.map((tile) => {
|
||||
if (!tile.finnhub) {
|
||||
return (
|
||||
<div key={tile.symbol} className="bento-tile p-1.5">
|
||||
<TradingViewWidget scriptUrl={SINGLE_QUOTE} config={singleQuoteConfig(tile.symbol)} height={112} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const quote = quotes[tile.finnhub];
|
||||
const isCrypto = tile.finnhub.startsWith('BINANCE:');
|
||||
return (
|
||||
<Link key={tile.symbol} href={`/stocks/${encodeURIComponent(tile.finnhub)}`} className="bento-tile transition-colors hover:bg-hover/60">
|
||||
<div className="bento-head">
|
||||
<span className="bento-ico"><LineChart /></span>
|
||||
<span className="truncate">{tile.label}</span>
|
||||
<span className="mono ml-auto text-xs text-faint">{tile.finnhub.replace('BINANCE:', '')}</span>
|
||||
</div>
|
||||
<p className="bento-value">
|
||||
{quote?.c ? (
|
||||
isCrypto ? <>{formatAmount(quote.c)}<small> USDT</small></> : <><small>$</small>{formatAmount(quote.c)}</>
|
||||
) : '—'}
|
||||
</p>
|
||||
<div className="bento-foot">
|
||||
{quote?.c ? (
|
||||
<>
|
||||
<ChangePill value={quote.dp} />
|
||||
<span className="num truncate">{(quote.d ?? 0) > 0 ? '+' : ''}{formatAmount(quote.d ?? 0)} vs prev close</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Quote unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PulseSkeleton = () => (
|
||||
<div className="bento" aria-busy="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="bento-tile h-[124px]"><span className="h-full animate-pulse rounded-lg bg-hover/60" /></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<nav aria-label="Markets" className="flex gap-1.5 overflow-x-auto px-1 pb-1 [scrollbar-width:none]">
|
||||
{MARKETS.map((m) => {
|
||||
const selected = m.id === active;
|
||||
const open = openIds.includes(m.id);
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
href={`/dashboard?market=${m.id}`}
|
||||
onClick={() => remember(m.id)}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-9 flex-none items-center gap-2 rounded-full px-3.5 text-[13.5px] font-semibold transition-[background-color,color,box-shadow] duration-150',
|
||||
selected
|
||||
? 'bg-brand-soft text-brand-ink shadow-[inset_0_0_0_1px_oklch(0.8_0.13_176/0.45)]'
|
||||
: 'text-muted-foreground shadow-[inset_0_0_0_1px_var(--line)] hover:bg-hover hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', open ? 'bg-up' : 'bg-line-strong')} title={open ? 'Open now' : 'Closed now'} />
|
||||
{m.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+89
-211
@@ -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 =
|
||||
|
||||
+167
@@ -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<string, MarketId> = { 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;
|
||||
}
|
||||
+9
-3
@@ -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<string, string> = {
|
||||
'.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<string, string> = {
|
||||
'.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();
|
||||
|
||||
Reference in New Issue
Block a user