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 * Minimal HTTP client for the HF Market Data API (native fetch, no dependencies).3 *4 * - Base URL from `HFMD_BASE_URL` (default https://www.hfmarketdata.io).5 * - Optional bearer key from `HFMD_API_KEY` (keyless mode works with reduced limits).6 * - Always requests `format=json`, parses both the v1 envelope `{count,data}` and the7 * v2 envelope `{data,meta}`, surfaces `X-RateLimit-*` headers and maps every failure to8 * a single `HfmdError` shape (`{status, code, message, docs, retryAfter}`).9 */1011export const DEFAULT_BASE_URL = 'https://www.hfmarketdata.io';12export const USER_AGENT = 'hfmarketdata-mcp/1.0.0 (+https://www.hfmarketdata.io/integrations/mcp)';1314export type Params = Record<string, string | number | boolean | string[] | undefined | null>;1516export interface RateInfo {17 limitRequests: number | null;18 remainingRequests: number | null;19 limitRows: number | null;20 remainingRows: number | null;21 /** Seconds (or epoch seconds, depending on the server) until the window resets. */22 reset: number | null;23 rowCount: number | null;24 retryAfter: number | null;25}2627export interface ApiResult<T = unknown> {28 status: number;29 url: string;30 body: T;31 rate: RateInfo;32 ms: number;33}3435export class HfmdError extends Error {36 status: number;37 code: string;38 docs?: string;39 details?: unknown;40 rate?: RateInfo;41 url?: string;4243 constructor(status: number, code: string, message: string, extra: { docs?: string; details?: unknown; rate?: RateInfo; url?: string } = {}) {44 super(message);45 this.name = 'HfmdError';46 this.status = status;47 this.code = code;48 this.docs = extra.docs;49 this.details = extra.details;50 this.rate = extra.rate;51 this.url = extra.url;52 }5354 /** Human readable, single paragraph, safe to return to an LLM. */55 describe(): string {56 const parts = [`HF Market Data error ${this.status} ${this.code}: ${this.message}`];57 if (this.status === 429) {58 const ra = this.rate?.retryAfter;59 parts.push(ra != null ? `Retry after ${ra}s.` : 'Retry later.');60 parts.push('Keyless mode has low hourly limits (30 requests/hour); set HFMD_API_KEY (free API key = 120 req/min; higher limits on request to contact@spboucher.ai, also free) or narrow the query.');61 } else if (this.status === 401) {62 parts.push('Check HFMD_API_KEY (format hfmd_live_…) or unset it to use keyless mode.');63 } else if (this.status === 404 && this.code === 'NOT_FOUND') {64 parts.push('This endpoint may not be deployed yet on this server (v2 futures/fundamentals roll out progressively). Check hfmarketdata://status.');65 }66 if (this.docs) parts.push(`Docs: ${this.docs}`);67 if (this.url) parts.push(`URL: ${this.url}`);68 return parts.join(' ');69 }70}7172export interface ClientOptions {73 baseUrl?: string;74 apiKey?: string;75 fetchImpl?: typeof fetch;76 timeoutMs?: number;77}7879function num(h: Headers, k: string): number | null {80 const v = h.get(k);81 if (v == null || v === '') return null;82 const n = Number(v);83 return Number.isFinite(n) ? n : null;84}8586export function rateFromHeaders(h: Headers): RateInfo {87 return {88 limitRequests: num(h, 'x-ratelimit-limit-requests') ?? num(h, 'x-ratelimit-limit'),89 remainingRequests: num(h, 'x-ratelimit-remaining-requests') ?? num(h, 'x-ratelimit-remaining'),90 limitRows: num(h, 'x-ratelimit-limit-rows'),91 remainingRows: num(h, 'x-ratelimit-remaining-rows'),92 reset: num(h, 'x-ratelimit-reset'),93 rowCount: num(h, 'x-row-count'),94 retryAfter: num(h, 'retry-after'),95 };96}9798export function describeRate(rate: RateInfo | undefined): string | null {99 if (!rate) return null;100 const bits: string[] = [];101 if (rate.remainingRequests != null) bits.push(`requests ${rate.remainingRequests}${rate.limitRequests != null ? `/${rate.limitRequests}` : ''} left`);102 if (rate.remainingRows != null) bits.push(`rows ${rate.remainingRows}${rate.limitRows != null ? `/${rate.limitRows}` : ''} left`);103 if (rate.reset != null) bits.push(`reset in ${rate.reset}s`);104 return bits.length ? `Rate limit: ${bits.join(' · ')}` : null;105}106107export class HfmdClient {108 readonly baseUrl: string;109 readonly apiKey: string | undefined;110 private readonly fetchImpl: typeof fetch;111 private readonly timeoutMs: number;112113 constructor(opts: ClientOptions = {}) {114 this.baseUrl = (opts.baseUrl ?? process.env.HFMD_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');115 const key = opts.apiKey ?? process.env.HFMD_API_KEY;116 this.apiKey = key && key.trim() ? key.trim() : undefined;117 this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;118 this.timeoutMs = opts.timeoutMs ?? 60_000;119 if (typeof this.fetchImpl !== 'function') {120 throw new Error('global fetch is not available: hfmarketdata-mcp needs Node.js >= 20');121 }122 }123124 get keyless(): boolean {125 return !this.apiKey;126 }127128 /** Websocket URL derived from the base URL (https → wss). */129 get wsUrl(): string {130 return this.baseUrl.replace(/^http/, 'ws') + '/v1/stream';131 }132133 buildUrl(path: string, params: Params = {}): string {134 const u = new URL(this.baseUrl + (path.startsWith('/') ? path : `/${path}`));135 for (const [k, v] of Object.entries(params)) {136 if (v === undefined || v === null || v === '') continue;137 u.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v));138 }139 if (!u.searchParams.has('format')) u.searchParams.set('format', 'json');140 return u.toString();141 }142143 async get<T = unknown>(path: string, params: Params = {}): Promise<ApiResult<T>> {144 const url = this.buildUrl(path, params);145 const headers: Record<string, string> = { Accept: 'application/json', 'User-Agent': USER_AGENT };146 if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;147 const ctrl = new AbortController();148 const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);149 const t0 = Date.now();150 let res: Response;151 try {152 res = await this.fetchImpl(url, { method: 'GET', headers, signal: ctrl.signal });153 } catch (e) {154 clearTimeout(timer);155 const msg = e instanceof Error ? e.message : String(e);156 const code = ctrl.signal.aborted ? 'TIMEOUT' : 'NETWORK_ERROR';157 throw new HfmdError(0, code, ctrl.signal.aborted ? `request timed out after ${this.timeoutMs} ms` : `network error: ${msg}`, { url });158 }159 clearTimeout(timer);160 const ms = Date.now() - t0;161 const rate = rateFromHeaders(res.headers);162 const ct = res.headers.get('content-type') || '';163 let body: unknown;164 const text = await res.text();165 if (ct.includes('json') || text.startsWith('{') || text.startsWith('[')) {166 try {167 body = JSON.parse(text);168 } catch {169 body = text;170 }171 } else {172 body = text;173 }174 if (!res.ok) throw errorFromResponse(res.status, body, rate, url);175 return { status: res.status, url, body: body as T, rate, ms };176 }177}178179export function errorFromResponse(status: number, body: unknown, rate: RateInfo, url: string): HfmdError {180 const b = (body ?? {}) as Record<string, unknown>;181 const err = (b.error ?? {}) as Record<string, unknown>;182 let code = typeof err.code === 'string' ? err.code : undefined;183 let message = typeof err.message === 'string' ? err.message : undefined;184 if (!message) {185 const detail = b.detail;186 if (typeof detail === 'string') message = detail;187 else if (Array.isArray(detail)) message = detail.map((d) => (d && typeof d === 'object' && 'msg' in d ? `${(d as any).loc?.join('.') ?? ''}: ${(d as any).msg}` : JSON.stringify(d))).join('; ');188 else if (typeof body === 'string' && body.trim()) message = body.trim().slice(0, 300);189 }190 if (!code) {191 code =192 status === 429 ? 'RATE_LIMIT_EXCEEDED'193 : status === 401 ? 'INVALID_API_KEY'194 : status === 403 ? 'FORBIDDEN'195 : status === 404 ? 'NOT_FOUND'196 : status === 400 || status === 422 ? 'INVALID_PARAMETER'197 : status >= 500 ? 'INTERNAL_ERROR'198 : 'HTTP_ERROR';199 }200 return new HfmdError(status, code, message ?? `HTTP ${status}`, {201 docs: typeof err.docs === 'string' ? err.docs : undefined,202 details: err.details,203 rate,204 url,205 });206}207