diff --git a/.env.trades.local.example b/.env.trades.local.example new file mode 100644 index 00000000..402217ff --- /dev/null +++ b/.env.trades.local.example @@ -0,0 +1,22 @@ +ENDPOINT=https://api.devnet.solana.com +WS_ENDPOINT=wss://api.devnet.solana.com +ENV=devnet + +RUNNING_LOCAL=true +LOCAL_CACHE=true +ELASTICACHE_HOST=localhost +ELASTICACHE_PORT=6379 +REDIS_CLIENT=DLOB + +METRICS_PORT=9465 +INDICATIVE_QUOTES_MAX_AGE_MS=1000 +INDICATIVE_QUOTES_CACHE_TTL_MS=250 + +ENABLE_MOCK_FILL_ENDPOINT=true +MOCK_ONLY_MODE=true +MOCK_FILL_PORT=9470 + +MOCK_MARKET_TYPE=perp +MOCK_MARKET_INDEX=0 +MOCK_QUOTES_JSON=[{"maker":"good-maker","side":"long","price":100,"size":2},{"maker":"bad-maker","side":"long","price":99,"size":1}] +MOCK_FILLS_JSON=[{"maker":"good-maker","side":"long","fillPrice":100,"fillSize":1,"oraclePrice":100}] diff --git a/.gitignore b/.gitignore index d5455f74..26ea2ce4 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,7 @@ src/**.js.map .idea .env +.env.*.local lib src/playground.ts @@ -146,4 +147,5 @@ src/playground.ts *.rdb *.log *.confg - +*.aof* +*.conf diff --git a/package.json b/package.json index 5cfe0a2f..abf4aff4 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "server-lite": "ts-node src/serverLite.ts", "dlob-publish": "ts-node src/publishers/dlobPublisher.ts", "trades-publish": "ts-node src/publishers/tradesPublisher.ts", + "trades-publish:local": "DOTENV_CONFIG_PATH=.env.trades.local ts-node -r dotenv/config src/publishers/tradesPublisher.ts", + "mock-jit:submit": "DOTENV_CONFIG_PATH=.env.trades.local ts-node -r dotenv/config src/scripts/submitMockJitMetricsData.ts", "fees-publish": "ts-node src/publishers/priorityFeesPublisher.ts", "pnl-publish": "ts-node src/scripts/publishUnsettledPnlUsers.ts", "ws-manager": "ts-node src/wsConnectionManager.ts", @@ -84,8 +86,8 @@ "lint": "eslint . --ext ts --quiet", "lint:fix": "eslint . --ext ts --fix", "playground": "ts-node src/playground.ts", - "test": "jest src/utils/tests/auctionParams.test.ts", - "test:watch": "jest src/utils/tests/auctionParams.test.ts --watch" + "test": "jest", + "test:watch": "jest --watch" }, "jest": { "preset": "ts-jest", diff --git a/src/publishers/tests/tradeMetrics.test.ts b/src/publishers/tests/tradeMetrics.test.ts new file mode 100644 index 00000000..bad5f218 --- /dev/null +++ b/src/publishers/tests/tradeMetrics.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from '@jest/globals'; +import { BASE_PRECISION, PRICE_PRECISION } from '@drift-labs/sdk'; +import { + getAbsoluteBpsDiff, + getCompetitiveLiquidity, + getIndicativeDirectionBucket, + getFillPrice, + getFillSide, + getFillTimestampMs, + getIndicativeBpsBucket, + getQuoteTimestampMs, + getQuoteValueOnBook, + getSignedBpsDiff, + isCompetitivePrice, + rawPriceToNumber, +} from '../tradeMetrics'; + +describe('tradeMetrics', () => { + describe('getFillPrice', () => { + it('computes the executed unit price', () => { + expect( + getFillPrice({ + baseAssetAmountFilled: 2, + quoteAssetAmountFilled: 210, + }) + ).toBe(105); + }); + + it('returns undefined for zero base size', () => { + expect( + getFillPrice({ + baseAssetAmountFilled: 0, + quoteAssetAmountFilled: 210, + }) + ).toBeUndefined(); + }); + }); + + describe('getFillTimestampMs', () => { + it('normalizes seconds to milliseconds', () => { + expect(getFillTimestampMs(1710000000)).toBe(1710000000000); + }); + + it('leaves millisecond timestamps unchanged', () => { + expect(getFillTimestampMs(1710000000000)).toBe(1710000000000); + }); + }); + + describe('getQuoteTimestampMs', () => { + it('extracts the quote timestamp when present', () => { + expect(getQuoteTimestampMs({ ts: 1710000000000 })).toBe(1710000000000); + }); + + it('returns undefined for missing or invalid timestamps', () => { + expect(getQuoteTimestampMs(null)).toBeUndefined(); + expect(getQuoteTimestampMs({ ts: 'bad-ts' })).toBeUndefined(); + }); + }); + + describe('getFillSide', () => { + it('infers the maker side from taker direction first', () => { + expect(getFillSide({ takerOrderDirection: 'long' })).toBe('short'); + expect(getFillSide({ takerOrderDirection: 'short' })).toBe('long'); + }); + + it('falls back to maker direction', () => { + expect(getFillSide({ makerOrderDirection: 'long' })).toBe('long'); + expect(getFillSide({ makerOrderDirection: 'short' })).toBe('short'); + }); + + it('returns undefined when neither side is available', () => { + expect(getFillSide({})).toBeUndefined(); + }); + }); + + describe('rawPriceToNumber', () => { + it('converts oracle-offset quotes to absolute prices', () => { + expect(rawPriceToNumber(2 * PRICE_PRECISION.toNumber(), 100)).toBe(102); + }); + }); + + describe('isCompetitivePrice', () => { + it('treats bid prices at or above the fill price as competitive', () => { + expect(isCompetitivePrice('long', 101, 100)).toBe(true); + expect(isCompetitivePrice('long', 99, 100)).toBe(false); + }); + + it('treats ask prices at or below the fill price as competitive', () => { + expect(isCompetitivePrice('short', 99, 100)).toBe(true); + expect(isCompetitivePrice('short', 101, 100)).toBe(false); + }); + }); + + describe('bps bucketing', () => { + it('computes absolute bps distance', () => { + expect(getAbsoluteBpsDiff(100.1, 100)).toBeCloseTo(10, 8); + expect(getAbsoluteBpsDiff(99.9, 100)).toBeCloseTo(10, 8); + }); + + it('computes signed bps distance', () => { + expect(getSignedBpsDiff(100.1, 100)).toBeCloseTo(10, 8); + expect(getSignedBpsDiff(99.9, 100)).toBeCloseTo(-10, 8); + }); + + it('maps bps distances into the configured buckets', () => { + expect(getIndicativeBpsBucket(0)).toBe('very_tight'); + expect(getIndicativeBpsBucket(9.99)).toBe('very_tight'); + expect(getIndicativeBpsBucket(10)).toBe('tight'); + expect(getIndicativeBpsBucket(19.99)).toBe('tight'); + expect(getIndicativeBpsBucket(20)).toBe('moderate'); + expect(getIndicativeBpsBucket(29.99)).toBe('moderate'); + expect(getIndicativeBpsBucket(30)).toBe('wide'); + expect(getIndicativeBpsBucket(49.99)).toBe('wide'); + expect(getIndicativeBpsBucket(50)).toBe('very_wide'); + expect(getIndicativeBpsBucket(500)).toBe('very_wide'); + }); + + it('maps signed bps distances into directional buckets', () => { + expect(getIndicativeDirectionBucket(10)).toBe('better'); + expect(getIndicativeDirectionBucket(0)).toBe('equal'); + expect(getIndicativeDirectionBucket(-10)).toBe('worse'); + }); + }); + + describe('getCompetitiveLiquidity', () => { + it('aggregates only bid levels that were competitive for a long maker', () => { + const liquidity = getCompetitiveLiquidity( + 'mm-1', + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'long', + 100, + { + ts: 1710000000000, + quotes: [ + { + bid_price: 101 * PRICE_PRECISION.toNumber(), + bid_size: BASE_PRECISION.toNumber(), + }, + { + bid_price: 100 * PRICE_PRECISION.toNumber(), + bid_size: 2 * BASE_PRECISION.toNumber(), + }, + { + bid_price: 99 * PRICE_PRECISION.toNumber(), + bid_size: 4 * BASE_PRECISION.toNumber(), + }, + ], + } + ); + + expect(liquidity).toEqual({ + maker: 'mm-1', + bestPrice: 101, + size: 3, + quoteValue: 301, + quoteTsMs: 1710000000000, + }); + }); + + it('aggregates only ask levels that were competitive for a short maker', () => { + const liquidity = getCompetitiveLiquidity( + 'mm-2', + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'short', + 100, + { + ts: 1710000000000, + quotes: [ + { + ask_price: 99 * PRICE_PRECISION.toNumber(), + ask_size: 1.5 * BASE_PRECISION.toNumber(), + }, + { + ask_price: 100 * PRICE_PRECISION.toNumber(), + ask_size: 0.5 * BASE_PRECISION.toNumber(), + }, + { + ask_price: 101 * PRICE_PRECISION.toNumber(), + ask_size: 10 * BASE_PRECISION.toNumber(), + }, + ], + } + ); + + expect(liquidity).toEqual({ + maker: 'mm-2', + bestPrice: 99, + size: 2, + quoteValue: 198.5, + quoteTsMs: 1710000000000, + }); + }); + + it('supports oracle-offset quotes', () => { + const liquidity = getCompetitiveLiquidity( + 'mm-3', + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'long', + 100, + { + ts: 1710000000000, + quotes: [ + { + bid_price: PRICE_PRECISION.toNumber(), + bid_size: BASE_PRECISION.toNumber(), + is_oracle_offset: true, + }, + ], + } + ); + + expect(liquidity?.bestPrice).toBe(101); + expect(liquidity?.size).toBe(1); + }); + + it('uses spot market precision when provided', () => { + const liquidity = getCompetitiveLiquidity( + 'mm-4', + { + marketIndex: 1, + marketType: 'spot', + oraclePrice: 10, + }, + 'long', + 10, + { + ts: 1710000000000, + quotes: [ + { + bid_price: 10 * PRICE_PRECISION.toNumber(), + bid_size: 2_000_000, + }, + ], + }, + 1_000_000 + ); + + expect(liquidity?.size).toBe(2); + }); + + it('returns undefined when no levels were competitive', () => { + expect( + getCompetitiveLiquidity( + 'mm-5', + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'long', + 100, + { + ts: 1710000000000, + quotes: [ + { + bid_price: 99 * PRICE_PRECISION.toNumber(), + bid_size: BASE_PRECISION.toNumber(), + }, + ], + } + ) + ).toBeUndefined(); + }); + }); + + describe('getQuoteValueOnBook', () => { + it('sums quote notional on the relevant side', () => { + expect( + getQuoteValueOnBook( + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'long', + { + ts: 1710000000000, + quotes: [ + { + bid_price: 101 * PRICE_PRECISION.toNumber(), + bid_size: BASE_PRECISION.toNumber(), + }, + { + bid_price: 100 * PRICE_PRECISION.toNumber(), + bid_size: 2 * BASE_PRECISION.toNumber(), + }, + ], + } + ) + ).toBe(301); + }); + + it('supports oracle offset prices', () => { + expect( + getQuoteValueOnBook( + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'long', + { + ts: 1710000000000, + quotes: [ + { + bid_price: PRICE_PRECISION.toNumber(), + bid_size: BASE_PRECISION.toNumber(), + is_oracle_offset: true, + }, + ], + } + ) + ).toBe(101); + }); + }); +}); diff --git a/src/publishers/tests/tradeMetricsProcessor.test.ts b/src/publishers/tests/tradeMetricsProcessor.test.ts new file mode 100644 index 00000000..d34326b3 --- /dev/null +++ b/src/publishers/tests/tradeMetricsProcessor.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from '@jest/globals'; +import { + createTradeMetricsProcessor, + FillEvent, + TradeMetricsSinks, +} from '../tradeMetricsProcessor'; + +type CounterTestSink = { + calls: Array<{ value: number; attributes: Record }>; + add: (value: number, attributes: Record) => void; +}; + +type GaugeTestSink = { + calls: Array<{ value: number; attributes: Record }>; + setLatestValue: ( + value: number, + attributes: Record + ) => void; +}; + +const createCounterSink = (): CounterTestSink => { + const calls: Array<{ + value: number; + attributes: Record; + }> = []; + return { + calls, + add: (value: number, attributes: Record) => { + calls.push({ value, attributes }); + }, + }; +}; + +const createGaugeSink = (): GaugeTestSink => { + const calls: Array<{ + value: number; + attributes: Record; + }> = []; + return { + calls, + setLatestValue: ( + value: number, + attributes: Record + ) => { + calls.push({ value, attributes }); + }, + }; +}; + +type TestTradeMetricsSinks = TradeMetricsSinks & { + marketFillCount: CounterTestSink; + indicativePresenceCount: CounterTestSink; + indicativeCompetitiveOpportunityCount: CounterTestSink; + indicativeCompetitiveFillCount: CounterTestSink; + indicativeCompetitiveOpportunityNotional: CounterTestSink; + indicativeCompetitiveCapturedNotional: CounterTestSink; + indicativeFillVsQuoteBucketCount: CounterTestSink; + indicativeFillVsQuoteDirectionBucketCount: CounterTestSink; + indicativeQuoteEvaluationCount: CounterTestSink; + indicativeTotalSizeOnBookGauge: GaugeTestSink; + indicativeCompetitiveSizeOnBookGauge: GaugeTestSink; +}; + +const createMetricSinks = (): TestTradeMetricsSinks => ({ + marketFillCount: createCounterSink(), + indicativePresenceCount: createCounterSink(), + indicativeCompetitiveOpportunityCount: createCounterSink(), + indicativeCompetitiveFillCount: createCounterSink(), + indicativeCompetitiveOpportunityNotional: createCounterSink(), + indicativeCompetitiveCapturedNotional: createCounterSink(), + indicativeFillVsQuoteBucketCount: createCounterSink(), + indicativeFillVsQuoteDirectionBucketCount: createCounterSink(), + indicativeQuoteEvaluationCount: createCounterSink(), + indicativeTotalSizeOnBookGauge: createGaugeSink(), + indicativeCompetitiveSizeOnBookGauge: createGaugeSink(), +}); + +describe('tradeMetricsProcessor', () => { + it('processes a fill against multiple makers and records presence, competitiveness, and gauges', async () => { + const published: Array<{ key: string; value: unknown }> = []; + const metrics = createMetricSinks(); + const quoteState = new Map([ + ['market_mms_perp_0', ['good-maker', 'bad-maker']], + [ + 'mm_quotes_v2_perp_0_good-maker', + { + ts: 1710000000000, + quotes: [ + { bid_price: 100100000, bid_size: 2000000000 }, + { ask_price: 99900000, ask_size: 2000000000 }, + ], + }, + ], + [ + 'mm_quotes_v2_perp_0_bad-maker', + { + ts: 1710000000000, + quotes: [{ bid_price: 101000000, bid_size: 1000000000 }], + }, + ], + ]); + + const { processFillEvent } = createTradeMetricsProcessor({ + redisClientPrefix: 'dlob:', + indicativeQuoteMaxAgeMs: 1000, + indicativeQuotesCacheTtlMs: 250, + spotMarketPrecisionResolver: () => undefined, + publisherRedisClient: { + publish: async (key, value) => { + published.push({ key, value }); + return 1; + }, + }, + indicativeQuotesRedisClient: { + smembers: async (key) => quoteState.get(key) ?? [], + get: async (key) => quoteState.get(key), + }, + metrics, + }); + + const fillEvent: FillEvent = { + ts: 1710000000000, + marketIndex: 0, + marketType: 'perp', + filler: 'mock-filler', + takerFee: 0, + makerFee: 0, + quoteAssetAmountSurplus: 0, + baseAssetAmountFilled: 1, + quoteAssetAmountFilled: 100.1, + taker: 'mock-taker', + takerOrderId: 1, + takerOrderDirection: 'short', + takerOrderBaseAssetAmount: 1, + takerOrderCumulativeBaseAssetAmountFilled: 1, + takerOrderCumulativeQuoteAssetAmountFilled: 100.1, + maker: 'good-maker', + makerOrderId: 2, + makerOrderDirection: 'long', + makerOrderBaseAssetAmount: 1, + makerOrderCumulativeBaseAssetAmountFilled: 1, + makerOrderCumulativeQuoteAssetAmountFilled: 100.1, + oraclePrice: 100, + txSig: 'mock-1', + slot: 1, + fillRecordId: 1, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }; + + await processFillEvent(fillEvent); + + expect(published[0]?.key).toBe('dlob:trades_perp_0'); + expect(metrics.marketFillCount.calls).toHaveLength(1); + + expect(metrics.indicativePresenceCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + { + value: 1, + attributes: { + maker: 'bad-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + ]); + + expect(metrics.indicativeCompetitiveOpportunityCount.calls).toHaveLength(2); + expect(metrics.indicativeCompetitiveFillCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + ]); + + expect(metrics.indicativeQuoteEvaluationCount.calls).toEqual( + expect.arrayContaining([ + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + result: 'competitive', + }, + }, + { + value: 1, + attributes: { + maker: 'bad-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + result: 'competitive', + }, + }, + ]) + ); + + expect(metrics.indicativeTotalSizeOnBookGauge.calls).toEqual( + expect.arrayContaining([ + { + value: 200.2, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + { + value: 101, + attributes: { + maker: 'bad-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + ]) + ); + + expect(metrics.indicativeCompetitiveSizeOnBookGauge.calls).toEqual( + expect.arrayContaining([ + { + value: 200.2, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + { + value: 101, + attributes: { + maker: 'bad-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + ]) + ); + + expect(metrics.indicativeFillVsQuoteBucketCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + bucket: 'very_tight', + }, + }, + ]); + + expect(metrics.indicativeFillVsQuoteDirectionBucketCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + bucket: 'equal', + }, + }, + ]); + }); +}); diff --git a/src/publishers/tradeMetrics.ts b/src/publishers/tradeMetrics.ts new file mode 100644 index 00000000..e60a1311 --- /dev/null +++ b/src/publishers/tradeMetrics.ts @@ -0,0 +1,321 @@ +import { BASE_PRECISION, PRICE_PRECISION } from '@drift-labs/sdk'; + +export type FillEventStub = { + ts: number; + marketIndex: number; + marketType: string; + baseAssetAmountFilled: number; + quoteAssetAmountFilled: number; + takerOrderDirection?: string; + makerOrderDirection?: string; + oraclePrice: number; +}; + +export type IndicativeQuoteLevel = { + bid_price?: number | string | null; + bid_size?: number | string | null; + ask_price?: number | string | null; + ask_size?: number | string | null; + is_oracle_offset?: boolean; +}; + +export type IndicativeQuoteBlob = { + ts?: number | string; + quotes?: IndicativeQuoteLevel[]; +}; + +export type CompetitiveLiquidity = { + maker: string; + size: number; + bestPrice: number; + quoteValue: number; + quoteTsMs: number; +}; + +export const INDICATIVE_BPS_BUCKETS = [ + { label: 'very_tight', min: 0, max: 10 }, + { label: 'tight', min: 10, max: 20 }, + { label: 'moderate', min: 20, max: 30 }, + { label: 'wide', min: 30, max: 50 }, + { label: 'very_wide', min: 50, max: Infinity }, +] as const; + +/** + * Extracts a raw indicative quote timestamp in milliseconds. + */ +export const getQuoteTimestampMs = ( + quoteBlob: IndicativeQuoteBlob | null +): number | undefined => { + const quoteTsMs = Number(quoteBlob?.ts); + return Number.isFinite(quoteTsMs) ? quoteTsMs : undefined; +}; + +/** + * Aggregates a maker's total quoted notional on the relevant side, regardless of whether it was competitive. + */ +export const getQuoteValueOnBook = ( + fillEvent: Pick, + side: 'long' | 'short', + quoteBlob: IndicativeQuoteBlob | null, + spotMarketPrecision?: number +): number => { + if (!quoteBlob?.quotes?.length) { + return 0; + } + + const basePrecision = getBasePrecisionForFillEvent( + fillEvent, + spotMarketPrecision + ); + + return quoteBlob.quotes + .map((quote) => { + if (side === 'long') { + if (!quote.bid_size || quote.bid_price == null) { + return 0; + } + const price = quote.is_oracle_offset + ? rawPriceToNumber(quote.bid_price, fillEvent.oraclePrice) + : Number(quote.bid_price) / PRICE_PRECISION.toNumber(); + const size = Number(quote.bid_size) / basePrecision; + return Number.isFinite(price) && Number.isFinite(size) + ? price * size + : 0; + } + if (!quote.ask_size || quote.ask_price == null) { + return 0; + } + const price = quote.is_oracle_offset + ? rawPriceToNumber(quote.ask_price, fillEvent.oraclePrice) + : Number(quote.ask_price) / PRICE_PRECISION.toNumber(); + const size = Number(quote.ask_size) / basePrecision; + return Number.isFinite(price) && Number.isFinite(size) ? price * size : 0; + }) + .filter((notional) => Number.isFinite(notional) && notional > 0) + .reduce((total, notional) => total + notional, 0); +}; + +/** + * Computes the executed unit price from the fill event's quote and base amounts. + */ +export const getFillPrice = ( + fillEvent: Pick< + FillEventStub, + 'baseAssetAmountFilled' | 'quoteAssetAmountFilled' + > +): number | undefined => { + if (fillEvent.baseAssetAmountFilled === 0) { + return undefined; + } + return fillEvent.quoteAssetAmountFilled / fillEvent.baseAssetAmountFilled; +}; + +/** + * Normalizes the fill timestamp to milliseconds so it can be compared with Redis quote timestamps. + */ +export const getFillTimestampMs = (fillTs: number): number => { + return fillTs < 1_000_000_000_000 ? fillTs * 1000 : fillTs; +}; + +/** + * Infers the maker-side direction for the fill so quotes can be compared on the correct side. + */ +export const getFillSide = ( + fillEvent: Pick +): 'long' | 'short' | undefined => { + if (fillEvent.takerOrderDirection === 'long') { + return 'short'; + } + if (fillEvent.takerOrderDirection === 'short') { + return 'long'; + } + if (fillEvent.makerOrderDirection === 'long') { + return 'long'; + } + if (fillEvent.makerOrderDirection === 'short') { + return 'short'; + } + return undefined; +}; + +/** + * Builds the common Prometheus label set used for per-maker competitive quote metrics. + */ +export const getMakerMetricAttrs = ( + fillEvent: Pick, + maker: string, + side: 'long' | 'short' +) => ({ + market_index: fillEvent.marketIndex, + market_type: fillEvent.marketType, + maker, + side, +}); + +/** + * Converts an oracle-offset indicative quote into an absolute display price. + */ +export const rawPriceToNumber = ( + rawPrice: number | string, + oraclePrice: number +): number => { + const raw = Number(rawPrice); + return raw / PRICE_PRECISION + oraclePrice; +}; + +/** + * Returns the correct base precision for the fill's market so quote sizes are normalized consistently. + */ +export const getBasePrecisionForFillEvent = ( + fillEvent: Pick, + spotMarketPrecision?: number +): number => { + if (fillEvent.marketType === 'spot' && spotMarketPrecision) { + return spotMarketPrecision; + } + return BASE_PRECISION.toNumber(); +}; + +/** + * Determines whether a quoted price was aggressive enough to have participated at the observed fill price. + */ +export const isCompetitivePrice = ( + side: 'long' | 'short', + quotePrice: number, + fillPrice: number +): boolean => { + if (side === 'long') { + return quotePrice >= fillPrice; + } + return quotePrice <= fillPrice; +}; + +/** + * Computes absolute bps difference between a fill price and a reference quote price. + */ +export const getAbsoluteBpsDiff = ( + fillPrice: number, + quotePrice: number +): number => { + return Math.abs(((fillPrice - quotePrice) / quotePrice) * 10000); +}; + +/** + * Computes signed bps difference between a fill price and a reference quote price. + */ +export const getSignedBpsDiff = ( + fillPrice: number, + quotePrice: number +): number => { + return ((fillPrice - quotePrice) / quotePrice) * 10000; +}; + +/** + * Buckets an absolute bps distance into the configured indicative spread bands. + */ +export const getIndicativeBpsBucket = (bpsDiff: number): string => { + for (const bucket of INDICATIVE_BPS_BUCKETS) { + if (bpsDiff >= bucket.min && bpsDiff < bucket.max) { + return bucket.label; + } + } + return INDICATIVE_BPS_BUCKETS[INDICATIVE_BPS_BUCKETS.length - 1].label; +}; + +/** + * Buckets a signed bps distance into a compact directional classification. + */ +export const getIndicativeDirectionBucket = (signedBpsDiff: number): string => { + const epsilon = 1e-9; + if (signedBpsDiff > epsilon) { + return 'better'; + } + if (signedBpsDiff < -epsilon) { + return 'worse'; + } + return 'equal'; +}; + +/** + * Aggregates a maker's fresh indicative liquidity that was actually competitive at the observed fill price. + */ +export const getCompetitiveLiquidity = ( + maker: string, + fillEvent: Pick, + side: 'long' | 'short', + fillPrice: number, + quoteBlob: IndicativeQuoteBlob | null, + spotMarketPrecision?: number +): CompetitiveLiquidity | undefined => { + if (!quoteBlob?.quotes?.length) { + return undefined; + } + + const basePrecision = getBasePrecisionForFillEvent( + fillEvent, + spotMarketPrecision + ); + const quoteTsMs = Number(quoteBlob.ts); + if (!Number.isFinite(quoteTsMs)) { + return undefined; + } + + const competitiveLevels = quoteBlob.quotes + .map((quote) => { + if (side === 'long') { + if (quote.bid_price == null || !quote.bid_size) { + return undefined; + } + const price = quote.is_oracle_offset + ? rawPriceToNumber(quote.bid_price, fillEvent.oraclePrice) + : Number(quote.bid_price) / PRICE_PRECISION.toNumber(); + const size = Number(quote.bid_size) / basePrecision; + return { price, size }; + } + + if (quote.ask_price == null || !quote.ask_size) { + return undefined; + } + const price = quote.is_oracle_offset + ? rawPriceToNumber(quote.ask_price, fillEvent.oraclePrice) + : Number(quote.ask_price) / PRICE_PRECISION.toNumber(); + const size = Number(quote.ask_size) / basePrecision; + return { price, size }; + }) + .filter((quote): quote is { price: number; size: number } => { + return ( + !!quote && + Number.isFinite(quote.price) && + Number.isFinite(quote.size) && + quote.size > 0 && + isCompetitivePrice(side, quote.price, fillPrice) + ); + }); + + if (!competitiveLevels.length) { + return undefined; + } + + const bestPrice = competitiveLevels.reduce((best, current) => { + if (side === 'long') { + return current.price > best ? current.price : best; + } + return current.price < best ? current.price : best; + }, competitiveLevels[0].price); + const size = competitiveLevels.reduce( + (total, current) => total + current.size, + 0 + ); + const quoteValue = competitiveLevels.reduce( + (total, current) => total + current.price * current.size, + 0 + ); + + return { + maker, + size, + bestPrice, + quoteValue, + quoteTsMs, + }; +}; diff --git a/src/publishers/tradeMetricsProcessor.ts b/src/publishers/tradeMetricsProcessor.ts new file mode 100644 index 00000000..ed7e0261 --- /dev/null +++ b/src/publishers/tradeMetricsProcessor.ts @@ -0,0 +1,323 @@ +import { + CompetitiveLiquidity, + getAbsoluteBpsDiff, + getCompetitiveLiquidity, + getIndicativeBpsBucket, + getIndicativeDirectionBucket, + getFillPrice, + getFillSide, + getFillTimestampMs, + getMakerMetricAttrs, + getQuoteTimestampMs, + getQuoteValueOnBook, + getSignedBpsDiff, + IndicativeQuoteBlob, +} from './tradeMetrics'; + +export type FillEvent = { + ts: number; + marketIndex: number; + marketType: string; + filler?: string; + takerFee: number; + makerFee: number; + quoteAssetAmountSurplus: number; + baseAssetAmountFilled: number; + quoteAssetAmountFilled: number; + taker?: string; + takerOrderId?: number; + takerOrderDirection?: string; + takerOrderBaseAssetAmount: number; + takerOrderCumulativeBaseAssetAmountFilled: number; + takerOrderCumulativeQuoteAssetAmountFilled: number; + maker?: string; + makerOrderId?: number; + makerOrderDirection?: string; + makerOrderBaseAssetAmount: number; + makerOrderCumulativeBaseAssetAmountFilled: number; + makerOrderCumulativeQuoteAssetAmountFilled: number; + oraclePrice: number; + txSig: string; + slot: number; + fillRecordId?: number; + action: string; + actionExplanation: string; + referrerReward: number; + bitFlags: number; +}; + +type MarketQuoteCacheEntry = { + fetchedAtMs: number; + quoteBlobs: Array<{ + maker: string; + quoteBlob: IndicativeQuoteBlob | null; + }>; +}; + +type MarketQuoteEvaluation = { + maker: string; + totalQuoteValueOnBook: number; + competitiveQuoteValueOnBook: number; + competitiveLiquidity?: CompetitiveLiquidity; +}; + +type CounterSink = { + add(value: number, attributes: Record): void; +}; + +type GaugeSink = { + setLatestValue( + value: number, + attributes: Record + ): void; +}; + +export type TradeMetricsSinks = { + marketFillCount: CounterSink; + indicativePresenceCount: CounterSink; + indicativeCompetitiveOpportunityCount: CounterSink; + indicativeCompetitiveFillCount: CounterSink; + indicativeCompetitiveOpportunityNotional: CounterSink; + indicativeCompetitiveCapturedNotional: CounterSink; + indicativeFillVsQuoteBucketCount: CounterSink; + indicativeFillVsQuoteDirectionBucketCount: CounterSink; + indicativeQuoteEvaluationCount: CounterSink; + indicativeTotalSizeOnBookGauge: GaugeSink; + indicativeCompetitiveSizeOnBookGauge: GaugeSink; +}; + +export type PublisherRedisClient = { + publish(key: string, value: any): Promise | number; +}; + +export type IndicativeQuotesRedisClient = { + smembers(key: string): Promise; + get(key: string): Promise; +}; + +export const createTradeMetricsProcessor = ({ + redisClientPrefix, + indicativeQuoteMaxAgeMs, + indicativeQuotesCacheTtlMs, + spotMarketPrecisionResolver, + publisherRedisClient, + indicativeQuotesRedisClient, + metrics, + onError, +}: { + redisClientPrefix: string; + indicativeQuoteMaxAgeMs: number; + indicativeQuotesCacheTtlMs: number; + spotMarketPrecisionResolver: (marketIndex: number) => number | undefined; + publisherRedisClient: PublisherRedisClient; + indicativeQuotesRedisClient: IndicativeQuotesRedisClient; + metrics: TradeMetricsSinks; + onError?: (error: unknown) => void; +}) => { + const marketQuoteCache = new Map(); + + const getMarketQuotes = async ( + fillEvent: FillEvent, + side: 'long' | 'short', + fillPrice: number, + fillTsMs: number + ): Promise => { + const marketKey = `${fillEvent.marketType}_${fillEvent.marketIndex}_${side}`; + const mapQuoteBlob = ( + maker: string, + quoteBlob: IndicativeQuoteBlob | null + ): MarketQuoteEvaluation => { + const spotPrecision = + fillEvent.marketType === 'spot' + ? spotMarketPrecisionResolver(fillEvent.marketIndex) + : undefined; + const competitiveLiquidity = getCompetitiveLiquidity( + maker, + fillEvent, + side, + fillPrice, + quoteBlob, + spotPrecision + ); + const quoteTsMs = getQuoteTimestampMs(quoteBlob); + const quoteAgeMs = + quoteTsMs !== undefined ? fillTsMs - quoteTsMs : Infinity; + const isFresh = quoteAgeMs >= 0 && quoteAgeMs <= indicativeQuoteMaxAgeMs; + + return { + maker, + totalQuoteValueOnBook: isFresh + ? getQuoteValueOnBook(fillEvent, side, quoteBlob, spotPrecision) + : 0, + competitiveQuoteValueOnBook: + isFresh && competitiveLiquidity ? competitiveLiquidity.quoteValue : 0, + competitiveLiquidity: isFresh ? competitiveLiquidity : undefined, + }; + }; + + const cached = marketQuoteCache.get(marketKey); + if ( + cached && + Date.now() - cached.fetchedAtMs <= indicativeQuotesCacheTtlMs + ) { + return cached.quoteBlobs.map(({ maker, quoteBlob }) => + mapQuoteBlob(maker, quoteBlob) + ); + } + + const mmSetKey = `market_mms_${fillEvent.marketType}_${fillEvent.marketIndex}`; + const makers = await indicativeQuotesRedisClient.smembers(mmSetKey); + if (!makers.length) { + marketQuoteCache.set(marketKey, { + fetchedAtMs: Date.now(), + quoteBlobs: [], + }); + return []; + } + + const quoteBlobs = (await Promise.all( + makers.map((maker) => + indicativeQuotesRedisClient.get( + `mm_quotes_v2_${fillEvent.marketType}_${fillEvent.marketIndex}_${maker}` + ) + ) + )) as (IndicativeQuoteBlob | null)[]; + + const cachedQuoteBlobs = makers.map((maker, idx) => ({ + maker, + quoteBlob: quoteBlobs[idx], + })); + marketQuoteCache.set(marketKey, { + fetchedAtMs: Date.now(), + quoteBlobs: cachedQuoteBlobs, + }); + + return cachedQuoteBlobs.map(({ maker, quoteBlob }) => + mapQuoteBlob(maker, quoteBlob) + ); + }; + + const processFillEvent = async (fillEvent: FillEvent) => { + await publisherRedisClient.publish( + `${redisClientPrefix}trades_${fillEvent.marketType}_${fillEvent.marketIndex}`, + fillEvent + ); + + const fillSide = getFillSide(fillEvent); + const fillPrice = getFillPrice(fillEvent); + if ( + !fillSide || + !fillPrice || + !Number.isFinite(fillPrice) || + fillPrice <= 0 + ) { + return; + } + + const marketMetricAttrs = { + market_index: fillEvent.marketIndex, + market_type: fillEvent.marketType, + side: fillSide, + }; + metrics.marketFillCount.add(1, marketMetricAttrs); + + try { + const marketQuoteEvaluations = await getMarketQuotes( + fillEvent, + fillSide, + fillPrice, + getFillTimestampMs(fillEvent.ts) + ); + const marketQuotes = marketQuoteEvaluations + .map((evaluation) => evaluation.competitiveLiquidity) + .filter((quote): quote is CompetitiveLiquidity => !!quote); + + for (const evaluation of marketQuoteEvaluations) { + const attrs = getMakerMetricAttrs( + fillEvent, + evaluation.maker, + fillSide + ); + if (evaluation.totalQuoteValueOnBook > 0) { + metrics.indicativePresenceCount.add(1, attrs); + } + metrics.indicativeTotalSizeOnBookGauge.setLatestValue( + evaluation.totalQuoteValueOnBook, + attrs + ); + metrics.indicativeCompetitiveSizeOnBookGauge.setLatestValue( + evaluation.competitiveQuoteValueOnBook, + attrs + ); + } + + for (const quote of marketQuotes) { + const attrs = getMakerMetricAttrs(fillEvent, quote.maker, fillSide); + const opportunitySize = Math.min( + fillEvent.baseAssetAmountFilled, + quote.size + ); + const opportunityNotional = opportunitySize * fillPrice; + metrics.indicativeCompetitiveOpportunityCount.add(1, attrs); + metrics.indicativeCompetitiveOpportunityNotional.add( + opportunityNotional, + attrs + ); + metrics.indicativeQuoteEvaluationCount.add(1, { + ...attrs, + result: 'competitive', + }); + + if (fillEvent.maker === quote.maker) { + metrics.indicativeCompetitiveFillCount.add(1, attrs); + metrics.indicativeCompetitiveCapturedNotional.add( + Math.min(fillEvent.baseAssetAmountFilled, opportunitySize) * + fillPrice, + attrs + ); + metrics.indicativeFillVsQuoteBucketCount.add(1, { + ...attrs, + bucket: getIndicativeBpsBucket( + getAbsoluteBpsDiff(fillPrice, quote.bestPrice) + ), + }); + metrics.indicativeFillVsQuoteDirectionBucketCount.add(1, { + ...attrs, + bucket: getIndicativeDirectionBucket( + getSignedBpsDiff(fillPrice, quote.bestPrice) + ), + }); + } + } + + if (!marketQuotes.length) { + metrics.indicativeQuoteEvaluationCount.add(1, { + ...marketMetricAttrs, + maker: 'all', + result: 'no_competitive_quotes', + }); + } + + if ( + fillEvent.maker && + !marketQuotes.find((quote) => quote.maker === fillEvent.maker) + ) { + metrics.indicativeQuoteEvaluationCount.add(1, { + ...getMakerMetricAttrs(fillEvent, fillEvent.maker, fillSide), + result: 'maker_not_competitive', + }); + } + } catch (error) { + onError?.(error); + metrics.indicativeQuoteEvaluationCount.add(1, { + ...marketMetricAttrs, + maker: 'all', + result: 'error', + }); + } + }; + + return { + processFillEvent, + }; +}; diff --git a/src/publishers/tradesPublisher.ts b/src/publishers/tradesPublisher.ts index adb6fb3c..90d47bfa 100644 --- a/src/publishers/tradesPublisher.ts +++ b/src/publishers/tradesPublisher.ts @@ -21,9 +21,15 @@ import { Event, } from '@drift-labs/sdk'; import { RedisClient, RedisClientPrefix } from '@drift-labs/common/clients'; +import express from 'express'; +import { Metrics } from '../core/metricsV2'; import { logger, setLogLevel } from '../utils/logger'; import { sleep } from '../utils/utils'; +import { + createTradeMetricsProcessor, + FillEvent, +} from './tradeMetricsProcessor'; import { fromEvent, filter, map } from 'rxjs'; import { setGlobalDispatcher, Agent } from 'undici'; @@ -37,6 +43,12 @@ require('dotenv').config(); const driftEnv = (process.env.ENV || 'devnet') as DriftEnv; const commitHash = process.env.COMMIT; const REDIS_CLIENT = process.env.REDIS_CLIENT || 'DLOB'; +const metricsPort = process.env.METRICS_PORT + ? parseInt(process.env.METRICS_PORT) + : 9464; +const indicativeQuoteMaxAgeMs = process.env.INDICATIVE_QUOTES_MAX_AGE_MS + ? parseInt(process.env.INDICATIVE_QUOTES_MAX_AGE_MS) + : 1000; console.log('Redis Clients:', REDIS_CLIENT); const redisClientPrefix = RedisClientPrefix[REDIS_CLIENT]; //@ts-ignore @@ -45,16 +57,97 @@ const sdkConfig = initialize({ env: process.env.ENV }); const stateCommitment: Commitment = 'confirmed'; let driftClient: DriftClient; +const metricsV2 = new Metrics('trades-publisher', undefined, metricsPort); +const marketFillCount = metricsV2.addCounter( + 'market_fill_count', + 'Total market fills considered for JIT competitive opportunity metrics' +); +const indicativePresenceCount = metricsV2.addCounter( + 'indicative_presence_total', + 'Count of fills where a maker had any fresh indicative quote on the relevant side' +); +const indicativeCompetitiveOpportunityCount = metricsV2.addCounter( + 'indicative_competitive_opportunity_total', + 'Count of market fills where a maker had a fresh competitive indicative quote' +); +const indicativeCompetitiveFillCount = metricsV2.addCounter( + 'indicative_competitive_fill_total', + 'Count of competitive opportunities where the maker captured the fill' +); +const indicativeCompetitiveOpportunityNotional = metricsV2.addCounter( + 'indicative_competitive_opportunity_notional_total', + 'Total competitive opportunity notional in quote units for each maker' +); +const indicativeCompetitiveCapturedNotional = metricsV2.addCounter( + 'indicative_competitive_captured_notional_total', + 'Total captured notional in quote units on competitive opportunities for each maker' +); +const indicativeFillVsQuoteBucketCount = metricsV2.addCounter( + 'indicative_fill_vs_quote_bucket_total', + 'Count of maker fills bucketed by absolute bps distance from the best competitive indicative quote' +); +const indicativeFillVsQuoteDirectionBucketCount = metricsV2.addCounter( + 'indicative_fill_vs_quote_direction_bucket_total', + 'Count of maker fills bucketed as better, equal, or worse than the best competitive indicative quote' +); +const indicativeQuoteEvaluationCount = metricsV2.addCounter( + 'indicative_quote_evaluation_total', + 'Count of quote evaluation outcomes by maker and market' +); +const indicativeTotalSizeOnBookGauge = metricsV2.addGauge( + 'indicative_total_size_on_book', + 'Latest fresh total quoted value on book by maker, market, and side' +); +const indicativeCompetitiveSizeOnBookGauge = metricsV2.addGauge( + 'indicative_competitive_size_on_book', + 'Latest fresh competitive quoted value on book by maker, market, and side' +); +metricsV2.finalizeObservables(); + const opts = program.opts(); setLogLevel(opts.debug ? 'debug' : 'info'); const endpoint = process.env.ENDPOINT; const wsEndpoint = process.env.WS_ENDPOINT; +const indicativeQuotesCacheTtlMs = process.env.INDICATIVE_QUOTES_CACHE_TTL_MS + ? parseInt(process.env.INDICATIVE_QUOTES_CACHE_TTL_MS) + : 250; +const enableMockFillEndpoint = + process.env.ENABLE_MOCK_FILL_ENDPOINT?.toLowerCase() === 'true'; +const mockOnlyMode = process.env.MOCK_ONLY_MODE?.toLowerCase() === 'true'; +const mockFillPort = process.env.MOCK_FILL_PORT + ? parseInt(process.env.MOCK_FILL_PORT) + : 9470; logger.info(`RPC endpoint: ${endpoint}`); logger.info(`WS endpoint: ${wsEndpoint}`); logger.info(`DriftEnv: ${driftEnv}`); logger.info(`Commit: ${commitHash}`); +const startMockFillEndpoint = ( + processFillEvent: (fillEvent: FillEvent) => Promise +) => { + if (!enableMockFillEndpoint) { + return; + } + + const app = express(); + app.use(express.json()); + app.post('/mockFill', async (req, res) => { + try { + await processFillEvent(req.body as FillEvent); + res.status(200).json({ ok: true }); + } catch (error) { + logger.error('Failed to process mock fill:', error); + res.status(500).json({ ok: false }); + } + }); + app.listen(mockFillPort, () => { + logger.info( + `Mock fill endpoint listening on http://localhost:${mockFillPort}` + ); + }); +}; + const main = async () => { const wallet = new Wallet(new Keypair()); const clearingHousePublicKey = new PublicKey(sdkConfig.DRIFT_PROGRAM_ID); @@ -78,6 +171,40 @@ const main = async () => { const redisClient = new RedisClient({ prefix: redisClientPrefix }); await redisClient.connect(); + const indicativeQuotesRedisClient = new RedisClient({}); + await indicativeQuotesRedisClient.connect(); + + const { processFillEvent } = createTradeMetricsProcessor({ + redisClientPrefix, + indicativeQuoteMaxAgeMs, + indicativeQuotesCacheTtlMs, + spotMarketPrecisionResolver: (marketIndex) => + sdkConfig.SPOT_MARKETS[marketIndex]?.precision, + publisherRedisClient: redisClient, + indicativeQuotesRedisClient, + metrics: { + marketFillCount, + indicativePresenceCount, + indicativeCompetitiveOpportunityCount, + indicativeCompetitiveFillCount, + indicativeCompetitiveOpportunityNotional, + indicativeCompetitiveCapturedNotional, + indicativeFillVsQuoteBucketCount, + indicativeFillVsQuoteDirectionBucketCount, + indicativeQuoteEvaluationCount, + indicativeTotalSizeOnBookGauge, + indicativeCompetitiveSizeOnBookGauge, + }, + onError: (error) => + logger.error('Error evaluating competitive indicative quotes:', error), + }); + + startMockFillEndpoint(processFillEvent); + + if (mockOnlyMode) { + logger.info('Running in MOCK_ONLY_MODE; skipping chain subscriptions'); + return; + } const slotSubscriber = new SlotSubscriber(connection, { resubTimeoutMs: 10_000, @@ -187,14 +314,11 @@ const main = async () => { QUOTE_PRECISION ), bitFlags: fill.bitFlags, - }; + } as FillEvent; }) ) - .subscribe((fillEvent) => { - redisClient.publish( - `${redisClientPrefix}trades_${fillEvent.marketType}_${fillEvent.marketIndex}`, - fillEvent - ); + .subscribe(async (fillEvent: FillEvent) => { + await processFillEvent(fillEvent); }); console.log('Publishing trades'); diff --git a/src/scripts/submitMockJitMetricsData.ts b/src/scripts/submitMockJitMetricsData.ts new file mode 100644 index 00000000..27f43a98 --- /dev/null +++ b/src/scripts/submitMockJitMetricsData.ts @@ -0,0 +1,149 @@ +import { RedisClient } from '@drift-labs/common/clients'; + +require('dotenv').config(); + +const mockFillPort = process.env.MOCK_FILL_PORT + ? parseInt(process.env.MOCK_FILL_PORT) + : 9470; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type MockQuote = { + maker: string; + side: 'long' | 'short'; + price: number; + size: number; +}; + +type MockFill = { + maker: string; + side: 'long' | 'short'; + fillPrice: number; + fillSize: number; + oraclePrice: number; +}; + +const parseJsonArrayEnv = ( + envValue: string | undefined +): T[] | undefined => { + if (!envValue) { + return undefined; + } + return JSON.parse(envValue) as T[]; +}; + +const groupQuotesByMaker = (quotes: MockQuote[]) => { + const grouped = new Map(); + for (const quote of quotes) { + const makerQuotes = grouped.get(quote.maker) ?? []; + makerQuotes.push(quote); + grouped.set(quote.maker, makerQuotes); + } + return grouped; +}; + +const main = async () => { + const indicativeRedisClient = new RedisClient({}); + await indicativeRedisClient.connect(); + + const marketType = process.env.MOCK_MARKET_TYPE || 'perp'; + const marketIndex = parseInt(process.env.MOCK_MARKET_INDEX || '0'); + const now = Date.now(); + const quotes = parseJsonArrayEnv(process.env.MOCK_QUOTES_JSON); + const fills = parseJsonArrayEnv(process.env.MOCK_FILLS_JSON); + if (!quotes?.length) { + throw new Error('MOCK_QUOTES_JSON must contain at least one quote'); + } + if (!fills?.length) { + throw new Error('MOCK_FILLS_JSON must contain at least one fill'); + } + const marketMmsKey = `market_mms_${marketType}_${marketIndex}`; + const existingMakers = await indicativeRedisClient.smembers(marketMmsKey); + const existingQuoteKeys = existingMakers.map( + (maker) => `mm_quotes_v2_${marketType}_${marketIndex}_${maker}` + ); + + if (existingQuoteKeys.length > 0) { + await indicativeRedisClient.delete(...existingQuoteKeys); + } + await indicativeRedisClient.delete(marketMmsKey); + + await indicativeRedisClient + .forceGetClient() + .sadd(marketMmsKey, ...quotes.map((quote) => quote.maker)); + + const quotesByMaker = groupQuotesByMaker(quotes); + + for (const [maker, makerQuotes] of quotesByMaker.entries()) { + await indicativeRedisClient.set( + `mm_quotes_v2_${marketType}_${marketIndex}_${maker}`, + { + ts: now, + quotes: makerQuotes.map((quote) => + quote.side === 'long' + ? { + bid_price: Math.round(quote.price * 1_000_000), + bid_size: Math.round(quote.size * 1_000_000_000), + } + : { + ask_price: Math.round(quote.price * 1_000_000), + ask_size: Math.round(quote.size * 1_000_000_000), + } + ), + } + ); + } + + await sleep(100); + + for (const [idx, fill] of fills.entries()) { + const fillEvent = { + ts: now + idx, + marketIndex, + marketType, + filler: 'mock-filler', + takerFee: 0, + makerFee: 0, + quoteAssetAmountSurplus: 0, + baseAssetAmountFilled: fill.fillSize, + quoteAssetAmountFilled: fill.fillPrice * fill.fillSize, + taker: 'mock-taker', + takerOrderId: idx + 1, + takerOrderDirection: fill.side === 'long' ? 'short' : 'long', + takerOrderBaseAssetAmount: fill.fillSize, + takerOrderCumulativeBaseAssetAmountFilled: fill.fillSize, + takerOrderCumulativeQuoteAssetAmountFilled: + fill.fillPrice * fill.fillSize, + maker: fill.maker, + makerOrderId: idx + 100, + makerOrderDirection: fill.side, + makerOrderBaseAssetAmount: fill.fillSize, + makerOrderCumulativeBaseAssetAmountFilled: fill.fillSize, + makerOrderCumulativeQuoteAssetAmountFilled: + fill.fillPrice * fill.fillSize, + oraclePrice: fill.oraclePrice, + txSig: `mock-${now}-${idx}`, + slot: idx + 1, + fillRecordId: idx + 1, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }; + + const response = await fetch(`http://localhost:${mockFillPort}/mockFill`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(fillEvent), + }); + + console.log(await response.text()); + } +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/utils/tests/auctionParams.test.ts b/src/utils/tests/auctionParams.test.ts index f9f4b2b8..e7eaf22b 100644 --- a/src/utils/tests/auctionParams.test.ts +++ b/src/utils/tests/auctionParams.test.ts @@ -182,7 +182,7 @@ describe('Auction Parameters Functions', () => { describe('mapToMarketOrderParams with Mock L2 Data', () => { const mockDriftClient = { - getOracleDataForPerpMarket: jest.fn(), + getMMOracleDataForPerpMarket: jest.fn(), getOracleDataForSpotMarket: jest.fn(), }; @@ -209,7 +209,7 @@ describe('Auction Parameters Functions', () => { it('should successfully calculate prices with mock L2 orderbook data', async () => { const solPrice = 160; // $160 SOL price - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: new BN(solPrice).mul(PRICE_PRECISION), }); @@ -265,7 +265,7 @@ describe('Auction Parameters Functions', () => { const quoteAmount = 1000; // $1,000 worth const quoteAmountInPrecision = new BN(quoteAmount).mul(QUOTE_PRECISION); // Convert to quote precision - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: new BN(solPrice).mul(PRICE_PRECISION), }); @@ -352,7 +352,7 @@ describe('Auction Parameters Functions', () => { it('should handle different directions correctly', async () => { const solPrice = 160; // $160 SOL price - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: new BN(solPrice).mul(PRICE_PRECISION), }); @@ -392,7 +392,7 @@ describe('Auction Parameters Functions', () => { it('should handle various order sizes with L2 depth', async () => { const solPrice = 160; // $160 SOL price - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: new BN(solPrice).mul(PRICE_PRECISION), }); @@ -456,7 +456,7 @@ describe('Auction Parameters Functions', () => { }); it('should handle zero oracle price scenario', async () => { - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: ZERO, }); @@ -696,7 +696,7 @@ describe('Auction Parameters Functions', () => { describe('calculateDynamicSlippage - crossed book handling', () => { const mockDriftClient = { - getOracleDataForPerpMarket: jest.fn(), + getMMOracleDataForPerpMarket: jest.fn(), getOracleDataForSpotMarket: jest.fn(), } as any; @@ -714,7 +714,7 @@ describe('calculateDynamicSlippage - crossed book handling', () => { process.env.DYNAMIC_CROSS_SPREAD_CAP = '0.1'; // 0.1% // Oracle 100 - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: new BN(100).mul(PRICE_PRECISION), }); @@ -748,7 +748,7 @@ describe('calculateDynamicSlippage - crossed book handling', () => { delete process.env.DYNAMIC_CROSS_SPREAD_MODE; // default cap process.env.DYNAMIC_CROSS_SPREAD_CAP = '0.1'; - mockDriftClient.getOracleDataForPerpMarket.mockReturnValue({ + mockDriftClient.getMMOracleDataForPerpMarket.mockReturnValue({ price: new BN(100).mul(PRICE_PRECISION), });