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

@@ -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);