BREAKING CHANGE(stocks): Unify stock provider API to discriminated IStockDataRequest and add company name/fullname enrichment

This commit is contained in:
2025-10-31 15:05:48 +00:00
parent 596be63554
commit ec3e4dde75
9 changed files with 266 additions and 211 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 2025-10-31 - 3.0.0 - BREAKING CHANGE(stocks)
Unify stock provider API to discriminated IStockDataRequest and add company name/fullname enrichment
- Replace legacy provider methods (fetchPrice/fetchPrices) with a single fetchData(request: IStockDataRequest) on IStockProvider — providers must be migrated to the new signature.
- Migrate StockPriceService to the unified getData(request: IStockDataRequest) API. Convenience helpers getPrice/getPrices now wrap getData.
- Add companyName and companyFullName fields to IStockPrice and populate them in provider mappings (Marketstack mapping updated; Yahoo provider updated to support the unified API).
- MarketstackProvider: added buildCompanyFullName helper and improved mapping to include company identification fields and full name formatting.
- YahooFinanceProvider: updated to implement fetchData and to route current/batch requests through the new unified request types; historical/intraday throw explicit errors.
- Updated tests to exercise the new unified API, company-name enrichment, caching behavior, and provider direct methods.
- Note: This is a breaking change for external providers and integrations that implemented the old fetchPrice/fetchPrices API. Bump major version.
## 2025-10-31 - 2.1.0 - feat(stocks)
Add unified stock data API (getData) with historical/OHLCV support, smart caching and provider enhancements

View File

