spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
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