SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
7.2 KB · 197 lines typescript
Raw Blame History
1// Formatting helpers shared by server and client components. Deterministic (en-US) to avoid hydration drift.23const nf = (opts: Intl.NumberFormatOptions) => new Intl.NumberFormat("en-US", opts);4const cache = new Map<string, Intl.NumberFormat>();5function fmt(min: number, max: number) {6  const k = `${min}:${max}`;7  let f = cache.get(k);8  if (!f) {9    f = nf({ minimumFractionDigits: min, maximumFractionDigits: max });10    cache.set(k, f);11  }12  return f;13}1415/** Price precision by magnitude and asset class: crypto sub-cent, FX 4–5 decimals, yields 2–3. */16export function priceDecimals(v: number, assetClass?: string | null): number {17  const a = Math.abs(v);18  if (assetClass === "FOREX") return a >= 20 ? 3 : a >= 1 ? 4 : 5;19  if (assetClass === "TREASURY" || assetClass === "INTEREST_RATE" || assetClass === "BOND") return 2;20  if (a === 0) return 2;21  if (a >= 10000) return 0;22  if (a >= 1000) return 1;23  if (a >= 1) return 2;24  if (a >= 0.1) return 4;25  if (a >= 0.01) return 5;26  return 6;27}2829export function formatPrice(v: number | null | undefined, assetClass?: string | null): string {30  if (v == null || !Number.isFinite(v)) return "—";31  const d = priceDecimals(v, assetClass);32  return fmt(d, d).format(v);33}3435export function isRateClass(assetClass?: string | null): boolean {36  return assetClass === "TREASURY" || assetClass === "INTEREST_RATE" || assetClass === "BOND";37}3839/** Price with unit: yields get a % suffix, everything else the currency code when provided. */40export function formatQuoteValue(v: number | null | undefined, assetClass?: string | null, currency?: string | null): string {41  if (v == null) return "—";42  if (isRateClass(assetClass)) return `${formatPrice(v, assetClass)}%`;43  const p = formatPrice(v, assetClass);44  return currency && currency !== "USD" && currency !== "USDT" ? `${p} ${currency}` : p;45}4647export function formatPercent(v: number | null | undefined, digits = 2, sign = true): string {48  if (v == null || !Number.isFinite(v)) return "—";49  const s = fmt(digits, digits).format(Math.abs(v));50  return `${sign ? (v > 0 ? "+" : v < 0 ? "−" : "") : v < 0 ? "−" : ""}${s}%`;51}5253export function formatChange(v: number | null | undefined, assetClass?: string | null): string {54  if (v == null || !Number.isFinite(v)) return "—";55  const d = priceDecimals(v, assetClass);56  const s = fmt(Math.min(d, 4), Math.min(d, 4)).format(Math.abs(v));57  return `${v > 0 ? "+" : v < 0 ? "−" : ""}${s}`;58}5960export function formatCompact(v: number | null | undefined, digits = 1): string {61  if (v == null || !Number.isFinite(v)) return "—";62  const a = Math.abs(v);63  if (a >= 1e12) return `${fmt(0, digits).format(v / 1e12)}T`;64  if (a >= 1e9) return `${fmt(0, digits).format(v / 1e9)}B`;65  if (a >= 1e6) return `${fmt(0, digits).format(v / 1e6)}M`;66  if (a >= 1e4) return `${fmt(0, digits).format(v / 1e3)}K`;67  return fmt(0, a < 10 ? 2 : 0).format(v);68}6970export function formatInt(v: number | null | undefined): string {71  if (v == null || !Number.isFinite(v)) return "—";72  return fmt(0, 0).format(v);73}7475export function formatBps(v: number | null | undefined): string {76  if (v == null) return "—";77  return `${fmt(0, v < 10 ? 2 : 0).format(v)} bps`;78}7980/** Parse API timestamps: ISO, epoch ms, or Postgres text "2026-09-11 19:50:00-04". */81export function toMs(v: string | number | null | undefined): number | null {82  if (v == null) return null;83  if (typeof v === "number") return v;84  let s = v.trim();85  if (/^\d+$/.test(s)) return Number(s);86  s = s.replace(" ", "T");87  if (/[+-]\d{2}$/.test(s)) s += ":00";88  const ms = Date.parse(s);89  return Number.isFinite(ms) ? ms : null;90}9192export function formatDuration(ms: number | null | undefined): string {93  if (ms == null || !Number.isFinite(ms)) return "—";94  if (ms < 1000) return `${Math.round(ms)} ms`;95  if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;96  if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;97  if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ${Math.floor((ms % 3_600_000) / 60_000)}m`;98  return `${Math.floor(ms / 86_400_000)}d ${Math.floor((ms % 86_400_000) / 3_600_000)}h`;99}100101export function relativeTime(ms: number | null | undefined, now = Date.now()): string {102  if (ms == null) return "—";103  const d = now - ms;104  if (d < 0) return "just now";105  if (d < 1000) return `${d} ms ago`;106  if (d < 60_000) return `${Math.floor(d / 1000)}s ago`;107  if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;108  if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;109  return `${Math.floor(d / 86_400_000)}d ago`;110}111112const dtfCache = new Map<string, Intl.DateTimeFormat>();113export function formatDateTime(ms: number | string | null | undefined, opts: { tz?: string; seconds?: boolean; dateOnly?: boolean } = {}): string {114  const t = typeof ms === "string" ? toMs(ms) : ms;115  if (t == null) return "—";116  const key = `${opts.tz ?? "UTC"}:${opts.seconds ? 1 : 0}:${opts.dateOnly ? 1 : 0}`;117  let f = dtfCache.get(key);118  if (!f) {119    f = new Intl.DateTimeFormat("en-US", {120      timeZone: opts.tz ?? "UTC",121      year: "numeric",122      month: "short",123      day: "2-digit",124      ...(opts.dateOnly ? {} : { hour: "2-digit", minute: "2-digit", ...(opts.seconds ? { second: "2-digit" } : {}), hourCycle: "h23", timeZoneName: "short" }),125    });126    dtfCache.set(key, f);127  }128  return f.format(new Date(t));129}130131const timeFmt = new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" });132/** "09:11:41" (UTC) for tape rows. */133export function formatClock(ms: number | null | undefined): string {134  return ms == null ? "—" : timeFmt.format(new Date(ms));135}136137export const ASSET_CLASS_LABEL: Record<string, string> = {138  EQUITY: "Stock",139  ETF: "ETF",140  ETN: "ETN",141  INDEX: "Index",142  CRYPTO: "Crypto",143  FOREX: "FX",144  COMMODITY: "Commodity",145  FUTURE: "Future",146  OPTION: "Option",147  BOND: "Bond yield",148  TREASURY: "Treasury",149  INTEREST_RATE: "Rate",150  MUTUAL_FUND: "Fund",151  ADR: "ADR",152  REIT: "REIT",153  WARRANT: "Warrant",154  OTHER: "Other",155};156157export const EVENT_TYPE_LABEL: Record<string, string> = {158  PRICE_CHANGE: "Price move",159  SESSION_HIGH: "Session high",160  SESSION_LOW: "Session low",161  VOLATILITY_SPIKE: "Volatility spike",162  VOLUME_SPIKE: "Volume spike",163  SOURCE_DIVERGENCE: "Source divergence",164  SOURCE_FAILURE: "Source failure",165  SOURCE_RECOVERY: "Source recovery",166  SCHEMA_DRIFT: "Schema drift",167  MARKET_OPEN: "Market open",168  MARKET_CLOSE: "Market close",169  TRADING_HALT: "Trading halt",170  TRADING_RESUME: "Trading resumed",171  FILING_PUBLISHED: "Filing",172  DOCUMENT_CHANGED: "Document changed",173  INSTRUMENT_LISTED: "Listed",174  INSTRUMENT_DELISTED: "Delisted",175  REFERENCE_RATE_PUBLISHED: "Reference rate",176  RATE_DECISION: "Rate decision",177};178179export const STATUS_LABEL: Record<string, string> = {180  REALTIME: "Live",181  DELAYED: "Delayed",182  END_OF_DAY: "End of day",183  AT_CLOSE: "At close",184  STALE: "Stale",185  WITHHELD: "Withheld",186  INDICATIVE: "Indicative",187  UNKNOWN: "Unknown",188};189190export function instrumentHref(idOrSymbol: string): string {191  return `/instruments/${encodeURIComponent(idOrSymbol)}`;192}193194export function cx(...xs: Array<string | false | null | undefined>): string {195  return xs.filter(Boolean).join(" ");196}197