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#v1.4.13
npx shadcn@latest add tradecn/ui/limits#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/limits#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/limits#v1.4.13
-
Copy the files into your project:
lib/limits.ts import { formatQuantity, formatTicks, quoteBasisOf, ticksBetween, type InstrumentConvention } from "@/lib/format" // 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 ticks: string bps: 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", ticks: "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, ...context.labels } const problems: Problem[] = [] const quantity = draft.quantity 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: formatQuantity(quantity), max: formatQuantity(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: formatQuantity(quantity), max: formatQuantity(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: formatQuantity(quantity), min: formatQuantity(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: formatQuantity(quantity), min: formatQuantity(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 = labels[max.unit] const words = { field, distance: `${formatTicks(distance.value, { signed: false })} ${unit}`, max: `${formatTicks(max.value, { signed: false })} ${unit}` } 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 out: Record<string, string> = {} for (const p of problems) if (!(p.field in out)) out[p.field] = p.message return out }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 { --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.
| 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. |
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, with the custom-name exception below. Pass blocks(problems) to collect blocking messages. |
All three helpers accept readonly Problem[].
Use custom field names that are not inherited from Object.prototype. problemsByField omits names such as constructor, toString, and __proto__; both tickets use that field map to stop actions, so a custom block on one of those names cannot stop an action by itself.
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. A block in the problemsByField map stops a checked action before confirmation. Blocks appear under quantity/price in Ticket or bid/ask in RfqTicket; problems for other fields appear on the limits line.
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:
| 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 |
ticks, bps |
ticks, bp |
Quantity templates receive {n} and {max} or {min}, formatted by formatQuantity. Distance templates receive the raw field name (price, bid, or ask), plus {distance} and {max} formatted by formatTicks without a positive sign and with the translated unit appended. {side} uses the buy or sell label.
Custom messages are returned unchanged. Ticket and RfqTicket do not pass labels into checkLimits; their own labels props control ticket text, not these templates.
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 v1.4.13.