@fin.cx/opendata 3.2.2 → 3.4.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.
@@ -10,32 +10,39 @@ import type {
10
10
  } from '../interfaces/stockprice.js';
11
11
 
12
12
  /**
13
- * Marketstack API v2 Provider - Enhanced
13
+ * Marketstack API v2 Provider - Professional Plan with Intelligent Intraday Support
14
14
  * Documentation: https://docs.apilayer.com/marketstack/docs/marketstack-api-v2-v-2-0-0
15
15
  *
16
16
  * Features:
17
+ * - Intelligent endpoint selection based on market hours
18
+ * - Real-time intraday pricing with multiple intervals (1min, 5min, 10min, 15min, 30min, 1hour)
17
19
  * - End-of-Day (EOD) stock prices with historical data
18
- * - Intraday pricing with multiple intervals (1min, 5min, 15min, 30min, 1hour)
20
+ * - Market state detection (PRE, REGULAR, POST, CLOSED)
19
21
  * - Exchange filtering via MIC codes (XNAS, XNYS, XLON, etc.)
20
22
  * - Supports 500,000+ tickers across 72+ exchanges worldwide
21
23
  * - OHLCV data (Open, High, Low, Close, Volume)
22
24
  * - Pagination for large datasets
25
+ * - Automatic fallback from intraday to EOD on errors
23
26
  * - Requires API key authentication
24
27
  *
28
+ * Intelligent Endpoint Selection:
29
+ * - During market hours (PRE/REGULAR/POST): Uses intraday endpoints for fresh data
30
+ * - After market close (CLOSED): Uses EOD endpoints to save API credits
31
+ * - Automatic fallback to EOD if intraday fails (rate limits, plan restrictions, etc.)
32
+ *
25
33
  * Rate Limits:
26
34
  * - Free Plan: 100 requests/month (EOD only)
27
- * - Basic Plan: 10,000 requests/month
28
- * - Professional Plan: 100,000 requests/month (intraday access)
35
+ * - Basic Plan: 10,000 requests/month (EOD only)
36
+ * - Professional Plan: 100,000 requests/month (intraday + EOD)
29
37
  *
30
- * Phase 1 Enhancements:
31
- * - Historical data retrieval with date ranges
32
- * - Exchange filtering
33
- * - OHLCV data support
34
- * - Pagination handling
38
+ * Intraday Access:
39
+ * - Intervals below 15min (1min, 5min, 10min) require Professional Plan or higher
40
+ * - Real-time data from IEX Exchange for US tickers
41
+ * - Symbol formatting: Periods replaced with hyphens for intraday (BRK.B → BRK-B)
35
42
  */
