SpreadMatrix
Compare instruments in a matrix of row-minus-column spreads, or list curves and butterflies as weighted legs. Spreads print with their signs in ticks of price or basis points of yield and flash when they change.
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"
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} label="Price spreads" className="w-fit max-w-full" />
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/spread-matrix#v1.4.13
npx shadcn@latest add tradecn/ui/spread-matrix#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/spread-matrix#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/spread-matrix#v1.4.13
-
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 { memo, useMemo, useRef } 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" // A spread matrix: instruments down the side and across the top, each cell the spread of the row over the // column, in ticks of price or basis points of yield, printed with its sign and flashing by the direction it // moved. Every row reads its own quote and every cell the column's, so a quote that moves wakes the cells it // is part of and nothing else. Given a list of structures (curves and butterflies as weighted legs) the matrix // lists those as rows instead of the full grid. It reads quotes and prints spreads; it stages and sends nothing. /** 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 SpreadMatrixLabels { /** The corner header of the matrix, followed by the unit. */ instrument: string /** Headers of the structures list. */ structure: string legs: string spread: string /** The unit words. */ ticks: string bps: string /** Read to a screen reader as the table's caption: which side of a cell is which. */ rule: string } export const DEFAULT_SPREAD_MATRIX_LABELS: SpreadMatrixLabels = { instrument: "Instrument", structure: "Structure", legs: "Legs", spread: "Spread", ticks: "ticks", bps: "bp", rule: "Each cell is the row less the column.", } export interface SpreadMatrixProps<T extends object = SpreadQuote> { /** Quotes keyed by instrument id. */ store: RowStore<T> /** The rows, and the columns unless `columns` is given, in this order. */ instruments: readonly SpreadInstrument[] /** A different set across the top, for a matrix of one set against another. */ columns?: readonly SpreadInstrument[] /** Ticks of price or basis points of yield. Default `ticks`. */ basis?: SpreadBasis /** Curves and butterflies. When given, the matrix lists these as rows instead of the full grid. */ structures?: readonly SpreadStructure[] /** Read a quote's value in the basis. Default: `price` for ticks, `yield` for basis points. Keep it stable between renders. */ value?: (quote: T, basis: SpreadBasis) => Nullable /** Print a spread. `formatSpread` by default. Keep it stable between renders. */ format?: (value: number, basis: SpreadBasis) => string /** Accessible name of the table. */ label: string labels?: Partial<SpreadMatrixLabels> /** Cell flash duration in ms. Default 900. */ flashWindowMs?: number className?: string } 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 CellProps<T extends object> { store: RowStore<T> rowId: RowId columnId: RowId /** The row's value in the basis, or null while its quote is missing. */ rowValue: number | null tick: number basis: SpreadBasis read: (quote: T, basis: SpreadBasis) => Nullable format: (value: number, basis: SpreadBasis) => string windowMs: number } function MatrixCellInner<T extends object>(p: CellProps<T>) { const quote = useRow(p.store, p.columnId) const diagonal = p.rowId === p.columnId const spread = diagonal ? null : spreadBetween(p.rowValue, quote === undefined ? null : p.read(quote, p.basis), p.basis, p.tick) const ref = useRef<HTMLTableCellElement>(null) // One flash per move: the spread between these two instruments changed. useFlash(ref, spread, { windowMs: p.windowMs, variant: "fill", disabled: diagonal }) return ( <td ref={ref} data-row={p.rowId} data-column={p.columnId} data-diagonal={diagonal ? "" : undefined} data-numeric="" className={cn(CELL, NUMERIC_CLASS, FILL_CLASSES, diagonal && "bg-muted/40")}> {diagonal ? "" : spread === null ? NULL_TOKEN : p.format(spread, p.basis)} </td> ) } const MatrixCell = memo(MatrixCellInner) as typeof MatrixCellInner interface RowProps<T extends object> { store: RowStore<T> instrument: SpreadInstrument columns: readonly SpreadInstrument[] basis: SpreadBasis read: (quote: T, basis: SpreadBasis) => Nullable format: (value: number, basis: SpreadBasis) => string windowMs: number } function MatrixRowInner<T extends object>(p: RowProps<T>) { const quote = useRow(p.store, p.instrument.id) const raw = quote === undefined ? null : p.read(quote, p.basis) const rowValue = isNumber(raw) ? raw : null return ( <tr data-row={p.instrument.id} className="border-t border-border/60"> <th scope="row" className={cn(HEAD, "text-left text-foreground")}> {p.instrument.label} </th> {p.columns.map((column) => ( <MatrixCell key={column.id} store={p.store} rowId={p.instrument.id} columnId={column.id} rowValue={rowValue} tick={p.instrument.convention.tick} basis={p.basis} read={p.read} format={p.format} windowMs={p.windowMs} /> ))} </tr> ) } const MatrixRow = memo(MatrixRowInner) as typeof MatrixRowInner interface StructureRowProps<T extends object> { store: RowStore<T> structure: SpreadStructure instruments: ReadonlyMap<RowId, SpreadInstrument> basis: SpreadBasis read: (quote: T, basis: SpreadBasis) => Nullable format: (value: number, basis: SpreadBasis) => string windowMs: number } function StructureRowInner<T extends object>(p: StructureRowProps<T>) { const legs = p.structure.legs const a = legs[0] const b = legs[1] const c = legs.length === 3 ? legs[2] : undefined // Three subscriptions whatever the leg count, so the hook count never changes; a curve's third reads its second again. const qa = useRow(p.store, a) const qb = useRow(p.store, b) const qc = useRow(p.store, c ?? b) const values = (c === undefined ? [qa, qb] : [qa, qb, qc]).map((quote) => (quote === undefined ? null : p.read(quote, p.basis))) const tick = p.structure.tick ?? p.instruments.get(a)?.convention.tick ?? NaN const spread = structureSpread(values, p.structure.weights, p.basis, tick) const ref = useRef<HTMLTableCellElement>(null) useFlash(ref, spread, { windowMs: p.windowMs, variant: "fill" }) const legLabels = legs.map((id) => p.instruments.get(id)?.label ?? id).join(" / ") const weights = (p.structure.weights ?? defaultWeights(legs.length)).join(" ") return ( <tr data-structure={p.structure.id} data-weights={weights} className="border-t border-border/60"> <th scope="row" className={cn(HEAD, "text-left text-foreground")}> {p.structure.label} </th> <td data-legs="" className={cn(HEAD, "text-left")}> {legLabels} </td> <td ref={ref} data-spread="" data-numeric="" className={cn(CELL, NUMERIC_CLASS, FILL_CLASSES)}> {spread === null ? NULL_TOKEN : p.format(spread, p.basis)} </td> </tr> ) } const StructureRow = memo(StructureRowInner) as typeof StructureRowInner export function SpreadMatrix<T extends object = SpreadQuote>({ store, instruments, columns, basis = "ticks", structures, value, format = formatSpread, label, labels: labelsProp, flashWindowMs = 900, className }: SpreadMatrixProps<T>) { const labels = useMemo<SpreadMatrixLabels>(() => ({ ...DEFAULT_SPREAD_MATRIX_LABELS, ...labelsProp }), [labelsProp]) const read: (quote: T, basis: SpreadBasis) => Nullable = value ?? defaultValue const across = columns ?? instruments const byId = useMemo(() => new Map(instruments.concat(columns ?? []).map((i) => [i.id, i])), [instruments, columns]) const unit = labels[basis] const mode = structures ? "structures" : "matrix" return ( <div data-slot="tradecn-spread-matrix" data-basis={basis} data-mode={mode} className={cn("overflow-auto rounded-md border border-border bg-background text-xs text-foreground lining-nums tabular-nums", className)}> <table aria-label={label} className="w-full border-collapse"> <caption className="sr-only">{mode === "matrix" ? `${labels.rule} ${unit}.` : `${labels.spread}: ${unit}.`}</caption> <thead> {mode === "matrix" ? ( <tr> <th scope="col" data-unit={basis} className={cn(HEAD, "text-left")}> {labels.instrument} <span className="text-muted-foreground/80">({unit})</span> </th> {across.map((column) => ( <th key={column.id} scope="col" data-column={column.id} className={HEAD}> {column.label} </th> ))} </tr> ) : ( <tr> <th scope="col" className={cn(HEAD, "text-left")}> {labels.structure} </th> <th scope="col" className={cn(HEAD, "text-left")}> {labels.legs} </th> <th scope="col" data-unit={basis} className={HEAD}> {labels.spread} <span className="text-muted-foreground/80">({unit})</span> </th> </tr> )} </thead> <tbody> {structures ? structures.map((structure) => <StructureRow key={structure.id} store={store} structure={structure} instruments={byId} basis={basis} read={read} format={format} windowMs={flashWindowMs} />) : instruments.map((instrument) => <MatrixRow key={instrument.id} store={store} instrument={instrument} columns={across} basis={basis} read={read} format={format} windowMs={flashWindowMs} />)} </tbody> </table> </div> ) }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, useEffect, useRef, 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 this component owns: made for the options given, disposed on the way out, and made again for * new options. Keep the options object's identity stable (a `useMemo`), since a new object is a new view. * Null options give null, for a component that takes a view from its props instead. * * Why a hook and not a memo with a dispose effect: React's StrictMode mounts, unmounts, and mounts a * component again in development, and the cleanup of that rehearsal disposes the memo's view while the * memo keeps handing it out, so the grid stopped following its store until something remade the view. * The effect here notices a disposed view and makes another. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [view, setView] = useState<RowView<T> | null>(() => (options ? store.createView(options) : null)) const made = useRef({ store, options, view }) useEffect(() => { let current = made.current.view if (made.current.store !== store || made.current.options !== options || (current !== null && current.isDisposed())) { current = options ? store.createView(options) : null made.current = { store, options, view: current } // A view the rehearsal disposed, or new options: the replacement is state so the render that reads it re-runs. setView(current) } return () => current?.dispose() }, [store, options]) 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. 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 once per applied batch. */ 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 dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } 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 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[] = [] let idsSnapshot: readonly RowId[] = ids 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) 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, createView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.delete(view)) views.add(view) return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = ids meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true) 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.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 RowView<T> { private ids: readonly RowId[] = [] private readonly listeners = new Set<Listener>() private holdUntil = 0 private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly onDispose: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, onDispose: () => void) { this.store = store this.opts = opts this.onDispose = onDispose this.now = opts.now ?? Date.now this.ids = this.compute() } getIds() { return this.ids } subscribe(cb: Listener) { this.listeners.add(cb) return () => { this.listeners.delete(cb) } } isHeld() { return this.now() < this.holdUntil } touch() { const hold = this.opts.reorderHoldMs ?? 0 if (hold <= 0) return this.holdUntil = this.now() + hold if (this.timer) clearTimeout(this.timer) // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.set(this.compute()) }, hold) } dispose() { if (this.disposed) return this.disposed = true if (this.timer) clearTimeout(this.timer) this.listeners.clear() this.onDispose() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean) { if (!touched.size && !orderChanged) return if (this.isHeld()) { // Frozen order: keep what is there, drop removals and rows that no longer pass the filter, append 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) const next = kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] this.set(next) 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.set(this.compute()) } 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 set(next: readonly RowId[]) { if (sameOrder(this.ids, next)) return this.ids = next for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame. */ 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 function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { if (handle !== null) { caf(handle) handle = 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) { meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), gap: Boolean(meta?.gap) || Boolean(delta.meta.gap), } } if (handle === null) { handle = raf(() => { handle = null flush() }) } }, flush, cancel() { if (handle !== null) caf(handle) handle = null reset() }, pending: () => handle !== 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 } 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>() 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) 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) } } /** * 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 = s.trim().replace(/−/g, "-").replace(/,/g, "") 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 return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 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. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).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, whose dash, ticks, and tail must line up down a column. */ 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 * 99-16+ over 99-17 keeps its dash and its tail in the same place; 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" } /** 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 = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : 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, 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} label="Price spreads" className="w-fit max-w-full" />
}
Each cell is the row price minus the column price, counted in the row's tick. The 5Y row under 10Y reads +15; the reverse reads −15. The 2Y uses a smaller tick, 1/128 instead of 1/64: its spread over 5Y is +64, while 5Y over 2Y is −32. Matching row and column ids stay blank.
The quotes live in a row store. Seed them with upsert before patching: patches for unknown ids are ignored.
Yield basis
Set basis="bps" to compare yields instead of prices. Supply yields in percent: 4.25 means 4.25%, not 0.0425. The 10Y yield is 12.5 basis points above 2Y, so the 10Y row under 2Y reads +12.5. Instrument ticks do not affect this basis.
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"
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 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" label="Yield spreads" className="w-fit max-w-full" />
}
Curves and butterflies
Pass structures to list selected combinations instead of every pair. The default curve weights are [-1, 1]: 2s10s is 10Y minus 2Y, the opposite order from the matrix's row-minus-column rule. The butterfly weights are [-1, 2, -1]: twice 5Y minus 2Y and 10Y. These yields produce +12.5 bp and −37.5 bp respectively. Supply weights for another combination; in ticks mode, tick can override the first leg's tick.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { SpreadMatrix, 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 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} structures={structures} basis="bps" label="Yield curves and butterflies" className="w-fit max-w-full" />
}
Quote updates
The Lower 5Y by one tick button patches its price down by 1/64. The two spreads in its row fall and flash down; the two in its column rise and flash up. The 2Y row counts that change as two of its smaller ticks. Other pairs and the blank diagonal stay quiet. Signs describe the resulting spreads, not the direction of their latest movement.
The changed quotes stay visible after the flashes end. Restore quotes returns to the original prices and lets you repeat the update. These controls stand in for feed batches; there is no background publisher.
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"
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} label="Updating price spreads" />
<p role="status" className="text-muted-foreground">{moved ? "5Y price lowered by 1/64. Its row falls; its column rises." : "Initial quotes."}</p>
</div>
</>
)
}
API Reference
Matrix rows subscribe to their own quote through useRow, and cells subscribe to the column's quote. With stable props, a store update affects only the corresponding row and column; a structure row subscribes to its legs. The table uses native row and column headers and composes no shadcn component.
Props
SpreadMatrixProps<T extends object = SpreadQuote> accepts the following inputs. SpreadBasis is "ticks" | "bps"; Nullable is number | null | undefined from format.
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
RowStore<T> |
Required | Quotes keyed by instrument id. |
instruments |
readonly SpreadInstrument[] |
Required | The rows, and the columns unless columns is given, in this order. |
columns |
readonly SpreadInstrument[] |
instruments in matrix mode |
A different set across the top. Also supplies leg metadata in structures mode. |
basis |
SpreadBasis |
"ticks" |
Ticks of price or basis points of yield. |
structures |
readonly SpreadStructure[] |
None | Replaces the matrix with these structure rows. An empty array shows an empty structures table. |
value |
(quote: T, basis: SpreadBasis) => Nullable |
price for ticks, yield for basis points |
Read a quote's value in the basis. Keep it stable between renders. |
format |
(value: number, basis: SpreadBasis) => string |
formatSpread |
Print a spread. Keep it stable between renders. |
label |
string |
Required | Accessible name of the table. |
labels |
Partial<SpreadMatrixLabels> |
DEFAULT_SPREAD_MATRIX_LABELS |
Override the words listed below. |
flashWindowMs |
number |
900 |
Cell flash duration in ms. |
className |
string |
None | Classes on the outer scrolling div. |
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. The unit appears in the corner header; default cell formatting omits it. A custom format receives the calculated spread, before display rounding, and is skipped for blank cells and null results.
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 instruments and columns; entries in columns win 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, or the result is missing. The Legs column joins labels with /, the row carries space-separated data-weights, and the Spread header carries the unit.
| 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 NaN or infinity can still reach a custom formatter; formatSpread prints – for those results.
Marks and flashes
| Attribute | Where | Meaning |
|---|---|---|
data-basis and data-mode |
The root | ticks or bps; matrix or structures. |
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-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 mount. 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
labels merges partial overrides into DEFAULT_SPREAD_MATRIX_LABELS:
| Label | Default | Where |
|---|---|---|
instrument |
Instrument |
The corner header of the matrix, followed by the unit |
structure / legs / spread |
Structure / Legs / Spread |
The structures' headers |
ticks / bps |
ticks / bp |
The unit in the header |
rule |
Each cell is the row less the column. |
Matrix screen-reader caption, followed by the unit. Structures use the spread label and unit instead. |
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. There is no click callback or custom-header prop; keep actions in surrounding controls or a ticket.
Tokens
The install adds up, down, and flat with their soft variants if you do not have them. The flashes use the soft ones.
This page is docs/spread-matrix.md at v1.4.13.