pmxtjs 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/API_REFERENCE.md +88 -0
  2. package/coverage/clover.xml +334 -0
  3. package/coverage/coverage-final.json +4 -0
  4. package/coverage/lcov-report/base.css +224 -0
  5. package/coverage/lcov-report/block-navigation.js +87 -0
  6. package/coverage/lcov-report/favicon.png +0 -0
  7. package/coverage/lcov-report/index.html +131 -0
  8. package/coverage/lcov-report/pmxt/BaseExchange.ts.html +256 -0
  9. package/coverage/lcov-report/pmxt/exchanges/Kalshi.ts.html +1132 -0
  10. package/coverage/lcov-report/pmxt/exchanges/Polymarket.ts.html +1456 -0
  11. package/coverage/lcov-report/pmxt/exchanges/index.html +131 -0
  12. package/coverage/lcov-report/pmxt/index.html +116 -0
  13. package/coverage/lcov-report/prettify.css +1 -0
  14. package/coverage/lcov-report/prettify.js +2 -0
  15. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  16. package/coverage/lcov-report/sorter.js +210 -0
  17. package/coverage/lcov-report/src/BaseExchange.ts.html +256 -0
  18. package/coverage/lcov-report/src/exchanges/Kalshi.ts.html +1132 -0
  19. package/coverage/lcov-report/src/exchanges/Polymarket.ts.html +1456 -0
  20. package/coverage/lcov-report/src/exchanges/index.html +131 -0
  21. package/coverage/lcov-report/src/index.html +116 -0
  22. package/coverage/lcov.info +766 -0
  23. package/examples/get_event_prices.ts +37 -0
  24. package/examples/historical_prices.ts +117 -0
  25. package/examples/orderbook.ts +102 -0
  26. package/examples/recent_trades.ts +29 -0
  27. package/examples/search_events.ts +68 -0
  28. package/examples/search_market.ts +29 -0
  29. package/jest.config.js +11 -0
  30. package/package.json +21 -21
  31. package/pmxt-0.1.0.tgz +0 -0
  32. package/src/BaseExchange.ts +57 -0
  33. package/src/exchanges/Kalshi.ts +349 -0
  34. package/src/exchanges/Polymarket.ts +457 -0
  35. package/src/index.ts +5 -0
  36. package/src/types.ts +61 -0
  37. package/test/exchanges/kalshi/ApiErrors.test.ts +132 -0
  38. package/test/exchanges/kalshi/EmptyResponse.test.ts +44 -0
  39. package/test/exchanges/kalshi/FetchAndNormalizeMarkets.test.ts +56 -0
  40. package/test/exchanges/kalshi/LiveApi.integration.test.ts +40 -0
  41. package/test/exchanges/kalshi/MarketHistory.test.ts +185 -0
  42. package/test/exchanges/kalshi/OrderBook.test.ts +149 -0
  43. package/test/exchanges/kalshi/SearchMarkets.test.ts +174 -0
  44. package/test/exchanges/kalshi/VolumeFallback.test.ts +44 -0
  45. package/test/exchanges/polymarket/DataValidation.test.ts +271 -0
  46. package/test/exchanges/polymarket/ErrorHandling.test.ts +34 -0
  47. package/test/exchanges/polymarket/FetchAndNormalizeMarkets.test.ts +68 -0
  48. package/test/exchanges/polymarket/GetMarketsBySlug.test.ts +268 -0
  49. package/test/exchanges/polymarket/LiveApi.integration.test.ts +44 -0
  50. package/test/exchanges/polymarket/MarketHistory.test.ts +207 -0
  51. package/test/exchanges/polymarket/OrderBook.test.ts +167 -0
  52. package/test/exchanges/polymarket/RequestParameters.test.ts +39 -0
  53. package/test/exchanges/polymarket/SearchMarkets.test.ts +176 -0
  54. package/test/exchanges/polymarket/TradeHistory.test.ts +248 -0
  55. package/tsconfig.json +12 -0
  56. package/Cargo.toml +0 -15
  57. package/README.md +0 -47
  58. package/src/lib.rs +0 -6
