yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import type { ReactElement } from 'react' |
| 2 | import { useBridge, useData, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { display as d } from './format' |
| 4 | import type { MoverList } from './server/quotes' |
| 5 | import { Bar, Loading, Section, Signed, useEvery, usePanelStateValue, useStateWriter } from './shared' |
| 6 | import { num, text } from './values' |
| 7 | import { MARKET_SCREENS, MARKET_TABS, marketsOutput, readMarketList, REFRESH_MS, tone, yahooPage } from './views' |
| 8 | import './styles.css' |
| 9 | |
| 10 | // The market at a glance: Yahoo's summary strip (index futures, commodities, rates, currencies, |
| 11 | // crypto) as tiles, then one of Yahoo's lists of stocks. Both are asked again every minute. A tab or |
| 12 | // the screens menu writes `list`. |
| 13 | |
| 14 | const LIMIT = 25 |
| 15 | |
| 16 | export function MarketsView({ panel }: { panel: PanelRef }): ReactElement { |
| 17 | const state = usePanelStateValue(panel) |
| 18 | const write = useStateWriter(panel) |
| 19 | if (state === undefined) return <Loading /> |
| 20 | const list = readMarketList(state) |
| 21 | const screen = MARKET_SCREENS.some((s) => s.list === list) |
| 22 | return ( |
| 23 | <div className="yf-root"> |
| 24 | <Bar> |
| 25 | <nav className="yf-tabs" aria-label="Lists"> |
| 26 | {MARKET_TABS.map((tab) => ( |
| 27 | <button |
| 28 | key={tab.list} |
| 29 | type="button" |
| 30 | className={tab.list === list ? 'yf-tab yf-tab-on' : 'yf-tab'} |
| 31 | aria-pressed={tab.list === list} |
| 32 | onClick={() => write([['list', tab.list]])} |
| 33 | > |
| 34 | {tab.label} |
| 35 | </button> |
| 36 | ))} |
| 37 | </nav> |
| 38 | <select |
| 39 | className={screen ? 'yf-select yf-tab-on' : 'yf-select'} |
| 40 | aria-label="Screens" |
| 41 | value={screen ? list : ''} |
| 42 | onChange={(event) => event.target.value && write([['list', event.target.value]])} |
| 43 | > |
| 44 | <option value="">Screens…</option> |
| 45 | {MARKET_SCREENS.map((s) => ( |
| 46 | <option key={s.list} value={s.list}> |
| 47 | {s.label} |
| 48 | </option> |
| 49 | ))} |
| 50 | </select> |
| 51 | </Bar> |
| 52 | <div className="yf-body"> |
| 53 | <Markets panel={panel} list={list} /> |
| 54 | </div> |
| 55 | </div> |
| 56 | ) |
| 57 | } |
| 58 | |
| 59 | function Markets({ panel, list }: { panel: PanelRef; list: MoverList }): ReactElement { |
| 60 | const bridge = useBridge() |
| 61 | const summary = useData('yfinance/market-summary', {}) |
| 62 | const movers = useData('yfinance/movers', { list, limit: LIMIT }) |
| 63 | useEvery(summary.refetch, REFRESH_MS) |
| 64 | useEvery(movers.refetch, REFRESH_MS) |
| 65 | const tiles = summary.data ?? [] |
| 66 | const rows = movers.data ?? [] |
| 67 | const title = text(movers.meta['title']) |
| 68 | const total = num(movers.meta['total']) |
| 69 | usePublish(panel, marketsOutput(list, tiles, { title, total, rows })) |
| 70 | const open = (symbol: string): void => void bridge.openLink(yahooPage(symbol)) |
| 71 | return ( |
| 72 | <> |
| 73 | <Section title="Markets" run={summary}> |
| 74 | <div className="yf-tiles"> |
| 75 | {tiles.map((t) => { |
| 76 | const symbol = text(t['symbol']) ?? '' |
| 77 | const hint = num(t['priceHint']) |
| 78 | return ( |
| 79 | <button key={symbol} type="button" className={`yf-tile yf-tile-${tone(num(t['changePercent'])) || 'flat'}`} title={symbol} onClick={() => open(symbol)}> |
| 80 | <span className="yf-tile-name">{text(t['name']) ?? symbol}</span> |
| 81 | <span className="yf-tile-price yf-num">{d.price(num(t['price']), hint)}</span> |
| 82 | <span className="yf-tile-move"> |
| 83 | <Signed value={num(t['change'])} hint={hint} /> <Signed value={num(t['changePercent'])} percent /> |
| 84 | </span> |
| 85 | </button> |
| 86 | ) |
| 87 | })} |
| 88 | </div> |
| 89 | </Section> |
| 90 | <Section |
| 91 | title={title ?? MARKET_TABS.find((t) => t.list === list)?.label ?? list} |
| 92 | run={movers} |
| 93 | aside={rows.length ? `${rows.length}${total !== null ? ` of ${d.fixed(total, 0)}` : ''}` : undefined} |
| 94 | empty={rows.length === 0} |
| 95 | > |
| 96 | {text(movers.meta['description']) && <p className="yf-line yf-muted">{text(movers.meta['description'])}</p>} |
| 97 | <table className="yf-table"> |
| 98 | <thead> |
| 99 | <tr> |
| 100 | <th>Symbol</th> |
| 101 | <th>Name</th> |
| 102 | <th className="yf-num">Price</th> |
| 103 | <th className="yf-num">Change</th> |
| 104 | <th className="yf-num">%</th> |
| 105 | <th className="yf-num">Volume</th> |
| 106 | <th className="yf-num">Avg volume</th> |
| 107 | <th className="yf-num">Market cap</th> |
| 108 | <th className="yf-num">P/E</th> |
| 109 | </tr> |
| 110 | </thead> |
| 111 | <tbody> |
| 112 | {rows.map((q) => { |
| 113 | const symbol = text(q['symbol']) ?? '' |
| 114 | const hint = num(q['priceHint']) |
| 115 | return ( |
| 116 | <tr key={symbol}> |
| 117 | <td> |
| 118 | <button type="button" className="yf-link yf-strong" onClick={() => open(symbol)}> |
| 119 | {symbol} |
| 120 | </button> |
| 121 | </td> |
| 122 | <td className="yf-name-cell">{text(q['name'])}</td> |
| 123 | <td className="yf-num">{d.price(num(q['price']), hint)}</td> |
| 124 | <td className="yf-num"> |
| 125 | <Signed value={num(q['change'])} hint={hint} /> |
| 126 | </td> |
| 127 | <td className="yf-num"> |
| 128 | <Signed value={num(q['changePercent'])} percent /> |
| 129 | </td> |
| 130 | <td className="yf-num">{d.big(num(q['volume']))}</td> |
| 131 | <td className="yf-num">{d.big(num(q['averageVolume']))}</td> |
| 132 | <td className="yf-num">{d.big(num(q['marketCap']))}</td> |
| 133 | <td className="yf-num">{d.fixed(num(q['pe']))}</td> |
| 134 | </tr> |
| 135 | ) |
| 136 | })} |
| 137 | </tbody> |
| 138 | </table> |
| 139 | </Section> |
| 140 | </> |
| 141 | ) |
| 142 | } |