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.

43 KB
1import { plain as f, smallPercent } from '../format.ts'
2import { list, num, percentOf, record, records, text, type Row } from '../values.ts'
3import { capped, parts, toolResult, type ToolResult } from './result.ts'
4import { YahooError } from './yahoo.ts'
5
6// What quoteSummary says about one listing, one tool per group of its modules. quoteSummary writes
7// ratios as fractions; a field here whose name ends in Percent is in percent (7.97 is 7.97%).
8
9export const MODULES = {
10 profile: 'assetProfile,summaryProfile,quoteType,price',
11 statistics: 'summaryDetail,defaultKeyStatistics,financialData,price',
12 earnings: 'earningsHistory,earningsTrend,calendarEvents,earnings,price',
13 analysts: 'financialData,recommendationTrend,upgradeDowngradeHistory,price',
14 holders: 'majorHoldersBreakdown,institutionOwnership,fundOwnership,insiderHolders,insiderTransactions,netSharePurchaseActivity,price',
15 fund: 'topHoldings,fundProfile,defaultKeyStatistics,summaryDetail,quoteType,price',
16} as const
17
18/** The first result, with each module by name. */
19export function summaryOf(json: unknown): Row {
20 const result = records(record(record(json)?.['quoteSummary'])?.['result'])[0]
21 if (!result) throw new YahooError('The market data service answered with no summary.')
22 return result
23}
24
25function mod(result: Row, name: string): Row {
26 return record(result[name]) ?? {}
27}
28
29function nameOf(result: Row): string | null {
30 const price = mod(result, 'price')
31 const type = mod(result, 'quoteType')
32 return text(price['longName']) ?? text(price['shortName']) ?? text(type['longName']) ?? text(type['shortName'])
33}
34
35function typeOf(result: Row): string | null {
36 return text(mod(result, 'quoteType')['quoteType']) ?? text(mod(result, 'price')['quoteType'])
37}
38
39function currencyOf(result: Row): string | null {
40 return (
41 text(mod(result, 'price')['currency']) ??
42 text(mod(result, 'summaryDetail')['currency']) ??
43 text(mod(result, 'financialData')['financialCurrency']) ??
44 text(mod(result, 'earnings')['financialCurrency'])
45 )
46}
47
48function day(value: unknown): string | null {
49 const seconds = num(value)
50 return seconds === null ? null : f.day(seconds)
51}
52
53function spaced(value: unknown): string | null {
54 return text(value)?.replace(/\s+/g, ' ') ?? null
55}
56
57function title(symbol: string, name: string | null): string {
58 return `${symbol}${name ? ` ${name}` : ''}`
59}
60
61// Profile.
62
63export type Officer = {
64 name: string
65 title: string | null
66 age: number | null
67 pay: number | null
68 payYear: number | null
69}
70
71export type Profile = {
72 symbol: string
73 name: string | null
74 type: string | null
75 sector: string | null
76 industry: string | null
77 employees: number | null
78 website: string | null
79 investorRelations: string | null
80 phone: string | null
81 address: string | null
82 /** ISS governance risk, 1 (low) to 10 (high). */
83 governance: { overall: number | null; audit: number | null; board: number | null; compensation: number | null; shareholderRights: number | null; asOf: string | null } | null
84 summary: string | null
85 rows: Officer[]
86}
87
88export function readProfile(symbol: string, json: unknown): Profile {
89 const result = summaryOf(json)
90 const profile = { ...mod(result, 'summaryProfile'), ...mod(result, 'assetProfile') }
91 const place = parts(
92 text(profile['address1']),
93 text(profile['address2']),
94 text(profile['city']),
95 [text(profile['state']), text(profile['zip'])].filter(Boolean).join(' '),
96 text(profile['country']),
97 )
98 const risks = ['overallRisk', 'auditRisk', 'boardRisk', 'compensationRisk', 'shareHolderRightsRisk'].map((key) => num(profile[key]))
99 return {
100 symbol,
101 name: nameOf(result),
102 type: typeOf(result),
103 sector: text(profile['sectorDisp']) ?? text(profile['sector']),
104 industry: text(profile['industryDisp']) ?? text(profile['industry']),
105 employees: num(profile['fullTimeEmployees']),
106 website: text(profile['website']),
107 investorRelations: text(profile['irWebsite']),
108 phone: text(profile['phone']),
109 address: place || null,
110 governance: risks.some((risk) => risk !== null)
111 ? {
112 overall: risks[0]!,
113 audit: risks[1]!,
114 board: risks[2]!,
115 compensation: risks[3]!,
116 shareholderRights: risks[4]!,
117 asOf: day(profile['governanceEpochDate']),
118 }
119 : null,
120 summary: text(profile['longBusinessSummary']),
121 rows: records(profile['companyOfficers'])
122 .map((officer) => ({
123 name: spaced(officer['name']) ?? '',
124 title: spaced(officer['title']),
125 age: num(officer['age']),
126 pay: num(officer['totalPay']),
127 payYear: num(officer['fiscalYear']),
128 }))
129 .filter((officer) => officer.name !== ''),
130 }
131}
132
133export function profileResult(profile: Profile): ToolResult {
134 const g = profile.governance
135 const lines = [
136 `${title(profile.symbol, profile.name)}${profile.type ? ` (${profile.type})` : ''}.`,
137 [
138 profile.sector && `Sector: ${profile.sector}.`,
139 profile.industry && `Industry: ${profile.industry}.`,
140 profile.employees !== null && `Employees: ${f.fixed(profile.employees, 0)}.`,
141 ]
142 .filter(Boolean)
143 .join(' '),
144 [
145 profile.address && `Headquarters: ${profile.address}.`,
146 profile.phone && `Phone: ${profile.phone}.`,
147 profile.website && `Website: ${profile.website}.`,
148 profile.investorRelations && `Investor relations: ${profile.investorRelations}.`,
149 ]
150 .filter(Boolean)
151 .join(' '),
152 g
153 ? `ISS governance risk, 1 (low) to 10 (high)${g.asOf ? `, as of ${g.asOf}` : ''}: ${parts(
154 g.overall !== null && `overall ${g.overall}`,
155 g.audit !== null && `audit ${g.audit}`,
156 g.board !== null && `board ${g.board}`,
157 g.compensation !== null && `compensation ${g.compensation}`,
158 g.shareholderRights !== null && `shareholder rights ${g.shareholderRights}`,
159 )}.`
160 : '',
161 profile.rows.length
162 ? `Officers: ${profile.rows
163 .map((o) => parts(o.name, o.title, o.age !== null && `age ${o.age}`, o.pay !== null && `paid ${f.big(o.pay)}${o.payYear !== null ? ` in ${o.payYear}` : ''}`))
164 .join('; ')}.`
165 : '',
166 profile.summary ? `Business summary: ${profile.summary}` : '',
167 ]
168 return toolResult(lines.filter(Boolean).join('\n'), profile)
169}
170
171// Statistics.
172
173type Kind = 'money' | 'big' | 'count' | 'ratio' | 'percent' | 'percentUnits' | 'date' | 'text'
174
175export type Stat = {
176 group: string
177 key: string
178 label: string
179 value: number | string
180 unit: 'currency' | 'number' | 'percent' | 'date' | 'text'
181 display: string
182}
183
184export type Statistics = {
185 symbol: string
186 name: string | null
187 currency: string | null
188 /** Every figure there is, by key, as in the rows. */
189 stats: Record<string, number | string>
190 rows: Stat[]
191}
192
193/** Group, key, label, module, Yahoo's field, and what kind of figure it is. */
194const STATS: [string, string, string, string, string, Kind][] = [
195 ['Valuation', 'marketCap', 'Market cap', 'summaryDetail', 'marketCap', 'big'],
196 ['Valuation', 'enterpriseValue', 'Enterprise value', 'defaultKeyStatistics', 'enterpriseValue', 'big'],
197 ['Valuation', 'trailingPE', 'Trailing P/E', 'summaryDetail', 'trailingPE', 'ratio'],
198 ['Valuation', 'forwardPE', 'Forward P/E', 'summaryDetail', 'forwardPE', 'ratio'],
199 ['Valuation', 'pegRatio', 'PEG ratio', 'defaultKeyStatistics', 'pegRatio', 'ratio'],
200 ['Valuation', 'priceToSales', 'Price/sales (ttm)', 'summaryDetail', 'priceToSalesTrailing12Months', 'ratio'],
201 ['Valuation', 'priceToBook', 'Price/book', 'defaultKeyStatistics', 'priceToBook', 'ratio'],
202 ['Valuation', 'enterpriseToRevenue', 'EV/revenue', 'defaultKeyStatistics', 'enterpriseToRevenue', 'ratio'],
203 ['Valuation', 'enterpriseToEbitda', 'EV/EBITDA', 'defaultKeyStatistics', 'enterpriseToEbitda', 'ratio'],
204 ['Profitability', 'profitMargin', 'Profit margin', 'financialData', 'profitMargins', 'percent'],
205 ['Profitability', 'operatingMargin', 'Operating margin', 'financialData', 'operatingMargins', 'percent'],
206 ['Profitability', 'grossMargin', 'Gross margin', 'financialData', 'grossMargins', 'percent'],
207 ['Profitability', 'ebitdaMargin', 'EBITDA margin', 'financialData', 'ebitdaMargins', 'percent'],
208 ['Profitability', 'returnOnAssets', 'Return on assets', 'financialData', 'returnOnAssets', 'percent'],
209 ['Profitability', 'returnOnEquity', 'Return on equity', 'financialData', 'returnOnEquity', 'percent'],
210 ['Income', 'revenue', 'Revenue (ttm)', 'financialData', 'totalRevenue', 'big'],
211 ['Income', 'revenuePerShare', 'Revenue per share', 'financialData', 'revenuePerShare', 'money'],
212 ['Income', 'revenueGrowth', 'Revenue growth (quarter, year over year)', 'financialData', 'revenueGrowth', 'percent'],
213 ['Income', 'grossProfit', 'Gross profit', 'financialData', 'grossProfits', 'big'],
214 ['Income', 'ebitda', 'EBITDA', 'financialData', 'ebitda', 'big'],
215 ['Income', 'netIncome', 'Net income to common (ttm)', 'defaultKeyStatistics', 'netIncomeToCommon', 'big'],
216 ['Income', 'trailingEps', 'EPS (ttm)', 'defaultKeyStatistics', 'trailingEps', 'money'],
217 ['Income', 'forwardEps', 'Forward EPS', 'defaultKeyStatistics', 'forwardEps', 'money'],
218 ['Income', 'earningsGrowth', 'Earnings growth (quarter, year over year)', 'financialData', 'earningsGrowth', 'percent'],
219 ['Balance sheet', 'totalCash', 'Total cash', 'financialData', 'totalCash', 'big'],
220 ['Balance sheet', 'cashPerShare', 'Cash per share', 'financialData', 'totalCashPerShare', 'money'],
221 ['Balance sheet', 'totalDebt', 'Total debt', 'financialData', 'totalDebt', 'big'],
222 ['Balance sheet', 'debtToEquity', 'Debt/equity (%)', 'financialData', 'debtToEquity', 'ratio'],
223 ['Balance sheet', 'currentRatio', 'Current ratio', 'financialData', 'currentRatio', 'ratio'],
224 ['Balance sheet', 'quickRatio', 'Quick ratio', 'financialData', 'quickRatio', 'ratio'],
225 ['Balance sheet', 'bookValue', 'Book value per share', 'defaultKeyStatistics', 'bookValue', 'money'],
226 ['Cash flow', 'operatingCashflow', 'Operating cash flow (ttm)', 'financialData', 'operatingCashflow', 'big'],
227 ['Cash flow', 'freeCashflow', 'Levered free cash flow (ttm)', 'financialData', 'freeCashflow', 'big'],
228 ['Trading', 'price', 'Price', 'financialData', 'currentPrice', 'money'],
229 ['Trading', 'beta', 'Beta (5 years, monthly)', 'summaryDetail', 'beta', 'ratio'],
230 ['Trading', 'fiftyTwoWeekChange', '52-week change', 'defaultKeyStatistics', '52WeekChange', 'percent'],
231 ['Trading', 'sp500FiftyTwoWeekChange', 'S&P 500 52-week change', 'defaultKeyStatistics', 'SandP52WeekChange', 'percent'],
232 ['Trading', 'fiftyTwoWeekHigh', '52-week high', 'summaryDetail', 'fiftyTwoWeekHigh', 'money'],
233 ['Trading', 'fiftyTwoWeekLow', '52-week low', 'summaryDetail', 'fiftyTwoWeekLow', 'money'],
234 ['Trading', 'fiftyDayAverage', '50-day average', 'summaryDetail', 'fiftyDayAverage', 'money'],
235 ['Trading', 'twoHundredDayAverage', '200-day average', 'summaryDetail', 'twoHundredDayAverage', 'money'],
236 ['Trading', 'allTimeHigh', 'All-time high', 'summaryDetail', 'allTimeHigh', 'money'],
237 ['Trading', 'volume', 'Volume', 'summaryDetail', 'volume', 'count'],
238 ['Trading', 'averageVolume', 'Average volume (3 months)', 'summaryDetail', 'averageVolume', 'count'],
239 ['Trading', 'averageVolume10Day', 'Average volume (10 days)', 'summaryDetail', 'averageVolume10days', 'count'],
240 ['Shares', 'sharesOutstanding', 'Shares outstanding', 'defaultKeyStatistics', 'sharesOutstanding', 'count'],
241 ['Shares', 'floatShares', 'Float', 'defaultKeyStatistics', 'floatShares', 'count'],
242 ['Shares', 'heldByInsiders', 'Held by insiders', 'defaultKeyStatistics', 'heldPercentInsiders', 'percent'],
243 ['Shares', 'heldByInstitutions', 'Held by institutions', 'defaultKeyStatistics', 'heldPercentInstitutions', 'percent'],
244 ['Shares', 'sharesShort', 'Shares short', 'defaultKeyStatistics', 'sharesShort', 'count'],
245 ['Shares', 'shortRatio', 'Short ratio (days to cover)', 'defaultKeyStatistics', 'shortRatio', 'ratio'],
246 ['Shares', 'shortPercentOfFloat', 'Short interest, % of float', 'defaultKeyStatistics', 'shortPercentOfFloat', 'percent'],
247 ['Shares', 'shortInterestDate', 'Short interest as of', 'defaultKeyStatistics', 'dateShortInterest', 'date'],
248 ['Dividends', 'dividendRate', 'Forward dividend rate', 'summaryDetail', 'dividendRate', 'money'],
249 ['Dividends', 'dividendYield', 'Forward dividend yield', 'summaryDetail', 'dividendYield', 'percent'],
250 ['Dividends', 'trailingDividendRate', 'Trailing dividend rate', 'summaryDetail', 'trailingAnnualDividendRate', 'money'],
251 ['Dividends', 'trailingDividendYield', 'Trailing dividend yield', 'summaryDetail', 'trailingAnnualDividendYield', 'percent'],
252 ['Dividends', 'fiveYearAverageDividendYield', '5-year average dividend yield', 'summaryDetail', 'fiveYearAvgDividendYield', 'percentUnits'],
253 ['Dividends', 'payoutRatio', 'Payout ratio', 'summaryDetail', 'payoutRatio', 'percent'],
254 ['Dividends', 'exDividendDate', 'Ex-dividend date', 'summaryDetail', 'exDividendDate', 'date'],
255 ['Dividends', 'lastSplit', 'Last split', 'defaultKeyStatistics', 'lastSplitFactor', 'text'],
256 ['Dividends', 'lastSplitDate', 'Last split date', 'defaultKeyStatistics', 'lastSplitDate', 'date'],
257 ['Fiscal year', 'fiscalYearEnd', 'Last fiscal year ended', 'defaultKeyStatistics', 'lastFiscalYearEnd', 'date'],
258 ['Fiscal year', 'mostRecentQuarter', 'Most recent quarter', 'defaultKeyStatistics', 'mostRecentQuarter', 'date'],
259]
260
261function stat(kind: Kind, raw: unknown): { value: number | string; unit: Stat['unit']; display: string } | null {
262 if (kind === 'text') {
263 const value = text(raw)
264 return value === null ? null : { value, unit: 'text', display: value }
265 }
266 if (kind === 'date') {
267 const value = day(raw)
268 return value === null ? null : { value, unit: 'date', display: value }
269 }
270 const n = num(raw)
271 if (n === null) return null
272 switch (kind) {
273 case 'money':
274 return { value: n, unit: 'currency', display: f.fixed(n) }
275 case 'big':
276 return { value: n, unit: 'currency', display: f.big(n) }
277 case 'count':
278 return { value: n, unit: 'number', display: f.big(n) }
279 case 'ratio':
280 return { value: n, unit: 'number', display: f.fixed(n) }
281 case 'percent': {
282 const value = percentOf(n)!
283 return { value, unit: 'percent', display: f.percent(value) }
284 }
285 case 'percentUnits':
286 return { value: n, unit: 'percent', display: f.percent(n) }
287 }
288}
289
290export function readStatistics(symbol: string, json: unknown): Statistics {
291 const result = summaryOf(json)
292 const rows: Stat[] = []
293 for (const [group, key, label, module, field, kind] of STATS) {
294 const read = stat(kind, mod(result, module)[field])
295 // Yahoo writes a margin it has no figure for (a bank's gross margin) as exactly 0.
296 if (!read || (group === 'Profitability' && read.value === 0)) continue
297 rows.push({ group, key, label, ...read })
298 }
299 return {
300 symbol,
301 name: nameOf(result),
302 currency: currencyOf(result),
303 stats: Object.fromEntries(rows.map((row) => [row.key, row.value])),
304 rows,
305 }
306}
307
308export function statisticsResult(statistics: Statistics): ToolResult {
309 const groups = new Map<string, Stat[]>()
310 for (const row of statistics.rows) groups.set(row.group, [...(groups.get(row.group) ?? []), row])
311 const head = `${title(statistics.symbol, statistics.name)} key statistics${statistics.currency ? `, in ${statistics.currency}` : ''}:`
312 if (!groups.size) return toolResult(`No key statistics for ${statistics.symbol}.`, statistics)
313 const lines = [...groups].map(([group, rows]) => `${group}: ${rows.map((row) => `${row.label} ${row.display}`).join('; ')}.`)
314 return toolResult([head, ...lines].join('\n'), statistics)
315}
316
317// Earnings.
318
319export type EarningsQuarter = {
320 /** When the quarter ended. */
321 quarter: string
322 /** The company's fiscal quarter: "Q3 2026". */
323 fiscalQuarter: string | null
324 reported: string | null
325 epsEstimate: number | null
326 epsActual: number | null
327 epsDifference: number | null
328 surprisePercent: number | null
329}
330
331export type Estimate = {
332 /** Yahoo's period: 0q, +1q, 0y, +1y. */
333 period: string
334 label: string
335 endDate: string | null
336 growthPercent: number | null
337 eps: { average: number | null; low: number | null; high: number | null; yearAgo: number | null; analysts: number | null; growthPercent: number | null }
338 revenue: { average: number | null; low: number | null; high: number | null; yearAgo: number | null; analysts: number | null; growthPercent: number | null }
339 epsTrend: { current: number | null; days7Ago: number | null; days30Ago: number | null; days60Ago: number | null; days90Ago: number | null }
340 revisions: { upLast7Days: number | null; upLast30Days: number | null; downLast7Days: number | null; downLast30Days: number | null }
341}
342
343export type Earnings = {
344 symbol: string
345 name: string | null
346 currency: string | null
347 next: {
348 date: string | null
349 /** The last day of the window when Yahoo gives one. */
350 endDate: string | null
351 /** Whether the date is Yahoo's estimate rather than the company's. */
352 estimated: boolean
353 epsAverage: number | null
354 epsLow: number | null
355 epsHigh: number | null
356 revenueAverage: number | null
357 revenueLow: number | null
358 revenueHigh: number | null
359 } | null
360 lastCall: string | null
361 exDividendDate: string | null
362 dividendDate: string | null
363 estimates: Estimate[]
364 annual: { year: number; revenue: number | null; earnings: number | null }[]
365 /** The last reported quarters, newest first. */
366 rows: EarningsQuarter[]
367}
368
369const PERIODS: Record<string, string> = {
370 '0q': 'Current quarter',
371 '+1q': 'Next quarter',
372 '0y': 'Current fiscal year',
373 '+1y': 'Next fiscal year',
374 '+5y': 'Next five years (per year)',
375 '-5y': 'Past five years (per year)',
376}
377
378function fiscal(label: string | null): string | null {
379 const match = label?.match(/^([1-4])Q(\d{4})$/)
380 return match ? `Q${match[1]} ${match[2]}` : label
381}
382
383export function readEarnings(symbol: string, json: unknown): Earnings {
384 const result = summaryOf(json)
385 const calendar = mod(result, 'calendarEvents')
386 const coming = record(calendar['earnings']) ?? {}
387 const dates = list(coming['earningsDate'])
388 const chart = record(mod(result, 'earnings')['earningsChart']) ?? {}
389 const labels = new Map<string, { fiscalQuarter: string | null; reported: string | null }>()
390 for (const q of records(chart['quarterly'])) {
391 const end = day(q['periodEndDate'])
392 if (end) labels.set(end, { fiscalQuarter: fiscal(text(q['fiscalQuarter'])), reported: day(q['reportedDate']) })
393 }
394 const rows = records(mod(result, 'earningsHistory')['history'])
395 .map((h) => {
396 const quarter = day(h['quarter'])
397 if (!quarter) return null
398 const label = labels.get(quarter)
399 return {
400 quarter,
401 fiscalQuarter: label?.fiscalQuarter ?? null,
402 reported: label?.reported ?? null,
403 epsEstimate: num(h['epsEstimate']),
404 epsActual: num(h['epsActual']),
405 epsDifference: num(h['epsDifference']),
406 surprisePercent: percentOf(num(h['surprisePercent'])),
407 }
408 })
409 .filter((row): row is EarningsQuarter => row !== null)
410 .sort((a, b) => b.quarter.localeCompare(a.quarter))
411 const estimates = records(mod(result, 'earningsTrend')['trend']).map((t): Estimate => {
412 const eps = record(t['earningsEstimate']) ?? {}
413 const revenue = record(t['revenueEstimate']) ?? {}
414 const trend = record(t['epsTrend']) ?? {}
415 const revisions = record(t['epsRevisions']) ?? {}
416 const period = text(t['period']) ?? ''
417 return {
418 period,
419 label: PERIODS[period] ?? period,
420 endDate: text(t['endDate']),
421 growthPercent: percentOf(num(t['growth'])),
422 eps: {
423 average: num(eps['avg']),
424 low: num(eps['low']),
425 high: num(eps['high']),
426 yearAgo: num(eps['yearAgoEps']),
427 analysts: num(eps['numberOfAnalysts']),
428 growthPercent: percentOf(num(eps['growth'])),
429 },
430 revenue: {
431 average: num(revenue['avg']),
432 low: num(revenue['low']),
433 high: num(revenue['high']),
434 yearAgo: num(revenue['yearAgoRevenue']),
435 analysts: num(revenue['numberOfAnalysts']),
436 growthPercent: percentOf(num(revenue['growth'])),
437 },
438 epsTrend: {
439 current: num(trend['current']),
440 days7Ago: num(trend['7daysAgo']),
441 days30Ago: num(trend['30daysAgo']),
442 days60Ago: num(trend['60daysAgo']),
443 days90Ago: num(trend['90daysAgo']),
444 },
445 revisions: {
446 upLast7Days: num(revisions['upLast7days']),
447 upLast30Days: num(revisions['upLast30days']),
448 downLast7Days: num(revisions['downLast7Days'] ?? revisions['downLast7days']),
449 downLast30Days: num(revisions['downLast30days']),
450 },
451 }
452 })
453 const annual = records(record(mod(result, 'earnings')['financialsChart'])?.['yearly'])
454 .map((y) => ({ year: num(y['date']), revenue: num(y['revenue']), earnings: num(y['earnings']) }))
455 .filter((y): y is { year: number; revenue: number | null; earnings: number | null } => y.year !== null)
456 const date = day(dates[0])
457 return {
458 symbol,
459 name: nameOf(result),
460 currency: currencyOf(result),
461 next: date
462 ? {
463 date,
464 endDate: dates.length > 1 ? day(dates.at(-1)) : null,
465 estimated: coming['isEarningsDateEstimate'] === true,
466 epsAverage: num(coming['earningsAverage']),
467 epsLow: num(coming['earningsLow']),
468 epsHigh: num(coming['earningsHigh']),
469 revenueAverage: num(coming['revenueAverage']),
470 revenueLow: num(coming['revenueLow']),
471 revenueHigh: num(coming['revenueHigh']),
472 }
473 : null,
474 lastCall: day(list(coming['earningsCallDate']).at(-1)),
475 exDividendDate: day(calendar['exDividendDate']),
476 dividendDate: day(calendar['dividendDate']),
477 estimates,
478 annual,
479 rows,
480 }
481}
482
483function spread(average: number | null, low: number | null, high: number | null, show: (n: number | null) => string): string | null {
484 if (average === null) return null
485 return low !== null && high !== null ? `${show(average)} (${show(low)} to ${show(high)})` : show(average)
486}
487
488export function earningsResult(earnings: Earnings): ToolResult {
489 const { next } = earnings
490 const money = (n: number | null): string => f.fixed(n)
491 const lines = [`${title(earnings.symbol, earnings.name)} earnings${earnings.currency ? `, in ${earnings.currency}` : ''}:`]
492 if (next) {
493 const when = next.endDate ? `${next.date} to ${next.endDate}` : next.date
494 const expected = parts(
495 spread(next.epsAverage, next.epsLow, next.epsHigh, money) && `EPS ${spread(next.epsAverage, next.epsLow, next.epsHigh, money)}`,
496 spread(next.revenueAverage, next.revenueLow, next.revenueHigh, f.big) && `revenue ${spread(next.revenueAverage, next.revenueLow, next.revenueHigh, f.big)}`,
497 )
498 lines.push(`Next report: ${when} (${next.estimated ? 'estimated' : 'confirmed'}).${expected ? ` Expected ${expected}.` : ''}`)
499 } else {
500 lines.push('Next report: not announced.')
501 }
502 const dates = [
503 earnings.lastCall && `Last earnings call: ${earnings.lastCall}.`,
504 earnings.exDividendDate && `Ex-dividend date: ${earnings.exDividendDate}.`,
505 earnings.dividendDate && `Dividend paid: ${earnings.dividendDate}.`,
506 ].filter(Boolean)
507 if (dates.length) lines.push(dates.join(' '))
508 if (earnings.rows.length) {
509 lines.push('Last quarters, newest first (EPS estimate, actual, surprise):')
510 for (const q of earnings.rows) {
511 const name = parts(q.fiscalQuarter ?? `Quarter ended ${q.quarter}`, q.fiscalQuarter && `ended ${q.quarter}`, q.reported && `reported ${q.reported}`)
512 lines.push(`${name}: estimate ${money(q.epsEstimate)}, actual ${money(q.epsActual)}, surprise ${f.signedPercent(q.surprisePercent)}`)
513 }
514 }
515 if (earnings.estimates.length) {
516 lines.push('Analyst estimates:')
517 for (const e of earnings.estimates) {
518 const detail = (x: Estimate['eps'], show: (n: number | null) => string): string =>
519 parts(
520 x.low !== null && x.high !== null && `${show(x.low)} to ${show(x.high)}`,
521 x.analysts !== null && `${x.analysts} analysts`,
522 x.yearAgo !== null && `a year ago ${show(x.yearAgo)}`,
523 x.growthPercent !== null && f.signedPercent(x.growthPercent),
524 )
525 const eps = e.eps.average !== null ? `EPS ${money(e.eps.average)}${detail(e.eps, money) ? ` (${detail(e.eps, money)})` : ''}` : null
526 const revenue = e.revenue.average !== null ? `revenue ${f.big(e.revenue.average)}${detail(e.revenue, f.big) ? ` (${detail(e.revenue, f.big)})` : ''}` : null
527 const trend = e.epsTrend.days90Ago !== null ? `EPS estimate 90 days ago ${money(e.epsTrend.days90Ago)}` : null
528 const revisions =
529 e.revisions.upLast30Days !== null || e.revisions.downLast30Days !== null
530 ? `revisions in 30 days: ${e.revisions.upLast30Days ?? 0} up, ${e.revisions.downLast30Days ?? 0} down`
531 : null
532 const body = [eps, revenue, trend, revisions].filter(Boolean).join('; ')
533 const growth = !eps && e.growthPercent !== null ? `growth ${f.signedPercent(e.growthPercent)}` : ''
534 lines.push(`${e.label}${e.endDate ? `, ends ${e.endDate}` : ''}: ${body || growth}.`)
535 }
536 }
537 if (earnings.annual.length) {
538 lines.push(`Annual revenue and earnings: ${earnings.annual.map((y) => `${y.year} ${f.big(y.revenue)} and ${f.big(y.earnings)}`).join('; ')}.`)
539 }
540 return toolResult(lines.join('\n'), earnings)
541}
542
543// Analysts.
544
545export type Rating = {
546 date: string | null
547 firm: string
548 /** upgrade, downgrade, maintain, initiate, reiterate, or Yahoo's own word. */
549 action: string | null
550 from: string | null
551 to: string | null
552 targetAction: string | null
553 target: number | null
554 priorTarget: number | null
555}
556
557export type Analysts = {
558 symbol: string
559 name: string | null
560 currency: string | null
561 price: number | null
562 target: { low: number | null; mean: number | null; median: number | null; high: number | null; analysts: number | null }
563 /** Yahoo's consensus: key (strong_buy … sell) and mean, 1 (strong buy) to 5 (strong sell). */
564 recommendation: { key: string | null; mean: number | null }
565 trend: { period: string; label: string; strongBuy: number; buy: number; hold: number; sell: number; strongSell: number }[]
566 /** Rating changes, newest first. */
567 rows: Rating[]
568}
569
570const ACTIONS: Record<string, string> = { up: 'upgrade', down: 'downgrade', main: 'maintain', init: 'initiate', reit: 'reiterate' }
571
572function monthLabel(period: string): string {
573 const back = Number(period.match(/^-?(\d+)m$/)?.[1] ?? NaN)
574 if (back === 0) return 'this month'
575 if (back === 1) return '1 month ago'
576 return Number.isFinite(back) ? `${back} months ago` : period
577}
578
579export function readAnalysts(symbol: string, json: unknown, limit: number): Analysts {
580 const result = summaryOf(json)
581 const data = mod(result, 'financialData')
582 const positive = (value: unknown): number | null => {
583 const n = num(value)
584 return n !== null && n > 0 ? n : null
585 }
586 const rows = records(mod(result, 'upgradeDowngradeHistory')['history'])
587 .map((h) => {
588 const action = text(h['action'])
589 return {
590 at: num(h['epochGradeDate']) ?? 0,
591 rating: {
592 date: day(h['epochGradeDate']),
593 firm: text(h['firm']) ?? '',
594 action: action ? (ACTIONS[action] ?? action) : null,
595 from: text(h['fromGrade']),
596 to: text(h['toGrade']),
597 targetAction: text(h['priceTargetAction']),
598 target: positive(h['currentPriceTarget']),
599 priorTarget: positive(h['priorPriceTarget']),
600 },
601 }
602 })
603 .filter((h) => h.rating.firm !== '')
604 .sort((a, b) => b.at - a.at)
605 .slice(0, limit)
606 .map((h) => h.rating)
607 return {
608 symbol,
609 name: nameOf(result),
610 currency: currencyOf(result),
611 price: num(data['currentPrice']) ?? num(mod(result, 'price')['regularMarketPrice']),
612 target: {
613 low: num(data['targetLowPrice']),
614 mean: num(data['targetMeanPrice']),
615 median: num(data['targetMedianPrice']),
616 high: num(data['targetHighPrice']),
617 analysts: num(data['numberOfAnalystOpinions']),
618 },
619 recommendation: { key: text(data['recommendationKey']), mean: num(data['recommendationMean']) },
620 trend: records(mod(result, 'recommendationTrend')['trend']).map((t) => {
621 const period = text(t['period']) ?? ''
622 return {
623 period,
624 label: monthLabel(period),
625 strongBuy: num(t['strongBuy']) ?? 0,
626 buy: num(t['buy']) ?? 0,
627 hold: num(t['hold']) ?? 0,
628 sell: num(t['sell']) ?? 0,
629 strongSell: num(t['strongSell']) ?? 0,
630 }
631 }),
632 rows,
633 }
634}
635
636function ratingLine(r: Rating): string {
637 let what: string
638 switch (r.action) {
639 case 'upgrade':
640 what = `upgrades to ${r.to ?? '?'}${r.from ? ` from ${r.from}` : ''}`
641 break
642 case 'downgrade':
643 what = `downgrades to ${r.to ?? '?'}${r.from ? ` from ${r.from}` : ''}`
644 break
645 case 'initiate':
646 what = `initiates at ${r.to ?? '?'}`
647 break
648 case 'maintain':
649 what = `maintains ${r.to ?? '?'}`
650 break
651 case 'reiterate':
652 what = `reiterates ${r.to ?? '?'}`
653 break
654 default:
655 what = `${r.action ?? 'rates'} ${r.to ?? '?'}`
656 }
657 const target =
658 r.target !== null
659 ? `, target ${f.price(r.target)}${r.priorTarget !== null && r.priorTarget !== r.target ? ` from ${f.price(r.priorTarget)}` : ''}${r.targetAction ? ` (${r.targetAction.toLowerCase()})` : ''}`
660 : ''
661 return `${[r.date, r.firm].filter(Boolean).join(' ')} ${what}${target}`
662}
663
664export function analystsResult(analysts: Analysts): ToolResult {
665 const { target, recommendation } = analysts
666 const lines = [`${title(analysts.symbol, analysts.name)} analyst ratings:`]
667 const price = analysts.price !== null ? `Price ${f.price(analysts.price)}${analysts.currency ? ` ${analysts.currency}` : ''}.` : ''
668 if (target.mean !== null) {
669 const away = analysts.price ? `; the mean is ${f.signedPercent(((target.mean - analysts.price) / analysts.price) * 100)} from the price` : ''
670 const from = target.analysts !== null ? ` from ${target.analysts} analysts` : ''
671 lines.push(
672 `${price ? `${price} ` : ''}Price targets${from}: ${parts(
673 target.low !== null && `low ${f.price(target.low)}`,
674 `mean ${f.price(target.mean)}`,
675 target.median !== null && `median ${f.price(target.median)}`,
676 target.high !== null && `high ${f.price(target.high)}`,
677 )}${away}.`,
678 )
679 } else {
680 lines.push(`${price ? `${price} ` : ''}No analyst price targets.`)
681 }
682 if (recommendation.key || recommendation.mean !== null) {
683 lines.push(
684 `Consensus: ${parts(recommendation.key?.replace(/_/g, ' '), recommendation.mean !== null && `mean ${f.fixed(recommendation.mean)} on a scale from 1 (strong buy) to 5 (strong sell)`)}.`,
685 )
686 }
687 if (analysts.trend.length) {
688 lines.push(
689 `Ratings by month (strong buy, buy, hold, sell, strong sell): ${analysts.trend
690 .map((t) => `${t.label} ${[t.strongBuy, t.buy, t.hold, t.sell, t.strongSell].join(', ')}`)
691 .join('; ')}.`,
692 )
693 }
694 if (analysts.rows.length) {
695 lines.push('Rating changes, newest first:')
696 lines.push(...analysts.rows.map(ratingLine))
697 }
698 return toolResult(lines.join('\n'), analysts)
699}
700
701// Holders.
702
703export type Holding = {
704 holder: string
705 shares: number | null
706 percent: number | null
707 value: number | null
708 reported: string | null
709 changePercent: number | null
710}
711
712export type Insider = {
713 name: string
714 relation: string | null
715 lastTransaction: string | null
716 date: string | null
717 shares: number | null
718 indirectShares: number | null
719}
720
721export type InsiderTrade = {
722 date: string | null
723 insider: string
724 relation: string | null
725 description: string | null
726 shares: number | null
727 value: number | null
728 ownership: 'direct' | 'indirect' | null
729}
730
731export type Holders = {
732 symbol: string
733 name: string | null
734 breakdown: { insidersPercent: number | null; institutionsPercent: number | null; institutionsFloatPercent: number | null; institutions: number | null } | null
735 funds: Holding[]
736 insiders: Insider[]
737 /** Newest first. */
738 transactions: InsiderTrade[]
739 netPurchases: {
740 period: string | null
741 buys: number | null
742 buyShares: number | null
743 sells: number | null
744 sellShares: number | null
745 netShares: number | null
746 insiderShares: number | null
747 institutionalNetShares: number | null
748 } | null
749 /** The top institutions. */
750 rows: Holding[]
751}
752
753function holdings(value: unknown, limit: number): Holding[] {
754 return records(record(value)?.['ownershipList'])
755 .map((o) => ({
756 holder: spaced(o['organization']) ?? '',
757 shares: num(o['position']),
758 percent: percentOf(num(o['pctHeld'])),
759 value: num(o['value']),
760 reported: day(o['reportDate']),
761 changePercent: percentOf(num(o['pctChange'])),
762 }))
763 .filter((h) => h.holder !== '')
764 .slice(0, limit)
765}
766
767export function readHolders(symbol: string, json: unknown, limit: number): Holders {
768 const result = summaryOf(json)
769 const major = mod(result, 'majorHoldersBreakdown')
770 const net = mod(result, 'netSharePurchaseActivity')
771 const breakdown = {
772 insidersPercent: percentOf(num(major['insidersPercentHeld'])),
773 institutionsPercent: percentOf(num(major['institutionsPercentHeld'])),
774 institutionsFloatPercent: percentOf(num(major['institutionsFloatPercentHeld'])),
775 institutions: num(major['institutionsCount']),
776 }
777 const ownership = (value: unknown): InsiderTrade['ownership'] => (value === 'D' ? 'direct' : value === 'I' ? 'indirect' : null)
778 return {
779 symbol,
780 name: nameOf(result),
781 breakdown: Object.values(breakdown).some((v) => v !== null) ? breakdown : null,
782 funds: holdings(result['fundOwnership'], limit),
783 insiders: records(mod(result, 'insiderHolders')['holders'])
784 .map((h) => ({
785 name: spaced(h['name']) ?? '',
786 relation: spaced(h['relation']),
787 lastTransaction: spaced(h['transactionDescription']),
788 date: day(h['latestTransDate']),
789 shares: num(h['positionDirect']),
790 indirectShares: num(h['positionIndirect']),
791 }))
792 .filter((h) => h.name !== '')
793 .slice(0, limit),
794 transactions: records(mod(result, 'insiderTransactions')['transactions'])
795 .map((t) => ({
796 at: num(t['startDate']) ?? 0,
797 trade: {
798 date: day(t['startDate']),
799 insider: spaced(t['filerName']) ?? '',
800 relation: spaced(t['filerRelation']),
801 description: spaced(t['transactionText']) ?? spaced(t['moneyText']),
802 shares: num(t['shares']),
803 value: num(t['value']),
804 ownership: ownership(t['ownership']),
805 },
806 }))
807 .filter((t) => t.trade.insider !== '')
808 .sort((a, b) => b.at - a.at)
809 .slice(0, limit)
810 .map((t) => t.trade),
811 netPurchases:
812 Object.keys(net).length > 1
813 ? {
814 period: text(net['period']),
815 buys: num(net['buyInfoCount']),
816 buyShares: num(net['buyInfoShares']),
817 sells: num(net['sellInfoCount']),
818 sellShares: num(net['sellInfoShares']),
819 netShares: num(net['netInfoShares']),
820 insiderShares: num(net['totalInsiderShares']),
821 institutionalNetShares: num(net['netInstSharesBuying']),
822 }
823 : null,
824 rows: holdings(result['institutionOwnership'], limit),
825 }
826}
827
828function holdingLine(h: Holding): string {
829 return `${h.holder}: ${parts(f.big(h.shares), f.percent(h.percent), f.big(h.value), h.reported, h.changePercent !== null && f.signedPercent(h.changePercent))}`
830}
831
832export function holdersResult(holders: Holders): ToolResult {
833 const lines = [`${title(holders.symbol, holders.name)} holders:`]
834 const b = holders.breakdown
835 if (b) {
836 const institutions = b.institutionsPercent !== null
837 ? `institutions ${f.percent(b.institutionsPercent)}${b.institutionsFloatPercent !== null ? ` (${f.percent(b.institutionsFloatPercent)} of the float)` : ''}`
838 : null
839 const held = [b.insidersPercent !== null && `insiders hold ${f.percent(b.insidersPercent)}`, institutions].filter(Boolean).join(' and ')
840 const count = b.institutions !== null ? `, across ${f.fixed(b.institutions, 0)} institutions` : ''
841 if (held) lines.push(`${held.charAt(0).toUpperCase()}${held.slice(1)}${count}.`)
842 }
843 if (holders.rows.length) lines.push('Top institutions (shares, % held, value, as of, change):', ...holders.rows.map(holdingLine))
844 if (holders.funds.length) lines.push('Top funds (shares, % held, value, as of, change):', ...holders.funds.map(holdingLine))
845 if (holders.insiders.length) {
846 lines.push('Insiders (latest transaction, shares held):')
847 for (const i of holders.insiders) {
848 const last = i.lastTransaction ? `${i.lastTransaction}${i.date ? ` on ${i.date}` : ''}` : null
849 const held = parts(i.shares !== null && `holds ${f.big(i.shares)} shares directly`, i.indirectShares !== null && `${f.big(i.indirectShares)} indirectly`)
850 lines.push(`${parts(i.name, i.relation)}: ${[last, held].filter(Boolean).join('; ')}`)
851 }
852 }
853 if (holders.transactions.length) {
854 lines.push('Insider transactions, newest first:')
855 for (const t of holders.transactions) {
856 const amounts = parts(t.shares !== null && `${f.big(t.shares)} shares`, t.value !== null && t.value !== 0 && f.big(t.value), t.ownership)
857 lines.push(`${t.date ? `${t.date} ` : ''}${parts(t.insider, t.relation)}: ${[t.description, amounts].filter(Boolean).join(' ')}`)
858 }
859 }
860 const n = holders.netPurchases
861 if (n) {
862 const trades = parts(
863 n.buys !== null && `${n.buys} buys${n.buyShares !== null ? ` (${f.big(n.buyShares)} shares)` : ''}`,
864 n.sells !== null && `${n.sells} sales${n.sellShares !== null ? ` (${f.big(n.sellShares)} shares)` : ''}`,
865 n.netShares !== null && `net ${f.big(n.netShares)} shares`,
866 )
867 const held = n.insiderShares !== null ? `; insiders hold ${f.big(n.insiderShares)} shares` : ''
868 const institutional =
869 n.institutionalNetShares !== null && n.institutionalNetShares !== 0
870 ? ` Institutions were net ${n.institutionalNetShares > 0 ? 'buyers' : 'sellers'} of ${f.big(Math.abs(n.institutionalNetShares))} shares.`
871 : ''
872 lines.push(`Insider trades${n.period ? ` over ${n.period}` : ''}: ${trades}${held}.${institutional}`)
873 }
874 return toolResult(capped([lines[0]!], lines.slice(1), (left) => `(${left} more lines left out.)`), holders)
875}
876
877// Funds.
878
879export type Fund = {
880 symbol: string
881 name: string | null
882 type: string | null
883 currency: string | null
884 family: string | null
885 category: string | null
886 legalType: string | null
887 inception: string | null
888 totalAssets: number | null
889 expenseRatioPercent: number | null
890 turnoverPercent: number | null
891 yieldPercent: number | null
892 nav: number | null
893 pe: number | null
894 beta3Year: number | null
895 ytdReturnPercent: number | null
896 threeYearReturnPercent: number | null
897 fiveYearReturnPercent: number | null
898 /** Each in percent. */
899 assets: { stock: number | null; bond: number | null; cash: number | null; preferred: number | null; convertible: number | null; other: number | null }
900 /** Largest first. */
901 sectors: { sector: string; percent: number }[]
902 /** The top holdings. */
903 rows: { symbol: string | null; name: string; percent: number | null }[]
904}
905
906const SECTORS: Record<string, string> = {
907 realestate: 'Real estate',
908 consumer_cyclical: 'Consumer cyclical',
909 basic_materials: 'Basic materials',
910 consumer_defensive: 'Consumer defensive',
911 technology: 'Technology',
912 communication_services: 'Communication services',
913 financial_services: 'Financial services',
914 utilities: 'Utilities',
915 industrials: 'Industrials',
916 energy: 'Energy',
917 healthcare: 'Healthcare',
918}
919
920export function readFund(symbol: string, json: unknown): Fund {
921 const result = summaryOf(json)
922 const top = record(result['topHoldings'])
923 const profile = record(result['fundProfile'])
924 const type = typeOf(result)
925 if (!top && !profile) throw new YahooError(`${symbol} is not a fund (${type ?? 'unknown type'}): fund holdings are for ETFs and mutual funds.`)
926 const stats = mod(result, 'defaultKeyStatistics')
927 const detail = mod(result, 'summaryDetail')
928 const fees = record(profile?.['feesExpensesInvestment']) ?? {}
929 const position = (key: string): number | null => percentOf(num(top?.[key]))
930 return {
931 symbol,
932 name: nameOf(result),
933 type,
934 currency: currencyOf(result),
935 family: text(profile?.['family']) ?? text(stats['fundFamily']),
936 category: text(profile?.['categoryName']) ?? text(stats['category']),
937 legalType: text(profile?.['legalType']) ?? text(stats['legalType']),
938 inception: day(stats['fundInceptionDate']),
939 totalAssets: num(stats['totalAssets']) ?? num(detail['totalAssets']),
940 expenseRatioPercent: percentOf(num(fees['annualReportExpenseRatio'])),
941 // Yahoo writes a turnover it has no figure for as 0.
942 turnoverPercent: percentOf(num(fees['annualHoldingsTurnover'])) || null,
943 yieldPercent: percentOf(num(stats['yield']) ?? num(detail['yield'])),
944 nav: num(detail['navPrice']),
945 pe: num(detail['trailingPE']),
946 beta3Year: num(stats['beta3Year']),
947 ytdReturnPercent: percentOf(num(stats['ytdReturn'])),
948 threeYearReturnPercent: percentOf(num(stats['threeYearAverageReturn'])),
949 fiveYearReturnPercent: percentOf(num(stats['fiveYearAverageReturn'])),
950 assets: {
951 stock: position('stockPosition'),
952 bond: position('bondPosition'),
953 cash: position('cashPosition'),
954 preferred: position('preferredPosition'),
955 convertible: position('convertiblePosition'),
956 other: position('otherPosition'),
957 },
958 sectors: records(top?.['sectorWeightings'])
959 .flatMap((weight) => Object.entries(weight))
960 .map(([key, value]) => ({ sector: SECTORS[key] ?? key.replace(/_/g, ' '), percent: percentOf(num(value)) }))
961 .filter((s): s is { sector: string; percent: number } => s.percent !== null && s.percent > 0)
962 .sort((a, b) => b.percent - a.percent),
963 rows: records(top?.['holdings'])
964 .map((h) => ({ symbol: text(h['symbol']), name: spaced(h['holdingName']) ?? text(h['symbol']) ?? '', percent: percentOf(num(h['holdingPercent'])) }))
965 .filter((h) => h.name !== ''),
966 }
967}
968
969export function fundResult(fund: Fund): ToolResult {
970 const lines = [`${title(fund.symbol, fund.name)}${fund.type ? ` (${fund.type})` : ''}.`]
971 const about = [
972 fund.family && `Family: ${fund.family}.`,
973 fund.category && `Category: ${fund.category}.`,
974 fund.legalType && `Legal type: ${fund.legalType}.`,
975 fund.inception && `Inception: ${fund.inception}.`,
976 ].filter(Boolean)
977 if (about.length) lines.push(about.join(' '))
978 const figures = [
979 fund.totalAssets !== null && `Net assets ${f.big(fund.totalAssets)}${fund.currency ? ` ${fund.currency}` : ''}.`,
980 fund.expenseRatioPercent !== null && `Expense ratio ${smallPercent(fund.expenseRatioPercent)}.`,
981 fund.turnoverPercent !== null && `Turnover ${f.percent(fund.turnoverPercent)}.`,
982 fund.yieldPercent !== null && `Yield ${f.percent(fund.yieldPercent)}.`,
983 fund.nav !== null && `NAV ${f.price(fund.nav)}.`,
984 fund.pe !== null && `P/E ${f.fixed(fund.pe)}.`,
985 fund.beta3Year !== null && `Beta (3 years) ${f.fixed(fund.beta3Year)}.`,
986 ].filter(Boolean)
987 if (figures.length) lines.push(figures.join(' '))
988 const returns = parts(
989 fund.ytdReturnPercent !== null && `year to date ${f.signedPercent(fund.ytdReturnPercent)}`,
990 fund.threeYearReturnPercent !== null && `3-year average ${f.signedPercent(fund.threeYearReturnPercent)}`,
991 fund.fiveYearReturnPercent !== null && `5-year average ${f.signedPercent(fund.fiveYearReturnPercent)}`,
992 )
993 if (returns) lines.push(`Returns: ${returns}.`)
994 const names: [keyof Fund['assets'], string][] = [
995 ['stock', 'stocks'],
996 ['bond', 'bonds'],
997 ['cash', 'cash'],
998 ['preferred', 'preferred'],
999 ['convertible', 'convertibles'],
1000 ['other', 'other'],
1001 ]
1002 const mix = parts(...names.map(([key, label]) => fund.assets[key] !== null && fund.assets[key] !== 0 && `${label} ${f.percent(fund.assets[key])}`))
1003 if (mix) lines.push(`Asset mix: ${mix}.`)
1004 if (fund.sectors.length) lines.push(`Sectors: ${fund.sectors.map((s) => `${s.sector} ${f.percent(s.percent)}`).join(', ')}.`)
1005 if (fund.rows.length) {
1006 lines.push(`Top holdings: ${fund.rows.map((h) => `${h.symbol ? `${h.symbol} ` : ''}${h.name} ${f.percent(h.percent)}`).join(', ')}.`)
1007 }
1008 return toolResult(lines.join('\n'), fund)
1009}