diff --git a/packages/inter-protocol/src/auction/auctionBook.js b/packages/inter-protocol/src/auction/auctionBook.js index 11b0da04228..df65ba02847 100644 --- a/packages/inter-protocol/src/auction/auctionBook.js +++ b/packages/inter-protocol/src/auction/auctionBook.js @@ -12,7 +12,6 @@ import { assertAllDefined, makeTracer } from '@agoric/internal'; import { atomicRearrange, ceilMultiplyBy, - floorDivideBy, makeRatioFromAmounts, makeRecorderTopic, multiplyRatios, @@ -21,6 +20,7 @@ import { import { observeNotifier } from '@agoric/notifier'; import { makeNatAmountShape } from '../contractSupport.js'; +import { amountsToSettle } from './auctionMath.js'; import { preparePriceBook, prepareScaledBidBook } from './offerBook.js'; import { isScaledBidPriceHigher, @@ -282,39 +282,30 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { return makeEmpty(collateralBrand); } - /** @type {Amount<'nat'>} */ - const initialCollateralTarget = AmountMath.min( - collateralWanted, - collateralAvailable, - ); - const { curAuctionPrice, bidHoldingSeat, remainingProceedsGoal } = this.state; curAuctionPrice !== null || Fail`auctionPrice must be set before each round`; assert(curAuctionPrice); - const proceedsNeeded = ceilMultiplyBy( - initialCollateralTarget, - curAuctionPrice, - ); - if (AmountMath.isEmpty(proceedsNeeded)) { + const { proceedsExpected, proceedsTarget, collateralTarget } = + amountsToSettle( + { + bidAlloc, + collateralWanted, + collateralAvailable, + curAuctionPrice, + remainingProceedsGoal, + }, + trace, + ); + + if (proceedsExpected === null) { seat.fail(Error('price fell to zero')); return makeEmpty(collateralBrand); } - const minProceedsTarget = AmountMath.min(proceedsNeeded, bidAlloc); - const proceedsLimit = remainingProceedsGoal - ? AmountMath.min(remainingProceedsGoal, minProceedsTarget) - : minProceedsTarget; - const isRaiseLimited = - remainingProceedsGoal || - !AmountMath.isGTE(proceedsLimit, proceedsNeeded); - - const [proceedsTarget, collateralTarget] = isRaiseLimited - ? [proceedsLimit, floorDivideBy(proceedsLimit, curAuctionPrice)] - : [minProceedsTarget, initialCollateralTarget]; - + // check that the requested amount could be satisfied const { Collateral } = seat.getProposal().want; if (Collateral && AmountMath.isGTE(Collateral, collateralTarget)) { seat.exit('unable to satisfy want'); @@ -335,7 +326,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { ); } - trace('settle', { + trace('settled', { collateralTarget, proceedsTarget, remainingProceedsGoal: this.state.remainingProceedsGoal, @@ -611,20 +602,14 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => { const pricedOffers = priceBook.offersAbove(curAuctionPrice); const scaledBidOffers = scaledBidBook.offersAbove(reduction); - const compareValues = (v1, v2) => { - if (v1 < v2) { - return -1; - } else if (v1 === v2) { - return 0; - } else { - return 1; - } - }; - trace(`settling`, pricedOffers.length, scaledBidOffers.length); // requested price or BidScaling gives no priority beyond specifying which // round the order will be serviced in. const prioritizedOffers = [...pricedOffers, ...scaledBidOffers].sort( - (a, b) => compareValues(a[1].seqNum, b[1].seqNum), + (a, b) => Number(a[1].seqNum - b[1].seqNum), + ); + + trace( + `settling ${prioritizedOffers.length} offers at ${curAuctionPrice} (priced ${pricedOffers.length}, scaled ${scaledBidOffers.length}) `, ); const { remainingProceedsGoal } = state; diff --git a/packages/inter-protocol/src/auction/auctionMath.js b/packages/inter-protocol/src/auction/auctionMath.js new file mode 100644 index 00000000000..33a7875c1a7 --- /dev/null +++ b/packages/inter-protocol/src/auction/auctionMath.js @@ -0,0 +1,81 @@ +import { AmountMath } from '@agoric/ertp'; +import { + ceilMultiplyBy, + floorDivideBy, +} from '@agoric/zoe/src/contractSupport/index.js'; + +/** + * @import {Amount} from '@agoric/ertp/src/types.js'; + */ + +/** + * @param {object} p + * @param {Amount<'nat'>} p.bidAlloc current allocation of the bidding seat + * @param {Amount<'nat'>} p.collateralWanted want of the offer + * @param {Amount<'nat'>} p.collateralAvailable available to auction + * @param {Ratio} p.curAuctionPrice current auction price + * @param {Amount<'nat'> | null} p.remainingProceedsGoal amount still needing + * liquidating over multiple rounds; null indicates no limit + * @param {(...msgs: any[]) => void} [log] + */ +export const amountsToSettle = ( + { + bidAlloc, + collateralWanted, + collateralAvailable, + curAuctionPrice, + remainingProceedsGoal, + }, + log = () => {}, +) => { + log('amountsToSettle', { + bidAlloc, + collateralWanted, + collateralAvailable, + curAuctionPrice, + remainingProceedsGoal, + }); + const initialCollateralTarget = AmountMath.min( + collateralWanted, + collateralAvailable, + ); + + const proceedsExpected = ceilMultiplyBy( + initialCollateralTarget, + curAuctionPrice, + ); + if (AmountMath.isEmpty(proceedsExpected)) { + return { proceedsExpected: null }; + } + + const targetByProceeds = proceedsLimit => + AmountMath.min( + collateralAvailable, + floorDivideBy(proceedsLimit, curAuctionPrice), + ); + + const [proceedsTarget, collateralTarget] = (() => { + // proceeds cannot exceed what is needed or being offered + const proceedsBidded = AmountMath.min(proceedsExpected, bidAlloc); + if (remainingProceedsGoal) { + const goalProceeds = AmountMath.min( + remainingProceedsGoal, + proceedsBidded, + ); + return [goalProceeds, targetByProceeds(goalProceeds)]; + } else if (AmountMath.isGTE(proceedsBidded, proceedsExpected)) { + // initial collateral suffices + return [proceedsBidded, initialCollateralTarget]; + } else { + return [proceedsBidded, targetByProceeds(proceedsBidded)]; + } + })(); + + assert( + AmountMath.isGTE(collateralAvailable, collateralTarget), + 'target cannot exceed available', + ); + + return { proceedsExpected, proceedsTarget, collateralTarget }; +}; +harden(amountsToSettle); diff --git a/packages/inter-protocol/test/auction/auctionMath.test.js b/packages/inter-protocol/test/auction/auctionMath.test.js new file mode 100644 index 00000000000..337c2d36dfe --- /dev/null +++ b/packages/inter-protocol/test/auction/auctionMath.test.js @@ -0,0 +1,204 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { AmountMath } from '@agoric/ertp'; +import { makeRatioFromAmounts } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { Far } from '@endo/far'; +import { amountsToSettle } from '../../src/auction/auctionMath.js'; + +/** + * @import {Amount, Brand} from '@agoric/ertp/src/types.js'; + */ + +const brand = /** @type {any} */ (Far('fungible brand', {})); + +const testAmounts = test.macro( + /** + * @type {( + * t: import('ava').ExecutionContext, + * input: any, + * output: any, + * ) => Promise} + */ + async ( + t, + { bid, want, avail, price, goal }, + { expected, procTarget, collTarget }, + ) => { + /** @type {(n: number) => Amount<'nat'>} */ + const amt = n => AmountMath.make(brand, BigInt(n)); + + const result = amountsToSettle({ + bidAlloc: amt(bid), + collateralWanted: amt(want), + collateralAvailable: amt(avail), + curAuctionPrice: makeRatioFromAmounts(amt(price[0]), amt(price[1])), + remainingProceedsGoal: goal ? amt(goal) : null, + }); + + t.deepEqual(result, { + proceedsExpected: amt(expected), + proceedsTarget: amt(procTarget), + collateralTarget: amt(collTarget), + }); + }, +); + +// These were observed in other tests +test( + 'observed 1', + testAmounts, + { bid: 578, want: 500, avail: 1000, price: [1155, 1000] }, + { + expected: 578, + procTarget: 578, + collTarget: 500, + }, +); + +test( + 'observed 2 - with remaining proceeds goal', + testAmounts, + { bid: 125, want: 100, avail: 400, price: [525, 1000], goal: 200 }, + { + expected: 53, + procTarget: 53, + collTarget: 100, + }, +); + +test( + 'observed 3', + testAmounts, + { bid: 231, want: 200, avail: 1000, price: [1155, 1000] }, + { + expected: 231, + procTarget: 231, + collTarget: 200, + }, +); + +test( + 'observed 4', + testAmounts, + { bid: 232, want: 200, avail: 100, price: [1155, 1000] }, + { + expected: 116, + procTarget: 116, + collTarget: 100, + }, +); + +test( + 'observed 5', + testAmounts, + { bid: 19, want: 300, avail: 300, price: [625, 10000] }, + { + expected: 19, + procTarget: 19, + collTarget: 300, + }, +); + +test( + 'observed 6', + testAmounts, + { bid: 23, want: 200, avail: 500, price: [1125, 10000] }, + { + expected: 23, + procTarget: 23, + collTarget: 200, + }, +); + +test( + 'observed 7', + testAmounts, + { bid: 500, want: 2000, avail: 717, price: [715, 1000] }, + { + expected: 513, + procTarget: 500, + collTarget: 699, + }, +); + +test( + 'observed 8', + testAmounts, + { bid: 240, want: 200, avail: 20, price: [1155, 1000] }, + { + expected: 24, + procTarget: 24, + collTarget: 20, + }, +); + +test( + 'observed 9', + testAmounts, + { bid: 2000, want: 200, avail: 1000, price: [1155, 1000] }, + { + expected: 231, + procTarget: 231, + collTarget: 200, + }, +); + +test( + 'observed 10', + testAmounts, + { bid: 2240, want: 200, avail: 1000, price: [1155, 1000] }, + { + expected: 231, + procTarget: 231, + collTarget: 200, + }, +); + +test( + 'want exceeeds avail', + testAmounts, + { bid: 2000, want: 2000, avail: 1000, price: [1, 1] }, + { + expected: 1000, + procTarget: 1000, + collTarget: 1000, + }, +); + +test( + 'want exceeeds avail at half price', + testAmounts, + { bid: 1999, want: 2000, avail: 1000, price: [201, 1] }, + { + expected: 201000, + procTarget: 1999, + collTarget: 9, + }, +); +test( + 'want exceeeds avail at half price with goal', + testAmounts, + { bid: 1999, want: 2000, avail: 1000, price: [201, 1], goal: 301 }, + { + expected: 201000, + procTarget: 301, + collTarget: 1, + }, +); + +test( + 'observed in production', + testAmounts, + { + bid: 3000, + want: 2000, + avail: 1000, + price: [4914, 10000], // "currentPriceLevel": "0.4914 IST/stOSMO", + goal: 1254_886835, // "remainingProceedsGoal": "1254.886835 IST", + }, + { + expected: 492, + procTarget: 492, + collTarget: 1000, + }, +); diff --git a/packages/zoe/src/contractSupport/ratio.js b/packages/zoe/src/contractSupport/ratio.js index 9beb84852c5..46e0281c04d 100644 --- a/packages/zoe/src/contractSupport/ratio.js +++ b/packages/zoe/src/contractSupport/ratio.js @@ -158,17 +158,26 @@ const divideHelper = (amount, ratio, divideOp) => { ); }; -/** @type {ScaleAmount} */ +/** + * Divide the amount by the ratio, truncating the remainder. + * @type {ScaleAmount} + */ export const floorDivideBy = (amount, ratio) => { return divideHelper(amount, ratio, floorDivide); }; -/** @type {ScaleAmount} */ +/** + * Divide the amount by the ratio, rounding up the remainder. + * @type {ScaleAmount} + */ export const ceilDivideBy = (amount, ratio) => { return divideHelper(amount, ratio, ceilDivide); }; -/** @type {ScaleAmount} */ +/** + * Divide the amount by the ratio, rounding to nearest with ties to even (aka Banker's Rounding) as in IEEE 754 default rounding. + * @type {ScaleAmount} + */ export const divideBy = (amount, ratio) => { return divideHelper(amount, ratio, bankersDivide); }; diff --git a/packages/zoe/test/unitTests/contractSupport/ratio.test.js b/packages/zoe/test/unitTests/contractSupport/ratio.test.js index 2c7896a5ad6..62b9ce9e56d 100644 --- a/packages/zoe/test/unitTests/contractSupport/ratio.test.js +++ b/packages/zoe/test/unitTests/contractSupport/ratio.test.js @@ -16,6 +16,7 @@ import { multiplyBy, subtractRatios, parseRatio, + divideBy, } from '../../../src/contractSupport/ratio.js'; /** @@ -461,6 +462,20 @@ test('ratio - rounding', t => { assertRounding(25n, 2n, 12n, floorMultiplyBy); assertRounding(25n, 2n, 12n, multiplyBy); assertRounding(25n, 2n, 13n, ceilMultiplyBy); + + // 23 / 12 = 1.9 + const twelve = makeRatioFromAmounts(moe(12n), moe(1n)); + amountsEqual(t, floorDivideBy(moe(23n), twelve), moe(1n), brand); + amountsEqual(t, ceilDivideBy(moe(23n), twelve), moe(2n), brand); + amountsEqual(t, divideBy(moe(23n), twelve), moe(2n), brand); + + // banker's rounding + const divideByTen = n => + divideBy(moe(n), makeRatioFromAmounts(moe(10n), moe(1n))); + amountsEqual(t, divideByTen(114n), moe(11n), brand); // 11.4 -> 11 + amountsEqual(t, divideByTen(115n), moe(12n), brand); // 11.5 -> 12 + amountsEqual(t, divideByTen(125n), moe(12n), brand); // 12.5 -> 12 + amountsEqual(t, divideByTen(126n), moe(13n), brand); // 12.6 -> 13 }); test('ratio - oneMinus', t => {