Skip to content

Commit a5df8cd

Browse files
Merge pull request #602 from drift-labs/master
Indicative liquidity metrics for trades publisher
2 parents 7139311 + 6da4a10 commit a5df8cd

11 files changed

Lines changed: 1762 additions & 23 deletions

.env.trades.local.example

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
ENDPOINT=https://api.devnet.solana.com
2+
WS_ENDPOINT=wss://api.devnet.solana.com
3+
ENV=devnet
4+
5+
RUNNING_LOCAL=true
6+
LOCAL_CACHE=true
7+
ELASTICACHE_HOST=localhost
8+
ELASTICACHE_PORT=6379
9+
REDIS_CLIENT=DLOB
10+
11+
METRICS_PORT=9465
12+
INDICATIVE_QUOTES_MAX_AGE_MS=1000
13+
INDICATIVE_QUOTES_CACHE_TTL_MS=250
14+
15+
ENABLE_MOCK_FILL_ENDPOINT=true
16+
MOCK_ONLY_MODE=true
17+
MOCK_FILL_PORT=9470
18+
19+
MOCK_MARKET_TYPE=perp
20+
MOCK_MARKET_INDEX=0
21+
MOCK_QUOTES_JSON=[{"maker":"good-maker","side":"long","price":100,"size":2},{"maker":"bad-maker","side":"long","price":99,"size":1}]
22+
MOCK_FILLS_JSON=[{"maker":"good-maker","side":"long","fillPrice":100,"fillSize":1,"oraclePrice":100}]

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,13 @@ src/**.js.map
139139
.idea
140140

141141
.env
142+
.env.*.local
142143
lib
143144
src/playground.ts
144145

145146
# redis files
146147
*.rdb
147148
*.log
148149
*.confg
149-
150+
*.aof*
151+
*.conf

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@aws-sdk/util-retry": "^3.374.0",
1111
"@coral-xyz/anchor": "^0.29.0",
1212
"@drift-labs/common": "1.0.20",
13-
"@drift-labs/sdk": "2.161.0-beta.4",
13+
"@drift-labs/sdk": "2.162.0-beta.1",
1414
"@opentelemetry/api": "^1.1.0",
1515
"@opentelemetry/auto-instrumentations-node": "^0.31.1",
1616
"@opentelemetry/exporter-prometheus": "^0.31.0",
@@ -71,6 +71,8 @@
7171
"server-lite": "ts-node src/serverLite.ts",
7272
"dlob-publish": "ts-node src/publishers/dlobPublisher.ts",
7373
"trades-publish": "ts-node src/publishers/tradesPublisher.ts",
74+
"trades-publish:local": "DOTENV_CONFIG_PATH=.env.trades.local ts-node -r dotenv/config src/publishers/tradesPublisher.ts",
75+
"mock-jit:submit": "DOTENV_CONFIG_PATH=.env.trades.local ts-node -r dotenv/config src/scripts/submitMockJitMetricsData.ts",
7476
"fees-publish": "ts-node src/publishers/priorityFeesPublisher.ts",
7577
"pnl-publish": "ts-node src/scripts/publishUnsettledPnlUsers.ts",
7678
"ws-manager": "ts-node src/wsConnectionManager.ts",
@@ -84,8 +86,8 @@
8486
"lint": "eslint . --ext ts --quiet",
8587
"lint:fix": "eslint . --ext ts --fix",
8688
"playground": "ts-node src/playground.ts",
87-
"test": "jest src/utils/tests/auctionParams.test.ts",
88-
"test:watch": "jest src/utils/tests/auctionParams.test.ts --watch"
89+
"test": "jest",
90+
"test:watch": "jest --watch"
8991
},
9092
"jest": {
9193
"preset": "ts-jest",
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import { BASE_PRECISION, PRICE_PRECISION } from '@drift-labs/sdk';
3+
import {
4+
getAbsoluteBpsDiff,
5+
getCompetitiveLiquidity,
6+
getIndicativeDirectionBucket,
7+
getFillPrice,
8+
getFillSide,
9+
getFillTimestampMs,
10+
getIndicativeBpsBucket,
11+
getQuoteTimestampMs,
12+
getQuoteValueOnBook,
13+
getSignedBpsDiff,
14+
isCompetitivePrice,
15+
rawPriceToNumber,
16+
} from '../tradeMetrics';
17+
18+
describe('tradeMetrics', () => {
19+
describe('getFillPrice', () => {
20+
it('computes the executed unit price', () => {
21+
expect(
22+
getFillPrice({
23+
baseAssetAmountFilled: 2,
24+
quoteAssetAmountFilled: 210,
25+
})
26+
).toBe(105);
27+
});
28+
29+
it('returns undefined for zero base size', () => {
30+
expect(
31+
getFillPrice({
32+
baseAssetAmountFilled: 0,
33+
quoteAssetAmountFilled: 210,
34+
})
35+
).toBeUndefined();
36+
});
37+
});
38+
39+
describe('getFillTimestampMs', () => {
40+
it('normalizes seconds to milliseconds', () => {
41+
expect(getFillTimestampMs(1710000000)).toBe(1710000000000);
42+
});
43+
44+
it('leaves millisecond timestamps unchanged', () => {
45+
expect(getFillTimestampMs(1710000000000)).toBe(1710000000000);
46+
});
47+
});
48+
49+
describe('getQuoteTimestampMs', () => {
50+
it('extracts the quote timestamp when present', () => {
51+
expect(getQuoteTimestampMs({ ts: 1710000000000 })).toBe(1710000000000);
52+
});
53+
54+
it('returns undefined for missing or invalid timestamps', () => {
55+
expect(getQuoteTimestampMs(null)).toBeUndefined();
56+
expect(getQuoteTimestampMs({ ts: 'bad-ts' })).toBeUndefined();
57+
});
58+
});
59+
60+
describe('getFillSide', () => {
61+
it('infers the maker side from taker direction first', () => {
62+
expect(getFillSide({ takerOrderDirection: 'long' })).toBe('short');
63+
expect(getFillSide({ takerOrderDirection: 'short' })).toBe('long');
64+
});
65+
66+
it('falls back to maker direction', () => {
67+
expect(getFillSide({ makerOrderDirection: 'long' })).toBe('long');
68+
expect(getFillSide({ makerOrderDirection: 'short' })).toBe('short');
69+
});
70+
71+
it('returns undefined when neither side is available', () => {
72+
expect(getFillSide({})).toBeUndefined();
73+
});
74+
});
75+
76+
describe('rawPriceToNumber', () => {
77+
it('converts oracle-offset quotes to absolute prices', () => {
78+
expect(rawPriceToNumber(2 * PRICE_PRECISION.toNumber(), 100)).toBe(102);
79+
});
80+
});
81+
82+
describe('isCompetitivePrice', () => {
83+
it('treats bid prices at or above the fill price as competitive', () => {
84+
expect(isCompetitivePrice('long', 101, 100)).toBe(true);
85+
expect(isCompetitivePrice('long', 99, 100)).toBe(false);
86+
});
87+
88+
it('treats ask prices at or below the fill price as competitive', () => {
89+
expect(isCompetitivePrice('short', 99, 100)).toBe(true);
90+
expect(isCompetitivePrice('short', 101, 100)).toBe(false);
91+
});
92+
});
93+
94+
describe('bps bucketing', () => {
95+
it('computes absolute bps distance', () => {
96+
expect(getAbsoluteBpsDiff(100.1, 100)).toBeCloseTo(10, 8);
97+
expect(getAbsoluteBpsDiff(99.9, 100)).toBeCloseTo(10, 8);
98+
});
99+
100+
it('computes signed bps distance', () => {
101+
expect(getSignedBpsDiff(100.1, 100)).toBeCloseTo(10, 8);
102+
expect(getSignedBpsDiff(99.9, 100)).toBeCloseTo(-10, 8);
103+
});
104+
105+
it('maps bps distances into the configured buckets', () => {
106+
expect(getIndicativeBpsBucket(0)).toBe('very_tight');
107+
expect(getIndicativeBpsBucket(9.99)).toBe('very_tight');
108+
expect(getIndicativeBpsBucket(10)).toBe('tight');
109+
expect(getIndicativeBpsBucket(19.99)).toBe('tight');
110+
expect(getIndicativeBpsBucket(20)).toBe('moderate');
111+
expect(getIndicativeBpsBucket(29.99)).toBe('moderate');
112+
expect(getIndicativeBpsBucket(30)).toBe('wide');
113+
expect(getIndicativeBpsBucket(49.99)).toBe('wide');
114+
expect(getIndicativeBpsBucket(50)).toBe('very_wide');
115+
expect(getIndicativeBpsBucket(500)).toBe('very_wide');
116+
});
117+
118+
it('maps signed bps distances into directional buckets', () => {
119+
expect(getIndicativeDirectionBucket(10)).toBe('better');
120+
expect(getIndicativeDirectionBucket(0)).toBe('equal');
121+
expect(getIndicativeDirectionBucket(-10)).toBe('worse');
122+
});
123+
});
124+
125+
describe('getCompetitiveLiquidity', () => {
126+
it('aggregates only bid levels that were competitive for a long maker', () => {
127+
const liquidity = getCompetitiveLiquidity(
128+
'mm-1',
129+
{
130+
marketIndex: 0,
131+
marketType: 'perp',
132+
oraclePrice: 100,
133+
},
134+
'long',
135+
100,
136+
{
137+
ts: 1710000000000,
138+
quotes: [
139+
{
140+
bid_price: 101 * PRICE_PRECISION.toNumber(),
141+
bid_size: BASE_PRECISION.toNumber(),
142+
},
143+
{
144+
bid_price: 100 * PRICE_PRECISION.toNumber(),
145+
bid_size: 2 * BASE_PRECISION.toNumber(),
146+
},
147+
{
148+
bid_price: 99 * PRICE_PRECISION.toNumber(),
149+
bid_size: 4 * BASE_PRECISION.toNumber(),
150+
},
151+
],
152+
}
153+
);
154+
155+
expect(liquidity).toEqual({
156+
maker: 'mm-1',
157+
bestPrice: 101,
158+
size: 3,
159+
quoteValue: 301,
160+
quoteTsMs: 1710000000000,
161+
});
162+
});
163+
164+
it('aggregates only ask levels that were competitive for a short maker', () => {
165+
const liquidity = getCompetitiveLiquidity(
166+
'mm-2',
167+
{
168+
marketIndex: 0,
169+
marketType: 'perp',
170+
oraclePrice: 100,
171+
},
172+
'short',
173+
100,
174+
{
175+
ts: 1710000000000,
176+
quotes: [
177+
{
178+
ask_price: 99 * PRICE_PRECISION.toNumber(),
179+
ask_size: 1.5 * BASE_PRECISION.toNumber(),
180+
},
181+
{
182+
ask_price: 100 * PRICE_PRECISION.toNumber(),
183+
ask_size: 0.5 * BASE_PRECISION.toNumber(),
184+
},
185+
{
186+
ask_price: 101 * PRICE_PRECISION.toNumber(),
187+
ask_size: 10 * BASE_PRECISION.toNumber(),
188+
},
189+
],
190+
}
191+
);
192+
193+
expect(liquidity).toEqual({
194+
maker: 'mm-2',
195+
bestPrice: 99,
196+
size: 2,
197+
quoteValue: 198.5,
198+
quoteTsMs: 1710000000000,
199+
});
200+
});
201+
202+
it('supports oracle-offset quotes', () => {
203+
const liquidity = getCompetitiveLiquidity(
204+
'mm-3',
205+
{
206+
marketIndex: 0,
207+
marketType: 'perp',
208+
oraclePrice: 100,
209+
},
210+
'long',
211+
100,
212+
{
213+
ts: 1710000000000,
214+
quotes: [
215+
{
216+
bid_price: PRICE_PRECISION.toNumber(),
217+
bid_size: BASE_PRECISION.toNumber(),
218+
is_oracle_offset: true,
219+
},
220+
],
221+
}
222+
);
223+
224+
expect(liquidity?.bestPrice).toBe(101);
225+
expect(liquidity?.size).toBe(1);
226+
});
227+
228+
it('uses spot market precision when provided', () => {
229+
const liquidity = getCompetitiveLiquidity(
230+
'mm-4',
231+
{
232+
marketIndex: 1,
233+
marketType: 'spot',
234+
oraclePrice: 10,
235+
},
236+
'long',
237+
10,
238+
{
239+
ts: 1710000000000,
240+
quotes: [
241+
{
242+
bid_price: 10 * PRICE_PRECISION.toNumber(),
243+
bid_size: 2_000_000,
244+
},
245+
],
246+
},
247+
1_000_000
248+
);
249+
250+
expect(liquidity?.size).toBe(2);
251+
});
252+
253+
it('returns undefined when no levels were competitive', () => {
254+
expect(
255+
getCompetitiveLiquidity(
256+
'mm-5',
257+
{
258+
marketIndex: 0,
259+
marketType: 'perp',
260+
oraclePrice: 100,
261+
},
262+
'long',
263+
100,
264+
{
265+
ts: 1710000000000,
266+
quotes: [
267+
{
268+
bid_price: 99 * PRICE_PRECISION.toNumber(),
269+
bid_size: BASE_PRECISION.toNumber(),
270+
},
271+
],
272+
}
273+
)
274+
).toBeUndefined();
275+
});
276+
});
277+
278+
describe('getQuoteValueOnBook', () => {
279+
it('sums quote notional on the relevant side', () => {
280+
expect(
281+
getQuoteValueOnBook(
282+
{
283+
marketIndex: 0,
284+
marketType: 'perp',
285+
oraclePrice: 100,
286+
},
287+
'long',
288+
{
289+
ts: 1710000000000,
290+
quotes: [
291+
{
292+
bid_price: 101 * PRICE_PRECISION.toNumber(),
293+
bid_size: BASE_PRECISION.toNumber(),
294+
},
295+
{
296+
bid_price: 100 * PRICE_PRECISION.toNumber(),
297+
bid_size: 2 * BASE_PRECISION.toNumber(),
298+
},
299+
],
300+
}
301+
)
302+
).toBe(301);
303+
});
304+
305+
it('supports oracle offset prices', () => {
306+
expect(
307+
getQuoteValueOnBook(
308+
{
309+
marketIndex: 0,
310+
marketType: 'perp',
311+
oraclePrice: 100,
312+
},
313+
'long',
314+
{
315+
ts: 1710000000000,
316+
quotes: [
317+
{
318+
bid_price: PRICE_PRECISION.toNumber(),
319+
bid_size: BASE_PRECISION.toNumber(),
320+
is_oracle_offset: true,
321+
},
322+
],
323+
}
324+
)
325+
).toBe(101);
326+
});
327+
});
328+
});

0 commit comments

Comments
 (0)