SPB Git forge

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)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
26.3 KB · 430 lines typescript
Raw Blame History
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