diff --git a/src/tools/finance/api.ts b/src/tools/finance/api.ts index b992e3d2..131da445 100644 --- a/src/tools/finance/api.ts +++ b/src/tools/finance/api.ts @@ -11,7 +11,7 @@ export interface ApiResponse { export async function callApi( endpoint: string, params: Record, - options?: { cacheable?: boolean } + options?: { cacheable?: boolean; cacheTtlMs?: number } ): Promise { const label = describeRequest(endpoint, params); @@ -70,7 +70,7 @@ export async function callApi( // Persist for future requests when the caller marked the response as cacheable if (options?.cacheable) { - writeCache(endpoint, params, data, url.toString()); + writeCache(endpoint, params, data, url.toString(), options.cacheTtlMs); } return { data, url: url.toString() }; diff --git a/src/tools/finance/company_facts.ts b/src/tools/finance/company_facts.ts index d807ca5c..8b077158 100644 --- a/src/tools/finance/company_facts.ts +++ b/src/tools/finance/company_facts.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_MONTHLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const CompanyFactsInputSchema = z.object({ @@ -14,7 +15,7 @@ export const getCompanyFacts = new DynamicStructuredTool({ description: `Retrieves company facts and metadata for a given ticker, including sector, industry, market cap, number of employees, listing date, exchange, location, weighted average shares, website. Useful for getting an overview of a company's profile and basic information.`, schema: CompanyFactsInputSchema, func: async (input) => { - const { data, url } = await callApi('/company/facts', { ticker: input.ticker }); + const { data, url } = await callApi('/company/facts', { ticker: input.ticker }, { cacheable: true, cacheTtlMs: CACHE_TTL_MONTHLY }); return formatToolResult(data.company_facts || {}, [url]); }, }); diff --git a/src/tools/finance/crypto.ts b/src/tools/finance/crypto.ts index 3bc3d8be..ec87c822 100644 --- a/src/tools/finance/crypto.ts +++ b/src/tools/finance/crypto.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_WEEKLY, CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const CryptoPriceSnapshotInputSchema = z.object({ @@ -17,7 +18,7 @@ export const getCryptoPriceSnapshot = new DynamicStructuredTool({ schema: CryptoPriceSnapshotInputSchema, func: async (input) => { const params = { ticker: input.ticker }; - const { data, url } = await callApi('/crypto/prices/snapshot/', params); + const { data, url } = await callApi('/crypto/prices/snapshot/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_WEEKLY }); return formatToolResult(data.snapshot || {}, [url]); }, }); @@ -56,7 +57,7 @@ export const getCryptoPrices = new DynamicStructuredTool({ const endDate = new Date(input.end_date + 'T00:00:00'); const today = new Date(); today.setHours(0, 0, 0, 0); - const { data, url } = await callApi('/crypto/prices/', params, { cacheable: endDate < today }); + const { data, url } = await callApi('/crypto/prices/', params, { cacheable: endDate < today, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.prices || [], [url]); }, }); diff --git a/src/tools/finance/estimates.ts b/src/tools/finance/estimates.ts index 780355ba..fe171493 100644 --- a/src/tools/finance/estimates.ts +++ b/src/tools/finance/estimates.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_MONTHLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const AnalystEstimatesInputSchema = z.object({ @@ -24,7 +25,7 @@ export const getAnalystEstimates = new DynamicStructuredTool({ ticker: input.ticker, period: input.period, }; - const { data, url } = await callApi('/analyst-estimates/', params); + const { data, url } = await callApi('/analyst-estimates/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_MONTHLY }); return formatToolResult(data.analyst_estimates || [], [url]); }, }); diff --git a/src/tools/finance/filings.ts b/src/tools/finance/filings.ts index 29493b1a..a6401390 100644 --- a/src/tools/finance/filings.ts +++ b/src/tools/finance/filings.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; // Types for filing item metadata @@ -63,7 +64,7 @@ export const getFilings = new DynamicStructuredTool({ limit: input.limit, filing_type: input.filing_type, }; - const { data, url } = await callApi('/filings/', params); + const { data, url } = await callApi('/filings/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.filings || [], [url]); }, }); @@ -95,7 +96,7 @@ export const get10KFilingItems = new DynamicStructuredTool({ item: input.items, // API expects 'item' not 'items' }; // SEC filings are legally immutable once filed - const { data, url } = await callApi('/filings/items/', params, { cacheable: true }); + const { data, url } = await callApi('/filings/items/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data, [url]); }, }); @@ -127,7 +128,7 @@ export const get10QFilingItems = new DynamicStructuredTool({ item: input.items, // API expects 'item' not 'items' }; // SEC filings are legally immutable once filed - const { data, url } = await callApi('/filings/items/', params, { cacheable: true }); + const { data, url } = await callApi('/filings/items/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data, [url]); }, }); @@ -152,7 +153,7 @@ export const get8KFilingItems = new DynamicStructuredTool({ accession_number: input.accession_number, }; // SEC filings are legally immutable once filed - const { data, url } = await callApi('/filings/items/', params, { cacheable: true }); + const { data, url } = await callApi('/filings/items/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data, [url]); }, }); diff --git a/src/tools/finance/fundamentals.ts b/src/tools/finance/fundamentals.ts index f698433a..e6918422 100644 --- a/src/tools/finance/fundamentals.ts +++ b/src/tools/finance/fundamentals.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const FinancialStatementsInputSchema = z.object({ @@ -60,7 +61,7 @@ export const getIncomeStatements = new DynamicStructuredTool({ schema: FinancialStatementsInputSchema, func: async (input) => { const params = createParams(input); - const { data, url } = await callApi('/financials/income-statements/', params); + const { data, url } = await callApi('/financials/income-statements/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.income_statements || {}, [url]); }, }); @@ -71,7 +72,7 @@ export const getBalanceSheets = new DynamicStructuredTool({ schema: FinancialStatementsInputSchema, func: async (input) => { const params = createParams(input); - const { data, url } = await callApi('/financials/balance-sheets/', params); + const { data, url } = await callApi('/financials/balance-sheets/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.balance_sheets || {}, [url]); }, }); @@ -82,7 +83,7 @@ export const getCashFlowStatements = new DynamicStructuredTool({ schema: FinancialStatementsInputSchema, func: async (input) => { const params = createParams(input); - const { data, url } = await callApi('/financials/cash-flow-statements/', params); + const { data, url } = await callApi('/financials/cash-flow-statements/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.cash_flow_statements || {}, [url]); }, }); @@ -93,7 +94,7 @@ export const getAllFinancialStatements = new DynamicStructuredTool({ schema: FinancialStatementsInputSchema, func: async (input) => { const params = createParams(input); - const { data, url } = await callApi('/financials/', params); + const { data, url } = await callApi('/financials/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.financials || {}, [url]); }, }); diff --git a/src/tools/finance/insider_trades.ts b/src/tools/finance/insider_trades.ts index 9f1ae13d..6b5c7727 100644 --- a/src/tools/finance/insider_trades.ts +++ b/src/tools/finance/insider_trades.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const InsiderTradesInputSchema = z.object({ @@ -47,7 +48,7 @@ export const getInsiderTrades = new DynamicStructuredTool({ filing_date_gt: input.filing_date_gt, filing_date_lt: input.filing_date_lt, }; - const { data, url } = await callApi('/insider-trades/', params); + const { data, url } = await callApi('/insider-trades/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.insider_trades || [], [url]); }, }); diff --git a/src/tools/finance/key-ratios.ts b/src/tools/finance/key-ratios.ts index 0cbeae4a..f22184e7 100644 --- a/src/tools/finance/key-ratios.ts +++ b/src/tools/finance/key-ratios.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_WEEKLY, CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const KeyRatiosSnapshotInputSchema = z.object({ @@ -17,7 +18,7 @@ export const getKeyRatiosSnapshot = new DynamicStructuredTool({ schema: KeyRatiosSnapshotInputSchema, func: async (input) => { const params = { ticker: input.ticker }; - const { data, url } = await callApi('/financial-metrics/snapshot/', params); + const { data, url } = await callApi('/financial-metrics/snapshot/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_WEEKLY }); return formatToolResult(data.snapshot || {}, [url]); }, }); @@ -79,7 +80,7 @@ export const getKeyRatios = new DynamicStructuredTool({ report_period_lt: input.report_period_lt, report_period_lte: input.report_period_lte, }; - const { data, url } = await callApi('/financial-metrics/', params); + const { data, url } = await callApi('/financial-metrics/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.financial_metrics || [], [url]); }, }); diff --git a/src/tools/finance/news.ts b/src/tools/finance/news.ts index a3acfae6..1ef3fd69 100644 --- a/src/tools/finance/news.ts +++ b/src/tools/finance/news.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_WEEKLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const NewsInputSchema = z.object({ @@ -29,7 +30,7 @@ export const getNews = new DynamicStructuredTool({ start_date: input.start_date, end_date: input.end_date, }; - const { data, url } = await callApi('/news/', params); + const { data, url } = await callApi('/news/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_WEEKLY }); return formatToolResult(data.news || [], [url]); }, }); diff --git a/src/tools/finance/prices.ts b/src/tools/finance/prices.ts index 07cda6c2..4be809f7 100644 --- a/src/tools/finance/prices.ts +++ b/src/tools/finance/prices.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_WEEKLY, CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const PriceSnapshotInputSchema = z.object({ @@ -17,7 +18,7 @@ export const getPriceSnapshot = new DynamicStructuredTool({ schema: PriceSnapshotInputSchema, func: async (input) => { const params = { ticker: input.ticker }; - const { data, url } = await callApi('/prices/snapshot/', params); + const { data, url } = await callApi('/prices/snapshot/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_WEEKLY }); return formatToolResult(data.snapshot || {}, [url]); }, }); @@ -56,7 +57,7 @@ export const getPrices = new DynamicStructuredTool({ const endDate = new Date(input.end_date + 'T00:00:00'); const today = new Date(); today.setHours(0, 0, 0, 0); - const { data, url } = await callApi('/prices/', params, { cacheable: endDate < today }); + const { data, url } = await callApi('/prices/', params, { cacheable: endDate < today, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.prices || [], [url]); }, }); diff --git a/src/tools/finance/segments.ts b/src/tools/finance/segments.ts index d9c3d437..8405fc6d 100644 --- a/src/tools/finance/segments.ts +++ b/src/tools/finance/segments.ts @@ -1,6 +1,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { callApi } from './api.js'; +import { CACHE_TTL_QUARTERLY } from '../../utils/cache.js'; import { formatToolResult } from '../types.js'; const SegmentedRevenuesInputSchema = z.object({ @@ -27,7 +28,7 @@ export const getSegmentedRevenues = new DynamicStructuredTool({ period: input.period, limit: input.limit, }; - const { data, url } = await callApi('/financials/segmented-revenues/', params); + const { data, url } = await callApi('/financials/segmented-revenues/', params, { cacheable: true, cacheTtlMs: CACHE_TTL_QUARTERLY }); return formatToolResult(data.segmented_revenues || {}, [url]); }, }); diff --git a/src/utils/cache.test.ts b/src/utils/cache.test.ts index 3177d391..16a1ba30 100644 --- a/src/utils/cache.test.ts +++ b/src/utils/cache.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { buildCacheKey, readCache, writeCache } from './cache.js'; +import { buildCacheKey, readCache, writeCache, CACHE_TTL_WEEKLY, CACHE_TTL_MONTHLY, CACHE_TTL_QUARTERLY } from './cache.js'; const TEST_CACHE_DIR = '.dexter/cache'; @@ -107,4 +107,84 @@ describe('readCache / writeCache', () => { expect(cached).toBeNull(); expect(existsSync(filepath)).toBe(false); }); + + test('respects per-entry quarterly TTL — fresh entry is returned', () => { + const endpoint = '/financials/income-statements/'; + const params = { ticker: 'AAPL', period: 'annual', limit: 5 }; + const data = { income_statements: [{ revenue: 400_000_000_000 }] }; + const url = 'https://api.financialdatasets.ai/financials/income-statements/?ticker=AAPL'; + + writeCache(endpoint, params, data, url, CACHE_TTL_QUARTERLY); + const cached = readCache(endpoint, params); + + expect(cached).not.toBeNull(); + expect(cached!.data).toEqual(data); + }); + + test('quarterly TTL entry survives past the default weekly window', () => { + const endpoint = '/institutional-ownership/'; + const params = { ticker: 'AAPL', limit: 10 }; + const data = { 'institutional-ownership': [{ investor: 'Vanguard', shares: 1_000_000 }] }; + const url = 'https://api.financialdatasets.ai/institutional-ownership/?ticker=AAPL'; + + // Write the entry, then manually backdate it to 30 days ago + writeCache(endpoint, params, data, url, CACHE_TTL_QUARTERLY); + + const key = buildCacheKey(endpoint, params); + const filepath = join(TEST_CACHE_DIR, key); + const raw = JSON.parse(readFileSync(filepath, 'utf-8')); + raw.cachedAt = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + writeFileSync(filepath, JSON.stringify(raw, null, 2)); + + // Should still be valid — 30 days < 90 day TTL + const cached = readCache(endpoint, params); + expect(cached).not.toBeNull(); + expect(cached!.data).toEqual(data); + }); + + test('quarterly TTL entry expires after 90 days', () => { + const endpoint = '/institutional-ownership/'; + const params = { ticker: 'MSFT', limit: 10 }; + const data = { 'institutional-ownership': [{ investor: 'BlackRock', shares: 500_000 }] }; + const url = 'https://api.financialdatasets.ai/institutional-ownership/?ticker=MSFT'; + + writeCache(endpoint, params, data, url, CACHE_TTL_QUARTERLY); + + const key = buildCacheKey(endpoint, params); + const filepath = join(TEST_CACHE_DIR, key); + const raw = JSON.parse(readFileSync(filepath, 'utf-8')); + raw.cachedAt = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString(); + writeFileSync(filepath, JSON.stringify(raw, null, 2)); + + // Should be expired — 91 days > 90 day TTL + const cached = readCache(endpoint, params); + expect(cached).toBeNull(); + expect(existsSync(filepath)).toBe(false); + }); + + test('entry without ttlMs falls back to default weekly TTL', () => { + const endpoint = '/prices/snapshot/'; + const params = { ticker: 'AAPL' }; + const data = { snapshot: { price: 195.5 } }; + const url = 'https://api.financialdatasets.ai/prices/snapshot/?ticker=AAPL'; + + // Write without ttlMs (legacy behavior) + writeCache(endpoint, params, data, url); + + const key = buildCacheKey(endpoint, params); + const filepath = join(TEST_CACHE_DIR, key); + const raw = JSON.parse(readFileSync(filepath, 'utf-8')); + // Backdate to 8 days ago — past the weekly default + raw.cachedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); + writeFileSync(filepath, JSON.stringify(raw, null, 2)); + + const cached = readCache(endpoint, params); + expect(cached).toBeNull(); + }); + + test('TTL constants have correct values', () => { + expect(CACHE_TTL_WEEKLY).toBe(7 * 24 * 60 * 60 * 1000); + expect(CACHE_TTL_MONTHLY).toBe(30 * 24 * 60 * 60 * 1000); + expect(CACHE_TTL_QUARTERLY).toBe(90 * 24 * 60 * 60 * 1000); + }); }); diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 1b104689..8e1132ea 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -26,10 +26,27 @@ interface CacheEntry { data: Record; url: string; cachedAt: string; + ttlMs?: number; } const CACHE_DIR = '.dexter/cache'; +// ============================================================================ +// TTL constants — aligned to data release cadence +// ============================================================================ + +/** 7 days — real-time-ish data: price snapshots, news, crypto snapshots */ +export const CACHE_TTL_WEEKLY = 7 * 24 * 60 * 60 * 1000; + +/** 30 days — periodically updated: analyst estimates, company facts */ +export const CACHE_TTL_MONTHLY = 30 * 24 * 60 * 60 * 1000; + +/** 90 days — quarterly/immutable: financial statements, SEC filings, insider trades, historical prices */ +export const CACHE_TTL_QUARTERLY = 90 * 24 * 60 * 60 * 1000; + +/** Default TTL for entries that don't specify one (backward compat) */ +const CACHE_TTL_DEFAULT = CACHE_TTL_WEEKLY; + // ============================================================================ // Helpers // ============================================================================ @@ -148,6 +165,15 @@ export function readCache( return null; } + // Check TTL — use per-entry TTL if stored, otherwise fall back to default + const ttl = parsed.ttlMs ?? CACHE_TTL_DEFAULT; + const age = Date.now() - new Date(parsed.cachedAt).getTime(); + if (age > ttl) { + logger.warn(`Cache expired (${Math.round(age / 86400000)}d old, ttl ${Math.round(ttl / 86400000)}d): ${label}`, { filepath }); + removeCacheFile(filepath); + return null; + } + return { data: parsed.data, url: parsed.url }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -167,7 +193,8 @@ export function writeCache( endpoint: string, params: Record, data: Record, - url: string + url: string, + ttlMs?: number ): void { const cacheKey = buildCacheKey(endpoint, params); const filepath = join(CACHE_DIR, cacheKey); @@ -179,6 +206,7 @@ export function writeCache( data, url, cachedAt: new Date().toISOString(), + ...(ttlMs !== undefined ? { ttlMs } : {}), }; try {