diff --git a/src/publishers/tests/tradeMetrics.test.ts b/src/publishers/tests/tradeMetrics.test.ts index bad5f21..144c284 100644 --- a/src/publishers/tests/tradeMetrics.test.ts +++ b/src/publishers/tests/tradeMetrics.test.ts @@ -8,6 +8,7 @@ import { getFillSide, getFillTimestampMs, getIndicativeBpsBucket, + getQuotedFillReferencePrice, getQuoteTimestampMs, getQuoteValueOnBook, getSignedBpsDiff, @@ -275,6 +276,62 @@ describe('tradeMetrics', () => { }); }); + describe('getQuotedFillReferencePrice', () => { + it('uses a size-weighted price capped by the fill size for long-side quotes', () => { + const referencePrice = getQuotedFillReferencePrice( + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'long', + 1.5, + { + 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(), + }, + ], + } + ); + + expect(referencePrice).toBeCloseTo(100.6666666667, 8); + }); + + it('uses the full quoted ladder when the fill is larger than the top level', () => { + const referencePrice = getQuotedFillReferencePrice( + { + marketIndex: 0, + marketType: 'perp', + oraclePrice: 100, + }, + 'short', + 1.5, + { + ts: 1710000000000, + quotes: [ + { + ask_price: 99 * PRICE_PRECISION.toNumber(), + ask_size: 1 * BASE_PRECISION.toNumber(), + }, + { + ask_price: 100 * PRICE_PRECISION.toNumber(), + ask_size: 1 * BASE_PRECISION.toNumber(), + }, + ], + } + ); + + expect(referencePrice).toBeCloseTo(99.3333333333, 8); + }); + }); + describe('getQuoteValueOnBook', () => { it('sums quote notional on the relevant side', () => { expect( diff --git a/src/publishers/tests/tradeMetricsProcessor.test.ts b/src/publishers/tests/tradeMetricsProcessor.test.ts index 1ff0829..30adb6b 100644 --- a/src/publishers/tests/tradeMetricsProcessor.test.ts +++ b/src/publishers/tests/tradeMetricsProcessor.test.ts @@ -54,9 +54,7 @@ type TestTradeMetricsSinks = TradeMetricsSinks & { indicativeCompetitiveFillCount: CounterTestSink; indicativeCompetitiveOpportunityNotional: CounterTestSink; indicativeCompetitiveCapturedNotional: CounterTestSink; - indicativeFillVsQuoteBucketCount: CounterTestSink; - indicativeFillVsQuoteDirectionBucketCount: CounterTestSink; - indicativeQuoteEvaluationCount: CounterTestSink; + indicativeFillVsQuoteOutcomeCount: CounterTestSink; indicativeTotalSizeOnBookGauge: GaugeTestSink; indicativeCompetitiveSizeOnBookGauge: GaugeTestSink; }; @@ -68,9 +66,7 @@ const createMetricSinks = (): TestTradeMetricsSinks => ({ indicativeCompetitiveFillCount: createCounterSink(), indicativeCompetitiveOpportunityNotional: createCounterSink(), indicativeCompetitiveCapturedNotional: createCounterSink(), - indicativeFillVsQuoteBucketCount: createCounterSink(), - indicativeFillVsQuoteDirectionBucketCount: createCounterSink(), - indicativeQuoteEvaluationCount: createCounterSink(), + indicativeFillVsQuoteOutcomeCount: createCounterSink(), indicativeTotalSizeOnBookGauge: createGaugeSink(), indicativeCompetitiveSizeOnBookGauge: createGaugeSink(), }); @@ -191,31 +187,6 @@ describe('tradeMetricsProcessor', () => { }, ]); - 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([ { @@ -262,7 +233,7 @@ describe('tradeMetricsProcessor', () => { ]) ); - expect(metrics.indicativeFillVsQuoteBucketCount.calls).toEqual([ + expect(metrics.indicativeFillVsQuoteOutcomeCount.calls).toEqual([ { value: 1, attributes: { @@ -271,25 +242,13 @@ describe('tradeMetricsProcessor', () => { 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', + direction: 'equal', }, }, ]); }); - it('does not emit maker_not_competitive for a fill maker without indicative quotes', async () => { + it('ignores fills from makers without indicative quotes', async () => { const metrics = createMetricSinks(); const quoteState = new Map([ ['market_mms_perp_0', ['quoted-maker']], @@ -352,23 +311,75 @@ describe('tradeMetricsProcessor', () => { await processFillEvent(fillEvent); - expect(metrics.indicativeQuoteEvaluationCount.calls).not.toEqual( - expect.arrayContaining([ + expect(metrics.indicativeFillVsQuoteOutcomeCount.calls).toHaveLength(0); + }); + + it('does not emit fill-vs-quote outcomes when the mapped indicative maker had no fresh quote', async () => { + const metrics = createMetricSinks(); + const quoteState = new Map([ + ['market_mms_perp_0', ['quoted-maker']], + [ + 'mm_quotes_v2_perp_0_quoted-maker', { - value: 1, - attributes: { - maker: 'external-maker', - market_index: 0, - market_type: 'perp', - side: 'long', - result: 'maker_not_competitive', - }, + ts: 1710000000000, + quotes: [{ bid_price: 99900000, bid_size: 1000000000 }], }, - ]) - ); + ], + ]); + + const { processFillEvent } = createTradeMetricsProcessor({ + redisClientPrefix: 'dlob:', + indicativeQuoteMaxAgeMs: 1000, + indicativeQuotesCacheTtlMs: 250, + spotMarketPrecisionResolver: () => undefined, + publisherRedisClient: { + publish: async () => 1, + }, + indicativeQuotesRedisClient: { + smembers: async (key) => quoteState.get(key) ?? [], + get: async (key) => quoteState.get(key), + }, + metrics, + nowMsProvider: () => 1710000005000, + }); + + await processFillEvent({ + ts: 1710000005, + marketIndex: 0, + marketType: 'perp', + filler: 'mock-filler', + takerFee: 0, + makerFee: 0, + quoteAssetAmountSurplus: 0, + baseAssetAmountFilled: 1, + quoteAssetAmountFilled: 100, + taker: 'mock-taker', + takerOrderId: 1, + takerOrderDirection: 'short', + takerOrderBaseAssetAmount: 1, + takerOrderCumulativeBaseAssetAmountFilled: 1, + takerOrderCumulativeQuoteAssetAmountFilled: 100, + maker: 'maker-user-account', + makerIndicativeKey: 'quoted-maker', + makerOrderId: 2, + makerOrderDirection: 'long', + makerOrderBaseAssetAmount: 1, + makerOrderCumulativeBaseAssetAmountFilled: 1, + makerOrderCumulativeQuoteAssetAmountFilled: 100, + oraclePrice: 100, + txSig: 'mock-no-quote', + slot: 5, + fillRecordId: 5, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }); + + expect(metrics.indicativeFillVsQuoteOutcomeCount.calls).toHaveLength(0); }); - it('emits maker_not_competitive using the indicative maker label when indicative key is provided', async () => { + it('does not count competitive fills when the mapped indicative maker is not competitive', async () => { const metrics = createMetricSinks(); const quoteState = new Map([ ['market_mms_perp_0', ['quoted-maker']], @@ -432,23 +443,88 @@ describe('tradeMetricsProcessor', () => { await processFillEvent(fillEvent); - expect(metrics.indicativeQuoteEvaluationCount.calls).toEqual( - expect.arrayContaining([ + expect(metrics.indicativeCompetitiveFillCount.calls).toHaveLength(0); + }); + + it('buckets fill-vs-quote for the mapped maker even when the quote was not competitive', async () => { + const metrics = createMetricSinks(); + const quoteState = new Map([ + ['market_mms_perp_0', ['quoted-maker']], + [ + 'mm_quotes_v2_perp_0_quoted-maker', { - value: 1, - attributes: { - maker: 'quoted-maker', - market_index: 0, - market_type: 'perp', - side: 'long', - result: 'maker_not_competitive', - }, + ts: 1710000000000, + quotes: [{ bid_price: 99900000, bid_size: 1000000000 }], }, - ]) - ); + ], + ]); + + const { processFillEvent } = createTradeMetricsProcessor({ + redisClientPrefix: 'dlob:', + indicativeQuoteMaxAgeMs: 1000, + indicativeQuotesCacheTtlMs: 250, + spotMarketPrecisionResolver: () => undefined, + publisherRedisClient: { + publish: async () => 1, + }, + indicativeQuotesRedisClient: { + smembers: async (key) => quoteState.get(key) ?? [], + get: async (key) => quoteState.get(key), + }, + metrics, + nowMsProvider: () => 1710000000000, + }); + + await processFillEvent({ + ts: 1710000000000, + marketIndex: 0, + marketType: 'perp', + filler: 'mock-filler', + takerFee: 0, + makerFee: 0, + quoteAssetAmountSurplus: 0, + baseAssetAmountFilled: 1, + quoteAssetAmountFilled: 100, + taker: 'mock-taker', + takerOrderId: 1, + takerOrderDirection: 'short', + takerOrderBaseAssetAmount: 1, + takerOrderCumulativeBaseAssetAmountFilled: 1, + takerOrderCumulativeQuoteAssetAmountFilled: 100, + maker: 'maker-user-account', + makerIndicativeKey: 'quoted-maker', + makerOrderId: 2, + makerOrderDirection: 'long', + makerOrderBaseAssetAmount: 1, + makerOrderCumulativeBaseAssetAmountFilled: 1, + makerOrderCumulativeQuoteAssetAmountFilled: 100, + oraclePrice: 100, + txSig: 'mock-5', + slot: 5, + fillRecordId: 5, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }); + + expect(metrics.indicativeCompetitiveFillCount.calls).toHaveLength(0); + expect(metrics.indicativeFillVsQuoteOutcomeCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'quoted-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + bucket: 'tight', + direction: 'better', + }, + }, + ]); }); - it('treats quotes as fresh based on evaluation time rather than fill timestamp', async () => { + it('matches the latest quote at or before the fill timestamp', async () => { const metrics = createMetricSinks(); const quoteState = new Map([ ['market_mms_perp_0', ['good-maker']], @@ -464,7 +540,7 @@ describe('tradeMetricsProcessor', () => { const { processFillEvent } = createTradeMetricsProcessor({ redisClientPrefix: 'dlob:', indicativeQuoteMaxAgeMs: 1000, - indicativeQuotesCacheTtlMs: 250, + indicativeQuotesCacheTtlMs: 1, spotMarketPrecisionResolver: () => undefined, publisherRedisClient: { publish: async () => 1, @@ -474,11 +550,11 @@ describe('tradeMetricsProcessor', () => { get: async (key) => quoteState.get(key), }, metrics, - nowMsProvider: () => 1710000000500, + nowMsProvider: () => 1710000002500, }); - const fillEvent: FillEvent = { - ts: 1709999999, + await processFillEvent({ + ts: 1710000000, marketIndex: 0, marketType: 'perp', filler: 'mock-filler', @@ -494,6 +570,7 @@ describe('tradeMetricsProcessor', () => { takerOrderCumulativeBaseAssetAmountFilled: 1, takerOrderCumulativeQuoteAssetAmountFilled: 100.1, maker: 'good-maker', + makerIndicativeKey: 'good-maker', makerOrderId: 2, makerOrderDirection: 'long', makerOrderBaseAssetAmount: 1, @@ -507,9 +584,47 @@ describe('tradeMetricsProcessor', () => { actionExplanation: 'none', referrerReward: 0, bitFlags: 0, - }; + }); - await processFillEvent(fillEvent); + quoteState.set('mm_quotes_v2_perp_0_good-maker', { + ts: 1710000002000, + quotes: [{ bid_price: 100500000, bid_size: 1000000000 }], + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await processFillEvent({ + ts: 1710000001, + marketIndex: 0, + marketType: 'perp', + filler: 'mock-filler', + takerFee: 0, + makerFee: 0, + quoteAssetAmountSurplus: 0, + baseAssetAmountFilled: 1, + quoteAssetAmountFilled: 100.1, + taker: 'mock-taker', + takerOrderId: 2, + takerOrderDirection: 'short', + takerOrderBaseAssetAmount: 1, + takerOrderCumulativeBaseAssetAmountFilled: 1, + takerOrderCumulativeQuoteAssetAmountFilled: 100.1, + maker: 'good-maker', + makerIndicativeKey: 'good-maker', + makerOrderId: 3, + makerOrderDirection: 'long', + makerOrderBaseAssetAmount: 1, + makerOrderCumulativeBaseAssetAmountFilled: 1, + makerOrderCumulativeQuoteAssetAmountFilled: 100.1, + oraclePrice: 100, + txSig: 'mock-3b', + slot: 4, + fillRecordId: 4, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }); expect(metrics.indicativePresenceCount.calls).toEqual([ { @@ -521,21 +636,40 @@ describe('tradeMetricsProcessor', () => { side: 'long', }, }, + { + 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', - }, + expect(metrics.indicativeFillVsQuoteOutcomeCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + bucket: 'very_tight', + direction: 'equal', }, - ]) - ); + }, + { + value: 1, + attributes: { + maker: 'good-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + bucket: 'very_tight', + direction: 'equal', + }, + }, + ]); }); it('matches competitive fills using precomputed indicative key', async () => { diff --git a/src/publishers/tradeMetrics.ts b/src/publishers/tradeMetrics.ts index e60a131..c5f4f38 100644 --- a/src/publishers/tradeMetrics.ts +++ b/src/publishers/tradeMetrics.ts @@ -95,6 +95,75 @@ export const getQuoteValueOnBook = ( .reduce((total, notional) => total + notional, 0); }; +/** + * Computes the quoted VWAP on the relevant side, capped by the observed fill size. + */ +export const getQuotedFillReferencePrice = ( + fillEvent: Pick, + side: 'long' | 'short', + fillSize: number, + quoteBlob: IndicativeQuoteBlob | null, + spotMarketPrecision?: number +): number | undefined => { + if ( + !quoteBlob?.quotes?.length || + !Number.isFinite(fillSize) || + fillSize <= 0 + ) { + return undefined; + } + + const basePrecision = getBasePrecisionForFillEvent( + fillEvent, + spotMarketPrecision + ); + const levels = quoteBlob.quotes + .map((quote) => { + const rawPrice = side === 'long' ? quote.bid_price : quote.ask_price; + const rawSize = side === 'long' ? quote.bid_size : quote.ask_size; + if (rawPrice == null || rawSize == null) { + return undefined; + } + + const price = quote.is_oracle_offset + ? rawPriceToNumber(rawPrice, fillEvent.oraclePrice) + : Number(rawPrice) / PRICE_PRECISION.toNumber(); + const size = Number(rawSize) / basePrecision; + + return Number.isFinite(price) && + price > 0 && + Number.isFinite(size) && + size > 0 + ? { price, size } + : undefined; + }) + .filter((level): level is { price: number; size: number } => !!level); + + if (!levels.length) { + return undefined; + } + + levels.sort((a, b) => + side === 'long' ? b.price - a.price : a.price - b.price + ); + + let remainingSize = fillSize; + let weightedNotional = 0; + let consumedSize = 0; + + for (const level of levels) { + if (remainingSize <= 0) { + break; + } + const sizeToConsume = Math.min(level.size, remainingSize); + weightedNotional += level.price * sizeToConsume; + consumedSize += sizeToConsume; + remainingSize -= sizeToConsume; + } + + return consumedSize > 0 ? weightedNotional / consumedSize : undefined; +}; + /** * Computes the executed unit price from the fill event's quote and base amounts. */ diff --git a/src/publishers/tradeMetricsProcessor.ts b/src/publishers/tradeMetricsProcessor.ts index c0d5ca2..0d757c1 100644 --- a/src/publishers/tradeMetricsProcessor.ts +++ b/src/publishers/tradeMetricsProcessor.ts @@ -4,9 +4,11 @@ import { getCompetitiveLiquidity, getIndicativeBpsBucket, getIndicativeDirectionBucket, + getFillTimestampMs, getFillPrice, getFillSide, getMakerMetricAttrs, + getQuotedFillReferencePrice, getQuoteTimestampMs, getQuoteValueOnBook, getSignedBpsDiff, @@ -47,17 +49,15 @@ export type FillEvent = { }; type MarketQuoteCacheEntry = { - fetchedAtMs: number; - quoteBlobs: Array<{ - maker: string; - quoteBlob: IndicativeQuoteBlob | null; - }>; + poller?: ReturnType; + historyByMaker: Map; }; type MarketQuoteEvaluation = { maker: string; totalQuoteValueOnBook: number; competitiveQuoteValueOnBook: number; + quotedFillReferencePrice?: number; competitiveLiquidity?: CompetitiveLiquidity; }; @@ -79,9 +79,7 @@ export type TradeMetricsSinks = { indicativeCompetitiveFillCount: CounterSink; indicativeCompetitiveOpportunityNotional: CounterSink; indicativeCompetitiveCapturedNotional: CounterSink; - indicativeFillVsQuoteBucketCount: CounterSink; - indicativeFillVsQuoteDirectionBucketCount: CounterSink; - indicativeQuoteEvaluationCount: CounterSink; + indicativeFillVsQuoteOutcomeCount: CounterSink; indicativeTotalSizeOnBookGauge: GaugeSink; indicativeCompetitiveSizeOnBookGauge: GaugeSink; }; @@ -99,6 +97,7 @@ export const createTradeMetricsProcessor = ({ redisClientPrefix, indicativeQuoteMaxAgeMs, indicativeQuotesCacheTtlMs, + indicativeQuoteHistoryWindowMs = 5000, spotMarketPrecisionResolver, publisherRedisClient, indicativeQuotesRedisClient, @@ -109,6 +108,7 @@ export const createTradeMetricsProcessor = ({ redisClientPrefix: string; indicativeQuoteMaxAgeMs: number; indicativeQuotesCacheTtlMs: number; + indicativeQuoteHistoryWindowMs?: number; spotMarketPrecisionResolver: (marketIndex: number) => number | undefined; publisherRedisClient: PublisherRedisClient; indicativeQuotesRedisClient: IndicativeQuotesRedisClient; @@ -118,13 +118,121 @@ export const createTradeMetricsProcessor = ({ }) => { const marketQuoteCache = new Map(); + const getMarketKey = ( + fillEvent: Pick + ) => `${fillEvent.marketType}_${fillEvent.marketIndex}`; + + const fetchLatestQuoteBlobs = async ( + fillEvent: Pick + ) => { + const mmSetKey = `market_mms_${fillEvent.marketType}_${fillEvent.marketIndex}`; + const makers = await indicativeQuotesRedisClient.smembers(mmSetKey); + if (!makers.length) { + return []; + } + + const quoteBlobs = (await Promise.all( + makers.map((maker) => + indicativeQuotesRedisClient.get( + `mm_quotes_v2_${fillEvent.marketType}_${fillEvent.marketIndex}_${maker}` + ) + ) + )) as (IndicativeQuoteBlob | null)[]; + + return makers.map((maker, idx) => ({ + maker, + quoteBlob: quoteBlobs[idx], + })); + }; + + const pruneQuoteHistory = ( + historyByMaker: Map, + nowMs: number + ) => { + for (const [maker, history] of historyByMaker.entries()) { + const pruned = history.filter((quoteBlob) => { + const quoteTsMs = getQuoteTimestampMs(quoteBlob); + return ( + quoteTsMs !== undefined && + nowMs - quoteTsMs <= indicativeQuoteHistoryWindowMs + ); + }); + if (pruned.length) { + historyByMaker.set(maker, pruned); + } else { + historyByMaker.delete(maker); + } + } + }; + + const upsertQuoteHistory = ( + historyByMaker: Map, + quoteBlobs: Array<{ maker: string; quoteBlob: IndicativeQuoteBlob | null }>, + nowMs: number + ) => { + for (const { maker, quoteBlob } of quoteBlobs) { + if (!quoteBlob) { + continue; + } + const quoteTsMs = getQuoteTimestampMs(quoteBlob); + if (quoteTsMs === undefined) { + continue; + } + const history = historyByMaker.get(maker) ?? []; + const lastQuoteTsMs = history.length + ? getQuoteTimestampMs(history[history.length - 1]) + : undefined; + if (lastQuoteTsMs !== quoteTsMs) { + history.push(quoteBlob); + historyByMaker.set(maker, history); + } + } + pruneQuoteHistory(historyByMaker, nowMs); + }; + + const ensureMarketPolling = ( + fillEvent: Pick + ) => { + const marketKey = getMarketKey(fillEvent); + const existing = marketQuoteCache.get(marketKey); + if (existing?.poller) { + return; + } + + const historyByMaker = + existing?.historyByMaker ?? new Map(); + const pollQuotes = async () => { + try { + const quoteBlobs = await fetchLatestQuoteBlobs(fillEvent); + upsertQuoteHistory(historyByMaker, quoteBlobs, nowMsProvider()); + } catch (error) { + onError?.(error); + } + }; + + void pollQuotes(); + const poller = setInterval(() => { + void pollQuotes(); + }, indicativeQuotesCacheTtlMs); + poller.unref?.(); + marketQuoteCache.set(marketKey, { historyByMaker, poller }); + }; + const getMarketQuotes = async ( fillEvent: FillEvent, side: 'long' | 'short', fillPrice: number, - evaluationTsMs: number + fillTsMs: number ): Promise => { - const marketKey = `${fillEvent.marketType}_${fillEvent.marketIndex}_${side}`; + const marketKey = getMarketKey(fillEvent); + ensureMarketPolling(fillEvent); + const marketQuoteEntry = marketQuoteCache.get(marketKey); + const historyByMaker = marketQuoteEntry?.historyByMaker ?? new Map(); + if (!historyByMaker.size) { + const latestQuoteBlobs = await fetchLatestQuoteBlobs(fillEvent); + upsertQuoteHistory(historyByMaker, latestQuoteBlobs, nowMsProvider()); + } + pruneQuoteHistory(historyByMaker, nowMsProvider()); const mapQuoteBlob = ( maker: string, quoteBlob: IndicativeQuoteBlob | null @@ -143,7 +251,7 @@ export const createTradeMetricsProcessor = ({ ); const quoteTsMs = getQuoteTimestampMs(quoteBlob); const quoteAgeMs = - quoteTsMs !== undefined ? evaluationTsMs - quoteTsMs : Infinity; + quoteTsMs !== undefined ? fillTsMs - quoteTsMs : Infinity; const isFresh = quoteAgeMs >= 0 && quoteAgeMs <= indicativeQuoteMaxAgeMs; return { @@ -153,48 +261,27 @@ export const createTradeMetricsProcessor = ({ : 0, competitiveQuoteValueOnBook: isFresh && competitiveLiquidity ? competitiveLiquidity.quoteValue : 0, + quotedFillReferencePrice: isFresh + ? getQuotedFillReferencePrice( + fillEvent, + side, + fillEvent.baseAssetAmountFilled, + quoteBlob, + spotPrecision + ) + : undefined, competitiveLiquidity: isFresh ? competitiveLiquidity : undefined, }; }; - - const cached = marketQuoteCache.get(marketKey); - if ( - cached && - nowMsProvider() - 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: nowMsProvider(), - 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: nowMsProvider(), - quoteBlobs: cachedQuoteBlobs, + const quoteBlobs = [...historyByMaker.entries()].map(([maker, history]) => { + const quoteBlob = + [...history].reverse().find((candidate) => { + const quoteTsMs = getQuoteTimestampMs(candidate); + return quoteTsMs !== undefined && quoteTsMs <= fillTsMs; + }) ?? null; + return { maker, quoteBlob }; }); - - return cachedQuoteBlobs.map(({ maker, quoteBlob }) => + return quoteBlobs.map(({ maker, quoteBlob }) => mapQuoteBlob(maker, quoteBlob) ); }; @@ -224,12 +311,11 @@ export const createTradeMetricsProcessor = ({ metrics.marketFillCount.add(1, marketMetricAttrs); try { - const evaluationTsMs = nowMsProvider(); const marketQuoteEvaluations = await getMarketQuotes( fillEvent, fillSide, fillPrice, - evaluationTsMs + getFillTimestampMs(fillEvent.ts) ); const marketQuotes = marketQuoteEvaluations .map((evaluation) => evaluation.competitiveLiquidity) @@ -266,10 +352,6 @@ export const createTradeMetricsProcessor = ({ opportunityNotional, attrs ); - metrics.indicativeQuoteEvaluationCount.add(1, { - ...attrs, - result: 'competitive', - }); if (fillEvent.makerIndicativeKey === quote.maker) { metrics.indicativeCompetitiveFillCount.add(1, attrs); @@ -278,29 +360,9 @@ export const createTradeMetricsProcessor = ({ 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', - }); - } - const fillMakerEvaluation = fillEvent.makerIndicativeKey ? marketQuoteEvaluations.find( (evaluation) => evaluation.maker === fillEvent.makerIndicativeKey @@ -310,26 +372,32 @@ export const createTradeMetricsProcessor = ({ fillEvent.makerIndicativeKey && fillMakerEvaluation && fillMakerEvaluation.totalQuoteValueOnBook > 0 && - !marketQuotes.find( - (quote) => quote.maker === fillEvent.makerIndicativeKey - ) + fillMakerEvaluation.quotedFillReferencePrice && + Number.isFinite(fillMakerEvaluation.quotedFillReferencePrice) ) { - metrics.indicativeQuoteEvaluationCount.add(1, { - ...getMakerMetricAttrs( - fillEvent, - fillMakerEvaluation.maker, - fillSide + const fillMakerAttrs = getMakerMetricAttrs( + fillEvent, + fillMakerEvaluation.maker, + fillSide + ); + metrics.indicativeFillVsQuoteOutcomeCount.add(1, { + ...fillMakerAttrs, + bucket: getIndicativeBpsBucket( + getAbsoluteBpsDiff( + fillPrice, + fillMakerEvaluation.quotedFillReferencePrice + ) + ), + direction: getIndicativeDirectionBucket( + getSignedBpsDiff( + fillPrice, + fillMakerEvaluation.quotedFillReferencePrice + ) ), - result: 'maker_not_competitive', }); } } catch (error) { onError?.(error); - metrics.indicativeQuoteEvaluationCount.add(1, { - ...marketMetricAttrs, - maker: 'all', - result: 'error', - }); } }; diff --git a/src/publishers/tradesPublisher.ts b/src/publishers/tradesPublisher.ts index 24fa9a5..36b9620 100644 --- a/src/publishers/tradesPublisher.ts +++ b/src/publishers/tradesPublisher.ts @@ -60,7 +60,7 @@ 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' + 'Total market fills considered for indicative quote metrics' ); const indicativePresenceCount = metricsV2.addCounter( 'indicative_presence_total', @@ -82,17 +82,9 @@ 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 indicativeFillVsQuoteOutcomeCount = metricsV2.addCounter( + 'indicative_fill_vs_quote_outcome_total', + 'Count of maker fills by weighted fresh indicative quote bucket and direction' ); const indicativeTotalSizeOnBookGauge = metricsV2.addGauge( 'indicative_total_size_on_book', @@ -232,9 +224,7 @@ const main = async () => { indicativeCompetitiveFillCount, indicativeCompetitiveOpportunityNotional, indicativeCompetitiveCapturedNotional, - indicativeFillVsQuoteBucketCount, - indicativeFillVsQuoteDirectionBucketCount, - indicativeQuoteEvaluationCount, + indicativeFillVsQuoteOutcomeCount, indicativeTotalSizeOnBookGauge, indicativeCompetitiveSizeOnBookGauge, },