Skip to content

Commit 0a229a8

Browse files
authored
feat(predict): add series data service for Crypto Up/Down markets (#28489)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> The Crypto Up/Down feature (Epic PRED-777) needs a data foundation to fetch and cache series of time-windowed markets (e.g. "BTC Up or Down 5m" repeating every 5 minutes). This PR implements the Series Data Service (PRED-782), which is Module 1 of 9 that downstream tickets depend on (Time Slot Picker, Detail Screen, Feed Grouping). **What this adds:** - **Types**: Enriched `PredictSeries` with `id`/`slug`/`title`, added `series?` to `PredictMarket`, new `GetSeriesParams` interface - **Provider layer**: `PolymarketProvider.getMarketSeries()` fetching via `GET /events?series_id=...` with `end_date_min/max` filtering, pagination, and ascending endDate ordering - **Controller layer**: `PredictController.getMarketSeries()` with messenger action type registration - **React Query layer**: `predictSeriesKeys`/`predictSeriesOptions` query factory, `usePredictSeries` hook with `setQueryData` cache seeding (each series event is individually cached under `['predict', 'market', eventId]`) - **Utility**: `parseRecurrenceToSeconds()` for converting recurrence strings (5m, 1h, daily, etc.) to seconds, plus window constants (`SERIES_PAST_WINDOW_MS`, `SERIES_FUTURE_WINDOW_MS`, `SERIES_MAX_EVENTS`) - **Parsing**: `parsePolymarketEvents()` now populates `market.series` metadata from the event's series array Provider-specific logic (API calls, slug parsing) stays in the Polymarket provider layer; provider-agnostic code lives in the Predict layer, maintaining the existing decoupling pattern. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-782 ## **Manual testing steps** ```gherkin Feature: Series Data Service Background: Given I am logged into MetaMask Mobile And Predict feature is enabled Scenario: series metadata populates on Crypto Up/Down markets Given I am on the Predict feed When I tap on a Crypto Up/Down market (e.g. "BTC Up or Down") Then the market detail screen loads And the market object contains series metadata (id, slug, title, recurrence) Scenario: series events load for a market with series data Given I am viewing a Crypto Up/Down market detail screen And the market has series metadata When the usePredictSeries hook fires with the series ID Then sibling time-slot events are fetched from the API And each event is individually cached under its market ID And the events are sorted by endDate ascending Scenario: non-series markets are unaffected Given I am on the Predict feed When I tap on a regular market (no series data) Then the market detail screen loads normally And the market object does not contain series metadata ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A ### **After** <img width="1039" height="873" alt="Screenshot 2026-04-07 at 11 22 08 AM" src="https://github.com/user-attachments/assets/68e13c10-6955-4bb3-bdd3-3245a04a3d99" /> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new network fetch path (`/events?series_id=...`) and cache-seeding behavior that could impact data correctness and query caching, but it’s largely additive and covered by unit tests. > > **Overview** > Adds a new **market series data flow** to Predict, including `GetSeriesParams`/enriched `PredictSeries` metadata on `PredictMarket` and updated Polymarket API types. > > Implements `getMarketSeries` from provider→controller→messenger action, backed by a new Polymarket `GET /events` call filtered by `series_id` and `end_date_min/max` (with default limit and ascending `endDate` ordering) and updates parsing to attach `market.series` metadata. > > Introduces React Query support (`predictSeriesKeys`/`predictSeriesOptions`) and a `usePredictSeries` hook that fetches series events and **seeds per-market cache entries**; adds utilities/constants for recurrence parsing and windows, with accompanying tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7b0b8d2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 182d77e commit 0a229a8

17 files changed

Lines changed: 665 additions & 13 deletions

app/components/UI/Predict/controllers/PredictController-method-action-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export type PredictControllerGetMarketAction = {
1818
handler: PredictController['getMarket'];
1919
};
2020

21+
export type PredictControllerGetMarketSeriesAction = {
22+
type: `PredictController:getMarketSeries`;
23+
handler: PredictController['getMarketSeries'];
24+
};
25+
2126
export type PredictControllerGetPriceHistoryAction = {
2227
type: `PredictController:getPriceHistory`;
2328
handler: PredictController['getPriceHistory'];
@@ -232,6 +237,7 @@ export type PredictControllerClearWithdrawTransactionAction = {
232237
export type PredictControllerMethodActions =
233238
| PredictControllerGetMarketsAction
234239
| PredictControllerGetMarketAction
240+
| PredictControllerGetMarketSeriesAction
235241
| PredictControllerGetPriceHistoryAction
236242
| PredictControllerGetPricesAction
237243
| PredictControllerGetPositionsAction

app/components/UI/Predict/controllers/PredictController.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9317,4 +9317,57 @@ describe('PredictController', () => {
93179317
});
93189318
});
93199319
});
9320+
9321+
describe('getMarketSeries', () => {
9322+
it('delegates the params to the provider', async () => {
9323+
const params = {
9324+
seriesId: '10684',
9325+
endDateMin: '2026-04-06T00:00:00.000Z',
9326+
endDateMax: '2026-04-07T00:00:00.000Z',
9327+
limit: 10,
9328+
};
9329+
9330+
await withController(async ({ controller }) => {
9331+
mockPolymarketProvider.getMarketSeries = jest
9332+
.fn()
9333+
.mockResolvedValue([]);
9334+
9335+
await controller.getMarketSeries(params);
9336+
9337+
expect(mockPolymarketProvider.getMarketSeries).toHaveBeenCalledWith(
9338+
params,
9339+
);
9340+
});
9341+
});
9342+
9343+
it('returns the provider result', async () => {
9344+
const params = {
9345+
seriesId: '10684',
9346+
endDateMin: '2026-04-06T00:00:00.000Z',
9347+
endDateMax: '2026-04-07T00:00:00.000Z',
9348+
};
9349+
const mockMarkets = [
9350+
{
9351+
id: 'series-market-1',
9352+
title: 'BTC Up or Down 5m',
9353+
series: {
9354+
id: '10684',
9355+
slug: 'btc-up-or-down-5m',
9356+
title: 'BTC Up or Down 5m',
9357+
recurrence: '5m',
9358+
},
9359+
},
9360+
];
9361+
9362+
await withController(async ({ controller }) => {
9363+
mockPolymarketProvider.getMarketSeries = jest
9364+
.fn()
9365+
.mockResolvedValue(mockMarkets);
9366+
9367+
const result = await controller.getMarketSeries(params);
9368+
9369+
expect(result).toEqual(mockMarkets);
9370+
});
9371+
});
9372+
});
93209373
});

