diff --git a/.env.trades.local.example b/.env.trades.local.example index 402217f..44e60b0 100644 --- a/.env.trades.local.example +++ b/.env.trades.local.example @@ -12,11 +12,13 @@ METRICS_PORT=9465 INDICATIVE_QUOTES_MAX_AGE_MS=1000 INDICATIVE_QUOTES_CACHE_TTL_MS=250 +INDICATIVE_TO_MAKER_AUTHORITY_MAP='{"7yivAV6EE7PofhvnjnGJMsL2qrnxVXzvGgrkSDQiveK6":"GXyE3Snk3pPYX4Nz9QRVBrnBfbJRTAQYxuy5DRdnebAn","CHmpeeywt1xkXUubKnCRV3DymSJ1xtMVSRhZcJepBamU":"BxTExiVRt9EHe4b47ZDQLDGxee1hPexvkmaDFMLZTDvv","wvTxWGdSG6aV4zShf3CFzPPwTuw6eZsZTrpRNRSkVL9":"E69Pb3EoqrEYjNzFuw2JyEerwyPBFVdGZwmWoDzT9LVL","BroTbC7irNtmQdxcq5BxgYXkkdgJqYWccfBVydt6nMhe":"EibQ2VYpzj18qSdEBkmxWVzde7FzamTxVG9rZyY689Yj"}' + 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}] +MOCK_QUOTES_JSON=[{"maker":"CHmpeeywt1xkXUubKnCRV3DymSJ1xtMVSRhZcJepBamU","side":"long","price":99.8,"size":10},{"maker":"CHmpeeywt1xkXUubKnCRV3DymSJ1xtMVSRhZcJepBamU","side":"long","price":99.9,"size":2},{"maker":"wvTxWGdSG6aV4zShf3CFzPPwTuw6eZsZTrpRNRSkVL9","side":"long","price":101,"size":1},{"maker":"CHmpeeywt1xkXUubKnCRV3DymSJ1xtMVSRhZcJepBamU","side":"short","price":101.1,"size":2}] +MOCK_FILLS_JSON=[{"maker":"C13FZykQfLXKuMAMh2iuG7JxhQqd8otujNRAgVETU6id","side":"long","fillPrice":99.9,"fillSize":1,"oraclePrice":99.95}] diff --git a/src/publishers/tests/tradeMetricsProcessor.test.ts b/src/publishers/tests/tradeMetricsProcessor.test.ts index b31d357..1ff0829 100644 --- a/src/publishers/tests/tradeMetricsProcessor.test.ts +++ b/src/publishers/tests/tradeMetricsProcessor.test.ts @@ -135,7 +135,8 @@ describe('tradeMetricsProcessor', () => { takerOrderBaseAssetAmount: 1, takerOrderCumulativeBaseAssetAmountFilled: 1, takerOrderCumulativeQuoteAssetAmountFilled: 100.1, - maker: 'good-maker', + maker: 'maker-user-account', + makerIndicativeKey: 'good-maker', makerOrderId: 2, makerOrderDirection: 'long', makerOrderBaseAssetAmount: 1, @@ -367,6 +368,86 @@ describe('tradeMetricsProcessor', () => { ); }); + it('emits maker_not_competitive using the indicative maker label when indicative key is provided', async () => { + const metrics = createMetricSinks(); + const quoteState = new Map([ + ['market_mms_perp_0', ['quoted-maker']], + [ + 'mm_quotes_v2_perp_0_quoted-maker', + { + 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, + }); + + const fillEvent: FillEvent = { + 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-4', + slot: 4, + fillRecordId: 4, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }; + + await processFillEvent(fillEvent); + + expect(metrics.indicativeQuoteEvaluationCount.calls).toEqual( + expect.arrayContaining([ + { + value: 1, + attributes: { + maker: 'quoted-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + result: 'maker_not_competitive', + }, + }, + ]) + ); + }); + it('treats quotes as fresh based on evaluation time rather than fill timestamp', async () => { const metrics = createMetricSinks(); const quoteState = new Map([ @@ -456,4 +537,81 @@ describe('tradeMetricsProcessor', () => { ]) ); }); + + it('matches competitive fills using precomputed indicative key', async () => { + const metrics = createMetricSinks(); + const quoteState = new Map([ + ['market_mms_perp_0', ['indicative-maker']], + [ + 'mm_quotes_v2_perp_0_indicative-maker', + { + ts: 1710000000000, + quotes: [{ bid_price: 100100000, 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, + }); + + 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: 'maker-user-account', + makerIndicativeKey: 'indicative-maker', + makerOrderId: 2, + makerOrderDirection: 'long', + makerOrderBaseAssetAmount: 1, + makerOrderCumulativeBaseAssetAmountFilled: 1, + makerOrderCumulativeQuoteAssetAmountFilled: 100.1, + oraclePrice: 100, + txSig: 'mock-5', + slot: 5, + fillRecordId: 5, + action: 'fill', + actionExplanation: 'none', + referrerReward: 0, + bitFlags: 0, + }; + + await processFillEvent(fillEvent); + + expect(metrics.indicativeCompetitiveFillCount.calls).toEqual([ + { + value: 1, + attributes: { + maker: 'indicative-maker', + market_index: 0, + market_type: 'perp', + side: 'long', + }, + }, + ]); + }); }); diff --git a/src/publishers/tradeMetricsProcessor.ts b/src/publishers/tradeMetricsProcessor.ts index bc5bc6f..c0d5ca2 100644 --- a/src/publishers/tradeMetricsProcessor.ts +++ b/src/publishers/tradeMetricsProcessor.ts @@ -30,6 +30,7 @@ export type FillEvent = { takerOrderCumulativeBaseAssetAmountFilled: number; takerOrderCumulativeQuoteAssetAmountFilled: number; maker?: string; + makerIndicativeKey?: string; makerOrderId?: number; makerOrderDirection?: string; makerOrderBaseAssetAmount: number; @@ -270,7 +271,7 @@ export const createTradeMetricsProcessor = ({ result: 'competitive', }); - if (fillEvent.maker === quote.maker) { + if (fillEvent.makerIndicativeKey === quote.maker) { metrics.indicativeCompetitiveFillCount.add(1, attrs); metrics.indicativeCompetitiveCapturedNotional.add( Math.min(fillEvent.baseAssetAmountFilled, opportunitySize) * @@ -300,19 +301,25 @@ export const createTradeMetricsProcessor = ({ }); } - const fillMakerEvaluation = fillEvent.maker + const fillMakerEvaluation = fillEvent.makerIndicativeKey ? marketQuoteEvaluations.find( - (evaluation) => evaluation.maker === fillEvent.maker + (evaluation) => evaluation.maker === fillEvent.makerIndicativeKey ) : undefined; if ( - fillEvent.maker && + fillEvent.makerIndicativeKey && fillMakerEvaluation && fillMakerEvaluation.totalQuoteValueOnBook > 0 && - !marketQuotes.find((quote) => quote.maker === fillEvent.maker) + !marketQuotes.find( + (quote) => quote.maker === fillEvent.makerIndicativeKey + ) ) { metrics.indicativeQuoteEvaluationCount.add(1, { - ...getMakerMetricAttrs(fillEvent, fillEvent.maker, fillSide), + ...getMakerMetricAttrs( + fillEvent, + fillMakerEvaluation.maker, + fillSide + ), result: 'maker_not_competitive', }); } diff --git a/src/publishers/tradesPublisher.ts b/src/publishers/tradesPublisher.ts index 90d47bf..24fa9a5 100644 --- a/src/publishers/tradesPublisher.ts +++ b/src/publishers/tradesPublisher.ts @@ -112,6 +112,14 @@ 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 indicativeToMakerAuthorityMap = process.env + .INDICATIVE_TO_MAKER_AUTHORITY_MAP + ? (JSON.parse(process.env.INDICATIVE_TO_MAKER_AUTHORITY_MAP) as Record< + string, + string + >) + : {}; +const subaccountToIndicative = new Map(); const enableMockFillEndpoint = process.env.ENABLE_MOCK_FILL_ENDPOINT?.toLowerCase() === 'true'; const mockOnlyMode = process.env.MOCK_ONLY_MODE?.toLowerCase() === 'true'; @@ -134,7 +142,13 @@ const startMockFillEndpoint = ( app.use(express.json()); app.post('/mockFill', async (req, res) => { try { - await processFillEvent(req.body as FillEvent); + const fillEvent = req.body as FillEvent; + await processFillEvent({ + ...fillEvent, + makerIndicativeKey: fillEvent.maker + ? subaccountToIndicative.get(fillEvent.maker) + : undefined, + } as FillEvent); res.status(200).json({ ok: true }); } catch (error) { logger.error('Failed to process mock fill:', error); @@ -148,6 +162,33 @@ const startMockFillEndpoint = ( }); }; +const preloadMakerAccountCache = async () => { + for (const [indicativeMaker, authority] of Object.entries( + indicativeToMakerAuthorityMap + )) { + try { + const userAccounts = + await driftClient.getUserAccountsAndAddressesForAuthority( + new PublicKey(authority) + ); + for (const userAccount of userAccounts) { + subaccountToIndicative.set( + userAccount.publicKey.toBase58(), + indicativeMaker + ); + } + logger.info( + `Preloaded ${userAccounts.length} subaccounts for indicative maker ${indicativeMaker}` + ); + } catch (error) { + logger.error( + `Failed to preload subaccounts for indicative maker ${indicativeMaker}:`, + error + ); + } + } +}; + const main = async () => { const wallet = new Wallet(new Keypair()); const clearingHousePublicKey = new PublicKey(sdkConfig.DRIFT_PROGRAM_ID); @@ -173,6 +214,8 @@ const main = async () => { await redisClient.connect(); const indicativeQuotesRedisClient = new RedisClient({}); await indicativeQuotesRedisClient.connect(); + await driftClient.subscribe(); + await preloadMakerAccountCache(); const { processFillEvent } = createTradeMetricsProcessor({ redisClientPrefix, @@ -216,8 +259,6 @@ const main = async () => { ); logger.info(`Wallet pubkey: ${wallet.publicKey.toBase58()}`); logger.info(` . SOL balance: ${lamportsBalance / 10 ** 9}`); - - await driftClient.subscribe(); driftClient.eventEmitter.on('error', (e) => { logger.error(e); }); @@ -318,7 +359,12 @@ const main = async () => { }) ) .subscribe(async (fillEvent: FillEvent) => { - await processFillEvent(fillEvent); + await processFillEvent({ + ...fillEvent, + makerIndicativeKey: fillEvent.maker + ? subaccountToIndicative.get(fillEvent.maker) + : undefined, + }); }); console.log('Publishing trades');