From 0e2e39bd7da60fcdf05376b010180b76308c8dc6 Mon Sep 17 00:00:00 2001 From: Teddy Ding Date: Wed, 22 Jan 2025 21:38:09 -0500 Subject: [PATCH] Incorporate defaultFunding into `nextFunding` --- .../src/types/perpetual-market-types.ts | 1 + .../caches/next-funding-cache.test.ts | 26 +++- .../redis/src/caches/next-funding-cache.ts | 7 +- .../handlers/funding-handler.test.ts | 79 +++++++++++-- .../ender/__tests__/helpers/redis-helpers.ts | 7 +- .../__tests__/tasks/market-updater.test.ts | 111 +++++++++++++++++- .../roundtable/src/tasks/market-updater.ts | 12 +- 7 files changed, 216 insertions(+), 27 deletions(-) diff --git a/indexer/packages/postgres/src/types/perpetual-market-types.ts b/indexer/packages/postgres/src/types/perpetual-market-types.ts index 9f75a625d09..4a1c5215f23 100644 --- a/indexer/packages/postgres/src/types/perpetual-market-types.ts +++ b/indexer/packages/postgres/src/types/perpetual-market-types.ts @@ -37,6 +37,7 @@ export interface PerpetualMarketUpdateObject { subticksPerTick?: number, stepBaseQuantums?: number, liquidityTierId?: number, + defaultFundingRate1H?: string, } export enum PerpetualMarketColumns { diff --git a/indexer/packages/redis/__tests__/caches/next-funding-cache.test.ts b/indexer/packages/redis/__tests__/caches/next-funding-cache.test.ts index 24d6e95ff48..bdfb1776e5c 100644 --- a/indexer/packages/redis/__tests__/caches/next-funding-cache.test.ts +++ b/indexer/packages/redis/__tests__/caches/next-funding-cache.test.ts @@ -17,8 +17,14 @@ describe('nextFundingCache', () => { await addFundingSample('BTC', new Big('0.0001'), client); await addFundingSample('BTC', new Big('0.0002'), client); // avg = 0.00015 await addFundingSample('ETH', new Big('0.0005'), client); // avg = 0.0005 - expect(await getNextFunding(client, ['BTC', 'ETH'])).toEqual( - { BTC: new Big('0.00015'), ETH: new Big('0.0005') }, + expect(await getNextFunding(client, [ + ['BTC', '0.0001'], + ['ETH', '0'], + ])).toEqual( + { + BTC: new Big('0.00025'), // 0.00015 + 0.0001 + ETH: new Big('0.0005'), // 0.0005 + 0 + }, ); }); @@ -27,13 +33,23 @@ describe('nextFundingCache', () => { await addFundingSample('BTC', new Big('0.0002'), client); // avg = 0.00015 await clearFundingSamples('BTC', client); await addFundingSample('ETH', new Big('0.0005'), client); // avg = 0.0005 - expect(await getNextFunding(client, ['BTC', 'ETH'])).toEqual( - { BTC: undefined, ETH: new Big('0.0005') }, + expect(await getNextFunding(client, [ + ['BTC', '0.0001'], + ['ETH', '0.00015'], + ])).toEqual( + { + BTC: undefined, // no samples + ETH: new Big('0.00065'), // 0.0005 + 0.00015 + }, ); }); it('get next funding with no values', async () => { - expect(await getNextFunding(client, ['BTC'])).toEqual( + expect(await getNextFunding(client, [ + ['BTC', '0.001'], + ])).toEqual( + // Even though default funding rate is 0.001, + // return undefined since there are no samples { BTC: undefined }, ); }); diff --git a/indexer/packages/redis/src/caches/next-funding-cache.ts b/indexer/packages/redis/src/caches/next-funding-cache.ts index cff104ae53f..7f4b21fb15a 100644 --- a/indexer/packages/redis/src/caches/next-funding-cache.ts +++ b/indexer/packages/redis/src/caches/next-funding-cache.ts @@ -20,11 +20,12 @@ function getKey(ticker: string): string { */ export async function getNextFunding( client: RedisClient, - tickers: string[], + tickerDefaultFundingRate1HPairs: [string, string][], ): Promise<{ [ticker: string]: Big | undefined }> { const fundingRates: { [ticker: string]: Big | undefined } = {}; + await Promise.all( - tickers.map(async (ticker: string) => { + tickerDefaultFundingRate1HPairs.map(async ([ticker, defaultFundingRate1H]) => { const rates: string[] = await lRangeAsync( getKey(ticker), client, @@ -36,7 +37,7 @@ export async function getNextFunding( new Big(0), ); const avg: Big = sum.div(rates.length); - fundingRates[ticker] = avg; + fundingRates[ticker] = avg.plus(new Big(defaultFundingRate1H)); } else { fundingRates[ticker] = undefined; } diff --git a/indexer/services/ender/__tests__/handlers/funding-handler.test.ts b/indexer/services/ender/__tests__/handlers/funding-handler.test.ts index afe0df78fa2..fd7a0fcf4e1 100644 --- a/indexer/services/ender/__tests__/handlers/funding-handler.test.ts +++ b/indexer/services/ender/__tests__/handlers/funding-handler.test.ts @@ -12,6 +12,7 @@ import { FundingIndexUpdatesColumns, FundingIndexUpdatesFromDatabase, FundingIndexUpdatesTable, + PerpetualMarketTable, OraclePriceTable, Ordering, perpetualMarketRefresher, @@ -42,6 +43,7 @@ import Big from 'big.js'; import { redisClient } from '../../src/helpers/redis/redis-controller'; import { bigIntToBytes } from '@dydxprotocol-indexer/v4-proto-parser'; import { createPostgresFunctions } from '../../src/helpers/postgres/postgres-functions'; +import { defaultPerpetualMarket } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; describe('fundingHandler', () => { beforeAll(async () => { @@ -127,10 +129,65 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage); await expectNextFundingRate( - 'BTC-USD', new Big(protocolTranslations.funding8HourValuePpmTo1HourRate( defaultFundingUpdateSampleEvent.updates[0].fundingValuePpm, )), + 'BTC-USD', + ); + }); + + it.each([ + [ + 'Non-zero sample', + 'TEST-USD', + '0.0001', + 120, + new Big('0.000115'), // 0.000120 / 8 + default 0.0001 + ], + [ + 'Sample is zero', + 'TEST-USD', + '0.0001', + 0, + new Big('0.0001'), // 0 + default 0.0001 + ], + ])('(%s) Non-zero default funding: successfully handle premium sample', async ( + _name: string, + ticker: string, + defaultFundingRate1H: string, + fundingValuePpm: number, + expectedNextFundingRate: Big, + ) => { + const testPerpetualMarket = await PerpetualMarketTable.create({ + ...defaultPerpetualMarket, + id: '1000', // Different id than `defaultPerpeptualMarket` to avoid conflict + ticker, + defaultFundingRate1H, + }); + + const fundingUpdateSampleEvent: FundingEventV1 = { + type: FundingEventV1_Type.TYPE_PREMIUM_SAMPLE, + updates: [ + { + perpetualId: parseInt(testPerpetualMarket.id, 10), + fundingValuePpm, + fundingIndex: bigIntToBytes(BigInt(0)), + }, + ], + }; + + const kafkaMessage: KafkaMessage = createKafkaMessageFromFundingEvents({ + fundingEvents: [fundingUpdateSampleEvent], + height: defaultHeight, + time: defaultTime, + }); + + await onMessage(kafkaMessage); + + await expectNextFundingRate( + expectedNextFundingRate, + ticker, + defaultFundingRate1H, ); }); @@ -160,14 +217,14 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage); await expectNextFundingRate( - 'BTC-USD', new Big('0.000006875'), + 'BTC-USD', ); await expectNextFundingRate( - 'ETH-USD', new Big(protocolTranslations.funding8HourValuePpmTo1HourRate( fundingUpdateSampleEvent2.updates[1].fundingValuePpm, )), + 'ETH-USD', ); }); @@ -204,10 +261,10 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage); await expectNextFundingRate( - 'BTC-USD', new Big(protocolTranslations.funding8HourValuePpmTo1HourRate( defaultFundingUpdateSampleEvent.updates[0].fundingValuePpm, )), + 'BTC-USD', ); const kafkaMessage2: KafkaMessage = createKafkaMessageFromFundingEvents({ @@ -218,8 +275,8 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage2); await expectNextFundingRate( - 'BTC-USD', undefined, + 'BTC-USD', ); const fundingIndices: FundingIndexUpdatesFromDatabase[] = await FundingIndexUpdatesTable.findAll({}, [], {}); @@ -253,10 +310,10 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage); await expectNextFundingRate( - 'BTC-USD', new Big(protocolTranslations.funding8HourValuePpmTo1HourRate( defaultFundingUpdateSampleEvent.updates[0].fundingValuePpm, )), + 'BTC-USD', ); const kafkaMessage2: KafkaMessage = createKafkaMessageFromFundingEvents({ @@ -267,8 +324,8 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage2); await expectNextFundingRate( - 'BTC-USD', undefined, + 'BTC-USD', ); const fundingIndices: FundingIndexUpdatesFromDatabase[] = await FundingIndexUpdatesTable.findAll({}, [], {}); @@ -310,16 +367,16 @@ describe('fundingHandler', () => { await Promise.all([ expectNextFundingRate( - 'BTC-USD', new Big(protocolTranslations.funding8HourValuePpmTo1HourRate( fundingSampleEvent.updates[0].fundingValuePpm, )), + 'BTC-USD', ), expectNextFundingRate( - 'ETH-USD', new Big(protocolTranslations.funding8HourValuePpmTo1HourRate( fundingSampleEvent.updates[1].fundingValuePpm, )), + 'ETH-USD', ), ]); @@ -347,12 +404,12 @@ describe('fundingHandler', () => { await onMessage(kafkaMessage2); await Promise.all([ expectNextFundingRate( - 'BTC-USD', undefined, + 'BTC-USD', ), expectNextFundingRate( - 'ETH-USD', undefined, + 'ETH-USD', ), ]); const fundingIndices: FundingIndexUpdatesFromDatabase[] = await diff --git a/indexer/services/ender/__tests__/helpers/redis-helpers.ts b/indexer/services/ender/__tests__/helpers/redis-helpers.ts index 7f2ec30800a..6a88a4ac037 100644 --- a/indexer/services/ender/__tests__/helpers/redis-helpers.ts +++ b/indexer/services/ender/__tests__/helpers/redis-helpers.ts @@ -9,14 +9,15 @@ import Big from 'big.js'; import { redisClient } from '../../src/helpers/redis/redis-controller'; export async function expectNextFundingRate( + expectedRate: Big | undefined, ticker: string, - rate: Big | undefined, + defaultFundingRate1H: string = '0', ): Promise { const rates: { [ticker: string]: Big | undefined } = await NextFundingCache.getNextFunding( redisClient, - [ticker], + [[ticker, defaultFundingRate1H]], ); - expect(rates[ticker]).toEqual(rate); + expect(rates[ticker]).toEqual(expectedRate); } export async function expectStateFilledQuantums( diff --git a/indexer/services/roundtable/__tests__/tasks/market-updater.test.ts b/indexer/services/roundtable/__tests__/tasks/market-updater.test.ts index b142b8b3d59..e97559e3fd3 100644 --- a/indexer/services/roundtable/__tests__/tasks/market-updater.test.ts +++ b/indexer/services/roundtable/__tests__/tasks/market-updater.test.ts @@ -144,7 +144,7 @@ describe('market-updater', () => { jest.clearAllMocks(); }); - it('succeeds with no fills, positions or funding rates', async () => { + it('succeeds with no fills, positions or funding samples', async () => { const producerSendSpy: jest.SpyInstance = jest.spyOn(producer, 'send'); const perpetualMarkets: PerpetualMarketFromDatabase[] = await PerpetualMarketTable.findAll( {}, @@ -368,7 +368,12 @@ describe('market-updater', () => { await Promise.all([ PerpetualMarketTable.update(perpMarketUpdate1), - PerpetualMarketTable.update(perpMarketUpdate2), + PerpetualMarketTable.update({ + ...perpMarketUpdate2, + // set initial `nextFundingRate` to non-zero + // we still don't expect update message if no funding samples. + nextFundingRate: '0.000225', + }), PerpetualMarketTable.update(perpMarketUpdate3), ]); @@ -376,6 +381,108 @@ describe('market-updater', () => { expect(producerSendSpy).toHaveBeenCalledTimes(0); }); + it('(non-zero default funding) no message sent if no update, and no funding samples', async () => { + const producerSendSpy: jest.SpyInstance = jest.spyOn(producer, 'send'); + synchronizeWrapBackgroundTask(wrapBackgroundTask); + await OrderTable.create(testConstants.defaultOrder); + await Promise.all([ + FillTable.create(testConstants.defaultFill), + PerpetualPositionTable.create(testConstants.defaultPerpetualPosition), + ]); + + await Promise.all([ + PerpetualMarketTable.update({ + ...perpMarketUpdate1, + defaultFundingRate1H: '0.0000125', + }), + PerpetualMarketTable.update({ + ...perpMarketUpdate2, + defaultFundingRate1H: '0.0000125', + }), + PerpetualMarketTable.update(perpMarketUpdate3), + ]); + + await marketUpdaterTask(); + expect(producerSendSpy).toHaveBeenCalledTimes(0); + }); + + it('(non-zero default funding) message send with funding sample of 0', async () => { + const producerSendSpy: jest.SpyInstance = jest.spyOn(producer, 'send'); + synchronizeWrapBackgroundTask(wrapBackgroundTask); + await OrderTable.create(testConstants.defaultOrder); + await Promise.all([ + FillTable.create(testConstants.defaultFill), + PerpetualPositionTable.create(testConstants.defaultPerpetualPosition), + ]); + + await Promise.all([ + NextFundingCache.addFundingSample( + testConstants.defaultPerpetualMarket.ticker, + new Big(0), // `0` sample for BTC-USD + redisClient, + ), + NextFundingCache.addFundingSample( + testConstants.defaultPerpetualMarket2.ticker, + new Big(0), // `0` sample for ETH-USD + redisClient, + ), + ]); + + await Promise.all([ + PerpetualMarketTable.update({ + ...perpMarketUpdate1, + // `nextFundingRate` equal to default funding rate. + // With a funding sample of `0` we don't expect a mssage to be sent for BTC-USD + nextFundingRate: '0.0000125', + defaultFundingRate1H: '0.0000125', + }), + PerpetualMarketTable.update({ + ...perpMarketUpdate2, + nextFundingRate: '0', + // Differ from current `nextFundingRate` of 0. + // Expect a message for this. + defaultFundingRate1H: '0.0000125', + }), + PerpetualMarketTable.update(perpMarketUpdate3), + ]); + + const perpetualMarkets: PerpetualMarketFromDatabase[] = await PerpetualMarketTable.findAll( + {}, + [], + ); + const perpetualMarketMap: _.Dictionary = _.keyBy( + perpetualMarkets, + PerpetualMarketColumns.id, + ); + const liquidityTiers: + LiquidityTiersFromDatabase[] = await LiquidityTiersTable.findAll({}, []); + + const liquidityTiersMap: LiquidityTiersMap = _.keyBy( + liquidityTiers, + LiquidityTiersColumns.id, + ); + + await marketUpdaterTask(); + + const newPerpetualMarketMap: _.Dictionary = {}; + newPerpetualMarketMap[testConstants.defaultPerpetualPosition.perpetualId] = { + ...perpetualMarketMap[testConstants.defaultPerpetualPosition.perpetualId], + }; + newPerpetualMarketMap[testConstants.defaultPerpetualMarket2.id] = { + ...perpetualMarketMap[testConstants.defaultPerpetualMarket2.id], + nextFundingRate: '0.0000125', + }; + newPerpetualMarketMap[testConstants.defaultPerpetualMarket3.id] = { + ...perpetualMarketMap[testConstants.defaultPerpetualMarket3.id], + }; + + const contents: string = JSON.stringify( + getUpdatedMarkets(perpetualMarketMap, newPerpetualMarketMap, liquidityTiersMap), + ); + expectMarketWebsocketMessage(producerSendSpy, contents); + expect(producerSendSpy).toHaveBeenCalledTimes(1); + }); + it('update sent if position and fills update, but funding was not', async () => { const producerSendSpy: jest.SpyInstance = jest.spyOn(producer, 'send'); synchronizeWrapBackgroundTask(wrapBackgroundTask); diff --git a/indexer/services/roundtable/src/tasks/market-updater.ts b/indexer/services/roundtable/src/tasks/market-updater.ts index ab6d2ea30ec..b47f0770019 100644 --- a/indexer/services/roundtable/src/tasks/market-updater.ts +++ b/indexer/services/roundtable/src/tasks/market-updater.ts @@ -52,7 +52,14 @@ export default async function runTask(): Promise { PerpetualMarketFromDatabase[] = await PerpetualMarketTable.findAll({}, []); const perpetualMarketIds: string[] = _.map(perpetualMarkets, PerpetualMarketColumns.id); const clobPairIds: string[] = _.map(perpetualMarkets, PerpetualMarketColumns.clobPairId); - const tickers: string[] = _.map(perpetualMarkets, PerpetualMarketColumns.ticker); + const tickerDefaultFundingRate1HPairs: [string, string][] = _.map( + perpetualMarkets, + (market) => [ + market[PerpetualMarketColumns.ticker], + // Use 0 as default for null default funding rate + market[PerpetualMarketColumns.defaultFundingRate1H] ?? '0', + ], + ); const latestPrices: PriceMap = await OraclePriceTable.getLatestPrices(); const prices24hAgo: PriceMap = await OraclePriceTable.getPricesFrom24hAgo(); @@ -70,8 +77,7 @@ export default async function runTask(): Promise { // TODO(DEC-1149 Add support for pulling information from candles FillTable.get24HourInformation(clobPairIds), PerpetualPositionTable.getOpenInterestLong(perpetualMarketIds), - // TODO(CT-1340): Need to add default funding rate to this value. - NextFundingCache.getNextFunding(redisClient, tickers), + NextFundingCache.getNextFunding(redisClient, tickerDefaultFundingRate1HPairs), ]); stats.timing(