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%
1import { describe, expect, it } from 'vitest';2import { TOOLS, TOOL_NAMES, runTool } from '../src/tools.js';3import { RATE_HEADERS, bars, clientWith, fakeWebSocketClass } from './helpers.js';45const EXPECTED = [6 'search_symbols', 'get_bars', 'get_futures_contracts', 'get_futures_chain', 'get_continuous', 'get_term_structure',7 'get_options_chain', 'get_coverage', 'get_financial_statements', 'get_ratios', 'get_ratios_daily', 'screen_fundamentals',8 'get_filings', 'subscribe_filings',9];1011describe('tool catalogue', () => {12 it('exposes exactly the 14 documented tools with descriptions', () => {13 expect(TOOL_NAMES).toEqual(EXPECTED);14 for (const t of TOOLS) {15 expect(t.description.length).toBeGreaterThan(80);16 expect(Object.keys(t.schema).length).toBeGreaterThan(0);17 }18 });1920 it('rejects invalid arguments before calling the API', async () => {21 const { client, calls } = clientWith([]);22 const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'AAPL', start: '01/02/2025' });23 expect(r.isError).toBe(true);24 expect(r.text).toContain('start');25 expect(calls).toHaveLength(0);26 const r2 = await runTool('nope', client, {});27 expect(r2.isError).toBe(true);28 });29});3031describe('search_symbols', () => {32 it('lists stock tickers via /v1/{asset}/tickers with search', async () => {33 const { client, calls } = clientWith([{ match: '/v1/stock/tickers', body: { asset: 'stock', timeframe: '1day', count: 3, tickers: ['AAPL', 'AAP', 'AAPG'] } }]);34 const r = await runTool('search_symbols', client, { asset: 'stock', query: 'AAP', limit: 2 });35 expect(r.isError).toBe(false);36 expect(calls[0]).toContain('/v1/stock/tickers?search=AAP&limit=2&timeframe=1day&format=json');37 expect(r.text).toContain('"tickers":["AAPL","AAP"]');38 });39 it('lists futures roots and filters client-side', async () => {40 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 } } }]);41 const r = await runTool('search_symbols', client, { asset: 'futures', query: 'crude' });42 expect(calls[0]).toContain('/v1/futures/roots');43 const json = JSON.parse(r.text.split('\n')[1]);44 expect(json.row_count).toBe(1);45 expect(json.rows[0][0]).toBe('CL');46 });47 it('lists optionable underlyings', async () => {48 const { client, calls } = clientWith([{ match: '/v1/options/tickers', body: { count: 1, data: [{ ticker: 'AAPL' }] } }]);49 const r = await runTool('search_symbols', client, { asset: 'options', query: 'AAP' });50 expect(calls[0]).toContain('/v1/options/tickers?search=AAP');51 expect(r.isError).toBe(false);52 });53});5455describe('get_bars', () => {56 it('fetches v1 bars with timeframe aliases and start/end, and shows rate headroom', async () => {57 const { client, calls } = clientWith([{ match: '/v1/bars/stock/AAPL', body: { count: 5, data: bars(5) }, headers: RATE_HEADERS }]);58 const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'aapl', timeframe: '1h', start: '2025-01-01', end: '2025-01-10', limit: 100 });59 expect(r.isError).toBe(false);60 const u = new URL(calls[0]);61 expect(u.pathname).toBe('/v1/bars/stock/AAPL');62 expect(u.searchParams.get('timeframe')).toBe('1hour');63 expect(u.searchParams.get('start')).toBe('2025-01-01');64 expect(u.searchParams.get('end')).toBe('2025-01-10');65 expect(u.searchParams.get('limit')).toBe('100');66 expect(u.searchParams.get('format')).toBe('json');67 expect(r.text).toContain('AAPL 1hour bars');68 expect(r.text).toContain('Rate limit: requests 29/30 left');69 });70 it('uses the multi-ticker endpoint for arrays', async () => {71 const { client, calls } = clientWith([{ match: '/v1/bars/etf?', body: { count: 2, data: [...bars(1, '2025-01-01', 'SPY'), ...bars(1, '2025-01-01', 'QQQ')] } }]);72 const r = await runTool('get_bars', client, { asset: 'etf', symbol: ['SPY', 'QQQ'] });73 expect(r.isError).toBe(false);74 const u = new URL(calls[0]);75 expect(u.pathname).toBe('/v1/bars/etf');76 expect(u.searchParams.get('tickers')).toBe('SPY,QQQ');77 expect(u.searchParams.get('timeframe')).toBe('1day');78 });79 it('routes futures_contract to the v2 contract bars endpoint with interval/from/to', async () => {80 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 } } }]);81 const r = await runTool('get_bars', client, { asset: 'futures_contract', symbol: 'ESZ25', start: '2025-09-01', end: '2025-09-30', session: 'rth' });82 const u = new URL(calls[0]);83 expect(u.searchParams.get('interval')).toBe('1d');84 expect(u.searchParams.get('from')).toBe('2025-09-01');85 expect(u.searchParams.get('to')).toBe('2025-09-30');86 expect(u.searchParams.get('session')).toBe('rth');87 expect(r.text).toContain('"symbol":"ESZ25"');88 });89 it('summarises large results', async () => {90 const { client } = clientWith([{ match: '/v1/bars/stock/AAPL', body: { count: 500, data: bars(500) } }]);91 const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'AAPL' });92 expect(r.text).toContain('"truncated":true');93 expect(r.text).toContain('"row_count":500');94 });95 it('surfaces 404 TICKER_NOT_FOUND as a readable error', async () => {96 const { client } = clientWith([{ match: '/v1/bars/stock/ZZZZ', status: 404, body: { error: { code: 'TICKER_NOT_FOUND', message: 'Unknown ticker ZZZZ' }, detail: 'Unknown ticker ZZZZ' } }]);97 const r = await runTool('get_bars', client, { asset: 'stock', symbol: 'ZZZZ' });98 expect(r.isError).toBe(true);99 expect(r.text).toContain('404 TICKER_NOT_FOUND');100 });101});102103describe('futures tools', () => {104 it('get_futures_contracts', async () => {105 const { client, calls } = clientWith([{ match: '/v1/futures/ES/contracts', body: { data: [{ symbol: 'ESZ25', expiration_date: '2025-12-19', status: 'active' }], meta: { count: 1 } } }]);106 const r = await runTool('get_futures_contracts', client, { root: 'es', status: 'active', year: 2025 });107 expect(r.isError).toBe(false);108 const u = new URL(calls[0]);109 expect(u.searchParams.get('status')).toBe('active');110 expect(u.searchParams.get('year')).toBe('2025');111 expect(r.text).toContain('ESZ25');112 });113 it('get_futures_chain', async () => {114 const { client, calls } = clientWith([{ match: '/v1/futures/CL/chain', body: { data: [{ symbol: 'CLX24', dte: 30 }], meta: { as_of: '2024-09-15' } } }]);115 const r = await runTool('get_futures_chain', client, { root: 'CL', as_of: '2024-09-15', depth: 6 });116 expect(r.isError).toBe(false);117 expect(new URL(calls[0]).searchParams.get('as_of')).toBe('2024-09-15');118 expect(r.text).toContain('"as_of":"2024-09-15"');119 });120 it('get_continuous passes roll/adjust/depth/interval', async () => {121 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'] } } }]);122 const r = await runTool('get_continuous', client, { root: 'ES', roll: 'open_interest', adjust: 'ratio', depth: 2, timeframe: '1day', start: '2025-01-01' });123 const u = new URL(calls[0]);124 expect(u.searchParams.get('roll')).toBe('open_interest');125 expect(u.searchParams.get('adjust')).toBe('ratio');126 expect(u.searchParams.get('depth')).toBe('2');127 expect(u.searchParams.get('interval')).toBe('1d');128 expect(u.searchParams.get('from')).toBe('2025-01-01');129 expect(r.text).toContain('roll_dates');130 });131 it('get_term_structure', async () => {132 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' } } }]);133 const r = await runTool('get_term_structure', client, { root: 'NG', as_of: '2025-09-01' });134 expect(calls[0]).toContain('/v1/futures/NG/term-structure?as_of=2025-09-01');135 expect(r.text).toContain('contango');136 });137 it('get_coverage for a contract and for fundamentals', async () => {138 const { client, calls } = clientWith([139 { match: '/v1/futures/contract/ESZ25/coverage', body: { symbol: 'ESZ25', timeframes: { '1day': { first: '2024-12-20', last: '2025-09-03', bars: 180 } }, gaps: [] } },140 { match: '/v1/fundamentals/AAPL/coverage', body: { ticker: 'AAPL', statements_since: '2009-09-26', filings: 210 } },141 ]);142 const r1 = await runTool('get_coverage', client, { kind: 'futures_contract', symbol: 'ESZ25' });143 const r2 = await runTool('get_coverage', client, { kind: 'fundamentals', symbol: 'AAPL' });144 expect(r1.isError).toBe(false);145 expect(r2.isError).toBe(false);146 expect(calls[0]).toContain('/v1/futures/contract/ESZ25/coverage');147 expect(calls[1]).toContain('/v1/fundamentals/AAPL/coverage');148 expect(r1.text).toContain('"bars":180');149 });150});151152describe('options', () => {153 it('get_options_chain filters and normalises call_put', async () => {154 const { client, calls } = clientWith([{ match: '/v1/options/chain/AAPL', body: { count: 1, data: [{ strike: 200, call_put: 'C', delta: 0.5 }] } }]);155 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 });156 const u = new URL(calls[0]);157 expect(u.searchParams.get('call_put')).toBe('C');158 expect(u.searchParams.get('strike_min')).toBe('190');159 expect(u.searchParams.get('trade_date')).toBe('2025-06-20');160 expect(r.text).toContain('"delta"');161 });162 it('get_options_chain list_expirations', async () => {163 const { client, calls } = clientWith([{ match: '/v1/options/expirations/SPY', body: { count: 2, data: [{ expiry: '2025-07-18' }, { expiry: '2025-08-15' }] } }]);164 const r = await runTool('get_options_chain', client, { ticker: 'SPY', list_expirations: true });165 expect(calls[0]).toContain('/v1/options/expirations/SPY');166 expect(r.text).toContain('2025-08-15');167 });168});169170describe('fundamentals tools', () => {171 it('get_financial_statements', async () => {172 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 } } }]);173 const r = await runTool('get_financial_statements', client, { ticker: 'AAPL', statement: 'income', period: 'quarterly', limit: 4 });174 const u = new URL(calls[0]);175 expect(u.searchParams.get('statement')).toBe('income');176 expect(u.searchParams.get('period')).toBe('quarterly');177 expect(u.searchParams.get('limit')).toBe('4');178 expect(r.text).toContain('filed_at');179 });180 it('get_financial_statements with concept → facts endpoint', async () => {181 const { client, calls } = clientWith([{ match: '/v1/fundamentals/AAPL/facts/Revenues', body: { data: [{ period_end: '2024-09-28', value: 391e9 }], meta: {} } }]);182 const r = await runTool('get_financial_statements', client, { ticker: 'AAPL', concept: 'Revenues', period: 'annual' });183 expect(calls[0]).toContain('/v1/fundamentals/AAPL/facts/Revenues');184 expect(r.isError).toBe(false);185 });186 it('get_ratios', async () => {187 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' } } }]);188 const r = await runTool('get_ratios', client, { ticker: 'MSFT' });189 expect(calls[0]).toContain('/v1/fundamentals/MSFT/ratios?format=json');190 expect(r.text).toContain('"pe":33.1');191 });192 it('get_ratios_daily passes metrics/from/to', async () => {193 const { client, calls } = clientWith([{ match: '/v1/fundamentals/MSFT/ratios/daily', body: { data: [{ date: '2025-01-02', pe: 30 }], meta: { count: 1 } } }]);194 const r = await runTool('get_ratios_daily', client, { ticker: 'MSFT', metrics: ['pe', 'pb'], start: '2025-01-01', end: '2025-02-01' });195 const u = new URL(calls[0]);196 expect(u.searchParams.get('metrics')).toBe('pe,pb');197 expect(u.searchParams.get('from')).toBe('2025-01-01');198 expect(r.isError).toBe(false);199 });200 it('screen_fundamentals', async () => {201 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' } } }]);202 const r = await runTool('screen_fundamentals', client, { filters: 'pe<15,fcf_yield>0.06', sort: 'fcf_yield:desc', limit: 20 });203 const u = new URL(calls[0]);204 expect(u.searchParams.get('filters')).toBe('pe<15,fcf_yield>0.06');205 expect(u.searchParams.get('sort')).toBe('fcf_yield:desc');206 expect(r.text).toContain('XYZ');207 });208 it('get_filings', async () => {209 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 } } }]);210 const r = await runTool('get_filings', client, { ticker: 'AAPL', forms: ['10-K', '10-Q'], start: '2024-01-01' });211 const u = new URL(calls[0]);212 expect(u.searchParams.get('forms')).toBe('10-K,10-Q');213 expect(r.text).toContain('10-K');214 });215});216217describe('subscribe_filings', () => {218 it('collects messages until max_messages then closes', async () => {219 const { client } = clientWith([]);220 const { FakeWS, instances } = fakeWebSocketClass([{ form: '8-K', ticker: 'AAPL' }, { form: '4', ticker: 'MSFT' }, { form: '10-Q', ticker: 'NVDA' }]);221 const r = await runTool('subscribe_filings', client, { tickers: ['AAPL', 'MSFT'], forms: ['8-K', '4'], max_messages: 2, timeout_seconds: 5 }, { WebSocketImpl: FakeWS });222 expect(r.isError).toBe(false);223 expect(r.text).toContain('2 message(s)');224 expect(r.text).toContain('stopped because max_messages');225 expect(instances[0].url).toBe('wss://api.test/v1/stream');226 const sub = JSON.parse(instances[0].sent[0]);227 expect(sub).toEqual({ action: 'subscribe', channel: 'filings', tickers: ['AAPL', 'MSFT'], forms: ['8-K', '4'] });228 expect(instances[0].closed).toBe(true);229 });230 it('times out gracefully with zero messages and explains', async () => {231 const { client } = clientWith([]);232 const { FakeWS } = fakeWebSocketClass([]);233 const r = await runTool('subscribe_filings', client, { max_messages: 5, timeout_seconds: 1 }, { WebSocketImpl: FakeWS });234 expect(r.isError).toBe(false);235 expect(r.text).toContain('0 message(s)');236 expect(r.text).toContain('stopped because timeout');237 expect(r.text).toContain('normal outside EDGAR hours');238 });239 it('reports websocket errors without throwing', async () => {240 const { client } = clientWith([]);241 const { FakeWS } = fakeWebSocketClass([], { failOpen: true });242 const r = await runTool('subscribe_filings', client, { timeout_seconds: 2 }, { WebSocketImpl: FakeWS });243 expect(r.text).toContain('stopped because error');244 });245 it('never leaks the api key in the returned subscription', async () => {246 const { client } = clientWith([], { apiKey: 'hfmd_live_secret' });247 const { FakeWS, instances } = fakeWebSocketClass([{ ok: 1 }]);248 const r = await runTool('subscribe_filings', client, { max_messages: 1, timeout_seconds: 2 }, { WebSocketImpl: FakeWS });249 expect(JSON.parse(instances[0].sent[0]).api_key).toBe('hfmd_live_secret');250 expect(r.text).not.toContain('hfmd_live_secret');251 });252});253