36
43
  export class MarketstackProvider implements IStockProvider {
37
44
  public name = 'Marketstack';
38
- public priority = 80; // Lower than Yahoo (100) due to rate limits and EOD-only data
45
+ public priority = 90; // Increased from 80 - now supports real-time intraday data during market hours
39
46
  public readonly requiresAuth = true;
40
47
  public readonly rateLimit = {
41
48
  requestsPerMinute: undefined, // No per-minute limit specified
@@ -73,39 +80,103 @@ export class MarketstackProvider implements IStockProvider {
73
80
  }
74
81
 
75
82
  /**
76
- * Fetch current/latest EOD price for a single ticker (new API)
83
+ * Fetch current/latest price with intelligent endpoint selection
84
+ * Uses intraday during market hours (PRE, REGULAR, POST) for fresh data
85
+ * Uses EOD after market close (CLOSED) to save API credits
77
86
  */
78
87
  private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
79
88
  try {
80
- let url = `${this.baseUrl}/tickers/${request.ticker}/eod/latest?access_key=${this.apiKey}`;
81
-
82
- // Add exchange filter if specified
83
- if (request.exchange) {
84
- url += `&exchange=${request.exchange}`;
89
+ // Determine current market state
90
+ const marketState = this.getUsMarketState();
91
+ const useIntraday = this.shouldUseIntradayEndpoint(marketState);
92
+
93
+ if (useIntraday) {
94
+ // Use intraday endpoint for fresh data during market hours
95
+ return await this.fetchCurrentPriceIntraday(request, marketState);
96
+ } else {
97
+ // Use EOD endpoint for after-close data
98
+ return await this.fetchCurrentPriceEod(request);
99
+ }
100
+ } catch (error) {
101
+ // If intraday fails, fallback to EOD with warning
102
+ if (error.message?.includes('intraday') || error.message?.includes('Marketstack API error')) {
103
+ this.logger.warn(`Intraday endpoint failed for ${request.ticker}, falling back to EOD:`, error.message);
104
+ try {
105
+ return await this.fetchCurrentPriceEod(request);
106
+ } catch (eodError) {
107
+ // Both failed, throw original error
108
+ throw error;
109
+ }
85
110
  }
111
+ throw error;
112
+ }
113
+ }
86
114
 
87
- const response = await plugins.smartrequest.SmartRequest.create()
88
- .url(url)
89
- .timeout(this.config?.timeout || 10000)
90
- .get();
115
+ /**
116
+ * Fetch current price using intraday endpoint (during market hours)
117
+ * Uses 1min interval for most recent data
118
+ */
119
+ private async fetchCurrentPriceIntraday(
120
+ request: IStockCurrentRequest,
121
+ marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED'
122
+ ): Promise<IStockPrice> {
123
+ const formattedSymbol = this.formatSymbolForIntraday(request.ticker);
124
+ let url = `${this.baseUrl}/tickers/${formattedSymbol}/intraday/latest?access_key=${this.apiKey}`;
125
+ url += `&interval=1min`; // Use 1min for most recent data
126
+
127
+ // Add exchange filter if specified
128
+ if (request.exchange) {
129
+ url += `&exchange=${request.exchange}`;
130
+ }
91
131
 
92
- const responseData = await response.json() as any;
132
+ const response = await plugins.smartrequest.SmartRequest.create()
133
+ .url(url)
134
+ .timeout(this.config?.timeout || 10000)
135
+ .get();
93
136
 
94
- // Check for API errors
95
- if (responseData.error) {
96
- throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
97
- }
137
+ const responseData = await response.json() as any;
98
138
 
99
- // For single ticker endpoint, response is direct object (not wrapped in data field)
100
- if (!responseData || !responseData.close) {
101
- throw new Error(`No data found for ticker ${request.ticker}`);
102
- }
139
+ // Check for API errors
140
+ if (responseData.error) {
141
+ throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
142
+ }
103
143
 
104
- return this.mapToStockPrice(responseData, 'eod');
105
- } catch (error) {
106
- this.logger.error(`Failed to fetch current price for ${request.ticker}:`, error);
107
- throw new Error(`Marketstack: Failed to fetch current price for ${request.ticker}: ${error.message}`);
144
+ if (!responseData || !responseData.close) {
145
+ throw new Error(`No intraday data found for ticker ${request.ticker}`);
108
146
  }
147
+
148
+ return this.mapToStockPrice(responseData, 'intraday', marketState);
149
+ }
150
+
151
+ /**
152
+ * Fetch current price using EOD endpoint (after market close)
153
+ */
154
+ private async fetchCurrentPriceEod(request: IStockCurrentRequest): Promise<IStockPrice> {
155
+ let url = `${this.baseUrl}/tickers/${request.ticker}/eod/latest?access_key=${this.apiKey}`;
156
+
157
+ // Add exchange filter if specified
158
+ if (request.exchange) {
159
+ url += `&exchange=${request.exchange}`;
160
+ }
161
+
162
+ const response = await plugins.smartrequest.SmartRequest.create()
163
+ .url(url)
164
+ .timeout(this.config?.timeout || 10000)
165
+ .get();
166
+
167
+ const responseData = await response.json() as any;
168
+
169
+ // Check for API errors
170
+ if (responseData.error) {
171
+ throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
172
+ }
173
+
174
+ // For single ticker endpoint, response is direct object (not wrapped in data field)
175
+ if (!responseData || !responseData.close) {
176
+ throw new Error(`No data found for ticker ${request.ticker}`);
177
+ }
178
+
179
+ return this.mapToStockPrice(responseData, 'eod');
109
180
  }
110
181
 
111
182
  /**
@@ -179,60 +250,210 @@ export class MarketstackProvider implements IStockProvider {
179
250
  }
180
251
 
181
252
  /**
182
- * Fetch intraday prices with specified interval (Phase 2 placeholder)
253
+ * Fetch intraday prices with specified interval
254
+ * Supports intervals: 1min, 5min, 10min, 15min, 30min, 1hour
255
+ * Note: Intervals below 15min require Professional Plan or higher
183
256
  */
184
257
  private async fetchIntradayPrices(request: IStockIntradayRequest): Promise<IStockPrice[]> {
185
- throw new Error('Intraday data support coming in Phase 2. For now, use EOD data with type: "current" or "historical"');
258
+ try {
259
+ const allPrices: IStockPrice[] = [];
260
+ let offset = 0;
261
+ const limit = 1000; // Max per page for intraday
262
+ const maxRecords = 10000; // Safety limit
263
+
264
+ // Format symbol for intraday endpoint (replace . with -)
265
+ const formattedSymbol = this.formatSymbolForIntraday(request.ticker);
266
+
267
+ while (true) {
268
+ let url = `${this.baseUrl}/tickers/${formattedSymbol}/intraday?access_key=${this.apiKey}`;
269
+ url += `&interval=${request.interval}`;
270
+ url += `&limit=${limit}`;
271
+ url += `&offset=${offset}`;
272
+
273
+ // Add date filter if specified
274
+ if (request.date) {
275
+ url += `&date_from=${this.formatDate(request.date)}`;
276
+ url += `&date_to=${this.formatDate(request.date)}`;
277
+ }
278
+
279
+ // Add exchange filter if specified
280
+ if (request.exchange) {
281
+ url += `&exchange=${request.exchange}`;
282
+ }
283
+
284
+ const response = await plugins.smartrequest.SmartRequest.create()
285
+ .url(url)
286
+ .timeout(this.config?.timeout || 15000)
287
+ .get();
288
+
289
+ const responseData = await response.json() as any;
290
+
291
+ // Check for API errors
292
+ if (responseData.error) {
293
+ throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
294
+ }
295
+
296
+ if (!responseData?.data || !Array.isArray(responseData.data)) {
297
+ throw new Error('Invalid response format from Marketstack API');
298
+ }
299
+
300
+ // Map data to stock prices
301
+ for (const data of responseData.data) {
302
+ try {
303
+ allPrices.push(this.mapToStockPrice(data, 'intraday'));
304
+ } catch (error) {
305
+ this.logger.warn(`Failed to parse intraday data for ${data.symbol}:`, error);
306
+ }
307
+ }
308
+
309
+ // Check if we have more pages
310
+ const pagination = responseData.pagination;
311
+ const hasMore = pagination && offset + limit < pagination.total;
312
+
313
+ // Honor limit from request if specified, or safety limit
314
+ if (!hasMore || (request.limit && allPrices.length >= request.limit) || allPrices.length >= maxRecords) {
315
+ break;
316
+ }
317
+
318
+ offset += limit;
319
+ }
320
+
321
+ // Apply limit if specified
322
+ if (request.limit && allPrices.length > request.limit) {
323
+ return allPrices.slice(0, request.limit);
324
+ }
325
+
326
+ return allPrices;
327
+ } catch (error) {
328
+ this.logger.error(`Failed to fetch intraday prices for ${request.ticker}:`, error);
329
+ throw new Error(`Marketstack: Failed to fetch intraday prices for ${request.ticker}: ${error.message}`);
330
+ }
186
331
  }
187
332
 
188
333
  /**
189
- * Fetch current prices for multiple tickers (new API)
334
+ * Fetch current prices for multiple tickers with intelligent endpoint selection
335
+ * Uses intraday during market hours (PRE, REGULAR, POST) for fresh data
336
+ * Uses EOD after market close (CLOSED) to save API credits
190
337
  */
191
338
  private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
192
339
  try {
193
- const symbols = request.tickers.join(',');
194
- let url = `${this.baseUrl}/eod/latest?access_key=${this.apiKey}&symbols=${symbols}`;
195
-
196
- if (request.exchange) {
197
- url += `&exchange=${request.exchange}`;
340
+ // Determine current market state
341
+ const marketState = this.getUsMarketState();
342
+ const useIntraday = this.shouldUseIntradayEndpoint(marketState);
343
+
344
+ if (useIntraday) {
345
+ return await this.fetchBatchCurrentPricesIntraday(request, marketState);
346
+ } else {
347
+ return await this.fetchBatchCurrentPricesEod(request);
198
348
  }
349
+ } catch (error) {
350
+ // Fallback to EOD if intraday fails
351
+ if (error.message?.includes('intraday') || error.message?.includes('Marketstack API error')) {
352
+ this.logger.warn(`Intraday batch endpoint failed, falling back to EOD:`, error.message);
353
+ try {
354
+ return await this.fetchBatchCurrentPricesEod(request);
355
+ } catch (eodError) {
356
+ // Both failed, throw original error
357
+ throw error;
358
+ }
359
+ }
360
+ throw error;
361
+ }
362
+ }
199
363
 
200
- const response = await plugins.smartrequest.SmartRequest.create()
201
- .url(url)
202
- .timeout(this.config?.timeout || 15000)
203
- .get();
364
+ /**
365
+ * Fetch batch current prices using intraday endpoint
366
+ */
367
+ private async fetchBatchCurrentPricesIntraday(
368
+ request: IStockBatchCurrentRequest,
369
+ marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED'
370
+ ): Promise<IStockPrice[]> {
371
+ // Format symbols for intraday (replace . with -)
372
+ const formattedSymbols = request.tickers.map(t => this.formatSymbolForIntraday(t)).join(',');
373
+
374
+ let url = `${this.baseUrl}/intraday/latest?access_key=${this.apiKey}`;
375
+ url += `&symbols=${formattedSymbols}`;
376
+ url += `&interval=1min`; // Use 1min for most recent data
377
+
378
+ if (request.exchange) {
379
+ url += `&exchange=${request.exchange}`;
380
+ }
204
381
 
205
- const responseData = await response.json() as any;
382
+ const response = await plugins.smartrequest.SmartRequest.create()
383
+ .url(url)
384
+ .timeout(this.config?.timeout || 15000)
385
+ .get();
206
386
 
207
- // Check for API errors
208
- if (responseData.error) {
209
- throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
210
- }
387
+ const responseData = await response.json() as any;
211
388
 
212
- if (!responseData?.data || !Array.isArray(responseData.data)) {
213
- throw new Error('Invalid response format from Marketstack API');
214
- }
389
+ if (responseData.error) {
390
+ throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
391
+ }
215
392
 
216
- const prices: IStockPrice[] = [];
393
+ if (!responseData?.data || !Array.isArray(responseData.data)) {
394
+ throw new Error('Invalid response format from Marketstack API');
395
+ }
217
396
 
218
- for (const data of responseData.data) {
219
- try {
220
- prices.push(this.mapToStockPrice(data, 'eod'));
221
- } catch (error) {
222
- this.logger.warn(`Failed to parse data for ${data.symbol}:`, error);
223
- // Continue processing other tickers
224
- }
397
+ const prices: IStockPrice[] = [];
398
+
399
+ for (const data of responseData.data) {
400
+ try {
401
+ prices.push(this.mapToStockPrice(data, 'intraday', marketState));
402
+ } catch (error) {
403
+ this.logger.warn(`Failed to parse intraday data for ${data.symbol}:`, error);
225
404
  }
405
+ }
406
+
407
+ if (prices.length === 0) {
408
+ throw new Error('No valid price data received from batch intraday request');
409
+ }
226
410
 
227
- if (prices.length === 0) {
228
- throw new Error('No valid price data received from batch request');
411
+ return prices;
412
+ }
413
+
414
+ /**
415
+ * Fetch batch current prices using EOD endpoint
416
+ */
417
+ private async fetchBatchCurrentPricesEod(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
418
+ const symbols = request.tickers.join(',');
419
+ let url = `${this.baseUrl}/eod/latest?access_key=${this.apiKey}&symbols=${symbols}`;
420
+
421
+ if (request.exchange) {
422
+ url += `&exchange=${request.exchange}`;
423
+ }
424
+
425
+ const response = await plugins.smartrequest.SmartRequest.create()
426
+ .url(url)
427
+ .timeout(this.config?.timeout || 15000)
428
+ .get();
429
+
430
+ const responseData = await response.json() as any;
431
+
432
+ // Check for API errors
433
+ if (responseData.error) {
434
+ throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
435
+ }
436
+
437
+ if (!responseData?.data || !Array.isArray(responseData.data)) {
438
+ throw new Error('Invalid response format from Marketstack API');
439
+ }
440
+
441
+ const prices: IStockPrice[] = [];
442
+
443
+ for (const data of responseData.data) {
444
+ try {
445
+ prices.push(this.mapToStockPrice(data, 'eod'));
446
+ } catch (error) {
447
+ this.logger.warn(`Failed to parse data for ${data.symbol}:`, error);
448
+ // Continue processing other tickers
229
449
  }
450
+ }
230
451
 
231
- return prices;
232
- } catch (error) {
233
- this.logger.error(`Failed to fetch batch current prices:`, error);
234
- throw new Error(`Marketstack: Failed to fetch batch current prices: ${error.message}`);
452
+ if (prices.length === 0) {
453
+ throw new Error('No valid price data received from batch request');
235
454
  }
455
+
456
+ return prices;
236
457
  }
237
458
 
238
459
  /**
@@ -283,8 +504,15 @@ export class MarketstackProvider implements IStockProvider {
283
504
 
284
505
  /**
285
506
  * Map Marketstack API response to IStockPrice interface
507
+ * @param data - API response data
508
+ * @param dataType - Type of data (eod, intraday, live)
509
+ * @param explicitMarketState - Override market state (used for intraday data fetched during known market hours)
286
510
  */
287
- private mapToStockPrice(data: any, dataType: 'eod' | 'intraday' | 'live' = 'eod'): IStockPrice {
511
+ private mapToStockPrice(
512
+ data: any,
513
+ dataType: 'eod' | 'intraday' | 'live' = 'eod',
514
+ explicitMarketState?: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED'
515
+ ): IStockPrice {
288
516
  if (!data.close) {
289
517
  throw new Error('Missing required price data');
290
518
  }
@@ -301,6 +529,23 @@ export class MarketstackProvider implements IStockProvider {
301
529
  const timestamp = data.date ? new Date(data.date) : new Date();
302
530
  const fetchedAt = new Date();
303
531
 
532
+ // Determine market state intelligently
533
+ let marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED';
534
+
535
+ if (explicitMarketState) {
536
+ // Use provided market state (for intraday data fetched during known market hours)
537
+ marketState = explicitMarketState;
538
+ } else if (dataType === 'eod') {
539
+ // EOD data is always for closed markets
540
+ marketState = 'CLOSED';
541
+ } else if (dataType === 'intraday') {
542
+ // For intraday data without explicit state, determine from timestamp
543
+ marketState = this.getUsMarketState(timestamp.getTime());
544
+ } else {
545
+ // Default fallback
546
+ marketState = 'CLOSED';
547
+ }
548
+
304
549
  const stockPrice: IStockPrice = {
305
550
  ticker: data.symbol.toUpperCase(),
306
551
  price: currentPrice,
@@ -310,7 +555,7 @@ export class MarketstackProvider implements IStockProvider {
310
555
  previousClose: previousClose,
311
556
  timestamp: timestamp,
312
557
  provider: this.name,
313
- marketState: 'CLOSED', // EOD data is always for closed markets
558
+ marketState: marketState, // Now dynamic based on data type, timestamp, and explicit state
314
559
  exchange: data.exchange,
315
560
  exchangeName: data.exchange_code || data.name,
316
561
 
@@ -373,4 +618,76 @@ export class MarketstackProvider implements IStockProvider {
373
618
  const day = String(date.getDate()).padStart(2, '0');
374
619
  return `${year}-${month}-${day}`;
375
620
  }
621
+
622
+ /**
623
+ * Get US market state based on Eastern Time
624
+ * Regular hours: 9:30 AM - 4:00 PM ET
625
+ * Pre-market: 4:00 AM - 9:30 AM ET
626
+ * After-hours: 4:00 PM - 8:00 PM ET
627
+ *
628
+ * @param timestampMs - Optional timestamp in milliseconds (defaults to current time)
629
+ * @returns Market state: PRE, REGULAR, POST, or CLOSED
630
+ */
631
+ private getUsMarketState(timestampMs?: number): 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' {
632
+ const now = timestampMs ? new Date(timestampMs) : new Date();
633
+
634
+ // Convert to ET (UTC-5 or UTC-4 depending on DST)
635
+ // For simplicity, we'll use a rough approximation
636
+ // TODO: Add proper timezone library for production use
637
+ const etOffset = -5; // Standard time, adjust for DST if needed
638
+ const etTime = new Date(now.getTime() + (etOffset * 60 * 60 * 1000));
639
+
640
+ // Get day of week (0 = Sunday, 6 = Saturday)
641
+ const dayOfWeek = etTime.getUTCDay();
642
+
643
+ // Check if weekend
644
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
645
+ return 'CLOSED';
646
+ }
647
+
648
+ // Get hour and minute in ET
649
+ const hours = etTime.getUTCHours();
650
+ const minutes = etTime.getUTCMinutes();
651
+ const timeInMinutes = hours * 60 + minutes;
652
+
653
+ // Define market hours in minutes
654
+ const preMarketStart = 4 * 60; // 4:00 AM
655
+ const regularMarketStart = 9 * 60 + 30; // 9:30 AM
656
+ const regularMarketEnd = 16 * 60; // 4:00 PM
657
+ const afterHoursEnd = 20 * 60; // 8:00 PM
658
+
659
+ if (timeInMinutes >= preMarketStart && timeInMinutes < regularMarketStart) {
660
+ return 'PRE';
661
+ } else if (timeInMinutes >= regularMarketStart && timeInMinutes < regularMarketEnd) {
662
+ return 'REGULAR';
663
+ } else if (timeInMinutes >= regularMarketEnd && timeInMinutes < afterHoursEnd) {
664
+ return 'POST';
665
+ } else {
666
+ return 'CLOSED';
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Determine if intraday endpoint should be used based on market state
672
+ * Uses intraday for PRE, REGULAR, and POST market states
673
+ * Uses EOD for CLOSED state to save API credits
674
+ *
675
+ * @param marketState - Current market state
676
+ * @returns true if intraday endpoint should be used
677
+ */
678
+ private shouldUseIntradayEndpoint(marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED'): boolean {
679
+ return marketState !== 'CLOSED';
680
+ }
681
+
682
+ /**
683
+ * Format ticker symbol for intraday endpoints
684
+ * Marketstack intraday API requires periods to be replaced with hyphens
685
+ * Example: BRK.B → BRK-B
686
+ *
687
+ * @param symbol - Original ticker symbol
688
+ * @returns Formatted symbol for intraday endpoints
689
+ */
690
+ private formatSymbolForIntraday(symbol: string): string {
691
+ return symbol.replace(/\./g, '-');
692
+ }
376
693
  }