format
Format trading values and parse or step quotes in each instrument's convention.
import { createInstrumentFormatter, formatBps, formatCoupon, formatDv01, formatMaturity, formatNotional, formatPercent, formatPrice, formatQuantity, formatSigned, formatTicks, formatYield, ticksBetween } from "@/lib/format"
const ust = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const bund = createInstrumentFormatter({ price: { kind: "tick", tick: 0.005 }, tick: 0.005 })
const bill = createInstrumentFormatter({ price: { kind: "decimal", decimals: 3 }, tick: 0.0005, quoteBasis: "discount" })
const groups: { title: string; rows: [string, string][] }[] = [
{
title: "Quotes",
rows: [
["UST price 99.515625", ust.price(99.515625)],
["One tick above 99-16", ust.price(ust.step(99.5, 1))],
['Parse "99-17"', String(ust.parsePrice("99-17"))],
["Bund price 130.0049", bund.price(130.0049)],
["Bill discount 4.2531", bill.quote(4.2531)],
["Missing price", formatPrice(null, { kind: "decimal", decimals: 2 })],
],
},
{
title: "Instrument details",
rows: [
["Coupon 4.125", formatCoupon(4.125)],
["Maturity 2034-05-15", formatMaturity("2034-05-15")],
["99-17 vs 99-16+", formatTicks(ticksBetween(99.53125, 99.515625, 1 / 64), { unit: "tick" })],
["5,000,000 in millions", formatNotional(5e6, { unit: "mm" })],
],
},
{
title: "Scalar values",
rows: [
["Yield 4.2531", formatYield(4.2531)],
["−12.5 bps, signed", formatBps(-12.5, { signed: true })],
["DV01 1234.4", formatDv01(1234.4)],
["1,250,000, compact", formatNotional(1.25e6, { compact: true })],
["1.234%, signed", formatPercent(1.234, { signed: true })],
["Quantity 5,000,000", formatQuantity(5e6)],
["Signed zero", formatSigned(0)],
],
},
]
export default function FormatDemo() {
return (
<div className="w-fit max-w-full space-y-4 text-xs lining-nums tabular-nums">
{groups.map(({ title, rows }) => (
<table key={title} className="w-full">
<caption className="pb-1 text-left font-medium">{title}</caption>
<tbody>
{rows.map(([label, value]) => (
<tr key={label} className="border-b border-border">
<th scope="row" className="py-1 pr-4 text-left font-normal text-muted-foreground">{label}</th>
<td className="py-1 text-right whitespace-nowrap font-(family-name:--tradecn-font-mono)">{value}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/format#v2.0.0
npx shadcn@latest add tradecn/ui/format#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/format#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/format#v2.0.0
-
Copy the files into your project:
lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = stripGrouping(s, { decimals: printsDecimals(c) }) if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, so digits and the dash take one width; tails still lengthen a quote. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * Every other price, and every quote in another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } .dark { --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
Bind the instrument's convention once, then format, parse, and step prices through the same formatter.
import { createInstrumentFormatter } from "@/lib/format"
const ust = createInstrumentFormatter({
price: { kind: "fraction", denominator: 32, half: "+" },
tick: 1 / 64,
})
ust.price(99.515625) // "99-16+"
ust.parsePrice("99-17") // 99.53125
ust.price(ust.step(99.5, 1)) // "99-16+"
The preview groups quote conventions, instrument details, and scalar values. It uses the default en-US locale; yield, discount, and percent inputs are percentage points, and mm means millions.
API Reference
Pure functions with no React dependency. Numeric formatters accept Nullable (number | null | undefined) and return NULL_TOKEN (–, an en dash) for null, undefined, NaN, and infinities. Negative numeric output uses the typographic minus (−, U+2212).
Locale is { locale?: string }, with "en-US" as the default. Decimal and date output can use another locale, such as { locale: "de-DE" }; fraction notation stays fixed. Parsing is not locale-aware, a bound locale included: parsePrice and parseQuote read a decimal point, trim whitespace, accept either minus sign, and read commas only as thousands separators in the whole part (1,234.5). A comma anywhere else, as in 99,5 typed on a decimal-comma keyboard, 1,0,0, or among the 32nds, reads as no price rather than a bigger one. Where the convention prints decimals, a number with one comma and nothing after its whole part reads either way, 4,253 as 4253 or as 4.253 with a decimal comma, so it reads as no price too; write 4,253.000 or 4253 for the thousands. A convention that prints no decimals reads 5,012 as its own text prints it. stripGrouping applies the same rule to other numeric text.
Prices
PriceConvention selects the notation and precision. Fields below are required unless marked optional.
kind |
Fields | Formatting |
|---|---|---|
"decimal" |
decimals: number |
Fixed decimal places; 1234.5 with decimals: 2 is 1,234.50 |
"tick" |
tick: number |
Snap to the tick grid and derive decimal places from the tick; 100.0049 with tick: 0.005 is 100.005 |
"fraction" |
denominator: 32 | 64, half: "+" | "5", optional eighths: boolean (default false) |
Round to halves of a 32nd or 64th, or eighths when enabled |
In 32nds, 99.515625 prints as 99-16+, or 99-165 with half: "5". With eighths: true, the trailing digit counts eighths: 99.5078125 is 99-162 and 99.515625 is 99-164. A denominator of 64 uses the same notation in 64ths. From 1e21, where a whole part would print in exponent form, fraction notation prints NULL_TOKEN, and parsePrice returns null for a value in that range.
| Function | Result |
|---|---|
formatPrice(value, convention, locale?) |
Format a Nullable price; the third argument is a Locale object |
formatFraction(value, convention) |
Format a Nullable price with the fraction variant of PriceConvention |
parsePrice(text, convention) |
Read a string in the convention's notation or as a plain decimal; return a number, or null for unreadable text or a value too large to print back in the convention's notation |
stripGrouping(text, { decimals }) |
Trim numeric text, read either minus sign as -, and remove its thousands separators; return null when a comma isn't one, and with decimals also for one comma with nothing after the whole part, such as 4,253; exponent text right after its last group, such as 1,000e3, is refused too; the rest of the text is left to the caller's own pattern |
decimalsFromTick(tick, max = 8) |
Decimal places needed for a numeric tick, capped at max; 0.005 gives 3, 1 / 32 gives 5; nonpositive or nonfinite ticks give 0 |
roundToTick(value, tick) |
Snap a number to the nearest tick and clean float noise; return value unchanged if it or the tick is nonfinite, or the tick is nonpositive |
stepByTick(value, tick, steps) |
Snap to the grid, move by steps ticks, then snap again; all inputs are numbers and negative steps are allowed |
parsePrice rounds decimal input to decimals for a decimal convention and snaps it to tick for a tick convention. For a fraction convention, plain decimal input snaps to the printable grid — half or eighth ticks — so the parsed value is the price its formatted text shows, with a negative tie snapping away from zero as it prints. With the default locale, formatted prices read back on the convention's representable grid.
Instrument formatter
createInstrumentFormatter(convention, locale?) binds an InstrumentConvention and optional Locale once. A grid column can then call formatters[row.instrumentId].price(value); a new instrument convention is another data object.
InstrumentConvention field |
Type | Default | Purpose |
|---|---|---|---|
price |
PriceConvention |
Required | Price notation |
tick |
number |
Required | Price increment for stepping |
yieldDecimals |
number |
3 |
Decimal places for the bound yield formatter |
quantityUnit |
"notional" | "contracts" |
Unset | Metadata; the bound quantity formatter always prints whole quantities |
quoteBasis |
QuoteBasis |
"price" |
Basis used by quote methods |
quoteStep |
number |
Basis default below | Step for yield, discount, or spread quotes; ignored for price quotes |
quoteDecimals |
number |
Basis default below | Decimal places for yield, discount, or spread quotes; ignored for price quotes |
quoteInverted |
boolean |
Yield and discount invert; price and spread do not | Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. A CDS spread quotes bid below offer; cash credit quotes the other way, so declare it where the default reads wrong. quoteInvertedOf(convention) reads the effective direction |
| Bound member | Equivalent |
|---|---|
tick, basis, quoteStep |
convention.tick, quoteBasisOf(convention), quoteStepOf(convention) |
price(value), parsePrice(text) |
formatPrice and parsePrice with convention.price; the bound locale applies to output only |
yield(value) |
formatYield with yieldDecimals |
quantity(value) |
formatQuantity |
step(value, steps) |
stepByTick with convention.tick |
quote(value), parseQuote(text), stepQuote(value, steps) |
Quote functions below with the bound convention; the bound locale applies to output only: quote in de-DE prints text parseQuote reads as no quote, such as 99,50, or as another one, such as an integer tick's 5.012, which reads as 5.012 and snaps to 5 |
The quote basis
QuoteBasis says what the trader types and reads. Bills quote on discount, some bonds on yield, and credit often on spread. ticket's quote field uses these quote functions.
| Basis | Default step | Default decimals | QUOTE_BASIS_LABELS |
|---|---|---|---|
"price" |
convention.tick |
From convention.price |
"Price" |
"yield" |
0.001 |
3 |
"Yield" |
"discount" |
0.001 |
3 |
"Discount" |
"spread" |
0.1 |
1 |
"Spread" |
| Function | Result |
|---|---|
quoteBasisOf(convention) |
Effective basis, defaulting to "price" |
quoteStepOf(convention) |
Effective step from the table or an applicable quoteStep override |
formatQuote(value, convention, locale?) |
Format a Nullable quote; optional third argument is Locale |
parseQuote(text, convention) |
Parse a string as a price or a decimal in the selected basis; return a number, or null for unreadable text or a value too large to print back in its basis |
stepQuote(value, convention, steps) |
Move a numeric quote by steps from the nearest grid value; negative steps are allowed |
Price quotes format and parse through convention.price. Other bases snap to quoteStep and print a fixed decimal with no unit; the field's label supplies the basis. Yield and discount values use percentage points; spread values use basis points. Set quoteDecimals high enough to display the chosen step without losing precision.
Coupons, maturities, and millions
| Function | Inputs and options | Example output |
|---|---|---|
formatCoupon(value, options?) |
Nullable; style: "fraction" | "decimal" (default "fraction"), decimals: number (default 3), locale |
4.125 → 4 1/8; decimal style → 4.125% |
formatMaturity(date, options?) |
DateLike | null | undefined; year: "2-digit" | "numeric" (default "2-digit"), locale |
"2034-05-15" → 05/15/34; numeric year → 05/15/2034 |
isInstant(value) |
Any value; true for a time a Date can hold, a finite number of milliseconds within 8.64e15 of the epoch either way. Guard a server's timestamp with it before formatting |
Date.now() → true; NaN, Infinity, "1700000000000" → false |
daysToMaturity(date, now?) |
DateLike | null | undefined; now: DateLike defaults to the current time |
Whole UTC calendar days to maturity; 0 on the same day, negative on later days, null if either date is invalid |
formatNotional(value, { unit: "mm" }) |
Nullable; optional decimals: number (default 2) and locale |
5e6 → 5mm; 1.25e9 → 1,250mm |
Fraction coupons reduce eighths: 4.5 is 4 1/2 and 4 is 4. Values off the eighths grid use up to decimals places, so 4.1 stays 4.1; decimal style always uses fixed places and a % suffix. The mm notional unit means millions, trims trailing zeros, and takes precedence over compact without scaling to billions.
DateLike is Date | number | string: a date, milliseconds since the Unix epoch, or a string Date can read. Maturities format in UTC, so a date-only string stays on the same day in every timezone. formatMaturity returns NULL_TOKEN for missing or invalid dates.
Ticks between two prices
| Function | Inputs and options | Result |
|---|---|---|
ticksBetween(a, b, tick) |
Three numbers | (a - b) / tick, rounded to the nearest eighth; NaN if either price is nonfinite or tick > 0 is false |
formatTicks(value, options?) |
Nullable; signed: boolean (default true), unit: string (default none), grouping: boolean (default true; false prints no thousands separators, for an editor's text), locale |
Up to three decimals, no trailing zeros: +1, −0.5, 0; a unit adds a space and suffix, such as +2 ticks |
A quote of 99-17 against a composite of 99-16+ on a 1 / 64 tick is one tick: ticksBetween(99.53125, 99.515625, 1 / 64) returns 1. Use formatBps for spread differences in basis points.
Numeric classes
Formatters add no alignment spaces. tradecn components set lining, tabular figures on numeric nodes; use these classes on your own nodes to meet rule 14 of the contract.
| Export | Value or behavior |
|---|---|
NUMERIC_CLASS |
font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums |
MONO_NUMERIC_CLASS |
font-(family-name:--tradecn-font-mono) lining-nums tabular-nums |
numericFontClass(convention?) |
Accepts PriceConvention | InstrumentConvention | null; selects mono for a fraction price and the numeric family otherwise, including non-price quote bases and no convention |
The numeric family defaults to sans: letters stay proportional and digits have equal widths. Fraction prices use mono for equal digit and dash widths; quotes with and without a tail still differ in length, so a right-aligned column lines tails up only against padding you add. quote-field, ticket, and rfq-ticket use numericFontClass; a data-grid column uses numeric: true and font: "mono" for the same effect. typography.md explains the tokens and choices.
The install adds the --tradecn-font-sans, --tradecn-font-mono, and --tradecn-font-numeric tokens, their accessible pair, and the hyperlegible remap alongside these helpers.
The rest
These functions take a Nullable value and an optional options object. Every options object accepts locale; examples use "en-US".
| Function | Options and defaults | Example output |
|---|---|---|
formatYield |
decimals: number = 3, suffix: "%" | "" = "%" |
4.2531 → 4.253% |
formatBps |
decimals: number = 1, signed: boolean = false, unit: "bp" | "bps" | "" = "bp" |
12.5 → 12.5 bp |
formatDv01 |
currency: string = "USD", compact: boolean = false |
1234 → $1,234; compact → $1.23K |
formatNotional |
decimals: number = 2, compact: boolean = false, optional unit: "mm" |
1250000 → 1,250,000.00; compact → 1.25M |
formatSigned |
decimals: number = 2 |
0.12 → +0.12; 0 → 0.00 |
formatPercent |
decimals: number = 2, signed: boolean = false |
1.234 → 1.23%; signed → +1.23% |
formatQuantity |
No options beyond locale |
1000000.6 → 1,000,001 |
Yield and percent inputs are already percentage points: 1 means 1%. DV01 rounds to whole currency units in its full form; the compact form keeps the compact decimals, as in $1.23K. Compact notional output uses K, M, B, or T; values below 1,000 round to a whole number. Signed formatters omit the sign when the displayed value rounds to zero.
numberFormat(locale, options) returns a cached Intl.NumberFormat for a locale string (or undefined for "en-US") and Intl.NumberFormatOptions. Numeric formatters share this cache by locale and option set.
This page is docs/format.md at v2.0.0.