limits
Check a draft against quantity, market-distance, and side limits. Each problem either blocks an action or asks for confirmation; the desk supplies the limits.
import { createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { checkLimits, type Limits, type LimitsDraft } from "@/lib/limits"
// One limits table: allowed, confirmation, quantity block, distance block, and minimum-size block.
const ust: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const fmt = createInstrumentFormatter(ust)
const market = { bid: 99.5, ask: 99.515625 }
const LIMITS: Limits = {
maxQuantity: { confirm: 10_000_000, block: 50_000_000 },
minQuantity: 1_000_000,
maxDistance: { ticks: 4 },
sides: ["buy", "sell"],
}
const DRAFTS: { label: string; draft: LimitsDraft }[] = [
{ label: "buy 5mm at 99-17", draft: { side: "buy", quantity: 5_000_000, price: 99.53125 } },
{ label: "buy 20mm at 99-17", draft: { side: "buy", quantity: 20_000_000, price: 99.53125 } },
{ label: "buy 60mm at 99-17", draft: { side: "buy", quantity: 60_000_000, price: 99.53125 } },
{ label: "buy 5mm at 99-20", draft: { side: "buy", quantity: 5_000_000, price: 99.625 } },
{ label: "sell 500k at 99-16", draft: { side: "sell", quantity: 500_000, price: 99.5 } },
]
export default function LimitsDemo() {
return (
<div className="w-2xl max-w-full space-y-2 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
<p className="text-muted-foreground">
market {fmt.price(market.bid)} / {fmt.price(market.ask)} · ask again above 10,000,000, stop above 50,000,000, at least 1,000,000, within 4 ticks of the market
</p>
<table className="w-full">
<caption className="sr-only">Draft limit checks</caption>
<thead>
<tr className="border-t border-border text-left text-muted-foreground">
<th scope="col" className="py-1 pr-3 font-normal">Draft</th>
<th scope="col" className="py-1 font-normal">Result</th>
</tr>
</thead>
<tbody>
{DRAFTS.map(({ label, draft }) => {
const problems = checkLimits(draft, LIMITS, { market, convention: ust })
return (
<tr key={label} className="border-t border-border align-top">
<th scope="row" className="py-1 pr-3 text-left font-normal">{label}</th>
<td className="py-1">
{problems.length === 0 ? (
<span className="text-muted-foreground">within the limits</span>
) : (
<ul className="space-y-0.5">
{problems.map((p) => (
<li key={p.rule + p.field} data-problem-level={p.level}>
<span className={p.level === "block" ? "text-destructive" : "text-stale"}>{p.level}</span> {p.message}
</li>
))}
</ul>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/limits#v2.0.0
npx shadcn@latest add tradecn/ui/limits#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/limits#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/limits#v2.0.0
-
Copy the files into your project:
lib/limits.ts import { formatQuantity, formatTicks, quoteBasisOf, ticksBetween, type InstrumentConvention } from "@/lib/format" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // Fat-finger checks are universal: a size above a line, a level too far from the market, a side the // book may not take. The tickets already stop a missing level and a crossed market; this gives them a // limits table, as data, and returns problems in the shape they already print. A `block` stops the // action; a `confirm` turns it into a two-step, the blotter's ask-again. Nothing here decides a // limit: the numbers are the desk's, the words are the consumer's, and the check runs where the // click lands, against the draft as it is then. export type ProblemLevel = "block" | "confirm" export interface Problem { /** The field it is about: "quantity", "price", "bid", "ask", or "side". */ field: string level: ProblemLevel /** A sentence, in the consumer's words. */ message: string /** Which limit raised it: "maxQuantity", "minQuantity", "maxDistance", "sides", or a custom rule's own name. */ rule: string } /** What the check reads of a draft: a ticket's side, quantity, and price, or a quote's bid and ask. */ export interface LimitsDraft { side?: "buy" | "sell" quantity?: number | null price?: number | null bid?: number | null ask?: number | null } export interface LimitsMarket { bid?: number | null ask?: number | null last?: number | null mid?: number | null } export interface LimitsLabels { /** `{n}` the quantity, `{max}` the limit. */ quantityAbove: string quantityAboveConfirm: string /** `{n}` the quantity, `{min}` the limit. */ quantityBelow: string quantityBelowConfirm: string /** `{field}` the level's field, `{distance}` how far, `{max}` the limit, both with their unit. */ tooFar: string tooFarConfirm: string /** `{side}` the side. */ sideNotAllowed: string buy: string sell: string /** The level's field in `{field}`: a ticket's price, a two-sided quote's bid and ask. */ price: string bid: string ask: string /** The unit after a distance in ticks: a word, or a function of the count, for a plural a word can't hold. */ ticks: string | ((n: number) => string) /** The unit after a distance in basis points: a word, or a function of the count. */ bps: string | ((n: number) => string) } export const DEFAULT_LIMITS_LABELS: LimitsLabels = { quantityAbove: "{n} is above the size limit of {max}.", quantityAboveConfirm: "{n} is above {max}. Send it anyway?", quantityBelow: "{n} is below the minimum of {min}.", quantityBelowConfirm: "{n} is below {min}. Send it anyway?", tooFar: "The {field} is {distance} from the market; the limit is {max}.", tooFarConfirm: "The {field} is {distance} from the market, past {max}. Send it anyway?", sideNotAllowed: "The book does not take a {side}.", buy: "buy", sell: "sell", price: "price", bid: "bid", ask: "ask", ticks: (n) => (n === 1 ? "tick" : "ticks"), bps: "bp", } export interface LimitsContext { market?: LimitsMarket convention?: InstrumentConvention labels?: Partial<LimitsLabels> } /** A line, or two: a size to ask again past, and a size to stop at. One number is a stop. */ export type Threshold = number | { confirm?: number; block?: number } export interface Limits { maxQuantity?: Threshold minQuantity?: Threshold /** How far a level may sit from the market's same side (the mid when there is none), in ticks for a price basis or basis points for the rest. */ maxDistance?: ({ ticks: number } | { bps: number }) & { level?: ProblemLevel } /** The sides the book may take. A ticket's side, or, for a quote, a bid is a buy and an offer a sell. */ sides?: readonly ("buy" | "sell")[] /** Your own rules, in the same shape. */ custom?: (draft: LimitsDraft, context: LimitsContext) => Problem[] } function fill(template: string, values: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, name: string) => String(values[name] ?? "")) } function thresholds(t: Threshold | undefined): { confirm?: number; block?: number } { if (t === undefined) return {} return typeof t === "number" ? { block: t } : t } /** The market's side a level is measured against: the same side as the level, else the mid, else the last. */ export function marketSideFor(field: "price" | "bid" | "ask", side: "buy" | "sell" | undefined, market: LimitsMarket | undefined): number | null { if (!market) return null const pick = (...values: (number | null | undefined)[]) => values.find((v): v is number => typeof v === "number" && Number.isFinite(v)) ?? null const mid = pick(market.mid) ?? (typeof market.bid === "number" && typeof market.ask === "number" ? (market.bid + market.ask) / 2 : null) if (field === "bid") return pick(market.bid, mid, market.last) if (field === "ask") return pick(market.ask, mid, market.last) // A price on a ticket: a buyer measures against the offer, a seller against the bid. if (side === "buy") return pick(market.ask, mid, market.last) if (side === "sell") return pick(market.bid, mid, market.last) return pick(mid, market.last) } /** How far a level is from a market level in the convention's unit: ticks for a price basis, basis points for the rest. */ export function distanceFromMarket(level: number, market: number, convention: InstrumentConvention | undefined): { value: number; unit: "ticks" | "bps" } { if (!convention || quoteBasisOf(convention) === "price") return { value: Math.abs(ticksBetween(level, market, convention?.tick ?? 1)), unit: "ticks" } // A yield or a discount is quoted in percent, so a hundredth of a point is a basis point; a spread is quoted in basis points already. const perPoint = quoteBasisOf(convention) === "spread" ? 1 : 100 // To a thousandth of a basis point, so 4.30 against 4.25 is 5 and not 4.999999999999982. return { value: Math.round(Math.abs(level - market) * perPoint * 1000) / 1000, unit: "bps" } } /** * What the limits say about a draft, in the tickets' shape, each with its level and a sentence. Empty * when nothing does. Quantity against the lines, each level against the market in the instrument's * unit, the side against the sides the book takes, then your own rules. */ export function checkLimits(draft: LimitsDraft, limits: Limits | undefined, context: LimitsContext = {}): Problem[] { if (!limits) return [] const labels = { ...DEFAULT_LIMITS_LABELS, ...defined(context.labels) } const problems: Problem[] = [] const quantity = draft.quantity // A fractional size prints exactly, or the prompt would round the trader's own number and // the limit into a lie such as "10 is above the size limit of 10". const size = (v: number) => (Number.isInteger(v) ? formatQuantity(v) : String(v).replace(/-/g, "−")) if (typeof quantity === "number" && Number.isFinite(quantity)) { const max = thresholds(limits.maxQuantity) if (max.block !== undefined && quantity > max.block) problems.push({ field: "quantity", level: "block", rule: "maxQuantity", message: fill(labels.quantityAbove, { n: size(quantity), max: size(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: size(quantity), max: size(max.confirm) }) }) const min = thresholds(limits.minQuantity) if (min.block !== undefined && quantity < min.block) problems.push({ field: "quantity", level: "block", rule: "minQuantity", message: fill(labels.quantityBelow, { n: size(quantity), min: size(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: size(quantity), min: size(min.confirm) }) }) } if (limits.maxDistance) { const rule = limits.maxDistance const level = rule.level ?? "block" const max = "ticks" in rule ? { value: rule.ticks, unit: "ticks" as const } : { value: rule.bps, unit: "bps" as const } for (const field of ["price", "bid", "ask"] as const) { const value = draft[field] if (typeof value !== "number" || !Number.isFinite(value)) continue const against = marketSideFor(field, draft.side, context.market) if (against === null) continue const distance = distanceFromMarket(value, against, context.convention) if (distance.unit !== max.unit || !(distance.value > max.value)) continue const unit = (n: number) => { const word = labels[max.unit] return typeof word === "function" ? word(n) : word } const words = { field: labels[field], distance: `${formatTicks(distance.value, { signed: false })} ${unit(distance.value)}`, max: `${formatTicks(max.value, { signed: false })} ${unit(max.value)}` } problems.push({ field, level, rule: "maxDistance", message: fill(level === "block" ? labels.tooFar : labels.tooFarConfirm, words) }) } } if (limits.sides) { const taken = new Set(limits.sides) const refuse = (side: "buy" | "sell", field: string) => problems.push({ field, level: "block", rule: "sides", message: fill(labels.sideNotAllowed, { side: labels[side] }) }) if (draft.side && !taken.has(draft.side)) refuse(draft.side, "side") if (typeof draft.bid === "number" && !taken.has("buy")) refuse("buy", "bid") if (typeof draft.ask === "number" && !taken.has("sell")) refuse("sell", "ask") } if (limits.custom) problems.push(...limits.custom(draft, context)) return problems } /** The problems that stop the action. */ export function blocks(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "block") } /** The problems that ask again. */ export function confirms(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "confirm") } /** The first message per field, for printing under the fields the way the tickets do. */ export function problemsByField(problems: readonly Problem[]): Record<string, string> { const messages = new Map<string, string>() for (const p of problems) if (!messages.has(p.field)) messages.set(p.field, p.message) return Object.fromEntries(messages) }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = stripGrouping(s, { decimals: printsDecimals(c) }) if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, so digits and the dash take one width; tails still lengthen a quote. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * Every other price, and every quote in another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } .dark { --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { checkLimits, type Limits } from "@/lib/limits"
import type { InstrumentConvention } from "@/lib/format"
const convention: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const limits: Limits = {
maxQuantity: { confirm: 10_000_000, block: 50_000_000 },
minQuantity: 1_000_000,
maxDistance: { ticks: 4 },
sides: ["buy", "sell"],
}
checkLimits({ side: "buy", quantity: 20_000_000, price: 99.625 }, limits, { market: { bid: 99.5, ask: 99.515625 }, convention })
// [{ field: "quantity", level: "confirm", rule: "maxQuantity", message: "20,000,000 is above 10,000,000. Send it anyway?" },
// { field: "price", level: "block", rule: "maxDistance", message: "The price is 7 ticks from the market; the limit is 4 ticks." }]
The same draft can produce both a confirm and a block. The preview checks five fixed drafts: within limits, above each quantity threshold, too far from the market, and below the minimum quantity. Both buy and sell are allowed. See Block and confirm for Ticket and RfqTicket integration.
API Reference
A limits table
Every Limits field is optional. Omitted rules do nothing.
| Field | Type | Behavior |
|---|---|---|
maxQuantity |
Threshold |
Checks whether quantity exceeds a threshold. |
minQuantity |
Threshold |
Checks whether quantity falls below a threshold. |
maxDistance |
({ ticks: number } | { bps: number }) & { level?: ProblemLevel } |
Checks each supplied price, bid, and ask against the market. Defaults to level: "block". |
sides |
readonly ("buy" | "sell")[] |
Blocks a disallowed ticket side, bid (buy), or ask (sell). An empty array allows neither side. |
custom |
(draft: LimitsDraft, context: LimitsContext) => Problem[] |
Appends your problems after the built-in checks. Receives the original draft and context, including any partial labels. |
Threshold accepts these forms. Quantity and thresholds use the same units; there is no conversion to thousands or millions. Sizes are positive magnitudes — the side rides separately, and a negative quantity is never above a maximum. A fractional size prints exactly in the messages; whole sizes print with digit grouping.
| Form | Result when crossed |
|---|---|
number |
A block. |
{ confirm: number } |
A confirm. |
{ block: number } |
A block. |
{ confirm: number, block: number } |
A block if its threshold is crossed; otherwise a confirm if its threshold is crossed. |
{} |
No check. |
Comparisons are strict: > for a maximum and < for a minimum. Equality does not cross that threshold, though another threshold may still apply. Each quantity rule returns at most one problem, with block taking precedence over confirm within that rule. maxQuantity and minQuantity are checked independently.
Draft and context
checkLimits(draft, limits, context?) accepts limits: Limits | undefined and defaults context to {}. Undefined limits return [].
LimitsDraft field |
Type | Use |
|---|---|---|
side |
"buy" | "sell" |
Optional ticket side; also selects the market side for price. |
quantity |
number | null |
Optional size. |
price |
number | null |
Optional ticket level. |
bid, ask |
number | null |
Optional quote levels, each checked separately. |
LimitsContext field |
Type | Default | Use |
|---|---|---|---|
market |
LimitsMarket |
Unset | Optional bid, ask, mid, and last, each number | null. Without a usable reference, distance checks are skipped. |
convention |
InstrumentConvention |
Price basis, tick 1 |
Sets the distance unit and tick size through format. |
labels |
Partial<LimitsLabels> |
DEFAULT_LIMITS_LABELS |
Overrides built-in messages, side names, and units. |
The install writes the shared font tokens and the hyperlegible remap into the stylesheet where missing, because the bundled format source references them. Quantity and distance checks skip missing, null, and non-finite values. A partial draft can still have problems: sides checks a supplied side before any number is entered, and checks any numeric bid or ask, including NaN and infinities. custom runs even on an empty draft. With neither of those rules producing a problem, a blank draft returns []; required-field validation belongs to the caller.
What comes back
checkLimits returns Problem[], empty when no rule produces a problem.
Problem field |
Type | Meaning |
|---|---|---|
field |
string |
Built-in rules use quantity, price, bid, ask, or side; custom rules can name other fields. |
level |
ProblemLevel ("block" | "confirm") |
Whether to stop or ask again. |
message |
string |
The sentence to display. |
rule |
string |
maxQuantity, minQuantity, maxDistance, sides, or your custom rule's name. |
Problems stay in check order: maximum quantity, minimum quantity, distance for price/bid/ask, allowed sides for side/bid/ask, then custom problems. A block does not discard confirms from other rules.
| Helper | Returns | Behavior |
|---|---|---|
blocks(problems) |
Problem[] |
Keeps only block problems, in their original order. |
confirms(problems) |
Problem[] |
Keeps only confirm problems, in their original order. |
problemsByField(problems) |
Record<string, string> |
Keeps the first message for each field, regardless of level. Pass blocks(problems) to collect blocking messages. |
All three helpers accept readonly Problem[].
Custom field names, including constructor, toString, and __proto__, become own enumerable properties of the returned object. An empty first message is kept too.
Distance from the market
marketSideFor(field, side, market) returns the first usable reference as number | null. field is "price" | "bid" | "ask"; side and market may be undefined.
| Draft field | First choice | Fallback |
|---|---|---|
bid |
Market bid |
Mid, then last. |
ask |
Market ask |
Mid, then last. |
price, side buy |
Market ask |
Mid, then last. |
price, side sell |
Market bid |
Mid, then last. |
price, no side |
Mid | last. |
Only finite references are usable. The mid is a finite explicit market.mid, or otherwise the average of numeric bid and ask. An unusable average is skipped. A missing market or no usable reference returns null; the check skips that field. It does not fall back directly to the opposite side.
distanceFromMarket(level, market, convention) takes two numbers and an InstrumentConvention | undefined, returning { value: number, unit: "ticks" | "bps" }. Distance is absolute, so levels on either side of the reference can exceed a limit.
| Quote basis | Calculation | Unit |
|---|---|---|
price (also the default basis) |
Difference divided by convention.tick, rounded to the nearest eighth of a tick, then made absolute. Without a convention, tick size is 1. |
ticks |
yield, discount |
Absolute difference × 100, rounded to 0.001 basis points. Inputs are percentage points: 4.30 against 4.25 gives 5. |
bps |
spread |
Absolute difference, rounded to 0.001 basis points. Inputs are already basis points: 203 against 200 gives 3. |
bps |
quoteStep and quoteDecimals do not affect this helper. For price quotes, a nonpositive tick produces NaN, which does not trigger a distance problem.
maxDistance produces a problem only when the calculated distance is strictly greater than the limit and the units match. A ticks rule on a yield, discount, or spread convention is skipped; a bps rule on a price convention is skipped too.
Block and confirm
ticket and rfq-ticket take a limits prop and check it live and again when an action is invoked, alongside their own draft validation. Any block stops a checked action before confirmation, regardless of its field name.
Blocks appear under quantity/price in Ticket or bid/ask in RfqTicket; problems for other fields appear on the limits line. Confirm reasons reach the limits line only once an activation reaches the confirm step, whichever field they concern.
A confirm changes the invoked action's label to ask again. A second click on the same action sends if validation and limits still permit it. Editing the draft clears confirmation; changes to market or limits props alone do not. Every click checks those current props again.
| Component | Draft passed to limits | Actions that bypass checks |
|---|---|---|
Ticket |
side, quantity, and price; price is null for an order type that does not require it. |
checked: false |
RfqTicket |
Only the bid and/or ask requested by the inquiry; unrequested sides are null. Quantity is not passed, so quantity limits do not apply. |
needsQuote: false |
Labels
Pass context.labels to checkLimits to override any string in DEFAULT_LIMITS_LABELS; a word given as undefined keeps its default:
| Label | Default |
|---|---|
quantityAbove |
{n} is above the size limit of {max}. |
quantityAboveConfirm |
{n} is above {max}. Send it anyway? |
quantityBelow |
{n} is below the minimum of {min}. |
quantityBelowConfirm |
{n} is below {min}. Send it anyway? |
tooFar |
The {field} is {distance} from the market; the limit is {max}. |
tooFarConfirm |
The {field} is {distance} from the market, past {max}. Send it anyway? |
sideNotAllowed |
The book does not take a {side}. |
buy, sell |
buy, sell |
price, bid, ask |
price, bid, ask |
ticks, bps |
(n) => n === 1 ? "tick" : "ticks", bp |
Quantity templates receive {n} and {max} or {min} — whole sizes print with digit grouping through formatQuantity, fractional sizes exactly. Distance templates receive {field}, the price, bid, or ask label for the level, plus {distance} and {max} formatted by formatTicks without a positive sign, each with its unit appended: ticks and bps are each a word, or a function of the count for a plural a word can't hold, so the default says 1 tick. {side} uses the buy or sell label.
Custom messages are returned unchanged. ticket, rfq-ticket, and quote-panel pass their limitsLabels prop as these labels; RfqTicket's own default calls the ask the offer, as its field does.
What it does not do
It holds no desk's numbers and calls no server. A limit the server enforces is still the server's; this is the check before the click leaves the screen.
This page is docs/limits.md at v2.0.0.