@fin.cx/opendata 3.0.0 → 3.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.
@@ -0,0 +1,429 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import type {
3
+ IFundamentalsProvider,
4
+ IStockFundamentals,
5
+ IFundamentalsRequest,
6
+ IFundamentalsCurrentRequest,
7
+ IFundamentalsBatchRequest
8
+ } from '../interfaces/fundamentals.js';
9
+
10
+ /**
11
+ * Configuration for SEC EDGAR provider
12
+ */
13
+ export interface ISecEdgarConfig {
14
+ userAgent: string; // Required: Format "Company Name Email" (e.g., "fin.cx info@fin.cx")
15
+ cikCacheTTL?: number; // Default: 30 days (CIK codes rarely change)
16
+ fundamentalsCacheTTL?: number; // Default: 90 days (quarterly filings)
17
+ timeout?: number; // Request timeout in ms
18
+ }
19
+
20
+ /**
21
+ * Rate limiter for SEC EDGAR API
22
+ * SEC requires: 10 requests per second maximum
23
+ */
24
+ class RateLimiter {
25
+ private requestTimes: number[] = [];
26
+ private readonly maxRequestsPerSecond = 10;
27
+
28
+ public async waitForSlot(): Promise<void> {
29
+ const now = Date.now();
30
+ const oneSecondAgo = now - 1000;
31
+
32
+ // Remove requests older than 1 second
33
+ this.requestTimes = this.requestTimes.filter(time => time > oneSecondAgo);
34
+
35
+ // If we've hit the limit, wait
36
+ if (this.requestTimes.length >= this.maxRequestsPerSecond) {
37
+ const oldestRequest = this.requestTimes[0];
38
+ const waitTime = 1000 - (now - oldestRequest) + 10; // +10ms buffer
39
+ await plugins.smartdelay.delayFor(waitTime);
40
+ return this.waitForSlot(); // Recursively check again
41
+ }
42
+
43
+ // Record this request
44
+ this.requestTimes.push(now);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * SEC EDGAR Fundamental Data Provider
50
+ *
51
+ * Features:
52
+ * - Free public access (no API key required)
53
+ * - All US public companies
54
+ * - Financial data from 10-K/10-Q filings
55
+ * - US GAAP standardized metrics
56
+ * - Historical data back to ~2009
57
+ * - < 1 minute filing delay
58
+ *
59
+ * Documentation: https://www.sec.gov/edgar/sec-api-documentation
60
+ *
61
+ * Rate Limits:
62
+ * - 10 requests per second (enforced by SEC)
63
+ * - Requires User-Agent header in format: "Company Name Email"
64
+ *
65
+ * Data Sources:
66
+ * - Company Facts API: /api/xbrl/companyfacts/CIK##########.json
67
+ * - Ticker Lookup: /files/company_tickers.json
68
+ */
69
+ export class SecEdgarProvider implements IFundamentalsProvider {
70
+ public name = 'SEC EDGAR';
71
+ public priority = 100; // High priority - free, authoritative, comprehensive
72
+ public readonly requiresAuth = false; // No API key needed!
73
+ public readonly rateLimit = {
74
+ requestsPerMinute: 600, // 10 requests/second = 600/minute
75
+ requestsPerDay: undefined // No daily limit
76
+ };
77
+
78
+ private logger = console;
79
+ private baseUrl = 'https://data.sec.gov/api/xbrl';
80
+ private tickersUrl = 'https://www.sec.gov/files/company_tickers.json';
81
+ private userAgent: string;
82
+ private config: Required<ISecEdgarConfig>;
83
+
84
+ // Caching
85
+ private cikCache = new Map<string, { cik: string; timestamp: Date }>();
86
+ private tickerListCache: { data: any; timestamp: Date } | null = null;
87
+
88
+ // Rate limiting
89
+ private rateLimiter = new RateLimiter();
90
+
91
+ constructor(config: ISecEdgarConfig) {
92
+ // Validate User-Agent
93
+ if (!config.userAgent) {
94
+ throw new Error('User-Agent is required for SEC EDGAR provider (format: "Company Name Email")');
95
+ }
96
+
97
+ // Validate User-Agent format (must contain space and @ symbol)
98
+ if (!config.userAgent.includes(' ') || !config.userAgent.includes('@')) {
99
+ throw new Error(
100
+ 'Invalid User-Agent format. Required: "Company Name Email" (e.g., "fin.cx info@fin.cx")'
101
+ );
102
+ }
103
+
104
+ this.userAgent = config.userAgent;
105
+ this.config = {
106
+ userAgent: config.userAgent,
107
+ cikCacheTTL: config.cikCacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days
108
+ fundamentalsCacheTTL: config.fundamentalsCacheTTL || 90 * 24 * 60 * 60 * 1000, // 90 days
109
+ timeout: config.timeout || 30000
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Unified data fetching method
115
+ */
116
+ public async fetchData(
117
+ request: IFundamentalsRequest
118
+ ): Promise<IStockFundamentals | IStockFundamentals[]> {
119
+ switch (request.type) {
120
+ case 'fundamentals-current':
121
+ return this.fetchFundamentals(request);
122
+ case 'fundamentals-batch':
123
+ return this.fetchBatchFundamentals(request);
124
+ default:
125
+ throw new Error(`Unsupported request type: ${(request as any).type}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Fetch fundamental data for a single ticker
131
+ */
132
+ private async fetchFundamentals(request: IFundamentalsCurrentRequest): Promise<IStockFundamentals> {
133
+ try {
134
+ // 1. Get CIK for ticker (with caching)
135
+ const cik = await this.getCIK(request.ticker);
136
+
137
+ // 2. Fetch company facts from SEC (with rate limiting)
138
+ const companyFacts = await this.fetchCompanyFacts(cik);
139
+
140
+ // 3. Parse facts into structured fundamental data
141
+ return this.parseCompanyFacts(request.ticker, cik, companyFacts);
142
+ } catch (error) {
143
+ this.logger.error(`Failed to fetch fundamentals for ${request.ticker}:`, error);
144
+ throw new Error(`SEC EDGAR: Failed to fetch fundamentals for ${request.ticker}: ${error.message}`);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Fetch fundamental data for multiple tickers
150
+ */
151
+ private async fetchBatchFundamentals(
152
+ request: IFundamentalsBatchRequest
153
+ ): Promise<IStockFundamentals[]> {
154
+ const results: IStockFundamentals[] = [];
155
+ const errors: string[] = [];
156
+
157
+ for (const ticker of request.tickers) {
158
+ try {
159
+ const fundamentals = await this.fetchFundamentals({
160
+ type: 'fundamentals-current',
161
+ ticker
162
+ });
163
+ results.push(fundamentals);
164
+ } catch (error) {
165
+ this.logger.warn(`Failed to fetch fundamentals for ${ticker}:`, error);
166
+ errors.push(`${ticker}: ${error.message}`);
167
+ // Continue with other tickers
168
+ }
169
+ }
170
+
171
+ if (results.length === 0) {
172
+ throw new Error(`Failed to fetch fundamentals for all tickers. Errors: ${errors.join(', ')}`);
173
+ }
174
+
175
+ return results;
176
+ }
177
+
178
+ /**
179
+ * Get CIK (Central Index Key) for a ticker symbol
180
+ * Uses SEC's public ticker-to-CIK mapping file
181
+ */
182
+ private async getCIK(ticker: string): Promise<string> {
183
+ const tickerUpper = ticker.toUpperCase();
184
+
185
+ // Check cache first
186
+ const cached = this.cikCache.get(tickerUpper);
187
+ if (cached) {
188
+ const age = Date.now() - cached.timestamp.getTime();
189
+ if (age < this.config.cikCacheTTL) {
190
+ return cached.cik;
191
+ }
192
+ // Cache expired, remove it
193
+ this.cikCache.delete(tickerUpper);
194
+ }
195
+
196
+ // Fetch ticker list (with caching at list level)
197
+ const tickers = await this.fetchTickerList();
198
+
199
+ // Find ticker in list (case-insensitive)
200
+ const entry = Object.values(tickers).find((t: any) => t.ticker === tickerUpper);
201
+
202
+ if (!entry) {
203
+ throw new Error(`CIK not found for ticker ${ticker}`);
204
+ }
205
+
206
+ const cik = String((entry as any).cik_str);
207
+
208
+ // Cache the result
209
+ this.cikCache.set(tickerUpper, {
210
+ cik,
211
+ timestamp: new Date()
212
+ });
213
+
214
+ return cik;
215
+ }
216
+
217
+ /**
218
+ * Fetch the SEC ticker-to-CIK mapping list
219
+ * Cached for 24 hours (list updates daily)
220
+ */
221
+ private async fetchTickerList(): Promise<any> {
222
+ // Check cache
223
+ if (this.tickerListCache) {
224
+ const age = Date.now() - this.tickerListCache.timestamp.getTime();
225
+ if (age < 24 * 60 * 60 * 1000) { // 24 hours
226
+ return this.tickerListCache.data;
227
+ }
228
+ }
229
+
230
+ // Wait for rate limit slot
231
+ await this.rateLimiter.waitForSlot();
232
+
233
+ // Fetch from SEC
234
+ const response = await plugins.smartrequest.SmartRequest.create()
235
+ .url(this.tickersUrl)
236
+ .headers({
237
+ 'User-Agent': this.userAgent,
238
+ 'Accept': 'application/json'
239
+ })
240
+ .timeout(this.config.timeout)
241
+ .get();
242
+
243
+ const data = await response.json();
244
+
245
+ // Cache the list
246
+ this.tickerListCache = {
247
+ data,
248
+ timestamp: new Date()
249
+ };
250
+
251
+ return data;
252
+ }
253
+
254
+ /**
255
+ * Fetch company facts from SEC EDGAR
256
+ */
257
+ private async fetchCompanyFacts(cik: string): Promise<any> {
258
+ // Pad CIK to 10 digits
259
+ const paddedCIK = cik.padStart(10, '0');
260
+ const url = `${this.baseUrl}/companyfacts/CIK${paddedCIK}.json`;
261
+
262
+ // Wait for rate limit slot
263
+ await this.rateLimiter.waitForSlot();
264
+
265
+ // Fetch from SEC
266
+ const response = await plugins.smartrequest.SmartRequest.create()
267
+ .url(url)
268
+ .headers({
269
+ 'User-Agent': this.userAgent,
270
+ 'Accept': 'application/json',
271
+ 'Accept-Encoding': 'gzip, deflate',
272
+ 'Host': 'data.sec.gov'
273
+ })
274
+ .timeout(this.config.timeout)
275
+ .get();
276
+
277
+ const data = await response.json();
278
+
279
+ // Validate response
280
+ if (!data || !data.facts) {
281
+ throw new Error('Invalid response from SEC EDGAR API');
282
+ }
283
+
284
+ return data;
285
+ }
286
+
287
+ /**
288
+ * Parse SEC company facts into structured fundamental data
289
+ */
290
+ private parseCompanyFacts(ticker: string, cik: string, data: any): IStockFundamentals {
291
+ const usGaap = data.facts?.['us-gaap'];
292
+
293
+ if (!usGaap) {
294
+ throw new Error('No US GAAP data available');
295
+ }
296
+
297
+ // Extract latest values for key metrics
298
+ const fundamentals: IStockFundamentals = {
299
+ ticker: ticker.toUpperCase(),
300
+ cik: cik,
301
+ companyName: data.entityName,
302
+ provider: this.name,
303
+ timestamp: new Date(),
304
+ fetchedAt: new Date(),
305
+
306
+ // Per-share metrics
307
+ earningsPerShareBasic: this.getLatestValue(usGaap, 'EarningsPerShareBasic'),
308
+ earningsPerShareDiluted: this.getLatestValue(usGaap, 'EarningsPerShareDiluted'),
309
+ sharesOutstanding: this.getLatestValue(usGaap, 'CommonStockSharesOutstanding'),
310
+ weightedAverageSharesOutstanding: this.getLatestValue(
311
+ usGaap,
312
+ 'WeightedAverageNumberOfSharesOutstandingBasic'
313
+ ),
314
+
315
+ // Income statement
316
+ revenue: this.getLatestValue(usGaap, 'Revenues') ||
317
+ this.getLatestValue(usGaap, 'RevenueFromContractWithCustomerExcludingAssessedTax'),
318
+ netIncome: this.getLatestValue(usGaap, 'NetIncomeLoss'),
319
+ operatingIncome: this.getLatestValue(usGaap, 'OperatingIncomeLoss'),
320
+ grossProfit: this.getLatestValue(usGaap, 'GrossProfit'),
321
+ costOfRevenue: this.getLatestValue(usGaap, 'CostOfRevenue'),
322
+
323
+ // Balance sheet
324
+ assets: this.getLatestValue(usGaap, 'Assets'),
325
+ liabilities: this.getLatestValue(usGaap, 'Liabilities'),
326
+ stockholdersEquity: this.getLatestValue(usGaap, 'StockholdersEquity'),
327
+ cash: this.getLatestValue(usGaap, 'CashAndCashEquivalentsAtCarryingValue'),
328
+ propertyPlantEquipment: this.getLatestValue(usGaap, 'PropertyPlantAndEquipmentNet'),
329
+
330
+ // Metadata (from latest available data point)
331
+ fiscalYear: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.fy,
332
+ fiscalQuarter: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.fp,
333
+ filingDate: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.filed
334
+ ? new Date(this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')!.filed)
335
+ : undefined,
336
+ form: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.form
337
+ };
338
+
339
+ return fundamentals;
340
+ }
341
+
342
+ /**
343
+ * Get the latest value for a US GAAP metric
344
+ */
345
+ private getLatestValue(usGaap: any, metricName: string): number | undefined {
346
+ const metric = usGaap[metricName];
347
+ if (!metric?.units) {
348
+ return undefined;
349
+ }
350
+
351
+ // Get the first unit type (USD, shares, etc.)
352
+ const unitType = Object.keys(metric.units)[0];
353
+ const values = metric.units[unitType];
354
+
355
+ if (!values || !Array.isArray(values) || values.length === 0) {
356
+ return undefined;
357
+ }
358
+
359
+ // Get the latest value (last in array)
360
+ const latest = values[values.length - 1];
361
+ return latest?.val;
362
+ }
363
+
364
+ /**
365
+ * Get metadata from the latest data point
366
+ */
367
+ private getLatestMetadata(usGaap: any, metricName: string): any | undefined {
368
+ const metric = usGaap[metricName];
369
+ if (!metric?.units) {
370
+ return undefined;
371
+ }
372
+
373
+ const unitType = Object.keys(metric.units)[0];
374
+ const values = metric.units[unitType];
375
+
376
+ if (!values || !Array.isArray(values) || values.length === 0) {
377
+ return undefined;
378
+ }
379
+
380
+ return values[values.length - 1];
381
+ }
382
+
383
+ /**
384
+ * Check if SEC EDGAR API is available
385
+ */
386
+ public async isAvailable(): Promise<boolean> {
387
+ try {
388
+ // Test with Apple's well-known CIK
389
+ const url = `${this.baseUrl}/companyfacts/CIK0000320193.json`;
390
+
391
+ const response = await plugins.smartrequest.SmartRequest.create()
392
+ .url(url)
393
+ .headers({
394
+ 'User-Agent': this.userAgent,
395
+ 'Accept': 'application/json'
396
+ })
397
+ .timeout(5000)
398
+ .get();
399
+
400
+ const data = await response.json();
401
+ return data && data.facts !== undefined;
402
+ } catch (error) {
403
+ this.logger.warn('SEC EDGAR provider is not available:', error);
404
+ return false;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Get cache statistics
410
+ */
411
+ public getCacheStats(): {
412
+ cikCacheSize: number;
413
+ hasTickerList: boolean;
414
+ } {
415
+ return {
416
+ cikCacheSize: this.cikCache.size,
417
+ hasTickerList: this.tickerListCache !== null
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Clear all caches
423
+ */
424
+ public clearCache(): void {
425
+ this.cikCache.clear();
426
+ this.tickerListCache = null;
427
+ this.logger.log('SEC EDGAR cache cleared');
428
+ }
429
+ }