From 7749eafd3cf9ab12540cb143acd39708a432ae8d Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 15 Aug 2026 07:58:28 +0300 Subject: [PATCH] fix(gemini-titan): lazily build symbol index in fetchRawOrderBook Closes #2037. GeminiTitanExchange.fetchOrderBook() depended on the in-memory symbolToEventTicker index, which is only populated as a side effect of a prior fetchMarkets/fetchEvents call on the same fetcher instance. The server creates a fresh GeminiTitanExchange (and a fresh, empty-indexed GeminiFetcher) on every REST dispatch for a credentialed client, so fetchRawOrderBook() always threw for any credentialed GeminiTitan SDK client regardless of call order. Populate the index lazily: when the event ticker for the requested symbol is not found, call fetchRawEvents once to build the index, then retry the lookup. Only throw if the symbol is still unknown after that. Adds regression tests: fetchRawOrderBook succeeds when called first on a fresh fetcher, and still throws for a genuinely unknown symbol. --- core/src/exchanges/gemini-titan/fetcher.ts | 13 +++- .../exchanges/gemini-titan-fetcher.test.ts | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/core/src/exchanges/gemini-titan/fetcher.ts b/core/src/exchanges/gemini-titan/fetcher.ts index c2014174..ae8b6333 100644 --- a/core/src/exchanges/gemini-titan/fetcher.ts +++ b/core/src/exchanges/gemini-titan/fetcher.ts @@ -106,11 +106,18 @@ export class GeminiFetcher implements IExchangeFetcher { - const eventTicker = this.getEventTickerForSymbol(instrumentSymbol); + let eventTicker = this.getEventTickerForSymbol(instrumentSymbol); + if (!eventTicker) { + // The symbol index is populated as a side effect of fetchRawEvents/ + // fetchRawMarkets. A freshly constructed fetcher (e.g. a new instance + // created per server request for a credentialed client) has an empty + // index, so lazily build it once before giving up. + await this.fetchRawEvents({}); + eventTicker = this.getEventTickerForSymbol(instrumentSymbol); + } if (!eventTicker) { throw new Error( - `Cannot fetch order book: no event ticker found for ${instrumentSymbol}. ` + - 'Call fetchMarkets first to build the symbol index.', + `Cannot fetch order book: no event ticker found for ${instrumentSymbol}.`, ); } diff --git a/core/test/exchanges/gemini-titan-fetcher.test.ts b/core/test/exchanges/gemini-titan-fetcher.test.ts index 931e1834..a81ccaeb 100644 --- a/core/test/exchanges/gemini-titan-fetcher.test.ts +++ b/core/test/exchanges/gemini-titan-fetcher.test.ts @@ -82,3 +82,67 @@ describe('GeminiFetcher authenticated orders', () => { await expect(fetcher.cancelRawOrder(123)).resolves.toBe(rawOrder); }); }); + +describe('GeminiFetcher order book symbol index', () => { + const eventsResponse = { + data: [ + { + ticker: 'EVT-1', + contracts: [ + { + instrumentSymbol: 'ABC-YES', + ticker: 'ABC-YES', + prices: { bestBid: '0.60', bestAsk: '0.62' }, + }, + ], + }, + ], + pagination: { total: 1 }, + }; + + const singleEventResponse = { + ticker: 'EVT-1', + contracts: [ + { + instrumentSymbol: 'ABC-YES', + prices: { bestBid: '0.60', bestAsk: '0.62' }, + }, + ], + }; + + function makeGetFetcher(getResponses: unknown[]) { + const get = jest.fn(async () => ({ data: getResponses.shift() })); + const ctx: FetcherContext = { + http: { get } as any, + callApi: jest.fn() as any, + getHeaders: jest.fn(() => ({})), + }; + + return { fetcher: new GeminiFetcher(ctx, 'https://api.gemini.test'), get }; + } + + it('lazily builds the symbol index when fetchRawOrderBook is called first', async () => { + // Regression for #2037: a freshly constructed fetcher has an empty + // symbolToEventTicker index. fetchRawOrderBook must populate it lazily + // instead of throwing when fetchMarkets/fetchEvents was not called first. + const { fetcher, get } = makeGetFetcher([eventsResponse, singleEventResponse]); + + const book = await fetcher.fetchRawOrderBook('ABC-YES'); + + expect(book).toEqual({ + bids: [{ price: '0.60', size: '0' }], + asks: [{ price: '0.62', size: '0' }], + timestamp: expect.any(Number), + }); + // One GET to list events (build the index) + one GET for the single event. + expect(get).toHaveBeenCalledTimes(2); + }); + + it('still throws when the symbol is unknown even after building the index', async () => { + const { fetcher } = makeGetFetcher([eventsResponse]); + + await expect(fetcher.fetchRawOrderBook('UNKNOWN-YES')).rejects.toThrow( + /no event ticker found for UNKNOWN-YES/, + ); + }); +});