Skip to content

Commit 3f1838c

Browse files
committed
feat: unified event discovery across all exchanges
- Removed mandatory query/eventId/slug requirement for fetchEvents() in BaseExchange - Implemented discovery path for Polymarket via Gamma /events endpoint - Implemented discovery path for Kalshi with client-side sorting (volume, liquidity, newest) - Implemented discovery path for Limitless via group market filtering - Updated unit tests to align with unrestricted parameter requirements - Bumped version to 2.17.0
1 parent 665032f commit 3f1838c

File tree

7 files changed

+336
-218
lines changed

7 files changed

+336
-218
lines changed

changelog.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.17.0] - 2026-02-24
6+
7+
### Improved
8+
9+
- **Unified Discovery: Unrestricted Event Fetching**: Removed the mandatory requirement for a query, event identification, or slug in `fetchEvents`. Users can now call `fetchEvents()` without parameters to retrieve the "front page" of an exchange (typically top events by volume).
10+
- **Polymarket: High-Performance Discovery**: Redirects no-query `fetchEvents` calls to the specific Gamma `/events` list endpoint, providing a cleaner and faster experience than the fuzzy search path.
11+
- **Kalshi: Client-Side Event Ranking**: Implemented robust client-side sorting for Kalshi events. Since the Kalshi API lacks server-side sorting for the event list, `fetchEvents` now aggregates volume, liquidity, and recency from nested markets to provide consistent `sort` support (`volume`, `liquidity`, `newest`).
12+
- **Limitless: Semantic Event Mapping**: Mapped Limitless "Group Markets" to the unified `fetchEvents` interface. Discovery calls now automatically filter for group markets, aligning Limitless with event-centric discovery patterns.
13+
- **Developer Experience**: Synchronized `fetchEvents` behavior with `fetchMarkets` across all exchanges (Baozi, Myriad, Probable, Kalshi, Polymarket, Limitless).
14+
15+
### Fixed
16+
17+
- **Unit Tests**: Updated core validation suite to reflect the relaxed requirements for event fetching.
18+
519
## [2.16.1] - 2026-02-24
620

721
### Fixed

core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pmxt-core",
3-
"version": "2.0.2",
3+
"version": "2.17.0",
44
"description": "pmxt is a unified prediction market data API. The ccxt for prediction markets.",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -80,4 +80,4 @@
8080
"optional": true
8181
}
8282
}
83-
}
83+
}

