SpreadMatrix
Composable tables of signed spreads in ticks or basis points.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import {
SpreadMatrix,
SpreadMatrixTable,
SpreadMatrixHead,
SpreadMatrixRow,
SpreadMatrixCell,
type SpreadInstrument,
} from "@/components/ui/spread-matrix"
const T32: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const instruments: SpreadInstrument[] = [
{ id: "2Y", label: "2Y", convention: { ...T32, tick: 1 / 128 } },
{ id: "5Y", label: "5Y", convention: T32 },
{ id: "10Y", label: "10Y", convention: T32 },
]
export default function SpreadMatrixDemo() {
const [store] = useState(() => {
const store = createRowStore<{ id: string; price: number }>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [
{ id: "2Y", price: 100.25 },
{ id: "5Y", price: 99.75 },
{ id: "10Y", price: 99.515625 },
] })
return store
})
return (
<SpreadMatrix store={store} instruments={instruments} className="w-fit max-w-full">
<SpreadMatrixTable label="Price spreads">
<caption className="sr-only">Each cell is the row less the column. ticks.</caption>
<thead>
<tr>
<SpreadMatrixHead className="text-left">Instrument (ticks)</SpreadMatrixHead>
{instruments.map((column) => <SpreadMatrixHead key={column.id} data-column={column.id}>{column.label}</SpreadMatrixHead>)}
</tr>
</thead>
<tbody>
{instruments.map((instrument) => (
<SpreadMatrixRow key={instrument.id} instrument={instrument}>
<SpreadMatrixHead scope="row">{instrument.label}</SpreadMatrixHead>
{instruments.map((column) => <SpreadMatrixCell key={column.id} column={column.id} />)}
</SpreadMatrixRow>
))}
</tbody>
</SpreadMatrixTable>
</SpreadMatrix>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/spread-matrix#v2.0.0
npx shadcn@latest add tradecn/ui/spread-matrix#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/spread-matrix#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/spread-matrix#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Copy the files into your project:
components/ui/ spread-matrix.tsx import { cn } from "cn" import { createContext, memo, useContext, useImperativeHandle, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode, type RefObject } from "react" import { useFlash } from "@/hooks/use-flash" import { useRow } from "@/hooks/use-row-store" import { NULL_TOKEN, NUMERIC_CLASS, formatBps, formatTicks, ticksBetween, type InstrumentConvention, type Nullable } from "@/lib/format" import type { RowId, RowStore } from "@/lib/row-store" // Callers own the table and its collection markup. Rows read their own quote and cells read the // column's quote, so a feed update wakes only the pairs it changes. Structures share their leg // subscriptions across readings; each value cell owns its flash. /** What a spread is measured in: ticks of price, or basis points of yield. */ export type SpreadBasis = "ticks" | "bps" /** One axis entry of the matrix. */ export interface SpreadInstrument { /** The quote's row id in the store. */ id: RowId /** The header text. */ label: string /** The tick a spread in ticks is counted in when this instrument is the row. */ convention: InstrumentConvention } /** * A quote as the default reader expects it: `price` for a spread in ticks, `yield` in percent for one in basis * points. Pass `value` to read other fields. */ export interface SpreadQuote { price?: number | null yield?: number | null } /** A curve or a butterfly: two or three legs, each an instrument's id, with a weight per leg. */ export interface SpreadStructure { id: string label: string legs: readonly [RowId, RowId] | readonly [RowId, RowId, RowId] /** One weight per leg. Default `[-1, 1]` for two legs (the second less the first) and `[-1, 2, -1]` for three (the belly against the wings). */ weights?: readonly number[] /** The tick a spread in ticks is counted in. Default: the first leg's instrument. */ tick?: number } export interface SpreadMatrixProps<T extends object = SpreadQuote> extends Omit<ComponentProps<"div">, "children"> { store: RowStore<T> /** Instrument metadata for structure leg labels and fallback ticks. Last duplicate id wins. */ instruments: readonly SpreadInstrument[] children: ReactNode /** Default ticks. Yield values are in percent when basis is bps. */ basis?: SpreadBasis /** Read price for ticks or yield for bps by default. Keep stable between renders. */ value?: (quote: T, basis: SpreadBasis) => Nullable /** Format non-null, non-diagonal spreads. Default formatSpread. Keep stable between renders. */ format?: (value: number, basis: SpreadBasis) => ReactNode /** Default 900ms. */ flashWindowMs?: number } const FILL_CLASSES = "data-[direction=up]:bg-up-soft data-[direction=down]:bg-down-soft data-[direction=flat]:bg-flat-soft" const CELL = "h-7 px-2 text-right whitespace-nowrap" const HEAD = "h-7 px-2 font-normal text-muted-foreground whitespace-nowrap" function isNumber(v: Nullable): v is number { return typeof v === "number" && Number.isFinite(v) } /** The default weights of a structure: the second leg less the first, or the belly against the wings. Empty for any other count. */ export function defaultWeights(legs: number): readonly number[] { return legs === 2 ? [-1, 1] : legs === 3 ? [-1, 2, -1] : [] } /** A difference of values in the basis: ticks of `tick`, to the nearest eighth, or basis points from yields in percent, to a thousandth. */ function toBasis(diff: number, basis: SpreadBasis, tick: number): number | null { if (basis === "ticks") { const ticks = ticksBetween(diff, 0, tick) return Number.isNaN(ticks) ? null : ticks } return Math.round(diff * 100 * 1000) / 1000 } /** The spread of `a` over `b`: `a − b` in ticks of `tick` or in basis points. Null when a side is missing. */ export function spreadBetween(a: Nullable, b: Nullable, basis: SpreadBasis, tick: number): number | null { if (!isNumber(a) || !isNumber(b)) return null return toBasis(a - b, basis, tick) } /** A structure's spread: each leg's value times its weight, summed, in the basis. Null when a leg is missing or the weights do not match the legs. */ export function structureSpread(values: readonly Nullable[], weights: readonly number[] | undefined, basis: SpreadBasis, tick: number): number | null { const w = weights ?? defaultWeights(values.length) if (w.length !== values.length || values.length === 0) return null let sum = 0 for (let i = 0; i < values.length; i++) { const v = values[i] if (!isNumber(v)) return null sum += w[i]! * v } return toBasis(sum, basis, tick) } /** A spread with its sign: `+1.5`, `−0.5`, `0` in ticks; `+12.3`, `−0.8` in basis points. The unit is in the header, not the cell. */ export function formatSpread(value: Nullable, basis: SpreadBasis): string { return basis === "ticks" ? formatTicks(value) : formatBps(value, { signed: true, unit: "" }) } /** The default reader: `price` for a spread in ticks, `yield` for one in basis points. */ function defaultValue(quote: object, basis: SpreadBasis): Nullable { if (basis === "bps") return "yield" in quote && typeof quote.yield === "number" ? quote.yield : null return "price" in quote && typeof quote.price === "number" ? quote.price : null } interface Configuration { store: RowStore<object> instruments: ReadonlyMap<RowId, SpreadInstrument> basis: SpreadBasis read: (quote: object, basis: SpreadBasis) => Nullable format: (value: number, basis: SpreadBasis) => ReactNode flashWindowMs: number } const MatrixContext = createContext<Configuration | null>(null) function useConfiguration() { const config = useContext(MatrixContext) if (!config) throw new Error("SpreadMatrix parts must be inside SpreadMatrix.") return config } export function SpreadMatrix<T extends object = SpreadQuote>({ store, instruments, basis = "ticks", value, format = formatSpread, flashWindowMs = 900, children, className, ...props }: SpreadMatrixProps<T>) { // The reader and store are erased together at the context boundary; only the matching reader sees a quote. const config = useMemo<Configuration>(() => ({ store: store as RowStore<object>, instruments: new Map(instruments.map((instrument) => [instrument.id, instrument])), basis, read: (value ?? defaultValue) as Configuration["read"], format, flashWindowMs }), [store, instruments, basis, value, format, flashWindowMs]) return <MatrixContext value={config}><div {...props} data-slot="tradecn-spread-matrix" data-basis={basis} className={cn("overflow-auto rounded-md border border-border bg-background text-xs text-foreground lining-nums tabular-nums", className)}>{children}</div></MatrixContext> } export interface SpreadMatrixTableProps extends ComponentProps<"table"> { "aria-label"?: never "aria-labelledby"?: never label: string children: ReactNode } /** A native table. Supply a caption describing the spread rule and unit. */ export function SpreadMatrixTable({ label, className, ...props }: SpreadMatrixTableProps) { return <table {...props} aria-label={label} aria-labelledby={undefined} className={cn("w-full border-collapse", className)} /> } /** A column header by default. Use scope="row" for row labels. */ export function SpreadMatrixHead({ scope = "col", className, ...props }: ComponentProps<"th">) { return <th scope={scope} className={cn(HEAD, scope === "row" && "text-left text-foreground", className)} {...props} /> } export interface SpreadMatrixRowState { instrument: SpreadInstrument value: number | null } const RowContext = createContext<SpreadMatrixRowState | null>(null) /** Read the current row without adding a subscription. */ export function useSpreadMatrixRow(): SpreadMatrixRowState { const row = useContext(RowContext) if (!row) throw new Error("useSpreadMatrixRow must be inside SpreadMatrixRow.") return row } export interface SpreadMatrixRowProps extends ComponentProps<"tr"> { instrument: SpreadInstrument children: ReactNode } export const SpreadMatrixRow = memo(function SpreadMatrixRow({ instrument, children, className, ...props }: SpreadMatrixRowProps) { const config = useConfiguration() const quote = useRow(config.store, instrument.id) const raw = quote === undefined ? null : config.read(quote, config.basis) const value = isNumber(raw) ? raw : null const row = useMemo(() => ({ instrument, value }), [instrument, value]) return <RowContext value={row}><tr {...props} data-row={instrument.id} className={cn("border-t border-border/60", className)}>{children}</tr></RowContext> }) export interface SpreadMatrixCellState { spread: number | null basis: SpreadBasis diagonal: boolean } const CellContext = createContext<SpreadMatrixCellState | null>(null) /** Read a matrix or structure cell without adding subscriptions or flashes. */ export function useSpreadMatrixCell(): SpreadMatrixCellState { const cell = useContext(CellContext) if (!cell) throw new Error("useSpreadMatrixCell must be inside SpreadMatrixCell or SpreadMatrixStructureCell.") return cell } /** The signed reading, blank on the diagonal and an en dash when missing. */ export function SpreadMatrixValue({ className, ...props }: Omit<ComponentProps<"span">, "children">) { const config = useConfiguration() const { spread, basis, diagonal } = useSpreadMatrixCell() return <span {...props} data-numeric="" className={cn(NUMERIC_CLASS, className)}>{diagonal ? null : spread === null ? NULL_TOKEN : config.format(spread, basis)}</span> } // This effect follows the td so its ref is attached before the first layout effect. Only the flash // owner remounts on a new instrument pair; the native cell, caller content and focus stay in place. function CellFlash({ target, spread, diagonal }: { target: RefObject<HTMLTableCellElement | null>; spread: number | null; diagonal: boolean }) { const { flashWindowMs } = useConfiguration() useFlash(target, spread, { windowMs: flashWindowMs, variant: "fill", disabled: diagonal }) useLayoutEffect(() => { const cell = target.current return () => { if (cell) delete cell.dataset.direction } }, [target]) return null } function ValueCell({ identity, spread, diagonal = false, ref, className, children, ...props }: ComponentProps<"td"> & { identity: string; spread: number | null; diagonal?: boolean }) { const config = useConfiguration() const local = useRef<HTMLTableCellElement>(null) useImperativeHandle(ref, () => local.current!, []) const state = useMemo(() => ({ spread, basis: config.basis, diagonal }), [spread, config.basis, diagonal]) return <CellContext value={state}> <td {...props} ref={local} data-numeric="" data-diagonal={diagonal ? "" : undefined} className={cn(CELL, NUMERIC_CLASS, FILL_CLASSES, diagonal && "bg-muted/40", className)}>{children === undefined ? <SpreadMatrixValue /> : children}</td> <CellFlash key={identity} target={local} spread={spread} diagonal={diagonal} /> </CellContext> } export interface SpreadMatrixCellProps extends ComponentProps<"td"> { column: RowId } export const SpreadMatrixCell = memo(function SpreadMatrixCell({ column, ...props }: SpreadMatrixCellProps) { const config = useConfiguration() const row = useSpreadMatrixRow() const quote = useRow(config.store, column) const diagonal = row.instrument.id === column const spread = diagonal ? null : spreadBetween(row.value, quote === undefined ? null : config.read(quote, config.basis), config.basis, row.instrument.convention.tick) return <ValueCell {...props} data-row={row.instrument.id} data-column={column} identity={JSON.stringify([row.instrument.id, column])} spread={spread} diagonal={diagonal} /> }) export interface SpreadMatrixStructureState { structure: SpreadStructure legLabels: readonly string[] weights: readonly number[] spread: number | null basis: SpreadBasis } const StructureContext = createContext<SpreadMatrixStructureState | null>(null) /** Read a structure and its legs without subscribing again. */ export function useSpreadMatrixStructure(): SpreadMatrixStructureState { const structure = useContext(StructureContext) if (!structure) throw new Error("useSpreadMatrixStructure must be inside SpreadMatrixStructureRow.") return structure } export interface SpreadMatrixStructureRowProps extends ComponentProps<"tr"> { structure: SpreadStructure children: ReactNode } export const SpreadMatrixStructureRow = memo(function SpreadMatrixStructureRow({ structure, children, className, ...props }: SpreadMatrixStructureRowProps) { const config = useConfiguration() const [a, b, c] = structure.legs // Keep the hook count fixed when a curve becomes a butterfly; a curve reads its second leg twice. const qa = useRow(config.store, a) const qb = useRow(config.store, b) const qc = useRow(config.store, c ?? b) const values = (c === undefined ? [qa, qb] : [qa, qb, qc]).map((quote) => quote === undefined ? null : config.read(quote, config.basis)) const tick = structure.tick ?? config.instruments.get(a)?.convention.tick ?? NaN const weights = structure.weights ?? defaultWeights(structure.legs.length) const spread = structureSpread(values, weights, config.basis, tick) const legLabels = structure.legs.map((id) => config.instruments.get(id)?.label ?? id) const state = { structure, legLabels, weights, spread, basis: config.basis } return <StructureContext value={state}><tr {...props} data-structure={structure.id} data-weights={weights.join(" ")} className={cn("border-t border-border/60", className)}>{children}</tr></StructureContext> }) /** Optional legs column. For another placement, read legLabels with useSpreadMatrixStructure. */ export function SpreadMatrixLegs({ children, className, ...props }: ComponentProps<"td">) { const { legLabels } = useSpreadMatrixStructure() return <td {...props} data-legs="" className={cn(HEAD, "text-left", className)}>{children === undefined ? legLabels.join(" / ") : children}</td> } export function SpreadMatrixStructureCell(props: ComponentProps<"td">) { const { structure, spread } = useSpreadMatrixStructure() return <ValueCell {...props} data-spread="" identity={structure.id} spread={spread} /> }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) }hooks/use-row-store.ts import { useCallback, useLayoutEffect, useState, useSyncExternalStore } from "react" import type { RowId, RowStore, RowView, StoreMeta, ViewOptions } from "@/lib/row-store" /** One row. The component re-renders when this row is replaced or patched, and for nothing else. */ export function useRow<T>(store: RowStore<T>, id: RowId): T | undefined { const subscribe = useCallback((cb: () => void) => store.subscribeRow(id, cb), [store, id]) const get = useCallback(() => store.getRow(id), [store, id]) return useSyncExternalStore(subscribe, get, get) } /** The ids of a store (insertion or authoritative order) or of a view (filtered, sorted, held). */ export function useRowIds<T>(source: RowStore<T> | RowView<T>): readonly RowId[] { const subscribe = useCallback((cb: () => void) => ("subscribeOrder" in source ? source.subscribeOrder(cb) : source.subscribe(cb)), [source]) const get = useCallback(() => source.getIds(), [source]) return useSyncExternalStore(subscribe, get, get) } /** Version, size, lane, drop count, sequence, gap, and timestamps. Changes once per batch. */ export function useStoreMeta<T>(store: RowStore<T>): StoreMeta { const subscribe = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const get = useCallback(() => store.getMeta(), [store]) return useSyncExternalStore(subscribe, get, get) } /** * A view whose connection belongs to this component. Preparation is pure, so abandoned renders and * server rendering leave no live resources. Cleanup disconnects; effect replay reconnects the same view. * Keep options stable (a `useMemo`): new store/options identities select a new view synchronously. * Null options give null, for a component that borrows a caller-owned view instead. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [prepared, setPrepared] = useState(() => ({ store, options, view: options ? store.prepareView(options) : null })) let current = prepared if (prepared.store !== store || prepared.options !== options) { current = { store, options, view: options ? store.prepareView(options) : null } setPrepared(current) } const view = current.view useLayoutEffect(() => view?.connect(), [view]) return view }lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. // setTimeout's longest delay; a longer one fires at once. const MAX_TIMER_MS = 2 ** 31 - 1 // How long a batch waits for a frame before a timer flushes it: a hidden tab runs no frames. const FRAME_FALLBACK_MS = 250 export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments per batch or clear, before any subscriber is notified. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean /** When the current or most recent reorder hold lapses or lapsed, in Date.now() milliseconds; null before any hold. Optional: without it, parked arrival marks age from their arrival, so a hold longer than the highlight window swallows those flashes. */ holdExpiresAt?(): number | null dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } /** A render-safe snapshot with an explicitly owned, reversible connection to its store. */ export interface PreparedRowView<T> extends RowView<T> { /** Follow batches until the returned release runs. Multiple owners release independently. */ connect(): () => void } /** Publish current row, order and metadata snapshots before notifying any subscriber. */ export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void /** Prepare a current snapshot without subscriptions, registration or timers. Safe during render. */ prepareView(opts?: ViewOptions<T>): PreparedRowView<T> /** Follow the store immediately, even without subscribers. The caller must dispose the view. */ createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] // Snapshots never alias the live array: the first batch appends to ids in place. let idsSnapshot: readonly RowId[] = ids.slice() let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged, meta.version) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, prepareView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.add(view), () => views.delete(view)) return view }, createView(opts) { const view = store.prepareView(opts) view.connect() return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = [] meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true, meta.version) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a === b) return true if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements PreparedRowView<T> { private ids: readonly RowId[] = [] private published: readonly RowId[] = [] private version: number private readonly listeners = new Set<Listener>() private holdUntil = 0 private settlePending = false private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private connections = 0 private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly attach: () => void private readonly detach: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, attach: () => void, detach: () => void) { this.store = store this.opts = opts this.attach = attach this.detach = detach this.now = opts.now ?? Date.now this.version = store.getMeta().version this.ids = this.compute() this.published = this.ids } getIds() { if (this.disposed) return this.ids const version = this.store.getMeta().version const expired = !this.connections && this.settlePending && !this.isHeld() if (version !== this.version || expired) { this.cache(this.isHeld() ? this.computeHeld() : this.compute()) this.version = version if (expired) this.settlePending = false } return this.ids } subscribe(cb: Listener) { if (this.disposed) return () => {} this.listeners.add(cb) return () => { this.listeners.delete(cb) } } connect() { if (this.disposed) return () => {} let released = false const release = () => { if (released || this.disposed) return released = true if (--this.connections === 0) { this.detach() this.clearTimer() } } if (this.connections === 0) { // Refresh while disconnected, including a hold that expired while effects were hidden. this.getIds() this.attach() } this.connections++ try { this.publish() this.armTimer() } catch (error) { release() throw error } return release } isHeld() { return this.now() < this.holdUntil } holdExpiresAt() { // The hold is kept in the view's own clock; the reported deadline is wall // time, so a grid can compare it with Date.now whatever clock was injected. return this.holdUntil > 0 ? Date.now() + (this.holdUntil - this.now()) : null } touch() { if (this.disposed) return // A hold that isn't a positive number holds nothing, and one longer than a timer can wait, Infinity included, holds // as long as one can, about 24.8 days: past that a timer fires at once. const asked = this.opts.reorderHoldMs ?? 0 const hold = asked > 0 ? Math.min(asked, MAX_TIMER_MS) : 0 if (hold <= 0) return if (!this.connections) this.getIds() this.holdUntil = this.now() + hold this.settlePending = true this.clearTimer() this.armTimer() } private armTimer() { if (this.disposed || !this.connections || !this.settlePending || this.timer !== null) return // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.settlePending = false this.cache(this.compute()) this.publish() }, Math.max(0, this.holdUntil - this.now())) } private clearTimer() { if (this.timer !== null) clearTimeout(this.timer) this.timer = null } dispose() { if (this.disposed) return this.disposed = true this.clearTimer() this.connections = 0 this.listeners.clear() this.detach() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean, version: number) { // Another view's listener may already have read our current snapshot. It cannot publish it. if (this.version === version) { this.publish() return } this.version = version if (!touched.size && !orderChanged) return if (this.isHeld()) { this.cache(this.computeHeld(removed)) this.publish() return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) { this.cache(this.compute()) this.publish() } } private computeHeld(removed?: Set<RowId>): readonly RowId[] { // Frozen order: drop removals and rows outside the filter, then append sorted newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed?.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) return kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private cache(next: readonly RowId[]) { if (!sameOrder(this.ids, next)) this.ids = next } private publish() { if (sameOrder(this.published, this.ids)) return this.published = this.ids for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame, or after 250 ms when no frame comes. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null // A hidden document runs no frames, so a timer stands in: whichever comes first flushes, and the other is cancelled. let timer: ReturnType<typeof setTimeout> | null = null // The frame scheduled now. A frame the flush cancelled can still run where it can't be cancelled, as with a `raf` // passed without its `caf`, and it finds another frame scheduled, or none, and flushes nothing. let scheduled: object | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { scheduled = null if (handle !== null) { caf(handle) handle = null } if (timer !== null) { clearTimeout(timer) timer = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { // Omitted fields keep their previous values all the way through: a frame that // never mentions gap must not write one, or a replay's recorded gap is // silenced mid-flight. Mentions apply in order, so a gap that opens and // closes inside one frame ends closed, exactly as two frames would. const gapMentioned = delta.meta.gap !== undefined || meta?.gap !== undefined meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), ...(gapMentioned ? { gap: delta.meta.gap ?? meta?.gap } : {}), } if (!gapMentioned) delete (meta as { gap?: boolean }).gap } if (handle === null && timer === null) { const frame = {} scheduled = frame const requested = raf(() => { if (scheduled !== frame) return handle = null flush() }) // A frame that ran as it was requested has flushed already, and leaves nothing to hold. if (scheduled === frame) { handle = requested timer = setTimeout(() => { timer = null flush() }, FRAME_FALLBACK_MS) } } }, flush, cancel() { scheduled = null if (handle !== null) caf(handle) if (timer !== null) clearTimeout(timer) handle = null timer = null reset() }, pending: () => handle !== null || timer !== null, } }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 { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --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; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --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; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
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
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import {
SpreadMatrix,
SpreadMatrixTable,
SpreadMatrixHead,
SpreadMatrixRow,
SpreadMatrixCell,
type SpreadInstrument,
} from "@/components/ui/spread-matrix"
const T32: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const instruments: SpreadInstrument[] = [
{ id: "2Y", label: "2Y", convention: { ...T32, tick: 1 / 128 } },
{ id: "5Y", label: "5Y", convention: T32 },
{ id: "10Y", label: "10Y", convention: T32 },
]
function PriceSpreads() {
const [store] = useState(() => {
const store = createRowStore<{ id: string; price: number }>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [
{ id: "2Y", price: 100.25 },
{ id: "5Y", price: 99.75 },
{ id: "10Y", price: 99.515625 },
] })
return store
})
return (
<SpreadMatrix store={store} instruments={instruments} className="w-fit max-w-full">
<SpreadMatrixTable label="Price spreads">
<caption className="sr-only">Each cell is the row less the column. ticks.</caption>
<thead>
<tr>
<SpreadMatrixHead className="text-left">Instrument (ticks)</SpreadMatrixHead>
{instruments.map((column) => <SpreadMatrixHead key={column.id} data-column={column.id}>{column.label}</SpreadMatrixHead>)}
</tr>
</thead>
<tbody>
{instruments.map((instrument) => (
<SpreadMatrixRow key={instrument.id} instrument={instrument}>
<SpreadMatrixHead scope="row">{instrument.label}</SpreadMatrixHead>
{instruments.map((column) => <SpreadMatrixCell key={column.id} column={column.id} />)}
</SpreadMatrixRow>
))}
</tbody>
</SpreadMatrixTable>
</SpreadMatrix>
)
}
Each cell subtracts the column from the row and counts the difference in the row's tick. The 5Y row under 10Y reads +15. The reverse reads −15.
The 2Y has a smaller tick, so its spread over 5Y is +64, while 5Y over 2Y is −32. Matching ids stay blank.
Seed the row store with upsert before patching. Patches for unknown ids are ignored.
Composition
Use the following composition to build a SpreadMatrix:
SpreadMatrix
└── SpreadMatrixTable
├── caption
├── thead
│ └── tr
│ └── SpreadMatrixHead
└── tbody
└── SpreadMatrixRow
├── SpreadMatrixHead (scope="row")
└── SpreadMatrixCell
└── SpreadMatrixValue (default)
Use SpreadMatrixStructureRow for curves and butterflies:
SpreadMatrix
└── SpreadMatrixTable
├── caption
├── thead
│ └── tr
│ └── SpreadMatrixHead
└── tbody
└── SpreadMatrixStructureRow
├── SpreadMatrixHead (scope="row")
├── SpreadMatrixLegs
└── SpreadMatrixStructureCell
└── SpreadMatrixValue (default)
Yield basis
Set basis="bps" to compare yields in percent: 4.25 means 4.25%, and a difference of 0.125 is 12.5 bp.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { SpreadMatrix, SpreadMatrixTable, SpreadMatrixHead, SpreadMatrixRow, SpreadMatrixCell, type SpreadBasis, type SpreadInstrument } from "@/components/ui/spread-matrix"
const T32: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const instruments: SpreadInstrument[] = [
{ id: "2Y", label: "2Y", convention: { ...T32, tick: 1 / 128 } },
{ id: "5Y", label: "5Y", convention: T32 },
{ id: "10Y", label: "10Y", convention: T32 },
]
export function SpreadMatrixContent({ instruments, columns = instruments, basis = "ticks", label }: { instruments: readonly SpreadInstrument[]; columns?: readonly SpreadInstrument[]; basis?: SpreadBasis; label: string }) {
const unit = basis === "ticks" ? "ticks" : "bp"
return (
<SpreadMatrixTable label={label}>
<caption className="sr-only">Each cell is the row less the column. {unit}.</caption>
<thead>
<tr>
<SpreadMatrixHead className="text-left">Instrument ({unit})</SpreadMatrixHead>
{columns.map((column) => <SpreadMatrixHead key={column.id} data-column={column.id}>{column.label}</SpreadMatrixHead>)}
</tr>
</thead>
<tbody>
{instruments.map((instrument) => (
<SpreadMatrixRow key={instrument.id} instrument={instrument}>
<SpreadMatrixHead scope="row">{instrument.label}</SpreadMatrixHead>
{columns.map((column) => <SpreadMatrixCell key={column.id} column={column.id} />)}
</SpreadMatrixRow>
))}
</tbody>
</SpreadMatrixTable>
)
}
export default function SpreadMatrixYieldsDemo() {
const [store] = useState(() => {
const store = createRowStore<{ id: string; yield: number }>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [
{ id: "2Y", yield: 4.25 },
{ id: "5Y", yield: 4.125 },
{ id: "10Y", yield: 4.375 },
] })
return store
})
return <SpreadMatrix store={store} instruments={instruments} basis="bps" className="w-fit max-w-full"><SpreadMatrixContent instruments={instruments} basis="bps" label="Yield spreads" /></SpreadMatrix>
}
Curves and butterflies
Use SpreadMatrixStructureRow for weighted legs and SpreadMatrixStructureCell for their spread.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { SpreadMatrix, SpreadMatrixTable, SpreadMatrixHead, SpreadMatrixStructureRow, SpreadMatrixLegs, SpreadMatrixStructureCell, type SpreadBasis, type SpreadInstrument, type SpreadStructure } from "@/components/ui/spread-matrix"
const T32: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const instruments: SpreadInstrument[] = [
{ id: "2Y", label: "2Y", convention: { ...T32, tick: 1 / 128 } },
{ id: "5Y", label: "5Y", convention: T32 },
{ id: "10Y", label: "10Y", convention: T32 },
]
const structures: SpreadStructure[] = [
{ id: "2s10s", label: "2s10s", legs: ["2Y", "10Y"] },
{ id: "2s5s10s", label: "2s5s10s", legs: ["2Y", "5Y", "10Y"] },
]
export function SpreadStructuresContent({ structures, basis = "ticks", label }: { structures: readonly SpreadStructure[]; basis?: SpreadBasis; label: string }) {
const unit = basis === "ticks" ? "ticks" : "bp"
return (
<SpreadMatrixTable label={label}>
<caption className="sr-only">Spread: {unit}.</caption>
<thead>
<tr>
<SpreadMatrixHead className="text-left">Structure</SpreadMatrixHead>
<SpreadMatrixHead className="text-left">Legs</SpreadMatrixHead>
<SpreadMatrixHead>Spread ({unit})</SpreadMatrixHead>
</tr>
</thead>
<tbody>
{structures.map((structure) => (
<SpreadMatrixStructureRow key={structure.id} structure={structure}>
<SpreadMatrixHead scope="row">{structure.label}</SpreadMatrixHead>
<SpreadMatrixLegs />
<SpreadMatrixStructureCell />
</SpreadMatrixStructureRow>
))}
</tbody>
</SpreadMatrixTable>
)
}
export default function SpreadMatrixStructuresDemo() {
const [store] = useState(() => {
const store = createRowStore<{ id: string; yield: number }>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [
{ id: "2Y", yield: 4.25 },
{ id: "5Y", yield: 4.125 },
{ id: "10Y", yield: 4.375 },
] })
return store
})
return <SpreadMatrix store={store} instruments={instruments} basis="bps" className="w-fit max-w-full"><SpreadStructuresContent structures={structures} basis="bps" label="Yield curves and butterflies" /></SpreadMatrix>
}
Quote updates
Save the yield example as spread-matrix-yields.tsx to reuse its SpreadMatrixContent here. The controls lower 5Y by one tick and restore the original quotes.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { SpreadMatrix, type SpreadInstrument } from "@/components/ui/spread-matrix"
import { SpreadMatrixContent } from "./spread-matrix-yields"
const T32: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const instruments: SpreadInstrument[] = [
{ id: "2Y", label: "2Y", convention: { ...T32, tick: 1 / 128 } },
{ id: "5Y", label: "5Y", convention: T32 },
{ id: "10Y", label: "10Y", convention: T32 },
]
const quotes = [
{ id: "2Y", price: 100.25 },
{ id: "5Y", price: 99.75 },
{ id: "10Y", price: 99.515625 },
]
export default function SpreadMatrixUpdatesDemo() {
const [store] = useState(() => {
const store = createRowStore<{ id: string; price: number }>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: quotes })
return store
})
const [moved, setMoved] = useState(false)
const move = () => {
store.applyDeltas({ patch: [{ id: "5Y", fields: { price: 99.734375 } }] })
setMoved(true)
}
const restore = () => {
store.applyDeltas({ upsert: quotes })
setMoved(false)
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={moved} onClick={move}>Lower 5Y by one tick</button>
<button type="button" className="rounded border px-2 py-1" onClick={restore}>Restore quotes</button>
</div>
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<SpreadMatrix store={store} instruments={instruments}><SpreadMatrixContent instruments={instruments} label="Updating price spreads" /></SpreadMatrix>
<p role="status" className="text-muted-foreground">{moved ? "5Y price lowered by 1/64. Its row falls; its column rises." : "Initial quotes."}</p>
</div>
</>
)
}
Custom layout
Reorder the collection, move leg descriptions into row headers, and add application controls with SpreadMatrixValue and the shared state hooks.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import {
SpreadMatrix,
SpreadMatrixTable,
SpreadMatrixHead,
SpreadMatrixRow,
SpreadMatrixCell,
SpreadMatrixStructureRow,
SpreadMatrixStructureCell,
SpreadMatrixValue,
useSpreadMatrixCell,
useSpreadMatrixStructure,
type SpreadInstrument,
type SpreadStructure,
} from "@/components/ui/spread-matrix"
const T32: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const instruments: SpreadInstrument[] = [
{ id: "2Y", label: "2Y", convention: { ...T32, tick: 1 / 128 } },
{ id: "5Y", label: "5Y", convention: T32 },
{ id: "10Y", label: "10Y", convention: T32 },
]
const structures: SpreadStructure[] = [
{ id: "2s10s", label: "2s10s", legs: ["2Y", "10Y"] },
{ id: "2s5s10s", label: "2s5s10s", legs: ["2Y", "5Y", "10Y"] },
]
const rows = instruments.slice(1).reverse()
function SpreadReadingButton({ label, onSelect }: { label: string; onSelect: () => void }) {
const { spread, diagonal } = useSpreadMatrixCell()
return <button type="button" disabled={spread === null || diagonal} onClick={onSelect} className="rounded px-1 underline underline-offset-4 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"><span className="sr-only">{label}: </span><SpreadMatrixValue /></button>
}
function StructureLegDescription() {
const { legLabels } = useSpreadMatrixStructure()
return <span className="block text-muted-foreground">{legLabels.join(" / ")}</span>
}
export default function SpreadMatrixCompositionDemo() {
const [store] = useState(() => {
const store = createRowStore<{ id: string; yield: number }>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [{ id: "2Y", yield: 4.25 }, { id: "5Y", yield: 4.125 }, { id: "10Y", yield: 4.375 }] })
return store
})
const [selected, setSelected] = useState("No spread selected.")
return (
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<SpreadMatrix store={store} instruments={instruments} basis="bps" className="space-y-4 p-2">
<SpreadMatrixTable label="Long maturities against 2Y">
<caption className="pb-2 text-left">Row less 2Y, in bp.</caption>
<thead><tr><SpreadMatrixHead className="text-left">Instrument</SpreadMatrixHead><SpreadMatrixHead>Spread (bp)</SpreadMatrixHead></tr></thead>
<tbody>
{rows.map((instrument) => (
<SpreadMatrixRow key={instrument.id} instrument={instrument}>
<SpreadMatrixHead scope="row">{instrument.label}</SpreadMatrixHead>
<SpreadMatrixCell column="2Y"><SpreadReadingButton label={`Select ${instrument.label} over 2Y`} onSelect={() => setSelected(`${instrument.label} over 2Y selected.`)} /></SpreadMatrixCell>
</SpreadMatrixRow>
))}
</tbody>
</SpreadMatrixTable>
<SpreadMatrixTable label="Selected curves and butterflies">
<caption className="pb-2 text-left">Weighted yield spreads, in bp.</caption>
<thead><tr><SpreadMatrixHead className="text-left">Structure and legs</SpreadMatrixHead><SpreadMatrixHead>Spread (bp)</SpreadMatrixHead></tr></thead>
<tbody>
{structures.toReversed().map((structure) => (
<SpreadMatrixStructureRow key={structure.id} structure={structure}>
<SpreadMatrixHead scope="row">{structure.label}<StructureLegDescription /></SpreadMatrixHead>
<SpreadMatrixStructureCell><strong><SpreadMatrixValue /></strong></SpreadMatrixStructureCell>
</SpreadMatrixStructureRow>
))}
</tbody>
</SpreadMatrixTable>
</SpreadMatrix>
<p role="status">{selected}</p>
</div>
)
}
API Reference
Rows subscribe to their instrument and cells subscribe to the column quote. Structure rows subscribe to their legs.
With stable inputs, a quote update reaches only its row, column and dependent structures.
Supply stable instrument objects, metadata arrays, readers and formatters to avoid extra work on parent renders.
Readings and state hooks add no subscriptions.
Props
SpreadMatrixProps<T extends object = SpreadQuote> extends native div props with required children. It coordinates data without rendering a table.
SpreadBasis is "ticks" | "bps". Nullable is number | null | undefined from format.
| Prop | Type | Default | Description |
|---|---|---|---|
store |
RowStore<T> |
Required | Quotes keyed by instrument id. |
instruments |
readonly SpreadInstrument[] |
Required | Metadata for structure leg names and fallback ticks. Last duplicate id wins. Does not choose visible rows or columns. |
children |
ReactNode |
Required | Tables and surrounding application content. Conditional content is supported. |
basis |
SpreadBasis |
"ticks" |
Price ticks or yield basis points. |
value |
(quote: T, basis: SpreadBasis) => Nullable |
Reads price or yield |
Read the value in the selected basis. |
format |
(value: number, basis: SpreadBasis) => ReactNode |
formatSpread |
Format a calculated spread. |
flashWindowMs |
number |
900 |
Cell flash duration in ms. |
className |
string |
- | Additional classes to apply to the root. |
Parts
Parts accept the native props, refs, classes and events of their elements. SpreadMatrixTable and SpreadMatrixHead work without a store. The coordinating parts require their parent contexts and throw when misplaced.
| Part | Element | Inputs |
|---|---|---|
SpreadMatrixTable |
table |
Required label: string and children: ReactNode. |
SpreadMatrixHead |
th |
Native children and scope, default "col". Use "row" for row headers. |
SpreadMatrixRow |
tr |
Required instrument: SpreadInstrument and children: ReactNode. |
SpreadMatrixCell |
td |
Required column: RowId. Optional children replace the default reading. |
SpreadMatrixStructureRow |
tr |
Required structure: SpreadStructure and children: ReactNode. |
SpreadMatrixStructureCell |
td |
Optional children replace the default reading. |
SpreadMatrixLegs |
td |
Optional children replace the joined leg labels. |
SpreadMatrixValue |
span |
Signed reading within either spread cell. No children prop. |
Omitting cell children renders SpreadMatrixValue. Passing null leaves the cell empty.
Custom content can wrap or move the reading while the cell retains its flash. Mount one value cell per presented spread. Extra SpreadMatrixValue readings share that cell's subscriptions and flash.
Hooks
| Hook | Parent | Returned state |
|---|---|---|
useSpreadMatrixRow() |
SpreadMatrixRow |
instrument: SpreadInstrument, value: number | null in the reader's input units. |
useSpreadMatrixCell() |
Either spread cell | spread: number | null, basis: SpreadBasis, diagonal: boolean. |
useSpreadMatrixStructure() |
SpreadMatrixStructureRow |
structure: SpreadStructure, legLabels: readonly string[], weights: readonly number[], spread: number | null, basis: SpreadBasis. |
The hooks reuse their parent's state. Use them to place leg descriptions outside the default Legs column or build controls around a spread without another quote subscription. With weights or a tick outside the rule in Structures, spread can also be infinite, or NaN in bps mode, and in bps mode a sum too large to hold is infinite even with valid inputs, where ticks mode reads one as null.
Accessibility
SpreadMatrixTable renders a native table named by label. Native aria-label and aria-labelledby props are reserved. Keep thead, tbody and tr in their valid table positions. Supply column headers and scope="row" headers matching the visible collection. Place row headers before the cells they label.
Include a caption describing the calculation and unit, visually hidden with sr-only if the headings already explain them.
The parts add no keyboard shortcuts or live announcements. Application controls retain native focus and keyboard behavior. Name custom controls, retain signs or another non-color cue, and choose any empty state or status announcement at the call site.
Instruments and quotes
SpreadInstrument field |
Type | Required | Purpose |
|---|---|---|---|
id |
RowId (string) |
Yes | The quote's row id in the store. |
label |
string |
Yes | The header text. |
convention |
InstrumentConvention |
Yes | Supplies tick when this instrument is the matrix row or a structure's first leg. Other convention fields do not format the spread. |
The default reader accepts SpreadQuote:
| Field | Type | Default | Read for |
|---|---|---|---|
price |
number | null |
Omitted | "ticks", in price points. |
yield |
number | null |
Omitted | "bps", in percent: 4.25 means 4.25%. |
For another quote shape, supply value(quote, basis) in the same units. A missing quote, null, undefined, NaN, or infinity produces the null token (–) in affected value cells. Matching row and column ids stay blank.
The default reader also treats nonnumeric fields as missing. Cells fill in when valid quotes arrive.
The cell
Each cell subtracts the column value from the row value. In the example, +15 in the 5Y row under 10Y means the 5Y price is fifteen ticks above the 10Y price. Both use a 1/64 tick, so the reverse cell reads −15.
| Basis | Calculation | Default display |
|---|---|---|
"ticks" |
(row − column) / row.convention.tick, rounded to the nearest eighth by ticksBetween. |
Signed, up to three decimals without trailing zeros: +1.5, −0.5, 0. |
"bps" |
(row yield − column yield) × 100, rounded to 0.001 bp. Tick is ignored. |
Signed, one decimal: +12.3, −0.8, 0.0. |
Reversing a pair does not guarantee equal displayed magnitudes. A price difference of 1/64 is +1 in a row with a 1/64 tick and −2 in the reverse row with a 1/128 tick.
Rounding ties can differ even with a shared tick: with tick 1, a difference of 1/16 rounds to +0.125, while its reverse displays 0.
Cells with the same row and column id are blank, including in a rectangular matrix. Put the unit in the header or caption. Default cell formatting omits it.
The default reading calls format with the calculated spread, before display rounding, and skips it for diagonal cells and null results. Custom cell children can use SpreadMatrixValue to retain this behavior.
Import the arithmetic and formatting helpers from @/components/ui/spread-matrix:
| Function | Inputs | Returns |
|---|---|---|
spreadBetween(a, b, basis, tick) |
a, b: Nullable; basis: SpreadBasis; tick: number. |
number | null: a − b converted to the basis. Missing or nonfinite sides return null. In ticks, a tick that is not positive also returns null; in bps, tick is ignored. |
formatSpread(value, basis) |
value: Nullable; basis: SpreadBasis. |
string: the default display above, or – for missing or nonfinite values. |
Structures
SpreadStructure field |
Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | The row's key. |
label |
string |
Required | The row header. |
legs |
readonly [RowId, RowId] | readonly [RowId, RowId, RowId] |
Required | Two legs make a curve, three a butterfly. |
weights |
readonly number[] |
[-1, 1] for two legs; [-1, 2, -1] for three |
One weight per leg. |
tick |
number |
First leg's instrument tick | Divides the weighted price sum in ticks mode. Ignored in bps mode. |
A structure sums each leg's value times its weight, then converts the sum with the same basis rounding as a matrix cell.
The default curve is second leg minus first, the opposite order from a matrix's row-minus-column rule.
The default butterfly is twice the middle leg minus both outer legs. Yields of 4.25, 4.125, and 4.375 produce +12.5 bp for 2s10s and −37.5 bp for 2s5s10s.
Leg quotes come from the store. Labels and the fallback tick come from the root's instruments. The last entry wins when ids overlap.
An unknown instrument label falls back to its id.
In ticks mode, provide tick if the first leg has no instrument metadata. Otherwise the result is missing.
SpreadMatrixLegs joins labels with /, and the row carries space-separated data-weights. Put the unit in your Spread header.
| Function | Inputs | Returns |
|---|---|---|
structureSpread(values, weights, basis, tick) |
values: readonly Nullable[]; weights: readonly number[] | undefined; basis: SpreadBasis; tick: number. |
number | null: weighted sum converted to the basis. Pass undefined for default weights. Empty values, a weight-count mismatch, or missing or nonfinite values return null. |
defaultWeights(count) |
count: number. |
readonly number[]: [-1, 1] for 2, [-1, 2, -1] for 3, otherwise []. |
The standalone helper accepts any nonempty number of values when supplied matching weights. The component accepts only two or three legs.
Supply finite weights and a positive finite tick for ticks mode.
Helpers do not validate weight finiteness or guard every arithmetic overflow, so infinity can reach a custom formatter in either mode, and NaN in bps mode; ticks mode turns NaN and non-finite sums into null first. formatSpread prints – for those results.
Marks and flashes
| Attribute | Where | Meaning |
|---|---|---|
data-basis |
The root | ticks or bps. |
data-row |
Matrix rows | The row instrument's id. |
data-row and data-column |
Matrix cells | The two instruments' ids. |
data-diagonal |
Matrix cells | The row and the column are one instrument; the cell is blank. |
data-structure and data-weights |
Structure rows | The structure's id and its weights. |
data-spread |
A structure's value cell | The cell that flashes. |
data-legs |
The optional legs cell | Joined leg labels or custom content. |
data-direction |
A flashing value cell | up when the calculated spread rose, down when it fell, or flat for a change to or from a missing result or between signed zeros. Removed when the flash ends. |
Initial values do not flash. A changed calculated spread starts a fill flash in the direction's soft token. Another change restarts it for flashWindowMs.
Results unchanged by Object.is stay quiet, even if a quote updates.
A change smaller than the formatter's precision can flash while the printed text stays the same, including a change between 0 and −0.
Flashes last only for the current instrument pair or structure id. Changing that identity clears the flash without replacing the cell or its focused content. Reduced motion uses a static tint for the same duration instead of animation.
The default formatter keeps the spread's sign visible after the flash ends. That sign describes the spread, while the flash describes its latest movement.
Labels
Write headings, captions and unit labels in your composition. Use ticks for price ticks and bp for basis points.
The Usage example writes its matrix caption as Each cell is the row less the column. ticks., and the Curves and butterflies example writes Spread: bp.; SpreadMatrixTable renders no caption of its own. Write yours to say which value is subtracted from which and in what unit, and translate those children and the table's label together.
What it does not do
It does not fetch quotes, pick the mid, stage a spread, or send anything. Feed the store from your quotes and choose the basis.
Place actions in your own cells or surrounding controls. Native cell events are forwarded without staging an order.
Tokens
The install adds up, down, and flat with their soft variants if you do not have them, plus the shared font tokens and the hyperlegible remap. The flashes use the soft ones.
This page is docs/spread-matrix.md at v2.0.0.