app/components/UI/Predict/controllers/PredictController.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
GetPriceHistoryParams,
7676
GetPriceParams,
7777
GetPriceResponse,
78+
GetSeriesParams,
7879
OrderPreview,
7980
PlaceOrderParams,
8081
PredictAccountMeta,
@@ -354,6 +355,7 @@ const MESSENGER_EXPOSED_METHODS = [
354355
'getBalance',
355356
'getConnectionStatus',
356357
'getMarket',
358+
'getMarketSeries',
357359
'getMarkets',
358360
'getPositions',
359361
'getPriceHistory',
@@ -686,6 +688,10 @@ export class PredictController extends BaseController<
686688
);
687689
}
688690

691+
async getMarketSeries(params: GetSeriesParams): Promise<PredictMarket[]> {
692+
return this.provider.getMarketSeries(params);
693+
}
694+
689695
async getPriceHistory(
690696
params: GetPriceHistoryParams,
691697
): Promise<PredictPriceHistoryPoint[]> {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import React from 'react';
2+
import { renderHook, waitFor } from '@testing-library/react-native';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { usePredictSeries } from './usePredictSeries';
5+
import { predictMarketKeys } from '../queries/market';
6+
import { predictSeriesKeys } from '../queries/series';
7+
import { Recurrence, type GetSeriesParams, type PredictMarket } from '../types';
8+
9+
jest.mock('../../../../util/Logger', () => ({
10+
__esModule: true,
11+
default: {
12+
error: jest.fn(),
13+
},
14+
}));
15+
16+
const mockGetMarketSeries = jest.fn();
17+
18+
jest.mock('../../../../core/Engine', () => ({
19+
context: {
20+
PredictController: {
21+
getMarketSeries: (...args: unknown[]) => mockGetMarketSeries(...args),
22+
},
23+
},
24+
}));
25+
26+
const createWrapper = () => {
27+
const queryClient = new QueryClient({
28+
defaultOptions: {
29+
queries: {
30+
retry: false,
31+
cacheTime: Infinity,
32+
},
33+
},
34+
});
35+
36+
const Wrapper = ({ children }: { children: React.ReactNode }) =>
37+
React.createElement(QueryClientProvider, { client: queryClient }, children);
38+
39+
return { Wrapper, queryClient };
40+
};
41+
42+
const createMockMarket = (id: string): PredictMarket =>
43+
({
44+
id,
45+
title: `Market ${id}`,
46+
slug: `market-${id}`,
47+
providerId: 'polymarket',
48+
description: `Description for ${id}`,
49+
image: `https://example.com/${id}.png`,
50+
status: 'open',
51+
recurrence: Recurrence.NONE,
52+
category: 'crypto',
53+
tags: [],
54+
outcomes: [],
55+
liquidity: 100,
56+
volume: 200,
57+
}) as PredictMarket;
58+
59+
describe('usePredictSeries', () => {
60+
const params: GetSeriesParams = {
61+
seriesId: 'series-1',
62+
endDateMin: '2026-01-01T00:00:00.000Z',
63+
endDateMax: '2026-12-31T23:59:59.999Z',
64+
limit: 3,
65+
};
66+
67+
beforeEach(() => {
68+
jest.clearAllMocks();
69+
});
70+
71+
it('returns series events', async () => {
72+
const { Wrapper } = createWrapper();
73+
const marketA = createMockMarket('market-a');
74+
const marketB = createMockMarket('market-b');
75+
const marketC = createMockMarket('market-c');
76+
mockGetMarketSeries.mockResolvedValue([marketA, marketB, marketC]);
77+
78+
const { result } = renderHook(() => usePredictSeries(params), {
79+
wrapper: Wrapper,
80+
});
81+
82+
await waitFor(() => {
83+
expect(result.current.isFetching).toBe(false);
84+
});
85+
86+
expect(result.current.data).toEqual([marketA, marketB, marketC]);
87+
expect(mockGetMarketSeries).toHaveBeenCalledWith(params);
88+
});
89+
90+
it('seeds individual market cache entries', async () => {
91+
const { Wrapper, queryClient } = createWrapper();
92+
const marketA = createMockMarket('market-a');
93+
const marketB = createMockMarket('market-b');
94+
const marketC = createMockMarket('market-c');
95+
mockGetMarketSeries.mockResolvedValue([marketA, marketB, marketC]);
96+
97+
renderHook(() => usePredictSeries(params), {
98+
wrapper: Wrapper,
99+
});
100+
101+
await waitFor(() => {
102+
expect(
103+
queryClient.getQueryData(predictMarketKeys.detail(marketA.id)),
104+
).toEqual(marketA);
105+
});
106+
107+
expect(queryClient.getQueryData(['predict', 'market', marketA.id])).toEqual(
108+
marketA,
109+
);
110+
});
111+
112+
it('registers query under correct cache key', async () => {
113+
const { Wrapper, queryClient } = createWrapper();
114+
const marketA = createMockMarket('market-a');
115+
const marketB = createMockMarket('market-b');
116+
const marketC = createMockMarket('market-c');
117+
const seriesEvents = [marketA, marketB, marketC];
118+
mockGetMarketSeries.mockResolvedValue(seriesEvents);
119+
120+
renderHook(() => usePredictSeries(params), {
121+
wrapper: Wrapper,
122+
});
123+
124+
await waitFor(() => {
125+
expect(
126+
queryClient.getQueryData(predictSeriesKeys.detail(params)),
127+
).toEqual(seriesEvents);
128+
});
129+
130+
expect(
131+
queryClient.getQueryData([
132+
'predict',
133+
'series',
134+
params.seriesId,
135+
params.endDateMin,
136+
params.endDateMax,
137+
params.limit,
138+
]),
139+
).toEqual(seriesEvents);
140+
});
141+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useEffect } from 'react';
2+
import { useQuery, useQueryClient } from '@tanstack/react-query';
3+
import Logger from '../../../../util/Logger';
4+
import { PREDICT_CONSTANTS } from '../constants/errors';
5+
import { ensureError } from '../utils/predictErrorHandler';
6+
import { predictQueries } from '../queries';
7+
import { predictMarketKeys } from '../queries/market';
8+
import type { GetSeriesParams } from '../types';
9+
10+
export const usePredictSeries = (params: GetSeriesParams) => {
11+
const queryClient = useQueryClient();
12+
13+
const query = useQuery(predictQueries.series.options(params));
14+
15+
useEffect(() => {
16+
if (!query.data) return;
17+
18+
query.data.forEach((event) => {
19+
queryClient.setQueryData(predictMarketKeys.detail(event.id), event);
20+
});
21+
}, [query.data, queryClient]);
22+
23+
useEffect(() => {
24+
if (!query.error) return;
25+
26+
Logger.error(ensureError(query.error), {
27+
tags: {
28+
feature: PREDICT_CONSTANTS.FEATURE_NAME,
29+
component: 'usePredictSeries',
30+
},
31+
context: {
32+
name: 'usePredictSeries',
33+
data: {
34+
method: 'queryFn',
35+
action: 'series_load',
36+
operation: 'data_fetching',
37+
seriesId: params.seriesId,
38+
},
39+
},
40+
});
41+
}, [query.error, params.seriesId]);
42+
43+
return query;
44+
};

app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7940,4 +7940,91 @@ describe('PolymarketProvider', () => {
79407940
});
79417941
});
79427942
});
7943+
7944+
describe('getMarketSeries', () => {
7945+
beforeEach(() => {
7946+
jest.clearAllMocks();
7947+
global.fetch = jest.fn();
7948+
});
7949+
7950+
afterEach(() => {
7951+
jest.restoreAllMocks();
7952+
});
7953+
7954+
it('calls the series events endpoint with the requested params', async () => {
7955+
const provider = createProvider();
7956+
const mockEvents = [{ id: 'event-1' }];
7957+
const parsedMarkets = [{ id: 'market-1' }];
7958+
const mockResponse = {
7959+
ok: true,
7960+
json: jest.fn().mockResolvedValue(mockEvents),
7961+
};
7962+
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
7963+
mockParsePolymarketEvents.mockReturnValue(parsedMarkets);
7964+
7965+
await provider.getMarketSeries({
7966+
seriesId: '10684',
7967+
endDateMin: '2026-04-06T00:00:00.000Z',
7968+
endDateMax: '2026-04-07T00:00:00.000Z',
7969+
limit: 10,
7970+
});
7971+
7972+
const requestUrl = new URL((global.fetch as jest.Mock).mock.calls[0][0]);
7973+
7974+
expect(global.fetch).toHaveBeenCalledWith(
7975+
expect.stringContaining('series_id=10684'),
7976+
);
7977+
expect(requestUrl.origin + requestUrl.pathname).toBe(
7978+
'https://gamma-api.polymarket.com/events',
7979+
);
7980+
expect(requestUrl.searchParams.get('series_id')).toBe('10684');
7981+
expect(requestUrl.searchParams.get('end_date_min')).toBe(
7982+
'2026-04-06T00:00:00.000Z',
7983+
);
7984+
expect(requestUrl.searchParams.get('end_date_max')).toBe(
7985+
'2026-04-07T00:00:00.000Z',
7986+
);
7987+
expect(requestUrl.searchParams.get('limit')).toBe('10');
7988+
expect(requestUrl.searchParams.get('order')).toBe('endDate');
7989+
expect(requestUrl.searchParams.get('ascending')).toBe('true');
7990+
});
7991+
7992+
it('returns an empty array when the API returns no events', async () => {
7993+
const provider = createProvider();
7994+
const mockResponse = {
7995+
ok: true,
7996+
json: jest.fn().mockResolvedValue([]),
7997+
};
7998+
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
7999+
8000+
const result = await provider.getMarketSeries({
8001+
seriesId: '10684',
8002+
endDateMin: '2026-04-06T00:00:00.000Z',
8003+
endDateMax: '2026-04-07T00:00:00.000Z',
8004+
});
8005+
8006+
expect(result).toEqual([]);
8007+
expect(mockParsePolymarketEvents).not.toHaveBeenCalled();
8008+
});
8009+
8010+
it('uses the default limit when one is not provided', async () => {
8011+
const provider = createProvider();
8012+
const mockResponse = {
8013+
ok: true,
8014+
json: jest.fn().mockResolvedValue([{ id: 'event-1' }]),
8015+
};
8016+
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
8017+
mockParsePolymarketEvents.mockReturnValue([]);
8018+
8019+
await provider.getMarketSeries({
8020+
seriesId: '10684',
8021+
endDateMin: '2026-04-06T00:00:00.000Z',
8022+
endDateMax: '2026-04-07T00:00:00.000Z',
8023+
});
8024+
8025+
const requestUrl = new URL((global.fetch as jest.Mock).mock.calls[0][0]);
8026+
8027+
expect(requestUrl.searchParams.get('limit')).toBe('50');
8028+
});
8029+
});
79438030
});

0 commit comments

Comments
 (0)