yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' |
| 2 | import { z } from 'zod' |
| 3 | import { |
| 4 | analystsResult, |
| 5 | earningsResult, |
| 6 | fundResult, |
| 7 | holdersResult, |
| 8 | MODULES, |
| 9 | profileResult, |
| 10 | readAnalysts, |
| 11 | readEarnings, |
| 12 | readFund, |
| 13 | readHolders, |
| 14 | readProfile, |
| 15 | readStatistics, |
| 16 | statisticsResult, |
| 17 | } from './company.ts' |
| 18 | import { FREQUENCIES, financialsResult, readFinancials, STATEMENTS, timeseriesQuery } from './financials.ts' |
| 19 | import { historyResult, INTERVALS, isIntraday, RANGES, readHistory } from './history.ts' |
| 20 | import { expirationSeconds, nearestExpirations, optionsResult, readExpirations, readOptions, SIDES } from './options.ts' |
| 21 | import { |
| 22 | marketSummaryResult, |
| 23 | MOVER_LISTS, |
| 24 | moversResult, |
| 25 | newsResult, |
| 26 | quotesResult, |
| 27 | readMarketSummary, |
| 28 | readNews, |
| 29 | readQuotes, |
| 30 | readScreen, |
| 31 | readSearch, |
| 32 | readTrending, |
| 33 | searchResult, |
| 34 | TRENDING_DESCRIPTION, |
| 35 | TRENDING_TITLE, |
| 36 | type MoverList, |
| 37 | type Quote, |
| 38 | } from './quotes.ts' |
| 39 | import type { ToolResult } from './result.ts' |
| 40 | import { createYahoo, YahooError, type Query, type YahooOptions } from './yahoo.ts' |
| 41 | |
| 42 | // Yahoo Finance as an MCP server: fourteen read-only tools for the plugin's connection, |
| 43 | // yfinance/server, and for anything else that speaks MCP. No key. Each tool answers text for the model |
| 44 | // and structuredContent with rows for the views. |
| 45 | |
| 46 | export const VERSION = '1.0.0' |
| 47 | |
| 48 | const SECOND = 1000 |
| 49 | const MINUTE = 60 * SECOND |
| 50 | const HOUR = 60 * MINUTE |
| 51 | const DAY_SECONDS = 86_400 |
| 52 | |
| 53 | const INSTRUCTIONS = [ |
| 54 | 'Market data, with no official API: quotes (some exchanges delayed), price history, company profiles, key statistics, financial statements, earnings and estimates, analyst ratings, holders, option chains, news, a market summary, market movers, and fund holdings. No key is needed.', |
| 55 | 'Symbols: AAPL, BRK-B, SHOP.TO, 7203.T, ^GSPC (index), ES=F (future), EURUSD=X (currency), BTC-USD (crypto). search finds one from a name.', |
| 56 | 'In structured results, a field whose name ends in Percent is in percent (7.97 is 7.97%). The data can be wrong or late, and requests can be limited.', |
| 57 | ].join(' ') |
| 58 | |
| 59 | /** A refusal worded for the caller. */ |
| 60 | class Refusal extends Error {} |
| 61 | |
| 62 | const SYMBOL = /^[A-Z0-9.^=&-]{1,32}$/ |
| 63 | |
| 64 | function symbolOf(raw: string): string { |
| 65 | const symbol = raw.trim().toUpperCase() |
| 66 | if (!SYMBOL.test(symbol)) throw new Refusal(`"${raw.trim().slice(0, 40)}" is not a symbol. Find one with search.`) |
| 67 | return symbol |
| 68 | } |
| 69 | |
| 70 | function dayStart(day: string): number { |
| 71 | return Date.parse(`${day}T00:00:00Z`) / 1000 |
| 72 | } |
| 73 | |
| 74 | type Failure = { content: { type: 'text'; text: string }[]; isError: true } |
| 75 | |
| 76 | /** Runs a tool, turning whatever it throws into an error result the caller can read. */ |
| 77 | function answer<A>(run: (args: A) => Promise<ToolResult>): (args: A) => Promise<ToolResult | Failure> { |
| 78 | return async (args) => { |
| 79 | try { |
| 80 | return await run(args) |
| 81 | } catch (err) { |
| 82 | const message = err instanceof Error ? err.message : String(err) |
| 83 | const text = err instanceof Refusal || err instanceof YahooError ? message : `Reading the market data answer failed: ${message}` |
| 84 | return { content: [{ type: 'text', text }], isError: true } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | const READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const |
| 90 | |
| 91 | export interface ServerOptions extends YahooOptions { |
| 92 | /** Milliseconds; what "today" is for date windows. */ |
| 93 | now?: () => number |
| 94 | } |
| 95 | |
| 96 | export function createServer(options: ServerOptions = {}): McpServer { |
| 97 | const now = options.now ?? Date.now |
| 98 | const yahoo = createYahoo({ ...options, now }) |
| 99 | const server = new McpServer({ name: 'yfinance', version: VERSION }, { instructions: INSTRUCTIONS }) |
| 100 | |
| 101 | const nextMidnight = (): number => Math.ceil(now() / 1000 / DAY_SECONDS) * DAY_SECONDS |
| 102 | |
| 103 | const summary = (symbol: string, modules: string, ttl: number, what: string): Promise<unknown> => |
| 104 | yahoo.get({ path: `/v10/finance/quoteSummary/${encodeURIComponent(symbol)}`, query: { modules, formatted: false }, crumb: true, ttl, what }) |
| 105 | |
| 106 | const quotes = async (symbols: string[]): Promise<Quote[]> => |
| 107 | readQuotes( |
| 108 | await yahoo.get({ |
| 109 | path: '/v7/finance/quote', |
| 110 | query: { symbols: symbols.join(','), formatted: false }, |
| 111 | crumb: true, |
| 112 | ttl: 15 * SECOND, |
| 113 | what: `the quote for ${symbols.join(', ')}`, |
| 114 | }), |
| 115 | ) |
| 116 | |
| 117 | const Symbol = z |
| 118 | .string() |
| 119 | .min(1) |
| 120 | .max(40) |
| 121 | .describe('A symbol: AAPL, BRK-B, SHOP.TO, ^GSPC (index), ES=F (future), EURUSD=X (currency), BTC-USD (crypto).') |
| 122 | const Day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) |
| 123 | |
| 124 | server.registerTool( |
| 125 | 'search', |
| 126 | { |
| 127 | description: |
| 128 | 'Finds symbols by company name, ticker, or keyword: stocks, ETFs, funds, indices, futures, currencies, and crypto, with the exchange, sector, and industry. Use it when unsure of a symbol, or for a listing on another exchange (SHOP.TO, APC.DE).', |
| 129 | inputSchema: { |
| 130 | query: z.string().min(1).max(100).describe('A name, ticker, or keyword: "apple", "nvidia", "gold".'), |
| 131 | limit: z.number().int().min(1).max(20).optional().describe('How many matches, 8 by default.'), |
| 132 | }, |
| 133 | annotations: READ_ONLY, |
| 134 | }, |
| 135 | answer(async ({ query, limit }: { query: string; limit?: number }) => { |
| 136 | const q = query.trim() |
| 137 | const json = await yahoo.get({ |
| 138 | path: '/v1/finance/search', |
| 139 | query: { q, quotesCount: limit ?? 8, newsCount: 0, listsCount: 0, enableFuzzyQuery: false }, |
| 140 | ttl: HOUR, |
| 141 | what: `a search for "${q}"`, |
| 142 | }) |
| 143 | return searchResult(q, readSearch(json)) |
| 144 | }), |
| 145 | ) |
| 146 | |
| 147 | server.registerTool( |
| 148 | 'get_quote', |
| 149 | { |
| 150 | description: |
| 151 | 'Live quotes for up to 50 symbols at once: price, change, day range, volume, market cap, P/E, EPS, dividend yield, 52-week range, market state, pre- or post-market price, and the average analyst rating. Some exchanges are delayed.', |
| 152 | inputSchema: { symbols: z.array(Symbol).min(1).max(50).describe('The symbols, like ["AAPL", "MSFT", "^GSPC"].') }, |
| 153 | annotations: READ_ONLY, |
| 154 | }, |
| 155 | answer(async ({ symbols }: { symbols: string[] }) => { |
| 156 | const asked = [...new Set(symbols.map(symbolOf))] |
| 157 | const found = await quotes(asked) |
| 158 | if (!found.length) throw new Refusal(`No quote for ${asked.join(', ')}. Check the symbol with search.`) |
| 159 | return quotesResult(found, asked) |
| 160 | }), |
| 161 | ) |
| 162 | |
| 163 | server.registerTool( |
| 164 | 'get_price_history', |
| 165 | { |
| 166 | description: |
| 167 | 'Price history: open, high, low, close, adjusted close, and volume per bar, a summary of the period, and the dividends and splits in it. Give range (1y by default) or start and end dates, and interval (1d by default). Minute bars reach back 8 days for 1m and 60 days for other minute sizes; hourly bars 730 days. The text lists the latest 400 bars.', |
| 168 | inputSchema: { |
| 169 | symbol: Symbol, |
| 170 | range: z.enum(RANGES).optional().describe('How far back, ending now: 1d, 5d, 1mo, 3mo, 6mo, ytd, 1y (default), 2y, 5y, 10y, max. Ignored when start is given.'), |
| 171 | interval: z.enum(INTERVALS).optional().describe('Bar size: 1m to 90m, 1h, 1d (default), 5d, 1wk, 1mo, 3mo.'), |
| 172 | start: Day.optional().describe('First day, YYYY-MM-DD, instead of range.'), |
| 173 | end: Day.optional().describe('Last day, YYYY-MM-DD, with start; today when left out.'), |
| 174 | prepost: z.boolean().optional().describe('Include pre- and post-market bars (intraday only).'), |
| 175 | }, |
| 176 | annotations: READ_ONLY, |
| 177 | }, |
| 178 | answer( |
| 179 | async ({ symbol: raw, range, interval, start, end, prepost }: { symbol: string; range?: string; interval?: string; start?: string; end?: string; prepost?: boolean }) => { |
| 180 | const symbol = symbolOf(raw) |
| 181 | if (end && !start) throw new Refusal('Give start with end, or a range.') |
| 182 | const every = interval ?? '1d' |
| 183 | const query: Query = { interval: every, includePrePost: prepost ?? false, events: 'div,splits' } |
| 184 | if (start) { |
| 185 | const from = dayStart(start) |
| 186 | const to = end ? dayStart(end) + DAY_SECONDS : nextMidnight() |
| 187 | if (!(to > from)) throw new Refusal('end has to be on or after start.') |
| 188 | query['period1'] = from |
| 189 | query['period2'] = to |
| 190 | } else { |
| 191 | query['range'] = range ?? '1y' |
| 192 | } |
| 193 | const json = await yahoo.get({ |
| 194 | path: `/v8/finance/chart/${encodeURIComponent(symbol)}`, |
| 195 | query, |
| 196 | ttl: isIntraday(every) ? MINUTE : 15 * MINUTE, |
| 197 | what: `${symbol} price history`, |
| 198 | }) |
| 199 | const history = readHistory(json) |
| 200 | return historyResult({ ...history, symbol: history.symbol || symbol }) |
| 201 | }, |
| 202 | ), |
| 203 | ) |
| 204 | |
| 205 | server.registerTool( |
| 206 | 'get_company_profile', |
| 207 | { |
| 208 | description: |
| 209 | 'A company’s profile: sector, industry, employees, headquarters, phone, website, business summary, officers with their pay, and ISS governance risk scores. For a fund, its summary.', |
| 210 | inputSchema: { symbol: Symbol }, |
| 211 | annotations: READ_ONLY, |
| 212 | }, |
| 213 | answer(async ({ symbol: raw }: { symbol: string }) => { |
| 214 | const symbol = symbolOf(raw) |
| 215 | return profileResult(readProfile(symbol, await summary(symbol, MODULES.profile, 6 * HOUR, `the ${symbol} profile`))) |
| 216 | }), |
| 217 | ) |
| 218 | |
| 219 | server.registerTool( |
| 220 | 'get_key_statistics', |
| 221 | { |
| 222 | description: |
| 223 | 'Key statistics: valuation (market cap, enterprise value, trailing and forward P/E, PEG, price/sales, price/book, EV/revenue, EV/EBITDA), margins and returns, revenue and earnings growth, cash and debt, beta and moving averages, shares, float, short interest, dividends, and fiscal dates.', |
| 224 | inputSchema: { symbol: Symbol }, |
| 225 | annotations: READ_ONLY, |
| 226 | }, |
| 227 | answer(async ({ symbol: raw }: { symbol: string }) => { |
| 228 | const symbol = symbolOf(raw) |
| 229 | return statisticsResult(readStatistics(symbol, await summary(symbol, MODULES.statistics, 5 * MINUTE, `${symbol} key statistics`))) |
| 230 | }), |
| 231 | ) |
| 232 | |
| 233 | server.registerTool( |
| 234 | 'get_financials', |
| 235 | { |
| 236 | description: |
| 237 | 'A company’s financial statements: the income statement, balance sheet, or cash flow statement, annual (about four years), quarterly (about five quarters), or trailing twelve months (income and cash flow). Values are as reported, in the statement’s currency, one column per period, latest first.', |
| 238 | inputSchema: { |
| 239 | symbol: Symbol, |
| 240 | statement: z.enum(STATEMENTS).optional().describe('income (default), balance, or cashflow.'), |
| 241 | frequency: z.enum(FREQUENCIES).optional().describe('annual (default), quarterly, or trailing.'), |
| 242 | }, |
| 243 | annotations: READ_ONLY, |
| 244 | }, |
| 245 | answer(async ({ symbol: raw, statement, frequency }: { symbol: string; statement?: (typeof STATEMENTS)[number]; frequency?: (typeof FREQUENCIES)[number] }) => { |
| 246 | const symbol = symbolOf(raw) |
| 247 | const which = statement ?? 'income' |
| 248 | const every = frequency ?? 'annual' |
| 249 | if (which === 'balance' && every === 'trailing') return financialsResult(readFinancials(symbol, which, every, null)) |
| 250 | const json = await yahoo.get({ |
| 251 | path: `/ws/fundamentals-timeseries/v1/finance/timeseries/${encodeURIComponent(symbol)}`, |
| 252 | query: timeseriesQuery(symbol, which, every, now()), |
| 253 | ttl: 6 * HOUR, |
| 254 | what: `the ${symbol} ${which} statement`, |
| 255 | }) |
| 256 | return financialsResult(readFinancials(symbol, which, every, json)) |
| 257 | }), |
| 258 | ) |
| 259 | |
| 260 | server.registerTool( |
| 261 | 'get_earnings', |
| 262 | { |
| 263 | description: |
| 264 | 'Earnings: the next report date with its EPS and revenue estimates, the last four quarters’ EPS against estimates, analyst estimates for this and next quarter and fiscal year (EPS, revenue, growth, trend, revisions), dividend dates, and annual revenue and earnings.', |
| 265 | inputSchema: { symbol: Symbol }, |
| 266 | annotations: READ_ONLY, |
| 267 | }, |
| 268 | answer(async ({ symbol: raw }: { symbol: string }) => { |
| 269 | const symbol = symbolOf(raw) |
| 270 | return earningsResult(readEarnings(symbol, await summary(symbol, MODULES.earnings, HOUR, `${symbol} earnings`))) |
| 271 | }), |
| 272 | ) |
| 273 | |
| 274 | server.registerTool( |
| 275 | 'get_analyst_ratings', |
| 276 | { |
| 277 | description: |
| 278 | 'Analyst views: price targets (low, mean, median, high), the consensus rating, the monthly count of strong buy to strong sell ratings, and the latest upgrades, downgrades, and target changes by firm.', |
| 279 | inputSchema: { |
| 280 | symbol: Symbol, |
| 281 | limit: z.number().int().min(1).max(50).optional().describe('How many rating changes, newest first; 15 by default.'), |
| 282 | }, |
| 283 | annotations: READ_ONLY, |
| 284 | }, |
| 285 | answer(async ({ symbol: raw, limit }: { symbol: string; limit?: number }) => { |
| 286 | const symbol = symbolOf(raw) |
| 287 | return analystsResult(readAnalysts(symbol, await summary(symbol, MODULES.analysts, HOUR, `${symbol} analyst ratings`), limit ?? 15)) |
| 288 | }), |
| 289 | ) |
| 290 | |
| 291 | server.registerTool( |
| 292 | 'get_holders', |
| 293 | { |
| 294 | description: |
| 295 | 'Who owns a company: the insider and institutional share, the top institutions and funds with their stakes and changes, insiders and their holdings, recent insider purchases and sales, and six months of net insider buying.', |
| 296 | inputSchema: { |
| 297 | symbol: Symbol, |
| 298 | limit: z.number().int().min(1).max(25).optional().describe('How many of each list; 10 by default.'), |
| 299 | }, |
| 300 | annotations: READ_ONLY, |
| 301 | }, |
| 302 | answer(async ({ symbol: raw, limit }: { symbol: string; limit?: number }) => { |
| 303 | const symbol = symbolOf(raw) |
| 304 | return holdersResult(readHolders(symbol, await summary(symbol, MODULES.holders, 6 * HOUR, `${symbol} holders`), limit ?? 10)) |
| 305 | }), |
| 306 | ) |
| 307 | |
| 308 | server.registerTool( |
| 309 | 'get_options_chain', |
| 310 | { |
| 311 | description: |
| 312 | 'An option chain: the expiration dates, and for one expiration (the nearest by default) the calls and puts at the strikes nearest the price, with last, bid, ask, volume, open interest, implied volatility, and whether in the money; the expiration’s totals and put/call ratios; and the at-the-money strike. Prices can be delayed.', |
| 313 | inputSchema: { |
| 314 | symbol: Symbol, |
| 315 | expiration: Day.optional().describe('The expiration day, YYYY-MM-DD, one of those listed; the nearest when left out.'), |
| 316 | strikes: z.number().int().min(1).max(50).optional().describe('How many strikes on each side of the price; 10 by default.'), |
| 317 | side: z.enum(SIDES).optional().describe('both (default), calls, or puts, for the text.'), |
| 318 | }, |
| 319 | annotations: READ_ONLY, |
| 320 | }, |
| 321 | answer(async ({ symbol: raw, expiration, strikes, side }: { symbol: string; expiration?: string; strikes?: number; side?: (typeof SIDES)[number] }) => { |
| 322 | const symbol = symbolOf(raw) |
| 323 | const path = `/v7/finance/options/${encodeURIComponent(symbol)}` |
| 324 | const each = strikes ?? 10 |
| 325 | let json = await yahoo.get({ path, crumb: true, ttl: MINUTE, what: `${symbol} options` }) |
| 326 | if (expiration) { |
| 327 | const listed = readExpirations(json, symbol) |
| 328 | if (!listed.length) throw new Refusal(`No options are listed for ${symbol}.`) |
| 329 | if (!listed.includes(expiration)) { |
| 330 | throw new Refusal(`${symbol} has no options expiring ${expiration}. The nearest expirations: ${nearestExpirations(listed, expiration).join(', ')}.`) |
| 331 | } |
| 332 | if (readOptions(json, each, symbol).expiration !== expiration) { |
| 333 | json = await yahoo.get({ path, query: { date: expirationSeconds(expiration) }, crumb: true, ttl: MINUTE, what: `${symbol} options expiring ${expiration}` }) |
| 334 | } |
| 335 | } |
| 336 | return optionsResult(readOptions(json, each, symbol), side ?? 'both') |
| 337 | }), |
| 338 | ) |
| 339 | |
| 340 | server.registerTool( |
| 341 | 'get_news', |
| 342 | { |
| 343 | description: 'Recent news for a ticker or a topic, newest first: headline, publisher, time, link, and the tickers each story mentions.', |
| 344 | inputSchema: { |
| 345 | query: z.string().min(1).max(100).describe('A ticker (AAPL) or a topic ("rate cut").'), |
| 346 | limit: z.number().int().min(1).max(20).optional().describe('How many stories; 10 by default.'), |
| 347 | }, |
| 348 | annotations: READ_ONLY, |
| 349 | }, |
| 350 | answer(async ({ query, limit }: { query: string; limit?: number }) => { |
| 351 | const q = query.trim() |
| 352 | const json = await yahoo.get({ |
| 353 | path: '/v1/finance/search', |
| 354 | query: { q, quotesCount: 0, newsCount: limit ?? 10, listsCount: 0 }, |
| 355 | ttl: 5 * MINUTE, |
| 356 | what: `news for "${q}"`, |
| 357 | }) |
| 358 | return newsResult(q, readNews(json)) |
| 359 | }), |
| 360 | ) |
| 361 | |
| 362 | server.registerTool( |
| 363 | 'get_market_summary', |
| 364 | { |
| 365 | description: |
| 366 | 'A snapshot of the markets: the main index futures or indices, oil, gold, silver, the 10-year Treasury yield, the VIX, major currencies, and crypto, each with price and change. region is a two-letter market: US by default, or GB, DE, FR, IN, HK, JP, AU, CA, and others.', |
| 367 | inputSchema: { region: z.string().regex(/^[A-Za-z]{2}$/).optional().describe('Two letters, US by default.') }, |
| 368 | annotations: READ_ONLY, |
| 369 | }, |
| 370 | answer(async ({ region }: { region?: string }) => { |
| 371 | const where = (region ?? 'US').toUpperCase() |
| 372 | const json = await yahoo.get({ |
| 373 | path: '/v6/finance/quote/marketSummary', |
| 374 | query: { lang: 'en-US', region: where, formatted: false }, |
| 375 | ttl: 30 * SECOND, |
| 376 | what: `the ${where} market summary`, |
| 377 | }) |
| 378 | return marketSummaryResult(where, readMarketSummary(json)) |
| 379 | }), |
| 380 | ) |
| 381 | |
| 382 | const lists = Object.keys(MOVER_LISTS) as [MoverList, ...MoverList[]] |
| 383 | |
| 384 | server.registerTool( |
| 385 | 'get_market_movers', |
| 386 | { |
| 387 | description: `Lists of US stocks, each with price, change, volume, and market cap: today's gainers, losers, and most active, the trending tickers, and saved screens (${lists |
| 388 | .filter((l) => !['gainers', 'losers', 'most_active', 'trending'].includes(l)) |
| 389 | .join(', ')}).`, |
| 390 | inputSchema: { |
| 391 | list: z.enum(lists).optional().describe('gainers (default), losers, most_active, trending, or a saved screen.'), |
| 392 | limit: z.number().int().min(1).max(100).optional().describe('How many; 25 by default.'), |
| 393 | }, |
| 394 | annotations: READ_ONLY, |
| 395 | }, |
| 396 | answer(async ({ list, limit }: { list?: MoverList; limit?: number }) => { |
| 397 | const which = list ?? 'gainers' |
| 398 | const count = limit ?? 25 |
| 399 | const saved = MOVER_LISTS[which] |
| 400 | if (saved) { |
| 401 | const json = await yahoo.get({ |
| 402 | path: '/v1/finance/screener/predefined/saved', |
| 403 | query: { scrIds: saved, count, formatted: false }, |
| 404 | ttl: MINUTE, |
| 405 | what: `the ${which.replace(/_/g, ' ')} list`, |
| 406 | }) |
| 407 | return moversResult(which, readScreen(json)) |
| 408 | } |
| 409 | const trending = readTrending(await yahoo.get({ path: '/v1/finance/trending/US', query: { count }, ttl: MINUTE, what: 'trending tickers' })).slice(0, count) |
| 410 | const found = trending.length ? await quotes(trending) : [] |
| 411 | const bySymbol = new Map(found.map((q) => [q.symbol, q])) |
| 412 | const rows = trending.map((s) => bySymbol.get(s)).filter((q): q is Quote => q !== undefined) |
| 413 | return moversResult(which, { title: TRENDING_TITLE, description: TRENDING_DESCRIPTION, total: trending.length, rows }) |
| 414 | }), |
| 415 | ) |
| 416 | |
| 417 | server.registerTool( |
| 418 | 'get_fund_holdings', |
| 419 | { |
| 420 | description: |
| 421 | 'An ETF’s or mutual fund’s profile: family, category, net assets, expense ratio, turnover, yield, NAV, returns, asset mix, sector weights, and top holdings.', |
| 422 | inputSchema: { symbol: Symbol }, |
| 423 | annotations: READ_ONLY, |
| 424 | }, |
| 425 | answer(async ({ symbol: raw }: { symbol: string }) => { |
| 426 | const symbol = symbolOf(raw) |
| 427 | return fundResult(readFund(symbol, await summary(symbol, MODULES.fund, 6 * HOUR, `the ${symbol} fund profile`))) |
| 428 | }), |
| 429 | ) |
| 430 | |
| 431 | return server |
| 432 | } |