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