spb/hfmarketdata
Public
Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)
JavaScript 53.7%
Python 38.3%
CSS 4.6%
TypeScript 3.1%
1/**2 * Tool catalogue. Each tool is a pure function `(client, args) => text` so it can be unit-tested3 * without an MCP transport; `server.ts` wraps them into MCP tools.4 *5 * Endpoint map (HF Market Data):6 * v1 (live) /v1/{asset}/tickers · /v1/bars/{asset}/{ticker} · /v1/bars/{asset}?tickers= ·7 * /v1/options/{tickers,chain,expirations,history}8 * v2 futures /v1/futures/roots · /v1/futures/{root}/contracts · /v1/futures/contract/{symbol}/bars ·9 * /v1/futures/{root}/chain · /v1/futures/{root}/continuous · /v1/futures/{root}/term-structure ·10 * /v1/futures/contract/{symbol}/coverage11 * v2 fundamentals /v1/fundamentals/{ticker}/{statements,facts/{concept},ratios,ratios/daily,filings,coverage} ·12 * /v1/fundamentals/screener · /v1/fundamentals/frames/{concept}13 */14import { z } from 'zod';15import { HfmdClient, HfmdError, type Params } from './client.js';16import { DEFAULT_LIMIT, MAX_LIMIT, extract, renderResult, rateLine, shrink } from './format.js';17import { MAX_MESSAGES, MAX_TIMEOUT_MS, subscribeFilings, type SubscribeOptions } from './stream.js';1819export type Shape = z.ZodRawShape;2021export interface ToolContext {22 /** Injected websocket implementation (tests). */23 WebSocketImpl?: typeof WebSocket;24}2526export interface ToolDef<S extends Shape = Shape> {27 name: string;28 title: string;29 description: string;30 schema: S;31 handler: (client: HfmdClient, args: z.infer<z.ZodObject<S>>, ctx?: ToolContext) => Promise<string>;32}3334function def<S extends Shape>(t: ToolDef<S>): ToolDef<S> {35 return t;36}3738// --- shared schema fragments -------------------------------------------------------------------3940const V1_ASSETS = ['stock', 'etf', 'crypto', 'index', 'fx', 'futures'] as const;41const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day'] as const;42const INTERVAL_ALIAS: Record<string, string> = { '1m': '1min', '5m': '5min', '30m': '30min', '1h': '1hour', '1d': '1day', '1min': '1min', '5min': '5min', '30min': '30min', '1hour': '1hour', '1day': '1day' };43const V2_INTERVAL: Record<string, string> = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1d', '1m': '1m', '5m': '5m', '30m': '30m', '1h': '1h', '1d': '1d' };4445const limitSchema = z.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT)46 .describe(`Maximum rows to fetch (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}). Results above 200 rows are summarised (head/tail + numeric ranges); lower the range or the limit to see every row.`);47const dateSchema = (what: string) => z.string().regex(/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?$/, 'expected YYYY-MM-DD or YYYY-MM-DDTHH:MM').optional().describe(what);48const timeframeSchema = z.enum([...TIMEFRAMES, '1m', '5m', '30m', '1h', '1d']).default('1day')49 .describe('Bar size: 1min, 5min, 30min, 1hour or 1day (aliases 1m/5m/30m/1h/1d accepted). Intraday timestamps are naive US/Eastern; daily bars are dates.');5051function tf(v: string | undefined): string {52 return INTERVAL_ALIAS[v ?? '1day'] ?? '1day';53}5455const TICKER_RE = /^[A-Za-z0-9._^=:-]{1,20}$/;56const symbol = (what: string) => z.string().regex(TICKER_RE, 'invalid symbol').transform((s) => s.trim().toUpperCase()).describe(what);5758// --- tools -------------------------------------------------------------------------------------5960export const searchSymbols = def({61 name: 'search_symbols',62 title: 'Search symbols',63 description:64 'Find tickers, futures roots or optionable underlyings available in HF Market Data. ' +65 'asset=stock|etf|crypto|index|fx lists tickers (substring search, e.g. "AAP"); asset=futures lists futures roots (ES, CL, NG, 6E…) with names and data ranges; ' +66 'asset=options lists underlyings with options chains. Use this before get_bars when unsure of the exact symbol.',67 schema: {68 asset: z.enum([...V1_ASSETS, 'options']).describe('Universe to search.'),69 query: z.string().max(40).optional().describe('Substring/prefix to match (case-insensitive). Omit to list the first `limit` symbols.'),70 timeframe: timeframeSchema.optional().describe('For stock/etf/crypto/index/fx: only symbols with data at this bar size (default 1day).'),71 limit: z.number().int().min(1).max(2000).default(100).describe('Max symbols returned (default 100).'),72 },73 async handler(client, a) {74 const q = a.query?.trim();75 if (a.asset === 'futures') {76 const r = await client.get('/v1/futures/roots', { limit: 1000 });77 const { rows } = extract(r.body);78 let list = rows ?? [];79 if (q) {80 const needle = q.toUpperCase();81 list = list.filter((x) => String(x.root ?? '').toUpperCase().includes(needle) || String(x.name ?? '').toUpperCase().includes(needle) || String(x.asset_class ?? '').toUpperCase().includes(needle));82 }83 return renderResult({ ...r, body: { data: list.slice(0, a.limit), meta: { count: list.length, filtered_by: q ?? null } } }, { title: `Futures roots${q ? ` matching "${q}"` : ''}` });84 }85 if (a.asset === 'options') {86 const r = await client.get('/v1/options/tickers', { search: q, limit: a.limit });87 return renderResult(r, { title: `Optionable underlyings${q ? ` matching "${q}"` : ''}` });88 }89 const r = await client.get<{ tickers?: string[]; count?: number }>(`/v1/${a.asset}/tickers`, { search: q, limit: a.limit, timeframe: tf(a.timeframe) });90 const body = r.body ?? {};91 const tickers = Array.isArray(body.tickers) ? body.tickers : [];92 return [93 `${a.asset} tickers${q ? ` matching "${q}"` : ''}: ${body.count ?? tickers.length} available, showing ${Math.min(tickers.length, a.limit)}`,94 JSON.stringify(shrink({ ...body, tickers: tickers.slice(0, a.limit) }, a.limit)),95 rateLine(r.rate),96 ].join('\n');97 },98});99100export const getBars = def({101 name: 'get_bars',102 title: 'Get OHLCV bars',103 description:104 'Historical OHLCV bars (open, high, low, close, volume) for one or several symbols. ' +105 'asset=stock|etf|crypto|index|fx use the v1 bars endpoint (adjustment adj_splitdiv by default for stocks/ETFs); ' +106 'asset=futures returns the vendor continuous series (adjustment contin_UNadj|contin_adj_ratio|contin_adj_absolute); ' +107 'asset=futures_contract takes an individual contract symbol such as ESZ25 or CLM26 (v2). ' +108 'Timeframes 1min…1day since ~2007 (2010 for futures). Dates YYYY-MM-DD; intraday datetimes are naive US/Eastern. ' +109 'Keep ranges small for intraday data (1 day of 1-min bars ≈ 390-1440 rows).',110 schema: {111 asset: z.enum([...V1_ASSETS, 'futures_contract']).describe('Asset class of the symbol(s).'),112 symbol: z.union([symbol('Ticker / root / contract symbol, e.g. AAPL, SPY, BTCUSD, ES, ESZ25.'), z.array(symbol('Ticker')).min(1).max(20)])113 .describe('One symbol, or up to 20 symbols (stock/etf/crypto/index/fx only) fetched in a single request.'),114 timeframe: timeframeSchema,115 start: dateSchema('First bar (inclusive), YYYY-MM-DD or YYYY-MM-DDTHH:MM.'),116 end: dateSchema('Last bar (inclusive), YYYY-MM-DD or YYYY-MM-DDTHH:MM.'),117 adjustment: z.string().max(30).optional().describe('stock/etf: UNADJUSTED | adj_split | adj_splitdiv (default). futures continuous: contin_UNadj (default) | contin_adj_ratio | contin_adj_absolute. Ignored for other assets.'),118 session: z.enum(['rth', 'eth', 'all']).optional().describe('futures_contract only: regular hours, extended hours or all (default all).'),119 order: z.enum(['asc', 'desc']).default('asc').describe('Sort by datetime. Use desc with a small limit to get the latest bars.'),120 limit: limitSchema,121 },122 async handler(client, a) {123 const timeframe = tf(a.timeframe);124 if (a.asset === 'futures_contract') {125 const sym = Array.isArray(a.symbol) ? a.symbol[0] : a.symbol;126 if (Array.isArray(a.symbol) && a.symbol.length > 1) throw new HfmdError(400, 'INVALID_PARAMETER', 'futures_contract accepts a single contract symbol per call (call the tool once per contract).');127 const r = await client.get(`/v1/futures/contract/${encodeURIComponent(sym)}/bars`, {128 interval: V2_INTERVAL[timeframe], from: a.start, to: a.end, session: a.session, order: a.order, limit: a.limit,129 });130 return renderResult(r, { title: `${sym} ${timeframe} bars (individual futures contract)` });131 }132 const common: Params = { timeframe, adjustment: a.adjustment, start: a.start, end: a.end, order: a.order, limit: a.limit };133 if (Array.isArray(a.symbol)) {134 if (a.symbol.length === 1) return renderResult(await client.get(`/v1/bars/${a.asset}/${encodeURIComponent(a.symbol[0])}`, common), { title: `${a.symbol[0]} ${timeframe} bars` });135 const r = await client.get(`/v1/bars/${a.asset}`, { ...common, tickers: a.symbol });136 return renderResult(r, { title: `${a.symbol.join(', ')} ${timeframe} bars (multi, ${a.limit} rows total across tickers)` });137 }138 const r = await client.get(`/v1/bars/${a.asset}/${encodeURIComponent(a.symbol)}`, common);139 return renderResult(r, { title: `${a.symbol} ${timeframe} bars${a.asset === 'futures' ? ' (vendor continuous, ' + (a.adjustment ?? 'contin_UNadj') + ')' : ''}` });140 },141});142143export const getFuturesContracts = def({144 name: 'get_futures_contracts',145 title: 'List futures contracts',146 description:147 'List the individual contracts (expirations) of a futures root, e.g. ES → ESH25, ESM25, ESU25, ESZ25… with expiration/last-trading/first-notice dates, ' +148 'status (active/expired), data range, average daily volume and last open interest. Use it to pick contract symbols for get_bars(asset=futures_contract) or get_coverage.',149 schema: {150 root: symbol('Futures root, e.g. ES, NQ, CL, NG, GC, ZN, 6E.'),151 status: z.enum(['active', 'expired', 'all']).default('all').describe('Filter by contract status.'),152 year: z.number().int().min(2000).max(2100).optional().describe('Only contracts expiring in this year.'),153 limit: z.number().int().min(1).max(1000).default(200).describe('Max contracts returned.'),154 },155 async handler(client, a) {156 const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/contracts`, { status: a.status === 'all' ? undefined : a.status, year: a.year, limit: a.limit });157 return renderResult(r, { title: `${a.root} contracts (${a.status})`, fullRowsThreshold: 400 });158 },159});160161export const getFuturesChain = def({162 name: 'get_futures_chain',163 title: 'Futures chain as of a date',164 description:165 'The chain of listed contracts for a root as of a given date (default today): front month first, with expirations, days to expiry, last price, volume and open interest. ' +166 'Answers "which CL contract was the front month on 2024-03-15?" and gives the symbols needed for compare/term-structure work.',167 schema: {168 root: symbol('Futures root, e.g. ES, CL.'),169 as_of: dateSchema('Reference date YYYY-MM-DD (default: latest available).'),170 depth: z.number().int().min(1).max(60).optional().describe('Number of contracts along the curve (default: all listed on as_of).'),171 },172 async handler(client, a) {173 const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/chain`, { as_of: a.as_of, depth: a.depth });174 return renderResult(r, { title: `${a.root} chain as of ${a.as_of ?? 'latest'}` });175 },176});177178export const getContinuous = def({179 name: 'get_continuous',180 title: 'Custom continuous futures series',181 description:182 'Build a continuous futures series server-side from individual contracts (v2): choose the roll rule (roll=volume rolls when the next contract out-trades the front; ' +183 'roll=open_interest on OI; roll=calendar N days before expiry), the price adjustment (adjust=none | back_adjusted (additive, preserves point changes) | ratio (multiplicative, preserves returns)) ' +184 'and the depth (1 = front month, 2 = second month…). The response meta lists the roll dates. Prefer this over get_bars(asset=futures) when the roll methodology matters.',185 schema: {186 root: symbol('Futures root, e.g. ES, CL, NG.'),187 roll: z.string().max(30).default('volume').describe('Roll rule: volume (default), open_interest, calendar (with roll_days), or any rule listed by the API.'),188 adjust: z.string().max(30).default('back_adjusted').describe('Price adjustment: none, back_adjusted (default), ratio.'),189 depth: z.number().int().min(1).max(12).default(1).describe('Curve position: 1 = front month (default), 2 = second month, …'),190 roll_days: z.number().int().min(0).max(60).optional().describe('For roll=calendar: business days before expiry to roll.'),191 timeframe: timeframeSchema,192 start: dateSchema('From date (inclusive).'),193 end: dateSchema('To date (inclusive).'),194 session: z.enum(['rth', 'eth', 'all']).optional().describe('Trading session filter (intraday).'),195 limit: limitSchema,196 },197 async handler(client, a) {198 const timeframe = tf(a.timeframe);199 const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/continuous`, {200 roll: a.roll, adjust: a.adjust, depth: a.depth, roll_days: a.roll_days, interval: V2_INTERVAL[timeframe], from: a.start, to: a.end, session: a.session, limit: a.limit,201 });202 return renderResult(r, { title: `${a.root} continuous (roll=${a.roll}, adjust=${a.adjust}, depth=${a.depth}, ${timeframe})` });203 },204});205206export const getTermStructure = def({207 name: 'get_term_structure',208 title: 'Futures term structure',209 description:210 'The futures curve of a root on a date: every listed contract with its settlement/last price, volume, open interest, days to expiry, plus spreads vs the front month. ' +211 'Upward-sloping curve = contango (later contracts more expensive: storage/financing), downward = backwardation (tight spot market). Compare two dates by calling it twice.',212 schema: {213 root: symbol('Futures root, e.g. CL, NG, ES, ZC.'),214 as_of: dateSchema('Curve date YYYY-MM-DD (default: latest).'),215 depth: z.number().int().min(2).max(60).optional().describe('Number of contracts along the curve.'),216 },217 async handler(client, a) {218 const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/term-structure`, { as_of: a.as_of, depth: a.depth });219 return renderResult(r, { title: `${a.root} term structure as of ${a.as_of ?? 'latest'}` });220 },221});222223export const getOptionsChain = def({224 name: 'get_options_chain',225 title: 'Options chain with Greeks',226 description:227 'End-of-day options chain of a US stock/ETF for one trade date: strike, expiry, call/put, bid/ask/last, volume, open interest, implied volatility and Greeks (delta, gamma, theta, vega). ' +228 'Chains are large: always filter by expiry and/or a strike range, or set list_expirations=true first to see the available expirations. History since 2010 by quarter.',229 schema: {230 ticker: symbol('Underlying ticker, e.g. AAPL, SPY, TSLA.'),231 trade_date: dateSchema('Trade date YYYY-MM-DD (default: latest available).'),232 expiry: dateSchema('Only this expiration date YYYY-MM-DD.'),233 call_put: z.enum(['C', 'P', 'call', 'put']).optional().describe('Only calls or only puts.'),234 strike_min: z.number().optional().describe('Minimum strike.'),235 strike_max: z.number().optional().describe('Maximum strike.'),236 min_volume: z.number().int().min(0).optional().describe('Drop contracts below this daily volume.'),237 list_expirations: z.boolean().default(false).describe('If true, return only the expiration dates listed on trade_date (cheap) instead of the chain.'),238 limit: limitSchema,239 },240 async handler(client, a) {241 if (a.list_expirations) {242 const r = await client.get(`/v1/options/expirations/${encodeURIComponent(a.ticker)}`, { trade_date: a.trade_date });243 return renderResult(r, { title: `${a.ticker} option expirations${a.trade_date ? ` on ${a.trade_date}` : ''}` });244 }245 const cp = a.call_put ? (a.call_put[0].toUpperCase()) : undefined;246 const r = await client.get(`/v1/options/chain/${encodeURIComponent(a.ticker)}`, {247 trade_date: a.trade_date, expiry: a.expiry, call_put: cp, strike_min: a.strike_min, strike_max: a.strike_max, min_volume: a.min_volume, limit: a.limit,248 });249 return renderResult(r, { title: `${a.ticker} options chain${a.trade_date ? ` ${a.trade_date}` : ''}${a.expiry ? ` exp ${a.expiry}` : ''}` });250 },251});252253export const getCoverage = def({254 name: 'get_coverage',255 title: 'Data coverage / gaps',256 description:257 'What data exists for a symbol and where the holes are. kind=futures_contract → per-timeframe first/last bar, bar counts and gaps > 3 business days for a contract such as ESZ25; ' +258 'kind=fundamentals → which statements/concepts/filings are available for a ticker and since when. Use it before drawing conclusions from sparse series (missing values are never invented by the API).',259 schema: {260 kind: z.enum(['futures_contract', 'fundamentals']).describe('Coverage type.'),261 symbol: symbol('Contract symbol (ESZ25) or ticker (AAPL).'),262 },263 async handler(client, a) {264 const path = a.kind === 'futures_contract' ? `/v1/futures/contract/${encodeURIComponent(a.symbol)}/coverage` : `/v1/fundamentals/${encodeURIComponent(a.symbol)}/coverage`;265 const r = await client.get(path);266 return renderResult(r, { title: `${a.symbol} coverage (${a.kind})` });267 },268});269270export const getFinancialStatements = def({271 name: 'get_financial_statements',272 title: 'Financial statements (SEC XBRL)',273 description:274 'Standardised income statement, balance sheet and cash-flow statement of a US company from SEC XBRL filings, annual or quarterly, point-in-time (each row carries filed_at/accession so you know when the number became public). ' +275 'Pass concept (e.g. Revenues, NetIncomeLoss, us-gaap:Assets) to get the full history of a single XBRL fact including restatements instead of statements.',276 schema: {277 ticker: symbol('US ticker, e.g. AAPL, MSFT.'),278 statement: z.enum(['income', 'balance', 'cashflow', 'all']).default('all').describe('Which statement(s).'),279 period: z.enum(['annual', 'quarterly', 'ttm', 'all']).default('quarterly').describe('Fiscal period type.'),280 concept: z.string().max(120).optional().describe('XBRL concept for a single-fact history (overrides statement).'),281 start: dateSchema('Earliest period end date.'),282 end: dateSchema('Latest period end date.'),283 limit: z.number().int().min(1).max(MAX_LIMIT).default(40).describe('Max rows (default 40: ~10 years of quarters for one statement).'),284 },285 async handler(client, a) {286 if (a.concept) {287 const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/facts/${encodeURIComponent(a.concept)}`, { period: a.period === 'all' ? undefined : a.period, from: a.start, to: a.end, limit: a.limit });288 return renderResult(r, { title: `${a.ticker} fact ${a.concept}` });289 }290 const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/statements`, {291 statement: a.statement === 'all' ? undefined : a.statement, period: a.period === 'all' ? undefined : a.period, from: a.start, to: a.end, limit: a.limit,292 });293 return renderResult(r, { title: `${a.ticker} ${a.statement} statements (${a.period})`, fullRowsThreshold: 300 });294 },295});296297export const getRatios = def({298 name: 'get_ratios',299 title: 'Latest fundamental ratios',300 description:301 'Current valuation, profitability, leverage and growth ratios of a US company computed from its latest filings and price: P/E, P/B, EV/EBITDA, FCF yield, ROE, ROA, gross/operating/net margins, debt/equity, current ratio, revenue and EPS growth… ' +302 'Each ratio states the period it is based on. For a time series use get_ratios_daily.',303 schema: {304 ticker: symbol('US ticker, e.g. AAPL.'),305 },306 async handler(client, a) {307 const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/ratios`);308 return renderResult(r, { title: `${a.ticker} ratios (latest)` });309 },310});311312export const getRatiosDaily = def({313 name: 'get_ratios_daily',314 title: 'Daily ratio history',315 description:316 'Daily history of price-based ratios (P/E, P/B, P/S, EV/EBITDA, FCF yield, dividend yield, market cap…) recomputed every trading day with the fundamentals known at that date (point-in-time, no look-ahead). ' +317 'Ideal for valuation charts and backtests. Narrow the date range or pick a few metrics to keep the output small.',318 schema: {319 ticker: symbol('US ticker.'),320 metrics: z.array(z.string().max(40)).max(20).optional().describe('Subset of ratio columns, e.g. ["pe","pb","fcf_yield"]. Default: all.'),321 start: dateSchema('From date.'),322 end: dateSchema('To date.'),323 limit: limitSchema,324 },325 async handler(client, a) {326 const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/ratios/daily`, { metrics: a.metrics, from: a.start, to: a.end, limit: a.limit });327 return renderResult(r, { title: `${a.ticker} daily ratios` });328 },329});330331export const screenFundamentals = def({332 name: 'screen_fundamentals',333 title: 'Fundamental screener',334 description:335 'Screen US stocks on fundamental ratios. filters is a comma-separated list of `metric<op>value` conditions with ops <, <=, >, >=, =, e.g. "pe<15,roe>0.15,fcf_yield>0.06,market_cap>1e9". ' +336 'Ratios are decimals (0.15 = 15 %). sort like "fcf_yield:desc". Optional as_of date screens with the data known on that day (point-in-time). Costs 2 requests of quota.',337 schema: {338 filters: z.string().min(1).max(500).describe('Conditions, e.g. "pe<15,roe>0.15". Known metrics: pe, pb, ps, ev_ebitda, fcf_yield, dividend_yield, roe, roa, gross_margin, operating_margin, net_margin, debt_to_equity, current_ratio, revenue_growth, eps_growth, market_cap, sector.'),339 sort: z.string().max(60).optional().describe('metric:asc|desc, e.g. "fcf_yield:desc".'),340 as_of: dateSchema('Point-in-time date YYYY-MM-DD (default: today).'),341 columns: z.array(z.string().max(40)).max(30).optional().describe('Extra columns to return besides the filtered metrics.'),342 limit: z.number().int().min(1).max(1000).default(50).describe('Max companies (default 50).'),343 },344 async handler(client, a) {345 const r = await client.get('/v1/fundamentals/screener', { filters: a.filters, sort: a.sort, as_of: a.as_of, columns: a.columns, limit: a.limit });346 return renderResult(r, { title: `Screener: ${a.filters}${a.sort ? ` sorted by ${a.sort}` : ''}` });347 },348});349350export const getFilings = def({351 name: 'get_filings',352 title: 'SEC filings',353 description:354 'SEC EDGAR filings of a US company (10-K, 10-Q, 8-K, 4, 13F, S-1, DEF 14A…): form type, filed date, period, accession number, primary document URL and a short description. ' +355 'Filter by forms and dates. For live notifications use subscribe_filings.',356 schema: {357 ticker: symbol('US ticker or CIK, e.g. AAPL.'),358 forms: z.array(z.string().max(12)).max(20).optional().describe('Form types to keep, e.g. ["10-K","10-Q","8-K"].'),359 start: dateSchema('Filed on/after this date.'),360 end: dateSchema('Filed on/before this date.'),361 limit: z.number().int().min(1).max(1000).default(50).describe('Max filings (default 50).'),362 },363 async handler(client, a) {364 const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/filings`, { forms: a.forms, from: a.start, to: a.end, limit: a.limit });365 return renderResult(r, { title: `${a.ticker} filings${a.forms ? ` (${a.forms.join(', ')})` : ''}` });366 },367});368369export const subscribeFilingsTool = def({370 name: 'subscribe_filings',371 title: 'Listen to the live filings stream',372 description:373 'Open the websocket wss://…/v1/stream, subscribe to the `filings` channel (new SEC filings as they are published) and return the messages received within a bounded window. ' +374 `The call blocks until max_messages messages arrive or timeout_seconds elapse (max ${MAX_TIMEOUT_MS / 1000}s), then closes the socket — it does NOT stay subscribed between calls. ` +375 'Outside SEC business hours (roughly 06:00-22:00 US/Eastern, weekdays) expect zero messages. Requires Node >= 22 (global WebSocket) or Node 20 with --experimental-websocket.',376 schema: {377 tickers: z.union([z.literal('all'), z.array(symbol('Ticker')).min(1).max(50)]).default('all').describe('Tickers to watch, or "all".'),378 forms: z.array(z.string().max(12)).max(20).optional().describe('Form types to keep, e.g. ["8-K","4"]. Default: all forms.'),379 max_messages: z.number().int().min(1).max(MAX_MESSAGES).default(10).describe('Stop after this many messages (default 10).'),380 timeout_seconds: z.number().int().min(1).max(MAX_TIMEOUT_MS / 1000).default(30).describe('Stop after this many seconds (default 30, max 120).'),381 },382 async handler(client, a, ctx) {383 const opts: SubscribeOptions = {384 url: client.wsUrl, channel: 'filings', tickers: a.tickers, forms: a.forms, apiKey: client.apiKey,385 maxMessages: a.max_messages, timeoutMs: a.timeout_seconds * 1000, WebSocketImpl: ctx?.WebSocketImpl,386 };387 const res = await subscribeFilings(opts);388 const head = `Filings stream: ${res.messages.length} message(s) in ${(res.elapsed_ms / 1000).toFixed(1)}s, stopped because ${res.stopped_because}${res.error ? ` (${res.error})` : ''}.`;389 const note = res.messages.length === 0 && res.stopped_because === 'timeout' ? ' No filing was published during the window — this is normal outside EDGAR hours; increase timeout_seconds or widen tickers/forms.' : '';390 return [head + note, JSON.stringify({ url: res.url, subscription: res.subscription, messages: res.messages })].join('\n');391 },392});393394export const TOOLS: ToolDef<any>[] = [395 searchSymbols,396 getBars,397 getFuturesContracts,398 getFuturesChain,399 getContinuous,400 getTermStructure,401 getOptionsChain,402 getCoverage,403 getFinancialStatements,404 getRatios,405 getRatiosDaily,406 screenFundamentals,407 getFilings,408 subscribeFilingsTool,409];410411export const TOOL_NAMES = TOOLS.map((t) => t.name);412413/** Run a tool by name with schema validation; used by tests and by the MCP wrapper. */414export async function runTool(name: string, client: HfmdClient, rawArgs: unknown, ctx?: ToolContext): Promise<{ text: string; isError: boolean }> {415 const tool = TOOLS.find((t) => t.name === name);416 if (!tool) return { text: `Unknown tool ${name}. Available: ${TOOL_NAMES.join(', ')}`, isError: true };417 const parsed = z.object(tool.schema).safeParse(rawArgs ?? {});418 if (!parsed.success) {419 const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');420 return { text: `Invalid arguments for ${name}: ${issues}`, isError: true };421 }422 try {423 return { text: await tool.handler(client, parsed.data, ctx), isError: false };424 } catch (e) {425 if (e instanceof HfmdError) return { text: e.describe(), isError: true };426 const msg = e instanceof Error ? e.message : String(e);427 return { text: `${name} failed: ${msg}`, isError: true };428 }429}430