yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { plain as f, smallPercent } from '../format.ts' |
| 2 | import { list, num, percentOf, record, records, text, type Row } from '../values.ts' |
| 3 | import { capped, parts, toolResult, type ToolResult } from './result.ts' |
| 4 | import { 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 | |
| 9 | export 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. */ |
| 19 | export 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 | |
| 25 | function mod(result: Row, name: string): Row { |
| 26 | return record(result[name]) ?? {} |
| 27 | } |
| 28 | |
| 29 | function 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 | |
| 35 | function typeOf(result: Row): string | null { |
| 36 | return text(mod(result, 'quoteType')['quoteType']) ?? text(mod(result, 'price')['quoteType']) |
| 37 | } |
| 38 | |
| 39 | function 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 | |
| 48 | function day(value: unknown): string | null { |
| 49 | const seconds = num(value) |
| 50 | return seconds === null ? null : f.day(seconds) |
| 51 | } |
| 52 | |
| 53 | function spaced(value: unknown): string | null { |
| 54 | return text(value)?.replace(/\s+/g, ' ') ?? null |
| 55 | } |
| 56 | |
| 57 | function title(symbol: string, name: string | null): string { |
| 58 | return `${symbol}${name ? ` ${name}` : ''}` |
| 59 | } |
| 60 | |
| 61 | // Profile. |
| 62 | |
| 63 | export type Officer = { |
| 64 | name: string |
| 65 | title: string | null |
| 66 | age: number | null |
| 67 | pay: number | null |
| 68 | payYear: number | null |
| 69 | } |
| 70 | |
| 71 | export 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 | |
| 88 | export 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 | |
| 133 | export 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 | |
| 173 | type Kind = 'money' | 'big' | 'count' | 'ratio' | 'percent' | 'percentUnits' | 'date' | 'text' |
| 174 | |
| 175 | export 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 | |
| 184 | export 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. */ |
| 194 | const 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 | |
| 261 | function 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 | |
| 290 | export 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 | |
| 308 | export 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 | |
| 319 | export 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 | |
| 331 | export 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 | |
| 343 | export 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 | |
| 369 | const 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 | |
| 378 | function 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 | |
| 383 | export 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 | |
| 483 | function 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 | |
| 488 | export 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 | |
| 545 | export 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 | |
| 557 | export 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 | |
| 570 | const ACTIONS: Record<string, string> = { up: 'upgrade', down: 'downgrade', main: 'maintain', init: 'initiate', reit: 'reiterate' } |
| 571 | |
| 572 | function 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 | |
| 579 | export 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 | |
| 636 | function 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 | |
| 664 | export 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 | |
| 703 | export 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 | |
| 712 | export 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 | |
| 721 | export 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 | |
| 731 | export 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 | |
| 753 | function 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 | |
| 767 | export 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 | |
| 828 | function 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 | |
| 832 | export 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 | |
| 879 | export 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 | |
| 906 | const 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 | |
| 920 | export 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 | |
| 969 | export 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 | } |