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%
15.2 KB · 253 lines typescript
Raw Blame History
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