Skip to content

Commit 518fe8b

Browse files
authored
fix(core): sync Gemini Titan order and websocket handling (#957)
1 parent 26e561a commit 518fe8b

6 files changed

Lines changed: 168 additions & 33 deletions

File tree

core/src/exchanges/gemini-titan/fetcher.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
GeminiRawEventsResponse,
88
GeminiRawOrder,
99
GeminiRawActiveOrdersResponse,
10+
GeminiRawOrderHistoryResponse,
1011
GeminiRawPosition,
1112
GeminiRawPositionsResponse,
1213
GeminiRawOrderBook,
@@ -135,30 +136,50 @@ export class GeminiFetcher implements IExchangeFetcher<GeminiRawEvent, GeminiRaw
135136
);
136137
}
137138

138-
async cancelRawOrder(orderId: number): Promise<{ result: string; message: string }> {
139-
return this.postAuthenticated<{ result: string; message: string }>(
139+
async cancelRawOrder(orderId: number): Promise<GeminiRawOrder> {
140+
return this.postAuthenticated<GeminiRawOrder>(
140141
'/v1/prediction-markets/order/cancel',
141142
{ orderId },
142143
);
143144
}
144145

145146
async fetchRawActiveOrders(symbol?: string): Promise<GeminiRawOrder[]> {
146-
const extra: Record<string, unknown> = {};
147-
if (symbol) extra.symbol = symbol;
148-
149-
const response = await this.postAuthenticated<GeminiRawActiveOrdersResponse>(
150-
'/v1/prediction-markets/orders/active',
151-
extra,
152-
);
153-
return response.orders;
147+
return this.fetchPaginatedOrders('/v1/prediction-markets/orders/active', symbol ? { symbol } : {});
154148
}
155149

156150
async fetchRawOrderHistory(): Promise<GeminiRawOrder[]> {
157-
const response = await this.postAuthenticated<GeminiRawOrder[]>(
158-
'/v1/prediction-markets/orders/history',
159-
{},
160-
);
161-
return Array.isArray(response) ? response : [];
151+
return this.fetchPaginatedOrders('/v1/prediction-markets/orders/history', {});
152+
}
153+
154+
private async fetchPaginatedOrders(
155+
path: '/v1/prediction-markets/orders/active' | '/v1/prediction-markets/orders/history',
156+
extra: Record<string, unknown>,
157+
): Promise<GeminiRawOrder[]> {
158+
const allOrders: GeminiRawOrder[] = [];
159+
const limit = 100;
160+
let offset = 0;
161+
162+
while (true) {
163+
const response = await this.postAuthenticated<GeminiRawActiveOrdersResponse | GeminiRawOrderHistoryResponse>(
164+
path,
165+
{ ...extra, limit, offset },
166+
);
167+
168+
const orders = response.orders ?? [];
169+
allOrders.push(...orders);
170+
171+
const pagination = response.pagination;
172+
const count = pagination?.count ?? orders.length;
173+
const pageOffset = pagination?.offset ?? offset;
174+
175+
if (orders.length === 0 || pageOffset + orders.length >= count) {
176+
break;
177+
}
178+
179+
offset = pageOffset + orders.length;
180+
}
181+
182+
return allOrders;
162183
}
163184

164185
async fetchRawPositions(): Promise<GeminiRawPosition[]> {

core/src/exchanges/gemini-titan/index.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -263,19 +263,8 @@ export class GeminiTitanExchange extends PredictionMarketExchange {
263263
this.requireAuth();
264264

265265
try {
266-
await this.fetcher.cancelRawOrder(parseInt(orderId, 10));
267-
return {
268-
id: orderId,
269-
marketId: '',
270-
outcomeId: '',
271-
side: 'buy',
272-
type: 'limit',
273-
amount: 0,
274-
status: 'canceled',
275-
filled: 0,
276-
remaining: 0,
277-
timestamp: Date.now(),
278-
};
266+
const rawOrder = await this.fetcher.cancelRawOrder(parseInt(orderId, 10));
267+
return this.normalizer.normalizeOrder(rawOrder);
279268
} catch (error: any) {
280269
throw geminiErrorMapper.mapError(error);
281270
}

core/src/exchanges/gemini-titan/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ export interface GeminiRawActiveOrdersResponse {
158158
};
159159
}
160160

161+
export type GeminiRawOrderHistoryResponse = GeminiRawActiveOrdersResponse;
162+
161163
export interface GeminiRawPositionsResponse {
162164
positions: GeminiRawPosition[];
163165
total?: number;

core/src/exchanges/gemini-titan/websocket.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface GeminiWebSocketConfig {
1919
* Gemini Titan WebSocket for real-time order book and trade streaming.
2020
*
2121
* Subscribes to:
22-
* - {symbol}@depth20 (L2 partial depth snapshots at 1s intervals)
22+
* - {symbol}@depth@100ms (full depth snapshots/deltas at 100ms intervals)
2323
* - {symbol}@trade (executed trades)
2424
*
2525
* Auth headers are sent during the handshake if credentials are provided
@@ -75,7 +75,7 @@ export class GeminiWebSocket {
7575
// Resubscribe on reconnect
7676
const allStreams: string[] = [];
7777
for (const sym of this.subscribedDepthSymbols) {
78-
allStreams.push(`${sym}@depth20`);
78+
allStreams.push(this.depthStream(sym));
7979
}
8080
for (const sym of this.subscribedTradeSymbols) {
8181
allStreams.push(`${sym}@trade`);
@@ -152,7 +152,7 @@ export class GeminiWebSocket {
152152

153153
private handleMessage(message: any): void {
154154
// Gemini sends flat objects, NOT wrapped in { stream, data }.
155-
// Depth snapshots: { lastUpdateId, symbol, bids, asks }
155+
// Depth snapshots: { lastUpdateId, s, bids, asks }
156156
// Depth deltas: { e, E, s, U, u, b, a }
157157
// Trades: { E, s, t, p, q, m }
158158
// Confirmations: { id, status: 200 }
@@ -171,7 +171,12 @@ export class GeminiWebSocket {
171171
private handleDepthSnapshot(data: any): void {
172172
// symbol comes back lowercase from the API, but we subscribed with
173173
// uppercase. Normalize to uppercase for resolver lookup.
174-
const symbol = (data.symbol as string).toUpperCase();
174+
const rawSymbol = data.s ?? data.symbol;
175+
if (typeof rawSymbol !== 'string' || rawSymbol.length === 0) {
176+
logger.warn('[gemini-titan] depth snapshot missing symbol field');
177+
return;
178+
}
179+
const symbol = rawSymbol.toUpperCase();
175180

176181
const bids: OrderLevel[] = (data.bids ?? []).map((level: [string, string]) => ({
177182
price: parseFloat(level[0]),
@@ -277,7 +282,7 @@ export class GeminiWebSocket {
277282
}
278283
});
279284
} else {
280-
this.sendSubscribe([`${symbol}@depth20`]);
285+
this.sendSubscribe([this.depthStream(symbol)]);
281286
}
282287

283288
const dataPromise = new Promise<OrderBook>((resolve, reject) => {
@@ -338,6 +343,10 @@ export class GeminiWebSocket {
338343
);
339344
}
340345

346+
private depthStream(symbol: string): string {
347+
return `${symbol}@depth@100ms`;
348+
}
349+
341350
async close(): Promise<void> {
342351
this.isTerminated = true;
343352

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { GeminiFetcher } from '../../src/exchanges/gemini-titan/fetcher';
2+
import { FetcherContext } from '../../src/exchanges/interfaces';
3+
4+
function makeFetcher(responses: unknown[]) {
5+
const post = jest.fn(async () => ({ data: responses.shift() }));
6+
const buildHeaders = jest.fn(() => ({ 'X-GEMINI-APIKEY': 'test-key' }));
7+
const auth = {
8+
nonce: jest.fn(() => 12345),
9+
buildHeaders,
10+
} as any;
11+
const ctx: FetcherContext = {
12+
http: { post } as any,
13+
callApi: jest.fn() as any,
14+
getHeaders: jest.fn(() => ({})),
15+
};
16+
17+
return {
18+
fetcher: new GeminiFetcher(ctx, 'https://api.gemini.test', auth),
19+
post,
20+
buildHeaders,
21+
};
22+
}
23+
24+
describe('GeminiFetcher authenticated orders', () => {
25+
it('reads paginated order history envelopes', async () => {
26+
const { fetcher, buildHeaders } = makeFetcher([
27+
{
28+
orders: [{ orderId: 1, status: 'filled' }],
29+
pagination: { limit: 100, offset: 0, count: 2 },
30+
},
31+
{
32+
orders: [{ orderId: 2, status: 'cancelled' }],
33+
pagination: { limit: 100, offset: 1, count: 2 },
34+
},
35+
]);
36+
37+
await expect(fetcher.fetchRawOrderHistory()).resolves.toEqual([
38+
{ orderId: 1, status: 'filled' },
39+
{ orderId: 2, status: 'cancelled' },
40+
]);
41+
42+
expect(buildHeaders).toHaveBeenNthCalledWith(1, expect.objectContaining({
43+
request: '/v1/prediction-markets/orders/history',
44+
limit: 100,
45+
offset: 0,
46+
}));
47+
expect(buildHeaders).toHaveBeenNthCalledWith(2, expect.objectContaining({
48+
request: '/v1/prediction-markets/orders/history',
49+
limit: 100,
50+
offset: 1,
51+
}));
52+
});
53+
54+
it('passes limit and offset when fetching active orders', async () => {
55+
const { fetcher, buildHeaders } = makeFetcher([
56+
{
57+
orders: [],
58+
pagination: { limit: 100, offset: 0, count: 0 },
59+
},
60+
]);
61+
62+
await expect(fetcher.fetchRawActiveOrders('BTCUSD-PERP')).resolves.toEqual([]);
63+
64+
expect(buildHeaders).toHaveBeenCalledWith(expect.objectContaining({
65+
request: '/v1/prediction-markets/orders/active',
66+
symbol: 'BTCUSD-PERP',
67+
limit: 100,
68+
offset: 0,
69+
}));
70+
});
71+
72+
it('returns the full raw cancel order response', async () => {
73+
const rawOrder = {
74+
orderId: 123,
75+
symbol: 'BTCUSD-PERP',
76+
side: 'buy',
77+
outcome: 'yes',
78+
status: 'cancelled',
79+
};
80+
const { fetcher } = makeFetcher([rawOrder]);
81+
82+
await expect(fetcher.cancelRawOrder(123)).resolves.toBe(rawOrder);
83+
});
84+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { GeminiWebSocket } from '../../src/exchanges/gemini-titan/websocket';
2+
3+
describe('GeminiWebSocket depth snapshots', () => {
4+
it('routes snapshots using s when symbol is absent', () => {
5+
const ws = new GeminiWebSocket(undefined, { wsUrl: 'wss://example.test' }) as any;
6+
const resolved: unknown[] = [];
7+
ws.orderBookResolvers.set('BTCUSD-PERP', [{
8+
resolve: (value: unknown) => resolved.push(value),
9+
reject: jest.fn(),
10+
}]);
11+
12+
ws.handleDepthSnapshot({
13+
lastUpdateId: 1,
14+
s: 'btcusd-perp',
15+
bids: [['0.48', '10']],
16+
asks: [['0.52', '12']],
17+
});
18+
19+
expect(resolved).toEqual([{
20+
bids: [{ price: 0.48, size: 10 }],
21+
asks: [{ price: 0.52, size: 12 }],
22+
timestamp: expect.any(Number),
23+
}]);
24+
});
25+
26+
it('uses the documented full-depth stream name', () => {
27+
const ws = new GeminiWebSocket(undefined, { wsUrl: 'wss://example.test' }) as any;
28+
expect(ws.depthStream('BTCUSD-PERP')).toBe('BTCUSD-PERP@depth@100ms');
29+
});
30+
});

0 commit comments

Comments
 (0)