Jaspers Hub

jaspers / yfinance

pluginOfficialReviewed★ 0↓ 11

yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.

14 KB
1import { useState, type ReactElement } from 'react'
2import { useBridge, useData, usePublish, type PanelRef, type SourceData } from '@jaspers-ai/sdk'
3import { display as d } from './format'
4import { Bar, Empty, Loading, marketState, RangeBar, Section, Signed, SymbolField, usePanelStateValue, useStateWriter } from './shared'
5import { num, record, records, text, type Row } from './values'
6import { cleanError, isFund, overviewOutput, overviewStats, ratingAction, ratingMix, readSymbol, smallPercent } from './views'
7import './styles.css'
8
9// One company or fund on a page: the quote, the 52-week range, key statistics, the analysts'
10// targets and ratings, earnings, and the profile, and for a fund its holdings. A run per tool, each
11// drawn as it lands; a section with nothing to show stays out.
12
13export function OverviewView({ panel }: { panel: PanelRef }): ReactElement {
14 const state = usePanelStateValue(panel)
15 const write = useStateWriter(panel)
16 if (state === undefined) return <Loading />
17 const symbol = readSymbol(state)
18 return (
19 <div className="yf-root">
20 <Bar>
21 <SymbolField symbol={symbol} onSymbol={(next) => write([['symbol', next]])} />
22 <span className="yf-muted">Company</span>
23 </Bar>
24 <div className="yf-body">
25 {symbol ? (
26 <Overview key={symbol} panel={panel} symbol={symbol} />
27 ) : (
28 <Empty panel={panel} output={{ symbol: '', errors: [] }}>
29 Enter a symbol: AAPL, SHOP.TO, SPY.
30 </Empty>
31 )}
32 </div>
33 </div>
34 )
35}
36
37function Overview({ panel, symbol }: { panel: PanelRef; symbol: string }): ReactElement {
38 const quote = useData('yfinance/quote', { symbols: [symbol] })
39 const statistics = useData('yfinance/statistics', { symbol })
40 const analysts = useData('yfinance/analysts', { symbol, limit: 5 })
41 const earnings = useData('yfinance/earnings', { symbol })
42 const profile = useData('yfinance/profile', { symbol })
43 const output = overviewOutput(symbol, { quote, statistics, analysts, earnings, profile })
44 usePublish(panel, output)
45 const q = quote.data?.[0] ?? {}
46 const hint = num(q['priceHint'])
47 return (
48 <>
49 <header className="yf-head">
50 <div className="yf-title">
51 <span className="yf-name">{output.name ?? symbol}</span>
52 <span className="yf-muted">{[symbol, text(q['exchange']), output.currency].filter(Boolean).join(' · ')}</span>
53 </div>
54 {quote.error ? (
55 <p className="yf-error">{cleanError(quote.error)}</p>
56 ) : (
57 <div className="yf-price-line">
58 <span className="yf-price yf-num">{quote.loading && output.price === null ? '…' : d.price(output.price, hint)}</span>
59 <Signed value={num(q['change'])} hint={hint} />
60 <Signed value={output.changePercent} percent />
61 <span className="yf-muted">{marketState(output.marketState)}</span>
62 <Extended quote={q} />
63 </div>
64 )}
65 <div className="yf-ranges">
66 <RangeBar label="Day range" low={num(q['dayLow'])} high={num(q['dayHigh'])} value={output.price} hint={hint} />
67 <RangeBar label="52-week range" low={output.fiftyTwoWeekLow} high={output.fiftyTwoWeekHigh} value={output.price} hint={hint} />
68 </div>
69 </header>
70 <Statistics run={statistics} />
71 {isFund(output.type) && <Fund symbol={symbol} />}
72 <Analysts run={analysts} price={output.price} hint={hint} />
73 <Earnings run={earnings} />
74 <Profile run={profile} />
75 </>
76 )
77}
78
79function Extended({ quote }: { quote: Row }): ReactElement | null {
80 const pre = num(quote['preMarketPrice'])
81 const post = num(quote['postMarketPrice'])
82 const price = pre ?? post
83 if (price === null) return null
84 return (
85 <span className="yf-muted">
86 {pre !== null ? 'Pre-market' : 'Post-market'} <span className="yf-num">{d.price(price, num(quote['priceHint']))}</span>{' '}
87 <Signed value={num(quote[pre !== null ? 'preMarketChangePercent' : 'postMarketChangePercent'])} percent />
88 </span>
89 )
90}
91
92function Statistics({ run }: { run: SourceData }): ReactElement | null {
93 const stats = overviewStats(run.data)
94 if (!run.error && !run.loading && stats.length === 0) return null
95 return (
96 <Section title="Key statistics" run={run}>
97 <dl className="yf-grid">
98 {stats.map((s) => (
99 <div key={s.key} className="yf-cell">
100 <dt className="yf-muted">{s.label}</dt>
101 <dd className="yf-num" title={s.value}>
102 {s.value}
103 </dd>
104 </div>
105 ))}
106 </dl>
107 </Section>
108 )
109}
110
111/** A fund's figures, top holdings, and sector weights, from its own run. */
112function Fund({ symbol }: { symbol: string }): ReactElement | null {
113 const run = useData('yfinance/fund', { symbol })
114 const m = run.meta
115 const figure = (key: string): number | null => num(m[key])
116 const cells: [string, string | null][] = [
117 ['Net assets', figure('totalAssets') !== null ? d.big(figure('totalAssets')) : null],
118 ['Expense ratio', figure('expenseRatioPercent') !== null ? smallPercent(figure('expenseRatioPercent')) : null],
119 ['Yield', figure('yieldPercent') !== null ? d.percent(figure('yieldPercent')) : null],
120 ['NAV', figure('nav') !== null ? d.price(figure('nav')) : null],
121 ['Year to date', figure('ytdReturnPercent') !== null ? d.signedPercent(figure('ytdReturnPercent')) : null],
122 ['3-year average', figure('threeYearReturnPercent') !== null ? d.signedPercent(figure('threeYearReturnPercent')) : null],
123 ['5-year average', figure('fiveYearReturnPercent') !== null ? d.signedPercent(figure('fiveYearReturnPercent')) : null],
124 ['Beta (3 years)', figure('beta3Year') !== null ? d.fixed(figure('beta3Year')) : null],
125 ['Turnover', figure('turnoverPercent') !== null ? d.percent(figure('turnoverPercent')) : null],
126 ['Category', text(m['category'])],
127 ['Family', text(m['family'])],
128 ['Inception', text(m['inception'])],
129 ]
130 const shown = cells.filter((cell): cell is [string, string] => cell[1] !== null)
131 const holdings = run.data ?? []
132 const sectors = records(m['sectors'])
133 const widest = Math.max(0, ...sectors.map((s) => num(s['percent']) ?? 0))
134 if (!run.error && !run.loading && shown.length === 0 && holdings.length === 0) return null
135 return (
136 <Section title="Fund" run={run} aside={text(m['legalType']) ?? undefined}>
137 <dl className="yf-grid">
138 {shown.map(([label, value]) => (
139 <div key={label} className="yf-cell">
140 <dt className="yf-muted">{label}</dt>
141 <dd className="yf-num" title={value}>
142 {value}
143 </dd>
144 </div>
145 ))}
146 </dl>
147 <div className="yf-two">
148 {holdings.length > 0 && (
149 <table className="yf-table">
150 <thead>
151 <tr>
152 <th colSpan={2}>Top holdings</th>
153 <th className="yf-num">Weight</th>
154 </tr>
155 </thead>
156 <tbody>
157 {holdings.map((h, i) => (
158 <tr key={i}>
159 <td className="yf-strong">{text(h['symbol'])}</td>
160 <td className="yf-name-cell">{text(h['name'])}</td>
161 <td className="yf-num">{d.percent(num(h['percent']))}</td>
162 </tr>
163 ))}
164 </tbody>
165 </table>
166 )}
167 {sectors.length > 0 && (
168 <div className="yf-bars" aria-label="Sector weights">
169 <div className="yf-muted yf-bars-title">Sectors</div>
170 {sectors.map((s) => {
171 const weight = num(s['percent']) ?? 0
172 return (
173 <div key={text(s['sector']) ?? ''} className="yf-bar-row">
174 <span className="yf-bar-label">{text(s['sector'])}</span>
175 <span className="yf-bar-track">
176 <span className="yf-bar-fill" style={{ width: `${widest ? (weight / widest) * 100 : 0}%` }} />
177 </span>
178 <span className="yf-num">{d.percent(weight)}</span>
179 </div>
180 )
181 })}
182 </div>
183 )}
184 </div>
185 </Section>
186 )
187}
188
189function Analysts({ run, price, hint }: { run: SourceData; price: number | null; hint: number | null }): ReactElement | null {
190 const target = record(run.meta['target'])
191 const recommendation = record(run.meta['recommendation'])
192 const month = records(run.meta['trend'])[0]
193 const mix = ratingMix(month)
194 const changes = run.data ?? []
195 const mean = num(target?.['mean'])
196 if (!run.error && !run.loading && mean === null && mix.length === 0 && changes.length === 0) return null
197 const key = text(recommendation?.['key'])
198 const score = num(recommendation?.['mean'])
199 const count = num(target?.['analysts'])
200 const aside = [key && key.replace(/_/g, ' '), score !== null && `${d.fixed(score)} of 5`, count !== null && `${count} analysts`].filter(Boolean).join(' · ')
201 const low = num(target?.['low'])
202 const high = num(target?.['high'])
203 const span = [low, high, price].filter((v): v is number => v !== null)
204 return (
205 <Section title="Analysts" run={run} aside={aside}>
206 {mean !== null && low !== null && high !== null && (
207 <>
208 <RangeBar
209 label={`Price target: low ${d.price(low, hint)}, mean ${d.price(mean, hint)}, high ${d.price(high, hint)}`}
210 low={Math.min(...span)}
211 high={Math.max(...span)}
212 value={price}
213 mark={{ value: mean, title: `Mean target ${d.price(mean, hint)}` }}
214 hint={hint}
215 />
216 <p className="yf-legend yf-muted">
217 <span className="yf-key yf-key-solid" /> price <span className="yf-key yf-key-hollow" /> mean target
218 </p>
219 </>
220 )}
221 {mix.length > 0 && (
222 <>
223 <div className="yf-mix" role="img" aria-label={mix.map((m) => `${m.label} ${m.count}`).join(', ')}>
224 {mix.map((m) => (
225 <span key={m.key} className={`yf-mix-${m.key}`} style={{ width: `${m.share * 100}%` }} title={`${m.label} ${m.count}`} />
226 ))}
227 </div>
228 <p className="yf-legend yf-muted">
229 {mix.map((m) => (
230 <span key={m.key}>
231 <span className={`yf-key yf-mix-${m.key}`} /> {m.label} {m.count}
232 </span>
233 ))}
234 </p>
235 </>
236 )}
237 {changes.length > 0 && (
238 <table className="yf-table yf-fit">
239 <tbody>
240 {changes.map((c, i) => (
241 <tr key={i}>
242 <td className="yf-muted">{text(c['date'])}</td>
243 <td>{text(c['firm'])}</td>
244 <td>{ratingAction(text(c['action']), text(c['from']), text(c['to']))}</td>
245 <td className="yf-num">
246 {num(c['target']) !== null ? d.price(num(c['target']), hint) : ''}
247 {num(c['priorTarget']) !== null && num(c['priorTarget']) !== num(c['target']) && (
248 <span className="yf-muted"> from {d.price(num(c['priorTarget']), hint)}</span>
249 )}
250 </td>
251 </tr>
252 ))}
253 </tbody>
254 </table>
255 )}
256 </Section>
257 )
258}
259
260function Earnings({ run }: { run: SourceData }): ReactElement | null {
261 const next = record(run.meta['next'])
262 const quarters = run.data ?? []
263 if (!run.error && !run.loading && !next && quarters.length === 0) return null
264 const date = text(next?.['date'])
265 const aside = date ? `next ${date}${next?.['estimated'] === true ? ' (estimated)' : ''}` : 'next date not announced'
266 const eps = num(next?.['epsAverage'])
267 const revenue = num(next?.['revenueAverage'])
268 return (
269 <Section title="Earnings" run={run} aside={aside}>
270 {(eps !== null || revenue !== null) && (
271 <p className="yf-line">
272 Expected: {eps !== null && <>EPS <span className="yf-num">{d.fixed(eps)}</span></>}
273 {eps !== null && revenue !== null && ', '}
274 {revenue !== null && <>revenue <span className="yf-num">{d.big(revenue)}</span></>}
275 </p>
276 )}
277 {quarters.length > 0 && (
278 <table className="yf-table yf-fit">
279 <thead>
280 <tr>
281 <th>Quarter</th>
282 <th className="yf-num">EPS estimate</th>
283 <th className="yf-num">Actual</th>
284 <th className="yf-num">Surprise</th>
285 </tr>
286 </thead>
287 <tbody>
288 {quarters.map((q) => (
289 <tr key={text(q['quarter']) ?? ''}>
290 <td>
291 {text(q['fiscalQuarter']) ?? text(q['quarter'])}
292 <span className="yf-muted"> {text(q['reported']) ? `reported ${text(q['reported'])}` : `ended ${text(q['quarter'])}`}</span>
293 </td>
294 <td className="yf-num">{d.fixed(num(q['epsEstimate']))}</td>
295 <td className="yf-num">{d.fixed(num(q['epsActual']))}</td>
296 <td className="yf-num">
297 <Signed value={num(q['surprisePercent'])} percent />
298 </td>
299 </tr>
300 ))}
301 </tbody>
302 </table>
303 )}
304 </Section>
305 )
306}
307
308function Profile({ run }: { run: SourceData }): ReactElement | null {
309 const bridge = useBridge()
310 const [open, setOpen] = useState(false)
311 const meta = run.meta
312 const summary = text(meta['summary'])
313 const website = text(meta['website'])
314 const employees = num(meta['employees'])
315 const facts = [text(meta['sector']), text(meta['industry']), employees !== null && `${d.fixed(employees, 0)} employees`].filter(Boolean).join(' · ')
316 const officers = (run.data ?? []).slice(0, 3)
317 if (!run.error && !run.loading && !summary && !facts) return null
318 return (
319 <Section title="Profile" run={run}>
320 {facts && <p className="yf-line">{facts}</p>}
321 {(text(meta['address']) || website) && (
322 <p className="yf-line yf-muted">
323 {text(meta['address'])}
324 {website?.startsWith('https://') && (
325 <>
326 {text(meta['address']) && ' · '}
327 <button type="button" className="yf-link" onClick={() => void bridge.openLink(website)}>
328 {website.replace(/^https:\/\/(www\.)?/, '').replace(/\/$/, '')}
329 </button>
330 </>
331 )}
332 </p>
333 )}
334 {officers.length > 0 && (
335 <p className="yf-line yf-muted">{officers.map((o) => [text(o['name']), text(o['title'])].filter(Boolean).join(', ')).join(' · ')}</p>
336 )}
337 {summary && (
338 <>
339 <p className={open ? 'yf-summary' : 'yf-summary yf-clamp'}>{summary}</p>
340 <button type="button" className="yf-link yf-more" onClick={() => setOpen(!open)}>
341 {open ? 'Less' : 'More'}
342 </button>
343 </>
344 )}
345 </Section>
346 )
347}