diff --git a/bots/quoter-bot/docs/reference.md b/bots/quoter-bot/docs/reference.md index 78fa651d..e273bd10 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 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. + 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..80d2f9f5 100644 --- a/bots/quoter-bot/playground/app.tsx +++ b/bots/quoter-bot/playground/app.tsx @@ -1,17 +1,13 @@ import type { ErrorInfo, ReactNode } from 'react' import { useForm } from '@tanstack/react-form' -import { - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable -} from '@tanstack/react-table' -import React, { Component, useEffect, useRef, useState } from 'react' +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' +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 +15,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, @@ -48,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' @@ -90,29 +93,27 @@ const initial = () => { } } -const columnHelper = createColumnHelper() -const rungColumns = [ - 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() - }), - columnHelper.accessor('offerMaxAssets', { - header: 'Offer maxAssets (assets)', - cell: info => 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 +139,11 @@ const RungTable = ({ graphic, index }: { graphic: LadderGraphicModel; index: num } const BootstrapGraphic = ({ + format, graphic, index }: { + format: AssetFormatter graphic: BootstrapGraphicModel index: number }) => { @@ -148,44 +151,68 @@ 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 ${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}, 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 => (
-
{item.label}
+
+ {item.label} + {item.parameters.length === 0 ? null : {item.parameters.join(' · ')}} +
{item.value}
))} @@ -194,33 +221,50 @@ const BootstrapGraphic = ({ ) } -const LadderGraphic = ({ graphic, index }: { graphic: LadderGraphicModel; index: number }) => { +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}

{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 => (
-
{item.label}
+
+ {item.label} + {item.parameters.length === 0 ? null : {item.parameters.join(' · ')}} +
{item.value}
))} @@ -256,6 +311,48 @@ const LadderGraphic = ({ graphic, index }: { graphic: LadderGraphicModel; index: ) } +const DisplayUnits = ({ + decimals, + onChange +}: { + decimals: string + onChange: (value: string) => void +}) => ( +
+
+
+ Display units +

Loan asset decimals

+
+
+
+ +

+ 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. +

+
+
+) + const InvalidPreview = ({ kind, errors }: { kind: CollectionKind; errors: string[] }) => (
{kind === 'bootstrap' ? 'Bootstrap' : 'Ladder'} preview unavailable @@ -264,7 +361,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. +
    ) @@ -362,6 +462,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 +521,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 +777,7 @@ const Playground = () => {

    +
    @@ -689,6 +792,7 @@ const Playground = () => { bootstrapGraphics.map((graphic, index) => ( @@ -704,6 +808,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..076e29c9 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 { ReferenceBand } from './reference-response.utils' import { BOOTSTRAP_MARKET_FIELDS, @@ -16,7 +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, ladderReferenceBand } from './reference-response.utils' import { StrictJsonError } from './strict-json.error' export type TargetRateInput = @@ -44,85 +45,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)', + 'Each rung further out is weighted this many BPS more; negative favours inner rungs', + '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', 'Caps lending in this market', 'number'], + [ + 'maximumTotalExposureAssets', + 'Total exposure cap', + 'Caps lending across every configured market together', + 'number' + ], + [ + 'minimumOfferAssets', + 'Minimum offer size', + 'Every funded rung gets at least this; a budget too small drops outermost 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)', + 'Keep the current centre until the target centre moves further 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 +229,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 => ({ @@ -244,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 @@ -258,14 +386,21 @@ 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 + /** Present when a derived rate leaves the plotted range, explaining the pinned markers. */ + notice?: string + 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) @@ -296,16 +431,24 @@ export const deriveBootstrapGraphicModels = (items: BootstrapInput[]): Bootstrap 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 { marketId: item.marketId, referenceRateBps: String(reference), @@ -316,41 +459,88 @@ export const deriveBootstrapGraphicModels = (items: BootstrapInput[]): Bootstrap creditTarget: item.creditTarget, acceptedCredit: String(BigInt(item.creditTarget) - BigInt(item.acceptanceAssets)), offerSize: item.offerSize, + ...(band === undefined ? {} : { referenceBand: band }), + ...(notice === undefined ? {} : { notice }), callouts: [ { label: 'Credit target', - value: `${item.creditTarget} target; complete at ${BigInt(item.creditTarget) - BigInt(item.acceptanceAssets)}` + value: + acceptedCredit <= 0n + ? `The allowed shortfall equals the ${formatAssets(item.creditTarget)} target, so completion is already satisfied at zero credit and no offer is ever published` + : `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. ${ + 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: + 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 ? [ { 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 once complete for this service instance only; completion is remembered in memory, so a restart forgets it and can lend again', + 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: 'Live state', value: 'No live offers, balances, positions, book, or network data' } + { + label: 'Failure handling', + value: + 'One failed check stops monitoring, cancels this bot’s own offers, and exits. It does not retry', + parameters: [] + }, + { + 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 +574,10 @@ export type LadderGraphicModel = { plotHeight: number rateToY: (rateBps: string) => number rungs: LadderGraphicRung[] + /** 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[] }[] } @@ -407,15 +601,16 @@ 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}. */ export const generateLadderGraphicModels = ( - value: LadderInput[] | PlaygroundState + value: LadderInput[] | PlaygroundState, + formatAssets: AssetFormatter = rawAssetFormatter ): LadderGraphicModel[] => parseLadder(collectionFromArgument(value)).map(input => { const config = ladderConfigsValue( @@ -428,14 +623,6 @@ 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' - ) - } // 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. @@ -448,6 +635,29 @@ export const generateLadderGraphicModels = ( config.maturityPremium === undefined ? undefined : 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] @@ -493,6 +703,8 @@ export const generateLadderGraphicModels = ( }, gapBps: input.spreadBps, plotHeight, + ...(referenceBand === undefined ? {} : { referenceBand }), + ...(notice === undefined ? {} : { notice }), rateToY, rungs: rows.map(({ rung, cap, side, sideLabel }) => ({ index: rung.index, @@ -507,19 +719,19 @@ export const generateLadderGraphicModels = ( })), callouts: [ { - label: 'Center', - value: `${reference} + ${config.quotePremiumBps} = ${generated.centerRateBps} BPS`, + label: 'Quote premium', + 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 ? [ { 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 +740,66 @@ 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' + : `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'] }, { 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: `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: `${config.targetMarketExposureAssets} target · ${config.maximumTotalExposureAssets} total`, + 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: 'Grouping', - value: config.groupMode, - parameters: ['groupMode'] + label: 'Minimum and maximum rate', + value: + referenceBand === undefined + ? `Rungs never cross ${input.minimumRateBps} or ${input.maximumRateBps} BPS, and some rung always sits on a limit whatever the market does` + : `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'] }, { - 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. 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'] }, { - 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..96be76fc --- /dev/null +++ b/bots/quoter-bot/playground/reference-response.utils.ts @@ -0,0 +1,123 @@ +import type { LadderConfig } from '../src/domain/ladder/ladder' +import type { TargetRateConfigured } from '../src/domain/target-rate' + +import { generateLadderWithDiagnostics } from '../src/domain/ladder/ladder' + +/** 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 +} +/** + * 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) + +/** + * 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[], + 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(lowest), + highestRateBps: String(highest), + contiguous: clean.slice(first, last + 1).every(Boolean) + } +} + +/** + * Measures the reference range over which a ladder keeps every rung off a hard rate bound. + * @param config - One validated ladder configuration. + * @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 ladderReferenceBand = ( + config: TargetRateConfigured +): ReferenceBand | undefined => { + 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 + ) + } + const margin = absolute(config.quotePremiumBps) + ladderReach(config) + 1n + const references = sampleReferences( + config.minimumRateBps - margin, + config.maximumRateBps + margin + ) + const isClean = (reference: bigint) => reference > 0n && pinnedRungs(reference) === 0 + return bandOf(references, references.map(isClean), isClean) +} + +/** + * 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) + 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/playground/styles.css b/bots/quoter-bot/playground/styles.css index 3eca6058..4f8b540f 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; @@ -127,75 +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; } -.quote-marker--maximum { - opacity: 0.55; +/* 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::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; } @@ -204,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 { @@ -221,6 +267,16 @@ main { right: 8%; opacity: 0.55; } +.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: #221c0e; + color: #f6e2b8; + font-size: 0.78rem; +} .callouts { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); @@ -236,6 +292,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..40d90f93 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, @@ -1032,7 +1033,7 @@ try { referenceTop: '66.66%', centerTop: '50%', referenceLabel: 'Reference 400 BPS', - centerLabel: 'Center 500 BPS' + centerLabel: 'Quote 500 BPS' }) await assertDocumentPersistenceClean('preview edit') @@ -1097,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)); @@ -1105,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 }) diff --git a/bots/quoter-bot/test/playground/artifact.test.ts b/bots/quoter-bot/test/playground/artifact.test.ts index cfa489ba..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|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/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..b62db921 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', () => { @@ -120,7 +140,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 +236,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,12 +315,16 @@ 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: 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', () => { @@ -333,10 +358,77 @@ describe('bootstrap + ladder only playground follow-up', () => { }) }) - test('rejects a deterministic ladder reference outside its own configured bounds', () => { + 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' + 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) }) @@ -377,14 +469,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' @@ -488,8 +582,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) @@ -571,7 +663,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' @@ -584,8 +676,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/module-graph.test.ts b/bots/quoter-bot/test/playground/module-graph.test.ts index 46438a08..bb07f4a7 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,8 @@ 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/rung-rendering.utils.tsx', '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..6f965c6f --- /dev/null +++ b/bots/quoter-bot/test/playground/reference-response.utils.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest' + +import { + bootstrapReferenceBand, + ladderReferenceBand +} 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('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( + 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( + ladderReferenceBand( + ladder({ spreadBps: '100', stepBps: '50', rungCount: '4', quotePremiumBps: '-50' }) + ) + ).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(ladderReferenceBand(ladder({}))).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('widens the band as the ladder reaches less far from its center', () => { + const narrow = ladderReferenceBand(ladder({ spreadBps: '100', stepBps: '50', rungCount: '1' })) + expect(narrow).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 + }) + }) +})
    Exact ladder rate, allocation, and offer cap correspondence + Ladder rate, allocation, and offer cap correspondence; hover an amount for its exact raw + value +