Skip to content

Commit f346663

Browse files
authored
Add Orderbook Mid Price Cache (#2338)
1 parent ab83828 commit f346663

File tree

12 files changed

+487
-30
lines changed

12 files changed

+487
-30
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { deleteAllAsync } from '../../src/helpers/redis';
2+
import { redis as client } from '../helpers/utils';
3+
import {
4+
setPrice,
5+
getMedianPrice,
6+
ORDERBOOK_MID_PRICES_CACHE_KEY_PREFIX,
7+
} from '../../src/caches/orderbook-mid-prices-cache';
8+
9+
describe('orderbook-mid-prices-cache', () => {
10+
const ticker: string = 'BTC-USD';
11+
12+
beforeEach(async () => {
13+
await deleteAllAsync(client);
14+
});
15+
16+
describe('setPrice', () => {
17+
it('sets a price for a ticker', async () => {
18+
await setPrice(client, ticker, '50000');
19+
20+
await client.zrange(
21+
`${ORDERBOOK_MID_PRICES_CACHE_KEY_PREFIX}${ticker}`,
22+
0,
23+
-1,
24+
(_: any, response: string[]) => {
25+
expect(response[0]).toBe('50000');
26+
},
27+
);
28+
});
29+
30+
it('sets multiple prices for a ticker', async () => {
31+
await Promise.all([
32+
setPrice(client, ticker, '50000'),
33+
setPrice(client, ticker, '51000'),
34+
setPrice(client, ticker, '49000'),
35+
]);
36+
37+
await client.zrange(
38+
`${ORDERBOOK_MID_PRICES_CACHE_KEY_PREFIX}${ticker}`,
39+
0,
40+
-1,
41+
(_: any, response: string[]) => {
42+
expect(response).toEqual(['49000', '50000', '51000']);
43+
},
44+
);
45+
});
46+
});
47+
48+
describe('getMedianPrice', () => {
49+
it('returns null when no prices are set', async () => {
50+
const result = await getMedianPrice(client, ticker);
51+
expect(result).toBeNull();
52+
});
53+
54+
it('returns the median price for odd number of prices', async () => {
55+
await Promise.all([
56+
setPrice(client, ticker, '50000'),
57+
setPrice(client, ticker, '51000'),
58+
setPrice(client, ticker, '49000'),
59+
]);
60+
61+
const result = await getMedianPrice(client, ticker);
62+
expect(result).toBe('50000');
63+
});
64+
65+
it('returns the median price for even number of prices', async () => {
66+
await Promise.all([
67+
setPrice(client, ticker, '50000'),
68+
setPrice(client, ticker, '51000'),
69+
setPrice(client, ticker, '49000'),
70+
setPrice(client, ticker, '52000'),
71+
]);
72+
73+
const result = await getMedianPrice(client, ticker);
74+
expect(result).toBe('50500');
75+
});
76+
77+
it('returns the correct median price after 5 seconds', async () => {
78+
jest.useFakeTimers();
79+
80+
const nowSeconds = Math.floor(Date.now() / 1000);
81+
jest.setSystemTime(nowSeconds * 1000);
82+
83+
await Promise.all([
84+
setPrice(client, ticker, '50000'),
85+
setPrice(client, ticker, '51000'),
86+
]);
87+
88+
jest.advanceTimersByTime(6000); // Advance time by 6 seconds
89+
await Promise.all([
90+
setPrice(client, ticker, '49000'),
91+
setPrice(client, ticker, '48000'),
92+
setPrice(client, ticker, '52000'),
93+
setPrice(client, ticker, '53000'),
94+
]);
95+
96+
const result = await getMedianPrice(client, ticker);
97+
expect(result).toBe('50500');
98+
99+
jest.useRealTimers();
100+
});
101+
102+
it('returns the correct median price for small numbers with even number of prices', async () => {
103+
await Promise.all([
104+
setPrice(client, ticker, '0.00000000002345'),
105+
setPrice(client, ticker, '0.00000000002346'),
106+
]);
107+
108+
const midPrice1 = await getMedianPrice(client, ticker);
109+
expect(midPrice1).toEqual('0.000000000023455');
110+
});
111+
112+
it('returns the correct median price for small numbers with odd number of prices', async () => {
113+
await Promise.all([
114+
setPrice(client, ticker, '0.00000000001'),
115+
setPrice(client, ticker, '0.00000000002'),
116+
setPrice(client, ticker, '0.00000000003'),
117+
setPrice(client, ticker, '0.00000000004'),
118+
setPrice(client, ticker, '0.00000000005'),
119+
]);
120+
121+
const midPrice1 = await getMedianPrice(client, ticker);
122+
expect(midPrice1).toEqual('0.00000000003');
123+
124+
await deleteAllAsync(client);
125+
126+
await Promise.all([
127+
setPrice(client, ticker, '0.00000847007'),
128+
setPrice(client, ticker, '0.00000847006'),
129+
setPrice(client, ticker, '0.00000847008'),
130+
]);
131+
132+
const midPrice2 = await getMedianPrice(client, ticker);
133+
expect(midPrice2).toEqual('0.00000847007');
134+
});
135+
});
136+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import Big from 'big.js';
2+
import { Callback, RedisClient } from 'redis';
3+
4+
import {
5+
addMarketPriceScript,
6+
getMarketMedianScript,
7+
} from './scripts';
8+
9+
// Cache of orderbook prices for each clob pair
10+
// Each price is cached for a 5 second window and in a ZSET
11+
export const ORDERBOOK_MID_PRICES_CACHE_KEY_PREFIX: string = 'v4/orderbook_mid_prices/';
12+
13+
/**
14+
* Generates a cache key for a given ticker's orderbook mid price.
15+
* @param ticker The ticker symbol
16+
* @returns The cache key string
17+
*/
18+
function getOrderbookMidPriceCacheKey(ticker: string): string {
19+
return `${ORDERBOOK_MID_PRICES_CACHE_KEY_PREFIX}${ticker}`;
20+
}
21+
22+
/**
23+
* Adds a price to the market prices cache for a given ticker.
24+
* Uses a Lua script to add the price with a timestamp to a sorted set in Redis.
25+
* @param client The Redis client
26+
* @param ticker The ticker symbol
27+
* @param price The price to be added
28+
* @returns A promise that resolves when the operation is complete
29+
*/
30+
export async function setPrice(
31+
client: RedisClient,
32+
ticker: string,
33+
price: string,
34+
): Promise<void> {
35+
// Number of keys for the lua script.
36+
const numKeys: number = 1;
37+
38+
let evalAsync: (
39+
marketCacheKey: string,
40+
) => Promise<void> = (marketCacheKey) => {
41+
42+
return new Promise<void>((resolve, reject) => {
43+
const callback: Callback<void> = (
44+
err: Error | null,
45+
) => {
46+
if (err) {
47+
return reject(err);
48+
}
49+
return resolve();
50+
};
51+
52+
const nowSeconds = Math.floor(Date.now() / 1000); // Current time in seconds
53+
client.evalsha(
54+
addMarketPriceScript.hash,
55+
numKeys,
56+
marketCacheKey,
57+
price,
58+
nowSeconds,
59+
callback,
60+
);
61+
62+
});
63+
};
64+
evalAsync = evalAsync.bind(client);
65+
66+
return evalAsync(
67+
getOrderbookMidPriceCacheKey(ticker),
68+
);
69+
}
70+
71+
/**
72+
* Retrieves the median price for a given ticker from the cache.
73+
* Uses a Lua script to fetch either the middle element (for odd number of prices)
74+
* or the two middle elements (for even number of prices) from a sorted set in Redis.
75+
* If two middle elements are returned, their average is calculated in JavaScript.
76+
* @param client The Redis client
77+
* @param ticker The ticker symbol
78+
* @returns A promise that resolves with the median price as a string, or null if not found
79+
*/
80+
export async function getMedianPrice(client: RedisClient, ticker: string): Promise<string | null> {
81+
let evalAsync: (
82+
marketCacheKey: string,
83+
) => Promise<string[]> = (
84+
marketCacheKey,
85+
) => {
86+
return new Promise((resolve, reject) => {
87+
const callback: Callback<string[]> = (
88+
err: Error | null,
89+
results: string[],
90+
) => {
91+
if (err) {
92+
return reject(err);
93+
}
94+
return resolve(results);
95+
};
96+
97+
client.evalsha(
98+
getMarketMedianScript.hash,
99+
1,
100+
marketCacheKey,
101+
callback,
102+
);
103+
});
104+
};
105+
evalAsync = evalAsync.bind(client);
106+
107+
const prices = await evalAsync(
108+
getOrderbookMidPriceCacheKey(ticker),
109+
);
110+
111+
if (!prices || prices.length === 0) {
112+
return null;
113+
}
114+
115+
if (prices.length === 1) {
116+
return Big(prices[0]).toFixed();
117+
}
118+
119+
if (prices.length === 2) {
120+
const [price1, price2] = prices.map((price) => {
121+
return Big(price);
122+
});
123+
return price1.plus(price2).div(2).toFixed();
124+
}
125+
126+
return null;
127+
}

indexer/packages/redis/src/caches/scripts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export const removeOrderScript: LuaScript = newLuaScript('removeOrder', '../scri
6363
export const addCanceledOrderIdScript: LuaScript = newLuaScript('addCanceledOrderId', '../scripts/add_canceled_order_id.lua');
6464
export const addStatefulOrderUpdateScript: LuaScript = newLuaScript('addStatefulOrderUpdate', '../scripts/add_stateful_order_update.lua');
6565
export const removeStatefulOrderUpdateScript: LuaScript = newLuaScript('removeStatefulOrderUpdate', '../scripts/remove_stateful_order_update.lua');
66+
export const addMarketPriceScript: LuaScript = newLuaScript('addMarketPrice', '../scripts/add_market_price.lua');
67+
export const getMarketMedianScript: LuaScript = newLuaScript('getMarketMedianPrice', '../scripts/get_market_median_price.lua');
6668

6769
export const allLuaScripts: LuaScript[] = [
6870
deleteZeroPriceLevelScript,
@@ -75,4 +77,6 @@ export const allLuaScripts: LuaScript[] = [
7577
addCanceledOrderIdScript,
7678
addStatefulOrderUpdateScript,
7779
removeStatefulOrderUpdateScript,
80+
addMarketPriceScript,
81+
getMarketMedianScript,
7882
];

indexer/packages/redis/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * as CanceledOrdersCache from './caches/canceled-orders-cache';
1212
export * as StatefulOrderUpdatesCache from './caches/stateful-order-updates-cache';
1313
export * as StateFilledQuantumsCache from './caches/state-filled-quantums-cache';
1414
export * as LeaderboardPnlProcessedCache from './caches/leaderboard-processed-cache';
15+
export * as OrderbookMidPricesCache from './caches/orderbook-mid-prices-cache';
1516
export { placeOrder } from './caches/place-order';
1617
export { removeOrder } from './caches/remove-order';
1718
export { updateOrder } from './caches/update-order';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Key for the ZSET storing price data
2+
local priceCacheKey = KEYS[1]
3+
-- Price to be added
4+
local price = tonumber(ARGV[1])
5+
-- Current timestamp
6+
local nowSeconds = tonumber(ARGV[2])
7+
-- Time window (5 seconds)
8+
local fiveSeconds = 5
9+
10+
-- 1. Add the price to the sorted set (score is the current timestamp)
11+
redis.call("zadd", priceCacheKey, nowSeconds, price)
12+
13+
-- 2. Remove any entries older than 5 seconds
14+
local cutoffTime = nowSeconds - fiveSeconds
15+
redis.call("zremrangebyscore", priceCacheKey, "-inf", cutoffTime)
16+
17+
return true
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Key for the sorted set storing price data
2+
local priceCacheKey = KEYS[1]
3+
4+
-- Get all the prices from the sorted set (ascending order)
5+
local prices = redis.call('zrange', priceCacheKey, 0, -1)
6+
7+
-- If no prices are found, return nil
8+
if #prices == 0 then
9+
return nil
10+
end
11+
12+
-- Calculate the middle index
13+
local middle = math.floor(#prices / 2)
14+
15+
-- Calculate median
16+
if #prices % 2 == 0 then
17+
-- If even, return both prices, division will be handled in Javascript
18+
return {prices[middle], prices[middle + 1]}
19+
else
20+
-- If odd, return the middle element
21+
return {prices[middle + 1]}
22+
end

0 commit comments

Comments
 (0)