From 3bc0b70ba3a04af605a923e9961591a512d865ea Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 9 Oct 2020 09:37:50 -0700 Subject: [PATCH 1/4] feat: a call spread option contract and tests. Implementation of a fully collateralized call spread option, following Joe Clark's description. This is a combination of a bought call option and a sold call option at a higher strike price. The contracts are sold in pairs, and the buyers of the two positions together invest the entire amount that will be paid out. This option is settled financially. Neither party is expected to have ownership of the underlying asset at the start, and neither expects to take delivery at closing. zoe.startInstance() takes an issuerKeywordRecord that specifies the issuers for the keywords Underlying, Strike, and Collateral. The payout uses Collateral. The price oracle quotes the value of the Underlying in the same units as the Strike prices. creatorFacet has a method makeInvitationPair(), that takes terms that specifies { expiration, underlyingAmount, priceAuthority, strikePrice1, strikePrice2, settlementAmount, buyPercent }. ownerFacet.makeInvitationPair() returns two invitations, which can be sold separately. They settle when the priceAuthority announces the settlement amout as of it's pre-programmed closing time. closes: #1829 --- packages/zoe/src/contracts/callSpread.js | 217 ++++++ .../unitTests/contracts/test-callSpread.js | 664 ++++++++++++++++++ packages/zoe/test/zoeTestHelpers.js | 9 +- 3 files changed, 886 insertions(+), 4 deletions(-) create mode 100644 packages/zoe/src/contracts/callSpread.js create mode 100644 packages/zoe/test/unitTests/contracts/test-callSpread.js diff --git a/packages/zoe/src/contracts/callSpread.js b/packages/zoe/src/contracts/callSpread.js new file mode 100644 index 00000000000..e056dec98e2 --- /dev/null +++ b/packages/zoe/src/contracts/callSpread.js @@ -0,0 +1,217 @@ +// @ts-check +import '../../exported'; + +import { assert, details } from '@agoric/assert'; +import { makePromiseKit } from '@agoric/promise-kit'; +import { E } from '@agoric/eventual-send'; +import { + assertProposalShape, + depositToSeat, + natSafeMath, + trade, + assertUsesNatMath, +} from '../contractSupport'; + +const { subtract, multiply, floorDivide } = natSafeMath; + +/** + * Constants for long and short positions. + * + * @type {{ LONG: 'long', SHORT: 'short' }} + */ +const Position = { + LONG: 'long', + SHORT: 'short', +}; + +const PERCENT_BASE = 100; +const inverse = percent => subtract(PERCENT_BASE, percent); + +/** + * This contract implements a fully collateralized call spread. This is a + * combination of a call option bought at one strike price and a second call + * option sold at a higher price. The contracts are sold in pairs, and the + * purchaser pays the entire amount that will be paid out. The individual + * options are ERTP invitations that are suitable for resale. + * + * This option is settled financially. There is no requirement that the original + * purchaser have ownership of the underlying asset at the start, and the + * beneficiaries shouldn't expect to take delivery at closing. + * + * The issuerKeywordRecord specifies the issuers for four keywords: Underlying, + * Strike, Collateral and Options. The payout is in Collateral. Strike amounts + * are used for the price oracle's quotes as to the value of the Underlying, as + * well as the strike prices in the terms. Options indicates the + * invitationIssuer, which is part of the amounts of the options. The terms + * include { expiration, underlyingAmount, priceAuthority, strikePrice1, + * strikePrice2, settlementAmount }. expiration is a time recognized by the + * priceAuthority. underlyingAmount is passed to the priceAuthority, so it could + * be an NFT or a fungible amount. strikePrice2 must be greater than + * strikePrice1. settlementAmount uses Collateral. + * + * The creatorInvitation has terms that include the amounts of the two options + * as longOption and shortOption. When the creatorInvitation is exercised, the + * payout includes the two option positions, which are themselves invitations + * which can be exercised for free, and provide the option payouts. + * + * Future enhancements: + * + issue multiple option pairs with the same expiration from a single instance + * + create separate invitations to purchase the pieces of the option pair. + * (This would remove the current requirement that an intermediary have the + * total collateral available before the option descriptions have been + * created.) + * + exit the contract when both seats have been paid. + * + increase the precision of the calcluations. (change PERCENT_BASE to 10000) + * + * @type {ContractStartFn} + */ +const start = zcf => { + // terms: underlyingAmount, priceAuthority, strike1, strike2, + // settlementAmount, expiration + + const terms = zcf.getTerms(); + const { + maths: { Collateral: collateralMath, Strike: strikeMath }, + } = terms; + assertUsesNatMath(zcf, collateralMath.getBrand()); + assertUsesNatMath(zcf, strikeMath.getBrand()); + // notice that we don't assert that the Underlying is fungible. + + assert( + strikeMath.isGTE(terms.strikePrice2, terms.strikePrice1), + details`strikePrice2 must be greater than strikePrice1`, + ); + + // Create the two options immediately and allocate them to this seat. + const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); + + // Since the seats for the payout of the settlement aren't created until the + // invitations for the options themselves are exercised, we don't have those + // seats at the time of creation of the options, so we use Promises, and + // allocate the payouts when those promises resolve. + const seatPromiseKits = {}; + + seatPromiseKits[Position.LONG] = makePromiseKit(); + seatPromiseKits[Position.SHORT] = makePromiseKit(); + + function reallocateToSeat(position, sharePercent) { + seatPromiseKits[position].promise.then(seat => { + const currentCollateral = collateralSeat.getCurrentAllocation() + .Collateral; + const totalCollateral = terms.settlementAmount; + const collateralShare = floorDivide( + multiply(totalCollateral.value, sharePercent), + PERCENT_BASE, + ); + const seatPortion = collateralMath.make(collateralShare); + const collateralRemainder = collateralMath.subtract( + currentCollateral, + seatPortion, + ); + zcf.reallocate( + seat.stage({ Collateral: seatPortion }), + collateralSeat.stage({ Collateral: collateralRemainder }), + ); + seat.exit(); + }); + } + + // calculate the portion (as a percentage) of the collateral that should be + // allocated to the long side. + function calculateLongShare(price) { + if (strikeMath.isGTE(terms.strikePrice1, price)) { + return 0; + } else if (strikeMath.isGTE(price, terms.strikePrice2)) { + return PERCENT_BASE; + } + + const denominator = strikeMath.subtract( + terms.strikePrice2, + terms.strikePrice1, + ).value; + const numerator = strikeMath.subtract(price, terms.strikePrice1).value; + return floorDivide(multiply(PERCENT_BASE, numerator), denominator); + } + + function payoffOptions(price) { + // either offer might be exercised late, so we pay the two seats separately. + const longShare = calculateLongShare(price); + reallocateToSeat(Position.LONG, longShare); + reallocateToSeat(Position.SHORT, inverse(longShare)); + } + + function schedulePayoffs() { + terms.priceAuthority + .priceAtTime(terms.expiration, terms.underlyingAmount) + .then(price => payoffOptions(price)); + } + + function makeOptionInvitation(dir) { + const optionsTerms = harden({ + ...terms, + position: dir, + }); + // All we do at time of exercise is resolve the promise. + return zcf.makeInvitation( + seat => seatPromiseKits[dir].resolve(seat), + `collect ${dir} payout`, + optionsTerms, + ); + } + + async function makeOptionPair() { + return { + longInvitation: makeOptionInvitation(Position.LONG), + shortInvitation: makeOptionInvitation(Position.SHORT), + }; + } + + async function makeInvitationToBuy() { + const { longInvitation, shortInvitation } = await makeOptionPair(); + const invitationIssuer = zcf.getInvitationIssuer(); + const longAmount = await E(invitationIssuer).getAmountOf(longInvitation); + const shortAmount = await E(invitationIssuer).getAmountOf(shortInvitation); + depositToSeat( + zcf, + collateralSeat, + { LongOption: longAmount, ShortOption: shortAmount }, + { LongOption: longInvitation, ShortOption: shortInvitation }, + ); + + // transfer collateral from longSeat to collateralSeat, then return a pair + // of callSpread invitations + /** @type {OfferHandler} */ + const pairBuyerPosition = longSeat => { + assertProposalShape(longSeat, { + give: { Collateral: null }, + want: { LongOption: null, ShortOption: null }, + }); + + trade( + zcf, + { + seat: collateralSeat, + gains: { Collateral: terms.settlementAmount }, + }, + { + seat: longSeat, + gains: { LongOption: longAmount, ShortOption: shortAmount }, + }, + ); + schedulePayoffs(); + longSeat.exit(); + }; + + const longTerms = harden({ + ...terms, + LongOption: longAmount, + ShortOption: shortAmount, + }); + return zcf.makeInvitation(pairBuyerPosition, `call spread pair`, longTerms); + } + + return harden({ creatorInvitation: makeInvitationToBuy() }); +}; + +harden(start); +export { start }; diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread.js b/packages/zoe/test/unitTests/contracts/test-callSpread.js new file mode 100644 index 00000000000..0207b04e756 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/test-callSpread.js @@ -0,0 +1,664 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/install-ses'; +// eslint-disable-next-line import/no-extraneous-dependencies +import test from 'ava'; +import { E } from '@agoric/eventual-send'; +import '../../../exported'; +import { makePromiseKit } from '@agoric/promise-kit'; +import buildManualTimer from '../../../tools/manualTimer'; + +import { setup } from '../setupBasicMints'; +import { installationPFromSource } from '../installFromSource'; +import { assertPayoutDeposit, assertPayoutAmount } from '../../zoeTestHelpers'; + +const callSpread = `${__dirname}/../../../src/contracts/callSpread`; +const simpleExchange = `${__dirname}/../../../src/contracts/simpleExchange`; + +function makeFakePriceAuthority( + timer, + underlyingAmountMath, + strikeAmountMath, + priceSchedule, +) { + function priceFromSchedule(strikeTime) { + let freshestPrice = 0; + let freshestTime = -1; + for (const tick of priceSchedule) { + if (tick.time > freshestTime && tick.time <= strikeTime) { + freshestTime = tick.time; + freshestPrice = tick.price; + } + } + return freshestPrice; + } + + const priceAuthority = { + getCurrentPrice: underlyingAmount => { + const underlyingValue = underlyingAmountMath.getValue(underlyingAmount); + return E(timer) + .getCurrentTimestamp() + .then(now => { + const price = priceFromSchedule(now); + return strikeAmountMath.make(price * underlyingValue); + }); + }, + priceAtTime: (timeStamp, underlyingAmount) => { + const { promise, resolve } = makePromiseKit(); + + underlyingAmountMath.getValue(underlyingAmount); + E(timer).setWakeup( + timeStamp, + harden({ + wake: () => { + return resolve(priceAuthority.getCurrentPrice(underlyingAmount)); + }, + }), + ); + return promise; + }, + }; + return priceAuthority; +} + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. The price oracle takes an amount in Underlying, and +// gives the value in Moola. +test('callSpread below Strike1', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature at a low price, and carol will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 1, price: 35 }, + { time: 2, price: 15 }, + { time: 3, price: 28 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit(t, bobPayout, bobBucksPurse, bucks(0)); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(300), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([bobDeposit, carolDeposit]); +}); + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. +test('callSpread above Strike2', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature at a high price, and bob will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 55 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(300), + ); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(0), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([bobDeposit, carolDeposit]); +}); + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. +test('callSpread, mid-strike', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature, and both will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 45 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(225), + ); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(75), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([bobDeposit, carolDeposit]); +}); + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. Carol waits to collect until after settlement +test('callSpread, late exercise', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature, and both will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 45 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(225), + ); + + manualTimer.tick(); + manualTimer.tick(); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = await carolOptionSeat.getPayout('Collateral'); + const carolDepositAmount = await E(carolBucksPurse).deposit(carolPayout); + await t.deepEqual( + carolDepositAmount, + bucks(75), + `payout was ${carolDepositAmount.value}, expected 75`, + ); + await Promise.all([bobDeposit]); +}); + +test('callSpread, sell options', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and sell the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature, and both will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + const aliceBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + const bobBucksPayment = bucksMint.mintPayment(bucks(200)); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + const carolBucksPayment = bucksMint.mintPayment(bucks(100)); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 45 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: longOption, + ShortOption: shortOption, + } = await aliceSeat.getPayouts(); + + const exchangeInstallation = await installationPFromSource( + zoe, + simpleExchange, + ); + const { publicFacet: exchangePublic } = await zoe.startInstance( + exchangeInstallation, + { + Asset: invitationIssuer, + Price: bucksIssuer, + }, + ); + + // Alice offers to sell the long invitation + const aliceLongInvitation = E(exchangePublic).makeInvitation(); + const proposalLong = harden({ + give: { Asset: longOptionAmount }, + want: { Price: bucks(200) }, + }); + const aliceSellLongSeat = await zoe.offer(aliceLongInvitation, proposalLong, { + Asset: longOption, + }); + const aliceLong = assertPayoutDeposit( + t, + aliceSellLongSeat.getPayout('Price'), + aliceBucksPurse, + bucks(200), + ); + + // Alice offers to sell the short invitation + const aliceShortInvitation = E(exchangePublic).makeInvitation(); + const proposalShort = harden({ + give: { Asset: shortOptionAmount }, + want: { Price: bucks(100) }, + }); + const aliceSellShortSeat = await zoe.offer( + aliceShortInvitation, + proposalShort, + { Asset: shortOption }, + ); + const aliceShort = assertPayoutDeposit( + t, + aliceSellShortSeat.getPayout('Price'), + carolBucksPurse, + bucks(100), + ); + + // Bob buys the long invitation + const bobLongInvitation = E(exchangePublic).makeInvitation(); + const bobProposal = harden({ + give: { Price: bucks(200) }, + want: { Asset: longOptionAmount }, + }); + const bobBuySeat = await zoe.offer(bobLongInvitation, bobProposal, { + Price: bobBucksPayment, + }); + const longInvitationPayout = await bobBuySeat.getPayout('Asset'); + assertPayoutAmount( + t, + invitationIssuer, + longInvitationPayout, + longOptionAmount, + ); + const bobOptionSeat = await zoe.offer(longInvitationPayout); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(225), + ); + + // Bob buys the Short invitation + const carolShortInvitation = E(exchangePublic).makeInvitation(); + const carolProposal = harden({ + give: { Price: bucks(100) }, + want: { Asset: shortOptionAmount }, + }); + const carolBuySeat = await zoe.offer(carolShortInvitation, carolProposal, { + Price: carolBucksPayment, + }); + const ShortInvitationPayout = await carolBuySeat.getPayout('Asset'); + assertPayoutAmount( + t, + invitationIssuer, + ShortInvitationPayout, + shortOptionAmount, + ); + const carolOptionSeat = await zoe.offer(ShortInvitationPayout); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(75), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([aliceLong, aliceShort, bobDeposit, carolDeposit]); +}); diff --git a/packages/zoe/test/zoeTestHelpers.js b/packages/zoe/test/zoeTestHelpers.js index cc962016104..f020a02bd7a 100644 --- a/packages/zoe/test/zoeTestHelpers.js +++ b/packages/zoe/test/zoeTestHelpers.js @@ -13,17 +13,18 @@ export const assertPayoutAmount = async ( t.deepEqual(amount, expectedAmount, `${label} payout was ${amount.value}`); }; +// Returns a promise that can be awaited in tests to ensure the check completes. export const assertPayoutDeposit = (t, payout, purse, amount) => { - payout.then(payment => { + return payout.then(payment => { E(purse) .deposit(payment) - .then(payoutAmount => { + .then(payoutAmount => t.deepEqual( payoutAmount, amount, `payout was ${payoutAmount.value}, expected ${amount}.value`, - ); - }); + ), + ); }); }; From a0dd76155896173783d225d110d4f63a45beccdf Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 28 Oct 2020 09:50:23 -0700 Subject: [PATCH 2/4] fix: integrate call spread contract with Oracle API. (#1928) --- packages/zoe/src/contracts/callSpread.js | 21 ++- .../unitTests/contracts/test-callSpread.js | 143 +++++++++++------- 2 files changed, 100 insertions(+), 64 deletions(-) diff --git a/packages/zoe/src/contracts/callSpread.js b/packages/zoe/src/contracts/callSpread.js index e056dec98e2..9d7d80353d3 100644 --- a/packages/zoe/src/contracts/callSpread.js +++ b/packages/zoe/src/contracts/callSpread.js @@ -71,7 +71,9 @@ const start = zcf => { const terms = zcf.getTerms(); const { - maths: { Collateral: collateralMath, Strike: strikeMath }, + maths: { Collateral: collateralMath, Strike: strikeMath, Quote: quoteMath }, + brands: { Strike: strikeBrand }, + issuers: { Quote: quoteIssuer }, } = terms; assertUsesNatMath(zcf, collateralMath.getBrand()); assertUsesNatMath(zcf, strikeMath.getBrand()); @@ -133,17 +135,24 @@ const start = zcf => { return floorDivide(multiply(PERCENT_BASE, numerator), denominator); } - function payoffOptions(price) { - // either offer might be exercised late, so we pay the two seats separately. + function payoffOptions(priceQuoteAmount) { + const { Price: price } = quoteMath.getValue(priceQuoteAmount)[0]; const longShare = calculateLongShare(price); + // either offer might be exercised late, so we pay the two seats separately. reallocateToSeat(Position.LONG, longShare); reallocateToSeat(Position.SHORT, inverse(longShare)); } function schedulePayoffs() { - terms.priceAuthority - .priceAtTime(terms.expiration, terms.underlyingAmount) - .then(price => payoffOptions(price)); + E(terms.priceAuthority) + .priceAtTime( + terms.timer, + terms.expiration, + terms.underlyingAmount, + strikeBrand, + ) + .then(quoteIssuer.getAmountOf) + .then(priceQuoteAmount => payoffOptions(priceQuoteAmount)); } function makeOptionInvitation(dir) { diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread.js b/packages/zoe/test/unitTests/contracts/test-callSpread.js index 0207b04e756..bcd594a99de 100644 --- a/packages/zoe/test/unitTests/contracts/test-callSpread.js +++ b/packages/zoe/test/unitTests/contracts/test-callSpread.js @@ -5,6 +5,7 @@ import test from 'ava'; import { E } from '@agoric/eventual-send'; import '../../../exported'; import { makePromiseKit } from '@agoric/promise-kit'; +import { makeIssuerKit, MathKind } from '@agoric/ertp'; import buildManualTimer from '../../../tools/manualTimer'; import { setup } from '../setupBasicMints'; @@ -15,11 +16,17 @@ const callSpread = `${__dirname}/../../../src/contracts/callSpread`; const simpleExchange = `${__dirname}/../../../src/contracts/simpleExchange`; function makeFakePriceAuthority( - timer, + // timer, underlyingAmountMath, strikeAmountMath, priceSchedule, ) { + const { + mint: quoteMint, + issuer: quoteIssuer, + amountMath: quote, + } = makeIssuerKit('quote', MathKind.SET); + function priceFromSchedule(strikeTime) { let freshestPrice = 0; let freshestTime = -1; @@ -32,25 +39,40 @@ function makeFakePriceAuthority( return freshestPrice; } + function getRecentPrice(timer, desiredPriceBrand, underlyingAmount) { + const underlyingValue = underlyingAmountMath.getValue(underlyingAmount); + return E(timer) + .getCurrentTimestamp() + .then(now => { + const price = priceFromSchedule(now); + const strikePrice = strikeAmountMath.make(price * underlyingValue); + return quoteMint.mintPayment( + quote.make( + harden([ + { + Asset: underlyingAmount, + Price: strikePrice, + timer, + timestamp: now, + }, + ]), + ), + ); + }); + } + const priceAuthority = { - getCurrentPrice: underlyingAmount => { - const underlyingValue = underlyingAmountMath.getValue(underlyingAmount); - return E(timer) - .getCurrentTimestamp() - .then(now => { - const price = priceFromSchedule(now); - return strikeAmountMath.make(price * underlyingValue); - }); - }, - priceAtTime: (timeStamp, underlyingAmount) => { + getQuoteIssuer: () => quoteIssuer, + priceAtTime: (timer, timeStamp, underlyingAmount, strikeBrand) => { const { promise, resolve } = makePromiseKit(); - underlyingAmountMath.getValue(underlyingAmount); E(timer).setWakeup( timeStamp, harden({ wake: () => { - return resolve(priceAuthority.getCurrentPrice(underlyingAmount)); + return resolve( + getRecentPrice(timer, strikeBrand, underlyingAmount), + ); }, }), ); @@ -89,17 +111,8 @@ test('callSpread below Strike1', async t => { // Setup Carol const carolBucksPurse = bucksIssuer.makeEmptyPurse(); - // Alice creates a callSpread instance - const issuerKeywordRecord = harden({ - Underlying: simoleanIssuer, - Collateral: bucksIssuer, - Strike: moolaIssuer, - Options: invitationIssuer, - }); - const manualTimer = buildManualTimer(console.log, 1); const priceAuthority = makeFakePriceAuthority( - manualTimer, amountMaths.get('simoleans'), amountMaths.get('moola'), [ @@ -117,6 +130,16 @@ test('callSpread below Strike1', async t => { strikePrice1: moola(60), strikePrice2: moola(100), settlementAmount: bucks(300), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( installation, @@ -191,17 +214,8 @@ test('callSpread above Strike2', async t => { // Setup Carol const carolBucksPurse = bucksIssuer.makeEmptyPurse(); - // Alice creates a callSpread instance - const issuerKeywordRecord = harden({ - Underlying: simoleanIssuer, - Collateral: bucksIssuer, - Strike: moolaIssuer, - Options: invitationIssuer, - }); - const manualTimer = buildManualTimer(console.log, 1); const priceAuthority = makeFakePriceAuthority( - manualTimer, amountMaths.get('simoleans'), amountMaths.get('moola'), [ @@ -217,6 +231,16 @@ test('callSpread above Strike2', async t => { strikePrice1: moola(60), strikePrice2: moola(100), settlementAmount: bucks(300), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( @@ -297,17 +321,8 @@ test('callSpread, mid-strike', async t => { // Setup Carol const carolBucksPurse = bucksIssuer.makeEmptyPurse(); - // Alice creates a callSpread instance - const issuerKeywordRecord = harden({ - Underlying: simoleanIssuer, - Collateral: bucksIssuer, - Strike: moolaIssuer, - Options: invitationIssuer, - }); - const manualTimer = buildManualTimer(console.log, 1); const priceAuthority = makeFakePriceAuthority( - manualTimer, amountMaths.get('simoleans'), amountMaths.get('moola'), [ @@ -323,7 +338,17 @@ test('callSpread, mid-strike', async t => { strikePrice1: moola(60), strikePrice2: moola(100), settlementAmount: bucks(300), + timer: manualTimer, }); + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + Quote: priceAuthority.getQuoteIssuer(), + }); + const { creatorInvitation } = await zoe.startInstance( installation, issuerKeywordRecord, @@ -402,17 +427,8 @@ test('callSpread, late exercise', async t => { // Setup Carol const carolBucksPurse = bucksIssuer.makeEmptyPurse(); - // Alice creates a callSpread instance - const issuerKeywordRecord = harden({ - Underlying: simoleanIssuer, - Collateral: bucksIssuer, - Strike: moolaIssuer, - Options: invitationIssuer, - }); - const manualTimer = buildManualTimer(console.log, 1); const priceAuthority = makeFakePriceAuthority( - manualTimer, amountMaths.get('simoleans'), amountMaths.get('moola'), [ @@ -428,6 +444,16 @@ test('callSpread, late exercise', async t => { strikePrice1: moola(60), strikePrice2: moola(100), settlementAmount: bucks(300), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( installation, @@ -508,17 +534,8 @@ test('callSpread, sell options', async t => { const carolBucksPurse = bucksIssuer.makeEmptyPurse(); const carolBucksPayment = bucksMint.mintPayment(bucks(100)); - // Alice creates a callSpread instance - const issuerKeywordRecord = harden({ - Underlying: simoleanIssuer, - Collateral: bucksIssuer, - Strike: moolaIssuer, - Options: invitationIssuer, - }); - const manualTimer = buildManualTimer(console.log, 1); const priceAuthority = makeFakePriceAuthority( - manualTimer, amountMaths.get('simoleans'), amountMaths.get('moola'), [ @@ -534,6 +551,16 @@ test('callSpread, sell options', async t => { strikePrice1: moola(60), strikePrice2: moola(100), settlementAmount: bucks(300), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Options: invitationIssuer, + Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( installation, From f900449562bd97964074b29e295765c8240dfa50 Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 28 Oct 2020 12:35:43 -0700 Subject: [PATCH 3/4] chore: cleanups from review This includes changes from the base PR and the separate PR for the updates to the oracle API. --- packages/zoe/src/contracts/callSpread.js | 22 ++++---- .../unitTests/contracts/test-callSpread.js | 51 +++++++++---------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/packages/zoe/src/contracts/callSpread.js b/packages/zoe/src/contracts/callSpread.js index 9d7d80353d3..0d7460bc20a 100644 --- a/packages/zoe/src/contracts/callSpread.js +++ b/packages/zoe/src/contracts/callSpread.js @@ -30,7 +30,7 @@ const inverse = percent => subtract(PERCENT_BASE, percent); /** * This contract implements a fully collateralized call spread. This is a * combination of a call option bought at one strike price and a second call - * option sold at a higher price. The contracts are sold in pairs, and the + * option sold at a higher price. The invitations are produced in pairs, and the * purchaser pays the entire amount that will be paid out. The individual * options are ERTP invitations that are suitable for resale. * @@ -49,10 +49,10 @@ const inverse = percent => subtract(PERCENT_BASE, percent); * be an NFT or a fungible amount. strikePrice2 must be greater than * strikePrice1. settlementAmount uses Collateral. * - * The creatorInvitation has terms that include the amounts of the two options - * as longOption and shortOption. When the creatorInvitation is exercised, the - * payout includes the two option positions, which are themselves invitations - * which can be exercised for free, and provide the option payouts. + * The creatorInvitation has customProperties that include the amounts of the + * two options as longOption and shortOption. When the creatorInvitation is + * exercised, the payout includes the two option positions, which are themselves + * invitations which can be exercised for free, and provide the option payouts. * * Future enhancements: * + issue multiple option pairs with the same expiration from a single instance @@ -73,7 +73,6 @@ const start = zcf => { const { maths: { Collateral: collateralMath, Strike: strikeMath, Quote: quoteMath }, brands: { Strike: strikeBrand }, - issuers: { Quote: quoteIssuer }, } = terms; assertUsesNatMath(zcf, collateralMath.getBrand()); assertUsesNatMath(zcf, strikeMath.getBrand()); @@ -136,7 +135,7 @@ const start = zcf => { } function payoffOptions(priceQuoteAmount) { - const { Price: price } = quoteMath.getValue(priceQuoteAmount)[0]; + const { price } = quoteMath.getValue(priceQuoteAmount)[0]; const longShare = calculateLongShare(price); // either offer might be exercised late, so we pay the two seats separately. reallocateToSeat(Position.LONG, longShare); @@ -151,8 +150,9 @@ const start = zcf => { terms.underlyingAmount, strikeBrand, ) - .then(quoteIssuer.getAmountOf) - .then(priceQuoteAmount => payoffOptions(priceQuoteAmount)); + .then(priceQuote => { + payoffOptions(priceQuote.quoteAmount); + }); } function makeOptionInvitation(dir) { @@ -211,12 +211,12 @@ const start = zcf => { longSeat.exit(); }; - const longTerms = harden({ + const custom = harden({ ...terms, LongOption: longAmount, ShortOption: shortAmount, }); - return zcf.makeInvitation(pairBuyerPosition, `call spread pair`, longTerms); + return zcf.makeInvitation(pairBuyerPosition, `call spread pair`, custom); } return harden({ creatorInvitation: makeInvitationToBuy() }); diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread.js b/packages/zoe/test/unitTests/contracts/test-callSpread.js index bcd594a99de..3542c2bba97 100644 --- a/packages/zoe/test/unitTests/contracts/test-callSpread.js +++ b/packages/zoe/test/unitTests/contracts/test-callSpread.js @@ -16,7 +16,6 @@ const callSpread = `${__dirname}/../../../src/contracts/callSpread`; const simpleExchange = `${__dirname}/../../../src/contracts/simpleExchange`; function makeFakePriceAuthority( - // timer, underlyingAmountMath, strikeAmountMath, priceSchedule, @@ -39,40 +38,36 @@ function makeFakePriceAuthority( return freshestPrice; } - function getRecentPrice(timer, desiredPriceBrand, underlyingAmount) { + function priceQuote(timer, currentTime, underlyingAmount) { const underlyingValue = underlyingAmountMath.getValue(underlyingAmount); - return E(timer) - .getCurrentTimestamp() - .then(now => { - const price = priceFromSchedule(now); - const strikePrice = strikeAmountMath.make(price * underlyingValue); - return quoteMint.mintPayment( - quote.make( - harden([ - { - Asset: underlyingAmount, - Price: strikePrice, - timer, - timestamp: now, - }, - ]), - ), - ); - }); + const price = priceFromSchedule(currentTime); + const strikePrice = strikeAmountMath.make(price * underlyingValue); + const quoteAmount = quote.make( + harden([ + { + assetAmount: underlyingAmount, + price: strikePrice, + timer, + timestamp: currentTime, + }, + ]), + ); + return harden({ + quotePament: quoteMint.mintPayment(quoteAmount), + quoteAmount, + }); } const priceAuthority = { getQuoteIssuer: () => quoteIssuer, - priceAtTime: (timer, timeStamp, underlyingAmount, strikeBrand) => { + priceAtTime: (timer, timeStamp, underlyingAmount) => { const { promise, resolve } = makePromiseKit(); E(timer).setWakeup( timeStamp, harden({ - wake: () => { - return resolve( - getRecentPrice(timer, strikeBrand, underlyingAmount), - ); + wake: time => { + return resolve(priceQuote(timer, time, underlyingAmount)); }, }), ); @@ -147,9 +142,9 @@ test('callSpread below Strike1', async t => { terms, ); - const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); - const longOptionAmount = optionAmount.value[0].LongOption; - const shortOptionAmount = optionAmount.value[0].ShortOption; + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.LongOption; + const shortOptionAmount = invitationDetail.ShortOption; const aliceProposal = harden({ want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, From 779b33bdb2bb74d45385b6024fa7a3c24c1f02ee Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Wed, 28 Oct 2020 17:21:07 -0700 Subject: [PATCH 4/4] chore: more review clean-ups extract calculation for testing purposes. save the invitationIssuer as an issuer in the contract exit contract when done. use trade() rathern than reallocate() some renaming --- packages/zoe/src/contracts/callSpread.js | 181 +++++++++--------- .../contracts/test-callSpread-calculation.js | 78 ++++++++ .../unitTests/contracts/test-callSpread.js | 55 ++---- 3 files changed, 188 insertions(+), 126 deletions(-) create mode 100644 packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js diff --git a/packages/zoe/src/contracts/callSpread.js b/packages/zoe/src/contracts/callSpread.js index 0d7460bc20a..2836fabf476 100644 --- a/packages/zoe/src/contracts/callSpread.js +++ b/packages/zoe/src/contracts/callSpread.js @@ -14,19 +14,6 @@ import { const { subtract, multiply, floorDivide } = natSafeMath; -/** - * Constants for long and short positions. - * - * @type {{ LONG: 'long', SHORT: 'short' }} - */ -const Position = { - LONG: 'long', - SHORT: 'short', -}; - -const PERCENT_BASE = 100; -const inverse = percent => subtract(PERCENT_BASE, percent); - /** * This contract implements a fully collateralized call spread. This is a * combination of a call option bought at one strike price and a second call @@ -39,20 +26,20 @@ const inverse = percent => subtract(PERCENT_BASE, percent); * beneficiaries shouldn't expect to take delivery at closing. * * The issuerKeywordRecord specifies the issuers for four keywords: Underlying, - * Strike, Collateral and Options. The payout is in Collateral. Strike amounts - * are used for the price oracle's quotes as to the value of the Underlying, as - * well as the strike prices in the terms. Options indicates the - * invitationIssuer, which is part of the amounts of the options. The terms - * include { expiration, underlyingAmount, priceAuthority, strikePrice1, - * strikePrice2, settlementAmount }. expiration is a time recognized by the - * priceAuthority. underlyingAmount is passed to the priceAuthority, so it could - * be an NFT or a fungible amount. strikePrice2 must be greater than + * Strike, and Collateral. The payout is in Collateral. Strike amounts are used + * for the price oracle's quotes as to the value of the Underlying, as well as + * the strike prices in the terms. The terms include { timer, underlyingAmount, + * expiration, priceAuthority, strikePrice1, strikePrice2, settlementAmount }. + * The timer must be recognized by the priceAuthority. expiration is a time + * recognized by the timer. underlyingAmount is passed to the priceAuthority, + * so it could be an NFT or a fungible amount. strikePrice2 must be greater than * strikePrice1. settlementAmount uses Collateral. * * The creatorInvitation has customProperties that include the amounts of the - * two options as longOption and shortOption. When the creatorInvitation is + * two options as longAmount and shortAmount. When the creatorInvitation is * exercised, the payout includes the two option positions, which are themselves - * invitations which can be exercised for free, and provide the option payouts. + * invitations which can be exercised for free, and provide the option payouts + * with the keyword Collateral. * * Future enhancements: * + issue multiple option pairs with the same expiration from a single instance @@ -60,14 +47,55 @@ const inverse = percent => subtract(PERCENT_BASE, percent); * (This would remove the current requirement that an intermediary have the * total collateral available before the option descriptions have been * created.) - * + exit the contract when both seats have been paid. - * + increase the precision of the calcluations. (change PERCENT_BASE to 10000) + * + increase the precision of the calculations. (change PERCENT_BASE to 10000) + */ + +/** + * Constants for long and short positions. + * + * @type {{ LONG: 'long', SHORT: 'short' }} + */ +const Position = { + LONG: 'long', + SHORT: 'short', +}; + +const PERCENT_BASE = 100; +const inverse = percent => subtract(PERCENT_BASE, percent); + +/** + * calculate the portion (as a percentage) of the collateral that should be + * allocated to the long side. * + * @param strikeMath AmountMath the math to use + * @param price Amount the value of the underlying asset at closing that + * determines the payouts to the parties + * @param strikePrice1 Amount the lower strike price + * @param strikePrice2 Amount the upper strike price + * + * if price <= strikePrice1, return 0 + * if price >= strikePrice2, return 100. + * Otherwise return a number between 1 and 99 reflecting the position of price + * in the range from strikePrice1 to strikePrice2. + */ +function calculateLongShare(strikeMath, price, strikePrice1, strikePrice2) { + if (strikeMath.isGTE(strikePrice1, price)) { + return 0; + } else if (strikeMath.isGTE(price, strikePrice2)) { + return PERCENT_BASE; + } + + const denominator = strikeMath.subtract(strikePrice2, strikePrice1).value; + const numerator = strikeMath.subtract(price, strikePrice1).value; + return floorDivide(multiply(PERCENT_BASE, numerator), denominator); +} + +/** * @type {ContractStartFn} */ const start = zcf => { - // terms: underlyingAmount, priceAuthority, strike1, strike2, - // settlementAmount, expiration + // terms: underlyingAmount, priceAuthority, strikePrice1, strikePrice2, + // settlementAmount, expiration, timer const terms = zcf.getTerms(); const { @@ -83,6 +111,8 @@ const start = zcf => { details`strikePrice2 must be greater than strikePrice1`, ); + zcf.saveIssuer(zcf.getInvitationIssuer(), 'Options'); + // Create the two options immediately and allocate them to this seat. const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); @@ -94,49 +124,35 @@ const start = zcf => { seatPromiseKits[Position.LONG] = makePromiseKit(); seatPromiseKits[Position.SHORT] = makePromiseKit(); + let seatsExited = 0; function reallocateToSeat(position, sharePercent) { seatPromiseKits[position].promise.then(seat => { - const currentCollateral = collateralSeat.getCurrentAllocation() - .Collateral; const totalCollateral = terms.settlementAmount; const collateralShare = floorDivide( multiply(totalCollateral.value, sharePercent), PERCENT_BASE, ); const seatPortion = collateralMath.make(collateralShare); - const collateralRemainder = collateralMath.subtract( - currentCollateral, - seatPortion, - ); - zcf.reallocate( - seat.stage({ Collateral: seatPortion }), - collateralSeat.stage({ Collateral: collateralRemainder }), + trade( + zcf, + { seat, gains: { Collateral: seatPortion } }, + { seat: collateralSeat, gains: {} }, ); seat.exit(); + seatsExited += 1; + const remainder = collateralSeat.getAmountAllocated('Collateral'); + if (collateralMath.isEmpty(remainder) && seatsExited === 2) { + zcf.shutdown('contract has been settled'); + } }); } - // calculate the portion (as a percentage) of the collateral that should be - // allocated to the long side. - function calculateLongShare(price) { - if (strikeMath.isGTE(terms.strikePrice1, price)) { - return 0; - } else if (strikeMath.isGTE(price, terms.strikePrice2)) { - return PERCENT_BASE; - } - - const denominator = strikeMath.subtract( - terms.strikePrice2, - terms.strikePrice1, - ).value; - const numerator = strikeMath.subtract(price, terms.strikePrice1).value; - return floorDivide(multiply(PERCENT_BASE, numerator), denominator); - } - function payoffOptions(priceQuoteAmount) { const { price } = quoteMath.getValue(priceQuoteAmount)[0]; - const longShare = calculateLongShare(price); + const strike1 = terms.strikePrice1; + const strike2 = terms.strikePrice2; + const longShare = calculateLongShare(strikeMath, price, strike1, strike2); // either offer might be exercised late, so we pay the two seats separately. reallocateToSeat(Position.LONG, longShare); reallocateToSeat(Position.SHORT, inverse(longShare)); @@ -150,48 +166,34 @@ const start = zcf => { terms.underlyingAmount, strikeBrand, ) - .then(priceQuote => { - payoffOptions(priceQuote.quoteAmount); - }); + .then(priceQuote => payoffOptions(priceQuote.quoteAmount)); } function makeOptionInvitation(dir) { - const optionsTerms = harden({ - ...terms, - position: dir, - }); // All we do at time of exercise is resolve the promise. return zcf.makeInvitation( seat => seatPromiseKits[dir].resolve(seat), `collect ${dir} payout`, - optionsTerms, + { position: dir }, ); } - async function makeOptionPair() { - return { - longInvitation: makeOptionInvitation(Position.LONG), - shortInvitation: makeOptionInvitation(Position.SHORT), + async function makeCreatorInvitation() { + const pair = { + LongOption: makeOptionInvitation(Position.LONG), + ShortOption: makeOptionInvitation(Position.SHORT), }; - } - - async function makeInvitationToBuy() { - const { longInvitation, shortInvitation } = await makeOptionPair(); const invitationIssuer = zcf.getInvitationIssuer(); - const longAmount = await E(invitationIssuer).getAmountOf(longInvitation); - const shortAmount = await E(invitationIssuer).getAmountOf(shortInvitation); - depositToSeat( - zcf, - collateralSeat, - { LongOption: longAmount, ShortOption: shortAmount }, - { LongOption: longInvitation, ShortOption: shortInvitation }, - ); + const longAmount = await E(invitationIssuer).getAmountOf(pair.LongOption); + const shortAmount = await E(invitationIssuer).getAmountOf(pair.ShortOption); + const amounts = { LongOption: longAmount, ShortOption: shortAmount }; + await depositToSeat(zcf, collateralSeat, amounts, pair); - // transfer collateral from longSeat to collateralSeat, then return a pair - // of callSpread invitations + // transfer collateral from creator to collateralSeat, then return a pair + // of callSpread options /** @type {OfferHandler} */ - const pairBuyerPosition = longSeat => { - assertProposalShape(longSeat, { + const createOptionsHandler = creatorSeat => { + assertProposalShape(creatorSeat, { give: { Collateral: null }, want: { LongOption: null, ShortOption: null }, }); @@ -203,24 +205,23 @@ const start = zcf => { gains: { Collateral: terms.settlementAmount }, }, { - seat: longSeat, + seat: creatorSeat, gains: { LongOption: longAmount, ShortOption: shortAmount }, }, ); schedulePayoffs(); - longSeat.exit(); + creatorSeat.exit(); }; const custom = harden({ - ...terms, - LongOption: longAmount, - ShortOption: shortAmount, + longAmount, + shortAmount, }); - return zcf.makeInvitation(pairBuyerPosition, `call spread pair`, custom); + return zcf.makeInvitation(createOptionsHandler, `call spread pair`, custom); } - return harden({ creatorInvitation: makeInvitationToBuy() }); + return harden({ creatorInvitation: makeCreatorInvitation() }); }; harden(start); -export { start }; +export { start, calculateLongShare }; diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js b/packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js new file mode 100644 index 00000000000..e4f19f39a1a --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js @@ -0,0 +1,78 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/install-ses'; +// eslint-disable-next-line import/no-extraneous-dependencies +import test from 'ava'; +import '../../../exported'; + +import { setup } from '../setupBasicMints'; +import { calculateLongShare } from '../../../src/contracts/callSpread'; + +test('callSpread-calculation, at lower bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(20); + const strike2 = moola(70); + const price = moola(20); + t.is(0, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, at upper bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(20); + const strike2 = moola(55); + const price = moola(55); + t.is(100, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, below lower bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(55); + const price = moola(0); + t.is(0, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, above upper bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(55); + const price = moola(60); + t.is(100, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, mid-way', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(45); + const price = moola(40); + t.is(83, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, zero', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(45); + const price = moola(0); + t.is(0, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, large', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(45); + const price = moola(10000000000); + t.is(100, calculateLongShare(moolaMath, price, strike1, strike2)); +}); diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread.js b/packages/zoe/test/unitTests/contracts/test-callSpread.js index 3542c2bba97..a0e1e5f659e 100644 --- a/packages/zoe/test/unitTests/contracts/test-callSpread.js +++ b/packages/zoe/test/unitTests/contracts/test-callSpread.js @@ -93,7 +93,6 @@ test('callSpread below Strike1', async t => { amountMaths, } = setup(); const installation = await installationPFromSource(zoe, callSpread); - const invitationIssuer = await E(zoe).getInvitationIssuer(); // Alice will create and fund a call spread contract, and give the invitations // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. @@ -133,7 +132,6 @@ test('callSpread below Strike1', async t => { Underlying: simoleanIssuer, Collateral: bucksIssuer, Strike: moolaIssuer, - Options: invitationIssuer, Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( @@ -143,8 +141,8 @@ test('callSpread below Strike1', async t => { ); const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); - const longOptionAmount = invitationDetail.LongOption; - const shortOptionAmount = invitationDetail.ShortOption; + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; const aliceProposal = harden({ want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, @@ -156,8 +154,6 @@ test('callSpread below Strike1', async t => { aliceProposal, alicePayments, ); - const aliceOption = await aliceSeat.getOfferResult(); - t.truthy(invitationIssuer.isLive(aliceOption)); const { LongOption: bobLongOption, ShortOption: carolShortOption, @@ -196,7 +192,6 @@ test('callSpread above Strike2', async t => { amountMaths, } = setup(); const installation = await installationPFromSource(zoe, callSpread); - const invitationIssuer = await E(zoe).getInvitationIssuer(); // Alice will create and fund a call spread contract, and give the invitations // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. @@ -234,7 +229,6 @@ test('callSpread above Strike2', async t => { Underlying: simoleanIssuer, Collateral: bucksIssuer, Strike: moolaIssuer, - Options: invitationIssuer, Quote: priceAuthority.getQuoteIssuer(), }); @@ -244,9 +238,9 @@ test('callSpread above Strike2', async t => { terms, ); - const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); - const longOptionAmount = optionAmount.value[0].LongOption; - const shortOptionAmount = optionAmount.value[0].ShortOption; + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; const aliceProposal = harden({ want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, @@ -258,8 +252,6 @@ test('callSpread above Strike2', async t => { aliceProposal, alicePayments, ); - const aliceOption = await aliceSeat.getOfferResult(); - t.truthy(invitationIssuer.isLive(aliceOption)); const { LongOption: bobLongOption, ShortOption: carolShortOption, @@ -303,7 +295,6 @@ test('callSpread, mid-strike', async t => { amountMaths, } = setup(); const installation = await installationPFromSource(zoe, callSpread); - const invitationIssuer = await E(zoe).getInvitationIssuer(); // Alice will create and fund a call spread contract, and give the invitations // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. @@ -340,7 +331,6 @@ test('callSpread, mid-strike', async t => { Underlying: simoleanIssuer, Collateral: bucksIssuer, Strike: moolaIssuer, - Options: invitationIssuer, Quote: priceAuthority.getQuoteIssuer(), }); @@ -350,9 +340,9 @@ test('callSpread, mid-strike', async t => { terms, ); - const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); - const longOptionAmount = optionAmount.value[0].LongOption; - const shortOptionAmount = optionAmount.value[0].ShortOption; + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; const aliceProposal = harden({ want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, @@ -364,8 +354,6 @@ test('callSpread, mid-strike', async t => { aliceProposal, alicePayments, ); - const aliceOption = await aliceSeat.getOfferResult(); - t.truthy(invitationIssuer.isLive(aliceOption)); const { LongOption: bobLongOption, ShortOption: carolShortOption, @@ -409,7 +397,6 @@ test('callSpread, late exercise', async t => { amountMaths, } = setup(); const installation = await installationPFromSource(zoe, callSpread); - const invitationIssuer = await E(zoe).getInvitationIssuer(); // Alice will create and fund a call spread contract, and give the invitations // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. @@ -447,7 +434,6 @@ test('callSpread, late exercise', async t => { Underlying: simoleanIssuer, Collateral: bucksIssuer, Strike: moolaIssuer, - Options: invitationIssuer, Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( @@ -456,12 +442,14 @@ test('callSpread, late exercise', async t => { terms, ); - const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); - const longOptionAmount = optionAmount.value[0].LongOption; - const shortOptionAmount = optionAmount.value[0].ShortOption; - + const invitationDetails = await E(zoe).getInvitationDetails( + creatorInvitation, + ); const aliceProposal = harden({ - want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + want: { + LongOption: invitationDetails.longAmount, + ShortOption: invitationDetails.shortAmount, + }, give: { Collateral: bucks(300) }, }); const alicePayments = { Collateral: aliceBucksPayment }; @@ -470,8 +458,6 @@ test('callSpread, late exercise', async t => { aliceProposal, alicePayments, ); - const aliceOption = await aliceSeat.getOfferResult(); - t.truthy(invitationIssuer.isLive(aliceOption)); const { LongOption: bobLongOption, ShortOption: carolShortOption, @@ -554,7 +540,6 @@ test('callSpread, sell options', async t => { Underlying: simoleanIssuer, Collateral: bucksIssuer, Strike: moolaIssuer, - Options: invitationIssuer, Quote: priceAuthority.getQuoteIssuer(), }); const { creatorInvitation } = await zoe.startInstance( @@ -563,9 +548,9 @@ test('callSpread, sell options', async t => { terms, ); - const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); - const longOptionAmount = optionAmount.value[0].LongOption; - const shortOptionAmount = optionAmount.value[0].ShortOption; + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; const aliceProposal = harden({ want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, @@ -577,8 +562,6 @@ test('callSpread, sell options', async t => { aliceProposal, alicePayments, ); - const aliceOption = await aliceSeat.getOfferResult(); - t.truthy(invitationIssuer.isLive(aliceOption)); const { LongOption: longOption, ShortOption: shortOption, @@ -655,7 +638,7 @@ test('callSpread, sell options', async t => { bucks(225), ); - // Bob buys the Short invitation + // Carol buys the Short invitation const carolShortInvitation = E(exchangePublic).makeInvitation(); const carolProposal = harden({ give: { Price: bucks(100) },