@fin.cx/opendata 2.1.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/stocks/classes.fundamentalsservice.d.ts +104 -0
- package/dist_ts/stocks/classes.fundamentalsservice.js +298 -0
- package/dist_ts/stocks/classes.stockservice.d.ts +16 -7
- package/dist_ts/stocks/classes.stockservice.js +17 -98
- package/dist_ts/stocks/index.d.ts +3 -0
- package/dist_ts/stocks/index.js +5 -2
- package/dist_ts/stocks/interfaces/fundamentals.d.ts +82 -0
- package/dist_ts/stocks/interfaces/fundamentals.js +6 -0
- package/dist_ts/stocks/interfaces/provider.d.ts +2 -3
- package/dist_ts/stocks/interfaces/stockprice.d.ts +2 -10
- package/dist_ts/stocks/interfaces/stockprice.js +2 -2
- package/dist_ts/stocks/providers/provider.marketstack.d.ts +6 -11
- package/dist_ts/stocks/providers/provider.marketstack.js +32 -24
- package/dist_ts/stocks/providers/provider.secedgar.d.ts +102 -0
- package/dist_ts/stocks/providers/provider.secedgar.js +343 -0
- package/dist_ts/stocks/providers/provider.yahoo.d.ts +13 -3
- package/dist_ts/stocks/providers/provider.yahoo.js +27 -4
- package/package.json +1 -1
- package/readme.md +383 -388
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/stocks/classes.fundamentalsservice.ts +404 -0
- package/ts/stocks/classes.stockservice.ts +28 -140
- package/ts/stocks/index.ts +5 -2
- package/ts/stocks/interfaces/fundamentals.ts +102 -0
- package/ts/stocks/interfaces/provider.ts +5 -6
- package/ts/stocks/interfaces/stockprice.ts +4 -19
- package/ts/stocks/providers/provider.marketstack.ts +39 -30
- package/ts/stocks/providers/provider.secedgar.ts +429 -0
- package/ts/stocks/providers/provider.yahoo.ts +33 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interfaces for stock fundamental data (financials from SEC filings)
|
|
3
|
+
* Separate from stock price data (OHLCV) to maintain clean architecture
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Request types for fundamental data
|
|
7
|
+
export interface IFundamentalsCurrentRequest {
|
|
8
|
+
type: 'fundamentals-current';
|
|
9
|
+
ticker: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IFundamentalsBatchRequest {
|
|
13
|
+
type: 'fundamentals-batch';
|
|
14
|
+
tickers: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type IFundamentalsRequest =
|
|
18
|
+
| IFundamentalsCurrentRequest
|
|
19
|
+
| IFundamentalsBatchRequest;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stock fundamental data from SEC filings (10-K, 10-Q)
|
|
23
|
+
* Contains financial metrics like EPS, Revenue, Assets, etc.
|
|
24
|
+
*/
|
|
25
|
+
export interface IStockFundamentals {
|
|
26
|
+
ticker: string;
|
|
27
|
+
cik: string;
|
|
28
|
+
companyName: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
timestamp: Date;
|
|
31
|
+
fetchedAt: Date;
|
|
32
|
+
|
|
33
|
+
// Per-share metrics
|
|
34
|
+
earningsPerShareBasic?: number;
|
|
35
|
+
earningsPerShareDiluted?: number;
|
|
36
|
+
sharesOutstanding?: number;
|
|
37
|
+
weightedAverageSharesOutstanding?: number;
|
|
38
|
+
|
|
39
|
+
// Income statement (annual USD)
|
|
40
|
+
revenue?: number;
|
|
41
|
+
netIncome?: number;
|
|
42
|
+
operatingIncome?: number;
|
|
43
|
+
grossProfit?: number;
|
|
44
|
+
costOfRevenue?: number;
|
|
45
|
+
|
|
46
|
+
// Balance sheet (annual USD)
|
|
47
|
+
assets?: number;
|
|
48
|
+
liabilities?: number;
|
|
49
|
+
stockholdersEquity?: number;
|
|
50
|
+
cash?: number;
|
|
51
|
+
propertyPlantEquipment?: number;
|
|
52
|
+
|
|
53
|
+
// Calculated metrics (requires current price)
|
|
54
|
+
marketCap?: number; // price × sharesOutstanding
|
|
55
|
+
priceToEarnings?: number; // price / EPS
|
|
56
|
+
priceToBook?: number; // marketCap / stockholdersEquity
|
|
57
|
+
|
|
58
|
+
// Metadata
|
|
59
|
+
fiscalYear?: string;
|
|
60
|
+
fiscalQuarter?: string;
|
|
61
|
+
filingDate?: Date;
|
|
62
|
+
form?: '10-K' | '10-Q' | string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Provider interface for fetching fundamental data
|
|
67
|
+
* Parallel to IStockProvider but for fundamentals instead of prices
|
|
68
|
+
*/
|
|
69
|
+
export interface IFundamentalsProvider {
|
|
70
|
+
name: string;
|
|
71
|
+
priority: number;
|
|
72
|
+
fetchData(request: IFundamentalsRequest): Promise<IStockFundamentals | IStockFundamentals[]>;
|
|
73
|
+
isAvailable(): Promise<boolean>;
|
|
74
|
+
readonly requiresAuth: boolean;
|
|
75
|
+
readonly rateLimit?: {
|
|
76
|
+
requestsPerMinute: number;
|
|
77
|
+
requestsPerDay?: number;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Configuration for fundamentals providers
|
|
83
|
+
*/
|
|
84
|
+
export interface IFundamentalsProviderConfig {
|
|
85
|
+
enabled: boolean;
|
|
86
|
+
priority?: number;
|
|
87
|
+
timeout?: number;
|
|
88
|
+
retryAttempts?: number;
|
|
89
|
+
retryDelay?: number;
|
|
90
|
+
cacheTTL?: number; // Custom cache TTL for this provider
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Registry for managing fundamental data providers
|
|
95
|
+
*/
|
|
96
|
+
export interface IFundamentalsProviderRegistry {
|
|
97
|
+
register(provider: IFundamentalsProvider, config?: IFundamentalsProviderConfig): void;
|
|
98
|
+
unregister(providerName: string): void;
|
|
99
|
+
getProvider(name: string): IFundamentalsProvider | undefined;
|
|
100
|
+
getAllProviders(): IFundamentalsProvider[];
|
|
101
|
+
getEnabledProviders(): IFundamentalsProvider[];
|
|
102
|
+
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import type { IStockPrice,
|
|
1
|
+
import type { IStockPrice, IStockDataRequest } from './stockprice.js';
|
|
2
2
|
|
|
3
3
|
export interface IStockProvider {
|
|
4
4
|
name: string;
|
|
5
5
|
priority: number;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]>;
|
|
6
|
+
|
|
7
|
+
fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]>;
|
|
9
8
|
isAvailable(): Promise<boolean>;
|
|
10
|
-
|
|
9
|
+
|
|
11
10
|
supportsMarket?(market: string): boolean;
|
|
12
11
|
supportsTicker?(ticker: string): boolean;
|
|
13
|
-
|
|
12
|
+
|
|
14
13
|
readonly requiresAuth: boolean;
|
|
15
14
|
readonly rateLimit?: {
|
|
16
15
|
requestsPerMinute: number;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import * as plugins from '../../plugins.js';
|
|
2
|
-
|
|
3
1
|
// Enhanced stock price interface with additional OHLCV data
|
|
4
2
|
export interface IStockPrice {
|
|
5
3
|
ticker: string;
|
|
@@ -22,11 +20,11 @@ export interface IStockPrice {
|
|
|
22
20
|
adjusted?: boolean; // If price is split/dividend adjusted
|
|
23
21
|
dataType: 'eod' | 'intraday' | 'live'; // What kind of data this is
|
|
24
22
|
fetchedAt: Date; // When we fetched (vs data timestamp)
|
|
23
|
+
|
|
24
|
+
// Company identification
|
|
25
|
+
companyName?: string; // Company name (e.g., "Apple Inc.")
|
|
26
|
+
companyFullName?: string; // Full company name with exchange (e.g., "Apple Inc. (NASDAQ:AAPL)")
|
|
25
27
|
}
|
|
26
|
-
type CheckStockPrice = plugins.tsclass.typeFest.IsEqual<
|
|
27
|
-
IStockPrice,
|
|
28
|
-
plugins.tsclass.finance.IStockPrice
|
|
29
|
-
>;
|
|
30
28
|
|
|
31
29
|
export interface IStockPriceError {
|
|
32
30
|
ticker: string;
|
|
@@ -94,16 +92,3 @@ export type IStockDataRequest =
|
|
|
94
92
|
| IStockHistoricalRequest
|
|
95
93
|
| IStockIntradayRequest
|
|
96
94
|
| IStockBatchCurrentRequest;
|
|
97
|
-
|
|
98
|
-
// Legacy interfaces (for backward compatibility during migration)
|
|
99
|
-
/** @deprecated Use IStockDataRequest with type: 'current' instead */
|
|
100
|
-
export interface IStockQuoteRequest {
|
|
101
|
-
ticker: string;
|
|
102
|
-
includeExtendedHours?: boolean;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** @deprecated Use IStockDataRequest with type: 'batch' instead */
|
|
106
|
-
export interface IStockBatchQuoteRequest {
|
|
107
|
-
tickers: string[];
|
|
108
|
-
includeExtendedHours?: boolean;
|
|
109
|
-
}
|
|
@@ -2,15 +2,11 @@ import * as plugins from '../../plugins.js';
|
|
|
2
2
|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
|
|
3
3
|
import type {
|
|
4
4
|
IStockPrice,
|
|
5
|
-
IStockQuoteRequest,
|
|
6
|
-
IStockBatchQuoteRequest,
|
|
7
5
|
IStockDataRequest,
|
|
8
6
|
IStockCurrentRequest,
|
|
9
7
|
IStockHistoricalRequest,
|
|
10
8
|
IStockIntradayRequest,
|
|
11
|
-
IStockBatchCurrentRequest
|
|
12
|
-
IPaginatedResponse,
|
|
13
|
-
TSortOrder
|
|
9
|
+
IStockBatchCurrentRequest
|
|
14
10
|
} from '../interfaces/stockprice.js';
|
|
15
11
|
|
|
16
12
|
/**
|
|
@@ -239,30 +235,6 @@ export class MarketstackProvider implements IStockProvider {
|
|
|
239
235
|
}
|
|
240
236
|
}
|
|
241
237
|
|
|
242
|
-
/**
|
|
243
|
-
* Legacy: Fetch latest EOD price for a single ticker
|
|
244
|
-
* @deprecated Use fetchData with IStockDataRequest instead
|
|
245
|
-
*/
|
|
246
|
-
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
|
247
|
-
// Map legacy request to new format
|
|
248
|
-
return this.fetchCurrentPrice({
|
|
249
|
-
type: 'current',
|
|
250
|
-
ticker: request.ticker
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Legacy: Fetch latest EOD prices for multiple tickers
|
|
256
|
-
* @deprecated Use fetchData with IStockDataRequest instead
|
|
257
|
-
*/
|
|
258
|
-
public async fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
|
|
259
|
-
// Map legacy request to new format
|
|
260
|
-
return this.fetchBatchCurrentPrices({
|
|
261
|
-
type: 'batch',
|
|
262
|
-
tickers: request.tickers
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
238
|
/**
|
|
267
239
|
* Check if the Marketstack API is available and accessible
|
|
268
240
|
*/
|
|
@@ -349,12 +321,49 @@ export class MarketstackProvider implements IStockProvider {
|
|
|
349
321
|
low: data.low,
|
|
350
322
|
adjusted: data.adj_close !== undefined, // If adj_close exists, price is adjusted
|
|
351
323
|
dataType: dataType,
|
|
352
|
-
fetchedAt: fetchedAt
|
|
324
|
+
fetchedAt: fetchedAt,
|
|
325
|
+
|
|
326
|
+
// Company identification
|
|
327
|
+
companyName: data.company_name || data.name || undefined,
|
|
328
|
+
companyFullName: this.buildCompanyFullName(data)
|
|
353
329
|
};
|
|
354
330
|
|
|
355
331
|
return stockPrice;
|
|
356
332
|
}
|
|
357
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Build full company name with exchange and ticker information
|
|
336
|
+
* Example: "Apple Inc (NASDAQ:AAPL)"
|
|
337
|
+
*/
|
|
338
|
+
private buildCompanyFullName(data: any): string | undefined {
|
|
339
|
+
// Check if API already provides full name
|
|
340
|
+
if (data.full_name || data.long_name) {
|
|
341
|
+
return data.full_name || data.long_name;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Build from available data
|
|
345
|
+
const companyName = data.company_name || data.name;
|
|
346
|
+
const exchangeCode = data.exchange_code; // e.g., "NASDAQ"
|
|
347
|
+
const symbol = data.symbol; // e.g., "AAPL"
|
|
348
|
+
|
|
349
|
+
if (!companyName) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// If we have exchange and symbol, build full name: "Apple Inc (NASDAQ:AAPL)"
|
|
354
|
+
if (exchangeCode && symbol) {
|
|
355
|
+
return `${companyName} (${exchangeCode}:${symbol})`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// If we only have symbol: "Apple Inc (AAPL)"
|
|
359
|
+
if (symbol) {
|
|
360
|
+
return `${companyName} (${symbol})`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Otherwise just return company name
|
|
364
|
+
return companyName;
|
|
365
|
+
}
|
|
366
|
+
|
|
358
367
|
/**
|
|
359
368
|
* Format date to YYYY-MM-DD for API requests
|
|
360
369
|
*/
|
|
@@ -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
|
+
}
|