core/src/BaseExchange.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ export abstract class PredictionMarketExchange {
475475
* Events group related markets together (e.g., "Who will be Fed Chair?" contains multiple candidate markets).
476476
*
477477
* @param params - Optional parameters for search and filtering
478-
* @param params.query - Search keyword to filter events (required)
478+
* @param params.query - Search keyword to filter events. If omitted, returns top events by volume.
479479
* @param params.limit - Maximum number of results
480480
* @param params.offset - Pagination offset
481481
* @param params.searchIn - Where to search ('title' | 'description' | 'both')
@@ -494,10 +494,7 @@ export abstract class PredictionMarketExchange {
494494
* print(fed_event.title, len(fed_event.markets), 'markets')
495495
*/
496496
async fetchEvents(params?: EventFetchParams): Promise<UnifiedEvent[]> {
497-
if (!params?.query && !params?.eventId && !params?.slug) {
498-
throw new Error("fetchEvents() requires a query, eventId, or slug parameter");
499-
}
500-
return this.fetchEventsImpl(params!);
497+
return this.fetchEventsImpl(params ?? {});
501498
}
502499

503500
/**

core/src/exchanges/kalshi/fetchEvents.ts

Lines changed: 108 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,61 @@ async function fetchEventByTicker(
4545
return [unifiedEvent];
4646
}
4747

48+
function rawEventToUnified(event: any): UnifiedEvent {
49+
const markets: UnifiedMarket[] = [];
50+
if (event.markets) {
51+
for (const market of event.markets) {
52+
const unifiedMarket = mapMarketToUnified(event, market);
53+
if (unifiedMarket) {
54+
markets.push(unifiedMarket);
55+
}
56+
}
57+
}
58+
return {
59+
id: event.event_ticker,
60+
title: event.title,
61+
description: event.mututals_description || "",
62+
slug: event.event_ticker,
63+
markets: markets,
64+
url: `https://kalshi.com/events/${event.event_ticker}`,
65+
image: event.image_url,
66+
category: event.category,
67+
tags: event.tags || [],
68+
};
69+
}
70+
71+
async function fetchAllWithStatus(
72+
callApi: CallApi,
73+
apiStatus: string,
74+
): Promise<any[]> {
75+
let allEvents: any[] = [];
76+
let cursor = null;
77+
let page = 0;
78+
79+
const MAX_PAGES = 1000;
80+
const BATCH_SIZE = 200;
81+
82+
do {
83+
const queryParams: any = {
84+
limit: BATCH_SIZE,
85+
with_nested_markets: true,
86+
status: apiStatus,
87+
};
88+
if (cursor) queryParams.cursor = cursor;
89+
90+
const data = await callApi("GetEvents", queryParams);
91+
const events = data.events || [];
92+
93+
if (events.length === 0) break;
94+
95+
allEvents = allEvents.concat(events);
96+
cursor = data.cursor;
97+
page++;
98+
} while (cursor && page < MAX_PAGES);
99+
100+
return allEvents;
101+
}
102+
48103
export async function fetchEvents(
49104
params: EventFetchParams,
50105
callApi: CallApi,
@@ -64,89 +119,74 @@ export async function fetchEvents(
64119
const limit = params?.limit || 10000;
65120
const query = (params?.query || "").toLowerCase();
66121

67-
const fetchAllWithStatus = async (apiStatus: string) => {
68-
let allEvents: any[] = [];
69-
let cursor = null;
70-
let page = 0;
71-
72-
const MAX_PAGES = 1000; // Safety cap against infinite loops
73-
const BATCH_SIZE = 200; // Max limit per Kalshi API docs
74-
75-
do {
76-
const queryParams: any = {
77-
limit: BATCH_SIZE,
78-
with_nested_markets: true,
79-
status: apiStatus,
80-
};
81-
if (cursor) queryParams.cursor = cursor;
82-
83-
const data = await callApi("GetEvents", queryParams);
84-
const events = data.events || [];
85-
86-
if (events.length === 0) break;
87-
88-
allEvents = allEvents.concat(events);
89-
cursor = data.cursor;
90-
page++;
91-
92-
// If we have no search query and have fetched enough events, we can stop early
93-
if (!query && allEvents.length >= limit * 1.5) {
94-
break;
95-
}
96-
} while (cursor && page < MAX_PAGES);
97-
98-
return allEvents;
99-
};
100-
101-
let events = [];
122+
let events: any[] = [];
102123
if (status === "all") {
103124
const [openEvents, closedEvents, settledEvents] = await Promise.all([
104-
fetchAllWithStatus("open"),
105-
fetchAllWithStatus("closed"),
106-
fetchAllWithStatus("settled"),
125+
fetchAllWithStatus(callApi, "open"),
126+
fetchAllWithStatus(callApi, "closed"),
127+
fetchAllWithStatus(callApi, "settled"),
107128
]);
108129
events = [...openEvents, ...closedEvents, ...settledEvents];
109130
} else if (status === "closed" || status === "inactive") {
110131
const [closedEvents, settledEvents] = await Promise.all([
111-
fetchAllWithStatus("closed"),
112-
fetchAllWithStatus("settled"),
132+
fetchAllWithStatus(callApi, "closed"),
133+
fetchAllWithStatus(callApi, "settled"),
113134
]);
114135
events = [...closedEvents, ...settledEvents];
115136
} else {
116-
events = await fetchAllWithStatus("open");
137+
events = await fetchAllWithStatus(callApi, "open");
117138
}
118139

119-
const filtered = events.filter((event: any) => {
120-
return (event.title || "").toLowerCase().includes(query);
121-
});
122-
123-
const unifiedEvents: UnifiedEvent[] = filtered.map((event: any) => {
124-
const markets: UnifiedMarket[] = [];
125-
if (event.markets) {
126-
for (const market of event.markets) {
127-
const unifiedMarket = mapMarketToUnified(event, market);
128-
if (unifiedMarket) {
129-
markets.push(unifiedMarket);
130-
}
131-
}
132-
}
140+
// Apply keyword filter if a query was provided
141+
const filtered = query
142+
? events.filter((event: any) =>
143+
(event.title || "").toLowerCase().includes(query),
144+
)
145+
: events;
133146

134-
const unifiedEvent: UnifiedEvent = {
135-
id: event.event_ticker,
136-
title: event.title,
137-
description: event.mututals_description || "",
138-
slug: event.event_ticker,
139-
markets: markets,
140-
url: `https://kalshi.com/events/${event.event_ticker}`,
141-
image: event.image_url,
142-
category: event.category,
143-
tags: event.tags || [],
144-
};
145-
return unifiedEvent;
146-
});
147+
// Client-side sort — Kalshi's /events endpoint has no sort param.
148+
// We aggregate stats from nested markets and sort the full set before slicing.
149+
const sort = params?.sort || "volume";
150+
const sorted = sortRawEvents(filtered, sort);
147151

152+
const unifiedEvents: UnifiedEvent[] = sorted.map(rawEventToUnified);
148153
return unifiedEvents.slice(0, limit);
149154
} catch (error: any) {
150155
throw kalshiErrorMapper.mapError(error);
151156
}
152157
}
158+
159+
function eventVolume(event: any): number {
160+
return (event.markets || []).reduce(
161+
(sum: number, m: any) => sum + Number(m.volume || 0),
162+
0,
163+
);
164+
}
165+
166+
function eventLiquidity(event: any): number {
167+
return (event.markets || []).reduce(
168+
(sum: number, m: any) => sum + Number(m.open_interest || m.liquidity || 0),
169+
0,
170+
);
171+
}
172+
173+
function eventNewest(event: any): number {
174+
// Use the earliest close_time across markets as a proxy for "newness"
175+
const times = (event.markets || [])
176+
.map((m: any) => (m.close_time ? new Date(m.close_time).getTime() : 0))
177+
.filter((t: number) => t > 0);
178+
return times.length > 0 ? Math.min(...times) : 0;
179+
}
180+
181+
function sortRawEvents(events: any[], sort: string): any[] {
182+
const copy = [...events];
183+
if (sort === "newest") {
184+
copy.sort((a, b) => eventNewest(b) - eventNewest(a));
185+
} else if (sort === "liquidity") {
186+
copy.sort((a, b) => eventLiquidity(b) - eventLiquidity(a));
187+
} else {
188+
// Default: volume
189+
copy.sort((a, b) => eventVolume(b) - eventVolume(a));
190+
}
191+
return copy;
192+
}

0 commit comments

Comments
 (0)