import { describe, expect, it } from 'vitest'; import { TOOLS, TOOL_NAMES, runTool } from '../src/tools.js'; import { RATE_HEADERS, bars, clientWith, fakeWebSocketClass } from './helpers.js'; const EXPECTED = [ 'search_symbols', 'get_bars', 'get_futures_contracts', 'get_futures_chain', 'get_continuous', 'get_term_structure', 'get_options_chain', 'get_coverage', 'get_financial_statements', 'get_ratios', 'get_ratios_daily', 'screen_fundamentals', 'get_filings', 'subscribe_filings', ]; describe('tool catalogue', () => { it('exposes exactly the 14 documented tools with descriptions', () => { expect(TOOL_NAMES).toEqual(EXPECTED); for (const t of TOOLS) { expect(t.description.length).toBeGreaterThan(80); expect(Object.keys(t.schema).length).toBeGreaterThan(0); } }); it('rejects invalid arguments before calling the API', async () => { const { client, calls } = clientWith([]); const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'AAPL', start: '01/02/2025' }); expect(r.isError).toBe(true); expect(r.text).toContain('start'); expect(calls).toHaveLength(0); const r2 = await runTool('nope', client, {}); expect(r2.isError).toBe(true); }); }); describe('search_symbols', () => { it('lists stock tickers via /v1/{asset}/tickers with search', async () => { const { client, calls } = clientWith([{ match: '/v1/stock/tickers', body: { asset: 'stock', timeframe: '1day', count: 3, tickers: ['AAPL', 'AAP', 'AAPG'] } }]); const r = await runTool('search_symbols', client, { asset: 'stock', query: 'AAP', limit: 2 }); expect(r.isError).toBe(false); expect(calls[0]).toContain('/v1/stock/tickers?search=AAP&limit=2&timeframe=1day&format=json'); expect(r.text).toContain('"tickers":["AAPL","AAP"]'); }); it('lists futures roots and filters client-side', async () => { const { client, calls } = clientWith([{ match: '/v1/futures/roots', body: { data: [{ root: 'ES', name: 'E-mini S&P 500' }, { root: 'CL', name: 'Crude Oil WTI' }], meta: { count: 2 } } }]); const r = await runTool('search_symbols', client, { asset: 'futures', query: 'crude' }); expect(calls[0]).toContain('/v1/futures/roots'); const json = JSON.parse(r.text.split('\n')[1]); expect(json.row_count).toBe(1); expect(json.rows[0][0]).toBe('CL'); }); it('lists optionable underlyings', async () => { const { client, calls } = clientWith([{ match: '/v1/options/tickers', body: { count: 1, data: [{ ticker: 'AAPL' }] } }]); const r = await runTool('search_symbols', client, { asset: 'options', query: 'AAP' }); expect(calls[0]).toContain('/v1/options/tickers?search=AAP'); expect(r.isError).toBe(false); }); }); describe('get_bars', () => { it('fetches v1 bars with timeframe aliases and start/end, and shows rate headroom', async () => { const { client, calls } = clientWith([{ match: '/v1/bars/stock/AAPL', body: { count: 5, data: bars(5) }, headers: RATE_HEADERS }]); const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'aapl', timeframe: '1h', start: '2025-01-01', end: '2025-01-10', limit: 100 }); expect(r.isError).toBe(false); const u = new URL(calls[0]); expect(u.pathname).toBe('/v1/bars/stock/AAPL'); expect(u.searchParams.get('timeframe')).toBe('1hour'); expect(u.searchParams.get('start')).toBe('2025-01-01'); expect(u.searchParams.get('end')).toBe('2025-01-10'); expect(u.searchParams.get('limit')).toBe('100'); expect(u.searchParams.get('format')).toBe('json'); expect(r.text).toContain('AAPL 1hour bars'); expect(r.text).toContain('Rate limit: requests 29/30 left'); }); it('uses the multi-ticker endpoint for arrays', async () => { const { client, calls } = clientWith([{ match: '/v1/bars/etf?', body: { count: 2, data: [...bars(1, '2025-01-01', 'SPY'), ...bars(1, '2025-01-01', 'QQQ')] } }]); const r = await runTool('get_bars', client, { asset: 'etf', symbol: ['SPY', 'QQQ'] }); expect(r.isError).toBe(false); const u = new URL(calls[0]); expect(u.pathname).toBe('/v1/bars/etf'); expect(u.searchParams.get('tickers')).toBe('SPY,QQQ'); expect(u.searchParams.get('timeframe')).toBe('1day'); }); it('routes futures_contract to the v2 contract bars endpoint with interval/from/to', async () => { const { client, calls } = clientWith([{ match: '/v1/futures/contract/ESZ25/bars', body: { data: bars(2, '2025-09-01', 'ES'), meta: { symbol: 'ESZ25', interval: '1d', count: 2 } } }]); const r = await runTool('get_bars', client, { asset: 'futures_contract', symbol: 'ESZ25', start: '2025-09-01', end: '2025-09-30', session: 'rth' }); const u = new URL(calls[0]); expect(u.searchParams.get('interval')).toBe('1d'); expect(u.searchParams.get('from')).toBe('2025-09-01'); expect(u.searchParams.get('to')).toBe('2025-09-30'); expect(u.searchParams.get('session')).toBe('rth'); expect(r.text).toContain('"symbol":"ESZ25"'); }); it('summarises large results', async () => { const { client } = clientWith([{ match: '/v1/bars/stock/AAPL', body: { count: 500, data: bars(500) } }]); const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'AAPL' }); expect(r.text).toContain('"truncated":true'); expect(r.text).toContain('"row_count":500'); }); it('surfaces 404 TICKER_NOT_FOUND as a readable error', async () => { const { client } = clientWith([{ match: '/v1/bars/stock/ZZZZ', status: 404, body: { error: { code: 'TICKER_NOT_FOUND', message: 'Unknown ticker ZZZZ' }, detail: 'Unknown ticker ZZZZ' } }]); const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'ZZZZ' }); expect(r.isError).toBe(true); expect(r.text).toContain('404 TICKER_NOT_FOUND'); }); }); describe('futures tools', () => { it('get_futures_contracts', async () => { const { client, calls } = clientWith([{ match: '/v1/futures/ES/contracts', body: { data: [{ symbol: 'ESZ25', expiration_date: '2025-12-19', status: 'active' }], meta: { count: 1 } } }]); const r = await runTool('get_futures_contracts', client, { root: 'es', status: 'active', year: 2025 }); expect(r.isError).toBe(false); const u = new URL(calls[0]); expect(u.searchParams.get('status')).toBe('active'); expect(u.searchParams.get('year')).toBe('2025'); expect(r.text).toContain('ESZ25'); }); it('get_futures_chain', async () => { const { client, calls } = clientWith([{ match: '/v1/futures/CL/chain', body: { data: [{ symbol: 'CLX24', dte: 30 }], meta: { as_of: '2024-09-15' } } }]); const r = await runTool('get_futures_chain', client, { root: 'CL', as_of: '2024-09-15', depth: 6 }); expect(r.isError).toBe(false); expect(new URL(calls[0]).searchParams.get('as_of')).toBe('2024-09-15'); expect(r.text).toContain('"as_of":"2024-09-15"'); }); it('get_continuous passes roll/adjust/depth/interval', async () => { const { client, calls } = clientWith([{ match: '/v1/futures/ES/continuous', body: { data: bars(3, '2025-01-01', 'ES'), meta: { count: 3, roll_dates: ['2025-03-14'] } } }]); const r = await runTool('get_continuous', client, { root: 'ES', roll: 'open_interest', adjust: 'ratio', depth: 2, timeframe: '1day', start: '2025-01-01' }); const u = new URL(calls[0]); expect(u.searchParams.get('roll')).toBe('open_interest'); expect(u.searchParams.get('adjust')).toBe('ratio'); expect(u.searchParams.get('depth')).toBe('2'); expect(u.searchParams.get('interval')).toBe('1d'); expect(u.searchParams.get('from')).toBe('2025-01-01'); expect(r.text).toContain('roll_dates'); }); it('get_term_structure', async () => { const { client, calls } = clientWith([{ match: '/v1/futures/NG/term-structure', body: { data: [{ symbol: 'NGX25', price: 3.1 }, { symbol: 'NGZ25', price: 3.4 }], meta: { as_of: '2025-09-01', shape: 'contango' } } }]); const r = await runTool('get_term_structure', client, { root: 'NG', as_of: '2025-09-01' }); expect(calls[0]).toContain('/v1/futures/NG/term-structure?as_of=2025-09-01'); expect(r.text).toContain('contango'); }); it('get_coverage for a contract and for fundamentals', async () => { const { client, calls } = clientWith([ { match: '/v1/futures/contract/ESZ25/coverage', body: { symbol: 'ESZ25', timeframes: { '1day': { first: '2024-12-20', last: '2025-09-03', bars: 180 } }, gaps: [] } }, { match: '/v1/fundamentals/AAPL/coverage', body: { ticker: 'AAPL', statements_since: '2009-09-26', filings: 210 } }, ]); const r1 = await runTool('get_coverage', client, { kind: 'futures_contract', symbol: 'ESZ25' }); const r2 = await runTool('get_coverage', client, { kind: 'fundamentals', symbol: 'AAPL' }); expect(r1.isError).toBe(false); expect(r2.isError).toBe(false); expect(calls[0]).toContain('/v1/futures/contract/ESZ25/coverage'); expect(calls[1]).toContain('/v1/fundamentals/AAPL/coverage'); expect(r1.text).toContain('"bars":180'); }); }); describe('options', () => { it('get_options_chain filters and normalises call_put', async () => { const { client, calls } = clientWith([{ match: '/v1/options/chain/AAPL', body: { count: 1, data: [{ strike: 200, call_put: 'C', delta: 0.5 }] } }]); const r = await runTool('get_options_chain', client, { ticker: 'AAPL', trade_date: '2025-06-20', expiry: '2025-07-18', call_put: 'call', strike_min: 190, strike_max: 210 }); const u = new URL(calls[0]); expect(u.searchParams.get('call_put')).toBe('C'); expect(u.searchParams.get('strike_min')).toBe('190'); expect(u.searchParams.get('trade_date')).toBe('2025-06-20'); expect(r.text).toContain('"delta"'); }); it('get_options_chain list_expirations', async () => { const { client, calls } = clientWith([{ match: '/v1/options/expirations/SPY', body: { count: 2, data: [{ expiry: '2025-07-18' }, { expiry: '2025-08-15' }] } }]); const r = await runTool('get_options_chain', client, { ticker: 'SPY', list_expirations: true }); expect(calls[0]).toContain('/v1/options/expirations/SPY'); expect(r.text).toContain('2025-08-15'); }); }); describe('fundamentals tools', () => { it('get_financial_statements', async () => { const { client, calls } = clientWith([{ match: '/v1/fundamentals/AAPL/statements', body: { data: [{ period_end: '2025-06-28', revenue: 94e9, filed_at: '2025-08-01' }], meta: { count: 1 } } }]); const r = await runTool('get_financial_statements', client, { ticker: 'AAPL', statement: 'income', period: 'quarterly', limit: 4 }); const u = new URL(calls[0]); expect(u.searchParams.get('statement')).toBe('income'); expect(u.searchParams.get('period')).toBe('quarterly'); expect(u.searchParams.get('limit')).toBe('4'); expect(r.text).toContain('filed_at'); }); it('get_financial_statements with concept → facts endpoint', async () => { const { client, calls } = clientWith([{ match: '/v1/fundamentals/AAPL/facts/Revenues', body: { data: [{ period_end: '2024-09-28', value: 391e9 }], meta: {} } }]); const r = await runTool('get_financial_statements', client, { ticker: 'AAPL', concept: 'Revenues', period: 'annual' }); expect(calls[0]).toContain('/v1/fundamentals/AAPL/facts/Revenues'); expect(r.isError).toBe(false); }); it('get_ratios', async () => { const { client, calls } = clientWith([{ match: '/v1/fundamentals/MSFT/ratios?', body: { data: { pe: 33.1, roe: 0.38, fcf_yield: 0.025 }, meta: { as_of: '2025-09-03' } } }]); const r = await runTool('get_ratios', client, { ticker: 'MSFT' }); expect(calls[0]).toContain('/v1/fundamentals/MSFT/ratios?format=json'); expect(r.text).toContain('"pe":33.1'); }); it('get_ratios_daily passes metrics/from/to', async () => { const { client, calls } = clientWith([{ match: '/v1/fundamentals/MSFT/ratios/daily', body: { data: [{ date: '2025-01-02', pe: 30 }], meta: { count: 1 } } }]); const r = await runTool('get_ratios_daily', client, { ticker: 'MSFT', metrics: ['pe', 'pb'], start: '2025-01-01', end: '2025-02-01' }); const u = new URL(calls[0]); expect(u.searchParams.get('metrics')).toBe('pe,pb'); expect(u.searchParams.get('from')).toBe('2025-01-01'); expect(r.isError).toBe(false); }); it('screen_fundamentals', async () => { const { client, calls } = clientWith([{ match: '/v1/fundamentals/screener', body: { data: [{ ticker: 'XYZ', pe: 12, fcf_yield: 0.08 }], meta: { count: 1, as_of: '2025-09-03' } } }]); const r = await runTool('screen_fundamentals', client, { filters: 'pe<15,fcf_yield>0.06', sort: 'fcf_yield:desc', limit: 20 }); const u = new URL(calls[0]); expect(u.searchParams.get('filters')).toBe('pe<15,fcf_yield>0.06'); expect(u.searchParams.get('sort')).toBe('fcf_yield:desc'); expect(r.text).toContain('XYZ'); }); it('get_filings', async () => { const { client, calls } = clientWith([{ match: '/v1/fundamentals/AAPL/filings', body: { data: [{ form: '10-K', filed_at: '2024-11-01', url: 'https://sec.gov/x' }], meta: { count: 1 } } }]); const r = await runTool('get_filings', client, { ticker: 'AAPL', forms: ['10-K', '10-Q'], start: '2024-01-01' }); const u = new URL(calls[0]); expect(u.searchParams.get('forms')).toBe('10-K,10-Q'); expect(r.text).toContain('10-K'); }); }); describe('subscribe_filings', () => { it('collects messages until max_messages then closes', async () => { const { client } = clientWith([]); const { FakeWS, instances } = fakeWebSocketClass([{ form: '8-K', ticker: 'AAPL' }, { form: '4', ticker: 'MSFT' }, { form: '10-Q', ticker: 'NVDA' }]); const r = await runTool('subscribe_filings', client, { tickers: ['AAPL', 'MSFT'], forms: ['8-K', '4'], max_messages: 2, timeout_seconds: 5 }, { WebSocketImpl: FakeWS }); expect(r.isError).toBe(false); expect(r.text).toContain('2 message(s)'); expect(r.text).toContain('stopped because max_messages'); expect(instances[0].url).toBe('wss://api.test/v1/stream'); const sub = JSON.parse(instances[0].sent[0]); expect(sub).toEqual({ action: 'subscribe', channel: 'filings', tickers: ['AAPL', 'MSFT'], forms: ['8-K', '4'] }); expect(instances[0].closed).toBe(true); }); it('times out gracefully with zero messages and explains', async () => { const { client } = clientWith([]); const { FakeWS } = fakeWebSocketClass([]); const r = await runTool('subscribe_filings', client, { max_messages: 5, timeout_seconds: 1 }, { WebSocketImpl: FakeWS }); expect(r.isError).toBe(false); expect(r.text).toContain('0 message(s)'); expect(r.text).toContain('stopped because timeout'); expect(r.text).toContain('normal outside EDGAR hours'); }); it('reports websocket errors without throwing', async () => { const { client } = clientWith([]); const { FakeWS } = fakeWebSocketClass([], { failOpen: true }); const r = await runTool('subscribe_filings', client, { timeout_seconds: 2 }, { WebSocketImpl: FakeWS }); expect(r.text).toContain('stopped because error'); }); it('never leaks the api key in the returned subscription', async () => { const { client } = clientWith([], { apiKey: 'hfmd_live_secret' }); const { FakeWS, instances } = fakeWebSocketClass([{ ok: 1 }]); const r = await runTool('subscribe_filings', client, { max_messages: 1, timeout_seconds: 2 }, { WebSocketImpl: FakeWS }); expect(JSON.parse(instances[0].sent[0]).api_key).toBe('hfmd_live_secret'); expect(r.text).not.toContain('hfmd_live_secret'); }); });