@@ -0,0 +1,457 @@
1
+ import axios from 'axios';
2
+ import { PredictionMarketExchange, MarketFilterParams, HistoryFilterParams } from '../BaseExchange';
3
+ import { UnifiedMarket, MarketOutcome, PriceCandle, CandleInterval, OrderBook, Trade } from '../types';
4
+
5
+ export class PolymarketExchange extends PredictionMarketExchange {
6
+ get name(): string {
7
+ return 'Polymarket';
8
+ }
9
+
10
+ // Utilizing the Gamma API for rich metadata and list view data
11
+ private readonly baseUrl = 'https://gamma-api.polymarket.com/events';
12
+ // CLOB API for orderbook, trades, and timeseries
13
+ private readonly clobUrl = 'https://clob.polymarket.com';
14
+
15
+ async fetchMarkets(params?: MarketFilterParams): Promise<UnifiedMarket[]> {
16
+ const limit = params?.limit || 200; // Higher default for better coverage
17
+ const offset = params?.offset || 0;
18
+
19
+ // Map generic sort params to Polymarket Gamma API params
20
+ let queryParams: any = {
21
+ active: 'true',
22
+ closed: 'false',
23
+ limit: limit,
24
+ offset: offset,
25
+ };
26
+
27
+ // Gamma API uses 'order' and 'ascending' for sorting
28
+ if (params?.sort === 'volume') {
29
+ queryParams.order = 'volume';
30
+ queryParams.ascending = 'false';
31
+ } else if (params?.sort === 'newest') {
32
+ queryParams.order = 'startDate';
33
+ queryParams.ascending = 'false';
34
+ } else if (params?.sort === 'liquidity') {
35
+ // queryParams.order = 'liquidity';
36
+ } else {
37
+ // Default: do not send order param to avoid 422
38
+ }
39
+
40
+ try {
41
+ // Fetch active events from Gamma
42
+ const response = await axios.get(this.baseUrl, {
43
+ params: queryParams
44
+ });
45
+
46
+ const events = response.data;
47
+ const unifiedMarkets: UnifiedMarket[] = [];
48
+
49
+ for (const event of events) {
50
+ // Each event is a container (e.g. "US Election").
51
+ // It contains specific "markets" (e.g. "Winner", "Pop Vote").
52
+ if (!event.markets) continue;
53
+
54
+ for (const market of event.markets) {
55
+ const outcomes: MarketOutcome[] = [];
56
+
57
+ // Polymarket Gamma often returns 'outcomes' and 'outcomePrices' as stringified JSON keys.
58
+ let outcomeLabels: string[] = [];
59
+ let outcomePrices: string[] = [];
60
+
61
+ try {
62
+ outcomeLabels = typeof market.outcomes === 'string' ? JSON.parse(market.outcomes) : (market.outcomes || []);
63
+ outcomePrices = typeof market.outcomePrices === 'string' ? JSON.parse(market.outcomePrices) : (market.outcomePrices || []);
64
+ } catch (e) {
65
+ console.warn(`Failed to parse outcomes for market ${market.id}`, e);
66
+ }
67
+
68
+ // Extract CLOB token IDs for granular operations
69
+ let clobTokenIds: string[] = [];
70
+ try {
71
+ clobTokenIds = typeof market.clobTokenIds === 'string' ? JSON.parse(market.clobTokenIds) : (market.clobTokenIds || []);
72
+ } catch (e) {
73
+ console.warn(`Failed to parse clobTokenIds for market ${market.id}`, e);
74
+ }
75
+
76
+ // Extract candidate/option name from market question for better outcome labels
77
+ let candidateName: string | null = null;
78
+ if (market.question && market.groupItemTitle) {
79
+ candidateName = market.groupItemTitle;
80
+ }
81
+
82
+ if (outcomeLabels.length > 0) {
83
+ outcomeLabels.forEach((label: string, index: number) => {
84
+ const rawPrice = outcomePrices[index] || "0";
85
+
86
+ // For Yes/No markets with specific candidates, use the candidate name
87
+ let outcomeLabel = label;
88
+ if (candidateName && label.toLowerCase() === 'yes') {
89
+ outcomeLabel = candidateName;
90
+ } else if (candidateName && label.toLowerCase() === 'no') {
91
+ outcomeLabel = `Not ${candidateName}`;
92
+ }
93
+
94
+ // 24h Price Change
95
+ // Polymarket API provides 'oneDayPriceChange' on the market object
96
+ let priceChange = 0;
97
+ if (index === 0 || label.toLowerCase() === 'yes' || (candidateName && label === candidateName)) {
98
+ priceChange = Number(market.oneDayPriceChange || 0);
99
+ }
100
+
101
+ outcomes.push({
102
+ id: String(index),
103
+ label: outcomeLabel,
104
+ price: parseFloat(rawPrice) || 0,
105
+ priceChange24h: priceChange,
106
+ metadata: {
107
+ clobTokenId: clobTokenIds[index]
108
+ }
109
+ });
110
+ });
111
+ }
112
+
113
+ unifiedMarkets.push({
114
+ id: market.id,
115
+ title: market.question ? `${event.title} - ${market.question}` : event.title,
116
+ description: market.description || event.description,
117
+ outcomes: outcomes,
118
+ resolutionDate: market.endDate ? new Date(market.endDate) : (market.end_date_iso ? new Date(market.end_date_iso) : new Date()),
119
+ volume24h: Number(market.volume24hr || market.volume_24h || 0),
120
+ volume: Number(market.volume || 0),
121
+ liquidity: Number(market.liquidity || market.rewards?.liquidity || 0),
122
+ openInterest: Number(market.openInterest || market.open_interest || 0),
123
+ url: `https://polymarket.com/event/${event.slug}`,
124
+ image: event.image || market.image || `https://polymarket.com/api/og?slug=${event.slug}`,
125
+ category: event.category || event.tags?.[0]?.label,
126
+ tags: event.tags?.map((t: any) => t.label) || []
127
+ });
128
+ }
129
+ }
130
+
131
+ // Client-side Sort capability to ensure contract fulfillment
132
+ // Often API filters are "good effort" or apply to the 'event' but not the 'market'
133
+ if (params?.sort === 'volume') {
134
+ unifiedMarkets.sort((a, b) => b.volume24h - a.volume24h);
135
+ } else if (params?.sort === 'newest') {
136
+ // unifiedMarkets.sort((a, b) => b.resolutionDate.getTime() - a.resolutionDate.getTime()); // Not quite 'newest'
137
+ } else if (params?.sort === 'liquidity') {
138
+ unifiedMarkets.sort((a, b) => b.liquidity - a.liquidity);
139
+ } else {
140
+ // Default volume sort
141
+ unifiedMarkets.sort((a, b) => b.volume24h - a.volume24h);
142
+ }
143
+
144
+ // Respect limit strictly after flattening
145
+ return unifiedMarkets.slice(0, limit);
146
+
147
+ } catch (error) {
148
+ console.error("Error fetching Polymarket data:", error);
149
+ return [];
150
+ }
151
+ }
152
+
153
+ async searchMarkets(query: string, params?: MarketFilterParams): Promise<UnifiedMarket[]> {
154
+ // Polymarket Gamma API doesn't support native search
155
+ // Fetch a larger batch and filter client-side
156
+ const searchLimit = 100; // Fetch more markets to search through
157
+
158
+ try {
159
+ // Fetch markets with a higher limit
160
+ const markets = await this.fetchMarkets({
161
+ ...params,
162
+ limit: searchLimit
163
+ });
164
+
165
+ // Client-side text filtering
166
+ const lowerQuery = query.toLowerCase();
167
+ const filtered = markets.filter(market =>
168
+ market.title.toLowerCase().includes(lowerQuery) ||
169
+ market.description.toLowerCase().includes(lowerQuery)
170
+ );
171
+
172
+ // Apply limit to filtered results
173
+ const limit = params?.limit || 20;
174
+ return filtered.slice(0, limit);
175
+
176
+ } catch (error) {
177
+ console.error("Error searching Polymarket data:", error);
178
+ return [];
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Fetch specific markets by their URL slug.
184
+ * Useful for looking up a specific event from a URL.
185
+ * @param slug - The event slug (e.g. "will-fed-cut-rates-in-march")
186
+ */
187
+ async getMarketsBySlug(slug: string): Promise<UnifiedMarket[]> {
188
+ try {
189
+ const response = await axios.get(this.baseUrl, {
190
+ params: { slug: slug }
191
+ });
192
+
193
+ const events = response.data;
194
+ if (!events || events.length === 0) return [];
195
+
196
+ // We can reuse the logic from fetchMarkets if we extract it,
197
+ // but for now I'll duplicate the extraction logic to keep it self-contained
198
+ // and avoid safe refactoring risks.
199
+ // Actually, fetchMarkets is built to work with the Gamma response structure.
200
+ // So we can manually map the response using the same logic.
201
+
202
+ const unifiedMarkets: UnifiedMarket[] = [];
203
+
204
+ for (const event of events) {
205
+ if (!event.markets) continue;
206
+
207
+ for (const market of event.markets) {
208
+ const outcomes: MarketOutcome[] = [];
209
+ let outcomeLabels: string[] = [];
210
+ let outcomePrices: string[] = [];
211
+ let clobTokenIds: string[] = [];
212
+
213
+ try {
214
+ outcomeLabels = typeof market.outcomes === 'string' ? JSON.parse(market.outcomes) : (market.outcomes || []);
215
+ outcomePrices = typeof market.outcomePrices === 'string' ? JSON.parse(market.outcomePrices) : (market.outcomePrices || []);
216
+ clobTokenIds = typeof market.clobTokenIds === 'string' ? JSON.parse(market.clobTokenIds) : (market.clobTokenIds || []);
217
+ } catch (e) { console.warn(`Parse error for market ${market.id}`, e); }
218
+
219
+ let candidateName = market.groupItemTitle;
220
+ if (!candidateName && market.question) candidateName = market.question;
221
+
222
+ if (outcomeLabels.length > 0) {
223
+ outcomeLabels.forEach((label: string, index: number) => {
224
+ let outcomeLabel = label;
225
+ // Clean up Yes/No labels if candidate name is available
226
+ if (candidateName && label.toLowerCase() === 'yes') outcomeLabel = candidateName;
227
+ else if (candidateName && label.toLowerCase() === 'no') outcomeLabel = `Not ${candidateName}`;
228
+
229
+ // 24h Price Change Logic
230
+ let priceChange = 0;
231
+ if (index === 0 || label.toLowerCase() === 'yes' || (candidateName && label === candidateName)) {
232
+ priceChange = Number(market.oneDayPriceChange || 0);
233
+ }
234
+
235
+ outcomes.push({
236
+ id: String(index),
237
+ label: outcomeLabel,
238
+ price: parseFloat(outcomePrices[index] || "0") || 0,
239
+ priceChange24h: priceChange,
240
+ metadata: {
241
+ clobTokenId: clobTokenIds[index]
242
+ }
243
+ });
244
+ });
245
+ }
246
+
247
+ unifiedMarkets.push({
248
+ id: market.id,
249
+ title: event.title,
250
+ description: market.description || event.description,
251
+ outcomes: outcomes,
252
+ resolutionDate: market.endDate ? new Date(market.endDate) : new Date(),
253
+ volume24h: Number(market.volume24hr || market.volume_24h || 0),
254
+ volume: Number(market.volume || 0),
255
+ liquidity: Number(market.liquidity || market.rewards?.liquidity || 0),
256
+ openInterest: Number(market.openInterest || market.open_interest || 0),
257
+ url: `https://polymarket.com/event/${event.slug}`,
258
+ image: event.image || market.image,
259
+ category: event.category || event.tags?.[0]?.label,
260
+ tags: event.tags?.map((t: any) => t.label) || []
261
+ });
262
+ }
263
+ }
264
+ return unifiedMarkets;
265
+
266
+ } catch (error) {
267
+ console.error(`Error fetching Polymarket slug ${slug}:`, error);
268
+ return [];
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Map our generic CandleInterval to Polymarket's fidelity (in minutes)
274
+ */
275
+ private mapIntervalToFidelity(interval: CandleInterval): number {
276
+ const mapping: Record<CandleInterval, number> = {
277
+ '1m': 1,
278
+ '5m': 5,
279
+ '15m': 15,
280
+ '1h': 60,
281
+ '6h': 360,
282
+ '1d': 1440
283
+ };
284
+ return mapping[interval];
285
+ }
286
+
287
+ /**
288
+ * Fetch historical price data (OHLCV candles) for a specific token.
289
+ * @param id - The CLOB token ID (e.g., outcome token ID)
290
+ */
291
+ async getMarketHistory(id: string, params: HistoryFilterParams): Promise<PriceCandle[]> {
292
+ // ID Validation: Polymarket CLOB requires a Token ID (long numeric string) not a Market ID
293
+ if (id.length < 10 && /^\d+$/.test(id)) {
294
+ throw new Error(`Invalid ID for Polymarket history: "${id}". You provided a Market ID, but Polymarket's CLOB API requires a Token ID. Use outcome.metadata.clobTokenId instead.`);
295
+ }
296
+
297
+ try {
298
+ const fidelity = this.mapIntervalToFidelity(params.resolution);
299
+ const nowTs = Math.floor(Date.now() / 1000);
300
+
301
+ // 1. Smart Lookback Calculation
302
+ // If start/end not provided, calculate window based on limit * resolution
303
+ let startTs = params.start ? Math.floor(params.start.getTime() / 1000) : 0;
304
+ let endTs = params.end ? Math.floor(params.end.getTime() / 1000) : nowTs;
305
+
306
+ if (!params.start) {
307
+ // Default limit is usually 20 in the example, but safety margin is good.
308
+ // If limit is not set, we default to 100 candles.
309
+ const count = params.limit || 100;
310
+ // fidelity is in minutes.
311
+ const durationSeconds = count * fidelity * 60;
312
+ startTs = endTs - durationSeconds;
313
+ }
314
+
315
+ const queryParams: any = {
316
+ market: id,
317
+ fidelity: fidelity,
318
+ startTs: startTs,
319
+ endTs: endTs
320
+ };
321
+
322
+ const response = await axios.get(`${this.clobUrl}/prices-history`, {
323
+ params: queryParams
324
+ });
325
+
326
+ const history = response.data.history || [];
327
+
328
+ // 2. Align Timestamps (Snap to Grid)
329
+ // Polymarket returns random tick timestamps (e.g. 1:00:21).
330
+ // We want to normalize this to the start of the bucket (1:00:00).
331
+ const resolutionMs = fidelity * 60 * 1000;
332
+
333
+ const candles: PriceCandle[] = history.map((item: any) => {
334
+ const rawMs = item.t * 1000;
335
+ const snappedMs = Math.floor(rawMs / resolutionMs) * resolutionMs;
336
+
337
+ return {
338
+ timestamp: snappedMs, // Aligned timestamp
339
+ open: item.p,
340
+ high: item.p,
341
+ low: item.p,
342
+ close: item.p,
343
+ volume: undefined
344
+ };
345
+ });
346
+
347
+ // Apply limit if specified
348
+ if (params.limit && candles.length > params.limit) {
349
+ return candles.slice(-params.limit);
350
+ }
351
+
352
+ return candles;
353
+
354
+ } catch (error: any) {
355
+ if (axios.isAxiosError(error) && error.response) {
356
+ const apiError = error.response.data?.error || error.response.data?.message || "Unknown API Error";
357
+ throw new Error(`Polymarket History API Error (${error.response.status}): ${apiError}. Used ID: ${id}`);
358
+ }
359
+ console.error(`Unexpected error fetching Polymarket history for ${id}:`, error);
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Fetch the current order book for a specific token.
366
+ * @param id - The CLOB token ID
367
+ */
368
+ async getOrderBook(id: string): Promise<OrderBook> {
369
+ try {
370
+ const response = await axios.get(`${this.clobUrl}/book`, {
371
+ params: { token_id: id }
372
+ });
373
+
374
+ const data = response.data;
375
+
376
+ // Response format: { bids: [{price: "0.52", size: "100"}], asks: [...] }
377
+ const bids = (data.bids || []).map((level: any) => ({
378
+ price: parseFloat(level.price),
379
+ size: parseFloat(level.size)
380
+ })).sort((a: { price: number, size: number }, b: { price: number, size: number }) => b.price - a.price); // Sort Bids Descending (Best/Highest first)
381
+
382
+ const asks = (data.asks || []).map((level: any) => ({
383
+ price: parseFloat(level.price),
384
+ size: parseFloat(level.size)
385
+ })).sort((a: { price: number, size: number }, b: { price: number, size: number }) => a.price - b.price); // Sort Asks Ascending (Best/Lowest first)
386
+
387
+ return {
388
+ bids,
389
+ asks,
390
+ timestamp: data.timestamp ? new Date(data.timestamp).getTime() : Date.now()
391
+ };
392
+
393
+ } catch (error) {
394
+ console.error(`Error fetching Polymarket orderbook for ${id}:`, error);
395
+ return { bids: [], asks: [] };
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Fetch raw trade history for a specific token.
401
+ * @param id - The CLOB token ID
402
+ *
403
+ * NOTE: Polymarket's /trades endpoint currently requires L2 Authentication (API Key).
404
+ * This method will return an empty array if an API key is not provided in headers.
405
+ * Use getMarketHistory for public historical price data instead.
406
+ */
407
+ async getTradeHistory(id: string, params: HistoryFilterParams): Promise<Trade[]> {
408
+ // ID Validation
409
+ if (id.length < 10 && /^\d+$/.test(id)) {
410
+ throw new Error(`Invalid ID for Polymarket trades: "${id}". You provided a Market ID, but Polymarket's CLOB API requires a Token ID.`);
411
+ }
412
+
413
+ try {
414
+ const queryParams: any = {
415
+ market: id
416
+ };
417
+
418
+ // Add time filters if provided
419
+ if (params.start) {
420
+ queryParams.after = Math.floor(params.start.getTime() / 1000);
421
+ }
422
+ if (params.end) {
423
+ queryParams.before = Math.floor(params.end.getTime() / 1000);
424
+ }
425
+
426
+ const response = await axios.get(`${this.clobUrl}/trades`, {
427
+ params: queryParams
428
+ });
429
+
430
+ // Response is an array of trade objects
431
+ const trades = response.data || [];
432
+
433
+ const mappedTrades: Trade[] = trades.map((trade: any) => ({
434
+ id: trade.id || `${trade.timestamp}-${trade.price}`,
435
+ timestamp: trade.timestamp * 1000, // Convert to milliseconds
436
+ price: parseFloat(trade.price),
437
+ amount: parseFloat(trade.size || trade.amount || 0),
438
+ side: trade.side === 'BUY' ? 'buy' : trade.side === 'SELL' ? 'sell' : 'unknown'
439
+ }));
440
+
441
+ // Apply limit if specified
442
+ if (params.limit && mappedTrades.length > params.limit) {
443
+ return mappedTrades.slice(-params.limit); // Return most recent N trades
444
+ }
445
+
446
+ return mappedTrades;
447
+
448
+ } catch (error: any) {
449
+ if (axios.isAxiosError(error) && error.response) {
450
+ const apiError = error.response.data?.error || error.response.data?.message || "Unknown API Error";
451
+ throw new Error(`Polymarket Trades API Error (${error.response.status}): ${apiError}. Used ID: ${id}`);
452
+ }
453
+ console.error(`Unexpected error fetching Polymarket trades for ${id}:`, error);
454
+ throw error;
455
+ }
456
+ }
457
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './BaseExchange';
2
+ export * from './types';
3
+ export * from './exchanges/Polymarket';
4
+ export * from './exchanges/Kalshi';
5
+
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+
2
+ // ----------------------------------------------------------------------------
3
+ // Core Data Models
4
+ // ----------------------------------------------------------------------------
5
+
6
+ export interface MarketOutcome {
7
+ id: string;
8
+ label: string;
9
+ price: number;
10
+ priceChange24h?: number;
11
+ metadata?: Record<string, any>;
12
+ }
13
+
14
+ export interface UnifiedMarket {
15
+ id: string;
16
+ title: string;
17
+ description: string;
18
+ outcomes: MarketOutcome[];
19
+
20
+ resolutionDate: Date;
21
+ volume24h: number;
22
+ volume?: number; // Total / Lifetime volume
23
+ liquidity: number;
24
+ openInterest?: number;
25
+
26
+ url: string;
27
+ image?: string;
28
+
29
+ category?: string;
30
+ tags?: string[];
31
+ }
32
+
33
+ export type CandleInterval = '1m' | '5m' | '15m' | '1h' | '6h' | '1d';
34
+
35
+ export interface PriceCandle {
36
+ timestamp: number;
37
+ open: number;
38
+ high: number;
39
+ low: number;
40
+ close: number;
41
+ volume?: number;
42
+ }
43
+
44
+ export interface OrderLevel {
45
+ price: number; // 0.0 to 1.0 (probability)
46
+ size: number; // contracts/shares
47
+ }
48
+
49
+ export interface OrderBook {
50
+ bids: OrderLevel[];
51
+ asks: OrderLevel[];
52
+ timestamp?: number;
53
+ }
54
+
55
+ export interface Trade {
56
+ id: string;
57
+ timestamp: number;
58
+ price: number;
59
+ amount: number;
60
+ side: 'buy' | 'sell' | 'unknown';
61
+ }
@@ -0,0 +1,132 @@
1
+ import axios from 'axios';
2
+ import { KalshiExchange } from '../../../src/exchanges/Kalshi';
3
+
4
+ /**
5
+ * Kalshi API Error Handling Test
6
+ *
7
+ * What: Tests how the exchange handles various API error responses (4xx, 5xx).
8
+ * Why: External APIs can fail with rate limits, authentication errors, or server issues.
9
+ * The library must handle these gracefully without crashing.
10
+ * How: Mocks different HTTP error responses and verifies proper error handling.
11
+ */
12
+
13
+ jest.mock('axios');
14
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
15
+
16
+ describe('KalshiExchange - API Error Handling', () => {
17
+ let exchange: KalshiExchange;
18
+
19
+ beforeEach(() => {
20
+ exchange = new KalshiExchange();
21
+ jest.clearAllMocks();
22
+ });
23
+
24
+ it('should handle 404 errors in getMarketsBySlug', async () => {
25
+ const error = {
26
+ response: {
27
+ status: 404,
28
+ data: { error: 'Event not found' }
29
+ },
30
+ isAxiosError: true
31
+ };
32
+ mockedAxios.get.mockRejectedValue(error);
33
+ // @ts-expect-error - Mock type mismatch is expected in tests
34
+ mockedAxios.isAxiosError = jest.fn().mockReturnValue(true);
35
+
36
+ await expect(exchange.getMarketsBySlug('INVALID-EVENT'))
37
+ .rejects
38
+ .toThrow(/not found/i);
39
+ });
40
+
41
+ it('should handle 500 errors in getMarketsBySlug', async () => {
42
+ const error = {
43
+ response: {
44
+ status: 500,
45
+ data: { error: 'Internal Server Error' }
46
+ },
47
+ isAxiosError: true
48
+ };
49
+ mockedAxios.get.mockRejectedValue(error);
50
+ // @ts-expect-error - Mock type mismatch is expected in tests
51
+ mockedAxios.isAxiosError = jest.fn().mockReturnValue(true);
52
+
53
+ await expect(exchange.getMarketsBySlug('SOME-EVENT'))
54
+ .rejects
55
+ .toThrow(/API Error/i);
56
+ });
57
+
58
+ it('should handle network errors in fetchMarkets', async () => {
59
+ mockedAxios.get.mockRejectedValue(new Error('Network timeout'));
60
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
61
+
62
+ const markets = await exchange.fetchMarkets();
63
+
64
+ expect(markets).toEqual([]);
65
+ expect(consoleSpy).toHaveBeenCalled();
66
+ consoleSpy.mockRestore();
67
+ });
68
+
69
+ it('should handle malformed response data', async () => {
70
+ mockedAxios.get.mockResolvedValue({
71
+ data: {
72
+ events: [
73
+ {
74
+ // Missing required fields
75
+ event_ticker: 'TEST',
76
+ markets: [{ ticker: 'TEST-MARKET' }]
77
+ }
78
+ ]
79
+ }
80
+ });
81
+
82
+ const markets = await exchange.fetchMarkets();
83
+
84
+ // Should not crash, may return empty or partial data
85
+ expect(Array.isArray(markets)).toBe(true);
86
+ });
87
+
88
+ it('should handle invalid ticker format in getMarketHistory', async () => {
89
+ await expect(exchange.getMarketHistory('INVALID', { resolution: '1h' }))
90
+ .rejects
91
+ .toThrow(/Invalid Kalshi Ticker format/i);
92
+ });
93
+
94
+ it('should handle API errors in getMarketHistory', async () => {
95
+ const error = {
96
+ response: {
97
+ status: 400,
98
+ data: { error: 'Invalid parameters' }
99
+ },
100
+ isAxiosError: true
101
+ };
102
+ mockedAxios.get.mockRejectedValue(error);
103
+ // @ts-expect-error - Mock type mismatch is expected in tests
104
+ mockedAxios.isAxiosError = jest.fn().mockReturnValue(true);
105
+
106
+ await expect(exchange.getMarketHistory('FED-25JAN-B4.75', { resolution: '1h' }))
107
+ .rejects
108
+ .toThrow(/History API Error/i);
109
+ });
110
+
111
+ it('should return empty orderbook on error', async () => {
112
+ mockedAxios.get.mockRejectedValue(new Error('API Error'));
113
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
114
+
115
+ const orderbook = await exchange.getOrderBook('TEST-TICKER');
116
+
117
+ expect(orderbook).toEqual({ bids: [], asks: [] });
118
+ expect(consoleSpy).toHaveBeenCalled();
119
+ consoleSpy.mockRestore();
120
+ });
121
+
122
+ it('should return empty trades on error', async () => {
123
+ mockedAxios.get.mockRejectedValue(new Error('API Error'));
124
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
125
+
126
+ const trades = await exchange.getTradeHistory('TEST-TICKER', { resolution: '1h' });
127
+
128
+ expect(trades).toEqual([]);
129
+ expect(consoleSpy).toHaveBeenCalled();
130
+ consoleSpy.mockRestore();
131
+ });
132
+ });