SPB Git

spb/findata

Public
13.1 KB · 463 lines typescript
1/**2 * Fetch wrapper for the FinData OS API (same origin, prefix /api/v1).3 * - cookie session (`credentials: same-origin`)4 * - X-CSRF-Token on every non-GET request (kept in memory + sessionStorage)5 * - error normalization: throws ApiError {code, message, request_id, status, extra}6 */7export const API_PREFIX = "/api/v1";89const CSRF_KEY = "findata.csrf";10let csrfToken: string | null = null;1112export function getCsrf(): string | null {13  if (csrfToken) return csrfToken;14  if (typeof window !== "undefined") {15    try {16      csrfToken = window.sessionStorage.getItem(CSRF_KEY);17    } catch {18      csrfToken = null;19    }20  }21  return csrfToken;22}2324export function setCsrf(token: string | null | undefined) {25  csrfToken = token ?? null;26  if (typeof window === "undefined") return;27  try {28    if (token) window.sessionStorage.setItem(CSRF_KEY, token);29    else window.sessionStorage.removeItem(CSRF_KEY);30  } catch {31    /* storage unavailable */32  }33}3435export class ApiError extends Error {36  code: string;37  status: number;38  request_id: string | null;39  extra: Record<string, unknown>;40  constructor(status: number, code: string, message: string, request_id: string | null, extra: Record<string, unknown> = {}) {41    super(message);42    this.name = "ApiError";43    this.status = status;44    this.code = code;45    this.request_id = request_id;46    this.extra = extra;47  }48}4950export function isApiError(e: unknown): e is ApiError {51  return e instanceof ApiError;52}5354export type Query = Record<string, string | number | boolean | null | undefined>;5556export function qs(params?: Query): string {57  if (!params) return "";58  const u = new URLSearchParams();59  for (const [k, v] of Object.entries(params)) {60    if (v === undefined || v === null || v === "") continue;61    u.set(k, String(v));62  }63  const s = u.toString();64  return s ? `?${s}` : "";65}6667interface RequestOptions {68  method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";69  body?: unknown;70  params?: Query;71  signal?: AbortSignal;72}7374async function parseError(res: Response): Promise<ApiError> {75  let payload: unknown = null;76  try {77    payload = await res.json();78  } catch {79    /* not JSON */80  }81  const err = (payload as { error?: Record<string, unknown> } | null)?.error;82  if (err && typeof err === "object") {83    const { code, message, request_id, ...extra } = err as Record<string, unknown>;84    return new ApiError(res.status, String(code ?? "http_error"), String(message ?? res.statusText), (request_id as string) ?? null, extra);85  }86  return new ApiError(res.status, "http_error", res.statusText || `HTTP ${res.status}`, res.headers.get("x-request-id"));87}8889export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {90  const method = opts.method ?? "GET";91  const headers: Record<string, string> = { Accept: "application/json" };92  if (opts.body !== undefined) headers["Content-Type"] = "application/json";93  if (method !== "GET") {94    const t = getCsrf();95    if (t) headers["X-CSRF-Token"] = t;96  }97  const url = (path.startsWith("/api/") ? path : API_PREFIX + path) + qs(opts.params);98  const res = await fetch(url, {99    method,100    headers,101    credentials: "same-origin",102    body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,103    signal: opts.signal,104  });105  if (!res.ok) throw await parseError(res);106  if (res.status === 204) return undefined as T;107  const text = await res.text();108  if (!text) return undefined as T;109  return JSON.parse(text) as T;110}111112export const api = {113  get: <T>(path: string, params?: Query, signal?: AbortSignal) => request<T>(path, { params, signal }),114  post: <T>(path: string, body?: unknown, params?: Query) => request<T>(path, { method: "POST", body, params }),115  put: <T>(path: string, body?: unknown) => request<T>(path, { method: "PUT", body }),116  del: <T>(path: string) => request<T>(path, { method: "DELETE" }),117};118119// ---------------------------------------------------------------- types (mirroring the FastAPI routers)120121export interface Me {122  kind: "user" | "api_key" | "anonymous";123  email: string | null;124  role: string | null;125  scopes: string[];126  mfa_passed: boolean;127  totp_enabled: boolean | null;128  csrf_token: string | null;129}130131export interface LoginOut {132  ok: boolean;133  mfa_required: boolean;134  csrf_token: string;135  email: string;136  role: string;137}138139export interface HealthAlert {140  severity: "info" | "warning" | "error";141  code: string;142  message: string;143}144145export interface HealthReport {146  status: "ok" | "warning" | "degraded";147  checked_at?: string;148  alerts: HealthAlert[];149  components: {150    postgres?: string;151    lake?: { root: string; free_gb: number; curated_gb: number; raw_gb: number; soft_limit_gb: number; hard_min_free_gb: number };152    ingest_worker?: string;153    backup?: { last: string | null; age_hours?: number; verified?: boolean | null };154    ssd_smart?: string;155  };156  datasets?: {157    dataset_id: string;158    version: number | null;159    last_session: string | null;160    days_since_last_session: number | null;161    vendor_last_update: string | null;162    vendor_lag_days: number | null;163  }[];164}165166export interface HealthSummary {167  datasets: { active: number; planned: number; unsupported: number; pending: number };168  instruments: { n: number; active: number };169  bars_1m: { symbols: number; sessions: number; bars: number | null; last: string | null };170  jobs: Record<string, number>;171  recent_jobs: Job[];172  fundamentals: { entities: number; facts: number };173  anomalies_7d: Record<string, number>;174  health: HealthReport;175}176177export type DatasetStatus = "active" | "planned" | "pending_configuration" | "unsupported" | "paused";178179export interface Dataset {180  id: string;181  name: string;182  description: string | null;183  kind: string;184  asset_class: string | null;185  interval: string | null;186  adjustment: string | null;187  license_class: string;188  license_text: string;189  status: DatasetStatus;190  timestamp_convention: string | null;191  timezone: string | null;192  current_version: number | null;193  snapshot_id?: string | null;194  file_count?: number | null;195  row_count?: number | null;196  bytes?: number | null;197  version_created_at?: string | null;198  first_session?: string | null;199  last_session?: string | null;200  symbols?: number | null;201  source_name?: string;202  source_id?: string;203  vendor?: string;204  product?: string;205  notes?: string | null;206  [k: string]: unknown;207}208209export interface DatasetVersion {210  version: number;211  snapshot_id: string | null;212  file_count: number | null;213  row_count: number | null;214  bytes: number | null;215  created_at: string;216  note: string | null;217}218219export interface DatasetDetail {220  data: Dataset;221  versions: DatasetVersion[];222  files: { layout: string; n: number; rows: number | null; bytes: number | null; d0: string | null; d1: string | null }[];223  checkpoints: { key: string; value: unknown; updated_at: string }[];224}225226export interface Instrument {227  instrument_id: string;228  symbol: string;229  name: string | null;230  asset_class: string;231  status: string;232  first_date: string | null;233  last_date: string | null;234  currency: string | null;235  [k: string]: unknown;236}237238export interface InstrumentDetail {239  instrument: Instrument | null;240  aliases: Record<string, unknown>[];241  corporate_actions: { action_type: string; ex_date: string; value: unknown }[];242  coverage: { dataset_id: string; sessions: number; d0: string; d1: string; bars: number }[];243  fundamentals: { facts: number; last_period: string | null; last_filing: string | null } | null;244}245246export interface Bar {247  timestamp_utc: string;248  session_date: string;249  open: number;250  high: number;251  low: number;252  close: number;253  volume: number;254  vwap?: number | null;255  trade_count?: number | null;256  quality_flags: string[];257  adjustment_type?: string;258  revision_id?: number;259  [k: string]: unknown;260}261262export interface BarsOut {263  data: Bar[];264  meta: {265    symbol: string;266    interval: string;267    adjustment: string;268    start: string;269    end: string;270    rows: number;271    rows_matching: number;272    adjustment_notes: string[];273    timestamp_convention: string;274    license: string;275    version?: number;276    files_scanned?: number;277    [k: string]: unknown;278  };279  next_cursor: string | null;280}281282export interface SessionRow {283  session_date: string;284  bar_count: number;285  expected_bars: number | null;286  first_ts: string | null;287  last_ts: string | null;288  flags: string[] | null;289  symbol?: string;290}291292export interface Heatmap {293  sessions: { session_date: string; symbols: number; bars: number; completeness: number | null; non_trading_rows: number }[];294  top_symbols: { symbol: string; sessions: number; bars: number; completeness: number | null }[];295  meta: { dataset_id: string; start: string; end: string };296}297298export interface QualityOut {299  latest: Record<string, unknown> | null;300  anomalies_by_code_30d: { code: string; severity: string; n: number; rows: number | null }[];301  reports: { id: string; created_at: string; rows_checked: number; anomalies: number; summary: unknown }[];302  codes: Record<string, string>;303}304305export interface Anomaly {306  id?: string;307  code: string;308  severity: string;309  symbol: string | null;310  session_date?: string | null;311  detail: Record<string, unknown> | null;312  created_at: string;313  [k: string]: unknown;314}315316export interface QueryOut {317  columns: { name: string; type: string }[];318  rows: Record<string, unknown>[];319  meta: { truncated?: boolean; rows: number; elapsed_ms: number; views: string[]; engine: string; [k: string]: unknown };320}321322export interface ExportRow {323  id: string;324  status: "queued" | "running" | "ready" | "failed" | "expired";325  request: { dataset_id: string; symbols: string[]; start: string; end: string; adjustment: string; format: string; version?: number };326  format: string;327  bytes: number | null;328  row_count: number | null;329  created_at: string;330  finished_at: string | null;331  expires_at: string | null;332  error: string | null;333  snapshot: unknown;334  download?: string;335}336337export interface Fact {338  entity_id: string;339  metric: string;340  value: number | null;341  unit: string;342  currency: string | null;343  fiscal_period_start: string | null;344  fiscal_period_end: string;345  fiscal_period_type: string;346  fiscal_year: number | null;347  fiscal_period: string | null;348  filing_date: string | null;349  published_at: string | null;350  available_at: string;351  observed_at: string;352  source_document: string | null;353  source_id: string;354  revision_id: number;355  restatement_flag: boolean;356  superseded_at: string | null;357}358359export interface FundamentalsOut {360  data: Fact[];361  entity: Record<string, unknown> | null;362  meta: { as_of: string; rows: number; facts_hidden_by_as_of: number; pit_policy: string; license: string };363}364365export interface PeriodsOut {366  periods: {367    fiscal_period_end: string;368    fiscal_period_type: string;369    fiscal_year: number | null;370    fiscal_period: string | null;371    filing_date: string | null;372    available_at: string;373    metrics: number;374    max_revision: number;375    restated: boolean;376  }[];377  earnings: { event_date: string; eps_estimate: number | null; eps_reported: number | null; release_time: string | null }[];378}379380export interface Job {381  id: string;382  kind: string;383  dataset_id: string | null;384  params?: Record<string, unknown>;385  priority?: number;386  status: string;387  attempts?: number;388  max_attempts?: number;389  next_run_at?: string | null;390  created_at?: string;391  started_at: string | null;392  finished_at: string | null;393  last_error: string | null;394  progress?: Record<string, unknown> | null;395  result?: Record<string, unknown> | null;396  created_by?: string | null;397}398399export interface ApiKey {400  id: string;401  name: string;402  prefix: string;403  scopes: string[];404  rate_limit_per_minute: number;405  expires_at: string | null;406  last_used_at: string | null;407  revoked_at: string | null;408  created_at: string;409  email: string;410}411412export interface UsageRow {413  principal: string;414  day: string;415  requests: number;416  rows_returned: number;417  bytes_exported: number;418}419420export interface AuditRow {421  id?: string;422  at: string;423  action: string;424  actor?: string | null;425  principal?: string | null;426  target?: string | null;427  outcome?: string | null;428  ip?: string | null;429  request_id?: string | null;430  detail?: Record<string, unknown> | null;431  [k: string]: unknown;432}433434export interface StorageOut {435  lake_root: string;436  volume: { total_gb: number; free_gb: number };437  budget: { soft_limit_gb: number; hard_min_free_gb: number };438  curated_bytes: number;439  raw_bytes: number;440  retired_bytes: number;441  exports_bytes: number;442  per_dataset: { dataset_id: string; layout: string; files: number; bytes: number | null; rows: number | null }[];443  raw_archives: { status: string; n: number; bytes: number | null }[];444  exports: { status: string; n: number; bytes: number }[];445  retired_files: { n: number; bytes: number };446  postgres_bytes: number;447  last_backup: { value: Record<string, unknown>; updated_at: string } | null;448  capacity_model: { bytes_per_1m_bar_curated: number; note: string };449}450451export interface SettingsOut {452  settings: { key: string; value: unknown; updated_at: string }[];453  env: {454    env: string;455    lake: string;456    public_url: string;457    backup_target: string;458    vendor_configured: boolean;459    sec_edgar_configured: boolean;460    ai_providers: { anthropic: boolean; openai: boolean };461  };462}463
Files · 6437fd798ded13a52b8fed8b7d8e78b700fbceec
Menu

Theme