BREAKING CHANGE(stocks): Unify stock provider API to discriminated IStockDataRequest and add company name/fullname enrichment
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user