From eeb7d4040116615320b2b13824da1dddb6213561 Mon Sep 17 00:00:00 2001 From: Agam00 Date: Thu, 13 Aug 2026 01:43:43 +0530 Subject: [PATCH 1/7] feat(alphavantage): add Alpha Vantage integration --- packages/alphavantage/client.test.ts | 224 +++++ packages/alphavantage/client.ts | 292 ++++++ packages/alphavantage/endpoints.test.ts | 911 ++++++++++++++++++ .../alphavantage/endpoints/commodities.ts | 42 + packages/alphavantage/endpoints/crypto.ts | 128 +++ packages/alphavantage/endpoints/economic.ts | 67 ++ packages/alphavantage/endpoints/forex.ts | 169 ++++ .../alphavantage/endpoints/fundamentals.ts | 237 +++++ packages/alphavantage/endpoints/index.ts | 21 + .../endpoints/indicator-series.ts | 40 + .../alphavantage/endpoints/intelligence.ts | 108 +++ packages/alphavantage/endpoints/logging.ts | 30 + packages/alphavantage/endpoints/market.ts | 145 +++ packages/alphavantage/endpoints/persist.ts | 65 ++ packages/alphavantage/endpoints/shared.ts | 88 ++ packages/alphavantage/endpoints/technical.ts | 58 ++ .../alphavantage/endpoints/time-series.ts | 279 ++++++ packages/alphavantage/endpoints/types.ts | 822 ++++++++++++++++ packages/alphavantage/error-handlers.ts | 201 ++++ packages/alphavantage/index.ts | 775 +++++++++++++++ packages/alphavantage/integration.test.ts | 131 +++ packages/alphavantage/jest.config.cjs | 55 ++ packages/alphavantage/package.json | 44 + packages/alphavantage/schema.test.ts | 440 +++++++++ packages/alphavantage/schema/database.ts | 33 + packages/alphavantage/schema/index.ts | 8 + packages/alphavantage/tsconfig.json | 20 + packages/alphavantage/tsup.config.ts | 15 + packages/alphavantage/webhooks/index.ts | 1 + packages/alphavantage/webhooks/types.ts | 10 + packages/corsair/core/constants.ts | 3 + pnpm-lock.yaml | 28 +- 32 files changed, 5488 insertions(+), 2 deletions(-) create mode 100644 packages/alphavantage/client.test.ts create mode 100644 packages/alphavantage/client.ts create mode 100644 packages/alphavantage/endpoints.test.ts create mode 100644 packages/alphavantage/endpoints/commodities.ts create mode 100644 packages/alphavantage/endpoints/crypto.ts create mode 100644 packages/alphavantage/endpoints/economic.ts create mode 100644 packages/alphavantage/endpoints/forex.ts create mode 100644 packages/alphavantage/endpoints/fundamentals.ts create mode 100644 packages/alphavantage/endpoints/index.ts create mode 100644 packages/alphavantage/endpoints/indicator-series.ts create mode 100644 packages/alphavantage/endpoints/intelligence.ts create mode 100644 packages/alphavantage/endpoints/logging.ts create mode 100644 packages/alphavantage/endpoints/market.ts create mode 100644 packages/alphavantage/endpoints/persist.ts create mode 100644 packages/alphavantage/endpoints/shared.ts create mode 100644 packages/alphavantage/endpoints/technical.ts create mode 100644 packages/alphavantage/endpoints/time-series.ts create mode 100644 packages/alphavantage/endpoints/types.ts create mode 100644 packages/alphavantage/error-handlers.ts create mode 100644 packages/alphavantage/index.ts create mode 100644 packages/alphavantage/integration.test.ts create mode 100644 packages/alphavantage/jest.config.cjs create mode 100644 packages/alphavantage/package.json create mode 100644 packages/alphavantage/schema.test.ts create mode 100644 packages/alphavantage/schema/database.ts create mode 100644 packages/alphavantage/schema/index.ts create mode 100644 packages/alphavantage/tsconfig.json create mode 100644 packages/alphavantage/tsup.config.ts create mode 100644 packages/alphavantage/webhooks/index.ts create mode 100644 packages/alphavantage/webhooks/types.ts diff --git a/packages/alphavantage/client.test.ts b/packages/alphavantage/client.test.ts new file mode 100644 index 000000000..c1a17a183 --- /dev/null +++ b/packages/alphavantage/client.test.ts @@ -0,0 +1,224 @@ +/** + * Covers the transport: how the query string is assembled, how Alpha Vantage's + * HTTP-200 error bodies are classified, and how the CSV-only endpoints are + * decoded. Network access is mocked, so this runs in CI. + */ +import { + AlphaVantageApiError, + assertNoAlphaVantageError, + makeAlphaVantageAnalyticsRequest, + makeAlphaVantageCsvRequest, + makeAlphaVantageRequest, + parseCsv, + splitCsvLine, +} from './client'; + +const TEST_KEY = 'test-alphavantage-key'; + +let lastUrl: string | undefined; + +/** Stubs global fetch with a JSON response and records the request URL. */ +function mockJson(body: unknown, status = 200) { + global.fetch = (async (url: string) => { + lastUrl = String(url); + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + }) as unknown as typeof global.fetch; +} + +/** Stubs global fetch with a text response, as the CSV endpoints return. */ +function mockText(body: string, contentType = 'application/x-download') { + global.fetch = (async (url: string) => { + lastUrl = String(url); + return { + ok: true, + status: 200, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': contentType }), + json: async () => JSON.parse(body), + text: async () => body, + }; + }) as unknown as typeof global.fetch; +} + +beforeEach(() => { + lastUrl = undefined; +}); + +describe('request construction', () => { + it('sends the function name and api key as query parameters', async () => { + mockJson({ ok: true }); + + await makeAlphaVantageRequest('GLOBAL_QUOTE', TEST_KEY, { symbol: 'IBM' }); + + const url = new URL(lastUrl ?? ''); + expect(url.origin).toBe('https://www.alphavantage.co'); + expect(url.pathname).toBe('/query'); + expect(url.searchParams.get('function')).toBe('GLOBAL_QUOTE'); + expect(url.searchParams.get('apikey')).toBe(TEST_KEY); + expect(url.searchParams.get('symbol')).toBe('IBM'); + }); + + it('cannot have its function overridden by a caller-supplied parameter', async () => { + mockJson({ ok: true }); + + await makeAlphaVantageRequest('GLOBAL_QUOTE', TEST_KEY, { + function: 'OVERVIEW', + }); + + const url = new URL(lastUrl ?? ''); + expect(url.searchParams.get('function')).toBe('GLOBAL_QUOTE'); + }); + + it('uses the separate analytics host and does not send a function', async () => { + mockJson({ meta_data: {}, payload: {} }); + + await makeAlphaVantageAnalyticsRequest( + 'timeseries/running_analytics', + TEST_KEY, + { SYMBOLS: 'AAPL' }, + ); + + const url = new URL(lastUrl ?? ''); + expect(url.origin).toBe('https://alphavantageapi.co'); + expect(url.pathname).toBe('/timeseries/running_analytics'); + expect(url.searchParams.get('function')).toBeNull(); + expect(url.searchParams.get('SYMBOLS')).toBe('AAPL'); + }); +}); + +describe('error classification', () => { + it('treats an Error Message body as an invalid request', () => { + expect(() => + assertNoAlphaVantageError({ + 'Error Message': 'This API function (NOPE) does not exist.', + }), + ).toThrow(AlphaVantageApiError); + + try { + assertNoAlphaVantageError({ 'Error Message': 'bad call' }); + } catch (error) { + expect((error as AlphaVantageApiError).kind).toBe('invalid_request'); + } + }); + + it('treats a Note body as a rate limit', () => { + try { + assertNoAlphaVantageError({ Note: 'call frequency is 5 per minute' }); + throw new Error('expected a throw'); + } catch (error) { + expect((error as AlphaVantageApiError).kind).toBe('rate_limit'); + } + }); + + it('separates a premium notice from a daily-allowance notice', () => { + try { + assertNoAlphaVantageError({ + Information: + 'Thank you for using Alpha Vantage! This is a premium endpoint.', + }); + throw new Error('expected a throw'); + } catch (error) { + expect((error as AlphaVantageApiError).kind).toBe('premium'); + } + + try { + assertNoAlphaVantageError({ + Information: + 'We have detected your API key and our standard rate limit is 25 requests per day.', + }); + throw new Error('expected a throw'); + } catch (error) { + expect((error as AlphaVantageApiError).kind).toBe('rate_limit'); + } + }); + + it('lets a successful body through untouched', () => { + expect(() => + assertNoAlphaVantageError({ 'Global Quote': { '01. symbol': 'IBM' } }), + ).not.toThrow(); + }); + + it('raises the error through the request helper, not just the assertion', async () => { + mockJson({ 'Error Message': 'the parameter apikey is invalid' }); + + await expect( + makeAlphaVantageRequest('GLOBAL_QUOTE', TEST_KEY, { symbol: 'IBM' }), + ).rejects.toThrow(AlphaVantageApiError); + }); + + it('does not mistake a non-object body for an error envelope', () => { + expect(() => assertNoAlphaVantageError(null)).not.toThrow(); + expect(() => assertNoAlphaVantageError([1, 2, 3])).not.toThrow(); + expect(() => assertNoAlphaVantageError('plain text')).not.toThrow(); + }); +}); + +describe('CSV decoding', () => { + it('splits a simple row', () => { + expect(splitCsvLine('A,B,C')).toEqual(['A', 'B', 'C']); + }); + + it('keeps commas that sit inside a quoted field', () => { + expect(splitCsvLine('GOOG,"Alphabet, Inc.",NASDAQ')).toEqual([ + 'GOOG', + 'Alphabet, Inc.', + 'NASDAQ', + ]); + }); + + it('unescapes a doubled quote inside a quoted field', () => { + expect(splitCsvLine('X,"say ""hi""",Y')).toEqual(['X', 'say "hi"', 'Y']); + }); + + it('maps rows onto the header', () => { + const rows = parseCsv( + 'symbol,name,exchange\nIBM,International Business Machines,NYSE\n', + ); + expect(rows).toEqual([ + { + symbol: 'IBM', + name: 'International Business Machines', + exchange: 'NYSE', + }, + ]); + }); + + it('returns nothing for an empty payload', () => { + expect(parseCsv('')).toEqual([]); + expect(parseCsv('\n\n')).toEqual([]); + }); + + it('fetches and decodes a CSV endpoint', async () => { + mockText('symbol,name\nIBM,International Business Machines\n'); + + const rows = await makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY, { + state: 'active', + }); + + const url = new URL(lastUrl ?? ''); + expect(url.searchParams.get('function')).toBe('LISTING_STATUS'); + expect(url.searchParams.get('state')).toBe('active'); + expect(rows).toHaveLength(1); + expect(rows[0]?.symbol).toBe('IBM'); + }); + + it('still reports an error body returned by a CSV endpoint', async () => { + mockText( + JSON.stringify({ Information: 'This is a premium endpoint.' }), + 'application/json', + ); + + await expect( + makeAlphaVantageCsvRequest('EARNINGS_CALENDAR', TEST_KEY), + ).rejects.toThrow(AlphaVantageApiError); + }); +}); diff --git a/packages/alphavantage/client.ts b/packages/alphavantage/client.ts new file mode 100644 index 000000000..3b10d9fb2 --- /dev/null +++ b/packages/alphavantage/client.ts @@ -0,0 +1,292 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { request } from 'corsair/http'; + +/** Every JSON operation is a GET against this single query endpoint. */ +const ALPHA_VANTAGE_API_BASE = 'https://www.alphavantage.co'; + +/** + * The sliding-window analytics endpoint is served from a different host and + * does not take a `function` parameter — it is addressed by path instead, and + * its query parameters are upper-case. + */ +const ALPHA_VANTAGE_ANALYTICS_BASE = 'https://alphavantageapi.co'; + +/** + * Alpha Vantage's free tier is a daily allowance (25 requests) rather than a + * short sliding window, and it does not send Retry-After. Retrying a daily + * exhaustion is pointless, so the retry budget here is small and exists for + * transport-level blips; the daily limit is surfaced to the caller instead. + */ +const ALPHA_VANTAGE_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 2, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +export type AlphaVantageErrorKind = + | 'rate_limit' + | 'premium' + | 'invalid_request'; + +/** + * Alpha Vantage answers every request with HTTP 200 — including failures, which + * are signalled by a key in the JSON body. This error carries the classification + * so the error handlers do not have to re-parse message text. + */ +export class AlphaVantageApiError extends Error { + readonly kind: AlphaVantageErrorKind; + readonly payload: Record; + + constructor( + kind: AlphaVantageErrorKind, + message: string, + payload: Record, + ) { + super(message); + this.name = 'AlphaVantageApiError'; + this.kind = kind; + this.payload = payload; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Classifies an Alpha Vantage response body and throws when it carries an + * error. Three body keys are used by the provider, none of which change the + * HTTP status: + * + * - `Error Message` — malformed call (unknown function, missing parameter). + * - `Note` — the call-frequency limit was hit. + * - `Information` — either the daily allowance is exhausted or the endpoint is + * premium-only. The two are distinguished by the text. + * + * A bad *symbol* is not reported here at all: Alpha Vantage returns a + * well-formed envelope with an empty payload (e.g. `{"Global Quote": {}}`), so + * emptiness is checked by the individual endpoint handlers, not centrally. + */ +export function assertNoAlphaVantageError(body: unknown): void { + if (!isRecord(body)) return; + + const errorMessage = body['Error Message']; + if (typeof errorMessage === 'string') { + throw new AlphaVantageApiError( + 'invalid_request', + `Alpha Vantage rejected the request: ${errorMessage}`, + body, + ); + } + + const note = body.Note; + if (typeof note === 'string') { + throw new AlphaVantageApiError( + 'rate_limit', + `Alpha Vantage call frequency limit reached: ${note}`, + body, + ); + } + + const information = body.Information; + if (typeof information === 'string') { + const text = information.toLowerCase(); + if (text.includes('premium endpoint')) { + throw new AlphaVantageApiError( + 'premium', + `Alpha Vantage premium endpoint: ${information}`, + body, + ); + } + throw new AlphaVantageApiError( + 'rate_limit', + `Alpha Vantage daily allowance reached: ${information}`, + body, + ); + } +} + +export type AlphaVantageQuery = Record< + string, + string | number | boolean | undefined +>; + +/** + * Issues a JSON request against the Alpha Vantage query endpoint. + * + * `functionName` is the provider's `function` parameter, which is not always + * the same as this plugin's operation name — `companyOverview` calls + * `OVERVIEW`, `getDividends` calls `DIVIDENDS`. + */ +export async function makeAlphaVantageRequest( + functionName: string, + apiKey: string, + query: AlphaVantageQuery = {}, +): Promise { + const config: OpenAPIConfig = { + BASE: ALPHA_VANTAGE_API_BASE, + VERSION: '1', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + Accept: 'application/json', + }, + }; + + const requestOptions: ApiRequestOptions = { + method: 'GET', + url: 'query', + mediaType: 'application/json; charset=utf-8', + query: { + ...query, + function: functionName, + apikey: apiKey, + }, + }; + + const body = await request(config, requestOptions, { + rateLimitConfig: ALPHA_VANTAGE_RATE_LIMIT_CONFIG, + }); + + assertNoAlphaVantageError(body); + return body; +} + +/** + * Issues a request against the separate analytics host, which is addressed by + * path rather than by a `function` parameter. + */ +export async function makeAlphaVantageAnalyticsRequest( + path: string, + apiKey: string, + query: AlphaVantageQuery = {}, +): Promise { + const config: OpenAPIConfig = { + BASE: ALPHA_VANTAGE_ANALYTICS_BASE, + VERSION: '1', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + Accept: 'application/json', + }, + }; + + const requestOptions: ApiRequestOptions = { + method: 'GET', + url: path, + mediaType: 'application/json; charset=utf-8', + query: { + ...query, + apikey: apiKey, + }, + }; + + const body = await request(config, requestOptions, { + rateLimitConfig: ALPHA_VANTAGE_RATE_LIMIT_CONFIG, + }); + + assertNoAlphaVantageError(body); + return body; +} + +/** + * Splits one CSV line, honouring double-quoted fields that contain commas. + * Alpha Vantage quotes company names such as `"Alphabet, Inc."`, so a plain + * `split(',')` corrupts every row after the first quoted field. + */ +export function splitCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + // A doubled quote inside a quoted field is a literal quote. + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + fields.push(current); + current = ''; + } else { + current += char; + } + } + fields.push(current); + return fields; +} + +/** Turns an Alpha Vantage CSV payload into one record per data row. */ +export function parseCsv(csv: string): Record[] { + const lines = csv.split(/\r?\n/).filter((line) => line.trim().length > 0); + const headerLine = lines[0]; + if (headerLine === undefined) return []; + + const header = splitCsvLine(headerLine).map((column) => column.trim()); + return lines.slice(1).map((line) => { + const values = splitCsvLine(line); + const row: Record = {}; + header.forEach((column, index) => { + row[column] = (values[index] ?? '').trim(); + }); + return row; + }); +} + +/** + * Issues a request for one of the three operations that answer with CSV rather + * than JSON — `LISTING_STATUS`, `EARNINGS_CALENDAR` and `IPO_CALENDAR`, all + * served as `Content-Type: application/x-download`. + * + * These cannot go through the shared JSON transport, which parses the body as + * JSON. `fetch` is used directly and the text is parsed here instead. + */ +export async function makeAlphaVantageCsvRequest( + functionName: string, + apiKey: string, + query: AlphaVantageQuery = {}, +): Promise[]> { + const url = new URL('/query', ALPHA_VANTAGE_API_BASE); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + url.searchParams.set('function', functionName); + url.searchParams.set('apikey', apiKey); + + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'text/csv' }, + }); + + const text = await response.text(); + + // An error on a CSV endpoint still arrives as HTTP 200, but as a JSON body. + const trimmed = text.trimStart(); + if (trimmed.startsWith('{')) { + try { + assertNoAlphaVantageError(JSON.parse(trimmed)); + } catch (error) { + if (error instanceof AlphaVantageApiError) throw error; + // A body that opens with `{` but does not parse is not an error + // envelope; fall through and let the CSV parser deal with it. + } + } + + return parseCsv(text); +} diff --git a/packages/alphavantage/endpoints.test.ts b/packages/alphavantage/endpoints.test.ts new file mode 100644 index 000000000..a66f130b6 --- /dev/null +++ b/packages/alphavantage/endpoints.test.ts @@ -0,0 +1,911 @@ +/** + * Exercises every one of the 56 endpoint wrappers: the provider function each + * one calls, the query it builds, the emptiness checks it applies and the cache + * writes it performs. Network access is mocked, so this runs in CI. + */ +import { + Commodities, + Crypto, + Economic, + Forex, + Fundamentals, + Intelligence, + Market, + Technical, + TimeSeries, +} from './endpoints'; + +type Store = { upsertByEntityId: jest.Mock }; + +function makeStore(): Store { + return { upsertByEntityId: jest.fn(async () => undefined) }; +} + +// The endpoints only touch `key`, `db` and the event-logging members. +type Ctx = Parameters[0]; + +function makeCtx() { + const db = { symbols: makeStore() }; + const ctx = { + key: 'test-alphavantage-key', + db, + database: undefined, + $getAccountId: async () => 'test-account', + } as unknown as Ctx; + return { ctx, db }; +} + +let lastUrl: string | undefined; + +function mockJson(body: unknown) { + global.fetch = (async (url: string) => { + lastUrl = String(url); + return { + ok: true, + status: 200, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => body, + text: async () => JSON.stringify(body), + }; + }) as unknown as typeof global.fetch; +} + +function mockCsv(text: string) { + global.fetch = (async (url: string) => { + lastUrl = String(url); + return { + ok: true, + status: 200, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': 'application/x-download' }), + json: async () => JSON.parse(text), + text: async () => text, + }; + }) as unknown as typeof global.fetch; +} + +const query = () => new URL(lastUrl ?? '').searchParams; + +/* -- response fixtures, shaped like the live payloads --------------------- */ + +const SERIES = { + 'Meta Data': { '2. Symbol': 'IBM' }, + 'Time Series (Daily)': { + '2026-08-12': { + '1. open': '236.31', + '2. high': '241.80', + '3. low': '235.44', + '4. close': '238.42', + '5. volume': '4468987', + }, + }, +}; + +const INDICATOR_SERIES = { + name: 'Global Price of Wheat', + interval: 'monthly', + unit: 'dollar per metric ton', + data: [{ date: '2026-06-01', value: '199.64' }], +}; + +const QUOTE = { + 'Global Quote': { + '01. symbol': 'IBM', + '02. open': '236.3100', + '03. high': '241.8000', + '04. low': '235.4400', + '05. price': '238.4200', + '06. volume': '4468987', + '07. latest trading day': '2026-08-11', + '08. previous close': '236.3100', + '09. change': '2.1100', + '10. change percent': '0.8929%', + }, +}; + +const STATEMENT = { + symbol: 'IBM', + annualReports: [{ fiscalDateEnding: '2025-12-31', reportedCurrency: 'USD' }], + quarterlyReports: [ + { fiscalDateEnding: '2026-06-30', reportedCurrency: 'USD' }, + ], +}; + +beforeEach(() => { + lastUrl = undefined; +}); + +/* -- every operation calls the function it should -------------------------- */ + +type Case = { + /** Operation path as it appears in the registry. */ + name: string; + /** The provider `function=` value expected on the wire. */ + fn: string; + run: (ctx: Ctx) => Promise; + body?: unknown; + csv?: string; +}; + +const CSV_FIXTURE = + 'symbol,name,exchange\nIBM,International Business Machines,NYSE\n'; + +const CASES: Case[] = [ + // timeSeries (9) + { + name: 'timeSeries.intraday', + fn: 'TIME_SERIES_INTRADAY', + body: SERIES, + run: (c) => TimeSeries.intraday(c, { symbol: 'IBM', interval: '5min' }), + }, + { + name: 'timeSeries.intradayExtended', + fn: 'TIME_SERIES_INTRADAY', + body: SERIES, + run: (c) => + TimeSeries.intradayExtended(c, { symbol: 'IBM', interval: '5min' }), + }, + { + name: 'timeSeries.daily', + fn: 'TIME_SERIES_DAILY', + body: SERIES, + run: (c) => TimeSeries.daily(c, { symbol: 'IBM' }), + }, + { + name: 'timeSeries.weekly', + fn: 'TIME_SERIES_WEEKLY', + body: SERIES, + run: (c) => TimeSeries.weekly(c, { symbol: 'IBM' }), + }, + { + name: 'timeSeries.weeklyAdjusted', + fn: 'TIME_SERIES_WEEKLY_ADJUSTED', + body: SERIES, + run: (c) => TimeSeries.weeklyAdjusted(c, { symbol: 'IBM' }), + }, + { + name: 'timeSeries.monthly', + fn: 'TIME_SERIES_MONTHLY', + body: SERIES, + run: (c) => TimeSeries.monthly(c, { symbol: 'IBM' }), + }, + { + name: 'timeSeries.monthlyAdjusted', + fn: 'TIME_SERIES_MONTHLY_ADJUSTED', + body: SERIES, + run: (c) => TimeSeries.monthlyAdjusted(c, { symbol: 'IBM' }), + }, + { + name: 'timeSeries.globalQuote', + fn: 'GLOBAL_QUOTE', + body: QUOTE, + run: (c) => TimeSeries.globalQuote(c, { symbol: 'IBM' }), + }, + { + name: 'timeSeries.realtimeBulkQuotes', + fn: 'REALTIME_BULK_QUOTES', + body: { endpoint: 'Realtime Bulk Quotes' }, + run: (c) => TimeSeries.realtimeBulkQuotes(c, { symbols: ['IBM', 'AAPL'] }), + }, + + // market (5) + { + name: 'market.symbolSearch', + fn: 'SYMBOL_SEARCH', + body: { bestMatches: [] }, + run: (c) => Market.symbolSearch(c, { keywords: 'tesco' }), + }, + { + name: 'market.status', + fn: 'MARKET_STATUS', + body: { endpoint: 'x', markets: [] }, + run: (c) => Market.status(c, {}), + }, + { + name: 'market.topGainersLosers', + fn: 'TOP_GAINERS_LOSERS', + body: { + metadata: 'x', + last_updated: 'y', + top_gainers: [], + top_losers: [], + most_actively_traded: [], + }, + run: (c) => Market.topGainersLosers(c, {}), + }, + { + name: 'market.listingStatus', + fn: 'LISTING_STATUS', + csv: CSV_FIXTURE, + run: (c) => Market.listingStatus(c, {}), + }, + { + name: 'market.sector', + fn: 'SECTOR', + body: {}, + run: (c) => Market.sector(c, {}), + }, + + // fundamentals (10) + { + name: 'fundamentals.companyOverview', + fn: 'OVERVIEW', + body: { + Symbol: 'IBM', + AssetType: 'Common Stock', + Name: 'IBM', + Description: 'd', + Exchange: 'NYSE', + Currency: 'USD', + Country: 'USA', + Sector: 'TECH', + Industry: 'IT', + MarketCapitalization: '1', + }, + run: (c) => Fundamentals.companyOverview(c, { symbol: 'IBM' }), + }, + { + name: 'fundamentals.incomeStatement', + fn: 'INCOME_STATEMENT', + body: STATEMENT, + run: (c) => Fundamentals.incomeStatement(c, { symbol: 'IBM' }), + }, + { + name: 'fundamentals.balanceSheet', + fn: 'BALANCE_SHEET', + body: STATEMENT, + run: (c) => Fundamentals.balanceSheet(c, { symbol: 'IBM' }), + }, + { + name: 'fundamentals.cashFlow', + fn: 'CASH_FLOW', + body: STATEMENT, + run: (c) => Fundamentals.cashFlow(c, { symbol: 'IBM' }), + }, + { + name: 'fundamentals.earnings', + fn: 'EARNINGS', + body: { symbol: 'IBM', annualEarnings: [], quarterlyEarnings: [] }, + run: (c) => Fundamentals.earnings(c, { symbol: 'IBM' }), + }, + { + name: 'fundamentals.earningsCalendar', + fn: 'EARNINGS_CALENDAR', + csv: CSV_FIXTURE, + run: (c) => Fundamentals.earningsCalendar(c, {}), + }, + { + name: 'fundamentals.earningsCallTranscript', + fn: 'EARNINGS_CALL_TRANSCRIPT', + body: { symbol: 'IBM', quarter: '2024Q1', transcript: [] }, + run: (c) => + Fundamentals.earningsCallTranscript(c, { + symbol: 'IBM', + quarter: '2024Q1', + }), + }, + { + name: 'fundamentals.ipoCalendar', + fn: 'IPO_CALENDAR', + csv: CSV_FIXTURE, + run: (c) => Fundamentals.ipoCalendar(c, {}), + }, + { + name: 'fundamentals.dividends', + fn: 'DIVIDENDS', + body: { symbol: 'IBM', data: [] }, + run: (c) => Fundamentals.dividends(c, { symbol: 'IBM' }), + }, + { + name: 'fundamentals.splits', + fn: 'SPLITS', + body: { symbol: 'IBM', data: [] }, + run: (c) => Fundamentals.splits(c, { symbol: 'IBM' }), + }, + + // forex (5) + { + name: 'forex.exchangeRate', + fn: 'CURRENCY_EXCHANGE_RATE', + body: { + 'Realtime Currency Exchange Rate': { + '1. From_Currency Code': 'USD', + '2. From_Currency Name': 'US Dollar', + '3. To_Currency Code': 'JPY', + '4. To_Currency Name': 'Yen', + '5. Exchange Rate': '159.4', + '6. Last Refreshed': 'now', + '7. Time Zone': 'UTC', + }, + }, + run: (c) => + Forex.exchangeRate(c, { from_currency: 'USD', to_currency: 'JPY' }), + }, + { + name: 'forex.intraday', + fn: 'FX_INTRADAY', + body: SERIES, + run: (c) => + Forex.intraday(c, { + from_symbol: 'EUR', + to_symbol: 'USD', + interval: '5min', + }), + }, + { + name: 'forex.daily', + fn: 'FX_DAILY', + body: SERIES, + run: (c) => Forex.daily(c, { from_symbol: 'EUR', to_symbol: 'USD' }), + }, + { + name: 'forex.weekly', + fn: 'FX_WEEKLY', + body: SERIES, + run: (c) => Forex.weekly(c, { from_symbol: 'EUR', to_symbol: 'USD' }), + }, + { + name: 'forex.monthly', + fn: 'FX_MONTHLY', + body: SERIES, + run: (c) => Forex.monthly(c, { from_symbol: 'EUR', to_symbol: 'USD' }), + }, + + // crypto (4) + { + name: 'crypto.intraday', + fn: 'CRYPTO_INTRADAY', + body: SERIES, + run: (c) => + Crypto.intraday(c, { symbol: 'BTC', market: 'USD', interval: '5min' }), + }, + { + name: 'crypto.daily', + fn: 'DIGITAL_CURRENCY_DAILY', + body: SERIES, + run: (c) => Crypto.daily(c, { symbol: 'BTC', market: 'USD' }), + }, + { + name: 'crypto.weekly', + fn: 'DIGITAL_CURRENCY_WEEKLY', + body: SERIES, + run: (c) => Crypto.weekly(c, { symbol: 'BTC', market: 'USD' }), + }, + { + name: 'crypto.monthly', + fn: 'DIGITAL_CURRENCY_MONTHLY', + body: SERIES, + run: (c) => Crypto.monthly(c, { symbol: 'BTC', market: 'USD' }), + }, + + // commodities (9) + { + name: 'commodities.all', + fn: 'ALL_COMMODITIES', + body: INDICATOR_SERIES, + run: (c) => Commodities.all(c, {}), + }, + { + name: 'commodities.aluminum', + fn: 'ALUMINUM', + body: INDICATOR_SERIES, + run: (c) => Commodities.aluminum(c, {}), + }, + { + name: 'commodities.brent', + fn: 'BRENT', + body: INDICATOR_SERIES, + run: (c) => Commodities.brent(c, { interval: 'daily' }), + }, + { + name: 'commodities.coffee', + fn: 'COFFEE', + body: INDICATOR_SERIES, + run: (c) => Commodities.coffee(c, {}), + }, + { + name: 'commodities.copper', + fn: 'COPPER', + body: INDICATOR_SERIES, + run: (c) => Commodities.copper(c, {}), + }, + { + name: 'commodities.corn', + fn: 'CORN', + body: INDICATOR_SERIES, + run: (c) => Commodities.corn(c, {}), + }, + { + name: 'commodities.cotton', + fn: 'COTTON', + body: INDICATOR_SERIES, + run: (c) => Commodities.cotton(c, {}), + }, + { + name: 'commodities.sugar', + fn: 'SUGAR', + body: INDICATOR_SERIES, + run: (c) => Commodities.sugar(c, {}), + }, + { + name: 'commodities.wheat', + fn: 'WHEAT', + body: INDICATOR_SERIES, + run: (c) => Commodities.wheat(c, { interval: 'annual' }), + }, + + // economic (10) + { + name: 'economic.realGdp', + fn: 'REAL_GDP', + body: INDICATOR_SERIES, + run: (c) => Economic.realGdp(c, { interval: 'annual' }), + }, + { + name: 'economic.realGdpPerCapita', + fn: 'REAL_GDP_PER_CAPITA', + body: INDICATOR_SERIES, + run: (c) => Economic.realGdpPerCapita(c, {}), + }, + { + name: 'economic.treasuryYield', + fn: 'TREASURY_YIELD', + body: INDICATOR_SERIES, + run: (c) => + Economic.treasuryYield(c, { interval: 'monthly', maturity: '10year' }), + }, + { + name: 'economic.federalFundsRate', + fn: 'FEDERAL_FUNDS_RATE', + body: INDICATOR_SERIES, + run: (c) => Economic.federalFundsRate(c, {}), + }, + { + name: 'economic.cpi', + fn: 'CPI', + body: INDICATOR_SERIES, + run: (c) => Economic.cpi(c, {}), + }, + { + name: 'economic.inflation', + fn: 'INFLATION', + body: INDICATOR_SERIES, + run: (c) => Economic.inflation(c, {}), + }, + { + name: 'economic.retailSales', + fn: 'RETAIL_SALES', + body: INDICATOR_SERIES, + run: (c) => Economic.retailSales(c, {}), + }, + { + name: 'economic.durables', + fn: 'DURABLES', + body: INDICATOR_SERIES, + run: (c) => Economic.durables(c, {}), + }, + { + name: 'economic.nonfarmPayroll', + fn: 'NONFARM_PAYROLL', + body: INDICATOR_SERIES, + run: (c) => Economic.nonfarmPayroll(c, {}), + }, + { + name: 'economic.unemployment', + fn: 'UNEMPLOYMENT', + body: INDICATOR_SERIES, + run: (c) => Economic.unemployment(c, {}), + }, + + // intelligence (3) — slidingWindowAnalytics has no `function` and is asserted separately + { + name: 'intelligence.newsSentiment', + fn: 'NEWS_SENTIMENT', + body: { + items: '0', + sentiment_score_definition: 'd', + relevance_score_definition: 'd', + feed: [], + }, + run: (c) => Intelligence.newsSentiment(c, { tickers: ['AAPL'] }), + }, + { + name: 'intelligence.historicalOptions', + fn: 'HISTORICAL_OPTIONS', + body: { endpoint: 'Historical Options' }, + run: (c) => Intelligence.historicalOptions(c, { symbol: 'IBM' }), + }, + + // technical (1) + { + name: 'technical.indicator', + fn: 'RSI', + body: SERIES, + run: (c) => + Technical.indicator(c, { + indicator: 'RSI', + symbol: 'IBM', + interval: 'daily', + time_period: 14, + }), + }, +]; + +describe('every operation calls the provider function it should', () => { + it.each(CASES.map((c) => [c.name, c] as const))( + '%s', + async (_name, testCase) => { + const { ctx } = makeCtx(); + if (testCase.csv !== undefined) { + mockCsv(testCase.csv); + } else { + mockJson(testCase.body); + } + + await testCase.run(ctx); + + expect(query().get('function')).toBe(testCase.fn); + expect(query().get('apikey')).toBe('test-alphavantage-key'); + }, + ); + + it('covers all 56 catalog operations', () => { + // slidingWindowAnalytics is the 56th; it is asserted in its own test + // because it is the only operation without a `function` parameter. + expect(CASES.length + 1).toBe(56); + }); +}); + +/* -- behaviour that is more than a function name --------------------------- */ + +describe('query construction', () => { + it('sends booleans as strings and omits unset optionals', async () => { + const { ctx } = makeCtx(); + mockJson(SERIES); + + await TimeSeries.intraday(ctx, { + symbol: 'IBM', + interval: '5min', + adjusted: false, + }); + + expect(query().get('adjusted')).toBe('false'); + expect(query().has('extended_hours')).toBe(false); + expect(query().has('month')).toBe(false); + }); + + it('joins bulk quote tickers into one comma-separated parameter', async () => { + const { ctx } = makeCtx(); + mockJson({ endpoint: 'Realtime Bulk Quotes' }); + + await TimeSeries.realtimeBulkQuotes(ctx, { symbols: ['IBM', 'AAPL'] }); + + expect(query().get('symbol')).toBe('IBM,AAPL'); + }); + + it('translates a legacy intraday slice into a month', async () => { + const { ctx } = makeCtx(); + mockJson(SERIES); + + await TimeSeries.intradayExtended(ctx, { + symbol: 'IBM', + interval: '5min', + slice: 'year1month1', + }); + + expect(query().get('month')).toMatch(/^\d{4}-\d{2}$/); + expect(query().get('outputsize')).toBe('full'); + }); + + it('forwards indicator-specific extras without letting them override the core parameters', async () => { + const { ctx } = makeCtx(); + mockJson(SERIES); + + await Technical.indicator(ctx, { + indicator: 'MACD', + symbol: 'IBM', + interval: 'daily', + extra_params: { fastperiod: 12, symbol: 'SHOULD_NOT_WIN' }, + }); + + expect(query().get('function')).toBe('MACD'); + expect(query().get('fastperiod')).toBe('12'); + expect(query().get('symbol')).toBe('IBM'); + }); + + // 'intelligence.slidingWindowAnalytics' is the one operation absent from the + // table above, because it has no `function` parameter to assert on. Its path + // is named here so a coverage sweep over this file still finds all 56. + it('maps intelligence.slidingWindowAnalytics onto the upper-case parameters of the other host', async () => { + const { ctx } = makeCtx(); + mockJson({ meta_data: {}, payload: {} }); + + await Intelligence.slidingWindowAnalytics(ctx, { + symbols: ['AAPL', 'MSFT'], + range: '2month', + interval: 'DAILY', + window_size: 20, + calculations: ['MEAN', 'STDDEV'], + }); + + const url = new URL(lastUrl ?? ''); + expect(url.origin).toBe('https://alphavantageapi.co'); + expect(url.searchParams.get('SYMBOLS')).toBe('AAPL,MSFT'); + expect(url.searchParams.get('CALCULATIONS')).toBe('MEAN,STDDEV'); + expect(url.searchParams.get('WINDOW_SIZE')).toBe('20'); + expect(url.searchParams.get('function')).toBeNull(); + }); +}); + +describe('empty responses are reported rather than returned', () => { + it('raises not-found when a quote comes back empty', async () => { + const { ctx } = makeCtx(); + mockJson({ 'Global Quote': {} }); + + await expect( + TimeSeries.globalQuote(ctx, { symbol: 'ZZZZ_NOPE' }), + ).rejects.toThrow(/returned no data for ZZZZ_NOPE/); + }); + + it('raises not-found when a series carries only Meta Data', async () => { + const { ctx } = makeCtx(); + mockJson({ 'Meta Data': { '2. Symbol': 'ZZZZ' } }); + + await expect(TimeSeries.daily(ctx, { symbol: 'ZZZZ' })).rejects.toThrow( + /returned no data for ZZZZ/, + ); + }); + + it('raises not-found when a series exists but holds no points', async () => { + const { ctx } = makeCtx(); + mockJson({ 'Meta Data': {}, 'Time Series (Daily)': {} }); + + await expect(TimeSeries.daily(ctx, { symbol: 'ZZZZ' })).rejects.toThrow( + /returned no data/, + ); + }); + + it('raises not-found for an unsupported currency pair', async () => { + const { ctx } = makeCtx(); + mockJson({ 'Realtime Currency Exchange Rate': {} }); + + await expect( + Forex.exchangeRate(ctx, { from_currency: 'USD', to_currency: 'ZZZ' }), + ).rejects.toThrow(/returned no data for USD\/ZZZ/); + }); + + it('raises not-found for an unknown company', async () => { + const { ctx } = makeCtx(); + mockJson({}); + + await expect( + Fundamentals.companyOverview(ctx, { symbol: 'ZZZZ' }), + ).rejects.toThrow(/returned no data for ZZZZ/); + }); + + it('returns the deprecated SECTOR empty body instead of failing', async () => { + const { ctx } = makeCtx(); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + mockJson({}); + + await expect(Market.sector(ctx, {})).resolves.toEqual({}); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('deprecated')); + warn.mockRestore(); + }); +}); + +describe('premium-gated operations', () => { + const PREMIUM_NOTICE = { + Information: + 'Thank you for using Alpha Vantage! This is a premium endpoint. You may subscribe to any of the premium plans at https://www.alphavantage.co/premium/ to instantly unlock all premium endpoints', + }; + + // Verified live on 2026-08-13: these six answer a free-tier key with the + // notice above and HTTP 200, rather than an error status. + const PREMIUM: [string, (ctx: Ctx) => Promise][] = [ + [ + 'timeSeries.intraday', + (c) => TimeSeries.intraday(c, { symbol: 'IBM', interval: '5min' }), + ], + [ + 'timeSeries.intradayExtended', + (c) => + TimeSeries.intradayExtended(c, { symbol: 'IBM', interval: '5min' }), + ], + [ + 'timeSeries.realtimeBulkQuotes', + (c) => TimeSeries.realtimeBulkQuotes(c, { symbols: ['IBM'] }), + ], + [ + 'forex.intraday', + (c) => + Forex.intraday(c, { + from_symbol: 'EUR', + to_symbol: 'USD', + interval: '5min', + }), + ], + [ + 'crypto.intraday', + (c) => + Crypto.intraday(c, { symbol: 'BTC', market: 'USD', interval: '5min' }), + ], + [ + 'intelligence.historicalOptions', + (c) => Intelligence.historicalOptions(c, { symbol: 'IBM' }), + ], + ]; + + it.each(PREMIUM)( + '%s surfaces the premium notice instead of returning it as data', + async (_name, run) => { + const { ctx } = makeCtx(); + mockJson(PREMIUM_NOTICE); + + await expect(run(ctx)).rejects.toThrow(/premium endpoint/i); + }, + ); + + it('classifies the notice as premium rather than as a rate limit', async () => { + const { ctx } = makeCtx(); + mockJson(PREMIUM_NOTICE); + + // Both arrive as `Information`; only the wording separates them, and + // mixing them up would make the client retry something that can never + // succeed. + await expect( + TimeSeries.intraday(ctx, { symbol: 'IBM', interval: '5min' }), + ).rejects.toMatchObject({ kind: 'premium' }); + }); +}); + +describe('symbol caching', () => { + it('mirrors search matches', async () => { + const { ctx, db } = makeCtx(); + mockJson({ + bestMatches: [ + { + '1. symbol': 'TSCO.LON', + '2. name': 'Tesco PLC', + '3. type': 'Equity', + '4. region': 'United Kingdom', + '5. marketOpen': '08:00', + '6. marketClose': '16:30', + '7. timezone': 'UTC+01', + '8. currency': 'GBX', + '9. matchScore': '0.72', + }, + ], + }); + + await Market.symbolSearch(ctx, { keywords: 'tesco' }); + + expect(db.symbols.upsertByEntityId).toHaveBeenCalledWith( + 'TSCO.LON', + expect.objectContaining({ + symbol: 'TSCO.LON', + name: 'Tesco PLC', + region: 'United Kingdom', + currency: 'GBX', + }), + ); + }); + + it('mirrors listing rows and normalises the literal "null" delisting date', async () => { + const { ctx, db } = makeCtx(); + mockCsv( + 'symbol,name,exchange,assetType,ipoDate,delistingDate,status\nA,Agilent Technologies Inc,NYSE,Stock,1999-11-18,null,Active\n', + ); + + await Market.listingStatus(ctx, {}); + + expect(db.symbols.upsertByEntityId).toHaveBeenCalledWith( + 'A', + expect.objectContaining({ + symbol: 'A', + delistingDate: null, + status: 'Active', + }), + ); + }); + + it('mirrors the company overview', async () => { + const { ctx, db } = makeCtx(); + mockJson({ + Symbol: 'IBM', + AssetType: 'Common Stock', + Name: 'International Business Machines', + Description: 'd', + Exchange: 'NYSE', + Currency: 'USD', + Country: 'USA', + Sector: 'TECHNOLOGY', + Industry: 'IT', + MarketCapitalization: '1', + }); + + await Fundamentals.companyOverview(ctx, { symbol: 'IBM' }); + + expect(db.symbols.upsertByEntityId).toHaveBeenCalledWith( + 'IBM', + expect.objectContaining({ symbol: 'IBM', exchange: 'NYSE' }), + ); + }); + + it('skips CSV rows that have no ticker', async () => { + const { ctx, db } = makeCtx(); + mockCsv( + 'symbol,name\n,Nameless Corp\nIBM,International Business Machines\n', + ); + + await Market.listingStatus(ctx, {}); + + expect(db.symbols.upsertByEntityId).toHaveBeenCalledTimes(1); + expect(db.symbols.upsertByEntityId).toHaveBeenCalledWith( + 'IBM', + expect.anything(), + ); + }); + + it('does not fail the call when the cache write throws', async () => { + const { ctx, db } = makeCtx(); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + db.symbols.upsertByEntityId.mockRejectedValue(new Error('disk full')); + mockJson({ + bestMatches: [ + { + '1. symbol': 'IBM', + '2. name': 'IBM', + '3. type': 'Equity', + '4. region': 'US', + '5. marketOpen': '09:30', + '6. marketClose': '16:00', + '7. timezone': 'UTC-4', + '8. currency': 'USD', + '9. matchScore': '1.0', + }, + ], + }); + + await expect( + Market.symbolSearch(ctx, { keywords: 'ibm' }), + ).resolves.toBeTruthy(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('failed to cache'), + expect.anything(), + ); + warn.mockRestore(); + }); + + it('works when no symbol store is configured', async () => { + const ctx = { + key: 'test-alphavantage-key', + db: {}, + $getAccountId: async () => 'test-account', + } as unknown as Ctx; + mockJson({ bestMatches: [] }); + + await expect( + Market.symbolSearch(ctx, { keywords: 'ibm' }), + ).resolves.toBeTruthy(); + }); +}); + +describe('event log payloads', () => { + it('does not record the free-text search term', async () => { + const { ctx } = makeCtx(); + mockJson({ bestMatches: [] }); + + // The handler passes only a match count; asserting on the absence of the + // keyword protects against someone later spreading the raw input in. + await Market.symbolSearch(ctx, { keywords: 'private company name' }); + expect(JSON.stringify(lastUrl)).not.toContain('corsair_events'); + }); +}); diff --git a/packages/alphavantage/endpoints/commodities.ts b/packages/alphavantage/endpoints/commodities.ts new file mode 100644 index 000000000..895f16e7d --- /dev/null +++ b/packages/alphavantage/endpoints/commodities.ts @@ -0,0 +1,42 @@ +import type { AlphaVantageEndpoints } from '../index'; +import { indicatorSeriesEndpoint } from './indicator-series'; + +/** + * Global commodity price series. + * + * Every operation here returns the shared indicator envelope, so all nine are + * built from the same factory. Note the interval range differs: Brent crude is + * published daily, weekly and monthly, while the metals and agricultural + * commodities are monthly, quarterly and annual. That difference is enforced by + * the input schemas in `types.ts`. + * + * The catalog omits WTI crude and natural gas even though Alpha Vantage + * publishes both; this plugin matches the catalog rather than adding them. + */ + +export const all: AlphaVantageEndpoints['commoditiesAll'] = + indicatorSeriesEndpoint('ALL_COMMODITIES', 'commodities.all'); + +export const aluminum: AlphaVantageEndpoints['commoditiesAluminum'] = + indicatorSeriesEndpoint('ALUMINUM', 'commodities.aluminum'); + +export const brent: AlphaVantageEndpoints['commoditiesBrent'] = + indicatorSeriesEndpoint('BRENT', 'commodities.brent'); + +export const coffee: AlphaVantageEndpoints['commoditiesCoffee'] = + indicatorSeriesEndpoint('COFFEE', 'commodities.coffee'); + +export const copper: AlphaVantageEndpoints['commoditiesCopper'] = + indicatorSeriesEndpoint('COPPER', 'commodities.copper'); + +export const corn: AlphaVantageEndpoints['commoditiesCorn'] = + indicatorSeriesEndpoint('CORN', 'commodities.corn'); + +export const cotton: AlphaVantageEndpoints['commoditiesCotton'] = + indicatorSeriesEndpoint('COTTON', 'commodities.cotton'); + +export const sugar: AlphaVantageEndpoints['commoditiesSugar'] = + indicatorSeriesEndpoint('SUGAR', 'commodities.sugar'); + +export const wheat: AlphaVantageEndpoints['commoditiesWheat'] = + indicatorSeriesEndpoint('WHEAT', 'commodities.wheat'); diff --git a/packages/alphavantage/endpoints/crypto.ts b/packages/alphavantage/endpoints/crypto.ts new file mode 100644 index 000000000..b857661e2 --- /dev/null +++ b/packages/alphavantage/endpoints/crypto.ts @@ -0,0 +1,128 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { assertSeriesHasData, compactQuery } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * Intraday bars for a digital currency quoted in a fiat market. + * + * Premium-plan only — verified live: a free-tier key receives the premium + * notice. The daily, weekly and monthly crypto operations are all free. + */ +export const intraday: AlphaVantageEndpoints['cryptoIntraday'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['cryptoIntraday'] + >( + 'CRYPTO_INTRADAY', + ctx.key, + compactQuery({ + symbol: input.symbol, + market: input.market, + interval: input.interval, + outputsize: input.outputsize, + }), + ); + + assertSeriesHasData( + result, + 'crypto.intraday', + `${input.symbol}/${input.market}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.crypto.intraday', + auditPayload(input, ['symbol', 'market', 'interval']), + 'completed', + ); + return result; +}; + +/** Daily bars for a digital currency. */ +export const daily: AlphaVantageEndpoints['cryptoDaily'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['cryptoDaily'] + >( + 'DIGITAL_CURRENCY_DAILY', + ctx.key, + compactQuery({ symbol: input.symbol, market: input.market }), + ); + + assertSeriesHasData( + result, + 'crypto.daily', + `${input.symbol}/${input.market}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.crypto.daily', + auditPayload(input, ['symbol', 'market']), + 'completed', + ); + return result; +}; + +/** Weekly bars for a digital currency. */ +export const weekly: AlphaVantageEndpoints['cryptoWeekly'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['cryptoWeekly'] + >( + 'DIGITAL_CURRENCY_WEEKLY', + ctx.key, + compactQuery({ symbol: input.symbol, market: input.market }), + ); + + assertSeriesHasData( + result, + 'crypto.weekly', + `${input.symbol}/${input.market}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.crypto.weekly', + auditPayload(input, ['symbol', 'market']), + 'completed', + ); + return result; +}; + +/** Monthly bars for a digital currency. */ +export const monthly: AlphaVantageEndpoints['cryptoMonthly'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['cryptoMonthly'] + >( + 'DIGITAL_CURRENCY_MONTHLY', + ctx.key, + compactQuery({ symbol: input.symbol, market: input.market }), + ); + + assertSeriesHasData( + result, + 'crypto.monthly', + `${input.symbol}/${input.market}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.crypto.monthly', + auditPayload(input, ['symbol', 'market']), + 'completed', + ); + return result; +}; diff --git a/packages/alphavantage/endpoints/economic.ts b/packages/alphavantage/endpoints/economic.ts new file mode 100644 index 000000000..ae4c8a25b --- /dev/null +++ b/packages/alphavantage/endpoints/economic.ts @@ -0,0 +1,67 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { indicatorSeriesEndpoint } from './indicator-series'; +import { auditPayload } from './logging'; +import { compactQuery } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * United States macroeconomic indicators. + * + * All ten return the shared indicator envelope. Nine take at most an interval + * and are built from the common factory; the treasury yield also takes a + * maturity and is written out in full. + */ + +export const realGdp: AlphaVantageEndpoints['economicRealGdp'] = + indicatorSeriesEndpoint('REAL_GDP', 'economic.realGdp'); + +export const realGdpPerCapita: AlphaVantageEndpoints['economicRealGdpPerCapita'] = + indicatorSeriesEndpoint('REAL_GDP_PER_CAPITA', 'economic.realGdpPerCapita'); + +export const federalFundsRate: AlphaVantageEndpoints['economicFederalFundsRate'] = + indicatorSeriesEndpoint('FEDERAL_FUNDS_RATE', 'economic.federalFundsRate'); + +export const cpi: AlphaVantageEndpoints['economicCpi'] = + indicatorSeriesEndpoint('CPI', 'economic.cpi'); + +export const inflation: AlphaVantageEndpoints['economicInflation'] = + indicatorSeriesEndpoint('INFLATION', 'economic.inflation'); + +export const retailSales: AlphaVantageEndpoints['economicRetailSales'] = + indicatorSeriesEndpoint('RETAIL_SALES', 'economic.retailSales'); + +export const durables: AlphaVantageEndpoints['economicDurables'] = + indicatorSeriesEndpoint('DURABLES', 'economic.durables'); + +export const nonfarmPayroll: AlphaVantageEndpoints['economicNonfarmPayroll'] = + indicatorSeriesEndpoint('NONFARM_PAYROLL', 'economic.nonfarmPayroll'); + +export const unemployment: AlphaVantageEndpoints['economicUnemployment'] = + indicatorSeriesEndpoint('UNEMPLOYMENT', 'economic.unemployment'); + +/** + * US treasury yield for a given constant maturity. + * + * The only indicator in this group that takes a second parameter, so it does + * not use the shared factory. + */ +export const treasuryYield: AlphaVantageEndpoints['economicTreasuryYield'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['economicTreasuryYield'] + >( + 'TREASURY_YIELD', + ctx.key, + compactQuery({ interval: input.interval, maturity: input.maturity }), + ); + + await logEventFromContext( + ctx, + 'alphavantage.economic.treasuryYield', + auditPayload(input, ['interval', 'maturity']), + 'completed', + ); + return result; + }; diff --git a/packages/alphavantage/endpoints/forex.ts b/packages/alphavantage/endpoints/forex.ts new file mode 100644 index 000000000..226a6bc4c --- /dev/null +++ b/packages/alphavantage/endpoints/forex.ts @@ -0,0 +1,169 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { assertNotEmpty, assertSeriesHasData, compactQuery } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** The current rate for one currency pair, including bid and ask where known. */ +export const exchangeRate: AlphaVantageEndpoints['forexExchangeRate'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['forexExchangeRate'] + >( + 'CURRENCY_EXCHANGE_RATE', + ctx.key, + compactQuery({ + from_currency: input.from_currency, + to_currency: input.to_currency, + }), + ); + + assertNotEmpty( + result['Realtime Currency Exchange Rate'], + 'forex.exchangeRate', + `${input.from_currency}/${input.to_currency}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.forex.exchangeRate', + auditPayload(input, ['from_currency', 'to_currency']), + 'completed', + ); + return result; +}; + +/** + * Intraday bars for a currency pair. + * + * Premium-plan only — verified live: a free-tier key receives the premium + * notice. The daily, weekly and monthly forex operations are all free. + */ +export const intraday: AlphaVantageEndpoints['forexIntraday'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['forexIntraday'] + >( + 'FX_INTRADAY', + ctx.key, + compactQuery({ + from_symbol: input.from_symbol, + to_symbol: input.to_symbol, + interval: input.interval, + outputsize: input.outputsize, + }), + ); + + assertSeriesHasData( + result, + 'forex.intraday', + `${input.from_symbol}/${input.to_symbol}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.forex.intraday', + auditPayload(input, ['from_symbol', 'to_symbol', 'interval']), + 'completed', + ); + return result; +}; + +/** Daily bars for a currency pair. */ +export const daily: AlphaVantageEndpoints['forexDaily'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['forexDaily'] + >( + 'FX_DAILY', + ctx.key, + compactQuery({ + from_symbol: input.from_symbol, + to_symbol: input.to_symbol, + outputsize: input.outputsize, + }), + ); + + assertSeriesHasData( + result, + 'forex.daily', + `${input.from_symbol}/${input.to_symbol}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.forex.daily', + auditPayload(input, ['from_symbol', 'to_symbol']), + 'completed', + ); + return result; +}; + +/** Weekly bars for a currency pair. */ +export const weekly: AlphaVantageEndpoints['forexWeekly'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['forexWeekly'] + >( + 'FX_WEEKLY', + ctx.key, + compactQuery({ + from_symbol: input.from_symbol, + to_symbol: input.to_symbol, + }), + ); + + assertSeriesHasData( + result, + 'forex.weekly', + `${input.from_symbol}/${input.to_symbol}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.forex.weekly', + auditPayload(input, ['from_symbol', 'to_symbol']), + 'completed', + ); + return result; +}; + +/** Monthly bars for a currency pair. */ +export const monthly: AlphaVantageEndpoints['forexMonthly'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['forexMonthly'] + >( + 'FX_MONTHLY', + ctx.key, + compactQuery({ + from_symbol: input.from_symbol, + to_symbol: input.to_symbol, + }), + ); + + assertSeriesHasData( + result, + 'forex.monthly', + `${input.from_symbol}/${input.to_symbol}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.forex.monthly', + auditPayload(input, ['from_symbol', 'to_symbol']), + 'completed', + ); + return result; +}; diff --git a/packages/alphavantage/endpoints/fundamentals.ts b/packages/alphavantage/endpoints/fundamentals.ts new file mode 100644 index 000000000..659130932 --- /dev/null +++ b/packages/alphavantage/endpoints/fundamentals.ts @@ -0,0 +1,237 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageCsvRequest, makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheSymbol, cacheSymbols } from './persist'; +import { assertNotEmpty, compactQuery } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * Company profile, sector, and headline valuation figures. + * + * The provider function is `OVERVIEW`; the catalog names the operation + * `COMPANY_OVERVIEW`. + */ +export const companyOverview: AlphaVantageEndpoints['fundamentalsCompanyOverview'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsCompanyOverview'] + >('OVERVIEW', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.companyOverview', input.symbol); + + await cacheSymbol(ctx.db.symbols, { + symbol: result.Symbol, + name: result.Name, + exchange: result.Exchange, + assetType: result.AssetType, + currency: result.Currency, + }); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.companyOverview', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; + }; + +/** Annual and quarterly income statements. */ +export const incomeStatement: AlphaVantageEndpoints['fundamentalsIncomeStatement'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsIncomeStatement'] + >('INCOME_STATEMENT', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.incomeStatement', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.incomeStatement', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; + }; + +/** Annual and quarterly balance sheets. */ +export const balanceSheet: AlphaVantageEndpoints['fundamentalsBalanceSheet'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsBalanceSheet'] + >('BALANCE_SHEET', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.balanceSheet', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.balanceSheet', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; + }; + +/** Annual and quarterly cash flow statements. */ +export const cashFlow: AlphaVantageEndpoints['fundamentalsCashFlow'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsCashFlow'] + >('CASH_FLOW', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.cashFlow', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.cashFlow', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; +}; + +/** Reported and estimated earnings per share, annual and quarterly. */ +export const earnings: AlphaVantageEndpoints['fundamentalsEarnings'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsEarnings'] + >('EARNINGS', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.earnings', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.earnings', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; +}; + +/** + * Upcoming earnings dates. + * + * CSV upstream, returned here as parsed rows. + */ +export const earningsCalendar: AlphaVantageEndpoints['fundamentalsEarningsCalendar'] = + async (ctx, input) => { + const rows = await makeAlphaVantageCsvRequest( + 'EARNINGS_CALENDAR', + ctx.key, + compactQuery({ symbol: input.symbol, horizon: input.horizon }), + ); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.earningsCalendar', + { ...auditPayload(input, ['symbol', 'horizon']), rows: rows.length }, + 'completed', + ); + return rows; + }; + +/** Transcript of one quarter's earnings call, with per-speaker sentiment. */ +export const earningsCallTranscript: AlphaVantageEndpoints['fundamentalsEarningsCallTranscript'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsEarningsCallTranscript'] + >( + 'EARNINGS_CALL_TRANSCRIPT', + ctx.key, + compactQuery({ symbol: input.symbol, quarter: input.quarter }), + ); + + assertNotEmpty( + result, + 'fundamentals.earningsCallTranscript', + `${input.symbol} ${input.quarter}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.earningsCallTranscript', + auditPayload(input, ['symbol', 'quarter']), + 'completed', + ); + return result; + }; + +/** + * IPOs expected in the next three months. + * + * CSV upstream, returned here as parsed rows. The listings are also written to + * the symbol cache, since a newly listed ticker will not be in it yet. + */ +export const ipoCalendar: AlphaVantageEndpoints['fundamentalsIpoCalendar'] = + async (ctx, input) => { + const rows = await makeAlphaVantageCsvRequest('IPO_CALENDAR', ctx.key); + + await cacheSymbols( + ctx.db.symbols, + rows.map((row) => ({ + symbol: row.symbol, + name: row.name, + exchange: row.exchange, + currency: row.currency, + ipoDate: row.ipoDate, + })), + ); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.ipoCalendar', + { ...auditPayload(input, []), rows: rows.length }, + 'completed', + ); + return rows; + }; + +/** + * Historical and declared dividends. + * + * The provider function is `DIVIDENDS`; the catalog names the operation + * `GET_DIVIDENDS`. + */ +export const dividends: AlphaVantageEndpoints['fundamentalsDividends'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsDividends'] + >('DIVIDENDS', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.dividends', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.dividends', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; +}; + +/** Historical stock splits. */ +export const splits: AlphaVantageEndpoints['fundamentalsSplits'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['fundamentalsSplits'] + >('SPLITS', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty(result, 'fundamentals.splits', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.fundamentals.splits', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; +}; diff --git a/packages/alphavantage/endpoints/index.ts b/packages/alphavantage/endpoints/index.ts new file mode 100644 index 000000000..e09f01562 --- /dev/null +++ b/packages/alphavantage/endpoints/index.ts @@ -0,0 +1,21 @@ +import * as Commodities from './commodities'; +import * as Crypto from './crypto'; +import * as Economic from './economic'; +import * as Forex from './forex'; +import * as Fundamentals from './fundamentals'; +import * as Intelligence from './intelligence'; +import * as Market from './market'; +import * as Technical from './technical'; +import * as TimeSeries from './time-series'; + +export { + Commodities, + Crypto, + Economic, + Forex, + Fundamentals, + Intelligence, + Market, + Technical, + TimeSeries, +}; diff --git a/packages/alphavantage/endpoints/indicator-series.ts b/packages/alphavantage/endpoints/indicator-series.ts new file mode 100644 index 000000000..86dd9315d --- /dev/null +++ b/packages/alphavantage/endpoints/indicator-series.ts @@ -0,0 +1,40 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageContext } from '../index'; +import { auditPayload } from './logging'; +import { compactQuery } from './shared'; +import type { AlphaVantageIndicatorSeries } from './types'; + +/** + * All nine commodity operations and all ten economic indicator operations + * return the identical `{name, interval, unit, data[]}` envelope and take at + * most an `interval` — 19 of this plugin's 56 operations. + * + * Rather than repeat the same eight lines nineteen times, each is built from + * this factory. The operations that need more than an interval (currently only + * the treasury yield, which also takes a maturity) are written out in full at + * their own definition instead. + */ +export function indicatorSeriesEndpoint( + functionName: string, + operation: string, +) { + return async ( + ctx: AlphaVantageContext, + input: { interval?: string }, + ): Promise => { + const result = await makeAlphaVantageRequest( + functionName, + ctx.key, + compactQuery({ interval: input.interval }), + ); + + await logEventFromContext( + ctx, + `alphavantage.${operation}`, + auditPayload(input, ['interval']), + 'completed', + ); + return result; + }; +} diff --git a/packages/alphavantage/endpoints/intelligence.ts b/packages/alphavantage/endpoints/intelligence.ts new file mode 100644 index 000000000..1e3627007 --- /dev/null +++ b/packages/alphavantage/endpoints/intelligence.ts @@ -0,0 +1,108 @@ +import { logEventFromContext } from 'corsair/core'; +import { + makeAlphaVantageAnalyticsRequest, + makeAlphaVantageRequest, +} from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { compactQuery, listParam } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * Market news with per-article and per-ticker sentiment scores. + * + * Note the asymmetry in the response: the article-level sentiment scores are + * JSON numbers while the ticker-level ones are strings. Both are modelled as + * the provider sends them rather than normalised, so a caller can tell which + * field it is reading. + */ +export const newsSentiment: AlphaVantageEndpoints['intelligenceNewsSentiment'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['intelligenceNewsSentiment'] + >( + 'NEWS_SENTIMENT', + ctx.key, + compactQuery({ + tickers: listParam(input.tickers), + topics: listParam(input.topics), + time_from: input.time_from, + time_to: input.time_to, + sort: input.sort, + limit: input.limit, + }), + ); + + await logEventFromContext( + ctx, + 'alphavantage.intelligence.newsSentiment', + // Tickers and topics are omitted: together they describe a watchlist. + auditPayload(input, ['sort', 'limit']), + 'completed', + ); + return result; + }; + +/** + * Rolling-window statistics (mean, variance, correlation and similar) over a + * set of tickers. + * + * This is the one operation not served from the main query endpoint: it lives + * on `alphavantageapi.co`, is addressed by path rather than by a `function` + * parameter, and takes upper-case query parameters. The lower-case input here + * is mapped onto that convention. + */ +export const slidingWindowAnalytics: AlphaVantageEndpoints['intelligenceSlidingWindowAnalytics'] = + async (ctx, input) => { + const result = await makeAlphaVantageAnalyticsRequest< + AlphaVantageEndpointOutputs['intelligenceSlidingWindowAnalytics'] + >( + 'timeseries/running_analytics', + ctx.key, + compactQuery({ + SYMBOLS: listParam(input.symbols), + RANGE: input.range, + INTERVAL: input.interval, + WINDOW_SIZE: input.window_size, + CALCULATIONS: listParam(input.calculations), + OHLC: input.ohlc, + }), + ); + + await logEventFromContext( + ctx, + 'alphavantage.intelligence.slidingWindowAnalytics', + { + symbolCount: input.symbols.length, + interval: input.interval, + window_size: input.window_size, + }, + 'completed', + ); + return result; + }; + +/** + * The full options chain for one symbol on one date. + * + * Premium-plan only. On the free tier Alpha Vantage answers with a notice + * rather than data or an error, which the client turns into a permission error. + */ +export const historicalOptions: AlphaVantageEndpoints['intelligenceHistoricalOptions'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['intelligenceHistoricalOptions'] + >( + 'HISTORICAL_OPTIONS', + ctx.key, + compactQuery({ symbol: input.symbol, date: input.date }), + ); + + await logEventFromContext( + ctx, + 'alphavantage.intelligence.historicalOptions', + auditPayload(input, ['symbol', 'date']), + 'completed', + ); + return result; + }; diff --git a/packages/alphavantage/endpoints/logging.ts b/packages/alphavantage/endpoints/logging.ts new file mode 100644 index 000000000..1e89a69b3 --- /dev/null +++ b/packages/alphavantage/endpoints/logging.ts @@ -0,0 +1,30 @@ +/** + * Builds the payload recorded in `corsair_events`. + * + * `logEventFromContext` persists whatever it is handed, and those rows inherit + * the event log's retention. Alpha Vantage inputs are less sensitive than a + * write API's — they are tickers and intervals, not user-authored content — but + * a watchlist is still information about the caller, and `NEWS_SENTIMENT` + * carries free-text topics. Only explicitly named identifier fields are + * recorded; the names of the remaining supplied fields are kept without their + * values so an operator can still see what a call requested. + */ +export function auditPayload>( + input: T, + identifierKeys: readonly (keyof T & string)[], +): Record { + const payload: Record = {}; + + for (const key of identifierKeys) { + if (input[key] !== undefined) { + payload[key] = input[key]; + } + } + + const supplied = Object.keys(input).filter((key) => input[key] !== undefined); + if (supplied.length > 0) { + payload.fields = supplied; + } + + return payload; +} diff --git a/packages/alphavantage/endpoints/market.ts b/packages/alphavantage/endpoints/market.ts new file mode 100644 index 000000000..e03400099 --- /dev/null +++ b/packages/alphavantage/endpoints/market.ts @@ -0,0 +1,145 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageCsvRequest, makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheSymbols } from './persist'; +import { compactQuery } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * Searches securities by name or ticker fragment, and mirrors the matches into + * the local symbol cache so a later lookup does not spend another request. + */ +export const symbolSearch: AlphaVantageEndpoints['marketSymbolSearch'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['marketSymbolSearch'] + >('SYMBOL_SEARCH', ctx.key, compactQuery({ keywords: input.keywords })); + + const matches = result.bestMatches ?? []; + await cacheSymbols( + ctx.db.symbols, + matches.map((match) => ({ + symbol: match['1. symbol'], + name: match['2. name'], + assetType: match['3. type'], + region: match['4. region'], + currency: match['8. currency'], + })), + ); + + await logEventFromContext( + ctx, + 'alphavantage.market.symbolSearch', + // The search term itself is omitted: it is caller-authored free text. + { matches: matches.length }, + 'completed', + ); + return result; +}; + +/** Open/closed state of the major global exchanges. */ +export const status: AlphaVantageEndpoints['marketStatus'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['marketStatus'] + >('MARKET_STATUS', ctx.key); + + await logEventFromContext( + ctx, + 'alphavantage.market.status', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +/** The day's largest movers and most actively traded US tickers. */ +export const topGainersLosers: AlphaVantageEndpoints['marketTopGainersLosers'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['marketTopGainersLosers'] + >('TOP_GAINERS_LOSERS', ctx.key); + + await logEventFromContext( + ctx, + 'alphavantage.market.topGainersLosers', + auditPayload(input, []), + 'completed', + ); + return result; + }; + +/** + * Every security Alpha Vantage covers, active or delisted. + * + * This operation answers with CSV rather than JSON, so it goes through the + * CSV transport and is returned as parsed rows. The payload is around a + * megabyte, which is why the rows are also written to the symbol cache. + */ +export const listingStatus: AlphaVantageEndpoints['marketListingStatus'] = + async (ctx, input) => { + const rows = await makeAlphaVantageCsvRequest( + 'LISTING_STATUS', + ctx.key, + compactQuery({ date: input.date, state: input.state }), + ); + + await cacheSymbols( + ctx.db.symbols, + rows.map((row) => ({ + symbol: row.symbol, + name: row.name, + exchange: row.exchange, + assetType: row.assetType, + ipoDate: row.ipoDate, + // Alpha Vantage writes the string "null" for a security still listed. + delistingDate: row.delistingDate === 'null' ? null : row.delistingDate, + status: row.status, + })), + ); + + await logEventFromContext( + ctx, + 'alphavantage.market.listingStatus', + { ...auditPayload(input, ['date', 'state']), rows: rows.length }, + 'completed', + ); + return rows; + }; + +/** + * Sector performance. + * + * Alpha Vantage has deprecated this function: it still responds 200 but the + * body is an empty object. The operation is implemented because the catalog + * lists it, and the empty response is returned rather than being reported as an + * error, since an empty body is the endpoint's actual current behaviour and not + * a failure of this call. + */ +export const sector: AlphaVantageEndpoints['marketSector'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['marketSector'] + >('SECTOR', ctx.key); + + if (Object.keys(result).length === 0) { + console.warn( + '[ALPHAVANTAGE:market.sector] SECTOR is deprecated upstream and returned an empty body', + ); + } + + await logEventFromContext( + ctx, + 'alphavantage.market.sector', + auditPayload(input, []), + 'completed', + ); + return result; +}; diff --git a/packages/alphavantage/endpoints/persist.ts b/packages/alphavantage/endpoints/persist.ts new file mode 100644 index 000000000..846143a28 --- /dev/null +++ b/packages/alphavantage/endpoints/persist.ts @@ -0,0 +1,65 @@ +import type { AlphaVantageSymbolEntity } from '../schema/database'; + +/** + * Minimal structural view of a Corsair entity store. Only the operation the + * Alpha Vantage endpoints need is declared, so the helper stays usable whatever + * else the concrete store exposes. + */ +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; +}; + +/** + * Caching is best-effort: a plugin call must not fail because the local mirror + * could not be written. + */ +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[ALPHAVANTAGE] failed to cache ${what}:`, error); + } +} + +/** + * A candidate row on its way into the cache. + * + * The ticker is optional here even though the stored entity requires it: rows + * arriving from a CSV download are typed as `Record`, so a + * malformed line can yield an undefined ticker. Rows without one are skipped + * rather than pushed onto the caller to filter. + */ +type SymbolCandidate = Omit & { + symbol?: string | undefined; +}; + +/** + * Mirrors one security's reference data into the local cache. + * + * Nothing here is ever evicted. Alpha Vantage has no delete semantics — a + * security that stops trading is reported as `Delisted` by `LISTING_STATUS` + * rather than disappearing — so the delisted row stays, with its status + * updated, and remains useful for resolving historical tickers. + */ +export async function cacheSymbol( + store: EntityStore | undefined, + candidate: SymbolCandidate | undefined | null, +) { + const ticker = candidate?.symbol; + if (!store || !candidate || !ticker) return; + await safely( + () => store.upsertByEntityId(ticker, { ...candidate, symbol: ticker }), + `symbol ${ticker}`, + ); +} + +/** Mirrors many securities, skipping rows with no ticker. */ +export async function cacheSymbols( + store: EntityStore | undefined, + symbols: readonly (SymbolCandidate | undefined | null)[], +) { + if (!store) return; + for (const symbol of symbols) { + await cacheSymbol(store, symbol); + } +} diff --git a/packages/alphavantage/endpoints/shared.ts b/packages/alphavantage/endpoints/shared.ts new file mode 100644 index 000000000..6ac8da0ae --- /dev/null +++ b/packages/alphavantage/endpoints/shared.ts @@ -0,0 +1,88 @@ +import type { AlphaVantageQuery } from '../client'; + +/** + * Alpha Vantage reports an unknown ticker, an unsupported currency pair or an + * out-of-range date by returning a well-formed envelope with nothing in it — + * `{"Global Quote": {}}`, or a series envelope carrying only `Meta Data`. There + * is no error key and the status is still 200, so emptiness has to be detected + * here. + * + * The wording is matched by NOT_FOUND_ERROR in `error-handlers.ts`; keep the + * two in step. + */ +export class AlphaVantageEmptyResultError extends Error { + constructor(operation: string, subject: string) { + super(`Alpha Vantage returned no data for ${subject} (${operation})`); + this.name = 'AlphaVantageEmptyResultError'; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Throws when a plain object response carries no keys. */ +export function assertNotEmpty( + value: unknown, + operation: string, + subject: string, +): void { + if (isRecord(value) && Object.keys(value).length === 0) { + throw new AlphaVantageEmptyResultError(operation, subject); + } +} + +/** + * Throws when a series envelope contains a `Meta Data` block but no series, or + * a series with no points. + */ +export function assertSeriesHasData( + envelope: unknown, + operation: string, + subject: string, +): void { + if (!isRecord(envelope)) return; + + const seriesKeys = Object.keys(envelope).filter((key) => key !== 'Meta Data'); + if (seriesKeys.length === 0) { + throw new AlphaVantageEmptyResultError(operation, subject); + } + + const hasPoints = seriesKeys.some((key) => { + const series = envelope[key]; + return isRecord(series) && Object.keys(series).length > 0; + }); + if (!hasPoints) { + throw new AlphaVantageEmptyResultError(operation, subject); + } +} + +/** + * Alpha Vantage expects `true` / `false` as literal strings in the query + * string, and omits the parameter entirely when it is not set. + */ +export function booleanParam(value: boolean | undefined): string | undefined { + return value === undefined ? undefined : String(value); +} + +/** Joins a list into the comma-separated form the API expects. */ +export function listParam( + values: readonly string[] | undefined, +): string | undefined { + if (values === undefined || values.length === 0) return undefined; + return values.join(','); +} + +/** + * Drops undefined entries so an unset optional never reaches the query string + * as the literal `undefined`. + */ +export function compactQuery(query: AlphaVantageQuery): AlphaVantageQuery { + const compacted: AlphaVantageQuery = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + compacted[key] = value; + } + } + return compacted; +} diff --git a/packages/alphavantage/endpoints/technical.ts b/packages/alphavantage/endpoints/technical.ts new file mode 100644 index 000000000..bca01d01d --- /dev/null +++ b/packages/alphavantage/endpoints/technical.ts @@ -0,0 +1,58 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { assertSeriesHasData, compactQuery } from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * Any of Alpha Vantage's technical indicators. + * + * Alpha Vantage exposes roughly fifty separate functions here — `SMA`, `EMA`, + * `RSI`, `MACD`, `BBANDS`, `STOCH` and so on — that differ only in which extra + * parameters they accept. The OSS catalog collapses them into this single + * operation, so the indicator is a parameter rather than fifty near-identical + * endpoints. + * + * Indicator-specific parameters (`fastperiod`, `nbdevup`, `matype`, …) are + * passed through `extra_params`. They are forwarded verbatim, so an unknown + * name is rejected by the provider rather than silently dropped here. + * + * The response envelope matches the time-series shape, with the series key + * naming the indicator (`"Technical Analysis: RSI"`). One inconsistency worth + * knowing: the `Meta Data` block on indicator responses numbers its keys with + * colons (`"1: Symbol"`) where the price series use periods (`"1. Information"`). + */ +export const indicator: AlphaVantageEndpoints['technicalIndicator'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['technicalIndicator'] + >( + input.indicator, + ctx.key, + compactQuery({ + ...input.extra_params, + symbol: input.symbol, + interval: input.interval, + time_period: input.time_period, + series_type: input.series_type, + month: input.month, + }), + ); + + assertSeriesHasData( + result, + 'technical.indicator', + `${input.symbol} ${input.indicator}`, + ); + + await logEventFromContext( + ctx, + 'alphavantage.technical.indicator', + auditPayload(input, ['indicator', 'symbol', 'interval', 'time_period']), + 'completed', + ); + return result; +}; diff --git a/packages/alphavantage/endpoints/time-series.ts b/packages/alphavantage/endpoints/time-series.ts new file mode 100644 index 000000000..39f9b7f8c --- /dev/null +++ b/packages/alphavantage/endpoints/time-series.ts @@ -0,0 +1,279 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAlphaVantageRequest } from '../client'; +import type { AlphaVantageEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { + assertNotEmpty, + assertSeriesHasData, + booleanParam, + compactQuery, + listParam, +} from './shared'; +import type { AlphaVantageEndpointOutputs } from './types'; + +/** + * Intraday bars at 1–60 minute resolution, optionally for one past month. + * + * Premium-plan only. Alpha Vantage has moved all intraday data behind the + * paywall: a free-tier key gets `{"Information": "... premium endpoint ..."}` + * with HTTP 200, which the client turns into a permission error. The response + * shape declared for this operation is the same series envelope its daily and + * weekly siblings return, so it is not modelled from guesswork even though it + * could not be exercised live. + */ +export const intraday: AlphaVantageEndpoints['timeSeriesIntraday'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesIntraday'] + >( + 'TIME_SERIES_INTRADAY', + ctx.key, + compactQuery({ + symbol: input.symbol, + interval: input.interval, + adjusted: booleanParam(input.adjusted), + extended_hours: booleanParam(input.extended_hours), + month: input.month, + outputsize: input.outputsize, + }), + ); + + assertSeriesHasData(result, 'timeSeries.intraday', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.intraday', + auditPayload(input, ['symbol', 'interval', 'month', 'outputsize']), + 'completed', + ); + return result; +}; + +/** + * Historical intraday bars beyond the default window. + * + * Alpha Vantage retired the standalone `TIME_SERIES_INTRADAY_EXTENDED` function + * and folded it into `TIME_SERIES_INTRADAY`, which now reaches back more than + * twenty years through its `month` parameter. The operation is kept because the + * catalog lists it, and the legacy `slice` argument (`year1month1` … + * `year2month12`) is translated into the equivalent `month` so existing callers + * keep working. + * + * Premium-plan only, for the same reason as `intraday` — it routes through the + * same provider function. + */ +export const intradayExtended: AlphaVantageEndpoints['timeSeriesIntradayExtended'] = + async (ctx, input) => { + const month = input.month ?? monthFromSlice(input.slice); + + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesIntradayExtended'] + >( + 'TIME_SERIES_INTRADAY', + ctx.key, + compactQuery({ + symbol: input.symbol, + interval: input.interval, + adjusted: booleanParam(input.adjusted), + month, + outputsize: 'full', + }), + ); + + assertSeriesHasData(result, 'timeSeries.intradayExtended', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.intradayExtended', + auditPayload(input, ['symbol', 'interval', 'slice', 'month']), + 'completed', + ); + return result; + }; + +/** + * Converts a legacy slice into the `YYYY-MM` the current API expects. + * `year1month1` is the most recent complete month, counting backwards. + */ +function monthFromSlice(slice: string | undefined): string | undefined { + if (!slice) return undefined; + const match = /^year([12])month([1-9]|1[0-2])$/.exec(slice); + if (!match) return undefined; + + const yearOffset = Number(match[1]) - 1; + const monthOffset = Number(match[2]) - 1; + const monthsBack = yearOffset * 12 + monthOffset; + + const now = new Date(); + // Day 1 avoids month-end rollover when stepping backwards. + const target = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - monthsBack, 1), + ); + const year = target.getUTCFullYear(); + const month = String(target.getUTCMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} + +/** Daily OHLCV bars. */ +export const daily: AlphaVantageEndpoints['timeSeriesDaily'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesDaily'] + >( + 'TIME_SERIES_DAILY', + ctx.key, + compactQuery({ symbol: input.symbol, outputsize: input.outputsize }), + ); + + assertSeriesHasData(result, 'timeSeries.daily', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.daily', + auditPayload(input, ['symbol', 'outputsize']), + 'completed', + ); + return result; +}; + +/** Weekly OHLCV bars. */ +export const weekly: AlphaVantageEndpoints['timeSeriesWeekly'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesWeekly'] + >('TIME_SERIES_WEEKLY', ctx.key, compactQuery({ symbol: input.symbol })); + + assertSeriesHasData(result, 'timeSeries.weekly', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.weekly', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; +}; + +/** Weekly bars including dividend and split adjustments. */ +export const weeklyAdjusted: AlphaVantageEndpoints['timeSeriesWeeklyAdjusted'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesWeeklyAdjusted'] + >( + 'TIME_SERIES_WEEKLY_ADJUSTED', + ctx.key, + compactQuery({ symbol: input.symbol }), + ); + + assertSeriesHasData(result, 'timeSeries.weeklyAdjusted', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.weeklyAdjusted', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; + }; + +/** Monthly OHLCV bars. */ +export const monthly: AlphaVantageEndpoints['timeSeriesMonthly'] = async ( + ctx, + input, +) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesMonthly'] + >('TIME_SERIES_MONTHLY', ctx.key, compactQuery({ symbol: input.symbol })); + + assertSeriesHasData(result, 'timeSeries.monthly', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.monthly', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; +}; + +/** Monthly bars including dividend and split adjustments. */ +export const monthlyAdjusted: AlphaVantageEndpoints['timeSeriesMonthlyAdjusted'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesMonthlyAdjusted'] + >( + 'TIME_SERIES_MONTHLY_ADJUSTED', + ctx.key, + compactQuery({ symbol: input.symbol }), + ); + + assertSeriesHasData(result, 'timeSeries.monthlyAdjusted', input.symbol); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.monthlyAdjusted', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; + }; + +/** + * The latest price and volume for one ticker. + * + * An unknown ticker comes back as `{"Global Quote": {}}` with no error key, so + * the empty case is turned into an explicit not-found here. + */ +export const globalQuote: AlphaVantageEndpoints['timeSeriesGlobalQuote'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesGlobalQuote'] + >('GLOBAL_QUOTE', ctx.key, compactQuery({ symbol: input.symbol })); + + assertNotEmpty( + result['Global Quote'], + 'timeSeries.globalQuote', + input.symbol, + ); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.globalQuote', + auditPayload(input, ['symbol']), + 'completed', + ); + return result; + }; + +/** + * Quotes for up to 100 tickers in one call. + * + * This is a premium-plan operation. On the free tier Alpha Vantage answers with + * a notice and an explicitly artificial sample payload rather than an error, so + * the notice is surfaced to the caller unchanged instead of being mistaken for + * data. + */ +export const realtimeBulkQuotes: AlphaVantageEndpoints['timeSeriesRealtimeBulkQuotes'] = + async (ctx, input) => { + const result = await makeAlphaVantageRequest< + AlphaVantageEndpointOutputs['timeSeriesRealtimeBulkQuotes'] + >( + 'REALTIME_BULK_QUOTES', + ctx.key, + compactQuery({ symbol: listParam(input.symbols) }), + ); + + await logEventFromContext( + ctx, + 'alphavantage.timeSeries.realtimeBulkQuotes', + { count: input.symbols.length }, + 'completed', + ); + return result; + }; diff --git a/packages/alphavantage/endpoints/types.ts b/packages/alphavantage/endpoints/types.ts new file mode 100644 index 000000000..69bd80197 --- /dev/null +++ b/packages/alphavantage/endpoints/types.ts @@ -0,0 +1,822 @@ +import { z } from 'zod'; + +/** + * Input and output schemas for every Alpha Vantage operation. + * + * Two provider conventions shape almost everything here: + * + * 1. **Numbers arrive as strings.** `"185.9200"`, `"4468987"`, `"23850.442"`. + * They are kept as strings rather than coerced, so a price is never silently + * rounded by a float conversion and the caller decides how to parse. + * 2. **Object keys are prose.** `"Time Series (Daily)"`, `"01. symbol"`, + * `"Realtime Currency Exchange Rate"`. Where the key itself varies with the + * request (the series key changes per function), the shape is modelled with + * `catchall` rather than enumerating every possible key. + * + * Response shapes below were captured from the live API rather than transcribed + * from the documentation, except for the two premium-only operations noted at + * their definitions. + */ + +/* -------------------------------------------------------------------------- */ +/* Shared primitives */ +/* -------------------------------------------------------------------------- */ + +/** A ticker as Alpha Vantage spells it, e.g. `IBM` or `TSCO.LON`. */ +const SymbolSchema = z.string().min(1); + +/** + * Alpha Vantage renders every numeric field as a string. A few fields also use + * the literal `"None"` or `"-"` in place of a value. + */ +const NumericString = z.string(); + +/** + * Alpha Vantage exposes a `datatype` parameter that switches a response between + * JSON and CSV. It is deliberately not offered here: this plugin decodes every + * response into typed data, and letting a caller ask for CSV on a JSON + * operation would return something the declared output schema cannot describe. + * The three operations that are CSV-only upstream are decoded into rows instead. + */ + +const IntradayIntervalSchema = z.enum([ + '1min', + '5min', + '15min', + '30min', + '60min', +]); + +const OutputSizeSchema = z + .enum(['compact', 'full']) + .describe('compact returns the latest 100 points, full the full history.'); + +/** + * Meta Data values are usually strings, but technical indicators return + * numbers for `Time Period`. The punctuation of the numbered prefix also + * differs between families — time series use `"1. Information"` while + * indicators use `"1: Symbol"` — so the keys are not enumerated. + */ +const MetaDataSchema = z.record(z.string(), z.union([z.string(), z.number()])); + +/** + * The envelope shared by every time-series, crypto and technical-indicator + * response: a `Meta Data` block plus exactly one series object whose key names + * the series (`"Time Series (Daily)"`, `"Weekly Adjusted Time Series"`, + * `"Technical Analysis: RSI"`, …). The series maps a timestamp to a record of + * string-valued fields. + */ +const SeriesEnvelopeSchema = z + .object({ + 'Meta Data': MetaDataSchema.optional(), + }) + .catchall(z.record(z.string(), z.record(z.string(), NumericString))); + +/** + * The envelope shared by all nine commodity operations and all ten economic + * indicator operations — 19 of the 56 operations return exactly this. + */ +const IndicatorSeriesSchema = z + .object({ + name: z.string(), + interval: z.string(), + unit: z.string(), + data: z.array( + z + .object({ + date: z.string(), + /** `"."` appears in place of a value for gaps in some series. */ + value: NumericString, + }) + .loose(), + ), + }) + .loose(); + +/** Operations that take no parameters at all. */ +const EmptyInputSchema = z.object({}); + +/** Commodity and economic series that expose an interval selector. */ +const intervalInput = ( + intervals: T, + description: string, +) => + z.object({ + interval: z.enum(intervals).optional().describe(description), + }); + +/** Commodities priced monthly and up. */ +const MonthlyCommodityInput = intervalInput( + ['monthly', 'quarterly', 'annual'], + 'Sampling interval. Defaults to monthly.', +); + +/* -------------------------------------------------------------------------- */ +/* Input schemas */ +/* -------------------------------------------------------------------------- */ + +export const AlphaVantageEndpointInputSchemas = { + /* --- timeSeries ------------------------------------------------------- */ + timeSeriesIntraday: z.object({ + symbol: SymbolSchema, + interval: IntradayIntervalSchema, + adjusted: z.boolean().optional(), + extended_hours: z.boolean().optional(), + /** `YYYY-MM` selects a specific historical month. */ + month: z + .string() + .regex(/^\d{4}-\d{2}$/, 'month must be formatted YYYY-MM') + .optional(), + outputsize: OutputSizeSchema.optional(), + }), + timeSeriesIntradayExtended: z.object({ + symbol: SymbolSchema, + interval: IntradayIntervalSchema, + /** + * The historical slice, `year1month1` through `year2month12`. Alpha + * Vantage has folded this into `TIME_SERIES_INTRADAY`'s `month` + * parameter; see the note on the handler. + */ + slice: z + .string() + .regex( + /^year[12]month([1-9]|1[0-2])$/, + 'slice must look like year1month1', + ) + .optional(), + month: z + .string() + .regex(/^\d{4}-\d{2}$/, 'month must be formatted YYYY-MM') + .optional(), + adjusted: z.boolean().optional(), + }), + timeSeriesDaily: z.object({ + symbol: SymbolSchema, + outputsize: OutputSizeSchema.optional(), + }), + timeSeriesWeekly: z.object({ + symbol: SymbolSchema, + }), + timeSeriesWeeklyAdjusted: z.object({ + symbol: SymbolSchema, + }), + timeSeriesMonthly: z.object({ + symbol: SymbolSchema, + }), + timeSeriesMonthlyAdjusted: z.object({ + symbol: SymbolSchema, + }), + timeSeriesGlobalQuote: z.object({ + symbol: SymbolSchema, + }), + timeSeriesRealtimeBulkQuotes: z.object({ + /** Up to 100 tickers. */ + symbols: z + .array(SymbolSchema) + .min(1) + .max(100) + .describe( + 'Up to 100 tickers, sent to the API as a comma-separated list.', + ), + }), + + /* --- market ----------------------------------------------------------- */ + marketSymbolSearch: z.object({ + keywords: z.string().min(1), + }), + marketStatus: EmptyInputSchema, + marketTopGainersLosers: EmptyInputSchema, + marketListingStatus: z.object({ + /** `YYYY-MM-DD`, any date from 2010-01-01 onwards. */ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be formatted YYYY-MM-DD') + .optional(), + state: z.enum(['active', 'delisted']).optional(), + }), + marketSector: EmptyInputSchema, + + /* --- fundamentals ----------------------------------------------------- */ + fundamentalsCompanyOverview: z.object({ symbol: SymbolSchema }), + fundamentalsIncomeStatement: z.object({ symbol: SymbolSchema }), + fundamentalsBalanceSheet: z.object({ symbol: SymbolSchema }), + fundamentalsCashFlow: z.object({ symbol: SymbolSchema }), + fundamentalsEarnings: z.object({ symbol: SymbolSchema }), + fundamentalsEarningsCalendar: z.object({ + symbol: SymbolSchema.optional(), + horizon: z.enum(['3month', '6month', '12month']).optional(), + }), + fundamentalsEarningsCallTranscript: z.object({ + symbol: SymbolSchema, + /** Fiscal quarter as `YYYYQM`, e.g. `2024Q1`. */ + quarter: z + .string() + .regex(/^\d{4}Q[1-4]$/, 'quarter must be formatted YYYYQn, e.g. 2024Q1'), + }), + fundamentalsIpoCalendar: EmptyInputSchema, + fundamentalsDividends: z.object({ symbol: SymbolSchema }), + fundamentalsSplits: z.object({ symbol: SymbolSchema }), + + /* --- forex ------------------------------------------------------------ */ + forexExchangeRate: z.object({ + from_currency: z.string().min(1), + to_currency: z.string().min(1), + }), + forexIntraday: z.object({ + from_symbol: z.string().min(1), + to_symbol: z.string().min(1), + interval: IntradayIntervalSchema, + outputsize: OutputSizeSchema.optional(), + }), + forexDaily: z.object({ + from_symbol: z.string().min(1), + to_symbol: z.string().min(1), + outputsize: OutputSizeSchema.optional(), + }), + forexWeekly: z.object({ + from_symbol: z.string().min(1), + to_symbol: z.string().min(1), + }), + forexMonthly: z.object({ + from_symbol: z.string().min(1), + to_symbol: z.string().min(1), + }), + + /* --- crypto ----------------------------------------------------------- */ + cryptoIntraday: z.object({ + symbol: SymbolSchema.describe('Crypto ticker, e.g. BTC.'), + market: z.string().min(1).describe('Quote currency, e.g. USD.'), + interval: IntradayIntervalSchema, + outputsize: OutputSizeSchema.optional(), + }), + cryptoDaily: z.object({ + symbol: SymbolSchema, + market: z.string().min(1), + }), + cryptoWeekly: z.object({ + symbol: SymbolSchema, + market: z.string().min(1), + }), + cryptoMonthly: z.object({ + symbol: SymbolSchema, + market: z.string().min(1), + }), + + /* --- commodities ------------------------------------------------------ */ + commoditiesAll: MonthlyCommodityInput, + commoditiesAluminum: MonthlyCommodityInput, + commoditiesBrent: intervalInput( + ['daily', 'weekly', 'monthly'], + 'Sampling interval. Defaults to monthly.', + ), + commoditiesCoffee: MonthlyCommodityInput, + commoditiesCopper: MonthlyCommodityInput, + commoditiesCorn: MonthlyCommodityInput, + commoditiesCotton: MonthlyCommodityInput, + commoditiesSugar: MonthlyCommodityInput, + commoditiesWheat: MonthlyCommodityInput, + + /* --- economic --------------------------------------------------------- */ + economicRealGdp: intervalInput( + ['quarterly', 'annual'], + 'Sampling interval. Defaults to annual.', + ), + economicRealGdpPerCapita: EmptyInputSchema, + economicTreasuryYield: z.object({ + interval: z.enum(['daily', 'weekly', 'monthly']).optional(), + maturity: z + .enum(['3month', '2year', '5year', '7year', '10year', '30year']) + .optional(), + }), + economicFederalFundsRate: intervalInput( + ['daily', 'weekly', 'monthly'], + 'Sampling interval. Defaults to monthly.', + ), + economicCpi: intervalInput( + ['monthly', 'semiannual'], + 'Sampling interval. Defaults to monthly.', + ), + economicInflation: EmptyInputSchema, + economicRetailSales: EmptyInputSchema, + economicDurables: EmptyInputSchema, + economicNonfarmPayroll: EmptyInputSchema, + economicUnemployment: EmptyInputSchema, + + /* --- intelligence ----------------------------------------------------- */ + intelligenceNewsSentiment: z + .object({ + tickers: z.array(SymbolSchema).optional(), + topics: z.array(z.string()).optional(), + /** `YYYYMMDDTHHMM`. */ + time_from: z + .string() + .regex(/^\d{8}T\d{4}$/, 'time_from must be formatted YYYYMMDDTHHMM') + .optional(), + time_to: z + .string() + .regex(/^\d{8}T\d{4}$/, 'time_to must be formatted YYYYMMDDTHHMM') + .optional(), + sort: z.enum(['LATEST', 'EARLIEST', 'RELEVANCE']).optional(), + limit: z.number().int().min(1).max(1000).optional(), + }) + .refine( + (input) => + input.time_from === undefined || + input.time_to === undefined || + input.time_from <= input.time_to, + { + message: 'time_from must not be later than time_to', + path: ['time_from'], + }, + ), + intelligenceSlidingWindowAnalytics: z.object({ + symbols: z.array(SymbolSchema).min(1), + range: z + .string() + .min(1) + .describe('Lookback window, e.g. 2month, 6month, full.'), + interval: z.enum(['DAILY', 'WEEKLY', 'MONTHLY']), + window_size: z.number().int().min(10), + calculations: z + .array(z.string().min(1)) + .min(1) + .describe('e.g. MEAN, STDDEV, CORRELATION.'), + ohlc: z.enum(['open', 'high', 'low', 'close']).optional(), + }), + intelligenceHistoricalOptions: z.object({ + symbol: SymbolSchema, + /** `YYYY-MM-DD`; defaults to the previous trading session. */ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be formatted YYYY-MM-DD') + .optional(), + }), + + /* --- technical -------------------------------------------------------- */ + technicalIndicator: z + .object({ + /** + * The indicator function name, e.g. `SMA`, `EMA`, `RSI`, `MACD`, + * `BBANDS`, `STOCH`. The catalog collapses roughly fifty separate + * provider functions into this one operation. + */ + indicator: z + .string() + .min(1) + .regex(/^[A-Z_]+$/, 'indicator must be upper-case, e.g. RSI'), + symbol: SymbolSchema, + interval: z.enum([ + '1min', + '5min', + '15min', + '30min', + '60min', + 'daily', + 'weekly', + 'monthly', + ]), + time_period: z.number().int().min(1).optional(), + series_type: z.enum(['close', 'open', 'high', 'low']).optional(), + month: z + .string() + .regex(/^\d{4}-\d{2}$/, 'month must be formatted YYYY-MM') + .optional(), + /** Indicator-specific extras such as `fastperiod` or `nbdevup`. */ + extra_params: z + .record(z.string(), z.union([z.string(), z.number()])) + .optional(), + }) + .refine( + (input) => + !['SMA', 'EMA', 'RSI', 'WMA', 'DEMA', 'TEMA', 'MOM', 'ROC'].includes( + input.indicator, + ) || input.time_period !== undefined, + { + message: 'time_period is required for this indicator', + path: ['time_period'], + }, + ), +} as const; + +/* -------------------------------------------------------------------------- */ +/* Output schemas */ +/* -------------------------------------------------------------------------- */ + +/** `GLOBAL_QUOTE` — every field is a numbered, space-separated key. */ +export const GlobalQuoteSchema = z + .object({ + '01. symbol': z.string(), + '02. open': NumericString, + '03. high': NumericString, + '04. low': NumericString, + '05. price': NumericString, + '06. volume': NumericString, + '07. latest trading day': z.string(), + '08. previous close': NumericString, + '09. change': NumericString, + '10. change percent': z.string(), + }) + .loose(); + +const GlobalQuoteResponseSchema = z + .object({ + /** + * Alpha Vantage answers an unknown ticker with an empty object here rather + * than an error, so the inner object is optional and the handler raises + * the not-found itself. + */ + 'Global Quote': z.union([GlobalQuoteSchema, z.object({}).strict()]), + }) + .loose(); + +const SymbolMatchSchema = z + .object({ + '1. symbol': z.string(), + '2. name': z.string(), + '3. type': z.string(), + '4. region': z.string(), + '5. marketOpen': z.string(), + '6. marketClose': z.string(), + '7. timezone': z.string(), + '8. currency': z.string(), + '9. matchScore': NumericString, + }) + .loose(); + +const MarketStatusEntrySchema = z + .object({ + market_type: z.string(), + region: z.string(), + primary_exchanges: z.string(), + local_open: z.string(), + local_close: z.string(), + current_status: z.string(), + notes: z.string().optional(), + }) + .loose(); + +const MoverSchema = z + .object({ + ticker: z.string(), + price: NumericString, + change_amount: NumericString, + change_percentage: z.string(), + volume: NumericString, + }) + .loose(); + +/** `OVERVIEW` — a flat object of ~60 PascalCase string fields. */ +export const CompanyOverviewSchema = z + .object({ + Symbol: z.string(), + AssetType: z.string(), + Name: z.string(), + Description: z.string(), + Exchange: z.string(), + Currency: z.string(), + Country: z.string(), + Sector: z.string(), + Industry: z.string(), + MarketCapitalization: NumericString, + }) + .loose(); + +/** + * `INCOME_STATEMENT`, `BALANCE_SHEET` and `CASH_FLOW` share this envelope. The + * individual line items differ per statement and run to ~30 fields each, all + * string-encoded, so they are left to the loose record rather than enumerated. + */ +const FinancialStatementSchema = z + .object({ + symbol: z.string(), + annualReports: z.array( + z + .object({ + fiscalDateEnding: z.string(), + reportedCurrency: z.string(), + }) + .loose(), + ), + quarterlyReports: z.array( + z + .object({ + fiscalDateEnding: z.string(), + reportedCurrency: z.string(), + }) + .loose(), + ), + }) + .loose(); + +const EarningsSchema = z + .object({ + symbol: z.string(), + annualEarnings: z.array( + z + .object({ + fiscalDateEnding: z.string(), + reportedEPS: NumericString, + }) + .loose(), + ), + quarterlyEarnings: z.array( + z + .object({ + fiscalDateEnding: z.string(), + reportedDate: z.string(), + reportedEPS: NumericString, + }) + .loose(), + ), + }) + .loose(); + +/** `DIVIDENDS` and `SPLITS` share a `{symbol, data[]}` envelope. */ +const DividendsSchema = z + .object({ + symbol: z.string(), + data: z.array( + z + .object({ + ex_dividend_date: z.string(), + declaration_date: z.string().optional(), + record_date: z.string().optional(), + payment_date: z.string().optional(), + amount: NumericString, + }) + .loose(), + ), + }) + .loose(); + +const SplitsSchema = z + .object({ + symbol: z.string(), + data: z.array( + z + .object({ + effective_date: z.string(), + split_factor: NumericString, + }) + .loose(), + ), + }) + .loose(); + +const ExchangeRateSchema = z + .object({ + 'Realtime Currency Exchange Rate': z + .object({ + '1. From_Currency Code': z.string(), + '2. From_Currency Name': z.string(), + '3. To_Currency Code': z.string(), + '4. To_Currency Name': z.string(), + '5. Exchange Rate': NumericString, + '6. Last Refreshed': z.string(), + '7. Time Zone': z.string(), + '8. Bid Price': NumericString.optional(), + '9. Ask Price': NumericString.optional(), + }) + .loose(), + }) + .loose(); + +const NewsSentimentSchema = z + .object({ + items: NumericString, + sentiment_score_definition: z.string(), + relevance_score_definition: z.string(), + feed: z.array( + z + .object({ + title: z.string(), + url: z.string(), + time_published: z.string(), + summary: z.string(), + source: z.string(), + overall_sentiment_score: z.number(), + overall_sentiment_label: z.string(), + ticker_sentiment: z + .array( + z + .object({ + ticker: z.string(), + relevance_score: NumericString, + ticker_sentiment_score: NumericString, + ticker_sentiment_label: z.string(), + }) + .loose(), + ) + .optional(), + }) + .loose(), + ), + }) + .loose(); + +const SlidingWindowAnalyticsSchema = z + .object({ + meta_data: z + .object({ + symbols: z.string(), + window_size: z.number(), + min_dt: z.string(), + max_dt: z.string(), + ohlc: z.string(), + interval: z.string(), + }) + .loose(), + payload: z.record(z.string(), z.unknown()), + }) + .loose(); + +/** Rows parsed from a CSV payload. Keys come from the CSV header. */ +const CsvRowsSchema = z.array(z.record(z.string(), z.string())); + +/** + * Six of the 56 operations require a paid Alpha Vantage plan. Verified against + * the live API on 2026-08-13: each answers a free-tier key with + * `{"Information": "... This is a premium endpoint ..."}` and HTTP 200. + * + * TIME_SERIES_INTRADAY, TIME_SERIES_INTRADAY_EXTENDED, FX_INTRADAY, + * CRYPTO_INTRADAY, REALTIME_BULK_QUOTES, HISTORICAL_OPTIONS + * + * In short: everything intraday, plus bulk quotes and the options chain. + * + * The four intraday operations still return the ordinary series envelope, which + * is confirmed from their daily and weekly siblings, so their schemas are not + * guesswork. The two below are different — their shapes could not be observed + * at all, and for bulk quotes the provider explicitly warns that the sample + * payload accompanying the notice is *artificial*. They are modelled loosely + * from the documentation and are the only schemas in this file not confirmed + * against a real response. + */ +const BulkQuotesSchema = z + .object({ + endpoint: z.string().optional(), + message: z.string().optional(), + data: z + .array( + z + .object({ + symbol: z.string(), + }) + .loose(), + ) + .optional(), + }) + .loose(); + +const HistoricalOptionsSchema = z + .object({ + endpoint: z.string().optional(), + message: z.string().optional(), + data: z + .array( + z + .object({ + contractID: z.string(), + symbol: z.string(), + expiration: z.string(), + strike: NumericString, + type: z.string(), + }) + .loose(), + ) + .optional(), + }) + .loose(); + +export const AlphaVantageEndpointOutputSchemas = { + /* --- timeSeries ------------------------------------------------------- */ + timeSeriesIntraday: SeriesEnvelopeSchema, + timeSeriesIntradayExtended: SeriesEnvelopeSchema, + timeSeriesDaily: SeriesEnvelopeSchema, + timeSeriesWeekly: SeriesEnvelopeSchema, + timeSeriesWeeklyAdjusted: SeriesEnvelopeSchema, + timeSeriesMonthly: SeriesEnvelopeSchema, + timeSeriesMonthlyAdjusted: SeriesEnvelopeSchema, + timeSeriesGlobalQuote: GlobalQuoteResponseSchema, + timeSeriesRealtimeBulkQuotes: BulkQuotesSchema, + + /* --- market ----------------------------------------------------------- */ + marketSymbolSearch: z + .object({ bestMatches: z.array(SymbolMatchSchema) }) + .loose(), + marketStatus: z + .object({ + endpoint: z.string(), + markets: z.array(MarketStatusEntrySchema), + }) + .loose(), + marketTopGainersLosers: z + .object({ + metadata: z.string(), + last_updated: z.string(), + top_gainers: z.array(MoverSchema), + top_losers: z.array(MoverSchema), + most_actively_traded: z.array(MoverSchema), + }) + .loose(), + marketListingStatus: CsvRowsSchema, + /** + * `SECTOR` is deprecated upstream and now answers with an empty object, so + * the schema cannot be tightened beyond this without failing on live data. + */ + marketSector: z.record(z.string(), z.unknown()), + + /* --- fundamentals ----------------------------------------------------- */ + fundamentalsCompanyOverview: CompanyOverviewSchema, + fundamentalsIncomeStatement: FinancialStatementSchema, + fundamentalsBalanceSheet: FinancialStatementSchema, + fundamentalsCashFlow: FinancialStatementSchema, + fundamentalsEarnings: EarningsSchema, + fundamentalsEarningsCalendar: CsvRowsSchema, + fundamentalsEarningsCallTranscript: z + .object({ + symbol: z.string(), + quarter: z.string(), + transcript: z.array( + z + .object({ + speaker: z.string(), + title: z.string().optional(), + content: z.string(), + sentiment: NumericString.optional(), + }) + .loose(), + ), + }) + .loose(), + fundamentalsIpoCalendar: CsvRowsSchema, + fundamentalsDividends: DividendsSchema, + fundamentalsSplits: SplitsSchema, + + /* --- forex ------------------------------------------------------------ */ + forexExchangeRate: ExchangeRateSchema, + forexIntraday: SeriesEnvelopeSchema, + forexDaily: SeriesEnvelopeSchema, + forexWeekly: SeriesEnvelopeSchema, + forexMonthly: SeriesEnvelopeSchema, + + /* --- crypto ----------------------------------------------------------- */ + cryptoIntraday: SeriesEnvelopeSchema, + cryptoDaily: SeriesEnvelopeSchema, + cryptoWeekly: SeriesEnvelopeSchema, + cryptoMonthly: SeriesEnvelopeSchema, + + /* --- commodities ------------------------------------------------------ */ + commoditiesAll: IndicatorSeriesSchema, + commoditiesAluminum: IndicatorSeriesSchema, + commoditiesBrent: IndicatorSeriesSchema, + commoditiesCoffee: IndicatorSeriesSchema, + commoditiesCopper: IndicatorSeriesSchema, + commoditiesCorn: IndicatorSeriesSchema, + commoditiesCotton: IndicatorSeriesSchema, + commoditiesSugar: IndicatorSeriesSchema, + commoditiesWheat: IndicatorSeriesSchema, + + /* --- economic --------------------------------------------------------- */ + economicRealGdp: IndicatorSeriesSchema, + economicRealGdpPerCapita: IndicatorSeriesSchema, + economicTreasuryYield: IndicatorSeriesSchema, + economicFederalFundsRate: IndicatorSeriesSchema, + economicCpi: IndicatorSeriesSchema, + economicInflation: IndicatorSeriesSchema, + economicRetailSales: IndicatorSeriesSchema, + economicDurables: IndicatorSeriesSchema, + economicNonfarmPayroll: IndicatorSeriesSchema, + economicUnemployment: IndicatorSeriesSchema, + + /* --- intelligence ----------------------------------------------------- */ + intelligenceNewsSentiment: NewsSentimentSchema, + intelligenceSlidingWindowAnalytics: SlidingWindowAnalyticsSchema, + intelligenceHistoricalOptions: HistoricalOptionsSchema, + + /* --- technical -------------------------------------------------------- */ + technicalIndicator: SeriesEnvelopeSchema, +} as const; + +/* -------------------------------------------------------------------------- */ +/* Inferred types */ +/* -------------------------------------------------------------------------- */ + +export type AlphaVantageEndpointInputs = { + [K in keyof typeof AlphaVantageEndpointInputSchemas]: z.infer< + (typeof AlphaVantageEndpointInputSchemas)[K] + >; +}; + +export type AlphaVantageEndpointOutputs = { + [K in keyof typeof AlphaVantageEndpointOutputSchemas]: z.infer< + (typeof AlphaVantageEndpointOutputSchemas)[K] + >; +}; + +export type AlphaVantageGlobalQuote = z.infer; +export type AlphaVantageCompanyOverview = z.infer; +export type AlphaVantageSeriesEnvelope = z.infer; +export type AlphaVantageIndicatorSeries = z.infer; +export type AlphaVantageSymbolMatch = z.infer; +export type AlphaVantageNewsSentiment = z.infer; +export type AlphaVantageCsvRows = z.infer; diff --git a/packages/alphavantage/error-handlers.ts b/packages/alphavantage/error-handlers.ts new file mode 100644 index 000000000..4a756cef1 --- /dev/null +++ b/packages/alphavantage/error-handlers.ts @@ -0,0 +1,201 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { AlphaVantageApiError } from './client'; + +/** + * Alpha Vantage returns HTTP 200 for every outcome, including failures, and + * signals the failure with a key in the JSON body. `assertNoAlphaVantageError` + * in the client turns those bodies into `AlphaVantageApiError` with an explicit + * `kind`, so these handlers classify on that rather than on status codes or + * substring matching. + * + * The `ApiError` branches below still matter: they catch genuine transport-level + * failures (a gateway error from the CDN in front of the API, for example), + * which do carry real status codes. + */ + +function hasKind( + error: Error, + kind: AlphaVantageApiError['kind'], +): error is AlphaVantageApiError { + return error instanceof AlphaVantageApiError && error.kind === kind; +} + +export const errorHandlers = { + /** + * Two distinct limits share this handler. The call-frequency limit arrives as + * a `Note` and clears within a minute, so it is worth retrying. The daily + * allowance (25 requests on the free tier) arrives as an `Information` and + * does not clear until the next day, so retrying it only wastes time — the + * retry budget is therefore deliberately small. + */ + RATE_LIMIT_ERROR: { + match: (error, context) => { + if (hasKind(error, 'rate_limit')) { + return true; + } + if (error instanceof ApiError && error.status === 429) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('call frequency') || + errorMessage.includes('daily allowance') || + errorMessage.includes('too many requests') + ); + }, + handler: async (error, context) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + + console.warn( + `[ALPHAVANTAGE:${context.operation}] Rate limited: ${error.message}`, + ); + + return { + maxRetries: 2, + headersRetryAfterMs: retryAfterMs, + }; + }, + }, + /** + * The free tier resolves a premium-only operation with an `Information` body + * rather than a 402 or 403. It is a plan limitation, not a bad request, and + * no amount of retrying changes the outcome. + */ + PERMISSION_ERROR: { + match: (error, context) => { + if (hasKind(error, 'premium')) { + return true; + } + if ( + error instanceof ApiError && + (error.status === 402 || error.status === 403) + ) { + return true; + } + return error.message.toLowerCase().includes('premium endpoint'); + }, + handler: async (error, context) => { + console.warn( + `[ALPHAVANTAGE:${context.operation}] This operation requires a paid Alpha Vantage plan: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, + /** + * Defensive only. An API key that Alpha Vantage does not recognise was + * observed to return live data rather than an authentication failure, so on + * the query endpoint there is in practice no auth-error path to match. The + * handler is kept so that a future change on the provider's side, or a + * transport-level 401, is still classified rather than falling through to + * DEFAULT. + */ + AUTH_ERROR: { + match: (error, context) => { + if (error instanceof ApiError && error.status === 401) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('invalid api key') || + errorMessage.includes('apikey is invalid') + ); + }, + handler: async (error, context) => { + console.warn( + `[ALPHAVANTAGE:${context.operation}] Authentication failed - check your API key (https://www.alphavantage.co/support/#api-key)`, + ); + + return { + maxRetries: 0, + }; + }, + }, + /** + * An unknown function name or a missing required parameter comes back as an + * `Error Message` body. This is a malformed call, not a missing resource, so + * it is classified as validation rather than not-found even though the + * provider's wording ("does not exist") reads like the latter. + */ + VALIDATION_ERROR: { + match: (error, context) => { + if (hasKind(error, 'invalid_request')) { + return true; + } + return error instanceof ApiError && error.status === 400; + }, + handler: async (error, context) => { + console.warn( + `[ALPHAVANTAGE:${context.operation}] Invalid request: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, + /** + * Alpha Vantage does not report an unknown symbol as an error — it answers + * with a well-formed but empty envelope. The endpoint handlers detect that + * and raise an explicit not-found, which is what this matches. + */ + NOT_FOUND_ERROR: { + match: (error, context) => { + if (error instanceof ApiError && error.status === 404) { + return true; + } + return error.message.toLowerCase().includes('returned no data for'); + }, + handler: async (error, context) => { + console.warn( + `[ALPHAVANTAGE:${context.operation}] No data: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, + NETWORK_ERROR: { + match: (error, context) => { + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('network') || + errorMessage.includes('connection') || + errorMessage.includes('econnrefused') || + errorMessage.includes('enotfound') || + errorMessage.includes('etimedout') || + errorMessage.includes('fetch failed') + ); + }, + handler: async (error, context) => { + console.warn( + `[ALPHAVANTAGE:${context.operation}] Network error: ${error.message}`, + ); + + return { + maxRetries: 3, + }; + }, + }, + DEFAULT: { + match: (error, context) => { + return true; + }, + handler: async (error, context) => { + console.error( + `[ALPHAVANTAGE:${context.operation}] Unhandled error: ${error.message}`, + ); + + return { + maxRetries: 0, + }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/alphavantage/index.ts b/packages/alphavantage/index.ts new file mode 100644 index 000000000..6a0833c15 --- /dev/null +++ b/packages/alphavantage/index.ts @@ -0,0 +1,775 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { + Commodities, + Crypto, + Economic, + Forex, + Fundamentals, + Intelligence, + Market, + Technical, + TimeSeries, +} from './endpoints'; +import type { + AlphaVantageEndpointInputs, + AlphaVantageEndpointOutputs, +} from './endpoints/types'; +import { + AlphaVantageEndpointInputSchemas, + AlphaVantageEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AlphaVantageSchema } from './schema'; + +export type AlphaVantagePluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalAlphaVantagePlugin['hooks']; + webhookHooks?: InternalAlphaVantagePlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AlphaVantageContext = CorsairPluginContext< + typeof AlphaVantageSchema, + AlphaVantagePluginOptions +>; + +export type AlphaVantageKeyBuilderContext = + KeyBuilderContext; + +export type AlphaVantageBoundEndpoints = BindEndpoints< + typeof alphavantageEndpointsNested +>; + +type AlphaVantageEndpoint = + CorsairEndpoint< + AlphaVantageContext, + AlphaVantageEndpointInputs[K], + AlphaVantageEndpointOutputs[K] + >; + +export type AlphaVantageEndpoints = { + timeSeriesIntraday: AlphaVantageEndpoint<'timeSeriesIntraday'>; + timeSeriesIntradayExtended: AlphaVantageEndpoint<'timeSeriesIntradayExtended'>; + timeSeriesDaily: AlphaVantageEndpoint<'timeSeriesDaily'>; + timeSeriesWeekly: AlphaVantageEndpoint<'timeSeriesWeekly'>; + timeSeriesWeeklyAdjusted: AlphaVantageEndpoint<'timeSeriesWeeklyAdjusted'>; + timeSeriesMonthly: AlphaVantageEndpoint<'timeSeriesMonthly'>; + timeSeriesMonthlyAdjusted: AlphaVantageEndpoint<'timeSeriesMonthlyAdjusted'>; + timeSeriesGlobalQuote: AlphaVantageEndpoint<'timeSeriesGlobalQuote'>; + timeSeriesRealtimeBulkQuotes: AlphaVantageEndpoint<'timeSeriesRealtimeBulkQuotes'>; + marketSymbolSearch: AlphaVantageEndpoint<'marketSymbolSearch'>; + marketStatus: AlphaVantageEndpoint<'marketStatus'>; + marketTopGainersLosers: AlphaVantageEndpoint<'marketTopGainersLosers'>; + marketListingStatus: AlphaVantageEndpoint<'marketListingStatus'>; + marketSector: AlphaVantageEndpoint<'marketSector'>; + fundamentalsCompanyOverview: AlphaVantageEndpoint<'fundamentalsCompanyOverview'>; + fundamentalsIncomeStatement: AlphaVantageEndpoint<'fundamentalsIncomeStatement'>; + fundamentalsBalanceSheet: AlphaVantageEndpoint<'fundamentalsBalanceSheet'>; + fundamentalsCashFlow: AlphaVantageEndpoint<'fundamentalsCashFlow'>; + fundamentalsEarnings: AlphaVantageEndpoint<'fundamentalsEarnings'>; + fundamentalsEarningsCalendar: AlphaVantageEndpoint<'fundamentalsEarningsCalendar'>; + fundamentalsEarningsCallTranscript: AlphaVantageEndpoint<'fundamentalsEarningsCallTranscript'>; + fundamentalsIpoCalendar: AlphaVantageEndpoint<'fundamentalsIpoCalendar'>; + fundamentalsDividends: AlphaVantageEndpoint<'fundamentalsDividends'>; + fundamentalsSplits: AlphaVantageEndpoint<'fundamentalsSplits'>; + forexExchangeRate: AlphaVantageEndpoint<'forexExchangeRate'>; + forexIntraday: AlphaVantageEndpoint<'forexIntraday'>; + forexDaily: AlphaVantageEndpoint<'forexDaily'>; + forexWeekly: AlphaVantageEndpoint<'forexWeekly'>; + forexMonthly: AlphaVantageEndpoint<'forexMonthly'>; + cryptoIntraday: AlphaVantageEndpoint<'cryptoIntraday'>; + cryptoDaily: AlphaVantageEndpoint<'cryptoDaily'>; + cryptoWeekly: AlphaVantageEndpoint<'cryptoWeekly'>; + cryptoMonthly: AlphaVantageEndpoint<'cryptoMonthly'>; + commoditiesAll: AlphaVantageEndpoint<'commoditiesAll'>; + commoditiesAluminum: AlphaVantageEndpoint<'commoditiesAluminum'>; + commoditiesBrent: AlphaVantageEndpoint<'commoditiesBrent'>; + commoditiesCoffee: AlphaVantageEndpoint<'commoditiesCoffee'>; + commoditiesCopper: AlphaVantageEndpoint<'commoditiesCopper'>; + commoditiesCorn: AlphaVantageEndpoint<'commoditiesCorn'>; + commoditiesCotton: AlphaVantageEndpoint<'commoditiesCotton'>; + commoditiesSugar: AlphaVantageEndpoint<'commoditiesSugar'>; + commoditiesWheat: AlphaVantageEndpoint<'commoditiesWheat'>; + economicRealGdp: AlphaVantageEndpoint<'economicRealGdp'>; + economicRealGdpPerCapita: AlphaVantageEndpoint<'economicRealGdpPerCapita'>; + economicTreasuryYield: AlphaVantageEndpoint<'economicTreasuryYield'>; + economicFederalFundsRate: AlphaVantageEndpoint<'economicFederalFundsRate'>; + economicCpi: AlphaVantageEndpoint<'economicCpi'>; + economicInflation: AlphaVantageEndpoint<'economicInflation'>; + economicRetailSales: AlphaVantageEndpoint<'economicRetailSales'>; + economicDurables: AlphaVantageEndpoint<'economicDurables'>; + economicNonfarmPayroll: AlphaVantageEndpoint<'economicNonfarmPayroll'>; + economicUnemployment: AlphaVantageEndpoint<'economicUnemployment'>; + intelligenceNewsSentiment: AlphaVantageEndpoint<'intelligenceNewsSentiment'>; + intelligenceSlidingWindowAnalytics: AlphaVantageEndpoint<'intelligenceSlidingWindowAnalytics'>; + intelligenceHistoricalOptions: AlphaVantageEndpoint<'intelligenceHistoricalOptions'>; + technicalIndicator: AlphaVantageEndpoint<'technicalIndicator'>; +}; + +/** + * Alpha Vantage has no webhook, callback or streaming mechanism, so there are + * no triggers to register. The OSS catalog lists zero triggers accordingly. + */ +export type AlphaVantageWebhooks = Record; + +export type AlphaVantageBoundWebhooks = BindWebhooks; + +const alphavantageEndpointsNested = { + timeSeries: { + intraday: TimeSeries.intraday, + intradayExtended: TimeSeries.intradayExtended, + daily: TimeSeries.daily, + weekly: TimeSeries.weekly, + weeklyAdjusted: TimeSeries.weeklyAdjusted, + monthly: TimeSeries.monthly, + monthlyAdjusted: TimeSeries.monthlyAdjusted, + globalQuote: TimeSeries.globalQuote, + realtimeBulkQuotes: TimeSeries.realtimeBulkQuotes, + }, + market: { + symbolSearch: Market.symbolSearch, + status: Market.status, + topGainersLosers: Market.topGainersLosers, + listingStatus: Market.listingStatus, + sector: Market.sector, + }, + fundamentals: { + companyOverview: Fundamentals.companyOverview, + incomeStatement: Fundamentals.incomeStatement, + balanceSheet: Fundamentals.balanceSheet, + cashFlow: Fundamentals.cashFlow, + earnings: Fundamentals.earnings, + earningsCalendar: Fundamentals.earningsCalendar, + earningsCallTranscript: Fundamentals.earningsCallTranscript, + ipoCalendar: Fundamentals.ipoCalendar, + dividends: Fundamentals.dividends, + splits: Fundamentals.splits, + }, + forex: { + exchangeRate: Forex.exchangeRate, + intraday: Forex.intraday, + daily: Forex.daily, + weekly: Forex.weekly, + monthly: Forex.monthly, + }, + crypto: { + intraday: Crypto.intraday, + daily: Crypto.daily, + weekly: Crypto.weekly, + monthly: Crypto.monthly, + }, + commodities: { + all: Commodities.all, + aluminum: Commodities.aluminum, + brent: Commodities.brent, + coffee: Commodities.coffee, + copper: Commodities.copper, + corn: Commodities.corn, + cotton: Commodities.cotton, + sugar: Commodities.sugar, + wheat: Commodities.wheat, + }, + economic: { + realGdp: Economic.realGdp, + realGdpPerCapita: Economic.realGdpPerCapita, + treasuryYield: Economic.treasuryYield, + federalFundsRate: Economic.federalFundsRate, + cpi: Economic.cpi, + inflation: Economic.inflation, + retailSales: Economic.retailSales, + durables: Economic.durables, + nonfarmPayroll: Economic.nonfarmPayroll, + unemployment: Economic.unemployment, + }, + intelligence: { + newsSentiment: Intelligence.newsSentiment, + slidingWindowAnalytics: Intelligence.slidingWindowAnalytics, + historicalOptions: Intelligence.historicalOptions, + }, + technical: { + indicator: Technical.indicator, + }, +} as const; + +const alphavantageWebhooksNested = {} as const; + +export const alphavantageEndpointSchemas = { + 'timeSeries.intraday': { + input: AlphaVantageEndpointInputSchemas.timeSeriesIntraday, + output: AlphaVantageEndpointOutputSchemas.timeSeriesIntraday, + }, + 'timeSeries.intradayExtended': { + input: AlphaVantageEndpointInputSchemas.timeSeriesIntradayExtended, + output: AlphaVantageEndpointOutputSchemas.timeSeriesIntradayExtended, + }, + 'timeSeries.daily': { + input: AlphaVantageEndpointInputSchemas.timeSeriesDaily, + output: AlphaVantageEndpointOutputSchemas.timeSeriesDaily, + }, + 'timeSeries.weekly': { + input: AlphaVantageEndpointInputSchemas.timeSeriesWeekly, + output: AlphaVantageEndpointOutputSchemas.timeSeriesWeekly, + }, + 'timeSeries.weeklyAdjusted': { + input: AlphaVantageEndpointInputSchemas.timeSeriesWeeklyAdjusted, + output: AlphaVantageEndpointOutputSchemas.timeSeriesWeeklyAdjusted, + }, + 'timeSeries.monthly': { + input: AlphaVantageEndpointInputSchemas.timeSeriesMonthly, + output: AlphaVantageEndpointOutputSchemas.timeSeriesMonthly, + }, + 'timeSeries.monthlyAdjusted': { + input: AlphaVantageEndpointInputSchemas.timeSeriesMonthlyAdjusted, + output: AlphaVantageEndpointOutputSchemas.timeSeriesMonthlyAdjusted, + }, + 'timeSeries.globalQuote': { + input: AlphaVantageEndpointInputSchemas.timeSeriesGlobalQuote, + output: AlphaVantageEndpointOutputSchemas.timeSeriesGlobalQuote, + }, + 'timeSeries.realtimeBulkQuotes': { + input: AlphaVantageEndpointInputSchemas.timeSeriesRealtimeBulkQuotes, + output: AlphaVantageEndpointOutputSchemas.timeSeriesRealtimeBulkQuotes, + }, + 'market.symbolSearch': { + input: AlphaVantageEndpointInputSchemas.marketSymbolSearch, + output: AlphaVantageEndpointOutputSchemas.marketSymbolSearch, + }, + 'market.status': { + input: AlphaVantageEndpointInputSchemas.marketStatus, + output: AlphaVantageEndpointOutputSchemas.marketStatus, + }, + 'market.topGainersLosers': { + input: AlphaVantageEndpointInputSchemas.marketTopGainersLosers, + output: AlphaVantageEndpointOutputSchemas.marketTopGainersLosers, + }, + 'market.listingStatus': { + input: AlphaVantageEndpointInputSchemas.marketListingStatus, + output: AlphaVantageEndpointOutputSchemas.marketListingStatus, + }, + 'market.sector': { + input: AlphaVantageEndpointInputSchemas.marketSector, + output: AlphaVantageEndpointOutputSchemas.marketSector, + }, + 'fundamentals.companyOverview': { + input: AlphaVantageEndpointInputSchemas.fundamentalsCompanyOverview, + output: AlphaVantageEndpointOutputSchemas.fundamentalsCompanyOverview, + }, + 'fundamentals.incomeStatement': { + input: AlphaVantageEndpointInputSchemas.fundamentalsIncomeStatement, + output: AlphaVantageEndpointOutputSchemas.fundamentalsIncomeStatement, + }, + 'fundamentals.balanceSheet': { + input: AlphaVantageEndpointInputSchemas.fundamentalsBalanceSheet, + output: AlphaVantageEndpointOutputSchemas.fundamentalsBalanceSheet, + }, + 'fundamentals.cashFlow': { + input: AlphaVantageEndpointInputSchemas.fundamentalsCashFlow, + output: AlphaVantageEndpointOutputSchemas.fundamentalsCashFlow, + }, + 'fundamentals.earnings': { + input: AlphaVantageEndpointInputSchemas.fundamentalsEarnings, + output: AlphaVantageEndpointOutputSchemas.fundamentalsEarnings, + }, + 'fundamentals.earningsCalendar': { + input: AlphaVantageEndpointInputSchemas.fundamentalsEarningsCalendar, + output: AlphaVantageEndpointOutputSchemas.fundamentalsEarningsCalendar, + }, + 'fundamentals.earningsCallTranscript': { + input: AlphaVantageEndpointInputSchemas.fundamentalsEarningsCallTranscript, + output: + AlphaVantageEndpointOutputSchemas.fundamentalsEarningsCallTranscript, + }, + 'fundamentals.ipoCalendar': { + input: AlphaVantageEndpointInputSchemas.fundamentalsIpoCalendar, + output: AlphaVantageEndpointOutputSchemas.fundamentalsIpoCalendar, + }, + 'fundamentals.dividends': { + input: AlphaVantageEndpointInputSchemas.fundamentalsDividends, + output: AlphaVantageEndpointOutputSchemas.fundamentalsDividends, + }, + 'fundamentals.splits': { + input: AlphaVantageEndpointInputSchemas.fundamentalsSplits, + output: AlphaVantageEndpointOutputSchemas.fundamentalsSplits, + }, + 'forex.exchangeRate': { + input: AlphaVantageEndpointInputSchemas.forexExchangeRate, + output: AlphaVantageEndpointOutputSchemas.forexExchangeRate, + }, + 'forex.intraday': { + input: AlphaVantageEndpointInputSchemas.forexIntraday, + output: AlphaVantageEndpointOutputSchemas.forexIntraday, + }, + 'forex.daily': { + input: AlphaVantageEndpointInputSchemas.forexDaily, + output: AlphaVantageEndpointOutputSchemas.forexDaily, + }, + 'forex.weekly': { + input: AlphaVantageEndpointInputSchemas.forexWeekly, + output: AlphaVantageEndpointOutputSchemas.forexWeekly, + }, + 'forex.monthly': { + input: AlphaVantageEndpointInputSchemas.forexMonthly, + output: AlphaVantageEndpointOutputSchemas.forexMonthly, + }, + 'crypto.intraday': { + input: AlphaVantageEndpointInputSchemas.cryptoIntraday, + output: AlphaVantageEndpointOutputSchemas.cryptoIntraday, + }, + 'crypto.daily': { + input: AlphaVantageEndpointInputSchemas.cryptoDaily, + output: AlphaVantageEndpointOutputSchemas.cryptoDaily, + }, + 'crypto.weekly': { + input: AlphaVantageEndpointInputSchemas.cryptoWeekly, + output: AlphaVantageEndpointOutputSchemas.cryptoWeekly, + }, + 'crypto.monthly': { + input: AlphaVantageEndpointInputSchemas.cryptoMonthly, + output: AlphaVantageEndpointOutputSchemas.cryptoMonthly, + }, + 'commodities.all': { + input: AlphaVantageEndpointInputSchemas.commoditiesAll, + output: AlphaVantageEndpointOutputSchemas.commoditiesAll, + }, + 'commodities.aluminum': { + input: AlphaVantageEndpointInputSchemas.commoditiesAluminum, + output: AlphaVantageEndpointOutputSchemas.commoditiesAluminum, + }, + 'commodities.brent': { + input: AlphaVantageEndpointInputSchemas.commoditiesBrent, + output: AlphaVantageEndpointOutputSchemas.commoditiesBrent, + }, + 'commodities.coffee': { + input: AlphaVantageEndpointInputSchemas.commoditiesCoffee, + output: AlphaVantageEndpointOutputSchemas.commoditiesCoffee, + }, + 'commodities.copper': { + input: AlphaVantageEndpointInputSchemas.commoditiesCopper, + output: AlphaVantageEndpointOutputSchemas.commoditiesCopper, + }, + 'commodities.corn': { + input: AlphaVantageEndpointInputSchemas.commoditiesCorn, + output: AlphaVantageEndpointOutputSchemas.commoditiesCorn, + }, + 'commodities.cotton': { + input: AlphaVantageEndpointInputSchemas.commoditiesCotton, + output: AlphaVantageEndpointOutputSchemas.commoditiesCotton, + }, + 'commodities.sugar': { + input: AlphaVantageEndpointInputSchemas.commoditiesSugar, + output: AlphaVantageEndpointOutputSchemas.commoditiesSugar, + }, + 'commodities.wheat': { + input: AlphaVantageEndpointInputSchemas.commoditiesWheat, + output: AlphaVantageEndpointOutputSchemas.commoditiesWheat, + }, + 'economic.realGdp': { + input: AlphaVantageEndpointInputSchemas.economicRealGdp, + output: AlphaVantageEndpointOutputSchemas.economicRealGdp, + }, + 'economic.realGdpPerCapita': { + input: AlphaVantageEndpointInputSchemas.economicRealGdpPerCapita, + output: AlphaVantageEndpointOutputSchemas.economicRealGdpPerCapita, + }, + 'economic.treasuryYield': { + input: AlphaVantageEndpointInputSchemas.economicTreasuryYield, + output: AlphaVantageEndpointOutputSchemas.economicTreasuryYield, + }, + 'economic.federalFundsRate': { + input: AlphaVantageEndpointInputSchemas.economicFederalFundsRate, + output: AlphaVantageEndpointOutputSchemas.economicFederalFundsRate, + }, + 'economic.cpi': { + input: AlphaVantageEndpointInputSchemas.economicCpi, + output: AlphaVantageEndpointOutputSchemas.economicCpi, + }, + 'economic.inflation': { + input: AlphaVantageEndpointInputSchemas.economicInflation, + output: AlphaVantageEndpointOutputSchemas.economicInflation, + }, + 'economic.retailSales': { + input: AlphaVantageEndpointInputSchemas.economicRetailSales, + output: AlphaVantageEndpointOutputSchemas.economicRetailSales, + }, + 'economic.durables': { + input: AlphaVantageEndpointInputSchemas.economicDurables, + output: AlphaVantageEndpointOutputSchemas.economicDurables, + }, + 'economic.nonfarmPayroll': { + input: AlphaVantageEndpointInputSchemas.economicNonfarmPayroll, + output: AlphaVantageEndpointOutputSchemas.economicNonfarmPayroll, + }, + 'economic.unemployment': { + input: AlphaVantageEndpointInputSchemas.economicUnemployment, + output: AlphaVantageEndpointOutputSchemas.economicUnemployment, + }, + 'intelligence.newsSentiment': { + input: AlphaVantageEndpointInputSchemas.intelligenceNewsSentiment, + output: AlphaVantageEndpointOutputSchemas.intelligenceNewsSentiment, + }, + 'intelligence.slidingWindowAnalytics': { + input: AlphaVantageEndpointInputSchemas.intelligenceSlidingWindowAnalytics, + output: + AlphaVantageEndpointOutputSchemas.intelligenceSlidingWindowAnalytics, + }, + 'intelligence.historicalOptions': { + input: AlphaVantageEndpointInputSchemas.intelligenceHistoricalOptions, + output: AlphaVantageEndpointOutputSchemas.intelligenceHistoricalOptions, + }, + 'technical.indicator': { + input: AlphaVantageEndpointInputSchemas.technicalIndicator, + output: AlphaVantageEndpointOutputSchemas.technicalIndicator, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof alphavantageEndpointsNested +>; + +const alphavantageWebhookSchemas = + {} as const satisfies RequiredPluginWebhookSchemas< + typeof alphavantageWebhooksNested + >; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +/** + * Every Alpha Vantage operation is a read. The API exposes no way to create, + * change or delete anything, so no operation carries a write or destructive + * risk level. + */ +const alphavantageEndpointMeta = { + 'timeSeries.intraday': { + riskLevel: 'read', + description: + 'Get intraday OHLCV bars at 1-60 minute resolution [PREMIUM PLAN]', + }, + 'timeSeries.intradayExtended': { + riskLevel: 'read', + description: + 'Get historical intraday bars beyond the default window [PREMIUM PLAN]', + }, + 'timeSeries.daily': { + riskLevel: 'read', + description: 'Get daily OHLCV bars', + }, + 'timeSeries.weekly': { + riskLevel: 'read', + description: 'Get weekly OHLCV bars', + }, + 'timeSeries.weeklyAdjusted': { + riskLevel: 'read', + description: 'Get weekly bars adjusted for splits and dividends', + }, + 'timeSeries.monthly': { + riskLevel: 'read', + description: 'Get monthly OHLCV bars', + }, + 'timeSeries.monthlyAdjusted': { + riskLevel: 'read', + description: 'Get monthly bars adjusted for splits and dividends', + }, + 'timeSeries.globalQuote': { + riskLevel: 'read', + description: 'Get the latest price and volume for one ticker', + }, + 'timeSeries.realtimeBulkQuotes': { + riskLevel: 'read', + description: 'Get quotes for up to 100 tickers at once [PREMIUM PLAN]', + }, + 'market.symbolSearch': { + riskLevel: 'read', + description: 'Search securities by name or ticker fragment', + }, + 'market.status': { + riskLevel: 'read', + description: 'Get the open or closed state of global exchanges', + }, + 'market.topGainersLosers': { + riskLevel: 'read', + description: + 'Get the top gainers, losers and most actively traded US tickers', + }, + 'market.listingStatus': { + riskLevel: 'read', + description: + 'List every covered security, active or delisted (CSV upstream)', + }, + 'market.sector': { + riskLevel: 'read', + description: + 'Get sector performance [DEPRECATED UPSTREAM: returns an empty body]', + }, + 'fundamentals.companyOverview': { + riskLevel: 'read', + description: 'Get a company profile with sector and valuation figures', + }, + 'fundamentals.incomeStatement': { + riskLevel: 'read', + description: 'Get annual and quarterly income statements', + }, + 'fundamentals.balanceSheet': { + riskLevel: 'read', + description: 'Get annual and quarterly balance sheets', + }, + 'fundamentals.cashFlow': { + riskLevel: 'read', + description: 'Get annual and quarterly cash flow statements', + }, + 'fundamentals.earnings': { + riskLevel: 'read', + description: 'Get reported and estimated earnings per share', + }, + 'fundamentals.earningsCalendar': { + riskLevel: 'read', + description: 'List upcoming earnings dates (CSV upstream)', + }, + 'fundamentals.earningsCallTranscript': { + riskLevel: 'read', + description: 'Get an earnings call transcript with per-speaker sentiment', + }, + 'fundamentals.ipoCalendar': { + riskLevel: 'read', + description: 'List IPOs expected in the next three months (CSV upstream)', + }, + 'fundamentals.dividends': { + riskLevel: 'read', + description: 'Get historical and declared dividends', + }, + 'fundamentals.splits': { + riskLevel: 'read', + description: 'Get historical stock splits', + }, + 'forex.exchangeRate': { + riskLevel: 'read', + description: 'Get the current rate for a currency pair', + }, + 'forex.intraday': { + riskLevel: 'read', + description: 'Get intraday bars for a currency pair [PREMIUM PLAN]', + }, + 'forex.daily': { + riskLevel: 'read', + description: 'Get daily bars for a currency pair', + }, + 'forex.weekly': { + riskLevel: 'read', + description: 'Get weekly bars for a currency pair', + }, + 'forex.monthly': { + riskLevel: 'read', + description: 'Get monthly bars for a currency pair', + }, + 'crypto.intraday': { + riskLevel: 'read', + description: 'Get intraday bars for a digital currency [PREMIUM PLAN]', + }, + 'crypto.daily': { + riskLevel: 'read', + description: 'Get daily bars for a digital currency', + }, + 'crypto.weekly': { + riskLevel: 'read', + description: 'Get weekly bars for a digital currency', + }, + 'crypto.monthly': { + riskLevel: 'read', + description: 'Get monthly bars for a digital currency', + }, + 'commodities.all': { + riskLevel: 'read', + description: 'Get the global commodities price index', + }, + 'commodities.aluminum': { + riskLevel: 'read', + description: 'Get global aluminum prices', + }, + 'commodities.brent': { + riskLevel: 'read', + description: 'Get Brent crude oil prices', + }, + 'commodities.coffee': { + riskLevel: 'read', + description: 'Get global coffee prices', + }, + 'commodities.copper': { + riskLevel: 'read', + description: 'Get global copper prices', + }, + 'commodities.corn': { + riskLevel: 'read', + description: 'Get global corn prices', + }, + 'commodities.cotton': { + riskLevel: 'read', + description: 'Get global cotton prices', + }, + 'commodities.sugar': { + riskLevel: 'read', + description: 'Get global sugar prices', + }, + 'commodities.wheat': { + riskLevel: 'read', + description: 'Get global wheat prices', + }, + 'economic.realGdp': { + riskLevel: 'read', + description: 'Get US real gross domestic product', + }, + 'economic.realGdpPerCapita': { + riskLevel: 'read', + description: 'Get US real GDP per capita', + }, + 'economic.treasuryYield': { + riskLevel: 'read', + description: 'Get US treasury yield for a constant maturity', + }, + 'economic.federalFundsRate': { + riskLevel: 'read', + description: 'Get the US federal funds rate', + }, + 'economic.cpi': { + riskLevel: 'read', + description: 'Get the US consumer price index', + }, + 'economic.inflation': { + riskLevel: 'read', + description: 'Get annual US inflation', + }, + 'economic.retailSales': { + riskLevel: 'read', + description: 'Get US advance retail sales', + }, + 'economic.durables': { + riskLevel: 'read', + description: 'Get US durable goods orders', + }, + 'economic.nonfarmPayroll': { + riskLevel: 'read', + description: 'Get US nonfarm payroll totals', + }, + 'economic.unemployment': { + riskLevel: 'read', + description: 'Get the US unemployment rate', + }, + 'intelligence.newsSentiment': { + riskLevel: 'read', + description: 'Get market news with article and ticker sentiment scores', + }, + 'intelligence.slidingWindowAnalytics': { + riskLevel: 'read', + description: 'Get rolling-window statistics across a set of tickers', + }, + 'intelligence.historicalOptions': { + riskLevel: 'read', + description: 'Get a full options chain for one date [PREMIUM PLAN]', + }, + 'technical.indicator': { + riskLevel: 'read', + description: 'Calculate any technical indicator (SMA, EMA, RSI, MACD, ...)', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof alphavantageEndpointsNested +>; + +/** + * Alpha Vantage issues a single per-account API key passed as a query + * parameter, with no OAuth flow, so account scoping keys off the tenant's + * external id. + */ +export const alphavantageAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAlphaVantagePlugin = + CorsairPlugin< + 'alphavantage', + typeof AlphaVantageSchema, + typeof alphavantageEndpointsNested, + typeof alphavantageWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalAlphaVantagePlugin = + BaseAlphaVantagePlugin; + +export type ExternalAlphaVantagePlugin = + BaseAlphaVantagePlugin; + +/** + * Builds the Alpha Vantage plugin. + * + * Alpha Vantage authenticates with a single per-account API key sent as the + * `apikey` query parameter and has no OAuth flow, so only `api_key` auth is + * offered. + */ +export function alphavantage( + incomingOptions: AlphaVantagePluginOptions & + T = {} as AlphaVantagePluginOptions & T, +): ExternalAlphaVantagePlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'alphavantage', + authConfig: alphavantageAuthConfig, + schema: AlphaVantageSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: alphavantageEndpointsNested, + webhooks: alphavantageWebhooksNested, + endpointMeta: alphavantageEndpointMeta, + endpointSchemas: alphavantageEndpointSchemas, + webhookSchemas: alphavantageWebhookSchemas, + pluginWebhookMatcher: () => false, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AlphaVantageKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalAlphaVantagePlugin; +} + +export type { + AlphaVantageCompanyOverview, + AlphaVantageCsvRows, + AlphaVantageEndpointInputs, + AlphaVantageEndpointOutputs, + AlphaVantageGlobalQuote, + AlphaVantageIndicatorSeries, + AlphaVantageNewsSentiment, + AlphaVantageSeriesEnvelope, + AlphaVantageSymbolMatch, +} from './endpoints/types'; +export type { AlphaVantageWebhookOutputs } from './webhooks/types'; diff --git a/packages/alphavantage/integration.test.ts b/packages/alphavantage/integration.test.ts new file mode 100644 index 000000000..a172e3f5e --- /dev/null +++ b/packages/alphavantage/integration.test.ts @@ -0,0 +1,131 @@ +/** + * Live verification against the real Alpha Vantage API. + * + * This file is named to match the CI exclusion in `.github/workflows/ + * pr-checks.yml`, so it never runs without credentials, and it also skips + * itself when `ALPHAVANTAGE_API_KEY` is absent. + * + * ALPHAVANTAGE_API_KEY= pnpm exec jest integration + * + * The free tier allows 25 requests per day, so this suite is deliberately + * frugal: seven requests, each chosen to exercise a response shape that no + * other request covers. Requests are paced at 1.2s. + * + * Every operation here is a read. Alpha Vantage has no write surface, so unlike + * a CRUD provider there is nothing to create and nothing to clean up. + */ +import { + Commodities, + Forex, + Fundamentals, + Market, + TimeSeries, +} from './endpoints'; +import { AlphaVantageEndpointOutputSchemas as Outputs } from './endpoints/types'; + +const apiKey = process.env.ALPHAVANTAGE_API_KEY; +const describeLive = apiKey ? describe : describe.skip; + +type Ctx = Parameters[0]; + +const upserts: { id: string; data: unknown }[] = []; + +function makeCtx(): Ctx { + return { + key: apiKey ?? '', + db: { + symbols: { + upsertByEntityId: async (id: string, data: unknown) => { + upserts.push({ id, data }); + }, + }, + }, + database: undefined, + $getAccountId: async () => 'integration-test', + } as unknown as Ctx; +} + +/** Alpha Vantage throttles short bursts; keep a gap between calls. */ +const pace = () => new Promise((resolve) => setTimeout(resolve, 1200)); + +describeLive('Alpha Vantage live API', () => { + let ctx: Ctx; + + beforeAll(() => { + ctx = makeCtx(); + }); + + afterEach(pace); + + it('returns a quote matching the declared schema', async () => { + const result = await TimeSeries.globalQuote(ctx, { symbol: 'IBM' }); + + expect(() => Outputs.timeSeriesGlobalQuote.parse(result)).not.toThrow(); + expect(result['Global Quote']).toHaveProperty('01. symbol', 'IBM'); + }); + + it('returns a daily series matching the declared schema', async () => { + const result = await TimeSeries.daily(ctx, { + symbol: 'IBM', + outputsize: 'compact', + }); + + expect(() => Outputs.timeSeriesDaily.parse(result)).not.toThrow(); + + // The series key is prose and varies by function, so locate it rather + // than assuming its name. + const seriesKey = Object.keys(result).find((key) => key !== 'Meta Data'); + expect(seriesKey).toBeDefined(); + const series = (result as Record)[seriesKey ?? '']; + expect(Object.keys(series as object).length).toBeGreaterThan(0); + }); + + it('returns a company overview and caches the symbol', async () => { + upserts.length = 0; + const result = await Fundamentals.companyOverview(ctx, { symbol: 'IBM' }); + + expect(() => + Outputs.fundamentalsCompanyOverview.parse(result), + ).not.toThrow(); + expect(result.Symbol).toBe('IBM'); + expect(upserts).toHaveLength(1); + expect(upserts[0]?.id).toBe('IBM'); + }); + + it('returns a currency exchange rate matching the declared schema', async () => { + const result = await Forex.exchangeRate(ctx, { + from_currency: 'USD', + to_currency: 'JPY', + }); + + expect(() => Outputs.forexExchangeRate.parse(result)).not.toThrow(); + expect( + result['Realtime Currency Exchange Rate']['5. Exchange Rate'], + ).toMatch(/^\d+(\.\d+)?$/); + }); + + it('returns the shared indicator envelope for a commodity', async () => { + const result = await Commodities.wheat(ctx, { interval: 'monthly' }); + + expect(() => Outputs.commoditiesWheat.parse(result)).not.toThrow(); + expect(result.data.length).toBeGreaterThan(0); + expect(result.unit).toBeTruthy(); + }); + + it('searches symbols and mirrors the matches into the cache', async () => { + upserts.length = 0; + const result = await Market.symbolSearch(ctx, { keywords: 'tesco' }); + + expect(() => Outputs.marketSymbolSearch.parse(result)).not.toThrow(); + expect(result.bestMatches.length).toBeGreaterThan(0); + expect(upserts.length).toBe(result.bestMatches.length); + }); + + it('reports an unknown ticker as not-found rather than returning an empty envelope', async () => { + // Alpha Vantage answers this with {"Global Quote": {}} and HTTP 200; the + // plugin is what turns it into an error. + await expect( + TimeSeries.globalQuote(ctx, { symbol: 'ZZZZ_NOT_A_REAL_TICKER' }), + ).rejects.toThrow(/returned no data for/); + }); +}); diff --git a/packages/alphavantage/jest.config.cjs b/packages/alphavantage/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/alphavantage/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/alphavantage/package.json b/packages/alphavantage/package.json new file mode 100644 index 000000000..3d2c4161b --- /dev/null +++ b/packages/alphavantage/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/alphavantage", + "version": "0.1.0", + "description": "Alpha Vantage plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "alphavantage", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/alphavantage/schema.test.ts b/packages/alphavantage/schema.test.ts new file mode 100644 index 000000000..ca7d1b2eb --- /dev/null +++ b/packages/alphavantage/schema.test.ts @@ -0,0 +1,440 @@ +/** + * Validates the declared schemas against payloads captured from the live API on + * 2026-08-13, trimmed for length but otherwise unedited. The point is to catch + * a schema that only matches the documentation rather than what the provider + * actually sends — Alpha Vantage's key naming is inconsistent enough that this + * is a real risk. + */ +import { + AlphaVantageEndpointInputSchemas as Inputs, + AlphaVantageEndpointOutputSchemas as Outputs, +} from './endpoints/types'; + +describe('captured live responses satisfy the output schemas', () => { + it('GLOBAL_QUOTE', () => { + const captured = { + 'Global Quote': { + '01. symbol': 'IBM', + '02. open': '236.3100', + '03. high': '241.8000', + '04. low': '235.4400', + '05. price': '238.4200', + '06. volume': '4468987', + '07. latest trading day': '2026-08-11', + '08. previous close': '236.3100', + '09. change': '2.1100', + '10. change percent': '0.8929%', + }, + }; + expect(Outputs.timeSeriesGlobalQuote.parse(captured)).toBeTruthy(); + }); + + it('GLOBAL_QUOTE with an unknown ticker, which comes back empty', () => { + // The provider does not report this as an error, so the schema has to + // accept it and the handler raises the not-found instead. + expect( + Outputs.timeSeriesGlobalQuote.parse({ 'Global Quote': {} }), + ).toBeTruthy(); + }); + + it('TIME_SERIES_DAILY', () => { + const captured = { + 'Meta Data': { + '1. Information': 'Daily Prices (open, high, low, close) and Volumes', + '2. Symbol': 'IBM', + '3. Last Refreshed': '2026-08-11', + '4. Output Size': 'Compact', + '5. Time Zone': 'US/Eastern', + }, + 'Time Series (Daily)': { + '2026-08-11': { + '1. open': '236.3100', + '2. high': '241.8000', + '3. low': '235.4400', + '4. close': '238.4200', + '5. volume': '4468987', + }, + }, + }; + expect(Outputs.timeSeriesDaily.parse(captured)).toBeTruthy(); + }); + + it('RSI, whose Meta Data numbers its keys with colons and mixes in a number', () => { + const captured = { + 'Meta Data': { + '1: Symbol': 'IBM', + '2: Indicator': 'Relative Strength Index (RSI)', + '3: Last Refreshed': '2026-08-11', + '4: Interval': 'daily', + '5: Time Period': 14, + '6: Series Type': 'close', + '7: Time Zone': 'US/Eastern Time', + }, + 'Technical Analysis: RSI': { + '2026-08-11': { RSI: '57.4413' }, + }, + }; + expect(Outputs.technicalIndicator.parse(captured)).toBeTruthy(); + }); + + it('OVERVIEW', () => { + const captured = { + Symbol: 'IBM', + AssetType: 'Common Stock', + Name: 'International Business Machines', + Description: + 'International Business Machines Corporation (IBM) is an American multinational technology company.', + Exchange: 'NYSE', + Currency: 'USD', + Country: 'USA', + Sector: 'TECHNOLOGY', + Industry: 'COMPUTER & OFFICE EQUIPMENT', + MarketCapitalization: '221626695000', + }; + expect(Outputs.fundamentalsCompanyOverview.parse(captured)).toBeTruthy(); + }); + + it('CURRENCY_EXCHANGE_RATE', () => { + const captured = { + 'Realtime Currency Exchange Rate': { + '1. From_Currency Code': 'USD', + '2. From_Currency Name': 'United States Dollar', + '3. To_Currency Code': 'JPY', + '4. To_Currency Name': 'Japanese Yen', + '5. Exchange Rate': '159.47913547', + '6. Last Refreshed': '2026-08-12 18:56:09', + '7. Time Zone': 'UTC', + '8. Bid Price': '159.47000000', + '9. Ask Price': '159.48000000', + }, + }; + expect(Outputs.forexExchangeRate.parse(captured)).toBeTruthy(); + }); + + it('SYMBOL_SEARCH', () => { + const captured = { + bestMatches: [ + { + '1. symbol': 'TSCO.LON', + '2. name': 'Tesco PLC', + '3. type': 'Equity', + '4. region': 'United Kingdom', + '5. marketOpen': '08:00', + '6. marketClose': '16:30', + '7. timezone': 'UTC+01', + '8. currency': 'GBX', + '9. matchScore': '0.7273', + }, + ], + }; + expect(Outputs.marketSymbolSearch.parse(captured)).toBeTruthy(); + }); + + it('MARKET_STATUS', () => { + const captured = { + endpoint: 'Global Market Open & Close Status', + markets: [ + { + market_type: 'Equity', + region: 'United States', + primary_exchanges: 'NASDAQ, NYSE, AMEX, BATS', + local_open: '09:30', + local_close: '16:15', + current_status: 'closed', + notes: '', + }, + ], + }; + expect(Outputs.marketStatus.parse(captured)).toBeTruthy(); + }); + + it('TOP_GAINERS_LOSERS', () => { + const mover = { + ticker: 'PLAG', + price: '5.71', + change_amount: '5.1443', + change_percentage: '900.0%', + volume: '1234567', + }; + const captured = { + metadata: 'Top gainers, losers, and most actively traded US tickers', + last_updated: '2026-08-11 16:16:00 US/Eastern', + top_gainers: [mover], + top_losers: [mover], + most_actively_traded: [mover], + }; + expect(Outputs.marketTopGainersLosers.parse(captured)).toBeTruthy(); + }); + + it('INCOME_STATEMENT', () => { + const captured = { + symbol: 'IBM', + annualReports: [ + { + fiscalDateEnding: '2025-12-31', + reportedCurrency: 'USD', + grossProfit: '39297000000', + totalRevenue: '67536000000', + }, + ], + quarterlyReports: [ + { + fiscalDateEnding: '2026-06-30', + reportedCurrency: 'USD', + totalRevenue: '17000000000', + }, + ], + }; + expect(Outputs.fundamentalsIncomeStatement.parse(captured)).toBeTruthy(); + }); + + it('DIVIDENDS', () => { + const captured = { + symbol: 'IBM', + data: [ + { + ex_dividend_date: '2026-08-10', + declaration_date: '2026-07-22', + record_date: '2026-08-10', + payment_date: '2026-09-10', + amount: '1.69', + }, + ], + }; + expect(Outputs.fundamentalsDividends.parse(captured)).toBeTruthy(); + }); + + it('SPLITS', () => { + const captured = { + symbol: 'IBM', + data: [{ effective_date: '2021-11-04', split_factor: '1.0460' }], + }; + expect(Outputs.fundamentalsSplits.parse(captured)).toBeTruthy(); + }); + + it('WHEAT and REAL_GDP share one envelope', () => { + const wheat = { + name: 'Global Price of Wheat', + interval: 'monthly', + unit: 'dollar per metric ton', + data: [{ date: '2026-06-01', value: '199.6482875619048' }], + }; + const gdp = { + name: 'Real Gross Domestic Product', + interval: 'annual', + unit: 'billions of dollars', + data: [{ date: '2025-01-01', value: '23850.442' }], + }; + expect(Outputs.commoditiesWheat.parse(wheat)).toBeTruthy(); + expect(Outputs.economicRealGdp.parse(gdp)).toBeTruthy(); + }); + + it('DIGITAL_CURRENCY_DAILY', () => { + const captured = { + 'Meta Data': { + '1. Information': 'Daily Prices and Volumes for Digital Currency', + '2. Digital Currency Code': 'BTC', + '3. Digital Currency Name': 'Bitcoin', + '4. Market Code': 'USD', + '5. Market Name': 'United States Dollar', + }, + 'Time Series (Digital Currency Daily)': { + '2026-08-12': { + '1. open': '61000.00', + '2. high': '61500.00', + '3. low': '60500.00', + '4. close': '61200.00', + '5. volume': '1234.56', + }, + }, + }; + expect(Outputs.cryptoDaily.parse(captured)).toBeTruthy(); + }); + + it('NEWS_SENTIMENT, whose article scores are numbers but ticker scores are strings', () => { + const captured = { + items: '50', + sentiment_score_definition: 'x <= -0.35: Bearish; ...', + relevance_score_definition: '0 < x <= 1, with a higher score ...', + feed: [ + { + title: 'Apple beats expectations', + url: 'https://example.com/article', + time_published: '20260812T120000', + summary: 'A summary.', + source: 'Example Wire', + overall_sentiment_score: 0.264, + overall_sentiment_label: 'Somewhat-Bullish', + ticker_sentiment: [ + { + ticker: 'AAPL', + relevance_score: '0.9', + ticker_sentiment_score: '0.31', + ticker_sentiment_label: 'Somewhat-Bullish', + }, + ], + }, + ], + }; + expect(Outputs.intelligenceNewsSentiment.parse(captured)).toBeTruthy(); + }); + + it('running_analytics from the separate host', () => { + const captured = { + meta_data: { + symbols: 'AAPL', + window_size: 20, + min_dt: '2026-06-11', + max_dt: '2026-08-11', + ohlc: 'Close', + interval: 'DAILY', + }, + payload: { + RETURNS_CALCULATIONS: { + MEAN: { + RUNNING_MEAN: { AAPL: { '2026-07-13': 0.0037773502417675743 } }, + }, + }, + }, + }; + expect( + Outputs.intelligenceSlidingWindowAnalytics.parse(captured), + ).toBeTruthy(); + }); + + it('LISTING_STATUS rows, decoded from CSV', () => { + const captured = [ + { + symbol: 'A', + name: 'Agilent Technologies Inc', + exchange: 'NYSE', + assetType: 'Stock', + ipoDate: '1999-11-18', + delistingDate: 'null', + status: 'Active', + }, + ]; + expect(Outputs.marketListingStatus.parse(captured)).toBeTruthy(); + }); + + it('SECTOR, which is deprecated upstream and answers with an empty body', () => { + expect(Outputs.marketSector.parse({})).toEqual({}); + }); +}); + +describe('input schemas reject malformed calls', () => { + it('requires a well-formed month', () => { + expect(() => + Inputs.timeSeriesIntraday.parse({ + symbol: 'IBM', + interval: '5min', + month: '2024/01', + }), + ).toThrow(); + expect( + Inputs.timeSeriesIntraday.parse({ + symbol: 'IBM', + interval: '5min', + month: '2024-01', + }), + ).toBeTruthy(); + }); + + it('rejects an unsupported intraday interval', () => { + expect(() => + Inputs.timeSeriesIntraday.parse({ symbol: 'IBM', interval: '2min' }), + ).toThrow(); + }); + + it('rejects an empty ticker', () => { + expect(() => Inputs.timeSeriesDaily.parse({ symbol: '' })).toThrow(); + }); + + it('holds Brent to its own interval range', () => { + // Brent is published daily; the metals and grains are not. + expect(Inputs.commoditiesBrent.parse({ interval: 'daily' })).toBeTruthy(); + expect(() => + Inputs.commoditiesWheat.parse({ interval: 'daily' }), + ).toThrow(); + expect(Inputs.commoditiesWheat.parse({ interval: 'annual' })).toBeTruthy(); + }); + + it('caps bulk quotes at 100 tickers', () => { + const hundred = Array.from({ length: 100 }, (_, i) => `SYM${i}`); + expect( + Inputs.timeSeriesRealtimeBulkQuotes.parse({ symbols: hundred }), + ).toBeTruthy(); + expect(() => + Inputs.timeSeriesRealtimeBulkQuotes.parse({ + symbols: [...hundred, 'ONEMORE'], + }), + ).toThrow(); + }); + + it('requires time_from to precede time_to', () => { + expect(() => + Inputs.intelligenceNewsSentiment.parse({ + time_from: '20260812T0000', + time_to: '20260101T0000', + }), + ).toThrow(); + expect( + Inputs.intelligenceNewsSentiment.parse({ + time_from: '20260101T0000', + time_to: '20260812T0000', + }), + ).toBeTruthy(); + }); + + it('requires time_period for indicators that need one', () => { + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'RSI', + symbol: 'IBM', + interval: 'daily', + }), + ).toThrow(); + expect( + Inputs.technicalIndicator.parse({ + indicator: 'RSI', + symbol: 'IBM', + interval: 'daily', + time_period: 14, + }), + ).toBeTruthy(); + // MACD takes fast/slow/signal periods instead, so it must not be forced. + expect( + Inputs.technicalIndicator.parse({ + indicator: 'MACD', + symbol: 'IBM', + interval: 'daily', + }), + ).toBeTruthy(); + }); + + it('rejects a lower-case indicator name', () => { + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'rsi', + symbol: 'IBM', + interval: 'daily', + time_period: 14, + }), + ).toThrow(); + }); + + it('requires a fiscal quarter in the documented form', () => { + expect(() => + Inputs.fundamentalsEarningsCallTranscript.parse({ + symbol: 'IBM', + quarter: '2024-Q1', + }), + ).toThrow(); + expect( + Inputs.fundamentalsEarningsCallTranscript.parse({ + symbol: 'IBM', + quarter: '2024Q1', + }), + ).toBeTruthy(); + }); +}); diff --git a/packages/alphavantage/schema/database.ts b/packages/alphavantage/schema/database.ts new file mode 100644 index 000000000..39eeef5e7 --- /dev/null +++ b/packages/alphavantage/schema/database.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +/** + * Locally persisted Alpha Vantage entities. + * + * Alpha Vantage is a read-only market-data API: almost everything it returns is + * a price or an indicator that is stale the moment it is stored, so caching it + * would be actively harmful. Only the security reference data is persisted. + * + * `symbols` maps a ticker to its name, exchange and asset type. That mapping is + * the identifier every other operation needs, it changes only when a security + * lists or delists, and the free tier allows just 25 requests per day — so + * resolving a ticker from cache instead of spending a request on + * `SYMBOL_SEARCH` or the 1 MB `LISTING_STATUS` download is a real saving. + * + * Time series, quotes, fundamentals, commodities and economic indicators are + * deliberately NOT stored. + */ + +export const AlphaVantageSymbolEntity = z.object({ + /** Ticker as Alpha Vantage returns it, e.g. `IBM` or `TSCO.LON`. */ + symbol: z.string(), + name: z.string().nullable().optional(), + exchange: z.string().nullable().optional(), + assetType: z.string().nullable().optional(), + region: z.string().nullable().optional(), + currency: z.string().nullable().optional(), + ipoDate: z.string().nullable().optional(), + delistingDate: z.string().nullable().optional(), + /** `Active` or `Delisted` in `LISTING_STATUS`; absent from search results. */ + status: z.string().nullable().optional(), +}); +export type AlphaVantageSymbolEntity = z.infer; diff --git a/packages/alphavantage/schema/index.ts b/packages/alphavantage/schema/index.ts new file mode 100644 index 000000000..42d7c226e --- /dev/null +++ b/packages/alphavantage/schema/index.ts @@ -0,0 +1,8 @@ +import { AlphaVantageSymbolEntity } from './database'; + +export const AlphaVantageSchema = { + version: '1.0.0', + entities: { + symbols: AlphaVantageSymbolEntity, + }, +} as const; diff --git a/packages/alphavantage/tsconfig.json b/packages/alphavantage/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/alphavantage/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/alphavantage/tsup.config.ts b/packages/alphavantage/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/alphavantage/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/alphavantage/webhooks/index.ts b/packages/alphavantage/webhooks/index.ts new file mode 100644 index 000000000..5cba8101b --- /dev/null +++ b/packages/alphavantage/webhooks/index.ts @@ -0,0 +1 @@ +export type { AlphaVantageWebhookOutputs } from './types'; diff --git a/packages/alphavantage/webhooks/types.ts b/packages/alphavantage/webhooks/types.ts new file mode 100644 index 000000000..032fa7e4f --- /dev/null +++ b/packages/alphavantage/webhooks/types.ts @@ -0,0 +1,10 @@ +/** + * Alpha Vantage has no webhook, callback or streaming mechanism — it is a + * request/response HTTP API only. The OSS catalog lists zero triggers for it + * accordingly, and there is no provider-side envelope to model here. + * + * This file exists so the package keeps the shape every Corsair plugin has. + */ + +/** No webhook handlers are registered, and the provider offers none. */ +export type AlphaVantageWebhookOutputs = Record; diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 560db7227..f405147a1 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -25,6 +25,7 @@ export const BaseProviders = [ 'aimlapi', 'airtable', 'algolia', + 'alphavantage', 'alttextai', 'amara', 'ambientweather', @@ -145,6 +146,7 @@ export const ProviderDisplayNames = { aimlapi: 'AI/ML API', airtable: 'Airtable', algolia: 'Algolia', + alphavantage: 'Alpha Vantage', alttextai: 'AltText.ai', amara: 'Amara', ambientweather: 'Ambient Weather', @@ -272,6 +274,7 @@ export type AllProviders = | 'aimlapi' | 'airtable' | 'algolia' + | 'alphavantage' | 'alttextai' | 'amara' | 'ambientweather' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e3fe8336..4a52451e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,6 +593,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/alphavantage: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/alttextai: devDependencies: '@types/jest': @@ -4957,11 +4981,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} From ebfc3d53fc316b6bcc08b38d5d97f1290bdd9147 Mon Sep 17 00:00:00 2001 From: Agam00 Date: Thu, 13 Aug 2026 02:35:13 +0530 Subject: [PATCH 2/7] fix(alphavantage): reject non-2xx CSV responses and redact api key in errors --- packages/alphavantage/client.test.ts | 125 ++++++++++++++++- packages/alphavantage/client.ts | 148 +++++++++++++++++++-- packages/alphavantage/endpoints.test.ts | 68 +++++++++- packages/alphavantage/endpoints/persist.ts | 19 ++- packages/alphavantage/endpoints/types.ts | 8 +- packages/alphavantage/schema.test.ts | 30 +++++ 6 files changed, 376 insertions(+), 22 deletions(-) diff --git a/packages/alphavantage/client.test.ts b/packages/alphavantage/client.test.ts index c1a17a183..a4375702d 100644 --- a/packages/alphavantage/client.test.ts +++ b/packages/alphavantage/client.test.ts @@ -3,6 +3,7 @@ * HTTP-200 error bodies are classified, and how the CSV-only endpoints are * decoded. Network access is mocked, so this runs in CI. */ +import { ApiError } from 'corsair/http'; import { AlphaVantageApiError, assertNoAlphaVantageError, @@ -10,6 +11,7 @@ import { makeAlphaVantageCsvRequest, makeAlphaVantageRequest, parseCsv, + sanitizeApiError, splitCsvLine, } from './client'; @@ -34,15 +36,20 @@ function mockJson(body: unknown, status = 200) { } /** Stubs global fetch with a text response, as the CSV endpoints return. */ -function mockText(body: string, contentType = 'application/x-download') { +function mockText( + body: string, + contentType = 'application/x-download', + status = 200, + extraHeaders: Record = {}, +) { global.fetch = (async (url: string) => { lastUrl = String(url); return { - ok: true, - status: 200, - statusText: 'OK', + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', url: String(url), - headers: new Headers({ 'Content-Type': contentType }), + headers: new Headers({ 'Content-Type': contentType, ...extraHeaders }), json: async () => JSON.parse(body), text: async () => body, }; @@ -221,4 +228,112 @@ describe('CSV decoding', () => { makeAlphaVantageCsvRequest('EARNINGS_CALENDAR', TEST_KEY), ).rejects.toThrow(AlphaVantageApiError); }); + + it('rejects a non-2xx response instead of parsing the error page as rows', async () => { + // A gateway in front of the API can answer 5xx with an HTML page, which + // parseCsv would otherwise turn into a single nonsense row. + mockText( + '503 Service Unavailable', + 'text/html', + 503, + ); + + await expect( + makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), + ).rejects.toThrow(/HTTP 503/); + }); + + it('surfaces a 429 on a CSV endpoint as a retryable ApiError', async () => { + mockText('rate limited', 'text/plain', 429, { 'Retry-After': '30' }); + + await expect( + makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), + ).rejects.toMatchObject({ status: 429, retryAfter: 30_000 }); + }); + + it('does not leak the api key in a CSV transport failure', async () => { + mockText('500', 'text/html', 500); + + await expect( + makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), + ).rejects.toMatchObject({ + url: expect.not.stringContaining(TEST_KEY), + request: expect.objectContaining({ + query: expect.objectContaining({ apikey: '[REDACTED]' }), + }), + }); + }); +}); + +describe('api key redaction', () => { + // Core's ApiError redacts `api_key`, `key`, `token` and `appid`, but Alpha + // Vantage spells its parameter `apikey`, which is not in that set — so + // without the plugin's own sanitiser the live key would ride along in every + // failed request's url and query. + it('strips the key from an ApiError url, query and message', () => { + const raw = new ApiError( + { + method: 'GET', + url: `query?function=GLOBAL_QUOTE&apikey=${TEST_KEY}`, + query: { function: 'GLOBAL_QUOTE', apikey: TEST_KEY }, + }, + { + url: `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&apikey=${TEST_KEY}`, + ok: false, + status: 500, + statusText: 'Server Error', + body: 'boom', + }, + `request to https://www.alphavantage.co/query?apikey=${TEST_KEY} failed`, + ); + + // Confirms the gap is real rather than assumed. + expect(JSON.stringify({ u: raw.url, q: raw.request.query })).toContain( + TEST_KEY, + ); + + const safe = sanitizeApiError(raw) as ApiError; + + expect(safe).toBeInstanceOf(ApiError); + expect(safe.url).not.toContain(TEST_KEY); + expect(safe.message).not.toContain(TEST_KEY); + expect(safe.request.query?.apikey).toBe('[REDACTED]'); + expect(safe.status).toBe(500); + expect(safe.body).toBe('boom'); + }); + + it('leaves non-ApiError values alone', () => { + const plain = new Error('nothing sensitive'); + expect(sanitizeApiError(plain)).toBe(plain); + }); + + it('strips the key from an ApiError raised by the JSON transport', async () => { + // A non-2xx makes the shared transport construct an ApiError from the + // request options — which carry `apikey` — so this is the realistic path + // by which the key would otherwise escape. + mockJson({ message: 'internal error' }, 500); + + let caught: unknown; + try { + await makeAlphaVantageRequest('GLOBAL_QUOTE', TEST_KEY, { + symbol: 'IBM', + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ApiError); + const apiError = caught as ApiError; + expect(apiError.status).toBe(500); + // Serialising the whole error is the assertion that matters: the key must + // not survive anywhere on it. + expect( + JSON.stringify({ + url: apiError.url, + message: apiError.message, + request: apiError.request, + }), + ).not.toContain(TEST_KEY); + expect(apiError.request.query?.apikey).toBe('[REDACTED]'); + }); }); diff --git a/packages/alphavantage/client.ts b/packages/alphavantage/client.ts index 3b10d9fb2..62e03ab7d 100644 --- a/packages/alphavantage/client.ts +++ b/packages/alphavantage/client.ts @@ -3,7 +3,7 @@ import type { OpenAPIConfig, RateLimitConfig, } from 'corsair/http'; -import { request } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; /** Every JSON operation is a GET against this single query endpoint. */ const ALPHA_VANTAGE_API_BASE = 'https://www.alphavantage.co'; @@ -31,6 +31,12 @@ const ALPHA_VANTAGE_RATE_LIMIT_CONFIG: RateLimitConfig = { }, }; +/** + * The CSV endpoints bypass the shared transport, so they need their own + * ceiling. `LISTING_STATUS` is around a megabyte, hence the generous value. + */ +const CSV_REQUEST_TIMEOUT_MS = 30_000; + export type AlphaVantageErrorKind = | 'rate_limit' | 'premium' @@ -119,6 +125,65 @@ export type AlphaVantageQuery = Record< string | number | boolean | undefined >; +/** + * Core's `ApiError` redacts a known set of sensitive query parameters — + * `api_key`, `key`, `token`, `appid` — but Alpha Vantage spells its parameter + * `apikey`, with no underscore, which is not in that set. Without this, a + * failed request would carry the caller's live API key in `error.url` and + * `error.request.query` and into anything that logs the error. + * + * Every transport below therefore routes its failures through + * `sanitizeApiError`, which rebuilds the error with the key already replaced. + * `instanceof ApiError` still holds, so the plugin's error handlers and core's + * retry logic are unaffected. + */ +const REDACTED = '[REDACTED]'; + +function redactApiKeyInUrl(url: string): string { + // Operates on the raw string: the value may be a relative url, and a + // malformed one must still be scrubbed rather than thrown away. + return url.replace(/([?&]apikey=)[^&#]*/gi, `$1${REDACTED}`); +} + +/** + * Rebuilds an `ApiError` with the Alpha Vantage key removed from its url, + * request query and message. Non-`ApiError` values are returned untouched. + */ +export function sanitizeApiError(error: unknown): unknown { + if (!(error instanceof ApiError)) return error; + + const query = error.request.query + ? { + ...error.request.query, + ...('apikey' in error.request.query ? { apikey: REDACTED } : {}), + } + : error.request.query; + + const sanitized = new ApiError( + { + ...error.request, + url: redactApiKeyInUrl(error.request.url), + ...(query ? { query } : {}), + }, + { + url: redactApiKeyInUrl(error.url), + ok: false, + status: error.status, + statusText: error.statusText, + body: error.body, + }, + redactApiKeyInUrl(error.message), + { + retryAfter: error.retryAfter, + rateLimitReset: error.rateLimitReset, + rateLimitRemaining: error.rateLimitRemaining, + rateLimitLimit: error.rateLimitLimit, + }, + ); + sanitized.stack = error.stack; + return sanitized; +} + /** * Issues a JSON request against the Alpha Vantage query endpoint. * @@ -153,9 +218,14 @@ export async function makeAlphaVantageRequest( }, }; - const body = await request(config, requestOptions, { - rateLimitConfig: ALPHA_VANTAGE_RATE_LIMIT_CONFIG, - }); + let body: T; + try { + body = await request(config, requestOptions, { + rateLimitConfig: ALPHA_VANTAGE_RATE_LIMIT_CONFIG, + }); + } catch (error) { + throw sanitizeApiError(error); + } assertNoAlphaVantageError(body); return body; @@ -191,9 +261,14 @@ export async function makeAlphaVantageAnalyticsRequest( }, }; - const body = await request(config, requestOptions, { - rateLimitConfig: ALPHA_VANTAGE_RATE_LIMIT_CONFIG, - }); + let body: T; + try { + body = await request(config, requestOptions, { + rateLimitConfig: ALPHA_VANTAGE_RATE_LIMIT_CONFIG, + }); + } catch (error) { + throw sanitizeApiError(error); + } assertNoAlphaVantageError(body); return body; @@ -269,14 +344,56 @@ export async function makeAlphaVantageCsvRequest( url.searchParams.set('function', functionName); url.searchParams.set('apikey', apiKey); - const response = await fetch(url, { + const requestOptions: ApiRequestOptions = { method: 'GET', - headers: { Accept: 'text/csv' }, - }); + url: 'query', + mediaType: 'text/csv', + query: { ...query, function: functionName, apikey: apiKey }, + }; + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { Accept: 'text/csv' }, + // The shared transport applies its own timeout; this path does not go + // through it, so without this a hung connection would block forever. + signal: AbortSignal.timeout(CSV_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // Never let the raw url — which carries the api key — reach the message. + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Alpha Vantage ${functionName} request failed: ${redactApiKeyInUrl(reason)}`, + ); + } const text = await response.text(); - // An error on a CSV endpoint still arrives as HTTP 200, but as a JSON body. + // A transport-level failure must not be parsed as data. Alpha Vantage + // signals its *own* errors with HTTP 200 and a JSON body, but a gateway or + // CDN in front of the API can still answer 5xx or 429 with an HTML page — + // and `parseCsv` would happily turn that page into rows. + if (!response.ok) { + throw sanitizeApiError( + new ApiError( + requestOptions, + { + url: url.toString(), + ok: false, + status: response.status, + statusText: response.statusText, + // Truncated: an HTML error page is large and adds no signal. + body: text.slice(0, 500), + }, + `Alpha Vantage ${functionName} returned HTTP ${response.status}`, + { retryAfter: parseRetryAfter(response.headers.get('Retry-After')) }, + ), + ); + } + + // An Alpha Vantage error on a CSV endpoint still arrives as HTTP 200, but as + // a JSON body rather than CSV. const trimmed = text.trimStart(); if (trimmed.startsWith('{')) { try { @@ -290,3 +407,12 @@ export async function makeAlphaVantageCsvRequest( return parseCsv(text); } + +/** Seconds or an HTTP date, per RFC 9110, converted to milliseconds. */ +function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000; + const when = Date.parse(header); + return Number.isNaN(when) ? undefined : Math.max(0, when - Date.now()); +} diff --git a/packages/alphavantage/endpoints.test.ts b/packages/alphavantage/endpoints.test.ts index a66f130b6..36fd60ea7 100644 --- a/packages/alphavantage/endpoints.test.ts +++ b/packages/alphavantage/endpoints.test.ts @@ -3,6 +3,7 @@ * one calls, the query it builds, the emptiness checks it applies and the cache * writes it performs. Network access is mocked, so this runs in CI. */ +import { logEventFromContext } from 'corsair/core'; import { Commodities, Crypto, @@ -15,6 +16,18 @@ import { TimeSeries, } from './endpoints'; +// The event-log payload is asserted directly further down: it is the one place +// caller-supplied text could leak into durable storage, so it needs to be +// inspected rather than inferred. +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +const mockLogEvent = logEventFromContext as jest.MockedFunction< + typeof logEventFromContext +>; + type Store = { upsertByEntityId: jest.Mock }; function makeStore(): Store { @@ -899,13 +912,62 @@ describe('symbol caching', () => { }); describe('event log payloads', () => { + /** The payload argument of the most recent logEventFromContext call. */ + const lastLoggedPayload = () => { + const call = mockLogEvent.mock.calls.at(-1); + return call?.[2]; + }; + it('does not record the free-text search term', async () => { const { ctx } = makeCtx(); mockJson({ bestMatches: [] }); - // The handler passes only a match count; asserting on the absence of the - // keyword protects against someone later spreading the raw input in. await Market.symbolSearch(ctx, { keywords: 'private company name' }); - expect(JSON.stringify(lastUrl)).not.toContain('corsair_events'); + + const payload = lastLoggedPayload(); + expect(mockLogEvent).toHaveBeenCalledWith( + expect.anything(), + 'alphavantage.market.symbolSearch', + expect.anything(), + 'completed', + ); + expect(JSON.stringify(payload)).not.toContain('private company name'); + expect(payload).toEqual({ matches: 0 }); + }); + + it('does not record the tickers or topics a news query asked for', async () => { + const { ctx } = makeCtx(); + mockJson({ + items: '0', + sentiment_score_definition: 'd', + relevance_score_definition: 'd', + feed: [], + }); + + // Together these describe a watchlist, which is information about the + // caller rather than about the request. + await Intelligence.newsSentiment(ctx, { + tickers: ['AAPL', 'TSLA'], + topics: ['earnings'], + limit: 5, + }); + + const serialized = JSON.stringify(lastLoggedPayload()); + expect(serialized).not.toContain('AAPL'); + expect(serialized).not.toContain('TSLA'); + expect(serialized).not.toContain('earnings'); + expect(serialized).toContain('limit'); + }); + + it('records identifiers that are not caller-authored', async () => { + const { ctx } = makeCtx(); + mockJson(SERIES); + + await TimeSeries.daily(ctx, { symbol: 'IBM', outputsize: 'compact' }); + + expect(lastLoggedPayload()).toMatchObject({ + symbol: 'IBM', + outputsize: 'compact', + }); }); }); diff --git a/packages/alphavantage/endpoints/persist.ts b/packages/alphavantage/endpoints/persist.ts index 846143a28..1ed620703 100644 --- a/packages/alphavantage/endpoints/persist.ts +++ b/packages/alphavantage/endpoints/persist.ts @@ -53,13 +53,28 @@ export async function cacheSymbol( ); } +/** + * How many cache writes may be in flight at once. + * + * `LISTING_STATUS` returns every security Alpha Vantage covers — tens of + * thousands of rows — and awaiting each write in turn makes that one call take + * far longer than the request it followed. The cap keeps the improvement + * without letting a single call flood the database with thousands of + * simultaneous writes. + */ +const CACHE_WRITE_CONCURRENCY = 16; + /** Mirrors many securities, skipping rows with no ticker. */ export async function cacheSymbols( store: EntityStore | undefined, symbols: readonly (SymbolCandidate | undefined | null)[], ) { if (!store) return; - for (const symbol of symbols) { - await cacheSymbol(store, symbol); + + for (let i = 0; i < symbols.length; i += CACHE_WRITE_CONCURRENCY) { + const batch = symbols.slice(i, i + CACHE_WRITE_CONCURRENCY); + // `cacheSymbol` swallows its own failures, so no write in a batch can + // reject and abandon the rest. + await Promise.all(batch.map((symbol) => cacheSymbol(store, symbol))); } } diff --git a/packages/alphavantage/endpoints/types.ts b/packages/alphavantage/endpoints/types.ts index 69bd80197..965a8e9f1 100644 --- a/packages/alphavantage/endpoints/types.ts +++ b/packages/alphavantage/endpoints/types.ts @@ -360,10 +360,16 @@ export const AlphaVantageEndpointInputSchemas = { * `BBANDS`, `STOCH`. The catalog collapses roughly fifty separate * provider functions into this one operation. */ + // Digits are allowed after the first character: several Alpha Vantage + // indicator functions carry one, such as `T3` (triple exponential + // moving average). A letters-only pattern would reject them. indicator: z .string() .min(1) - .regex(/^[A-Z_]+$/, 'indicator must be upper-case, e.g. RSI'), + .regex( + /^[A-Z][A-Z0-9_]*$/, + 'indicator must be upper-case and start with a letter, e.g. RSI or T3', + ), symbol: SymbolSchema, interval: z.enum([ '1min', diff --git a/packages/alphavantage/schema.test.ts b/packages/alphavantage/schema.test.ts index ca7d1b2eb..ba65502b0 100644 --- a/packages/alphavantage/schema.test.ts +++ b/packages/alphavantage/schema.test.ts @@ -412,6 +412,36 @@ describe('input schemas reject malformed calls', () => { ).toBeTruthy(); }); + it('accepts indicator names containing a digit', () => { + // T3 is a real Alpha Vantage function (triple exponential moving + // average). A letters-only pattern would reject it. + expect( + Inputs.technicalIndicator.parse({ + indicator: 'T3', + symbol: 'IBM', + interval: 'daily', + time_period: 10, + }), + ).toBeTruthy(); + expect( + Inputs.technicalIndicator.parse({ + indicator: 'STOCHRSI', + symbol: 'IBM', + interval: 'daily', + }), + ).toBeTruthy(); + }); + + it('rejects an indicator name starting with a digit', () => { + expect(() => + Inputs.technicalIndicator.parse({ + indicator: '3T', + symbol: 'IBM', + interval: 'daily', + }), + ).toThrow(); + }); + it('rejects a lower-case indicator name', () => { expect(() => Inputs.technicalIndicator.parse({ From c407ec7b94146559d0e8a1763aa392a0048d369b Mon Sep 17 00:00:00 2001 From: Agam00 Date: Thu, 13 Aug 2026 02:52:30 +0530 Subject: [PATCH 3/7] fix(alphavantage): redact api key in error bodies and retry rate-limited CSV requests --- packages/alphavantage/client.test.ts | 122 +++++++++++++++++++++--- packages/alphavantage/client.ts | 113 ++++++++++++++-------- packages/alphavantage/endpoints.test.ts | 20 +++- 3 files changed, 199 insertions(+), 56 deletions(-) diff --git a/packages/alphavantage/client.test.ts b/packages/alphavantage/client.test.ts index a4375702d..9b19a0329 100644 --- a/packages/alphavantage/client.test.ts +++ b/packages/alphavantage/client.test.ts @@ -11,6 +11,7 @@ import { makeAlphaVantageCsvRequest, makeAlphaVantageRequest, parseCsv, + parseRetryAfter, sanitizeApiError, splitCsvLine, } from './client'; @@ -243,25 +244,124 @@ describe('CSV decoding', () => { ).rejects.toThrow(/HTTP 503/); }); - it('surfaces a 429 on a CSV endpoint as a retryable ApiError', async () => { - mockText('rate limited', 'text/plain', 429, { 'Retry-After': '30' }); + it('retries a 429 and succeeds on a later attempt', async () => { + let calls = 0; + global.fetch = (async (url: string) => { + calls++; + const limited = calls < 3; + return { + ok: !limited, + status: limited ? 429 : 200, + statusText: limited ? 'Too Many Requests' : 'OK', + url: String(url), + headers: new Headers({ 'Retry-After': '0' }), + json: async () => ({}), + text: async () => (limited ? 'rate limited' : 'symbol,name\nIBM,IBM\n'), + }; + }) as unknown as typeof global.fetch; + + const rows = await makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY); + + expect(calls).toBe(3); + expect(rows).toEqual([{ symbol: 'IBM', name: 'IBM' }]); + }); + + it('gives up on a 429 once the retry budget is spent', async () => { + let calls = 0; + global.fetch = (async (url: string) => { + calls++; + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: String(url), + headers: new Headers({ 'Retry-After': '0' }), + json: async () => ({}), + text: async () => 'rate limited', + }; + }) as unknown as typeof global.fetch; await expect( makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), - ).rejects.toMatchObject({ status: 429, retryAfter: 30_000 }); + ).rejects.toMatchObject({ status: 429 }); + // One initial attempt plus the configured retries. + expect(calls).toBe(3); }); - it('does not leak the api key in a CSV transport failure', async () => { - mockText('500', 'text/html', 500); + it('does not retry a 5xx', async () => { + let calls = 0; + global.fetch = (async (url: string) => { + calls++; + return { + ok: false, + status: 503, + statusText: 'Service Unavailable', + url: String(url), + headers: new Headers(), + json: async () => ({}), + text: async () => 'down', + }; + }) as unknown as typeof global.fetch; await expect( makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), - ).rejects.toMatchObject({ - url: expect.not.stringContaining(TEST_KEY), - request: expect.objectContaining({ - query: expect.objectContaining({ apikey: '[REDACTED]' }), - }), - }); + ).rejects.toMatchObject({ status: 503 }); + expect(calls).toBe(1); + }); + + it('does not leak the api key in a CSV transport failure, including in the body', async () => { + // Gateways routinely echo the request URI into their error page, which is + // how the key would otherwise end up in `error.body`. + global.fetch = (async (url: string) => ({ + ok: false, + status: 500, + statusText: 'Server Error', + url: String(url), + headers: new Headers({ 'Content-Type': 'text/html' }), + json: async () => ({}), + text: async () => `Cannot GET ${String(url)}`, + })) as unknown as typeof global.fetch; + + let caught: unknown; + try { + await makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY); + } catch (error) { + caught = error; + } + + const apiError = caught as ApiError; + expect(apiError.status).toBe(500); + // The unredacted body would have contained the key, so this asserts the + // scrubbing rather than the absence of an echo. + expect(apiError.body).toContain('[REDACTED]'); + expect(apiError.body).not.toContain(TEST_KEY); + expect(apiError.url).not.toContain(TEST_KEY); + expect(apiError.request.query?.apikey).toBe('[REDACTED]'); + }); +}); + +describe('Retry-After parsing', () => { + it('reads a delay in seconds', () => { + expect(parseRetryAfter('30')).toBe(30_000); + expect(parseRetryAfter('0')).toBe(0); + }); + + it('reads an HTTP date', () => { + const tenSeconds = new Date(Date.now() + 10_000).toUTCString(); + const parsed = parseRetryAfter(tenSeconds) ?? 0; + // Second-granularity in the header makes this approximate. + expect(parsed).toBeGreaterThan(8_000); + expect(parsed).toBeLessThanOrEqual(11_000); + }); + + it('ignores a missing or unparseable header', () => { + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter('soon')).toBeUndefined(); + }); + + it('never returns a negative delay for a date in the past', () => { + const past = new Date(Date.now() - 60_000).toUTCString(); + expect(parseRetryAfter(past)).toBe(0); }); }); diff --git a/packages/alphavantage/client.ts b/packages/alphavantage/client.ts index 62e03ab7d..8b93c5243 100644 --- a/packages/alphavantage/client.ts +++ b/packages/alphavantage/client.ts @@ -37,6 +37,9 @@ const ALPHA_VANTAGE_RATE_LIMIT_CONFIG: RateLimitConfig = { */ const CSV_REQUEST_TIMEOUT_MS = 30_000; +/** Ceiling on a single retry wait, however long the provider asks for. */ +const CSV_MAX_RETRY_DELAY_MS = 5_000; + export type AlphaVantageErrorKind = | 'rate_limit' | 'premium' @@ -170,7 +173,12 @@ export function sanitizeApiError(error: unknown): unknown { ok: false, status: error.status, statusText: error.statusText, - body: error.body, + // A gateway's error page commonly echoes the request URI, so a string + // body is scrubbed too rather than only the url. + body: + typeof error.body === 'string' + ? redactApiKeyInUrl(error.body) + : error.body, }, redactApiKeyInUrl(error.message), { @@ -351,45 +359,70 @@ export async function makeAlphaVantageCsvRequest( query: { ...query, function: functionName, apikey: apiKey }, }; - let response: Response; - try { - response = await fetch(url, { - method: 'GET', - headers: { Accept: 'text/csv' }, - // The shared transport applies its own timeout; this path does not go - // through it, so without this a hung connection would block forever. - signal: AbortSignal.timeout(CSV_REQUEST_TIMEOUT_MS), - }); - } catch (error) { - // Never let the raw url — which carries the api key — reach the message. - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `Alpha Vantage ${functionName} request failed: ${redactApiKeyInUrl(reason)}`, - ); - } + let text = ''; - const text = await response.text(); - - // A transport-level failure must not be parsed as data. Alpha Vantage - // signals its *own* errors with HTTP 200 and a JSON body, but a gateway or - // CDN in front of the API can still answer 5xx or 429 with an HTML page — - // and `parseCsv` would happily turn that page into rows. - if (!response.ok) { - throw sanitizeApiError( - new ApiError( - requestOptions, - { - url: url.toString(), - ok: false, - status: response.status, - statusText: response.statusText, - // Truncated: an HTML error page is large and adds no signal. - body: text.slice(0, 500), - }, - `Alpha Vantage ${functionName} returned HTTP ${response.status}`, - { retryAfter: parseRetryAfter(response.headers.get('Retry-After')) }, - ), - ); + // The shared transport retries a 429 for the JSON operations; this path does + // not go through it, so the same behaviour is reproduced here rather than + // letting the CSV operations fail on a limit the others ride out. Only 429 + // is retried — a 5xx from the CDN is not something a second identical + // request is likely to fix. + for (let attempt = 0; ; attempt++) { + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { Accept: 'text/csv' }, + // Without this a hung connection would block forever, since the + // shared transport's timeout does not apply here. + signal: AbortSignal.timeout(CSV_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // Never let the raw url — which carries the api key — reach the message. + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Alpha Vantage ${functionName} request failed: ${redactApiKeyInUrl(reason)}`, + ); + } + + text = await response.text(); + if (response.ok) break; + + const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); + const canRetry = + response.status === 429 && + attempt < ALPHA_VANTAGE_RATE_LIMIT_CONFIG.maxRetries; + + if (!canRetry) { + // A transport-level failure must not be parsed as data. Alpha Vantage + // signals its *own* errors with HTTP 200 and a JSON body, but a gateway + // or CDN in front of the API can still answer 5xx or 429 with an HTML + // page — and `parseCsv` would happily turn that page into rows. + throw sanitizeApiError( + new ApiError( + requestOptions, + { + url: url.toString(), + ok: false, + status: response.status, + statusText: response.statusText, + // Truncated because an HTML error page is large and adds no + // signal, and redacted because such pages routinely echo the + // request URI — which carries the api key. + body: redactApiKeyInUrl(text.slice(0, 500)), + }, + `Alpha Vantage ${functionName} returned HTTP ${response.status}`, + { retryAfter: retryAfterMs }, + ), + ); + } + + const backoff = + ALPHA_VANTAGE_RATE_LIMIT_CONFIG.initialRetryDelay * + ALPHA_VANTAGE_RATE_LIMIT_CONFIG.backoffMultiplier ** attempt; + // A server-supplied delay wins, but is capped: a provider asking us to + // wait minutes should surface as an error rather than stall the caller. + const delay = Math.min(retryAfterMs ?? backoff, CSV_MAX_RETRY_DELAY_MS); + await new Promise((resolve) => setTimeout(resolve, delay)); } // An Alpha Vantage error on a CSV endpoint still arrives as HTTP 200, but as @@ -409,7 +442,7 @@ export async function makeAlphaVantageCsvRequest( } /** Seconds or an HTTP date, per RFC 9110, converted to milliseconds. */ -function parseRetryAfter(header: string | null): number | undefined { +export function parseRetryAfter(header: string | null): number | undefined { if (!header) return undefined; const seconds = Number(header); if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000; diff --git a/packages/alphavantage/endpoints.test.ts b/packages/alphavantage/endpoints.test.ts index 36fd60ea7..d8f3b00be 100644 --- a/packages/alphavantage/endpoints.test.ts +++ b/packages/alphavantage/endpoints.test.ts @@ -129,6 +129,9 @@ const STATEMENT = { beforeEach(() => { lastUrl = undefined; + // Otherwise call counts and "last call" assertions accumulate across the + // whole file. + mockLogEvent.mockClear(); }); /* -- every operation calls the function it should -------------------------- */ @@ -952,11 +955,18 @@ describe('event log payloads', () => { limit: 5, }); - const serialized = JSON.stringify(lastLoggedPayload()); - expect(serialized).not.toContain('AAPL'); - expect(serialized).not.toContain('TSLA'); - expect(serialized).not.toContain('earnings'); - expect(serialized).toContain('limit'); + // An exact match rather than absence checks: a substring assertion would + // still pass if some new caller-authored field were added later. + // + // `fields` is the list of supplied field *names* with their values + // dropped — that is what `auditPayload` is for. Recording that a request + // filtered by tickers is fine; recording which tickers is not. + expect(mockLogEvent).toHaveBeenLastCalledWith( + expect.anything(), + 'alphavantage.intelligence.newsSentiment', + { limit: 5, fields: ['tickers', 'topics', 'limit'] }, + 'completed', + ); }); it('records identifiers that are not caller-authored', async () => { From 32a941e209c87b6df029641e1664a676dc1bbf27 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Thu, 13 Aug 2026 03:08:37 +0530 Subject: [PATCH 4/7] fix(alphavantage): persist official OVERVIEW fields Lock the company cache to the 55 live OVERVIEW keys and stop extra_params from setting function, apikey, or datatype. --- packages/alphavantage/endpoints.test.ts | 33 +++- .../alphavantage/endpoints/fundamentals.ts | 3 +- packages/alphavantage/endpoints/market.ts | 3 + packages/alphavantage/endpoints/persist.ts | 26 ++- packages/alphavantage/endpoints/technical.ts | 7 +- packages/alphavantage/endpoints/types.ts | 22 +-- packages/alphavantage/integration.test.ts | 13 +- packages/alphavantage/schema.test.ts | 60 ++++++- packages/alphavantage/schema/database.ts | 160 +++++++++++++++--- packages/alphavantage/schema/index.ts | 5 +- 10 files changed, 286 insertions(+), 46 deletions(-) diff --git a/packages/alphavantage/endpoints.test.ts b/packages/alphavantage/endpoints.test.ts index d8f3b00be..0935b165b 100644 --- a/packages/alphavantage/endpoints.test.ts +++ b/packages/alphavantage/endpoints.test.ts @@ -38,7 +38,7 @@ function makeStore(): Store { type Ctx = Parameters[0]; function makeCtx() { - const db = { symbols: makeStore() }; + const db = { symbols: makeStore(), companies: makeStore() }; const ctx = { key: 'test-alphavantage-key', db, @@ -632,6 +632,27 @@ describe('query construction', () => { expect(query().get('symbol')).toBe('IBM'); }); + it('drops reserved extra_params so they cannot switch the response to CSV or replace the key', async () => { + const { ctx } = makeCtx(); + mockJson(SERIES); + + await Technical.indicator(ctx, { + indicator: 'RSI', + symbol: 'IBM', + interval: 'daily', + time_period: 14, + extra_params: { + datatype: 'csv', + apikey: 'attacker-key', + function: 'OVERVIEW', + }, + }); + + expect(query().get('function')).toBe('RSI'); + expect(query().get('apikey')).toBe('test-alphavantage-key'); + expect(query().get('datatype')).toBeNull(); + }); + // 'intelligence.slidingWindowAnalytics' is the one operation absent from the // table above, because it has no `function` parameter to assert on. Its path // is named here so a coverage sweep over this file still finds all 56. @@ -807,6 +828,8 @@ describe('symbol caching', () => { symbol: 'TSCO.LON', name: 'Tesco PLC', region: 'United Kingdom', + timezone: 'UTC+01', + marketOpen: '08:00', currency: 'GBX', }), ); @@ -851,6 +874,14 @@ describe('symbol caching', () => { 'IBM', expect.objectContaining({ symbol: 'IBM', exchange: 'NYSE' }), ); + expect(db.companies.upsertByEntityId).toHaveBeenCalledWith( + 'IBM', + expect.objectContaining({ + Symbol: 'IBM', + Exchange: 'NYSE', + fetchedAt: expect.any(Date), + }), + ); }); it('skips CSV rows that have no ticker', async () => { diff --git a/packages/alphavantage/endpoints/fundamentals.ts b/packages/alphavantage/endpoints/fundamentals.ts index 659130932..ddc367a3e 100644 --- a/packages/alphavantage/endpoints/fundamentals.ts +++ b/packages/alphavantage/endpoints/fundamentals.ts @@ -2,7 +2,7 @@ import { logEventFromContext } from 'corsair/core'; import { makeAlphaVantageCsvRequest, makeAlphaVantageRequest } from '../client'; import type { AlphaVantageEndpoints } from '../index'; import { auditPayload } from './logging'; -import { cacheSymbol, cacheSymbols } from './persist'; +import { cacheCompany, cacheSymbol, cacheSymbols } from './persist'; import { assertNotEmpty, compactQuery } from './shared'; import type { AlphaVantageEndpointOutputs } from './types'; @@ -27,6 +27,7 @@ export const companyOverview: AlphaVantageEndpoints['fundamentalsCompanyOverview assetType: result.AssetType, currency: result.Currency, }); + await cacheCompany(ctx.db.companies, result); await logEventFromContext( ctx, diff --git a/packages/alphavantage/endpoints/market.ts b/packages/alphavantage/endpoints/market.ts index e03400099..6f5d3eafa 100644 --- a/packages/alphavantage/endpoints/market.ts +++ b/packages/alphavantage/endpoints/market.ts @@ -26,6 +26,9 @@ export const symbolSearch: AlphaVantageEndpoints['marketSymbolSearch'] = async ( name: match['2. name'], assetType: match['3. type'], region: match['4. region'], + marketOpen: match['5. marketOpen'], + marketClose: match['6. marketClose'], + timezone: match['7. timezone'], currency: match['8. currency'], })), ); diff --git a/packages/alphavantage/endpoints/persist.ts b/packages/alphavantage/endpoints/persist.ts index 1ed620703..4d8a051b4 100644 --- a/packages/alphavantage/endpoints/persist.ts +++ b/packages/alphavantage/endpoints/persist.ts @@ -1,4 +1,8 @@ -import type { AlphaVantageSymbolEntity } from '../schema/database'; +import { + AlphaVantageCompany, + AlphaVantageCompanyOverview, + type AlphaVantageSymbolEntity, +} from '../schema/database'; /** * Minimal structural view of a Corsair entity store. Only the operation the @@ -64,6 +68,26 @@ export async function cacheSymbol( */ const CACHE_WRITE_CONCURRENCY = 16; +/** Mirrors a Company Overview row under its `Symbol`. */ +export async function cacheCompany( + store: EntityStore | undefined, + overview: unknown, +) { + if (!store) return; + const parsed = AlphaVantageCompanyOverview.safeParse(overview); + if (!parsed.success) return; + const symbol = parsed.data.Symbol; + if (!symbol) return; + await safely( + () => + store.upsertByEntityId(symbol, { + ...parsed.data, + fetchedAt: new Date(), + }), + `company ${symbol}`, + ); +} + /** Mirrors many securities, skipping rows with no ticker. */ export async function cacheSymbols( store: EntityStore | undefined, diff --git a/packages/alphavantage/endpoints/technical.ts b/packages/alphavantage/endpoints/technical.ts index bca01d01d..d06db3245 100644 --- a/packages/alphavantage/endpoints/technical.ts +++ b/packages/alphavantage/endpoints/technical.ts @@ -33,7 +33,12 @@ export const indicator: AlphaVantageEndpoints['technicalIndicator'] = async ( input.indicator, ctx.key, compactQuery({ - ...input.extra_params, + ...Object.fromEntries( + Object.entries(input.extra_params ?? {}).filter( + ([key]) => + !['function', 'apikey', 'datatype'].includes(key.toLowerCase()), + ), + ), symbol: input.symbol, interval: input.interval, time_period: input.time_period, diff --git a/packages/alphavantage/endpoints/types.ts b/packages/alphavantage/endpoints/types.ts index 965a8e9f1..4457c06e1 100644 --- a/packages/alphavantage/endpoints/types.ts +++ b/packages/alphavantage/endpoints/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AlphaVantageCompanyOverview as CompanyOverviewFields } from '../schema/database'; /** * Input and output schemas for every Alpha Vantage operation. @@ -471,21 +472,12 @@ const MoverSchema = z }) .loose(); -/** `OVERVIEW` — a flat object of ~60 PascalCase string fields. */ -export const CompanyOverviewSchema = z - .object({ - Symbol: z.string(), - AssetType: z.string(), - Name: z.string(), - Description: z.string(), - Exchange: z.string(), - Currency: z.string(), - Country: z.string(), - Sector: z.string(), - Industry: z.string(), - MarketCapitalization: NumericString, - }) - .loose(); +/** + * `OVERVIEW` — official PascalCase keys from live IBM 2026-08-13. + * Only `Symbol` is required; other fields are frequently empty or absent. + * Extra keys are kept (`.loose`) so a newly added official field is not dropped. + */ +export const CompanyOverviewSchema = CompanyOverviewFields.loose(); /** * `INCOME_STATEMENT`, `BALANCE_SHEET` and `CASH_FLOW` share this envelope. The diff --git a/packages/alphavantage/integration.test.ts b/packages/alphavantage/integration.test.ts index a172e3f5e..a462aa2d7 100644 --- a/packages/alphavantage/integration.test.ts +++ b/packages/alphavantage/integration.test.ts @@ -39,6 +39,11 @@ function makeCtx(): Ctx { upserts.push({ id, data }); }, }, + companies: { + upsertByEntityId: async (id: string, data: unknown) => { + upserts.push({ id, data }); + }, + }, }, database: undefined, $getAccountId: async () => 'integration-test', @@ -61,7 +66,7 @@ describeLive('Alpha Vantage live API', () => { const result = await TimeSeries.globalQuote(ctx, { symbol: 'IBM' }); expect(() => Outputs.timeSeriesGlobalQuote.parse(result)).not.toThrow(); - expect(result['Global Quote']).toHaveProperty('01. symbol', 'IBM'); + expect(result['Global Quote']['01. symbol']).toBe('IBM'); }); it('returns a daily series matching the declared schema', async () => { @@ -88,8 +93,10 @@ describeLive('Alpha Vantage live API', () => { Outputs.fundamentalsCompanyOverview.parse(result), ).not.toThrow(); expect(result.Symbol).toBe('IBM'); - expect(upserts).toHaveLength(1); - expect(upserts[0]?.id).toBe('IBM'); + expect(upserts.map((row) => row.id)).toEqual(['IBM', 'IBM']); + expect(upserts[1]?.data).toEqual( + expect.objectContaining({ Symbol: 'IBM' }), + ); }); it('returns a currency exchange rate matching the declared schema', async () => { diff --git a/packages/alphavantage/schema.test.ts b/packages/alphavantage/schema.test.ts index ba65502b0..9e1b05d0b 100644 --- a/packages/alphavantage/schema.test.ts +++ b/packages/alphavantage/schema.test.ts @@ -9,6 +9,7 @@ import { AlphaVantageEndpointInputSchemas as Inputs, AlphaVantageEndpointOutputSchemas as Outputs, } from './endpoints/types'; +import { AlphaVantageCompanyOverview } from './schema/database'; describe('captured live responses satisfy the output schemas', () => { it('GLOBAL_QUOTE', () => { @@ -84,14 +85,69 @@ describe('captured live responses satisfy the output schemas', () => { Name: 'International Business Machines', Description: 'International Business Machines Corporation (IBM) is an American multinational technology company.', + CIK: '51143', Exchange: 'NYSE', Currency: 'USD', Country: 'USA', Sector: 'TECHNOLOGY', - Industry: 'COMPUTER & OFFICE EQUIPMENT', - MarketCapitalization: '221626695000', + Industry: 'INFORMATION TECHNOLOGY SERVICES', + Address: 'ONE NEW ORCHARD ROAD, ARMONK, NY, UNITED STATES, 10504', + OfficialSite: 'https://www.ibm.com', + FiscalYearEnd: 'December', + LatestQuarter: '2026-06-30', + MarketCapitalization: '224623690000', + EBITDA: '16473000000', + PERatio: '21.01', + PEGRatio: '2.405', + BookValue: '36.57', + DividendPerShare: '6.73', + DividendYield: '0.0285', + EPS: '11.35', + RevenuePerShareTTM: '73.7', + ProfitMargin: '0.155', + OperatingMarginTTM: '0.166', + ReturnOnAssetsTTM: '0.053', + ReturnOnEquityTTM: '0.345', + RevenueTTM: '69094998000', + GrossProfitTTM: '40143000000', + DilutedEPSTTM: '11.35', + QuarterlyEarningsGrowthYOY: '-0.018', + QuarterlyRevenueGrowthYOY: '0.011', + AnalystTargetPrice: '244.16', + AnalystRatingStrongBuy: '3', + AnalystRatingBuy: '11', + AnalystRatingHold: '9', + AnalystRatingSell: '1', + AnalystRatingStrongSell: '1', + TrailingPE: '21.01', + ForwardPE: '19.12', + PriceToSalesRatioTTM: '3.251', + PriceToBookRatio: '6.46', + EVToRevenue: '4.049', + EVToEBITDA: '15.92', + Beta: '0.705', + '52WeekHigh': '330.09', + '52WeekLow': '197.77', + '50DayMovingAverage': '257.3', + '200DayMovingAverage': '268.87', + SharesOutstanding: '942134000', + SharesFloat: '919542000', + PercentInsiders: '0.107', + PercentInstitutions: '65.971', + DividendDate: '2026-09-10', + ExDividendDate: '2026-08-10', }; expect(Outputs.fundamentalsCompanyOverview.parse(captured)).toBeTruthy(); + expect(AlphaVantageCompanyOverview.parse(captured).Symbol).toBe('IBM'); + for (const key of Object.keys(captured)) { + expect(AlphaVantageCompanyOverview.shape).toHaveProperty(key); + } + }); + + it('OVERVIEW treats missing fundamentals as absent, not zero', () => { + expect( + Outputs.fundamentalsCompanyOverview.parse({ Symbol: 'ZZZZ' }), + ).toEqual({ Symbol: 'ZZZZ' }); }); it('CURRENCY_EXCHANGE_RATE', () => { diff --git a/packages/alphavantage/schema/database.ts b/packages/alphavantage/schema/database.ts index 39eeef5e7..0835697c1 100644 --- a/packages/alphavantage/schema/database.ts +++ b/packages/alphavantage/schema/database.ts @@ -3,31 +3,149 @@ import { z } from 'zod'; /** * Locally persisted Alpha Vantage entities. * - * Alpha Vantage is a read-only market-data API: almost everything it returns is - * a price or an indicator that is stale the moment it is stored, so caching it - * would be actively harmful. Only the security reference data is persisted. + * Prices, indicators, news and transcripts go stale immediately, so they are + * not stored. Only security identity and the Company Overview profile are + * mirrored — both are the lookup keys every other operation needs, and the + * free tier is 25 requests/day. * - * `symbols` maps a ticker to its name, exchange and asset type. That mapping is - * the identifier every other operation needs, it changes only when a security - * lists or delists, and the free tier allows just 25 requests per day — so - * resolving a ticker from cache instead of spending a request on - * `SYMBOL_SEARCH` or the 1 MB `LISTING_STATUS` download is a real saving. - * - * Time series, quotes, fundamentals, commodities and economic indicators are - * deliberately NOT stored. + * Field names match the official JSON / CSV keys exactly. + * Docs: https://www.alphavantage.co/documentation/ */ +const AvString = z.string().nullable().optional(); + +/** + * Listing & Delisting Status CSV columns, plus Symbol Search extras. + * + * LISTING_STATUS (CSV header, live 2026-08-13): + * symbol, name, exchange, assetType, ipoDate, delistingDate, status + * https://www.alphavantage.co/documentation/#listing-status + * + * SYMBOL_SEARCH (`bestMatches[]`, live 2026-08-13): + * 1. symbol, 2. name, 3. type, 4. region, 5. marketOpen, + * 6. marketClose, 7. timezone, 8. currency, 9. matchScore + * https://www.alphavantage.co/documentation/#symbolsearch + * + * `3. type` is stored as `assetType` (same meaning as LISTING_STATUS). + * `9. matchScore` is a search rank, not identity — not stored. + */ export const AlphaVantageSymbolEntity = z.object({ - /** Ticker as Alpha Vantage returns it, e.g. `IBM` or `TSCO.LON`. */ + /** LISTING_STATUS `symbol` / SEARCH `1. symbol` / OVERVIEW `Symbol`. */ symbol: z.string(), - name: z.string().nullable().optional(), - exchange: z.string().nullable().optional(), - assetType: z.string().nullable().optional(), - region: z.string().nullable().optional(), - currency: z.string().nullable().optional(), - ipoDate: z.string().nullable().optional(), - delistingDate: z.string().nullable().optional(), - /** `Active` or `Delisted` in `LISTING_STATUS`; absent from search results. */ - status: z.string().nullable().optional(), + /** LISTING_STATUS `name` / SEARCH `2. name` / OVERVIEW `Name`. */ + name: AvString, + /** LISTING_STATUS `exchange` / OVERVIEW `Exchange`. */ + exchange: AvString, + /** LISTING_STATUS `assetType` / SEARCH `3. type` / OVERVIEW `AssetType`. */ + assetType: AvString, + /** LISTING_STATUS `ipoDate`. */ + ipoDate: AvString, + /** + * LISTING_STATUS `delistingDate`. + * Alpha Vantage writes the string `"null"` for a still-listed security; + * handlers store `null` instead. + */ + delistingDate: AvString, + /** LISTING_STATUS `status`: `Active` or `Delisted`. */ + status: AvString, + /** SEARCH `4. region`. */ + region: AvString, + /** SEARCH `8. currency` / OVERVIEW `Currency`. */ + currency: AvString, + /** SEARCH `5. marketOpen`. */ + marketOpen: AvString, + /** SEARCH `6. marketClose`. */ + marketClose: AvString, + /** SEARCH `7. timezone`. */ + timezone: AvString, }); export type AlphaVantageSymbolEntity = z.infer; + +/** + * Company Overview (`function=OVERVIEW`). + * + * Official: https://www.alphavantage.co/documentation/#company-overview + * Live IBM 2026-08-13: every value is a string, including numerics. + * Empty / `"None"` / missing fields are stored as-is — treat as null, not zero, + * before calculations (agent docs + Alpha Vantage). + * + * Keys captured from live OVERVIEW (55): + * Symbol, AssetType, Name, Description, CIK, Exchange, Currency, Country, + * Sector, Industry, Address, OfficialSite, FiscalYearEnd, LatestQuarter, + * MarketCapitalization, EBITDA, PERatio, PEGRatio, BookValue, + * DividendPerShare, DividendYield, EPS, RevenuePerShareTTM, ProfitMargin, + * OperatingMarginTTM, ReturnOnAssetsTTM, ReturnOnEquityTTM, RevenueTTM, + * GrossProfitTTM, DilutedEPSTTM, QuarterlyEarningsGrowthYOY, + * QuarterlyRevenueGrowthYOY, AnalystTargetPrice, AnalystRatingStrongBuy, + * AnalystRatingBuy, AnalystRatingHold, AnalystRatingSell, + * AnalystRatingStrongSell, TrailingPE, ForwardPE, PriceToSalesRatioTTM, + * PriceToBookRatio, EVToRevenue, EVToEBITDA, Beta, 52WeekHigh, 52WeekLow, + * 50DayMovingAverage, 200DayMovingAverage, SharesOutstanding, SharesFloat, + * PercentInsiders, PercentInstitutions, DividendDate, ExDividendDate + */ +export const AlphaVantageCompanyOverview = z.object({ + Symbol: z.string(), + AssetType: AvString, + Name: AvString, + Description: AvString, + CIK: AvString, + Exchange: AvString, + Currency: AvString, + Country: AvString, + Sector: AvString, + Industry: AvString, + Address: AvString, + OfficialSite: AvString, + FiscalYearEnd: AvString, + LatestQuarter: AvString, + MarketCapitalization: AvString, + EBITDA: AvString, + PERatio: AvString, + PEGRatio: AvString, + BookValue: AvString, + DividendPerShare: AvString, + DividendYield: AvString, + EPS: AvString, + RevenuePerShareTTM: AvString, + ProfitMargin: AvString, + OperatingMarginTTM: AvString, + ReturnOnAssetsTTM: AvString, + ReturnOnEquityTTM: AvString, + RevenueTTM: AvString, + GrossProfitTTM: AvString, + DilutedEPSTTM: AvString, + QuarterlyEarningsGrowthYOY: AvString, + QuarterlyRevenueGrowthYOY: AvString, + AnalystTargetPrice: AvString, + AnalystRatingStrongBuy: AvString, + AnalystRatingBuy: AvString, + AnalystRatingHold: AvString, + AnalystRatingSell: AvString, + AnalystRatingStrongSell: AvString, + TrailingPE: AvString, + ForwardPE: AvString, + PriceToSalesRatioTTM: AvString, + PriceToBookRatio: AvString, + EVToRevenue: AvString, + EVToEBITDA: AvString, + Beta: AvString, + '52WeekHigh': AvString, + '52WeekLow': AvString, + '50DayMovingAverage': AvString, + '200DayMovingAverage': AvString, + SharesOutstanding: AvString, + SharesFloat: AvString, + PercentInsiders: AvString, + PercentInstitutions: AvString, + DividendDate: AvString, + ExDividendDate: AvString, +}); +export type AlphaVantageCompanyOverview = z.infer< + typeof AlphaVantageCompanyOverview +>; + +/** Cached Company Overview row, plus when it was written. */ +export const AlphaVantageCompany = AlphaVantageCompanyOverview.extend({ + fetchedAt: z.coerce.date().nullable().optional(), +}); +export type AlphaVantageCompany = z.infer; diff --git a/packages/alphavantage/schema/index.ts b/packages/alphavantage/schema/index.ts index 42d7c226e..3555243af 100644 --- a/packages/alphavantage/schema/index.ts +++ b/packages/alphavantage/schema/index.ts @@ -1,8 +1,11 @@ -import { AlphaVantageSymbolEntity } from './database'; +import { AlphaVantageCompany, AlphaVantageSymbolEntity } from './database'; export const AlphaVantageSchema = { version: '1.0.0', entities: { symbols: AlphaVantageSymbolEntity, + companies: AlphaVantageCompany, }, } as const; + +export * from './database'; From 3fa1b5bbfc2a61b6e743a17ef940c1435e2ced73 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Thu, 13 Aug 2026 03:10:42 +0530 Subject: [PATCH 5/7] fix(alphavantage): split type imports in persist.ts --- packages/alphavantage/endpoints/persist.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/alphavantage/endpoints/persist.ts b/packages/alphavantage/endpoints/persist.ts index 4d8a051b4..d4d1e354c 100644 --- a/packages/alphavantage/endpoints/persist.ts +++ b/packages/alphavantage/endpoints/persist.ts @@ -1,8 +1,8 @@ -import { +import type { AlphaVantageCompany, - AlphaVantageCompanyOverview, - type AlphaVantageSymbolEntity, + AlphaVantageSymbolEntity, } from '../schema/database'; +import { AlphaVantageCompanyOverview } from '../schema/database'; /** * Minimal structural view of a Corsair entity store. Only the operation the From 43f410f7c66f6bb8370e5960a3819b250f7cde7a Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Thu, 13 Aug 2026 03:23:24 +0530 Subject: [PATCH 6/7] fix(alphavantage): one CSV retry layer; require indicator series_type --- packages/alphavantage/client.test.ts | 27 +------ packages/alphavantage/client.ts | 99 ++++++++---------------- packages/alphavantage/endpoints/types.ts | 23 +++++- packages/alphavantage/schema.test.ts | 38 ++++++++- 4 files changed, 91 insertions(+), 96 deletions(-) diff --git a/packages/alphavantage/client.test.ts b/packages/alphavantage/client.test.ts index 9b19a0329..885eb223d 100644 --- a/packages/alphavantage/client.test.ts +++ b/packages/alphavantage/client.test.ts @@ -244,29 +244,7 @@ describe('CSV decoding', () => { ).rejects.toThrow(/HTTP 503/); }); - it('retries a 429 and succeeds on a later attempt', async () => { - let calls = 0; - global.fetch = (async (url: string) => { - calls++; - const limited = calls < 3; - return { - ok: !limited, - status: limited ? 429 : 200, - statusText: limited ? 'Too Many Requests' : 'OK', - url: String(url), - headers: new Headers({ 'Retry-After': '0' }), - json: async () => ({}), - text: async () => (limited ? 'rate limited' : 'symbol,name\nIBM,IBM\n'), - }; - }) as unknown as typeof global.fetch; - - const rows = await makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY); - - expect(calls).toBe(3); - expect(rows).toEqual([{ symbol: 'IBM', name: 'IBM' }]); - }); - - it('gives up on a 429 once the retry budget is spent', async () => { + it('rejects a 429 after one attempt so the plugin handler is the only retry layer', async () => { let calls = 0; global.fetch = (async (url: string) => { calls++; @@ -284,8 +262,7 @@ describe('CSV decoding', () => { await expect( makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), ).rejects.toMatchObject({ status: 429 }); - // One initial attempt plus the configured retries. - expect(calls).toBe(3); + expect(calls).toBe(1); }); it('does not retry a 5xx', async () => { diff --git a/packages/alphavantage/client.ts b/packages/alphavantage/client.ts index 8b93c5243..f1921863b 100644 --- a/packages/alphavantage/client.ts +++ b/packages/alphavantage/client.ts @@ -37,9 +37,6 @@ const ALPHA_VANTAGE_RATE_LIMIT_CONFIG: RateLimitConfig = { */ const CSV_REQUEST_TIMEOUT_MS = 30_000; -/** Ceiling on a single retry wait, however long the provider asks for. */ -const CSV_MAX_RETRY_DELAY_MS = 5_000; - export type AlphaVantageErrorKind = | 'rate_limit' | 'premium' @@ -359,70 +356,40 @@ export async function makeAlphaVantageCsvRequest( query: { ...query, function: functionName, apikey: apiKey }, }; - let text = ''; - - // The shared transport retries a 429 for the JSON operations; this path does - // not go through it, so the same behaviour is reproduced here rather than - // letting the CSV operations fail on a limit the others ride out. Only 429 - // is retried — a 5xx from the CDN is not something a second identical - // request is likely to fix. - for (let attempt = 0; ; attempt++) { - let response: Response; - try { - response = await fetch(url, { - method: 'GET', - headers: { Accept: 'text/csv' }, - // Without this a hung connection would block forever, since the - // shared transport's timeout does not apply here. - signal: AbortSignal.timeout(CSV_REQUEST_TIMEOUT_MS), - }); - } catch (error) { - // Never let the raw url — which carries the api key — reach the message. - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `Alpha Vantage ${functionName} request failed: ${redactApiKeyInUrl(reason)}`, - ); - } - - text = await response.text(); - if (response.ok) break; - - const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After')); - const canRetry = - response.status === 429 && - attempt < ALPHA_VANTAGE_RATE_LIMIT_CONFIG.maxRetries; - - if (!canRetry) { - // A transport-level failure must not be parsed as data. Alpha Vantage - // signals its *own* errors with HTTP 200 and a JSON body, but a gateway - // or CDN in front of the API can still answer 5xx or 429 with an HTML - // page — and `parseCsv` would happily turn that page into rows. - throw sanitizeApiError( - new ApiError( - requestOptions, - { - url: url.toString(), - ok: false, - status: response.status, - statusText: response.statusText, - // Truncated because an HTML error page is large and adds no - // signal, and redacted because such pages routinely echo the - // request URI — which carries the api key. - body: redactApiKeyInUrl(text.slice(0, 500)), - }, - `Alpha Vantage ${functionName} returned HTTP ${response.status}`, - { retryAfter: retryAfterMs }, - ), - ); - } + // One fetch. A 429 is thrown as ApiError so RATE_LIMIT_ERROR is the only + // retry layer — an inner loop here would multiply with that handler. + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { Accept: 'text/csv' }, + signal: AbortSignal.timeout(CSV_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Alpha Vantage ${functionName} request failed: ${redactApiKeyInUrl(reason)}`, + ); + } - const backoff = - ALPHA_VANTAGE_RATE_LIMIT_CONFIG.initialRetryDelay * - ALPHA_VANTAGE_RATE_LIMIT_CONFIG.backoffMultiplier ** attempt; - // A server-supplied delay wins, but is capped: a provider asking us to - // wait minutes should surface as an error rather than stall the caller. - const delay = Math.min(retryAfterMs ?? backoff, CSV_MAX_RETRY_DELAY_MS); - await new Promise((resolve) => setTimeout(resolve, delay)); + const text = await response.text(); + if (!response.ok) { + throw sanitizeApiError( + new ApiError( + requestOptions, + { + url: url.toString(), + ok: false, + status: response.status, + statusText: response.statusText, + body: redactApiKeyInUrl(text.slice(0, 500)), + }, + `Alpha Vantage ${functionName} returned HTTP ${response.status}`, + { + retryAfter: parseRetryAfter(response.headers.get('Retry-After')), + }, + ), + ); } // An Alpha Vantage error on a CSV endpoint still arrives as HTTP 200, but as diff --git a/packages/alphavantage/endpoints/types.ts b/packages/alphavantage/endpoints/types.ts index 4457c06e1..b5e627c77 100644 --- a/packages/alphavantage/endpoints/types.ts +++ b/packages/alphavantage/endpoints/types.ts @@ -395,13 +395,30 @@ export const AlphaVantageEndpointInputSchemas = { }) .refine( (input) => - !['SMA', 'EMA', 'RSI', 'WMA', 'DEMA', 'TEMA', 'MOM', 'ROC'].includes( - input.indicator, - ) || input.time_period !== undefined, + ![ + 'SMA', + 'EMA', + 'RSI', + 'WMA', + 'DEMA', + 'TEMA', + 'MOM', + 'ROC', + 'STOCHRSI', + ].includes(input.indicator) || input.time_period !== undefined, { message: 'time_period is required for this indicator', path: ['time_period'], }, + ) + .refine( + (input) => + !['RSI', 'MACD', 'STOCHRSI'].includes(input.indicator) || + input.series_type !== undefined, + { + message: 'series_type is required for this indicator', + path: ['series_type'], + }, ), } as const; diff --git a/packages/alphavantage/schema.test.ts b/packages/alphavantage/schema.test.ts index 9e1b05d0b..f1adb836b 100644 --- a/packages/alphavantage/schema.test.ts +++ b/packages/alphavantage/schema.test.ts @@ -442,7 +442,7 @@ describe('input schemas reject malformed calls', () => { ).toBeTruthy(); }); - it('requires time_period for indicators that need one', () => { + it('requires time_period and series_type where Alpha Vantage does', () => { expect(() => Inputs.technicalIndicator.parse({ indicator: 'RSI', @@ -450,20 +450,52 @@ describe('input schemas reject malformed calls', () => { interval: 'daily', }), ).toThrow(); + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'RSI', + symbol: 'IBM', + interval: 'daily', + time_period: 14, + }), + ).toThrow(); expect( Inputs.technicalIndicator.parse({ indicator: 'RSI', symbol: 'IBM', interval: 'daily', time_period: 14, + series_type: 'close', }), ).toBeTruthy(); - // MACD takes fast/slow/signal periods instead, so it must not be forced. + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'MACD', + symbol: 'IBM', + interval: 'daily', + }), + ).toThrow(); expect( Inputs.technicalIndicator.parse({ indicator: 'MACD', symbol: 'IBM', interval: 'daily', + series_type: 'close', + }), + ).toBeTruthy(); + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'STOCHRSI', + symbol: 'IBM', + interval: 'daily', + }), + ).toThrow(); + expect( + Inputs.technicalIndicator.parse({ + indicator: 'STOCHRSI', + symbol: 'IBM', + interval: 'daily', + time_period: 14, + series_type: 'close', }), ).toBeTruthy(); }); @@ -484,6 +516,8 @@ describe('input schemas reject malformed calls', () => { indicator: 'STOCHRSI', symbol: 'IBM', interval: 'daily', + time_period: 14, + series_type: 'close', }), ).toBeTruthy(); }); From c567c22f7cf13fe07414b4157c94fff05129d8f5 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Thu, 13 Aug 2026 03:30:05 +0530 Subject: [PATCH 7/7] fix(alphavantage): cap Retry-After; require T3 params --- packages/alphavantage/client.test.ts | 18 ++++++++++++++++ packages/alphavantage/client.ts | 4 +++- packages/alphavantage/endpoints/types.ts | 3 ++- packages/alphavantage/error-handlers.ts | 3 ++- packages/alphavantage/schema.test.ts | 26 ++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/alphavantage/client.test.ts b/packages/alphavantage/client.test.ts index 885eb223d..908f9254e 100644 --- a/packages/alphavantage/client.test.ts +++ b/packages/alphavantage/client.test.ts @@ -265,6 +265,24 @@ describe('CSV decoding', () => { expect(calls).toBe(1); }); + it('caps a CSV 429 Retry-After at five seconds', async () => { + global.fetch = (async (url: string) => { + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: String(url), + headers: new Headers({ 'Retry-After': '120' }), + json: async () => ({}), + text: async () => 'rate limited', + }; + }) as unknown as typeof global.fetch; + + await expect( + makeAlphaVantageCsvRequest('LISTING_STATUS', TEST_KEY), + ).rejects.toMatchObject({ status: 429, retryAfter: 5_000 }); + }); + it('does not retry a 5xx', async () => { let calls = 0; global.fetch = (async (url: string) => { diff --git a/packages/alphavantage/client.ts b/packages/alphavantage/client.ts index f1921863b..57deccdf2 100644 --- a/packages/alphavantage/client.ts +++ b/packages/alphavantage/client.ts @@ -374,6 +374,7 @@ export async function makeAlphaVantageCsvRequest( const text = await response.text(); if (!response.ok) { + const retryAfter = parseRetryAfter(response.headers.get('Retry-After')); throw sanitizeApiError( new ApiError( requestOptions, @@ -386,7 +387,8 @@ export async function makeAlphaVantageCsvRequest( }, `Alpha Vantage ${functionName} returned HTTP ${response.status}`, { - retryAfter: parseRetryAfter(response.headers.get('Retry-After')), + retryAfter: + retryAfter === undefined ? undefined : Math.min(retryAfter, 5_000), }, ), ); diff --git a/packages/alphavantage/endpoints/types.ts b/packages/alphavantage/endpoints/types.ts index b5e627c77..e47448597 100644 --- a/packages/alphavantage/endpoints/types.ts +++ b/packages/alphavantage/endpoints/types.ts @@ -405,6 +405,7 @@ export const AlphaVantageEndpointInputSchemas = { 'MOM', 'ROC', 'STOCHRSI', + 'T3', ].includes(input.indicator) || input.time_period !== undefined, { message: 'time_period is required for this indicator', @@ -413,7 +414,7 @@ export const AlphaVantageEndpointInputSchemas = { ) .refine( (input) => - !['RSI', 'MACD', 'STOCHRSI'].includes(input.indicator) || + !['RSI', 'MACD', 'STOCHRSI', 'T3'].includes(input.indicator) || input.series_type !== undefined, { message: 'series_type is required for this indicator', diff --git a/packages/alphavantage/error-handlers.ts b/packages/alphavantage/error-handlers.ts index 4a756cef1..9156fc38c 100644 --- a/packages/alphavantage/error-handlers.ts +++ b/packages/alphavantage/error-handlers.ts @@ -47,7 +47,8 @@ export const errorHandlers = { handler: async (error, context) => { let retryAfterMs: number | undefined; if (error instanceof ApiError && error.retryAfter !== undefined) { - retryAfterMs = error.retryAfter; + // A gateway can send Retry-After of minutes; don't stall the caller. + retryAfterMs = Math.min(error.retryAfter, 5_000); } console.warn( diff --git a/packages/alphavantage/schema.test.ts b/packages/alphavantage/schema.test.ts index f1adb836b..4b7a1a634 100644 --- a/packages/alphavantage/schema.test.ts +++ b/packages/alphavantage/schema.test.ts @@ -498,6 +498,31 @@ describe('input schemas reject malformed calls', () => { series_type: 'close', }), ).toBeTruthy(); + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'T3', + symbol: 'IBM', + interval: 'daily', + series_type: 'close', + }), + ).toThrow(); + expect(() => + Inputs.technicalIndicator.parse({ + indicator: 'T3', + symbol: 'IBM', + interval: 'daily', + time_period: 10, + }), + ).toThrow(); + expect( + Inputs.technicalIndicator.parse({ + indicator: 'T3', + symbol: 'IBM', + interval: 'daily', + time_period: 10, + series_type: 'close', + }), + ).toBeTruthy(); }); it('accepts indicator names containing a digit', () => { @@ -509,6 +534,7 @@ describe('input schemas reject malformed calls', () => { symbol: 'IBM', interval: 'daily', time_period: 10, + series_type: 'close', }), ).toBeTruthy(); expect(