From 28f8de695458f015405f8f4d292f7e686e4968d6 Mon Sep 17 00:00:00 2001 From: thomas-chabert Date: Mon, 31 Aug 2026 11:02:26 +0200 Subject: [PATCH 1/5] feat(quoter-bot): make playground previews readable Amounts render in whole loan-asset units from one decimals box, defaulting to 6 for USDC and falling back to exact raw integers when cleared. A quoter process has a single LOAN_ASSET_ADDRESS, so one scale covers both collections; the exact raw integer stays on hover. Each entry now reports the reference band it tolerates, swept from the config through the runtime's own generateLadderWithDiagnostics, with a response strip under each ladder plot. This answers what the deterministic preview cannot: the synthetic reference always lands mid-bounds, which for a ladder spanning its full half-range is the single rate at which no rung pins. Tiles reuse the editor field labels and print their config keys. Two labels contradicted their own help text and were corrected: offerSize was "Pending-offer cap" though it bounds one offer, and acceptanceAssets was "Completion threshold" though it is the shortfall. Bootstrap plot markers are labelled on the plot, and the ladder centre marker matches bootstrap's "Quote". Exports, the fragment codec, import parsing and validation are untouched: a differential run against main confirms all four outputs, the share URL and the fragment are byte-identical. autoRefill now defaults to true, the one deliberate change to emitted config. Co-Authored-By: Claude Opus 5 --- bots/quoter-bot/docs/reference.md | 14 + bots/quoter-bot/playground/app.tsx | 183 ++++++++-- .../playground/asset-format.utils.ts | 60 ++++ bots/quoter-bot/playground/model.ts | 327 +++++++++++++----- .../playground/reference-response.utils.ts | 142 ++++++++ bots/quoter-bot/playground/styles.css | 96 +++++ bots/quoter-bot/scripts/playground-smoke.mjs | 2 +- .../test/playground/artifact.test.ts | 2 +- .../playground/asset-format.utils.test.ts | 87 +++++ bots/quoter-bot/test/playground/model.test.ts | 23 +- .../test/playground/module-graph.test.ts | 2 + .../reference-response.utils.test.ts | 113 ++++++ 12 files changed, 926 insertions(+), 125 deletions(-) create mode 100644 bots/quoter-bot/playground/asset-format.utils.ts create mode 100644 bots/quoter-bot/playground/reference-response.utils.ts create mode 100644 bots/quoter-bot/test/playground/asset-format.utils.test.ts create mode 100644 bots/quoter-bot/test/playground/reference-response.utils.test.ts diff --git a/bots/quoter-bot/docs/reference.md b/bots/quoter-bot/docs/reference.md index 78fa651d..f2341cd0 100644 --- a/bots/quoter-bot/docs/reference.md +++ b/bots/quoter-bot/docs/reference.md @@ -956,6 +956,20 @@ imports secret, provider, logging, or observability modules. It does not read cu positions, or a live market book; use storage, cookies, a backend, or network requests; or model runtime capacity. +A display-units panel at the top of the page carries one token-decimals entry for the configured +loan asset. One entry is sufficient and correct: a process has a single `LOAN_ASSET_ADDRESS` shared +by every configured market, and every configured amount — credit targets, offer sizes, budgets, and +exposure caps in both collections — is a raw smallest-unit amount of that one asset, so collateral +decimals never apply. The entry starts at 6 as a convenience for USDC, the loan asset in practice; it is not +resolved from chain data, so it must be corrected for any other loan asset, and clearing it returns +every amount to its exact raw integer. A supplied entry renders raw asset and credit amounts as whole token units across the previews, +callouts, rung tables, and accessible descriptions, rounding the fractional units away so magnitudes +stay scannable; a non-zero amount below one unit renders as `<1` rather than zero, and hovering any +amount reveals its exact raw integer. The entry is display state only: it is never exported, never enters the URL +fragment, and leaves the editors and the four collection outputs on exact raw integers. An entry +that is not a whole number of at most 36 decimals is marked invalid and leaves amounts raw rather +than showing a misleading amount. + The URL fragment is a strict, bounded, versioned JSON payload containing only `version`, `bootstrap`, and `ladder`. Valid edits synchronize with `history.replaceState`; invalid edits leave the last valid URL untouched. The copied URL reproduces collection order, configuration, and graphics on a fresh page, diff --git a/bots/quoter-bot/playground/app.tsx b/bots/quoter-bot/playground/app.tsx index de80b978..50478bea 100644 --- a/bots/quoter-bot/playground/app.tsx +++ b/bots/quoter-bot/playground/app.tsx @@ -7,11 +7,12 @@ import { getCoreRowModel, useReactTable } from '@tanstack/react-table' -import React, { Component, useEffect, useRef, useState } from 'react' +import React, { Component, useEffect, useMemo, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import type { FieldDefinition } from './field-visibility.utils' import type { + AssetFormatter, BootstrapGraphicModel, BootstrapInput, LadderGraphicModel, @@ -19,6 +20,12 @@ import type { PlaygroundState } from './model' +import { + DEFAULT_ASSET_DECIMALS, + MAXIMUM_ASSET_DECIMALS, + assetFormatter, + resolveDecimals +} from './asset-format.utils' import { CollectionImportError } from './collection-import.error' import { maturityPremiumSelection, @@ -91,28 +98,46 @@ const initial = () => { } const columnHelper = createColumnHelper() -const rungColumns = [ +/** Renders one display amount while keeping its exact raw integer reachable on hover. */ +const amountCell = (rawAmount: string, display: string) => ( + + {display} + +) +const rungColumnsFor = (format: AssetFormatter) => [ columnHelper.accessor('sideLabel', { header: 'Side', cell: info => info.getValue() }), columnHelper.accessor('rateBps', { header: 'Rate (BPS)', cell: info => info.getValue() }), columnHelper.accessor('allocationAssets', { - header: 'Allocation (assets)', - cell: info => info.getValue() + header: 'Allocation', + cell: info => amountCell(info.getValue(), format(info.getValue())) }), columnHelper.accessor('offerMaxAssets', { - header: 'Offer maxAssets (assets)', - cell: info => info.getValue() + header: 'Offer maxAssets', + cell: info => amountCell(info.getValue(), format(info.getValue())) }) ] -const RungTable = ({ graphic, index }: { graphic: LadderGraphicModel; index: number }) => { +const RungTable = ({ + format, + graphic, + index +}: { + format: AssetFormatter + graphic: LadderGraphicModel + index: number +}) => { + const columns = useMemo(() => rungColumnsFor(format), [format]) const table = useReactTable({ data: graphic.rungs, - columns: rungColumns, + columns, getCoreRowModel: getCoreRowModel() }) return ( - + {table.getHeaderGroups().map(group => ( @@ -138,9 +163,11 @@ const RungTable = ({ graphic, index }: { graphic: LadderGraphicModel; index: num } const BootstrapGraphic = ({ + format, graphic, index }: { + format: AssetFormatter graphic: BootstrapGraphicModel index: number }) => { @@ -153,7 +180,7 @@ const BootstrapGraphic = ({ graphic.maximumQuotedRateBps === undefined ? `quote ${graphic.quotedRateBps} BPS` : `quote range ${graphic.quotedRateBps} to ${graphic.maximumQuotedRateBps} BPS across maturities` - const description = `${title}, market ${graphic.marketId}. Configured range ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS. Deterministic reference ${graphic.referenceRateBps} BPS produces ${quoteText}. Credit target ${graphic.creditTarget}, completion threshold ${graphic.acceptedCredit}, pending-offer cap ${graphic.offerSize}. ${graphic.callouts.map(item => `${item.label}: ${item.value}.`).join(' ')} Explicitly no live offers or balances.` + const description = `${title}, market ${graphic.marketId}. Configured range ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS. Deterministic reference ${graphic.referenceRateBps} BPS produces ${quoteText}. Credit target ${format(graphic.creditTarget)}, completion threshold ${format(graphic.acceptedCredit)}, pending-offer cap ${format(graphic.offerSize)}. ${graphic.callouts.map(item => `${item.label}: ${item.value}.`).join(' ')} Explicitly no live offers or balances.` return (

{title}

@@ -165,27 +192,30 @@ const BootstrapGraphic = ({ - + > + Reference {graphic.referenceRateBps} BPS + + + Quote {graphic.quotedRateBps} BPS + {graphic.maximumQuotedRateBps === undefined ? null : ( + > + Far maturity {graphic.maximumQuotedRateBps} BPS + )} -
- Reference {graphic.referenceRateBps} BPS ◆ Quote {graphic.quotedRateBps} - {graphic.maximumQuotedRateBps === undefined - ? '' - : `–${graphic.maximumQuotedRateBps}`} BPS - ● -
+
◆ Reference · ● Quote · values are also in the tiles below
{graphic.callouts.map(item => (
-
{item.label}
+
+ {item.label} + {item.parameters.length === 0 ? null : {item.parameters.join(' · ')}} +
{item.value}
))} @@ -194,12 +224,46 @@ const BootstrapGraphic = ({ ) } -const LadderGraphic = ({ graphic, index }: { graphic: LadderGraphicModel; index: number }) => { +const ReferenceStrip = ({ graphic, index }: { graphic: LadderGraphicModel; index: number }) => { + const { band, strip, totalRungs } = graphic.referenceResponse + const description = `Reference response for ladder market ${index + 1}. Across references ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS, ${ + band === undefined + ? 'every reference pins at least one rung to a hard bound' + : `references ${band.lowestRateBps} to ${band.highestRateBps} BPS pin no rung` + }. Taller bars pin more of the ${totalRungs} rungs.` + return ( +
+ +
+ Rungs pinned to a bound by reference · {graphic.minimumRateBps}–{graphic.maximumRateBps} BPS +
+
+ ) +} + +const LadderGraphic = ({ + format, + graphic, + index +}: { + format: AssetFormatter + graphic: LadderGraphicModel + index: number +}) => { const maturityText = graphic.maximumCenterRateBps === undefined ? '' - : ` The maturity premium raises the center to ${graphic.maximumCenterRateBps} BPS at far maturities.` - const description = `Ladder market ${index + 1}, ${graphic.marketId}. Range ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS. Deterministic reference ${graphic.referenceRateBps} BPS and center ${graphic.centerRateBps} BPS.${maturityText} Triangle markers are lend rungs and circle markers are reduce-only rungs. Exact allocations and caps are in the semantic table. No live offers, balances, positions, or book.` + : ` The maturity premium raises the quote to ${graphic.maximumCenterRateBps} BPS at far maturities.` + const description = `Ladder market ${index + 1}, ${graphic.marketId}. Range ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS. Deterministic reference ${graphic.referenceRateBps} BPS and quote ${graphic.centerRateBps} BPS.${maturityText} Triangle markers are lend rungs and circle markers are reduce-only rungs. Exact allocations and caps are in the semantic table. No live offers, balances, positions, or book.` return (

Ladder market {index + 1}

@@ -213,7 +277,7 @@ const LadderGraphic = ({ graphic, index }: { graphic: LadderGraphicModel; index: key={`${rung.side}-${rung.index}-${rungIndex}`} className={`rung rung--${rung.side}`} style={{ top: `${rung.y}%` }} - title={`${rung.sideLabel}: ${rung.rateBps} BPS, allocation ${rung.allocationAssets}, cap ${rung.offerMaxAssets}`} + title={`${rung.sideLabel}: ${rung.rateBps} BPS, allocation ${format(rung.allocationAssets)}, cap ${format(rung.offerMaxAssets)}`} > {rung.side === 'higher' ? '▲' : '●'} {rung.rateBps}
@@ -228,7 +292,7 @@ const LadderGraphic = ({ graphic, index }: { graphic: LadderGraphicModel; index: className="ladder-marker ladder-center-marker" style={{ top: `${clampPlotPercent(graphic.rateToY(graphic.centerRateBps))}%` }} > - Center {graphic.centerRateBps} BPS + Quote {graphic.centerRateBps} BPS {graphic.maximumCenterRateBps === undefined ? null : ( - Far-maturity center {graphic.maximumCenterRateBps} BPS + Far-maturity quote {graphic.maximumCenterRateBps} BPS )}
▲ Lend · ● Reduce-only · values are also available in the table
- + +
{graphic.callouts.map(item => (
-
{item.label}
+
+ {item.label} + {item.parameters.length === 0 ? null : {item.parameters.join(' · ')}} +
{item.value}
))} @@ -256,6 +324,54 @@ const LadderGraphic = ({ graphic, index }: { graphic: LadderGraphicModel; index: ) } +const DisplayUnits = ({ + decimals, + onChange +}: { + decimals: string + onChange: (value: string) => void +}) => ( +
+
+
+ Display units +

Loan asset decimals

+
+
+
+ +

+ One scale covers both collections: a quoter-bot process has a single{' '} + LOAN_ASSET_ADDRESS, and every configured amount — credit targets, offer sizes, + budgets, and exposure caps — is a raw smallest-unit amount of that one loan asset. + Collateral tokens never appear in a market collection, so their decimals are irrelevant + here. Display only. It starts at 6 for USDC as a convenience, not because the playground + resolved it — no chain data is read, so correct it for any other loan asset, and clear it to + return every amount to its exact raw integer. The scale rounds amounts to whole units so + magnitudes stay scannable; hover any amount to read its exact raw value. The editors, the + four outputs, and the share URL always keep the exact raw integers. +

+
+
+) + const InvalidPreview = ({ kind, errors }: { kind: CollectionKind; errors: string[] }) => (
{kind === 'bootstrap' ? 'Bootstrap' : 'Ladder'} preview unavailable @@ -362,6 +478,8 @@ const Playground = () => { status: initialValue.error ? 'error' : undefined }) const [copyStatus, setCopyStatus] = useState({ message: '' }) + const [decimals, setDecimals] = useState(DEFAULT_ASSET_DECIMALS) + const format = useMemo(() => assetFormatter(decimals), [decimals]) const [unexpectedFailure, setUnexpectedFailure] = useState<{ error: unknown }>() const [activeExport, setActiveExport] = useState('bootstrap-json') const outputRefs = useRef>({ @@ -419,14 +537,14 @@ const Playground = () => { let ladderErrors = [...ladderValidation.errors] if (bootstrapValidation.valid) { try { - bootstrapGraphics = deriveBootstrapGraphicModels(state.bootstrap) + bootstrapGraphics = deriveBootstrapGraphicModels(state.bootstrap, format) } catch (error) { bootstrapErrors = [playgroundErrorMessage(error)] } } if (ladderValidation.valid) { try { - ladderGraphics = generateLadderGraphicModels(state.ladder) + ladderGraphics = generateLadderGraphicModels(state.ladder, format) } catch (error) { ladderErrors = [playgroundErrorMessage(error)] } @@ -675,6 +793,7 @@ const Playground = () => {

+
@@ -689,6 +808,7 @@ const Playground = () => { bootstrapGraphics.map((graphic, index) => ( @@ -704,6 +824,7 @@ const Playground = () => { ladderGraphics.map((graphic, index) => ( diff --git a/bots/quoter-bot/playground/asset-format.utils.ts b/bots/quoter-bot/playground/asset-format.utils.ts new file mode 100644 index 00000000..73b149b4 --- /dev/null +++ b/bots/quoter-bot/playground/asset-format.utils.ts @@ -0,0 +1,60 @@ +/** + * Decimals the display-units panel starts from. + * @remarks A convenience default, not derived data: the playground reads no chain data and cannot + * resolve the configured `loanAsset`, but that asset is USDC in practice, so the panel starts at + * its 6 decimals. Correct it for any other loan asset; clearing the entry returns every amount to + * its exact raw integer. + */ +export const DEFAULT_ASSET_DECIMALS = '6' +/** Inclusive upper bound accepted for the display-decimals entry. */ +export const MAXIMUM_ASSET_DECIMALS = 36 + +/** + * Formats one raw integer amount as a whole-token-unit amount for reading. + * @param rawAmount - Raw unsigned integer asset or credit amount as configured. + * @param decimals - Non-negative token decimals applied as the fixed-point scale. + * @returns A grouped whole-unit amount, `<1` for a non-zero amount below one unit, or the input + * when it is not an integer. + * @remarks Deliberately lossy: fractional units are rounded away because the rendering exists only + * to make magnitudes scannable and never feeds configuration. The exact raw integer stays in the + * editors, the four collection outputs, the share URL, and each amount's hover title. + */ +export const formatAssetAmount = (rawAmount: string, decimals: number): string => { + if (!/^-?\d+$/.test(rawAmount) || !Number.isInteger(decimals) || decimals < 0) return rawAmount + const negative = rawAmount.startsWith('-') + const value = BigInt(negative ? rawAmount.slice(1) : rawAmount) + const scale = 10n ** BigInt(decimals) + const whole = (value + scale / 2n) / scale + const sign = negative ? '-' : '' + if (whole === 0n && value > 0n) return `${sign}<1` + return `${sign}${whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}` +} + +/** + * Resolves the decimals entry typed into the display-units panel. + * @param value - Raw text held by the panel input. + * @returns The bounded non-negative decimals, or `undefined` when the entry is empty or unusable. + * @remarks An unresolved entry always means raw amounts, never an assumed scale. + */ +export const resolveDecimals = (value: string): number | undefined => { + const trimmed = value.trim() + if (!/^\d+$/.test(trimmed)) return undefined + const decimals = Number(trimmed) + return decimals > MAXIMUM_ASSET_DECIMALS ? undefined : decimals +} + +/** + * Builds the display formatter applied to every raw amount rendered in the previews. + * @param decimals - Current display-units panel entry for the configured loan asset. + * @returns A formatter rendering every amount in whole token units, leaving amounts raw while no + * usable entry exists so the panel can never fabricate a misleading amount. + * @remarks One scale covers both collections: a quoter-bot process has exactly one + * `LOAN_ASSET_ADDRESS`, and every configured amount — credit targets, offer sizes, budgets, and + * exposure caps in both collections — is a raw smallest-unit amount of that single loan asset. + * Collateral tokens never appear in an ordered market collection. + */ +export const assetFormatter = (decimals: string) => { + const resolved = resolveDecimals(decimals) + return (rawAmount: string): string => + resolved === undefined ? rawAmount : formatAssetAmount(rawAmount, resolved) +} diff --git a/bots/quoter-bot/playground/model.ts b/bots/quoter-bot/playground/model.ts index 83c92411..7ec0cf34 100644 --- a/bots/quoter-bot/playground/model.ts +++ b/bots/quoter-bot/playground/model.ts @@ -2,6 +2,7 @@ import type { BootstrapConfig } from '../src/domain/bootstrap/position-bootstrap import type { LadderConfig } from '../src/domain/ladder/ladder' import type { MaturityPremiumConfig } from '../src/domain/maturity-premium' import type { TargetRateConfigured } from '../src/domain/target-rate' +import type { LadderReferenceResponse, ReferenceBand } from './reference-response.utils' import { BOOTSTRAP_MARKET_FIELDS, @@ -17,6 +18,7 @@ import { CollectionImportError } from './collection-import.error' import { CollectionValidationError } from './collection-validation.error' import { FragmentCodecError } from './fragment-codec.error' import { PreviewGenerationError } from './preview-generation.error' +import { bootstrapReferenceBand, ladderReferenceResponse } from './reference-response.utils' import { StrictJsonError } from './strict-json.error' export type TargetRateInput = @@ -44,85 +46,175 @@ export type PlaygroundState = { ladder: LadderInput[] } +/** + * Formats one raw asset or credit amount for display. + * @param rawAmount - Exact raw integer amount as configured and exported. + * @returns The display amount; the identity formatter keeps the exact raw integer. + * @remarks One formatter serves every entry in both collections: each amount is a raw + * smallest-unit amount of the single configured `loanAsset` shared by all configured markets. + */ +export type AssetFormatter = (rawAmount: string) => string +const rawAssetFormatter: AssetFormatter = rawAmount => rawAmount + export const BOOTSTRAP_FIELDS = [ - ['marketId', 'Market ID', '0x-prefixed bytes32 market', 'text'], - ['targetRate.strategy', 'Target rate', 'Reference-rate strategy', 'target-rate-select'], + ['marketId', 'Market ID', '0x-prefixed 32-byte Midnight market id', 'text'], + [ + 'targetRate.strategy', + 'Target rate source', + '6-hour average supply APY on the reference Blue market, or a fixed rate you set', + 'target-rate-select' + ], [ 'targetRate.hardcodedRateBps', - 'Hardcoded target rate (BPS)', - 'Positive reference rate used by the hardcoded strategy', + 'Fixed target rate (BPS)', + 'Used instead of the market rate when the source is hardcoded', 'target-rate-number' ], - ['creditTarget', 'Credit target', 'Positive raw credit units', 'number'], - ['acceptanceAssets', 'Completion threshold', 'Allowed target shortfall', 'number'], - ['offerSize', 'Pending-offer cap', 'Maximum desired offer assets', 'number'], - ['premiumBps', 'Quote premium (BPS)', 'Zero or negative reference offset', 'number'], + ['creditTarget', 'Credit target', 'How much credit to build in this market', 'number'], + [ + 'acceptanceAssets', + 'Allowed shortfall', + 'Stop this far below the target; completion is target minus this', + 'number' + ], + [ + 'offerSize', + 'Maximum offer size', + 'Largest single offer; also capped by remaining target, cash and exposure', + 'number' + ], + [ + 'premiumBps', + 'Quote premium (BPS)', + 'Added to the market rate to get your quote; zero or negative', + 'number' + ], [ 'maturityPremium', 'Maturity premium', - 'Optional premium function of time to maturity', + 'Optional extra rate that grows with time left to maturity', 'maturity-premium-select' ], [ 'maturityPremium.premiumPerYearBps', - 'Premium slope (BPS/year)', - 'Positive premium per year to maturity', + 'Premium per year (BPS)', + 'Extra rate added per year left to maturity', 'maturity-premium-number' ], [ 'maturityPremium.maximumPremiumBps', 'Premium cap (BPS)', - 'Optional positive inclusive maturity-premium cap', + 'Optional ceiling on that extra rate', 'maturity-premium-number' ], - ['maximumMarketExposure', 'Market exposure cap', 'Positive raw assets', 'number'], - ['maximumTotalExposure', 'Total exposure cap', 'Positive raw assets', 'number'], - ['minimumRateBps', 'Minimum rate (BPS)', 'Inclusive quote floor', 'number'], - ['maximumRateBps', 'Maximum rate (BPS)', 'Inclusive quote ceiling', 'number'], - ['autoRefill', 'Auto-refill', 'Resume after observed completion', 'checkbox'] + ['maximumMarketExposure', 'Market exposure cap', 'Most this market may hold', 'number'], + [ + 'maximumTotalExposure', + 'Total exposure cap', + 'Most every configured market may hold together', + 'number' + ], + ['minimumRateBps', 'Minimum rate (BPS)', 'Quotes never go below this', 'number'], + ['maximumRateBps', 'Maximum rate (BPS)', 'Quotes never go above this', 'number'], + ['autoRefill', 'Auto-refill', 'Lend again if the position later falls below target', 'checkbox'] ] as const export const LADDER_FIELDS = [ - ['marketId', 'Market ID', '0x-prefixed bytes32 market', 'text'], - ['targetRate.strategy', 'Target rate', 'Reference-rate strategy', 'target-rate-select'], + ['marketId', 'Market ID', '0x-prefixed 32-byte Midnight market id', 'text'], + [ + 'targetRate.strategy', + 'Target rate source', + '6-hour average supply APY on the reference Blue market, or a fixed rate you set', + 'target-rate-select' + ], [ 'targetRate.hardcodedRateBps', - 'Hardcoded target rate (BPS)', - 'Positive reference rate used by the hardcoded strategy', + 'Fixed target rate (BPS)', + 'Used instead of the market rate when the source is hardcoded', 'target-rate-number' ], - ['quotePremiumBps', 'Quote premium (BPS)', 'Signed center offset', 'number'], + [ + 'quotePremiumBps', + 'Quote premium (BPS)', + 'Shifts the ladder centre off the market rate; may be negative', + 'number' + ], [ 'maturityPremium', 'Maturity premium', - 'Optional premium function of time to maturity', + 'Optional extra rate that grows with time left to maturity', 'maturity-premium-select' ], [ 'maturityPremium.premiumPerYearBps', - 'Premium slope (BPS/year)', - 'Positive premium per year to maturity', + 'Premium per year (BPS)', + 'Extra rate added per year left to maturity', 'maturity-premium-number' ], [ 'maturityPremium.maximumPremiumBps', 'Premium cap (BPS)', - 'Optional positive inclusive maturity-premium cap', + 'Optional ceiling on that extra rate', 'maturity-premium-number' ], - ['spreadBps', 'Full spread (BPS)', 'Positive even nearest-rung distance', 'number'], - ['stepBps', 'Step (BPS)', 'Positive same-side rung distance', 'number'], - ['rungCount', 'Rungs per side', '1–512', 'number'], - ['sizeSkewBps', 'Size skew (BPS)', 'Signed outer-rung weight change', 'number'], - ['lowerRateBudgetAssets', 'Lower-rate budget', 'Positive reduce-only budget', 'number'], - ['higherRateBudgetAssets', 'Higher-rate budget', 'Positive lend budget', 'number'], - ['targetMarketExposureAssets', 'Target market exposure', 'Positive market cap', 'number'], - ['maximumTotalExposureAssets', 'Maximum total exposure', 'Positive strategy cap', 'number'], - ['minimumOfferAssets', 'Minimum offer assets', 'Positive emitted-rung floor', 'number'], - ['groupMode', 'Group mode', 'shared-rung or per-book', 'select'], - ['loopIntervalSeconds', 'Cadence (seconds)', '1–2147483', 'number'], - ['movementToleranceBps', 'Movement tolerance (BPS)', 'Non-negative deadband', 'number'], - ['minimumRateBps', 'Minimum rate (BPS)', 'Inclusive hard floor', 'number'], - ['maximumRateBps', 'Maximum rate (BPS)', 'Inclusive hard ceiling', 'number'] + [ + 'spreadBps', + 'Full spread (BPS)', + 'Gap between the two rungs closest to the centre; must be even', + 'number' + ], + ['stepBps', 'Step (BPS)', 'Gap between neighbouring rungs on the same side', 'number'], + ['rungCount', 'Rungs per side', 'Rungs above and below the centre; 1 to 512', 'number'], + [ + 'sizeSkewBps', + 'Size skew (BPS)', + 'Positive sizes outer rungs bigger, negative sizes inner rungs bigger', + 'number' + ], + [ + 'lowerRateBudgetAssets', + 'Reduce-only budget', + 'For offers below the centre, which reduce an existing position', + 'number' + ], + [ + 'higherRateBudgetAssets', + 'Lending budget', + 'For offers above the centre, which lend new credit', + 'number' + ], + ['targetMarketExposureAssets', 'Market exposure cap', 'Most this market may hold', 'number'], + [ + 'maximumTotalExposureAssets', + 'Total exposure cap', + 'Most every configured market may hold together', + 'number' + ], + [ + 'minimumOfferAssets', + 'Minimum offer size', + 'Rungs smaller than this are dropped, funding fewer rungs', + 'number' + ], + [ + 'groupMode', + 'Fill sharing', + 'shared-rung: capacity per rung. per-book: one capacity per side', + 'select' + ], + [ + 'loopIntervalSeconds', + 'Check interval (seconds)', + 'How often to re-evaluate the ladder; 1 to 2147483', + 'number' + ], + [ + 'movementToleranceBps', + 'Movement tolerance (BPS)', + 'Ignore rate moves smaller than this', + 'number' + ], + ['minimumRateBps', 'Minimum rate (BPS)', 'Rungs never go below this', 'number'], + ['maximumRateBps', 'Maximum rate (BPS)', 'Rungs never go above this', 'number'] ] as const const DEFAULT_MARKET_ID = `0x${'5'.repeat(64)}` @@ -138,7 +230,7 @@ export const createDefaultBootstrap = (marketId = DEFAULT_MARKET_ID): BootstrapI maximumTotalExposure: '30000000000', minimumRateBps: '200', maximumRateBps: '800', - autoRefill: false + autoRefill: true }) export const createDefaultLadder = (marketId = DEFAULT_MARKET_ID): LadderInput => ({ @@ -258,14 +350,19 @@ export type BootstrapGraphicModel = { creditTarget: string acceptedCredit: string offerSize: string - callouts: { label: string; value: string }[] + /** Reference range over which the quote tracks instead of saturating at a bound. */ + referenceBand?: ReferenceBand + callouts: { label: string; value: string; parameters: string[] }[] } /** * Derives a synthetic reference whose premium-adjusted quote is the integer midpoint of the bounds; * a maturity premium additionally renders the clamped quote range reachable across maturities. */ -export const deriveBootstrapGraphicModels = (items: BootstrapInput[]): BootstrapGraphicModel[] => +export const deriveBootstrapGraphicModels = ( + items: BootstrapInput[], + formatAssets: AssetFormatter = rawAssetFormatter +): BootstrapGraphicModel[] => parseBootstrap(items).map(item => { const minimum = BigInt(item.minimumRateBps) const maximum = BigInt(item.maximumRateBps) @@ -306,6 +403,8 @@ export const deriveBootstrapGraphicModels = (items: BootstrapInput[]): Bootstrap 'Bootstrap derived reference and quoted rates must be positive and remain inside configured bounds' ) } + const acceptedCredit = BigInt(item.creditTarget) - BigInt(item.acceptanceAssets) + const band = bootstrapReferenceBand(premium, minimum, maximum) return { marketId: item.marketId, referenceRateBps: String(reference), @@ -316,41 +415,76 @@ export const deriveBootstrapGraphicModels = (items: BootstrapInput[]): Bootstrap creditTarget: item.creditTarget, acceptedCredit: String(BigInt(item.creditTarget) - BigInt(item.acceptanceAssets)), offerSize: item.offerSize, + ...(band === undefined ? {} : { referenceBand: band }), callouts: [ { label: 'Credit target', - value: `${item.creditTarget} target; complete at ${BigInt(item.creditTarget) - BigInt(item.acceptanceAssets)}` + value: `Builds up to ${formatAssets(item.creditTarget)} of credit here, stopping once ${formatAssets(String(acceptedCredit))} is in place`, + parameters: ['creditTarget', 'acceptanceAssets'] }, { - label: 'Refill behavior', - value: item.autoRefill - ? 'Auto-refill enabled after completion' - : 'One-shot; observe after completion' + label: 'Maximum offer size', + value: `${formatAssets(item.offerSize)} per offer, also capped by the remaining target, the cash on hand and the two exposure caps. ${ + BigInt(item.offerSize) >= acceptedCredit + ? 'One offer can fill the target' + : `About ${(acceptedCredit + BigInt(item.offerSize) - 1n) / BigInt(item.offerSize)} offers to fill the target` + }`, + parameters: ['offerSize'] + }, + { + label: 'Quote premium', + value: + band === undefined + ? `Your quote is the market rate ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, which never lands inside ${item.minimumRateBps}–${item.maximumRateBps} BPS, so it always sticks at a limit` + : `Your quote is the market rate ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, so it follows the market while that rate is ${band.lowestRateBps}–${band.highestRateBps} BPS and sticks at ${item.minimumRateBps} or ${item.maximumRateBps} BPS outside it`, + parameters: ['premiumBps', 'minimumRateBps', 'maximumRateBps'] }, ...(item.maturityPremium ? [ { label: 'Maturity premium', - value: `Linear +${item.maturityPremium.premiumPerYearBps} BPS per year to maturity${ + value: `Adds ${item.maturityPremium.premiumPerYearBps} BPS per year left to maturity${ item.maturityPremium.maximumPremiumBps === undefined ? '' - : `, capped at ${item.maturityPremium.maximumPremiumBps} BPS` - }; the preview quote range spans the clamped rates reachable from live time to maturity` + : `, up to ${item.maturityPremium.maximumPremiumBps} BPS` + }, shrinking as maturity approaches`, + parameters: [ + 'maturityPremium.premiumPerYearBps', + 'maturityPremium.maximumPremiumBps' + ] } ] : []), - { label: 'Cadence', value: '60 seconds (fixed bootstrap monitor cadence)' }, - { label: 'Movement tolerance', value: '0 BPS; changed valid terms are reconciled' }, - { label: 'Pending-offer cap', value: `${item.offerSize} assets before live capacity caps` }, { - label: 'Failure threshold', - value: '1 failed cycle halts monitoring and triggers owned-group cleanup' + label: 'Exposure caps', + value: `${formatAssets(item.maximumMarketExposure)} in this market, ${formatAssets(item.maximumTotalExposure)} across every configured market`, + parameters: ['maximumMarketExposure', 'maximumTotalExposure'] + }, + { + label: 'Auto-refill', + value: item.autoRefill + ? 'Lends again if the position later falls below target' + : 'Stops for good once complete, even if the position later falls', + parameters: ['autoRefill'] }, { - label: 'Exposure caps', - value: `${item.maximumMarketExposure} market · ${item.maximumTotalExposure} total` + label: 'Check interval', + value: + 'Every 60 seconds, fixed for bootstrap. A resting offer is reposted on any size or rate change, and at least hourly even when nothing moves', + parameters: [] + }, + { + label: 'Failure handling', + value: + 'One failed check stops monitoring, cancels this bot’s own offers, and exits. It does not retry', + parameters: [] }, - { label: 'Live state', value: 'No live offers, balances, positions, book, or network data' } + { + label: 'Not shown here', + value: + 'Live offers, balances, positions and the order book. This page reads no chain data, so nothing above reflects the current market', + parameters: [] + } ] } }) @@ -384,6 +518,8 @@ export type LadderGraphicModel = { plotHeight: number rateToY: (rateBps: string) => number rungs: LadderGraphicRung[] + /** How the shape degrades as the reference moves across the configured range. */ + referenceResponse: LadderReferenceResponse callouts: { label: string; value: string; parameters: string[] }[] } @@ -415,7 +551,8 @@ export const clampPlotPercent = (percent: number): number => Math.min(100, Math. * only their plot coordinate through {@link clampPlotPercent}. */ export const generateLadderGraphicModels = ( - value: LadderInput[] | PlaygroundState + value: LadderInput[] | PlaygroundState, + formatAssets: AssetFormatter = rawAssetFormatter ): LadderGraphicModel[] => parseLadder(collectionFromArgument(value)).map(input => { const config = ladderConfigsValue( @@ -448,6 +585,8 @@ export const generateLadderGraphicModels = ( config.maturityPremium === undefined ? undefined : generated.centerRateBps + highestReachableMaturityPremiumBps(config.maturityPremium) + const amountOf = (rawAmount: bigint) => formatAssets(String(rawAmount)) + const referenceResponse = ladderReferenceResponse(config) const caps = offerMaxAssetsByRung(generated) const paired = (side: 'higher' | 'lower') => { const rungs = generated[side] @@ -493,6 +632,7 @@ export const generateLadderGraphicModels = ( }, gapBps: input.spreadBps, plotHeight, + referenceResponse, rateToY, rungs: rows.map(({ rung, cap, side, sideLabel }) => ({ index: rung.index, @@ -507,19 +647,19 @@ export const generateLadderGraphicModels = ( })), callouts: [ { - label: 'Center', - value: `${reference} + ${config.quotePremiumBps} = ${generated.centerRateBps} BPS`, + label: 'Quote premium', + value: `Ladder centred on ${generated.centerRateBps} BPS: market rate ${reference} ${config.quotePremiumBps < 0n ? `minus ${-config.quotePremiumBps}` : `plus ${config.quotePremiumBps}`} BPS`, parameters: ['quotePremiumBps'] }, ...(config.maturityPremium ? [ { label: 'Maturity premium', - value: `Linear +${config.maturityPremium.premiumPerYearBps} BPS per year to maturity${ + value: `Adds ${config.maturityPremium.premiumPerYearBps} BPS per year left to maturity${ config.maturityPremium.maximumPremiumBps === undefined ? '' - : `, capped at ${config.maturityPremium.maximumPremiumBps} BPS` - }; the preview anchors the at-maturity center and marks the far-maturity center at the highest reachable premium`, + : `, up to ${config.maturityPremium.maximumPremiumBps} BPS` + }, shrinking as maturity approaches. The plot marks both ends of that travel`, parameters: [ 'maturityPremium.premiumPerYearBps', 'maturityPremium.maximumPremiumBps' @@ -528,39 +668,62 @@ export const generateLadderGraphicModels = ( ] : []), { - label: 'Spacing & sizing', - value: `${config.spreadBps} BPS spread · ${config.stepBps} BPS step · ${config.rungCount} rungs/side · ${config.sizeSkewBps} BPS skew · ${config.minimumOfferAssets} asset floor`, - parameters: ['spreadBps', 'stepBps', 'rungCount', 'sizeSkewBps', 'minimumOfferAssets'] + label: 'Full spread and step', + value: `${config.rungCount} rungs per side. The two rungs closest to the centre sit ${config.spreadBps} BPS apart, then each further rung steps out ${config.stepBps} BPS`, + parameters: ['spreadBps', 'stepBps', 'rungCount'] + }, + { + label: 'Size skew', + value: + config.sizeSkewBps === 0n + ? 'Every rung on a side gets an equal share of that side’s budget' + : config.sizeSkewBps > 0n + ? `Outer rungs are sized ${config.sizeSkewBps} BPS bigger than inner ones` + : `Inner rungs are sized ${-config.sizeSkewBps} BPS bigger than outer ones`, + parameters: ['sizeSkewBps'] }, { label: 'Budgets', - value: `${config.lowerRateBudgetAssets} reduce-only · ${config.higherRateBudgetAssets} lend`, - parameters: ['lowerRateBudgetAssets', 'higherRateBudgetAssets'] + value: `Lends up to ${amountOf(config.higherRateBudgetAssets)} above the centre, and offers up to ${amountOf(config.lowerRateBudgetAssets)} below it to reduce an existing position`, + parameters: ['higherRateBudgetAssets', 'lowerRateBudgetAssets'] + }, + { + label: 'Minimum offer size', + value: `Rungs smaller than ${amountOf(config.minimumOfferAssets)} are dropped. Your budgets can fund ${config.higherRateBudgetAssets / config.minimumOfferAssets} lending and ${config.lowerRateBudgetAssets / config.minimumOfferAssets} reduce-only rungs, against the ${config.rungCount} configured`, + parameters: ['minimumOfferAssets', 'higherRateBudgetAssets', 'lowerRateBudgetAssets'] }, { label: 'Exposure caps', - value: `${config.targetMarketExposureAssets} target · ${config.maximumTotalExposureAssets} total`, + value: `${amountOf(config.targetMarketExposureAssets)} in this market, ${amountOf(config.maximumTotalExposureAssets)} across every configured market`, parameters: ['targetMarketExposureAssets', 'maximumTotalExposureAssets'] }, { - label: 'Grouping', - value: config.groupMode, - parameters: ['groupMode'] + label: 'Minimum and maximum rate', + value: + referenceResponse.band === undefined + ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS, and some rung always sits on a limit whatever the market does` + : referenceResponse.band.lowestRateBps === referenceResponse.band.highestRateBps + ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits only at a market rate of exactly ${referenceResponse.band.lowestRateBps} BPS; any move squashes rungs onto a limit` + : `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits while the market rate is ${referenceResponse.band.lowestRateBps}–${referenceResponse.band.highestRateBps} BPS; outside that, rungs squash onto a limit`, + parameters: ['minimumRateBps', 'maximumRateBps'] }, { - label: 'Cadence & tolerance', - value: `${config.loopIntervalSeconds}s cadence · ${config.movementToleranceBps} BPS movement tolerance`, - parameters: ['loopIntervalSeconds', 'movementToleranceBps'] + label: 'Fill sharing', + value: + config.groupMode === 'shared-rung' + ? 'Each rung has its own capacity, so a fill at one rate leaves every other rate untouched' + : 'All rungs on a side share one capacity, so a fill at any rate reduces what every other rate on that side can take', + parameters: ['groupMode'] }, { - label: 'Hard bounds', - value: `${config.minimumRateBps}–${config.maximumRateBps} BPS`, - parameters: ['minimumRateBps', 'maximumRateBps'] + label: 'Check interval', + value: `Every ${config.loopIntervalSeconds} seconds, and offers are rewritten only once a rate moves more than ${config.movementToleranceBps} BPS`, + parameters: ['loopIntervalSeconds', 'movementToleranceBps'] }, { - label: 'Live state', + label: 'Not shown here', value: - 'No live offers, balances, positions, book, capacity, persistence, or network data', + 'Live offers, balances, positions and the order book. This page reads no chain data, so nothing above reflects the current market', parameters: [] } ] diff --git a/bots/quoter-bot/playground/reference-response.utils.ts b/bots/quoter-bot/playground/reference-response.utils.ts new file mode 100644 index 00000000..1c636298 --- /dev/null +++ b/bots/quoter-bot/playground/reference-response.utils.ts @@ -0,0 +1,142 @@ +import type { LadderConfig } from '../src/domain/ladder/ladder' +import type { TargetRateConfigured } from '../src/domain/target-rate' + +import { generateLadderWithDiagnostics } from '../src/domain/ladder/ladder' + +/** Samples rendered in a response strip; enough to show shape without crowding the axis. */ +export const RESPONSE_STRIP_POINTS = 61 +/** Upper bound on swept references, protecting an extreme configured rate range. */ +const MAXIMUM_REFERENCE_SAMPLES = 20_001 + +/** Inclusive reference range over which a configuration stays free of a described degradation. */ +export type ReferenceBand = { + lowestRateBps: string + highestRateBps: string + /** False when the range encloses a degraded reference, so the endpoints alone would mislead. */ + contiguous: boolean +} +/** One swept reference and the rung count that saturates at a hard bound there. */ +export type ReferenceResponsePoint = { referenceRateBps: string; pinnedRungs: number } +/** How one ladder entry responds across every reference its configured rate range admits. */ +export type LadderReferenceResponse = { + totalRungs: number + strip: ReferenceResponsePoint[] + band?: ReferenceBand +} + +/** + * Enumerates candidate references at one-BPS resolution. + * @remarks The lowest reference is 1 BPS because the runtime requires a positive reference; the + * caller widens the range past the configured bounds, since a live reference has no reason to stay + * inside them. + */ +const sampleReferences = (lowest: bigint, highest: bigint) => { + const first = lowest > 1n ? lowest : 1n + const stride = (highest - first) / BigInt(MAXIMUM_REFERENCE_SAMPLES - 1) + 1n + const references: bigint[] = [] + for (let value = first; value <= highest; value += stride) references.push(value) + if (references.at(-1) !== highest) references.push(highest) + return references +} + +/** Distance from a ladder center to its outermost rung on either side. */ +const ladderReach = (config: TargetRateConfigured) => + config.spreadBps / 2n + (BigInt(config.rungCount) - 1n) * config.stepBps + +const absolute = (value: bigint) => (value < 0n ? -value : value) + +const bandOf = ( + references: readonly bigint[], + clean: readonly boolean[] +): ReferenceBand | undefined => { + const first = clean.indexOf(true) + if (first === -1) return undefined + const last = clean.lastIndexOf(true) + return { + lowestRateBps: String(references[first]), + highestRateBps: String(references[last]), + contiguous: clean.slice(first, last + 1).every(Boolean) + } +} + +const downsample = (items: readonly T[], count: number): T[] => { + if (items.length <= count) return [...items] + return Array.from( + { length: count }, + (_unused, index) => items[Math.round((index * (items.length - 1)) / (count - 1))]! + ) +} + +/** + * Measures how a ladder entry degrades as its reference rate moves across the configured range. + * @param config - One validated ladder configuration. + * @returns The rung total, a downsampled response strip, and the widest reference band that pins + * no rung to a hard bound; `band` is absent only defensively, because the collection parser + * already rejects a shape that fits at no reference. + * @remarks Derived entirely from the configuration through the runtime's own + * `generateLadderWithDiagnostics`, so it assumes no live market data. Answers the question a + * single deterministic preview cannot: how far the market may move before the shape degrades. + */ +export const ladderReferenceResponse = ( + config: TargetRateConfigured +): LadderReferenceResponse => { + const pinnedRungs = (referenceRateBps: bigint) => { + const { diagnostics } = generateLadderWithDiagnostics({ + config, + referenceRateBps, + ...(config.maturityPremium === undefined ? {} : { secondsToMaturity: 0n }) + }) + return ( + diagnostics.lower.clampedToMinimumRungs + + diagnostics.lower.clampedToMaximumRungs + + diagnostics.higher.clampedToMinimumRungs + + diagnostics.higher.clampedToMaximumRungs + ) + } + // The band is measured past the configured bounds, because the runtime reference is a market rate + // that may sit anywhere; the strip stays on the plotted axis it is drawn under. + const margin = absolute(config.quotePremiumBps) + ladderReach(config) + 1n + const wide = sampleReferences(config.minimumRateBps - margin, config.maximumRateBps + margin) + const band = bandOf( + wide, + wide.map(reference => pinnedRungs(reference) === 0) + ) + const axis = sampleReferences(config.minimumRateBps, config.maximumRateBps) + return { + totalRungs: config.rungCount * 2, + strip: downsample( + axis.map(referenceRateBps => ({ + referenceRateBps: String(referenceRateBps), + pinnedRungs: pinnedRungs(referenceRateBps) + })), + RESPONSE_STRIP_POINTS + ), + ...(band === undefined ? {} : { band }) + } +} + +/** + * Measures the reference range over which a bootstrap quote tracks the reference instead of + * saturating at a hard bound. + * @param premiumBps - The entry's signed quote premium. + * @param minimum - Inclusive configured rate floor. + * @param maximum - Inclusive configured rate ceiling. + * @returns The widest unsaturated reference band, or `undefined` when every swept reference + * saturates. + * @remarks Uses the premium-free base quote, matching the anchor the deterministic preview draws. + */ +export const bootstrapReferenceBand = ( + premiumBps: bigint, + minimum: bigint, + maximum: bigint +): ReferenceBand | undefined => { + const margin = absolute(premiumBps) + 1n + const references = sampleReferences(minimum - margin, maximum + margin) + return bandOf( + references, + references.map(reference => { + const quote = reference + premiumBps + return quote >= minimum && quote <= maximum + }) + ) +} diff --git a/bots/quoter-bot/playground/styles.css b/bots/quoter-bot/playground/styles.css index 3eca6058..e842db81 100644 --- a/bots/quoter-bot/playground/styles.css +++ b/bots/quoter-bot/playground/styles.css @@ -87,6 +87,7 @@ main { display: grid; gap: 1rem; } +.units-card, .share-card, .import-card, .editor-section, @@ -96,6 +97,44 @@ main { background: #0d1f18; padding: 1rem; } +.units-card { + grid-column: 1 / -1; +} +.units-row { + display: flex; + gap: 1.25rem; + align-items: start; + flex-wrap: wrap; +} +.units-row .field { + flex: 0 0 9rem; +} +.units-row .field small { + min-height: 1.4em; +} +.units-row > p { + flex: 1 1 24rem; + max-width: 60rem; + margin: 0; + color: #b7ccbf; +} +.units-row code { + color: #b8dbc9; +} +.units-row input { + width: 100%; + border: 1px solid #46725f; + border-radius: 0.45rem; + padding: 0.55rem; + background: #07130f; + color: #f1fbf5; +} +.units-row input[aria-invalid='true'] { + border-color: #f0a58b; +} +.semantic-table [data-raw-amount] { + cursor: help; +} .section-heading { display: flex; justify-content: space-between; @@ -164,6 +203,29 @@ main { left: -0.35rem; color: #ff87a5; } +.reference-marker b, +.quote-marker b { + position: absolute; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + font-size: 0.72rem; + font-weight: 700; + padding: 0.05rem 0.35rem; + border-radius: 0.3rem; + background: #0b1c16; +} +.reference-marker b { + top: 0.6rem; + color: #f8d477; +} +.quote-marker b { + bottom: 0.6rem; + color: #ff87a5; +} +.quote-marker--maximum b { + bottom: 2.2rem; +} .quote-marker--maximum { opacity: 0.55; } @@ -221,6 +283,32 @@ main { right: 8%; opacity: 0.55; } +.reference-strip { + margin: 0 0 1rem; +} +.strip-track { + display: flex; + align-items: flex-end; + gap: 1px; + height: 2.6rem; + padding: 0.15rem; + border: 1px solid #2c5747; + border-radius: 0.4rem; + background: #0b1c16; +} +.strip-bar { + flex: 1 1 0; + background: #f0a58b; + border-radius: 1px; +} +.strip-bar--clean { + background: #2f6f57; +} +.reference-strip figcaption { + margin-top: 0.3rem; + font-size: 0.78rem; + color: #a9c1b4; +} .callouts { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); @@ -236,6 +324,14 @@ main { color: #8bf0c1; font-weight: 800; } +.callouts dt code { + display: block; + margin-top: 0.15rem; + font-size: 0.72rem; + font-weight: 400; + color: #8fae9e; + overflow-wrap: anywhere; +} .callouts dd { margin: 0.2rem 0 0; color: #d5e5dc; diff --git a/bots/quoter-bot/scripts/playground-smoke.mjs b/bots/quoter-bot/scripts/playground-smoke.mjs index 16097617..54442afb 100644 --- a/bots/quoter-bot/scripts/playground-smoke.mjs +++ b/bots/quoter-bot/scripts/playground-smoke.mjs @@ -1032,7 +1032,7 @@ try { referenceTop: '66.66%', centerTop: '50%', referenceLabel: 'Reference 400 BPS', - centerLabel: 'Center 500 BPS' + centerLabel: 'Quote 500 BPS' }) await assertDocumentPersistenceClean('preview edit') diff --git a/bots/quoter-bot/test/playground/artifact.test.ts b/bots/quoter-bot/test/playground/artifact.test.ts index cfa489ba..664690fe 100644 --- a/bots/quoter-bot/test/playground/artifact.test.ts +++ b/bots/quoter-bot/test/playground/artifact.test.ts @@ -85,7 +85,7 @@ describe('playground browser artifact boundary', () => { const firstParty = inputs.filter(path => !path.includes('node_modules/')) expect( firstParty.every(path => - /playground\/(?:app|model|playground-error\.utils|field-visibility\.utils|(?:collection-import|collection-validation|fragment-codec|playground-initialization|preview-generation|strict-json)\.error)\.tsx?$|src\/config\/(?:market-collections|config-validation\.error)\.ts$|src\/domain\/(?:bootstrap|ladder)\/|src\/domain\/(?:bytes32|cross-book|maturity-premium)\.ts$|packages\/utils\//.test( + /playground\/(?:app|model|asset-format\.utils|reference-response\.utils|playground-error\.utils|field-visibility\.utils|(?:collection-import|collection-validation|fragment-codec|playground-initialization|preview-generation|strict-json)\.error)\.tsx?$|src\/config\/(?:market-collections|config-validation\.error)\.ts$|src\/domain\/(?:bootstrap|ladder)\/|src\/domain\/(?:bytes32|cross-book|maturity-premium)\.ts$|packages\/utils\//.test( path ) ), diff --git a/bots/quoter-bot/test/playground/asset-format.utils.test.ts b/bots/quoter-bot/test/playground/asset-format.utils.test.ts new file mode 100644 index 00000000..64b696a4 --- /dev/null +++ b/bots/quoter-bot/test/playground/asset-format.utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest' + +import { + DEFAULT_ASSET_DECIMALS, + MAXIMUM_ASSET_DECIMALS, + assetFormatter, + formatAssetAmount, + resolveDecimals +} from '../../playground/asset-format.utils' + +describe('formatAssetAmount', () => { + test('renders whole token units, grouped', () => { + expect(formatAssetAmount('10000000000', 6)).toBe('10,000') + expect(formatAssetAmount('101000000', 6)).toBe('101') + expect(formatAssetAmount('10000000000', 8)).toBe('100') + expect(formatAssetAmount('123456789012345678901234567890', 18)).toBe('123,456,789,012') + }) + + test('rounds the fractional units away to the nearest whole unit', () => { + expect(formatAssetAmount('2011326045', 6)).toBe('2,011') + expect(formatAssetAmount('2011726045', 6)).toBe('2,012') + expect(formatAssetAmount('1500000', 6)).toBe('2') + expect(formatAssetAmount('1499999', 6)).toBe('1') + }) + + test('never renders a non-zero amount as zero', () => { + expect(formatAssetAmount('1', 18)).toBe('<1') + expect(formatAssetAmount('499999', 6)).toBe('<1') + expect(formatAssetAmount('0', 18)).toBe('0') + expect(formatAssetAmount('0', 0)).toBe('0') + }) + + test('renders the exact integer at zero decimals', () => { + expect(formatAssetAmount('10000000000', 0)).toBe('10,000,000,000') + expect(formatAssetAmount('7', 0)).toBe('7') + }) + + test('leaves values it cannot scale untouched', () => { + expect(formatAssetAmount('not-a-number', 6)).toBe('not-a-number') + expect(formatAssetAmount('1.5', 6)).toBe('1.5') + expect(formatAssetAmount('100', -1)).toBe('100') + expect(formatAssetAmount('100', 1.5)).toBe('100') + }) +}) + +describe('resolveDecimals', () => { + test('accepts whole numbers within the inclusive bound', () => { + expect(resolveDecimals('0')).toBe(0) + expect(resolveDecimals('6')).toBe(6) + expect(resolveDecimals(' 18 ')).toBe(18) + expect(resolveDecimals(String(MAXIMUM_ASSET_DECIMALS))).toBe(MAXIMUM_ASSET_DECIMALS) + }) + + test('resolves the USDC default the panel starts from', () => { + expect(resolveDecimals(DEFAULT_ASSET_DECIMALS)).toBe(6) + }) + + test('rejects a cleared entry and every unusable entry', () => { + for (const value of ['', 'abc', '-1', '6.5', '1e3', '37']) + expect(resolveDecimals(value)).toBeUndefined() + }) +}) + +describe('assetFormatter', () => { + test('applies one scale to every amount', () => { + const format = assetFormatter('6') + expect(format('10000000000')).toBe('10,000') + expect(format('500000000')).toBe('500') + }) + + test('renders whole USDC units from the default entry', () => { + expect(assetFormatter(DEFAULT_ASSET_DECIMALS)('10000000000')).toBe('10,000') + }) + + test('renders raw amounts while no usable scale is supplied', () => { + for (const entry of ['', 'abc', '37']) + expect(assetFormatter(entry)('10000000000')).toBe('10000000000') + }) + + test('changes only the rendering, whatever the scale', () => { + const raw = '18000000000' + expect(assetFormatter('0')(raw)).toBe('18,000,000,000') + expect(assetFormatter('6')(raw)).toBe('18,000') + expect(assetFormatter('8')(raw)).toBe('180') + expect(assetFormatter('18')(raw)).toBe('<1') + }) +}) diff --git a/bots/quoter-bot/test/playground/model.test.ts b/bots/quoter-bot/test/playground/model.test.ts index d254b5a4..360ff523 100644 --- a/bots/quoter-bot/test/playground/model.test.ts +++ b/bots/quoter-bot/test/playground/model.test.ts @@ -120,7 +120,8 @@ describe('bootstrap + ladder only playground follow-up', () => { expect(graphic?.callouts).toContainEqual({ label: 'Maturity premium', value: - 'Linear +120 BPS per year to maturity, capped at 300 BPS; the preview quote range spans the clamped rates reachable from live time to maturity' + 'Adds 120 BPS per year left to maturity, up to 300 BPS, shrinking as maturity approaches', + parameters: ['maturityPremium.premiumPerYearBps', 'maturityPremium.maximumPremiumBps'] }) expect(graphic).toMatchObject({ referenceRateBps: '550', @@ -215,7 +216,7 @@ describe('bootstrap + ladder only playground follow-up', () => { expect(graphic?.callouts).toContainEqual({ label: 'Maturity premium', value: - 'Linear +120 BPS per year to maturity, capped at 300 BPS; the preview anchors the at-maturity center and marks the far-maturity center at the highest reachable premium', + 'Adds 120 BPS per year left to maturity, up to 300 BPS, shrinking as maturity approaches. The plot marks both ends of that travel', parameters: ['maturityPremium.premiumPerYearBps', 'maturityPremium.maximumPremiumBps'] }) @@ -294,8 +295,8 @@ describe('bootstrap + ladder only playground follow-up', () => { }) expect(graphic.axis.centerRateBps).toBe('100') expect(graphic.callouts).toContainEqual({ - label: 'Center', - value: '400 + -300 = 100 BPS', + label: 'Quote premium', + value: 'Ladder centred on 100 BPS: market rate 400 minus 300 BPS', parameters: ['quotePremiumBps'] }) expect(graphic.rateToY('100')).toBeGreaterThan(100) @@ -377,14 +378,16 @@ describe('bootstrap + ladder only playground follow-up', () => { ]) expect(shared.rungs.every(rung => rung.y >= 0 && rung.y <= 100)).toBe(true) expect(shared.callouts.map(callout => callout.label)).toEqual([ - 'Center', - 'Spacing & sizing', + 'Quote premium', + 'Full spread and step', + 'Size skew', 'Budgets', + 'Minimum offer size', 'Exposure caps', - 'Grouping', - 'Cadence & tolerance', - 'Hard bounds', - 'Live state' + 'Minimum and maximum rate', + 'Fill sharing', + 'Check interval', + 'Not shown here' ]) ladder.groupMode = 'per-book' diff --git a/bots/quoter-bot/test/playground/module-graph.test.ts b/bots/quoter-bot/test/playground/module-graph.test.ts index 46438a08..519262bb 100644 --- a/bots/quoter-bot/test/playground/module-graph.test.ts +++ b/bots/quoter-bot/test/playground/module-graph.test.ts @@ -28,6 +28,7 @@ describe('playground browser module graph', () => { ) expect(local.toSorted()).toEqual([ 'playground/app.tsx', + 'playground/asset-format.utils.ts', 'playground/collection-import.error.ts', 'playground/collection-validation.error.ts', 'playground/field-visibility.utils.ts', @@ -36,6 +37,7 @@ describe('playground browser module graph', () => { 'playground/playground-error.utils.ts', 'playground/playground-initialization.error.ts', 'playground/preview-generation.error.ts', + 'playground/reference-response.utils.ts', 'playground/strict-json.error.ts', 'src/config/config-validation.error.ts', 'src/config/market-collections.ts', diff --git a/bots/quoter-bot/test/playground/reference-response.utils.test.ts b/bots/quoter-bot/test/playground/reference-response.utils.test.ts new file mode 100644 index 00000000..f7523779 --- /dev/null +++ b/bots/quoter-bot/test/playground/reference-response.utils.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'vitest' + +import { + RESPONSE_STRIP_POINTS, + bootstrapReferenceBand, + ladderReferenceResponse +} from '../../playground/reference-response.utils' +import { ladderConfigsValue, parseBytes32 } from '../../src/config/market-collections' + +const MARKET = `0x${'5'.repeat(64)}` +const ladder = (overrides: Record) => { + const entry = { + marketId: MARKET, + targetRate: { strategy: 'variable_rate_avg' }, + quotePremiumBps: '0', + spreadBps: '200', + stepBps: '100', + rungCount: '3', + sizeSkewBps: '0', + lowerRateBudgetAssets: '10000000000', + higherRateBudgetAssets: '10000000000', + targetMarketExposureAssets: '20000000000', + maximumTotalExposureAssets: '30000000000', + minimumOfferAssets: '101000000', + groupMode: 'shared-rung', + loopIntervalSeconds: '60', + movementToleranceBps: '10', + minimumRateBps: '200', + maximumRateBps: '800', + ...overrides + } + return ladderConfigsValue([entry], [parseBytes32(MARKET, 'marketId')])[0]! +} + +describe('ladderReferenceResponse', () => { + test('reports the clamp-free band a single deterministic preview cannot show', () => { + // spread 100 / step 50 / 4 rungs reaches center ± 200 against 200–800 bounds. + expect( + ladderReferenceResponse(ladder({ spreadBps: '100', stepBps: '50', rungCount: '4' })).band + ).toEqual({ lowestRateBps: '400', highestRateBps: '600', contiguous: true }) + }) + + test('shifts the band by the quote premium, which offsets the center', () => { + expect( + ladderReferenceResponse( + ladder({ spreadBps: '100', stepBps: '50', rungCount: '4', quotePremiumBps: '-50' }) + ).band + ).toEqual({ lowestRateBps: '450', highestRateBps: '650', contiguous: true }) + }) + + test('collapses to a single clean reference when the rungs exactly span the bounds', () => { + // spread 200 / step 100 / 3 rungs reaches reference ± 300, the full half-range. + expect(ladderReferenceResponse(ladder({})).band).toEqual({ + lowestRateBps: '500', + highestRateBps: '500', + contiguous: true + }) + }) + + test('never has to omit the band: the parser rejects a shape that fits at no reference', () => { + expect(() => ladder({ spreadBps: '800', stepBps: '400' })).toThrow( + 'full ladder shape cannot fit in the hard range' + ) + }) + + test('counts pinned rungs against the configured total and bounds the strip', () => { + const response = ladderReferenceResponse(ladder({})) + expect(response.totalRungs).toBe(6) + expect(response.strip.length).toBeLessThanOrEqual(RESPONSE_STRIP_POINTS) + expect(response.strip.at(0)?.referenceRateBps).toBe('200') + expect(response.strip.at(-1)?.referenceRateBps).toBe('800') + for (const point of response.strip) expect(point.pinnedRungs).toBeLessThanOrEqual(6) + }) + + test('widens the band as the ladder reaches less far from its center', () => { + const narrow = ladderReferenceResponse( + ladder({ spreadBps: '100', stepBps: '50', rungCount: '1' }) + ) + expect(narrow.band).toEqual({ lowestRateBps: '250', highestRateBps: '750', contiguous: true }) + }) +}) + +describe('bootstrapReferenceBand', () => { + test('reports where the quote tracks rather than saturating', () => { + // quote = reference - 90 must land inside 200-800, so the reference may run to 890. + expect(bootstrapReferenceBand(-90n, 200n, 800n)).toEqual({ + lowestRateBps: '290', + highestRateBps: '890', + contiguous: true + }) + }) + + test('measures past the configured bounds, which never constrain a market reference', () => { + const band = bootstrapReferenceBand(-50n, 200n, 800n) + expect(band).toEqual({ lowestRateBps: '250', highestRateBps: '850', contiguous: true }) + }) + + test('covers the whole range at a zero premium', () => { + expect(bootstrapReferenceBand(0n, 200n, 800n)).toEqual({ + lowestRateBps: '200', + highestRateBps: '800', + contiguous: true + }) + }) + + test('follows a large premium out to the references that keep the quote in range', () => { + expect(bootstrapReferenceBand(-700n, 200n, 800n)).toEqual({ + lowestRateBps: '900', + highestRateBps: '1500', + contiguous: true + }) + }) +}) From c9abed7e23d60231753ab60c6e48aff919803569 Mon Sep 17 00:00:00 2001 From: thomas-chabert Date: Mon, 31 Aug 2026 11:44:08 +0200 Subject: [PATCH 2/5] feat(quoter-bot): show playground previews as order books Both plots now share one geometry: a vertical rate axis with the bounds labelled above and below, and one row per offer carrying its rate, a depth bar and its size. The ladder reads as price-level depth, and bootstrap as the single offer it publishes, sized against its credit target. Tile and helper text is anchored on the editor field labels, and the audit behind that corrected four ladder descriptions that misstated the runtime: sizeSkewBps compounds per step off a 10,000 BPS base rather than being a flat outer-versus-inner difference, an underfunded side drops its outermost rungs rather than its small ones, the exposure caps bound only the lending side, and movementToleranceBps gates recentring while a resize can still happen inside it. A derived reference outside the configured bounds no longer suppresses the preview. It renders with markers pinned to the axis and carries a notice saying so, because on the variable strategy that state is an artefact of the synthetic reference rather than an invalid config. A shape the shared parser genuinely rejects still cannot be drawn, so its message now restates the runtime's own inequality with the operator's numbers. Co-Authored-By: Claude Opus 5 --- bots/quoter-bot/playground/app.tsx | 128 +++++++-------- bots/quoter-bot/playground/model.ts | 134 ++++++++++------ .../playground/reference-response.utils.ts | 57 ++----- bots/quoter-bot/playground/styles.css | 148 +++++++----------- bots/quoter-bot/scripts/playground-smoke.mjs | 5 +- bots/quoter-bot/test/playground/model.test.ts | 65 ++++++-- .../reference-response.utils.test.ts | 28 +--- 7 files changed, 292 insertions(+), 273 deletions(-) diff --git a/bots/quoter-bot/playground/app.tsx b/bots/quoter-bot/playground/app.tsx index 50478bea..bacca364 100644 --- a/bots/quoter-bot/playground/app.tsx +++ b/bots/quoter-bot/playground/app.tsx @@ -112,7 +112,7 @@ const rungColumnsFor = (format: AssetFormatter) => [ cell: info => amountCell(info.getValue(), format(info.getValue())) }), columnHelper.accessor('offerMaxAssets', { - header: 'Offer maxAssets', + header: 'Offer cap', cell: info => amountCell(info.getValue(), format(info.getValue())) }) ] @@ -175,40 +175,59 @@ const BootstrapGraphic = ({ const minimum = BigInt(graphic.minimumRateBps) const maximum = BigInt(graphic.maximumRateBps) const range = maximum - minimum || 1n - const position = (value: string) => Number(((BigInt(value) - minimum) * 10_000n) / range) / 100 + const y = (value: string) => + clampPlotPercent(Number(((maximum - BigInt(value)) * 10_000n) / range) / 100) + const target = BigInt(graphic.creditTarget) + const depth = (value: string) => + target <= 0n + ? 100 + : Math.max(1, Math.min(100, Number((BigInt(value) * 10_000n) / target) / 100)) const quoteText = graphic.maximumQuotedRateBps === undefined ? `quote ${graphic.quotedRateBps} BPS` : `quote range ${graphic.quotedRateBps} to ${graphic.maximumQuotedRateBps} BPS across maturities` - const description = `${title}, market ${graphic.marketId}. Configured range ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS. Deterministic reference ${graphic.referenceRateBps} BPS produces ${quoteText}. Credit target ${format(graphic.creditTarget)}, completion threshold ${format(graphic.acceptedCredit)}, pending-offer cap ${format(graphic.offerSize)}. ${graphic.callouts.map(item => `${item.label}: ${item.value}.`).join(' ')} Explicitly no live offers or balances.` + const description = `${title}, market ${graphic.marketId}. Configured range ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS. Deterministic reference ${graphic.referenceRateBps} BPS produces ${quoteText}, offering ${format(graphic.offerSize)} against a ${format(graphic.creditTarget)} credit target completed at ${format(graphic.acceptedCredit)}. ${graphic.callouts.map(item => `${item.label}: ${item.value}.`).join(' ')} Explicitly no live offers or balances.` return (

{title}

{graphic.marketId}
-
+ {graphic.notice === undefined ? null : ( +

+ {graphic.notice} +

+ )}
{graphic.callouts.map(item => (
@@ -224,32 +243,6 @@ const BootstrapGraphic = ({ ) } -const ReferenceStrip = ({ graphic, index }: { graphic: LadderGraphicModel; index: number }) => { - const { band, strip, totalRungs } = graphic.referenceResponse - const description = `Reference response for ladder market ${index + 1}. Across references ${graphic.minimumRateBps} to ${graphic.maximumRateBps} BPS, ${ - band === undefined - ? 'every reference pins at least one rung to a hard bound' - : `references ${band.lowestRateBps} to ${band.highestRateBps} BPS pin no rung` - }. Taller bars pin more of the ${totalRungs} rungs.` - return ( -
- -
- Rungs pinned to a bound by reference · {graphic.minimumRateBps}–{graphic.maximumRateBps} BPS -
-
- ) -} - const LadderGraphic = ({ format, graphic, @@ -269,22 +262,29 @@ const LadderGraphic = ({

Ladder market {index + 1}

{graphic.marketId}
+ -
▲ Lend · ● Reduce-only · values are also available in the table
+ +
▲ Lend · ● Reduce-only · bar length is the allocation at that rate
- + {graphic.notice === undefined ? null : ( +

+ {graphic.notice} +

+ )}
{graphic.callouts.map(item => (
@@ -358,15 +365,9 @@ const DisplayUnits = ({ />

- One scale covers both collections: a quoter-bot process has a single{' '} - LOAN_ASSET_ADDRESS, and every configured amount — credit targets, offer sizes, - budgets, and exposure caps — is a raw smallest-unit amount of that one loan asset. - Collateral tokens never appear in a market collection, so their decimals are irrelevant - here. Display only. It starts at 6 for USDC as a convenience, not because the playground - resolved it — no chain data is read, so correct it for any other loan asset, and clear it to - return every amount to its exact raw integer. The scale rounds amounts to whole units so - magnitudes stay scannable; hover any amount to read its exact raw value. The editors, the - four outputs, and the share URL always keep the exact raw integers. + Display only: every amount below is shown in whole loan-asset units at this scale, with the + exact raw integer on hover. Starts at 6 for USDC — correct it for another loan asset, or + clear it to read raw integers.

@@ -380,7 +381,10 @@ const InvalidPreview = ({ kind, errors }: { kind: CollectionKind; errors: string
  • {error}
  • ))} - No misleading graphic was generated. + + The shared parser the bot itself uses rejects this entry, so there are no offers to plot. The + matching output below is disabled until it is fixed. +
    ) diff --git a/bots/quoter-bot/playground/model.ts b/bots/quoter-bot/playground/model.ts index 7ec0cf34..a580c92d 100644 --- a/bots/quoter-bot/playground/model.ts +++ b/bots/quoter-bot/playground/model.ts @@ -2,7 +2,7 @@ import type { BootstrapConfig } from '../src/domain/bootstrap/position-bootstrap import type { LadderConfig } from '../src/domain/ladder/ladder' import type { MaturityPremiumConfig } from '../src/domain/maturity-premium' import type { TargetRateConfigured } from '../src/domain/target-rate' -import type { LadderReferenceResponse, ReferenceBand } from './reference-response.utils' +import type { ReferenceBand } from './reference-response.utils' import { BOOTSTRAP_MARKET_FIELDS, @@ -17,8 +17,7 @@ import { highestReachableMaturityPremiumBps } from '../src/domain/maturity-premi import { CollectionImportError } from './collection-import.error' import { CollectionValidationError } from './collection-validation.error' import { FragmentCodecError } from './fragment-codec.error' -import { PreviewGenerationError } from './preview-generation.error' -import { bootstrapReferenceBand, ladderReferenceResponse } from './reference-response.utils' +import { bootstrapReferenceBand, ladderReferenceBand } from './reference-response.utils' import { StrictJsonError } from './strict-json.error' export type TargetRateInput = @@ -167,7 +166,7 @@ export const LADDER_FIELDS = [ [ 'sizeSkewBps', 'Size skew (BPS)', - 'Positive sizes outer rungs bigger, negative sizes inner rungs bigger', + 'Each rung further out is weighted this many BPS more; negative favours inner rungs', 'number' ], [ @@ -182,17 +181,17 @@ export const LADDER_FIELDS = [ 'For offers above the centre, which lend new credit', 'number' ], - ['targetMarketExposureAssets', 'Market exposure cap', 'Most this market may hold', 'number'], + ['targetMarketExposureAssets', 'Market exposure cap', 'Caps lending in this market', 'number'], [ 'maximumTotalExposureAssets', 'Total exposure cap', - 'Most every configured market may hold together', + 'Caps lending across every configured market together', 'number' ], [ 'minimumOfferAssets', 'Minimum offer size', - 'Rungs smaller than this are dropped, funding fewer rungs', + 'Every funded rung gets at least this; a budget too small drops outermost rungs', 'number' ], [ @@ -210,7 +209,7 @@ export const LADDER_FIELDS = [ [ 'movementToleranceBps', 'Movement tolerance (BPS)', - 'Ignore rate moves smaller than this', + 'Keep the current centre until the target centre moves further than this', 'number' ], ['minimumRateBps', 'Minimum rate (BPS)', 'Rungs never go below this', 'number'], @@ -336,8 +335,45 @@ const validation = (operation: () => unknown): CollectionValidation => { } export const validateBootstrapCollection = (items: BootstrapInput[]) => validation(() => parseBootstrap(items)) -export const validateLadderCollection = (items: LadderInput[]) => - validation(() => parseLadder(items)) +/** + * Explains, in the operator's own units, why a ladder shape cannot fit its hard rate range. + * @param items - Ordered ladder inputs as typed, which may not parse. + * @returns One sentence per entry whose rungs span more than its configured range, naming the span + * it needs and the width it has; empty when a shape fits or its integers are unusable. + * @remarks Restates the runtime's own `sideWidth * 2 > maximumRateBps - minimumRateBps` invariant + * so the sanitized parser message gains the arithmetic an operator needs to fix it. + */ +const ladderShapeDiagnostics = (items: LadderInput[]): string[] => + items.flatMap((item, index) => { + const raw = [ + item.spreadBps, + item.stepBps, + item.rungCount, + item.minimumRateBps, + item.maximumRateBps + ].map(value => value.trim()) + if (raw.some(value => !/^\d+$/.test(value))) return [] + const [spread, step, count, minimum, maximum] = raw.map(BigInt) as [ + bigint, + bigint, + bigint, + bigint, + bigint + ] + if (count <= 0n || maximum < minimum) return [] + const span = (spread / 2n + (count - 1n) * step) * 2n + const width = maximum - minimum + if (span <= width) return [] + return [ + `Ladder ${index + 1}: ${count} rungs per side with a ${spread} BPS spread and a ${step} BPS step span ${span} BPS, but ${minimum}–${maximum} BPS is only ${width} BPS wide. Lower the rung count, the step or the spread, or widen the rate bounds.` + ] + }) + +export const validateLadderCollection = (items: LadderInput[]) => { + const result = validation(() => parseLadder(items)) + if (result.valid) return result + return { valid: false, errors: [...result.errors, ...ladderShapeDiagnostics(items)] } +} export type BootstrapGraphicModel = { marketId: string @@ -352,6 +388,8 @@ export type BootstrapGraphicModel = { offerSize: string /** Reference range over which the quote tracks instead of saturating at a bound. */ referenceBand?: ReferenceBand + /** Present when a derived rate leaves the plotted range, explaining the pinned markers. */ + notice?: string callouts: { label: string; value: string; parameters: string[] }[] } @@ -393,16 +431,22 @@ export const deriveBootstrapGraphicModels = ( minimum, maximum ) - if ( - reference <= 0n || - (item.targetRate.strategy !== 'hardcoded' && (reference < minimum || reference > maximum)) || - quoted < minimum || - quoted > maximum - ) { - throw new PreviewGenerationError( - 'Bootstrap derived reference and quoted rates must be positive and remain inside configured bounds' - ) + const issues: string[] = [] + if (reference <= 0n) issues.push(`the derived reference ${reference} BPS is not positive`) + else if (reference < minimum || reference > maximum) { + issues.push(`the derived reference ${reference} BPS falls outside the plotted range`) + } + if (quoted < minimum || quoted > maximum) { + issues.push(`the quote ${quoted} BPS would saturate at the nearest bound`) } + const notice = + issues.length === 0 + ? undefined + : `Markers are pinned to the edge of the range: ${issues.join(' and ')}.${ + item.targetRate.strategy === 'hardcoded' + ? '' + : ' The preview derives its reference from the bounds and the premium, so this is an artefact of the preview, not an invalid configuration.' + }` const acceptedCredit = BigInt(item.creditTarget) - BigInt(item.acceptanceAssets) const band = bootstrapReferenceBand(premium, minimum, maximum) return { @@ -416,6 +460,7 @@ export const deriveBootstrapGraphicModels = ( acceptedCredit: String(BigInt(item.creditTarget) - BigInt(item.acceptanceAssets)), offerSize: item.offerSize, ...(band === undefined ? {} : { referenceBand: band }), + ...(notice === undefined ? {} : { notice }), callouts: [ { label: 'Credit target', @@ -518,8 +563,10 @@ export type LadderGraphicModel = { plotHeight: number rateToY: (rateBps: string) => number rungs: LadderGraphicRung[] - /** How the shape degrades as the reference moves across the configured range. */ - referenceResponse: LadderReferenceResponse + /** Reference range over which no rung pins to a hard bound. */ + referenceBand?: ReferenceBand + /** Present when the derived reference leaves the plotted range, explaining the pinned marker. */ + notice?: string callouts: { label: string; value: string; parameters: string[] }[] } @@ -543,9 +590,9 @@ export const clampPlotPercent = (percent: number): number => Math.min(100, Math. * @returns One graphic model per entry: true center values (the at-maturity anchor and, with a * maturity premium, the far-maturity center at the highest reachable premium), display-ordered * rung rows with allocation and cap ratios, plot geometry, and callouts. - * @throws `ConfigValidationError` from the shared collection parser when any entry is invalid, - * and `PreviewGenerationError` when the deterministic derived reference cannot stay positive or, - * for the variable strategy, inside its own configured bounds. + * @throws `ConfigValidationError` from the shared collection parser when any entry is invalid. A + * derived reference outside the configured bounds is not a failure: the preview is still generated + * and carries a `notice` explaining that its markers are pinned to the edge. * @remarks Pure and browser-safe with no provider, logging, or persistence access. Center values * stay unclamped because the runtime clamps individual rungs, never the center; markers clamp * only their plot coordinate through {@link clampPlotPercent}. @@ -565,14 +612,14 @@ export const generateLadderGraphicModels = ( input.targetRate.strategy === 'hardcoded' ? BigInt(input.targetRate.hardcodedRateBps) : (minimum + maximum) / 2n - config.quotePremiumBps - if ( - reference <= 0n || - (input.targetRate.strategy !== 'hardcoded' && (reference < minimum || reference > maximum)) - ) { - throw new PreviewGenerationError( - 'Ladder derived reference and center rates must remain inside configured bounds' - ) - } + const notice = + reference > 0n && reference >= minimum && reference <= maximum + ? undefined + : `Markers are pinned to the edge of the range: the derived reference ${reference} BPS falls outside it.${ + input.targetRate.strategy === 'hardcoded' + ? '' + : ' The preview derives its reference from the bounds and the premium, so this is an artefact of the preview, not an invalid configuration.' + }` // The deterministic preview anchors the shape at the zero-premium (at-maturity) center. The // model carries true center values — the runtime clamps individual rungs, never the center — // and the component clamps only marker plot coordinates into the axis. @@ -586,7 +633,7 @@ export const generateLadderGraphicModels = ( ? undefined : generated.centerRateBps + highestReachableMaturityPremiumBps(config.maturityPremium) const amountOf = (rawAmount: bigint) => formatAssets(String(rawAmount)) - const referenceResponse = ladderReferenceResponse(config) + const referenceBand = ladderReferenceBand(config) const caps = offerMaxAssetsByRung(generated) const paired = (side: 'higher' | 'lower') => { const rungs = generated[side] @@ -632,7 +679,8 @@ export const generateLadderGraphicModels = ( }, gapBps: input.spreadBps, plotHeight, - referenceResponse, + ...(referenceBand === undefined ? {} : { referenceBand }), + ...(notice === undefined ? {} : { notice }), rateToY, rungs: rows.map(({ rung, cap, side, sideLabel }) => ({ index: rung.index, @@ -677,9 +725,7 @@ export const generateLadderGraphicModels = ( value: config.sizeSkewBps === 0n ? 'Every rung on a side gets an equal share of that side’s budget' - : config.sizeSkewBps > 0n - ? `Outer rungs are sized ${config.sizeSkewBps} BPS bigger than inner ones` - : `Inner rungs are sized ${-config.sizeSkewBps} BPS bigger than outer ones`, + : `Each step out from the centre adds ${config.sizeSkewBps} BPS of weight, so the outermost rung is ${(BigInt(config.rungCount) - 1n) * (config.sizeSkewBps < 0n ? -config.sizeSkewBps : config.sizeSkewBps)} BPS ${config.sizeSkewBps > 0n ? 'heavier' : 'lighter'} than the innermost`, parameters: ['sizeSkewBps'] }, { @@ -689,22 +735,22 @@ export const generateLadderGraphicModels = ( }, { label: 'Minimum offer size', - value: `Rungs smaller than ${amountOf(config.minimumOfferAssets)} are dropped. Your budgets can fund ${config.higherRateBudgetAssets / config.minimumOfferAssets} lending and ${config.lowerRateBudgetAssets / config.minimumOfferAssets} reduce-only rungs, against the ${config.rungCount} configured`, + value: `Every funded rung gets at least ${amountOf(config.minimumOfferAssets)}; when a side cannot cover them all its outermost rungs are dropped. Your budgets cover ${config.higherRateBudgetAssets / config.minimumOfferAssets} lending and ${config.lowerRateBudgetAssets / config.minimumOfferAssets} reduce-only rungs, against the ${config.rungCount} configured`, parameters: ['minimumOfferAssets', 'higherRateBudgetAssets', 'lowerRateBudgetAssets'] }, { label: 'Exposure caps', - value: `${amountOf(config.targetMarketExposureAssets)} in this market, ${amountOf(config.maximumTotalExposureAssets)} across every configured market`, + value: `Cap the lending side only: ${amountOf(config.targetMarketExposureAssets)} in this market and ${amountOf(config.maximumTotalExposureAssets)} across every configured market, whichever binds first. Reduce-only offers are not capped by either`, parameters: ['targetMarketExposureAssets', 'maximumTotalExposureAssets'] }, { label: 'Minimum and maximum rate', value: - referenceResponse.band === undefined + referenceBand === undefined ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS, and some rung always sits on a limit whatever the market does` - : referenceResponse.band.lowestRateBps === referenceResponse.band.highestRateBps - ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits only at a market rate of exactly ${referenceResponse.band.lowestRateBps} BPS; any move squashes rungs onto a limit` - : `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits while the market rate is ${referenceResponse.band.lowestRateBps}–${referenceResponse.band.highestRateBps} BPS; outside that, rungs squash onto a limit`, + : referenceBand.lowestRateBps === referenceBand.highestRateBps + ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits only at a market rate of exactly ${referenceBand.lowestRateBps} BPS; any move squashes rungs onto a limit` + : `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits while the market rate is ${referenceBand.lowestRateBps}–${referenceBand.highestRateBps} BPS; outside that, rungs squash onto a limit`, parameters: ['minimumRateBps', 'maximumRateBps'] }, { @@ -717,7 +763,7 @@ export const generateLadderGraphicModels = ( }, { label: 'Check interval', - value: `Every ${config.loopIntervalSeconds} seconds, and offers are rewritten only once a rate moves more than ${config.movementToleranceBps} BPS`, + value: `Every ${config.loopIntervalSeconds} seconds. While the target centre stays within ${config.movementToleranceBps} BPS the ladder holds its current centre and only resizes; a bigger move recentres every rung`, parameters: ['loopIntervalSeconds', 'movementToleranceBps'] }, { diff --git a/bots/quoter-bot/playground/reference-response.utils.ts b/bots/quoter-bot/playground/reference-response.utils.ts index 1c636298..33ddb3b7 100644 --- a/bots/quoter-bot/playground/reference-response.utils.ts +++ b/bots/quoter-bot/playground/reference-response.utils.ts @@ -3,8 +3,6 @@ import type { TargetRateConfigured } from '../src/domain/target-rate' import { generateLadderWithDiagnostics } from '../src/domain/ladder/ladder' -/** Samples rendered in a response strip; enough to show shape without crowding the axis. */ -export const RESPONSE_STRIP_POINTS = 61 /** Upper bound on swept references, protecting an extreme configured rate range. */ const MAXIMUM_REFERENCE_SAMPLES = 20_001 @@ -15,15 +13,6 @@ export type ReferenceBand = { /** False when the range encloses a degraded reference, so the endpoints alone would mislead. */ contiguous: boolean } -/** One swept reference and the rung count that saturates at a hard bound there. */ -export type ReferenceResponsePoint = { referenceRateBps: string; pinnedRungs: number } -/** How one ladder entry responds across every reference its configured rate range admits. */ -export type LadderReferenceResponse = { - totalRungs: number - strip: ReferenceResponsePoint[] - band?: ReferenceBand -} - /** * Enumerates candidate references at one-BPS resolution. * @remarks The lowest reference is 1 BPS because the runtime requires a positive reference; the @@ -59,27 +48,20 @@ const bandOf = ( } } -const downsample = (items: readonly T[], count: number): T[] => { - if (items.length <= count) return [...items] - return Array.from( - { length: count }, - (_unused, index) => items[Math.round((index * (items.length - 1)) / (count - 1))]! - ) -} - /** - * Measures how a ladder entry degrades as its reference rate moves across the configured range. + * Measures the reference range over which a ladder keeps every rung off a hard rate bound. * @param config - One validated ladder configuration. - * @returns The rung total, a downsampled response strip, and the widest reference band that pins - * no rung to a hard bound; `band` is absent only defensively, because the collection parser - * already rejects a shape that fits at no reference. + * @returns The widest reference band pinning no rung; absent only defensively, because the + * collection parser already rejects a shape that fits at no reference. * @remarks Derived entirely from the configuration through the runtime's own * `generateLadderWithDiagnostics`, so it assumes no live market data. Answers the question a * single deterministic preview cannot: how far the market may move before the shape degrades. + * The sweep runs past the configured bounds, since a live reference has no reason to stay inside + * them. */ -export const ladderReferenceResponse = ( +export const ladderReferenceBand = ( config: TargetRateConfigured -): LadderReferenceResponse => { +): ReferenceBand | undefined => { const pinnedRungs = (referenceRateBps: bigint) => { const { diagnostics } = generateLadderWithDiagnostics({ config, @@ -93,26 +75,15 @@ export const ladderReferenceResponse = ( diagnostics.higher.clampedToMaximumRungs ) } - // The band is measured past the configured bounds, because the runtime reference is a market rate - // that may sit anywhere; the strip stays on the plotted axis it is drawn under. const margin = absolute(config.quotePremiumBps) + ladderReach(config) + 1n - const wide = sampleReferences(config.minimumRateBps - margin, config.maximumRateBps + margin) - const band = bandOf( - wide, - wide.map(reference => pinnedRungs(reference) === 0) + const references = sampleReferences( + config.minimumRateBps - margin, + config.maximumRateBps + margin + ) + return bandOf( + references, + references.map(reference => pinnedRungs(reference) === 0) ) - const axis = sampleReferences(config.minimumRateBps, config.maximumRateBps) - return { - totalRungs: config.rungCount * 2, - strip: downsample( - axis.map(referenceRateBps => ({ - referenceRateBps: String(referenceRateBps), - pinnedRungs: pinnedRungs(referenceRateBps) - })), - RESPONSE_STRIP_POINTS - ), - ...(band === undefined ? {} : { band }) - } } /** diff --git a/bots/quoter-bot/playground/styles.css b/bots/quoter-bot/playground/styles.css index e842db81..4f8b540f 100644 --- a/bots/quoter-bot/playground/styles.css +++ b/bots/quoter-bot/playground/styles.css @@ -166,98 +166,79 @@ main { .preview-card figure { margin: 0.5rem 0 1rem; } -.rate-track, .ladder-plot { position: relative; - min-height: 9rem; border-left: 2px solid #bce8d2; border-right: 2px solid #bce8d2; background: repeating-linear-gradient(90deg, #17372b, #17372b 10%, #132d24 10%, #132d24 20%); - margin: 1.5rem 1rem; -} -.reference-marker, -.quote-marker { - position: absolute; - top: 1.8rem; - bottom: 1.8rem; - width: 0.25rem; - transform: translateX(-50%); } -.reference-marker { - background: #f8d477; +/* Two classes so the single-row bootstrap height beats the taller .ladder-plot rule below. */ +.bootstrap-preview .bootstrap-plot { + min-height: 7rem; } -.quote-marker { - background: #ff87a5; +.bootstrap-plot .ladder-reference-marker { + left: auto; + right: 0.5rem; } -.reference-marker::before { - content: '◆'; - position: absolute; - top: -0.9rem; - left: -0.45rem; - color: #f8d477; +/* The reference chip owns the right lane, so it can never sit on a quote row's text. */ +.bootstrap-preview .rung { + right: 10rem; } -.quote-marker::before { - content: '●'; - position: absolute; - bottom: -0.9rem; - left: -0.35rem; +.rung--quote { color: #ff87a5; } -.reference-marker b, -.quote-marker b { - position: absolute; - left: 50%; - transform: translateX(-50%); - white-space: nowrap; - font-size: 0.72rem; - font-weight: 700; - padding: 0.05rem 0.35rem; - border-radius: 0.3rem; - background: #0b1c16; -} -.reference-marker b { - top: 0.6rem; - color: #f8d477; -} -.quote-marker b { - bottom: 0.6rem; +/* The far-maturity quote is one rate the single offer can travel to, not a second offer. */ +.bootstrap-far-marker { + left: auto; + right: 0.5rem; color: #ff87a5; + opacity: 0.65; } -.quote-marker--maximum b { - bottom: 2.2rem; -} -.quote-marker--maximum { - opacity: 0.55; -} -.quote-marker--maximum::before { - content: '○'; +.ladder-plot { + min-height: 24rem; + /* Clears the half-height overhang of a rung row clamped onto a bound. */ + margin: 0.75rem 1rem; } -.range-label { - position: absolute; +.ladder-bound { + /* Sits above and below the plot, aligned with the rate column it annotates. */ + margin: 0 1rem; + padding-left: 1rem; font-size: 0.72rem; color: #d8eadf; } -.range-label--min { - left: 0.3rem; - bottom: 0.2rem; -} -.range-label--max { - right: 0.3rem; - top: 0.2rem; -} -.ladder-plot { - min-height: 24rem; -} .rung { position: absolute; left: 1rem; right: 1rem; + transform: translateY(-50%); display: flex; - gap: 0.4rem; - border-top: 2px solid currentColor; + align-items: center; + gap: 0.5rem; font-style: normal; font-size: 0.72rem; } +.rung-rate { + flex: 0 0 3.6rem; + font-weight: 700; +} +.rung-depth { + flex: 1 1 auto; + height: 0.75rem; + border-radius: 0.2rem; + background: #ffffff0f; + overflow: hidden; +} +.rung-depth b { + display: block; + height: 100%; + background: currentColor; + opacity: 0.6; +} +.rung-size { + flex: 0 0 5.5rem; + text-align: right; + color: #d8eadf; +} .rung--higher { color: #f8d477; } @@ -266,9 +247,12 @@ main { } .ladder-marker { position: absolute; + z-index: 1; padding: 0.25rem 0.45rem; + border-radius: 0.3rem; background: #06100c; font-size: 0.72rem; + white-space: nowrap; transform: translateY(-50%); } .ladder-reference-marker { @@ -283,31 +267,15 @@ main { right: 8%; opacity: 0.55; } -.reference-strip { - margin: 0 0 1rem; -} -.strip-track { - display: flex; - align-items: flex-end; - gap: 1px; - height: 2.6rem; - padding: 0.15rem; - border: 1px solid #2c5747; +.preview-notice { + margin: 0 0 0.8rem; + padding: 0.5rem 0.7rem; + border: 1px solid #7d6438; + border-left-width: 3px; border-radius: 0.4rem; - background: #0b1c16; -} -.strip-bar { - flex: 1 1 0; - background: #f0a58b; - border-radius: 1px; -} -.strip-bar--clean { - background: #2f6f57; -} -.reference-strip figcaption { - margin-top: 0.3rem; + background: #221c0e; + color: #f6e2b8; font-size: 0.78rem; - color: #a9c1b4; } .callouts { display: grid; diff --git a/bots/quoter-bot/scripts/playground-smoke.mjs b/bots/quoter-bot/scripts/playground-smoke.mjs index 54442afb..d5501fec 100644 --- a/bots/quoter-bot/scripts/playground-smoke.mjs +++ b/bots/quoter-bot/scripts/playground-smoke.mjs @@ -1019,8 +1019,9 @@ try { input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await new Promise(r => setTimeout(r, 30)); - const reference = document.querySelector('.ladder-reference-marker'); - const center = document.querySelector('.ladder-center-marker'); + const plot = document.querySelector('[data-preview="ladder"]'); + const reference = plot.querySelector('.ladder-reference-marker'); + const center = plot.querySelector('.ladder-center-marker'); return { referenceTop: reference?.style.top, centerTop: center?.style.top, diff --git a/bots/quoter-bot/test/playground/model.test.ts b/bots/quoter-bot/test/playground/model.test.ts index 360ff523..6b61ae1e 100644 --- a/bots/quoter-bot/test/playground/model.test.ts +++ b/bots/quoter-bot/test/playground/model.test.ts @@ -22,10 +22,30 @@ import { validateBootstrapCollection, validateLadderCollection } from '../../playground/model' -import { PreviewGenerationError } from '../../playground/preview-generation.error' import { StrictJsonError } from '../../playground/strict-json.error' describe('bootstrap + ladder only playground follow-up', () => { + test('still previews a config whose derived reference leaves the plotted range', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.premiumBps = '-1000' + state.ladder[0]!.quotePremiumBps = '-400' + + const bootstrap = deriveBootstrapGraphicModels(state.bootstrap)[0] + expect(bootstrap?.referenceRateBps).toBe('1500') + expect(bootstrap?.notice).toContain('falls outside the plotted range') + expect(bootstrap?.notice).toContain('artefact of the preview') + + const ladder = generateLadderGraphicModels(state.ladder)[0] + expect(ladder?.rungs.length).toBeGreaterThan(0) + expect(ladder?.notice).toContain('falls outside it') + }) + + test('leaves an in-range preview free of a notice', () => { + const state = createDefaultPlaygroundState() + expect(deriveBootstrapGraphicModels(state.bootstrap)[0]?.notice).toBeUndefined() + expect(generateLadderGraphicModels(state.ladder)[0]?.notice).toBeUndefined() + }) + test('canonical state contains exactly the two ordered collections', () => { const state = createDefaultPlaygroundState() expect(Object.keys(state)).toEqual(['bootstrap', 'ladder']) @@ -80,13 +100,13 @@ describe('bootstrap + ladder only playground follow-up', () => { state.bootstrap[0]!.premiumBps = '0' expect(deriveBootstrapGraphicModels(state.bootstrap)[0]?.referenceRateBps).toBe('1') state.bootstrap[0]!.maximumRateBps = '0' - expect(() => deriveBootstrapGraphicModels(state.bootstrap)).toThrow( - 'derived reference and quoted rates' - ) + expect(deriveBootstrapGraphicModels(state.bootstrap)[0]?.notice).toContain('is not positive') state.bootstrap[0] = createDefaultBootstrap() state.bootstrap[0].premiumBps = '-1000' - expect(() => deriveBootstrapGraphicModels(state.bootstrap)).toThrow('configured bounds') + expect(deriveBootstrapGraphicModels(state.bootstrap)[0]?.notice).toContain( + 'falls outside the plotted range' + ) }) test('round-trips, validates, and annotates a bootstrap maturity premium', () => { @@ -334,10 +354,33 @@ describe('bootstrap + ladder only playground follow-up', () => { }) }) - test('rejects a deterministic ladder reference outside its own configured bounds', () => { + test('explains a ladder shape that cannot fit its hard range, with the arithmetic', () => { + const state = createDefaultPlaygroundState() + state.ladder[0]!.spreadBps = '200' + state.ladder[0]!.stepBps = '100' + state.ladder[0]!.rungCount = '4' + + const result = validateLadderCollection(state.ladder) + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('full ladder shape cannot fit in the hard range') + expect(result.errors[1]).toBe( + 'Ladder 1: 4 rungs per side with a 200 BPS spread and a 100 BPS step span 800 BPS, but 200–800 BPS is only 600 BPS wide. Lower the rung count, the step or the spread, or widen the rate bounds.' + ) + }) + + test('adds no shape diagnostic when the entry is valid or its integers are unusable', () => { + const state = createDefaultPlaygroundState() + expect(validateLadderCollection(state.ladder)).toEqual({ valid: true, errors: [] }) + state.ladder[0]!.stepBps = 'abc' + expect(validateLadderCollection(state.ladder).errors).toHaveLength(1) + }) + + test('previews a deterministic ladder reference outside its own configured bounds', () => { const state = createDefaultPlaygroundState() state.ladder[0]!.quotePremiumBps = '-1000' - expect(() => generateLadderGraphicModels(state.ladder)).toThrow('configured bounds') + const graphic = generateLadderGraphicModels(state.ladder)[0] + expect(graphic?.notice).toContain('falls outside it') + expect(graphic?.rungs.length).toBeGreaterThan(0) expect(validateLadderCollection(state.ladder).valid).toBe(true) }) @@ -491,8 +534,6 @@ describe('bootstrap + ladder only playground follow-up', () => { test('classifies expected playground failures by concern without echoing rejected payloads', () => { const state = createDefaultPlaygroundState() - state.bootstrap[0]!.premiumBps = '-1000' - expect(() => deriveBootstrapGraphicModels(state.bootstrap)).toThrow(PreviewGenerationError) expect(() => parseCollectionsImport('{"bootstrap":')).toThrow(StrictJsonError) expect(() => parseCollectionsImport('42')).toThrow(CollectionImportError) @@ -574,7 +615,7 @@ describe('bootstrap + ladder only playground follow-up', () => { ).toThrow('unsupported key') }) - test('keeps runtime-valid collections exportable when only synthetic previews cannot derive', () => { + test('keeps runtime-valid collections exportable when the synthetic reference leaves the range', () => { const state = createDefaultPlaygroundState() state.bootstrap[0]!.premiumBps = '-1000' state.ladder[0]!.quotePremiumBps = '-1000' @@ -587,8 +628,8 @@ describe('bootstrap + ladder only playground follow-up', () => { expect(exportBootstrapMarketsEnvValue(state.bootstrap)).toBe(JSON.stringify(state.bootstrap)) expect(exportLadderJson(state.ladder)).toBe(`${JSON.stringify(state.ladder, null, 2)}\n`) expect(exportLadderMarketsEnvValue(state.ladder)).toBe(JSON.stringify(state.ladder)) - expect(() => deriveBootstrapGraphicModels(state.bootstrap)).toThrow('configured bounds') - expect(() => generateLadderGraphicModels(state.ladder)).toThrow('configured bounds') + expect(deriveBootstrapGraphicModels(state.bootstrap)[0]?.notice).toBeDefined() + expect(generateLadderGraphicModels(state.ladder)[0]?.notice).toBeDefined() }) test('exports exactly four independently validated collection values', () => { diff --git a/bots/quoter-bot/test/playground/reference-response.utils.test.ts b/bots/quoter-bot/test/playground/reference-response.utils.test.ts index f7523779..6f965c6f 100644 --- a/bots/quoter-bot/test/playground/reference-response.utils.test.ts +++ b/bots/quoter-bot/test/playground/reference-response.utils.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from 'vitest' import { - RESPONSE_STRIP_POINTS, bootstrapReferenceBand, - ladderReferenceResponse + ladderReferenceBand } from '../../playground/reference-response.utils' import { ladderConfigsValue, parseBytes32 } from '../../src/config/market-collections' @@ -32,25 +31,25 @@ const ladder = (overrides: Record) => { return ladderConfigsValue([entry], [parseBytes32(MARKET, 'marketId')])[0]! } -describe('ladderReferenceResponse', () => { +describe('ladderReferenceBand', () => { test('reports the clamp-free band a single deterministic preview cannot show', () => { // spread 100 / step 50 / 4 rungs reaches center ± 200 against 200–800 bounds. expect( - ladderReferenceResponse(ladder({ spreadBps: '100', stepBps: '50', rungCount: '4' })).band + ladderReferenceBand(ladder({ spreadBps: '100', stepBps: '50', rungCount: '4' })) ).toEqual({ lowestRateBps: '400', highestRateBps: '600', contiguous: true }) }) test('shifts the band by the quote premium, which offsets the center', () => { expect( - ladderReferenceResponse( + ladderReferenceBand( ladder({ spreadBps: '100', stepBps: '50', rungCount: '4', quotePremiumBps: '-50' }) - ).band + ) ).toEqual({ lowestRateBps: '450', highestRateBps: '650', contiguous: true }) }) test('collapses to a single clean reference when the rungs exactly span the bounds', () => { // spread 200 / step 100 / 3 rungs reaches reference ± 300, the full half-range. - expect(ladderReferenceResponse(ladder({})).band).toEqual({ + expect(ladderReferenceBand(ladder({}))).toEqual({ lowestRateBps: '500', highestRateBps: '500', contiguous: true @@ -63,20 +62,9 @@ describe('ladderReferenceResponse', () => { ) }) - test('counts pinned rungs against the configured total and bounds the strip', () => { - const response = ladderReferenceResponse(ladder({})) - expect(response.totalRungs).toBe(6) - expect(response.strip.length).toBeLessThanOrEqual(RESPONSE_STRIP_POINTS) - expect(response.strip.at(0)?.referenceRateBps).toBe('200') - expect(response.strip.at(-1)?.referenceRateBps).toBe('800') - for (const point of response.strip) expect(point.pinnedRungs).toBeLessThanOrEqual(6) - }) - test('widens the band as the ladder reaches less far from its center', () => { - const narrow = ladderReferenceResponse( - ladder({ spreadBps: '100', stepBps: '50', rungCount: '1' }) - ) - expect(narrow.band).toEqual({ lowestRateBps: '250', highestRateBps: '750', contiguous: true }) + const narrow = ladderReferenceBand(ladder({ spreadBps: '100', stepBps: '50', rungCount: '1' })) + expect(narrow).toEqual({ lowestRateBps: '250', highestRateBps: '750', contiguous: true }) }) }) From e5a7601172c93bad45de077d363e162b92de1aea Mon Sep 17 00:00:00 2001 From: thomas-chabert Date: Mon, 31 Aug 2026 11:54:43 +0200 Subject: [PATCH 3/5] test(quoter-bot): assert previews render for a valid out-of-range reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-preview parity smoke expected two preview errors for a collection whose synthetic reference leaves the plotted range. That suppression is exactly what this branch removed, so the assertion now requires zero errors, two notices, and both plots rendered — the contract it was written to protect, that a runtime-valid collection stays exportable. --- bots/quoter-bot/scripts/playground-smoke.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/bots/quoter-bot/scripts/playground-smoke.mjs b/bots/quoter-bot/scripts/playground-smoke.mjs index d5501fec..40d90f93 100644 --- a/bots/quoter-bot/scripts/playground-smoke.mjs +++ b/bots/quoter-bot/scripts/playground-smoke.mjs @@ -1098,6 +1098,8 @@ try { await new Promise(r => setTimeout(r, 30)); const invalid = [...document.querySelectorAll('.exports textarea')].map(x => x.dataset.invalid); const previewErrors = document.querySelectorAll('[data-preview-error]').length; + const previewNotices = document.querySelectorAll('.preview-notice').length; + const plots = document.querySelectorAll('.ladder-plot').length; const shareDisabled = document.querySelector('#copy-share-url').disabled; document.querySelector('#copy-share-url').click(); await new Promise(r => setTimeout(r, 0)); @@ -1106,11 +1108,22 @@ try { set('#bootstrap-0-premiumBps', '-50'); set('#ladder-0-quotePremiumBps', '100'); await new Promise(r => setTimeout(r, 30)); - return { invalid, previewErrors, shareDisabled, copiedMatchesDisplayed: copied === displayed }; + return { + invalid, + previewErrors, + previewNotices, + plots, + shareDisabled, + copiedMatchesDisplayed: copied === displayed + }; })()`) + // A runtime-valid collection whose synthetic reference leaves the plotted range still previews: + // both plots render and each carries a notice, instead of the previews refusing to draw. assert.deepEqual(runtimePreviewParity, { invalid: ['false', 'false', 'false', 'false'], - previewErrors: 2, + previewErrors: 0, + previewNotices: 2, + plots: 2, shareDisabled: false, copiedMatchesDisplayed: true }) From bf2ff8e5893b7f8742b5407fbfe26e3188b22811 Mon Sep 17 00:00:00 2001 From: thomas-chabert Date: Mon, 31 Aug 2026 12:10:46 +0200 Subject: [PATCH 4/5] fix(quoter-bot): correct playground copy and band edges from review Both bot reviewers landed on real problems in the preview, all of them cases where the page stated something the runtime does not do. Reference bands reported the first and last *sampled* clean rate as if exact. A stride above one BPS is reachable once the swept interval passes the sample cap, so a 0-20000 BPS range reported 19999 as its edge. The sweep now walks outward one BPS from each sampled edge, which costs at most one stride per side and makes the endpoints exact at any range. A maturity premium made both bands wrong: bootstrap ignored the premium and ladder pinned maturity at zero, so a band presented as general held only at maturity. Both callouts now say so. The ladder notice covered only the reference, leaving a premium-pushed centre visibly pinned with no explanation; it now names every marker the view can clamp, centre and far-maturity centre included. A hardcoded target was described as following the market across a band it can never traverse. The bootstrap offer row promised a raw amount on hover that only the ladder table carried. `acceptanceAssets` equal to `creditTarget` makes the runtime complete immediately and publish nothing, while the tile claimed one offer would fill it. `autoRefill: false` claimed it stops for good, though completion lives in an in-memory set a restart discards. Per repo utility isolation, the rung rendering helpers move out of the module holding ErrorBoundary into playground/rung-rendering.utils.tsx. Config output is unchanged: exports, fragment codec and validation stay byte-identical to main across the hardcoded strategy, per-book grouping and a zero accepted-credit target. --- bots/quoter-bot/playground/app.tsx | 32 ++------- bots/quoter-bot/playground/model.ts | 70 +++++++++++++------ .../playground/reference-response.utils.ts | 38 ++++++---- .../playground/rung-rendering.utils.tsx | 37 ++++++++++ .../test/playground/artifact.test.ts | 2 +- bots/quoter-bot/test/playground/model.test.ts | 50 ++++++++++++- .../test/playground/module-graph.test.ts | 1 + 7 files changed, 167 insertions(+), 63 deletions(-) create mode 100644 bots/quoter-bot/playground/rung-rendering.utils.tsx diff --git a/bots/quoter-bot/playground/app.tsx b/bots/quoter-bot/playground/app.tsx index bacca364..b102ae1f 100644 --- a/bots/quoter-bot/playground/app.tsx +++ b/bots/quoter-bot/playground/app.tsx @@ -1,12 +1,7 @@ import type { ErrorInfo, ReactNode } from 'react' import { useForm } from '@tanstack/react-form' -import { - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable -} from '@tanstack/react-table' +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' import React, { Component, useEffect, useMemo, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' @@ -55,6 +50,7 @@ import { } from './model' import { playgroundErrorMessage } from './playground-error.utils' import { PlaygroundInitializationError } from './playground-initialization.error' +import { amountCell, rungColumnsFor } from './rung-rendering.utils' type CollectionKind = keyof PlaygroundState type ExportFormat = 'bootstrap-json' | 'bootstrap-string' | 'ladder-json' | 'ladder-string' @@ -97,26 +93,6 @@ const initial = () => { } } -const columnHelper = createColumnHelper() -/** Renders one display amount while keeping its exact raw integer reachable on hover. */ -const amountCell = (rawAmount: string, display: string) => ( - - {display} - -) -const rungColumnsFor = (format: AssetFormatter) => [ - columnHelper.accessor('sideLabel', { header: 'Side', cell: info => info.getValue() }), - columnHelper.accessor('rateBps', { header: 'Rate (BPS)', cell: info => info.getValue() }), - columnHelper.accessor('allocationAssets', { - header: 'Allocation', - cell: info => amountCell(info.getValue(), format(info.getValue())) - }), - columnHelper.accessor('offerMaxAssets', { - header: 'Offer cap', - cell: info => amountCell(info.getValue(), format(info.getValue())) - }) -] - const RungTable = ({ format, graphic, @@ -207,7 +183,9 @@ const BootstrapGraphic = ({ - {format(graphic.offerSize)} + + {amountCell(graphic.offerSize, format(graphic.offerSize))} + {graphic.maximumQuotedRateBps === undefined ? null : ( = acceptedCredit - ? 'One offer can fill the target' - : `About ${(acceptedCredit + BigInt(item.offerSize) - 1n) / BigInt(item.offerSize)} offers to fill the target` + acceptedCredit <= 0n + ? 'No offer is published while completion is already satisfied' + : BigInt(item.offerSize) >= acceptedCredit + ? 'One offer can fill the target' + : `About ${(acceptedCredit + BigInt(item.offerSize) - 1n) / BigInt(item.offerSize)} offers to fill the target` }`, parameters: ['offerSize'] }, { label: 'Quote premium', value: - band === undefined - ? `Your quote is the market rate ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, which never lands inside ${item.minimumRateBps}–${item.maximumRateBps} BPS, so it always sticks at a limit` - : `Your quote is the market rate ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, so it follows the market while that rate is ${band.lowestRateBps}–${band.highestRateBps} BPS and sticks at ${item.minimumRateBps} or ${item.maximumRateBps} BPS outside it`, + item.targetRate.strategy === 'hardcoded' + ? `Your quote is a fixed ${clampRateBps(reference + premium, minimum, maximum)} BPS — the ${reference} BPS target ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, clamped into ${item.minimumRateBps}–${item.maximumRateBps} BPS. It does not follow the market` + : band === undefined + ? `Your quote is the market rate ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, which never lands inside ${item.minimumRateBps}–${item.maximumRateBps} BPS, so it always sticks at a limit` + : `Your quote is the market rate ${premium < 0n ? `minus ${-premium}` : `plus ${premium}`} BPS, so it follows the market while that rate is ${band.lowestRateBps}–${band.highestRateBps} BPS and sticks at ${item.minimumRateBps} or ${item.maximumRateBps} BPS outside it${ + item.maturityPremium === undefined + ? '' + : '. That band is measured at maturity; the maturity premium shifts it as time to maturity grows' + }`, parameters: ['premiumBps', 'minimumRateBps', 'maximumRateBps'] }, ...(item.maturityPremium @@ -509,7 +520,7 @@ export const deriveBootstrapGraphicModels = ( label: 'Auto-refill', value: item.autoRefill ? 'Lends again if the position later falls below target' - : 'Stops for good once complete, even if the position later falls', + : 'Stops once complete for this service instance only; completion is remembered in memory, so a restart forgets it and can lend again', parameters: ['autoRefill'] }, { @@ -612,14 +623,6 @@ export const generateLadderGraphicModels = ( input.targetRate.strategy === 'hardcoded' ? BigInt(input.targetRate.hardcodedRateBps) : (minimum + maximum) / 2n - config.quotePremiumBps - const notice = - reference > 0n && reference >= minimum && reference <= maximum - ? undefined - : `Markers are pinned to the edge of the range: the derived reference ${reference} BPS falls outside it.${ - input.targetRate.strategy === 'hardcoded' - ? '' - : ' The preview derives its reference from the bounds and the premium, so this is an artefact of the preview, not an invalid configuration.' - }` // The deterministic preview anchors the shape at the zero-premium (at-maturity) center. The // model carries true center values — the runtime clamps individual rungs, never the center — // and the component clamps only marker plot coordinates into the axis. @@ -634,6 +637,27 @@ export const generateLadderGraphicModels = ( : generated.centerRateBps + highestReachableMaturityPremiumBps(config.maturityPremium) const amountOf = (rawAmount: bigint) => formatAssets(String(rawAmount)) const referenceBand = ladderReferenceBand(config) + // Every marker the component can clamp needs to say so, not just the reference: a premium can + // push the center outside the plotted range while the reference itself sits inside it. + const pinned: string[] = [] + if (reference <= 0n) pinned.push(`the derived reference ${reference} BPS is not positive`) + else if (reference < minimum || reference > maximum) { + pinned.push(`the derived reference ${reference} BPS falls outside it`) + } + if (generated.centerRateBps < minimum || generated.centerRateBps > maximum) { + pinned.push(`the center ${generated.centerRateBps} BPS falls outside it`) + } + if (maximumCenter !== undefined && (maximumCenter < minimum || maximumCenter > maximum)) { + pinned.push(`the far-maturity center ${maximumCenter} BPS falls outside it`) + } + const notice = + pinned.length === 0 + ? undefined + : `Markers are pinned to the edge of the range: ${pinned.join(' and ')}.${ + input.targetRate.strategy === 'hardcoded' + ? '' + : ' The preview derives its reference from the bounds and the premium, so this is an artefact of the preview, not an invalid configuration.' + }` const caps = offerMaxAssetsByRung(generated) const paired = (side: 'higher' | 'lower') => { const rungs = generated[side] @@ -696,7 +720,7 @@ export const generateLadderGraphicModels = ( callouts: [ { label: 'Quote premium', - value: `Ladder centred on ${generated.centerRateBps} BPS: market rate ${reference} ${config.quotePremiumBps < 0n ? `minus ${-config.quotePremiumBps}` : `plus ${config.quotePremiumBps}`} BPS`, + value: `Ladder centred on ${generated.centerRateBps} BPS: ${input.targetRate.strategy === 'hardcoded' ? 'fixed target' : 'market rate'} ${reference} ${config.quotePremiumBps < 0n ? `minus ${-config.quotePremiumBps}` : `plus ${config.quotePremiumBps}`} BPS${input.targetRate.strategy === 'hardcoded' ? ', which does not follow the market' : ''}`, parameters: ['quotePremiumBps'] }, ...(config.maturityPremium @@ -748,9 +772,15 @@ export const generateLadderGraphicModels = ( value: referenceBand === undefined ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS, and some rung always sits on a limit whatever the market does` - : referenceBand.lowestRateBps === referenceBand.highestRateBps - ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits only at a market rate of exactly ${referenceBand.lowestRateBps} BPS; any move squashes rungs onto a limit` - : `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits while the market rate is ${referenceBand.lowestRateBps}–${referenceBand.highestRateBps} BPS; outside that, rungs squash onto a limit`, + : `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS. The full ladder fits ${ + referenceBand.lowestRateBps === referenceBand.highestRateBps + ? `only at a ${input.targetRate.strategy === 'hardcoded' ? 'target' : 'market'} rate of exactly ${referenceBand.lowestRateBps} BPS; any move squashes` + : `while the ${input.targetRate.strategy === 'hardcoded' ? 'target' : 'market'} rate is ${referenceBand.lowestRateBps}–${referenceBand.highestRateBps} BPS; outside that, rungs squash` + } onto a limit${ + config.maturityPremium === undefined + ? '' + : '. Measured at maturity; the maturity premium shifts the band as time to maturity grows' + }`, parameters: ['minimumRateBps', 'maximumRateBps'] }, { diff --git a/bots/quoter-bot/playground/reference-response.utils.ts b/bots/quoter-bot/playground/reference-response.utils.ts index 33ddb3b7..96be76fc 100644 --- a/bots/quoter-bot/playground/reference-response.utils.ts +++ b/bots/quoter-bot/playground/reference-response.utils.ts @@ -34,16 +34,30 @@ const ladderReach = (config: TargetRateConfigured) => const absolute = (value: bigint) => (value < 0n ? -value : value) +/** + * Locates the band from a sampled sweep, then recovers exact endpoints. + * @remarks A stride above one BPS — reachable once the swept interval exceeds the sample cap — + * would otherwise report the first and last *sampled* clean rates as if they were the true + * boundaries, understating the band. Walking outward one BPS at a time from each sampled edge + * costs at most one stride per side and makes the reported endpoints exact at any range. + */ const bandOf = ( references: readonly bigint[], - clean: readonly boolean[] + clean: readonly boolean[], + isClean: (reference: bigint) => boolean ): ReferenceBand | undefined => { const first = clean.indexOf(true) if (first === -1) return undefined const last = clean.lastIndexOf(true) + const floor = references[0]! + const ceiling = references.at(-1)! + let lowest = references[first]! + while (lowest > floor && isClean(lowest - 1n)) lowest -= 1n + let highest = references[last]! + while (highest < ceiling && isClean(highest + 1n)) highest += 1n return { - lowestRateBps: String(references[first]), - highestRateBps: String(references[last]), + lowestRateBps: String(lowest), + highestRateBps: String(highest), contiguous: clean.slice(first, last + 1).every(Boolean) } } @@ -80,10 +94,8 @@ export const ladderReferenceBand = ( config.minimumRateBps - margin, config.maximumRateBps + margin ) - return bandOf( - references, - references.map(reference => pinnedRungs(reference) === 0) - ) + const isClean = (reference: bigint) => reference > 0n && pinnedRungs(reference) === 0 + return bandOf(references, references.map(isClean), isClean) } /** @@ -103,11 +115,9 @@ export const bootstrapReferenceBand = ( ): ReferenceBand | undefined => { const margin = absolute(premiumBps) + 1n const references = sampleReferences(minimum - margin, maximum + margin) - return bandOf( - references, - references.map(reference => { - const quote = reference + premiumBps - return quote >= minimum && quote <= maximum - }) - ) + const isClean = (reference: bigint) => { + const quote = reference + premiumBps + return quote >= minimum && quote <= maximum + } + return bandOf(references, references.map(isClean), isClean) } diff --git a/bots/quoter-bot/playground/rung-rendering.utils.tsx b/bots/quoter-bot/playground/rung-rendering.utils.tsx new file mode 100644 index 00000000..db44d176 --- /dev/null +++ b/bots/quoter-bot/playground/rung-rendering.utils.tsx @@ -0,0 +1,37 @@ +import { createColumnHelper } from '@tanstack/react-table' + +import type { AssetFormatter, LadderGraphicModel } from './model' + +const columnHelper = createColumnHelper() + +/** + * Renders one display amount while keeping its exact raw integer reachable. + * @param rawAmount - Exact raw integer amount as configured and exported. + * @param display - Scaled amount to show in its place. + * @returns A span carrying the display amount, with the raw integer on hover and in + * `data-raw-amount` so the rounded rendering never hides the configured value. + */ +export const amountCell = (rawAmount: string, display: string) => ( + + {display} + +) + +/** + * Builds the rung table columns for one display scale. + * @param format - Current display formatter for configured amounts. + * @returns Column definitions pairing each rate with its allocation and offer cap, both scaled for + * reading and both retaining their raw integers. + */ +export const rungColumnsFor = (format: AssetFormatter) => [ + columnHelper.accessor('sideLabel', { header: 'Side', cell: info => info.getValue() }), + columnHelper.accessor('rateBps', { header: 'Rate (BPS)', cell: info => info.getValue() }), + columnHelper.accessor('allocationAssets', { + header: 'Allocation', + cell: info => amountCell(info.getValue(), format(info.getValue())) + }), + columnHelper.accessor('offerMaxAssets', { + header: 'Offer cap', + cell: info => amountCell(info.getValue(), format(info.getValue())) + }) +] diff --git a/bots/quoter-bot/test/playground/artifact.test.ts b/bots/quoter-bot/test/playground/artifact.test.ts index 664690fe..3980e1d7 100644 --- a/bots/quoter-bot/test/playground/artifact.test.ts +++ b/bots/quoter-bot/test/playground/artifact.test.ts @@ -85,7 +85,7 @@ describe('playground browser artifact boundary', () => { const firstParty = inputs.filter(path => !path.includes('node_modules/')) expect( firstParty.every(path => - /playground\/(?:app|model|asset-format\.utils|reference-response\.utils|playground-error\.utils|field-visibility\.utils|(?:collection-import|collection-validation|fragment-codec|playground-initialization|preview-generation|strict-json)\.error)\.tsx?$|src\/config\/(?:market-collections|config-validation\.error)\.ts$|src\/domain\/(?:bootstrap|ladder)\/|src\/domain\/(?:bytes32|cross-book|maturity-premium)\.ts$|packages\/utils\//.test( + /playground\/(?:app|model|asset-format\.utils|reference-response\.utils|rung-rendering\.utils|playground-error\.utils|field-visibility\.utils|(?:collection-import|collection-validation|fragment-codec|playground-initialization|preview-generation|strict-json)\.error)\.tsx?$|src\/config\/(?:market-collections|config-validation\.error)\.ts$|src\/domain\/(?:bootstrap|ladder)\/|src\/domain\/(?:bytes32|cross-book|maturity-premium)\.ts$|packages\/utils\//.test( path ) ), diff --git a/bots/quoter-bot/test/playground/model.test.ts b/bots/quoter-bot/test/playground/model.test.ts index 6b61ae1e..b62db921 100644 --- a/bots/quoter-bot/test/playground/model.test.ts +++ b/bots/quoter-bot/test/playground/model.test.ts @@ -316,11 +316,15 @@ describe('bootstrap + ladder only playground follow-up', () => { expect(graphic.axis.centerRateBps).toBe('100') expect(graphic.callouts).toContainEqual({ label: 'Quote premium', - value: 'Ladder centred on 100 BPS: market rate 400 minus 300 BPS', + value: + 'Ladder centred on 100 BPS: fixed target 400 minus 300 BPS, which does not follow the market', parameters: ['quotePremiumBps'] }) expect(graphic.rateToY('100')).toBeGreaterThan(100) expect(clampPlotPercent(graphic.rateToY('100'))).toBe(100) + // The reference itself is in range, so only a marker-aware notice catches this. + expect(graphic.notice).toContain('the center 100 BPS falls outside it') + expect(graphic.notice).toContain('far-maturity center 20100 BPS falls outside it') }) test('renders a hardcoded bootstrap reference outside bounds when its premium-adjusted quote is valid', () => { @@ -354,6 +358,50 @@ describe('bootstrap + ladder only playground follow-up', () => { }) }) + test('reports a bootstrap target already satisfied by its allowed shortfall', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.acceptanceAssets = state.bootstrap[0]!.creditTarget + expect(validateBootstrapCollection(state.bootstrap).valid).toBe(true) + const callouts = deriveBootstrapGraphicModels(state.bootstrap)[0]!.callouts + expect(callouts.find(item => item.label === 'Credit target')?.value).toContain( + 'no offer is ever published' + ) + expect(callouts.find(item => item.label === 'Maximum offer size')?.value).toContain( + 'No offer is published' + ) + }) + + test('describes a hardcoded bootstrap target as fixed rather than market-following', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.targetRate = { strategy: 'hardcoded', hardcodedRateBps: '500' } + const value = deriveBootstrapGraphicModels(state.bootstrap)[0]!.callouts.find( + item => item.label === 'Quote premium' + )?.value + expect(value).toContain('fixed 450 BPS') + expect(value).toContain('does not follow the market') + expect(value).not.toContain('follows the market while') + }) + + test('qualifies a reference band as at-maturity when a maturity premium is configured', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.maturityPremium = { shape: 'linear', premiumPerYearBps: '120' } + expect( + deriveBootstrapGraphicModels(state.bootstrap)[0]!.callouts.find( + item => item.label === 'Quote premium' + )?.value + ).toContain('measured at maturity') + }) + + test('limits the auto-refill promise to the running service instance', () => { + const state = createDefaultPlaygroundState() + state.bootstrap[0]!.autoRefill = false + const value = deriveBootstrapGraphicModels(state.bootstrap)[0]!.callouts.find( + item => item.label === 'Auto-refill' + )?.value + expect(value).toContain('this service instance only') + expect(value).not.toContain('for good') + }) + test('explains a ladder shape that cannot fit its hard range, with the arithmetic', () => { const state = createDefaultPlaygroundState() state.ladder[0]!.spreadBps = '200' diff --git a/bots/quoter-bot/test/playground/module-graph.test.ts b/bots/quoter-bot/test/playground/module-graph.test.ts index 519262bb..bb07f4a7 100644 --- a/bots/quoter-bot/test/playground/module-graph.test.ts +++ b/bots/quoter-bot/test/playground/module-graph.test.ts @@ -38,6 +38,7 @@ describe('playground browser module graph', () => { 'playground/playground-initialization.error.ts', 'playground/preview-generation.error.ts', 'playground/reference-response.utils.ts', + 'playground/rung-rendering.utils.tsx', 'playground/strict-json.error.ts', 'src/config/config-validation.error.ts', 'src/config/market-collections.ts', From fd6f98fe65dca1c06038c9545442507f44e35630 Mon Sep 17 00:00:00 2001 From: thomas-chabert Date: Mon, 31 Aug 2026 12:15:48 +0200 Subject: [PATCH 5/5] fix(quoter-bot): expose raw amounts on every plot amount Devin's review covered more ground than the bootstrap row I had fixed: ladder plot sizes and rung hover text also showed only scaled amounts. Both now carry the raw integer, and the display-units promise is scoped to the plots and tables, which are the surfaces that actually honour it. Callout prose stays scaled-only, so it no longer claims otherwise. --- bots/quoter-bot/docs/reference.md | 4 ++-- bots/quoter-bot/playground/app.tsx | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/bots/quoter-bot/docs/reference.md b/bots/quoter-bot/docs/reference.md index f2341cd0..e273bd10 100644 --- a/bots/quoter-bot/docs/reference.md +++ b/bots/quoter-bot/docs/reference.md @@ -964,8 +964,8 @@ decimals never apply. The entry starts at 6 as a convenience for USDC, the loan resolved from chain data, so it must be corrected for any other loan asset, and clearing it returns every amount to its exact raw integer. A supplied entry renders raw asset and credit amounts as whole token units across the previews, callouts, rung tables, and accessible descriptions, rounding the fractional units away so magnitudes -stay scannable; a non-zero amount below one unit renders as `<1` rather than zero, and hovering any -amount reveals its exact raw integer. The entry is display state only: it is never exported, never enters the URL +stay scannable; a non-zero amount below one unit renders as `<1` rather than zero, and hovering an +amount in the plots or tables reveals its exact raw integer. The entry is display state only: it is never exported, never enters the URL fragment, and leaves the editors and the four collection outputs on exact raw integers. An entry that is not a whole number of at most 36 decimals is marked invalid and leaves amounts raw rather than showing a misleading amount. diff --git a/bots/quoter-bot/playground/app.tsx b/bots/quoter-bot/playground/app.tsx index b102ae1f..80d2f9f5 100644 --- a/bots/quoter-bot/playground/app.tsx +++ b/bots/quoter-bot/playground/app.tsx @@ -249,7 +249,7 @@ const LadderGraphic = ({ key={`${rung.side}-${rung.index}-${rungIndex}`} className={`rung rung--${rung.side}`} style={{ top: `${rung.y}%` }} - title={`${rung.sideLabel} rung at ${rung.rateBps} BPS · allocation ${format(rung.allocationAssets)} · offer cap ${format(rung.offerMaxAssets)}`} + title={`${rung.sideLabel} rung at ${rung.rateBps} BPS · allocation ${format(rung.allocationAssets)} (${rung.allocationAssets}) · offer cap ${format(rung.offerMaxAssets)} (${rung.offerMaxAssets})`} > {rung.side === 'higher' ? '▲' : '●'} {rung.rateBps} @@ -257,7 +257,9 @@ const LadderGraphic = ({ - {format(rung.allocationAssets)} + + {amountCell(rung.allocationAssets, format(rung.allocationAssets))} + ))}

    - Display only: every amount below is shown in whole loan-asset units at this scale, with the - exact raw integer on hover. Starts at 6 for USDC — correct it for another loan asset, or - clear it to read raw integers. + Display only: amounts below are shown in whole loan-asset units at this scale, with the + exact raw integer on hover in the plots and tables. Starts at 6 for USDC — correct it for + another loan asset, or clear it to read raw integers everywhere.

    Exact ladder rate, allocation, and offer cap correspondence + Ladder rate, allocation, and offer cap correspondence; hover an amount for its exact raw + value +