@@ -93,6 +93,8 @@ stockService.register(new MarketstackProvider('YOUR_API_KEY'), {
const apple = await stockService.getData({ type: 'current', ticker: 'AAPL' });
console.log(`Apple: $${apple.price} (${apple.changePercent.toFixed(2)}%)`);
console.log(`OHLCV: O=${apple.open} H=${apple.high} L=${apple.low} V=${apple.volume}`);
console.log(`Company: ${apple.companyName}`); // "Apple Inc"
console.log(`Full: ${apple.companyFullName}`); // "Apple Inc (NASDAQ:AAPL)"
// Get historical data (1 year of daily prices)
const history = await stockService.getData({
@@ -117,11 +119,25 @@ const vodNYSE = await stockService.getData({
exchange: 'XNYS' // New York Stock Exchange
});
// Batch current prices
// Batch current prices with company names
const prices = await stockService.getData({
type: 'batch',
tickers: ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA']
});
// Display with company names (automatically included - zero extra API calls!)
for (const stock of prices) {
console.log(`${stock.companyName}: $${stock.price}`);
// Output:
// Apple Inc: $271.40
// Microsoft Corporation: $525.76
// Alphabet Inc - Class A: $281.48
// Amazon.com Inc: $222.86
// Tesla Inc: $440.10
}
// Use companyFullName for richer context
console.log(prices[0].companyFullName); // "Apple Inc (NASDAQ:AAPL)"
```
### 🏢 German Business Data
@@ -164,6 +180,7 @@ await openData.buildInitialDb();
### 🎯 Stock Market Module (v2.1 Enhanced)
- **Company Names** - Automatic company name extraction with zero extra API calls (e.g., "Apple Inc (NASDAQ:AAPL)")
- **Historical Data** - Up to 15 years of daily EOD prices with automatic pagination
- **Exchange Filtering** - Query specific exchanges via MIC codes (XNAS, XLON, XNYS, etc.)
- **OHLCV Data** - Open, High, Low, Close, Volume for comprehensive analysis

View File

@@ -151,7 +151,7 @@ tap.test('should handle invalid ticker gracefully', async () => {
await stockService.getPrice({ ticker: invalidTicker });
throw new Error('Should have thrown an error for invalid ticker');
} catch (error) {
expect(error.message).toInclude('Failed to fetch price');
expect(error.message).toInclude('Failed to fetch');
console.log('✓ Invalid ticker handled correctly');
}
});
@@ -215,19 +215,20 @@ tap.test('should test direct provider methods', async () => {
expect(available).toEqual(true);
console.log(' ✓ isAvailable() returned true');
// Test fetchPrice directly
const price = await marketstackProvider.fetchPrice({ ticker: 'MSFT' });
// Test fetchData for single ticker
const price = await marketstackProvider.fetchData({ type: 'current', ticker: 'MSFT' }) as opendata.IStockPrice;
expect(price.ticker).toEqual('MSFT');
expect(price.provider).toEqual('Marketstack');
expect(price.price).toBeGreaterThan(0);
console.log(` ✓ fetchPrice() for MSFT: $${price.price}`);
console.log(` ✓ fetchData (current) for MSFT: $${price.price}`);
// Test fetchPrices directly
const prices = await marketstackProvider.fetchPrices({
// Test fetchData for batch
const prices = await marketstackProvider.fetchData({
type: 'batch',
tickers: ['AAPL', 'GOOGL']
});
}) as opendata.IStockPrice[];
expect(prices.length).toBeGreaterThan(0);
console.log(` ✓ fetchPrices() returned ${prices.length} prices`);
console.log(` ✓ fetchData (batch) returned ${prices.length} prices`);
for (const p of prices) {
console.log(` ${p.ticker}: $${p.price}`);
@@ -252,9 +253,10 @@ tap.test('should fetch sample EOD data', async () => {
];
try {
const prices = await marketstackProvider.fetchPrices({
const prices = await marketstackProvider.fetchData({
type: 'batch',
tickers: sampleTickers.map(t => t.ticker)
});
}) as opendata.IStockPrice[];
const priceMap = new Map(prices.map(p => [p.ticker, p]));
@@ -452,4 +454,119 @@ tap.test('should verify smart caching with historical data', async () => {
console.log(`✓ Speed improvement: ${Math.round((duration1 / duration2) * 10) / 10}x faster`);
});
// Company Name Feature Tests
tap.test('should include company name in single price request', async () => {
if (!marketstackProvider) {
console.log('⚠️ Skipping - Marketstack provider not initialized');
return;
}
console.log('\n🏢 Testing Company Name Feature: Single Request');
const price = await stockService.getPrice({ ticker: 'AAPL' });
expect(price.companyName).not.toEqual(undefined);
expect(typeof price.companyName).toEqual('string');
expect(price.companyName).toInclude('Apple');
console.log(`✓ Company name retrieved: "${price.companyName}"`);
console.log(` Ticker: ${price.ticker}`);
console.log(` Price: $${price.price}`);
console.log(` Company: ${price.companyName}`);
});
tap.test('should include company names in batch price request', async () => {
if (!marketstackProvider) {
console.log('⚠️ Skipping - Marketstack provider not initialized');
return;
}
console.log('\n🏢 Testing Company Name Feature: Batch Request');
const prices = await stockService.getPrices({
tickers: ['AAPL', 'MSFT', 'GOOGL']
});
expect(prices).toBeArray();
expect(prices.length).toBeGreaterThan(0);
console.log(`✓ Fetched ${prices.length} prices with company names:`);
for (const price of prices) {
expect(price.companyName).not.toEqual(undefined);
expect(typeof price.companyName).toEqual('string');
console.log(` ${price.ticker.padEnd(6)} - ${price.companyName}`);
}
});
tap.test('should include company name in historical data', async () => {
if (!marketstackProvider) {
console.log('⚠️ Skipping - Marketstack provider not initialized');
return;
}
console.log('\n🏢 Testing Company Name Feature: Historical Data');
const prices = await stockService.getData({
type: 'historical',
ticker: 'TSLA',
from: new Date('2025-10-01'),
to: new Date('2025-10-05')
});
expect(prices).toBeArray();
const historicalPrices = prices as opendata.IStockPrice[];
expect(historicalPrices.length).toBeGreaterThan(0);
// All historical records should have the same company name
for (const price of historicalPrices) {
expect(price.companyName).not.toEqual(undefined);
expect(typeof price.companyName).toEqual('string');
}
const firstPrice = historicalPrices[0];
console.log(`✓ Historical records include company name: "${firstPrice.companyName}"`);
console.log(` Ticker: ${firstPrice.ticker}`);
console.log(` Records: ${historicalPrices.length}`);
console.log(` Date range: ${historicalPrices[historicalPrices.length - 1].timestamp.toISOString().split('T')[0]} to ${firstPrice.timestamp.toISOString().split('T')[0]}`);
});
tap.test('should verify company name is included with zero extra API calls', async () => {
if (!marketstackProvider) {
console.log('⚠️ Skipping - Marketstack provider not initialized');
return;
}
console.log('\n⚡ Testing Company Name Efficiency: Zero Extra API Calls');
// Clear cache to ensure we're making fresh API calls
stockService.clearCache();
// Single request timing
const start1 = Date.now();
const singlePrice = await stockService.getPrice({ ticker: 'AMZN' });
const duration1 = Date.now() - start1;
expect(singlePrice.companyName).not.toEqual(undefined);
// Batch request timing
stockService.clearCache();
const start2 = Date.now();
const batchPrices = await stockService.getPrices({ tickers: ['NVDA', 'AMD', 'INTC'] });
const duration2 = Date.now() - start2;
for (const price of batchPrices) {
expect(price.companyName).not.toEqual(undefined);
}
console.log(`✓ Single request (with company name): ${duration1}ms`);
console.log(`✓ Batch request (with company names): ${duration2}ms`);
console.log(`✓ Company names included in standard EOD response - zero extra calls!`);
console.log(` Single: ${singlePrice.ticker} - "${singlePrice.companyName}"`);
for (const price of batchPrices) {
console.log(` Batch: ${price.ticker} - "${price.companyName}"`);
}
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@fin.cx/opendata',
version: '2.1.0',
version: '3.0.0',
description: 'A comprehensive TypeScript library for accessing business data and real-time financial information. Features include German company data management with MongoDB integration, JSONL bulk processing, automated Handelsregister interactions, and real-time stock market data from multiple providers.'
}

View File

@@ -2,8 +2,6 @@ import * as plugins from '../plugins.js';
import type { IStockProvider, IProviderConfig, IProviderRegistry } from './interfaces/provider.js';
import type {
IStockPrice,
IStockQuoteRequest,
IStockBatchQuoteRequest,
IStockPriceError,
IStockDataRequest,
IStockCurrentRequest,
@@ -13,6 +11,15 @@ import type {
TIntervalType
} from './interfaces/stockprice.js';
// Simple request interfaces for convenience methods
interface ISimpleQuoteRequest {
ticker: string;
}
interface ISimpleBatchRequest {
tickers: string[];
}
interface IProviderEntry {
provider: IStockProvider;
config: IProviderConfig;
@@ -122,132 +129,26 @@ export class StockPriceService implements IProviderRegistry {
.map(entry => entry.provider);
}
public async getPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
const cacheKey = this.getCacheKey(request);
const cached = this.getFromCache(cacheKey) as IStockPrice | null;
if (cached) {
console.log(`Cache hit for ${request.ticker}`);
return cached;
}
const providers = this.getEnabledProviders();
if (providers.length === 0) {
throw new Error('No stock price providers available');
}
let lastError: Error | undefined;
for (const provider of providers) {
const entry = this.providers.get(provider.name)!;
try {
const price = await this.fetchWithRetry(
() => provider.fetchPrice(request),
entry.config
);
entry.successCount++;
// Use smart TTL based on data type
const ttl = this.getCacheTTL(price.dataType);
this.addToCache(cacheKey, price, ttl);
console.log(`Successfully fetched ${request.ticker} from ${provider.name}`);
return price;
} catch (error) {
entry.errorCount++;
entry.lastError = error as Error;
entry.lastErrorTime = new Date();
lastError = error as Error;
console.warn(
`Provider ${provider.name} failed for ${request.ticker}: ${error.message}`
);
}
}
throw new Error(
`Failed to fetch price for ${request.ticker} from all providers. Last error: ${lastError?.message}`
);
/**
* Convenience method: Get current price for a single ticker
*/
public async getPrice(request: ISimpleQuoteRequest): Promise<IStockPrice> {
const result = await this.getData({
type: 'current',
ticker: request.ticker
});
return result as IStockPrice;
}
public async getPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
const cachedPrices: IStockPrice[] = [];
const tickersToFetch: string[] = [];
// Check cache for each ticker
for (const ticker of request.tickers) {
const cacheKey = this.getCacheKey({ ticker, includeExtendedHours: request.includeExtendedHours });
const cached = this.getFromCache(cacheKey) as IStockPrice | null;
if (cached) {
cachedPrices.push(cached);
} else {
tickersToFetch.push(ticker);
}
}
if (tickersToFetch.length === 0) {
console.log(`All ${request.tickers.length} tickers served from cache`);
return cachedPrices;
}
const providers = this.getEnabledProviders();
if (providers.length === 0) {
throw new Error('No stock price providers available');
}
let lastError: Error | undefined;
let fetchedPrices: IStockPrice[] = [];
for (const provider of providers) {
const entry = this.providers.get(provider.name)!;
try {
fetchedPrices = await this.fetchWithRetry(
() => provider.fetchPrices({
tickers: tickersToFetch,
includeExtendedHours: request.includeExtendedHours
}),
entry.config
);
entry.successCount++;
// Cache the fetched prices with smart TTL
for (const price of fetchedPrices) {
const cacheKey = this.getCacheKey({
ticker: price.ticker,
includeExtendedHours: request.includeExtendedHours
});
const ttl = this.getCacheTTL(price.dataType);
this.addToCache(cacheKey, price, ttl);
}
console.log(
`Successfully fetched ${fetchedPrices.length} prices from ${provider.name}`
);
break;
} catch (error) {
entry.errorCount++;
entry.lastError = error as Error;
entry.lastErrorTime = new Date();
lastError = error as Error;
console.warn(
`Provider ${provider.name} failed for batch request: ${error.message}`
);
}
}
if (fetchedPrices.length === 0 && lastError) {
throw new Error(
`Failed to fetch prices from all providers. Last error: ${lastError.message}`
);
}
return [...cachedPrices, ...fetchedPrices];
/**
* Convenience method: Get current prices for multiple tickers
*/
public async getPrices(request: ISimpleBatchRequest): Promise<IStockPrice[]> {
const result = await this.getData({
type: 'batch',
tickers: request.tickers
});
return result as IStockPrice[];
}
/**
@@ -272,15 +173,9 @@ export class StockPriceService implements IProviderRegistry {
for (const provider of providers) {
const entry = this.providers.get(provider.name)!;
// Check if provider supports the new fetchData method
if (typeof (provider as any).fetchData !== 'function') {
console.warn(`Provider ${provider.name} does not support new API, skipping`);
continue;
}
try {
const result = await this.fetchWithRetry(
() => (provider as any).fetchData(request),
() => provider.fetchData(request),
entry.config
) as IStockPrice | IStockPrice[];
@@ -420,13 +315,6 @@ export class StockPriceService implements IProviderRegistry {
throw lastError || new Error('Unknown error during fetch');
}
/**
* Legacy cache key generation
*/
private getCacheKey(request: IStockQuoteRequest): string {
return `${request.ticker}:${request.includeExtendedHours || false}`;
}
/**
* New cache key generation for discriminated union requests
*/

View File

@@ -1,16 +1,15 @@
import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest } from './stockprice.js';
import type { IStockPrice, IStockDataRequest } from './stockprice.js';
export interface IStockProvider {
name: string;
priority: number;
fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice>;
fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]>;
fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]>;
isAvailable(): Promise<boolean>;
supportsMarket?(market: string): boolean;
supportsTicker?(ticker: string): boolean;
readonly requiresAuth: boolean;
readonly rateLimit?: {
requestsPerMinute: number;

View File

@@ -1,5 +1,3 @@
import * as plugins from '../../plugins.js';
// Enhanced stock price interface with additional OHLCV data
export interface IStockPrice {
ticker: string;
@@ -22,11 +20,11 @@ export interface IStockPrice {
adjusted?: boolean; // If price is split/dividend adjusted
dataType: 'eod' | 'intraday' | 'live'; // What kind of data this is
fetchedAt: Date; // When we fetched (vs data timestamp)
// Company identification
companyName?: string; // Company name (e.g., "Apple Inc.")
companyFullName?: string; // Full company name with exchange (e.g., "Apple Inc. (NASDAQ:AAPL)")
}
type CheckStockPrice = plugins.tsclass.typeFest.IsEqual<
IStockPrice,
plugins.tsclass.finance.IStockPrice
>;
export interface IStockPriceError {
ticker: string;
@@ -94,16 +92,3 @@ export type IStockDataRequest =
| IStockHistoricalRequest
| IStockIntradayRequest
| IStockBatchCurrentRequest;
// Legacy interfaces (for backward compatibility during migration)
/** @deprecated Use IStockDataRequest with type: 'current' instead */
export interface IStockQuoteRequest {
ticker: string;
includeExtendedHours?: boolean;
}
/** @deprecated Use IStockDataRequest with type: 'batch' instead */
export interface IStockBatchQuoteRequest {
tickers: string[];
includeExtendedHours?: boolean;
}

View File

@@ -2,15 +2,11 @@ import * as plugins from '../../plugins.js';
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
import type {
IStockPrice,
IStockQuoteRequest,
IStockBatchQuoteRequest,
IStockDataRequest,
IStockCurrentRequest,
IStockHistoricalRequest,
IStockIntradayRequest,
IStockBatchCurrentRequest,
IPaginatedResponse,
TSortOrder
IStockBatchCurrentRequest
} from '../interfaces/stockprice.js';
/**
@@ -239,30 +235,6 @@ export class MarketstackProvider implements IStockProvider {
}
}
/**
* Legacy: Fetch latest EOD price for a single ticker
* @deprecated Use fetchData with IStockDataRequest instead
*/
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
// Map legacy request to new format
return this.fetchCurrentPrice({
type: 'current',
ticker: request.ticker
});
}
/**
* Legacy: Fetch latest EOD prices for multiple tickers
* @deprecated Use fetchData with IStockDataRequest instead
*/
public async fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
// Map legacy request to new format
return this.fetchBatchCurrentPrices({
type: 'batch',
tickers: request.tickers
});
}
/**
* Check if the Marketstack API is available and accessible
*/
@@ -349,12 +321,49 @@ export class MarketstackProvider implements IStockProvider {
low: data.low,
adjusted: data.adj_close !== undefined, // If adj_close exists, price is adjusted
dataType: dataType,
fetchedAt: fetchedAt
fetchedAt: fetchedAt,
// Company identification
companyName: data.company_name || data.name || undefined,
companyFullName: this.buildCompanyFullName(data)
};
return stockPrice;
}
/**
* Build full company name with exchange and ticker information
* Example: "Apple Inc (NASDAQ:AAPL)"
*/
private buildCompanyFullName(data: any): string | undefined {
// Check if API already provides full name
if (data.full_name || data.long_name) {
return data.full_name || data.long_name;
}
// Build from available data
const companyName = data.company_name || data.name;
const exchangeCode = data.exchange_code; // e.g., "NASDAQ"
const symbol = data.symbol; // e.g., "AAPL"
if (!companyName) {
return undefined;
}
// If we have exchange and symbol, build full name: "Apple Inc (NASDAQ:AAPL)"
if (exchangeCode && symbol) {
return `${companyName} (${exchangeCode}:${symbol})`;
}
// If we only have symbol: "Apple Inc (AAPL)"
if (symbol) {
return `${companyName} (${symbol})`;
}
// Otherwise just return company name
return companyName;
}
/**
* Format date to YYYY-MM-DD for API requests
*/

View File

@@ -1,6 +1,11 @@
import * as plugins from '../../plugins.js';
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest } from '../interfaces/stockprice.js';
import type {
IStockPrice,
IStockDataRequest,
IStockCurrentRequest,
IStockBatchCurrentRequest
} from '../interfaces/stockprice.js';
export class YahooFinanceProvider implements IStockProvider {
public name = 'Yahoo Finance';
@@ -17,7 +22,28 @@ export class YahooFinanceProvider implements IStockProvider {
constructor(private config?: IProviderConfig) {}
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
/**
* Unified data fetching method
*/
public async fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
switch (request.type) {
case 'current':
return this.fetchCurrentPrice(request);
case 'batch':
return this.fetchBatchCurrentPrices(request);
case 'historical':
throw new Error('Yahoo Finance provider does not support historical data. Use Marketstack provider instead.');
case 'intraday':
throw new Error('Yahoo Finance provider does not support intraday data yet. Use Marketstack provider instead.');
default:
throw new Error(`Unsupported request type: ${(request as any).type}`);
}
}
/**
* Fetch current price for a single ticker
*/
private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
try {
const url = `${this.baseUrl}/v8/finance/chart/${request.ticker}`;
const response = await plugins.smartrequest.SmartRequest.create()
@@ -64,7 +90,10 @@ export class YahooFinanceProvider implements IStockProvider {
}
}
public async fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
/**
* Fetch batch current prices
*/
private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
try {
const symbols = request.tickers.join(',');
const url = `${this.baseUrl}/v8/finance/spark?symbols=${symbols}&range=1d&interval=5m`;
@@ -123,7 +152,7 @@ export class YahooFinanceProvider implements IStockProvider {
public async isAvailable(): Promise<boolean> {
try {
// Test with a well-known ticker
await this.fetchPrice({ ticker: 'AAPL' });
await this.fetchData({ type: 'current', ticker: 'AAPL' });
return true;
} catch (error) {
console.warn('Yahoo Finance provider is not available:', error);