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 * Turns API payloads into compact, LLM-friendly text while keeping tool outputs bounded.3 *4 * Tabular payloads are rendered as `{"columns":[…],"rows":[[…]]}` (about half the tokens of an5 * array of objects). Above `fullRowsThreshold` rows only the head/tail are kept, together with the6 * total count, the columns, and a hint to narrow the request.7 */8import { describeRate, type ApiResult, type RateInfo } from './client.js';910export const DEFAULT_LIMIT = 500;11export const MAX_LIMIT = 5000;12export const FULL_ROWS_THRESHOLD = 200;13export const HEAD_TAIL = 15;1415export type Row = Record<string, unknown>;1617export interface Extracted {18 rows: Row[] | null;19 meta: Record<string, unknown>;20 /** Anything that is not the rows nor the meta (e.g. v1 `tickers` list). */21 rest: Record<string, unknown>;22}2324/** Understand both envelopes: v1 `{count,data}` and v2 `{data,meta}`, plus bare arrays. */25export function extract(body: unknown): Extracted {26 if (Array.isArray(body)) return { rows: body.every(isRow) ? (body as Row[]) : null, meta: {}, rest: body.every(isRow) ? {} : { data: body } };27 if (!body || typeof body !== 'object') return { rows: null, meta: {}, rest: { value: body } };28 const b = body as Record<string, unknown>;29 const meta: Record<string, unknown> = typeof b.meta === 'object' && b.meta ? { ...(b.meta as Record<string, unknown>) } : {};30 if (typeof b.count === 'number' && meta.count === undefined) meta.count = b.count;31 const rest: Record<string, unknown> = {};32 for (const [k, v] of Object.entries(b)) if (k !== 'data' && k !== 'meta' && k !== 'count') rest[k] = v;33 const data = b.data;34 if (Array.isArray(data)) {35 if (data.every(isRow)) return { rows: data as Row[], meta, rest };36 return { rows: null, meta, rest: { ...rest, data } };37 }38 if (data && typeof data === 'object') return { rows: null, meta, rest: { ...rest, data } };39 return { rows: null, meta, rest };40}4142function isRow(x: unknown): x is Row {43 return !!x && typeof x === 'object' && !Array.isArray(x);44}4546export function columnsOf(rows: Row[]): string[] {47 const cols: string[] = [];48 const seen = new Set<string>();49 for (const r of rows.slice(0, 50)) for (const k of Object.keys(r)) if (!seen.has(k)) { seen.add(k); cols.push(k); }50 return cols;51}5253function cell(v: unknown): unknown {54 if (typeof v === 'number' && !Number.isInteger(v)) return Number(v.toFixed(6));55 return v === undefined ? null : v;56}5758export function toTable(rows: Row[], columns = columnsOf(rows)): { columns: string[]; rows: unknown[][] } {59 return { columns, rows: rows.map((r) => columns.map((c) => cell(r[c]))) };60}6162export interface RenderOptions {63 /** Rows above which the output is summarised (head + tail). */64 fullRowsThreshold?: number;65 headTail?: number;66 title?: string;67 hint?: string;68}6970/** Render a full API result as text. */71export function renderResult(result: ApiResult, opts: RenderOptions = {}): string {72 const { rows, meta, rest } = extract(result.body);73 const lines: string[] = [];74 if (opts.title) lines.push(opts.title);75 const out: Record<string, unknown> = {};76 if (rows) {77 const threshold = opts.fullRowsThreshold ?? FULL_ROWS_THRESHOLD;78 const ht = opts.headTail ?? HEAD_TAIL;79 const total = rows.length;80 const columns = columnsOf(rows);81 out.row_count = total;82 if (typeof meta.count === 'number' && meta.count !== total) out.count_reported = meta.count;83 out.columns = columns;84 if (total <= threshold) {85 out.rows = toTable(rows, columns).rows;86 } else {87 out.truncated = true;88 out.first_rows = toTable(rows.slice(0, ht), columns).rows;89 out.last_rows = toTable(rows.slice(-ht), columns).rows;90 out.numeric_summary = numericSummary(rows, columns);91 out.hint = opts.hint ?? `Showing ${ht} first and ${ht} last rows of ${total}. Narrow the date range, use a coarser timeframe, or lower \`limit\` to see every row.`;92 }93 if (meta.next_cursor) out.next_cursor = meta.next_cursor;94 }95 const metaRest = { ...meta };96 delete metaRest.count;97 if (Object.keys(metaRest).length) out.meta = metaRest;98 for (const [k, v] of Object.entries(rest)) out[k] = shrink(v);99 lines.push(JSON.stringify(out));100 const rate = describeRate(result.rate);101 if (rate) lines.push(rate);102 return lines.join('\n');103}104105/** Long arrays inside non-tabular payloads (e.g. 7 000 tickers) get truncated with a count. */106export function shrink(v: unknown, max = 400): unknown {107 if (Array.isArray(v) && v.length > max) return { count: v.length, first: v.slice(0, max), truncated: true };108 if (v && typeof v === 'object' && !Array.isArray(v)) {109 const o: Record<string, unknown> = {};110 for (const [k, x] of Object.entries(v as Record<string, unknown>)) o[k] = shrink(x, max);111 return o;112 }113 return v;114}115116export function numericSummary(rows: Row[], columns: string[]): Record<string, { min: number; max: number; first: number | null; last: number | null }> {117 const s: Record<string, { min: number; max: number; first: number | null; last: number | null }> = {};118 for (const c of columns) {119 let min = Infinity, max = -Infinity, first: number | null = null, last: number | null = null, n = 0;120 for (const r of rows) {121 const v = r[c];122 if (typeof v !== 'number' || !Number.isFinite(v)) continue;123 n++;124 if (first === null) first = v;125 last = v;126 if (v < min) min = v;127 if (v > max) max = v;128 }129 if (n >= 2 && c !== 'datetime') s[c] = { min: Number(min.toFixed(6)), max: Number(max.toFixed(6)), first, last };130 }131 return s;132}133134export function rateLine(rate: RateInfo | undefined): string {135 return describeRate(rate) ?? 'Rate limit: headers not provided by the server';136}137