From 0d22e1c742d72ca05e42c3fbd3b61e1f5b9f50b8 Mon Sep 17 00:00:00 2001 From: Ame Date: Tue, 14 Apr 2026 09:25:10 +0800 Subject: [PATCH 1/5] feat: commodity canonical naming + catalog search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish provider-agnostic commodity identity so agent/cron can use canonical names (gold, crude_oil, copper) instead of provider-specific tickers (GC=F, GCUSD). Provider fetchers translate internally. - Add CommodityCatalog (24 commodities, search/resolve/list) with Chinese aliases and provider ticker aliases for migration ease - Add FMP COMMODITY_MAP mirroring yfinance's pattern for canonical→ticker - Wire commodity into marketSearchForResearch as 4th asset class - Add bbProvider e2e tests for both FMP and yfinance canonical names (FMP Starter: only gold/silver/brent; yfinance: all 19 pass) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fmp/models/commodity-spot-price.ts | 36 +++++- .../bbProviders/fmp.bbProvider.spec.ts | 21 +++- .../bbProviders/yfinance.bbProvider.spec.ts | 36 +++++- .../commodity/commodity-catalog.ts | 110 ++++++++++++++++++ src/domain/market-data/commodity/index.ts | 2 + src/domain/market-data/index.ts | 2 + src/main.ts | 6 +- src/tool/market.ts | 17 ++- 8 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 src/domain/market-data/commodity/commodity-catalog.ts create mode 100644 src/domain/market-data/commodity/index.ts diff --git a/packages/opentypebb/src/providers/fmp/models/commodity-spot-price.ts b/packages/opentypebb/src/providers/fmp/models/commodity-spot-price.ts index 3b39e85a..1f7276f0 100644 --- a/packages/opentypebb/src/providers/fmp/models/commodity-spot-price.ts +++ b/packages/opentypebb/src/providers/fmp/models/commodity-spot-price.ts @@ -11,6 +11,39 @@ import { CommoditySpotPriceQueryParamsSchema, CommoditySpotPriceDataSchema } fro import { getHistoricalOhlc } from '../utils/helpers.js' import { EmptyDataError } from '../../../core/provider/utils/errors.js' +// Canonical commodity names → FMP ticker symbols +// Mirrors yfinance's COMMODITY_MAP pattern for provider-agnostic naming +const COMMODITY_MAP: Record = { + // Precious metals + gold: 'GCUSD', + silver: 'SIUSD', + platinum: 'PLUSD', + palladium: 'PAUSD', + // Industrial metals + copper: 'HGUSD', + // Energy + crude_oil: 'CLUSD', + wti: 'CLUSD', + brent: 'BZUSD', + natural_gas: 'NGUSD', + heating_oil: 'HOUSD', + gasoline: 'RBUSD', + // Agriculture (may require higher FMP tier) + corn: 'ZCUSX', + wheat: 'KEUSX', + soybeans: 'ZSUSX', + // Softs (may require higher FMP tier) + sugar: 'SBUSX', + coffee: 'KCUSX', + cocoa: 'CCUSX', + cotton: 'CTUSX', +} + +function resolveSymbol(sym: string): string { + const lower = sym.toLowerCase().trim() + return COMMODITY_MAP[lower] ?? sym.trim() +} + export const FMPCommoditySpotPriceQueryParamsSchema = CommoditySpotPriceQueryParamsSchema export type FMPCommoditySpotPriceQueryParams = z.infer @@ -39,9 +72,10 @@ export class FMPCommoditySpotPriceFetcher extends Fetcher { query: FMPCommoditySpotPriceQueryParams, credentials: Record | null, ): Promise[]> { + const symbols = query.symbol.split(',').map(s => resolveSymbol(s)).join(',') return getHistoricalOhlc( { - symbol: query.symbol, + symbol: symbols, interval: '1d', start_date: query.start_date, end_date: query.end_date, diff --git a/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts index 692993a0..fe779fb5 100644 --- a/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts +++ b/src/domain/market-data/__tests__/bbProviders/fmp.bbProvider.spec.ts @@ -88,8 +88,25 @@ describe('fmp — index', () => { it('RiskPremium', async () => { expect((await exec('RiskPremium')).length).toBeGreaterThan(0) }) }) -describe('fmp — commodity', () => { +describe('fmp — commodity (direct FMP tickers)', () => { beforeEach(({ skip }) => { if (!hasCredential(ctx.credentials, 'fmp')) skip('no fmp_api_key') }) - it('CommoditySpotPrice', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'GCUSD' })).length).toBeGreaterThan(0) }) + it('CommoditySpotPrice — GCUSD', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'GCUSD' })).length).toBeGreaterThan(0) }) +}) + +describe('fmp — commodity (canonical names via COMMODITY_MAP)', () => { + beforeEach(({ skip }) => { if (!hasCredential(ctx.credentials, 'fmp')) skip('no fmp_api_key') }) + + // Verified working on FMP Starter (2026-04-13) + it('gold', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'gold' })).length).toBeGreaterThan(0) }) + it('silver', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'silver' })).length).toBeGreaterThan(0) }) + it('brent', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'brent' })).length).toBeGreaterThan(0) }) + + // Premium restricted on FMP Starter (verified 2026-04-13) + it.skip('crude_oil — premium', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'crude_oil' })).length).toBeGreaterThan(0) }) + it.skip('natural_gas — premium', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'natural_gas' })).length).toBeGreaterThan(0) }) + it.skip('copper — premium', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'copper' })).length).toBeGreaterThan(0) }) + it.skip('platinum — premium', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'platinum' })).length).toBeGreaterThan(0) }) + it.skip('corn — premium (USX)', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'corn' })).length).toBeGreaterThan(0) }) + it.skip('wheat — premium (USX)', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'wheat' })).length).toBeGreaterThan(0) }) }) diff --git a/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts index 55413f1d..0d425904 100644 --- a/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts +++ b/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts @@ -58,9 +58,41 @@ describe('yfinance — ETF & index', () => { it('AvailableIndices', async () => { expect((await exec('AvailableIndices')).length).toBeGreaterThan(0) }) }) -describe('yfinance — derivatives & commodity', () => { +describe('yfinance — derivatives', () => { it('OptionsChains', async () => { expect((await exec('OptionsChains', { symbol: 'AAPL' })).length).toBeGreaterThan(0) }) it('FuturesHistorical', async () => { expect((await exec('FuturesHistorical', { symbol: 'ES=F' })).length).toBeGreaterThan(0) }) it('FuturesCurve', async () => { expect((await exec('FuturesCurve', { symbol: 'ES' })).length).toBeGreaterThan(0) }) - it('CommoditySpotPrice', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'GC=F' })).length).toBeGreaterThan(0) }) +}) + +describe('yfinance — commodity (canonical names)', () => { + // Precious metals + it('gold', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'gold' })).length).toBeGreaterThan(0) }) + it('silver', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'silver' })).length).toBeGreaterThan(0) }) + it('platinum', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'platinum' })).length).toBeGreaterThan(0) }) + it('palladium', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'palladium' })).length).toBeGreaterThan(0) }) + + // Industrial metals + it('copper', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'copper' })).length).toBeGreaterThan(0) }) + + // Energy + it('crude_oil', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'crude_oil' })).length).toBeGreaterThan(0) }) + it('brent', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'brent' })).length).toBeGreaterThan(0) }) + it('natural_gas', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'natural_gas' })).length).toBeGreaterThan(0) }) + it('heating_oil', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'heating_oil' })).length).toBeGreaterThan(0) }) + it('gasoline', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'gasoline' })).length).toBeGreaterThan(0) }) + + // Agriculture (CBOT) — yfinance coverage historically flaky + it('corn', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'corn' })).length).toBeGreaterThan(0) }) + it('wheat', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'wheat' })).length).toBeGreaterThan(0) }) + it('soybeans', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'soybeans' })).length).toBeGreaterThan(0) }) + + // Softs (ICE) + it('sugar', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'sugar' })).length).toBeGreaterThan(0) }) + it('coffee', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'coffee' })).length).toBeGreaterThan(0) }) + it('cocoa', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'cocoa' })).length).toBeGreaterThan(0) }) + it('cotton', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'cotton' })).length).toBeGreaterThan(0) }) + + // Livestock + it('live_cattle', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'live_cattle' })).length).toBeGreaterThan(0) }) + it('lean_hogs', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'lean_hogs' })).length).toBeGreaterThan(0) }) }) diff --git a/src/domain/market-data/commodity/commodity-catalog.ts b/src/domain/market-data/commodity/commodity-catalog.ts new file mode 100644 index 00000000..c2dedd96 --- /dev/null +++ b/src/domain/market-data/commodity/commodity-catalog.ts @@ -0,0 +1,110 @@ +/** + * Commodity Catalog — canonical naming + enumeration + * + * Commodity is a closed set (~30 root symbols, stable for decades). + * Unlike equities (open set, daily IPO/delist), commodities only need + * enumeration, not server-side search. + * + * Each entry uses a canonical id (e.g. "gold", "crude_oil") that is + * provider-agnostic. Provider-specific ticker translation (gold → GC=F + * for yfinance, gold → GCUSD for FMP) lives in each provider's fetcher. + * + * Aliases include provider tickers so that searching "GC=F" or "GCUSD" + * still resolves to the canonical "gold" entry — easing migration from + * provider-specific naming to canonical naming. + */ + +export interface CommodityCatalogEntry { + id: string + name: string + category: string + aliases: string[] +} + +export class CommodityCatalog { + private entries: CommodityCatalogEntry[] = [] + + get size(): number { return this.entries.length } + + load(): void { + this.entries = CATALOG + } + + /** + * Regex/substring search across id, name, and aliases. + * Same logic as SymbolIndex.search() — regex with fallback to substring. + */ + search(pattern: string, limit = 20): CommodityCatalogEntry[] { + let test: (s: string) => boolean + + try { + const re = new RegExp(pattern, 'i') + test = (s) => re.test(s) + } catch { + const lower = pattern.toLowerCase() + test = (s) => s.toLowerCase().includes(lower) + } + + const results: CommodityCatalogEntry[] = [] + for (const entry of this.entries) { + if ( + test(entry.id) || + test(entry.name) || + entry.aliases.some(test) + ) { + results.push(entry) + if (results.length >= limit) break + } + } + return results + } + + resolve(id: string): CommodityCatalogEntry | undefined { + const lower = id.toLowerCase() + return this.entries.find((e) => e.id === lower) + } + + list(): CommodityCatalogEntry[] { + return [...this.entries] + } +} + +// ==================== Canonical Commodity Catalog ==================== + +const CATALOG: CommodityCatalogEntry[] = [ + // Precious metals + { id: 'gold', name: 'Gold', category: 'metals', aliases: ['黄金', 'xau', 'GC=F', 'GCUSD'] }, + { id: 'silver', name: 'Silver', category: 'metals', aliases: ['白银', 'xag', 'SI=F', 'SIUSD'] }, + { id: 'platinum', name: 'Platinum', category: 'metals', aliases: ['铂金', 'PL=F', 'PLUSD'] }, + { id: 'palladium', name: 'Palladium', category: 'metals', aliases: ['钯金', 'PA=F', 'PAUSD'] }, + + // Industrial metals + { id: 'copper', name: 'Copper', category: 'metals', aliases: ['铜', 'HG=F', 'HGUSD'] }, + + // Energy + { id: 'crude_oil', name: 'Crude Oil (WTI)', category: 'energy', aliases: ['原油', 'wti', 'CL=F', 'CLUSD'] }, + { id: 'brent', name: 'Brent Crude', category: 'energy', aliases: ['布伦特原油', 'BZ=F', 'BZUSD'] }, + { id: 'natural_gas', name: 'Natural Gas', category: 'energy', aliases: ['天然气', 'NG=F', 'NGUSD'] }, + { id: 'heating_oil', name: 'Heating Oil', category: 'energy', aliases: ['取暖油', 'HO=F', 'HOUSD'] }, + { id: 'gasoline', name: 'Gasoline (RBOB)', category: 'energy', aliases: ['汽油', 'RB=F', 'RBUSD'] }, + + // Agriculture (CBOT grains) + { id: 'corn', name: 'Corn', category: 'agriculture', aliases: ['玉米', 'ZC=F', 'ZCUSX'] }, + { id: 'wheat', name: 'Wheat', category: 'agriculture', aliases: ['小麦', 'ZW=F', 'KEUSX'] }, + { id: 'soybeans', name: 'Soybeans', category: 'agriculture', aliases: ['大豆', 'ZS=F', 'ZSUSX'] }, + { id: 'oats', name: 'Oats', category: 'agriculture', aliases: ['燕麦', 'ZO=F'] }, + { id: 'rice', name: 'Rough Rice', category: 'agriculture', aliases: ['稻米', 'ZR=F'] }, + + // Softs (ICE) + { id: 'sugar', name: 'Sugar #11', category: 'softs', aliases: ['糖', 'SB=F', 'SBUSX'] }, + { id: 'coffee', name: 'Coffee', category: 'softs', aliases: ['咖啡', 'KC=F', 'KCUSX'] }, + { id: 'cocoa', name: 'Cocoa', category: 'softs', aliases: ['可可', 'CC=F', 'CCUSX'] }, + { id: 'cotton', name: 'Cotton', category: 'softs', aliases: ['棉花', 'CT=F', 'CTUSX'] }, + { id: 'lumber', name: 'Lumber', category: 'softs', aliases: ['木材', 'LBS=F'] }, + { id: 'orange_juice', name: 'Orange Juice', category: 'softs', aliases: ['橙汁', 'OJ=F'] }, + + // Livestock + { id: 'live_cattle', name: 'Live Cattle', category: 'livestock', aliases: ['活牛', 'LE=F'] }, + { id: 'lean_hogs', name: 'Lean Hogs', category: 'livestock', aliases: ['瘦肉猪', 'HE=F'] }, + { id: 'feeder_cattle', name: 'Feeder Cattle', category: 'livestock', aliases: ['架子牛', 'GF=F'] }, +] diff --git a/src/domain/market-data/commodity/index.ts b/src/domain/market-data/commodity/index.ts new file mode 100644 index 00000000..d5de8ae9 --- /dev/null +++ b/src/domain/market-data/commodity/index.ts @@ -0,0 +1,2 @@ +export { CommodityCatalog } from './commodity-catalog.js' +export type { CommodityCatalogEntry } from './commodity-catalog.js' diff --git a/src/domain/market-data/index.ts b/src/domain/market-data/index.ts index 8b4371f2..eaae336d 100644 --- a/src/domain/market-data/index.ts +++ b/src/domain/market-data/index.ts @@ -4,3 +4,5 @@ export type * from './client/types.js' export * from './credential-map.js' export { SymbolIndex } from './equity/index.js' export type { SymbolEntry } from './equity/index.js' +export { CommodityCatalog } from './commodity/index.js' +export type { CommodityCatalogEntry } from './commodity/index.js' diff --git a/src/main.ts b/src/main.ts index c53622fa..747a387b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { createBrainTools } from './tool/brain.js' import type { BrainExportState } from './domain/brain/index.js' import { createBrowserTools } from './tool/browser.js' import { SymbolIndex } from './domain/market-data/equity/index.js' +import { CommodityCatalog } from './domain/market-data/commodity/index.js' import { createEquityTools } from './tool/equity.js' import { getSDKExecutor, buildRouteMap, SDKEquityClient, SDKCryptoClient, SDKCurrencyClient, SDKEtfClient, SDKIndexClient, SDKDerivativesClient, SDKCommodityClient } from './domain/market-data/client/typebb/index.js' import type { EquityClientLike, CryptoClientLike, CurrencyClientLike, EtfClientLike, IndexClientLike, DerivativesClientLike, CommodityClientLike } from './domain/market-data/client/types.js' @@ -197,6 +198,9 @@ async function main() { const symbolIndex = new SymbolIndex() await symbolIndex.load(equityClient) + const commodityCatalog = new CommodityCatalog() + commodityCatalog.load() + // ==================== Tool Registration ==================== toolCenter.register(createThinkingTools(), 'thinking') @@ -210,7 +214,7 @@ async function main() { toolCenter.register(createBrainTools(brain), 'brain') toolCenter.register(createBrowserTools(), 'browser') toolCenter.register(createCronTools(cronEngine), 'cron') - toolCenter.register(createMarketSearchTools(symbolIndex, cryptoClient, currencyClient), 'market-search') + toolCenter.register(createMarketSearchTools(symbolIndex, cryptoClient, currencyClient, commodityCatalog), 'market-search') toolCenter.register(createEquityTools(equityClient), 'equity') if (config.news.enabled) { toolCenter.register(createNewsArchiveTools(newsStore), 'news') diff --git a/src/tool/market.ts b/src/tool/market.ts index da1370ca..d5601e3f 100644 --- a/src/tool/market.ts +++ b/src/tool/market.ts @@ -13,19 +13,25 @@ import { tool } from 'ai' import { z } from 'zod' import type { SymbolIndex } from '@/domain/market-data/equity/symbol-index' import type { CryptoClientLike, CurrencyClientLike } from '@/domain/market-data/client/types' +import type { CommodityCatalog } from '@/domain/market-data/commodity/commodity-catalog' export function createMarketSearchTools( symbolIndex: SymbolIndex, cryptoClient: CryptoClientLike, currencyClient: CurrencyClientLike, + commodityCatalog: CommodityCatalog, ) { return { marketSearchForResearch: tool({ - description: `Search for symbols across all asset classes (equities, crypto, currencies) for market data research. + description: `Search for symbols across all asset classes (equities, crypto, currencies, commodities) for market data research. -Returns matching symbols with assetClass attribution ("equity", "crypto", or "currency"). +Returns matching symbols with assetClass attribution ("equity", "crypto", "currency", or "commodity"). Equity results come from SEC/TMX listings (~13k US/CA stocks); crypto and currency results -come from Yahoo Finance fuzzy search. Currency results are filtered to XXXUSD pairs only. +come from Yahoo Finance fuzzy search; commodity results come from a canonical catalog (~25 items). +Currency results are filtered to XXXUSD pairs only. + +For commodities, use the canonical id (e.g. "gold", "crude_oil", "copper") with calculateIndicator +and other tools — provider-specific tickers (GC=F, GCUSD) are resolved automatically. If unsure about the symbol, use this to find the correct one for market data tools (equityGetProfile, equityGetFinancials, calculateIndicator, etc.). @@ -37,8 +43,9 @@ This is NOT for trading — use searchContracts to find broker-tradeable contrac execute: async ({ query, limit }) => { const cap = limit ?? 20 - // equity: 本地同步搜索 + // equity + commodity: 本��同步搜索 const equityResults = symbolIndex.search(query, cap).map((r) => ({ ...r, assetClass: 'equity' as const })) + const commodityResults = commodityCatalog.search(query, cap).map((r) => ({ ...r, assetClass: 'commodity' as const })) // crypto + currency: yfinance 在线搜索,并行,容错 const [cryptoSettled, currencySettled] = await Promise.allSettled([ @@ -58,7 +65,7 @@ This is NOT for trading — use searchContracts to find broker-tradeable contrac }) .map((r) => ({ ...r, assetClass: 'currency' as const })) - const results = [...equityResults, ...cryptoResults, ...currencyResults] + const results = [...equityResults, ...cryptoResults, ...currencyResults, ...commodityResults] if (results.length === 0) { return { results: [], message: `No symbols matching "${query}". Try a different keyword.` } } From a746d59a97eaefee73053b69a93d4cb38c0d079d Mon Sep 17 00:00:00 2001 From: Ame Date: Tue, 14 Apr 2026 09:45:51 +0800 Subject: [PATCH 2/5] fix: commodityClient used equity provider instead of commodity provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.ts passed `providers.equity` (fmp) to SDKCommodityClient and OpenBBCommodityClient instead of `providers.commodity` (yfinance). This caused all commodity data requests to silently go through FMP, which returned 2022-era historical data for Yahoo-style =F symbols without any error — explaining the "frozen data" reports from the commodity_watchlist cron (04-07 through 04-10). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index 747a387b..93173768 100644 --- a/src/main.ts +++ b/src/main.ts @@ -172,7 +172,7 @@ async function main() { equityClient = new OpenBBEquityClient(url, providers.equity, keys) cryptoClient = new OpenBBCryptoClient(url, providers.crypto, keys) currencyClient = new OpenBBCurrencyClient(url, providers.currency, keys) - commodityClient = new OpenBBCommodityClient(url, providers.equity, keys) + commodityClient = new OpenBBCommodityClient(url, providers.commodity, keys) } else { const executor = getSDKExecutor() const routeMap = buildRouteMap() @@ -180,7 +180,7 @@ async function main() { equityClient = new SDKEquityClient(executor, 'equity', providers.equity, credentials, routeMap) cryptoClient = new SDKCryptoClient(executor, 'crypto', providers.crypto, credentials, routeMap) currencyClient = new SDKCurrencyClient(executor, 'currency', providers.currency, credentials, routeMap) - commodityClient = new SDKCommodityClient(executor, 'commodity', providers.equity, credentials, routeMap) + commodityClient = new SDKCommodityClient(executor, 'commodity', providers.commodity, credentials, routeMap) etfClient = new SDKEtfClient(executor, 'etf', providers.equity, credentials, routeMap) indexClient = new SDKIndexClient(executor, 'index', providers.equity, credentials, routeMap) derivativesClient = new SDKDerivativesClient(executor, 'derivatives', providers.equity, credentials, routeMap) From 414283af20c403de77d18ddb1bc5b276005b1bf3 Mon Sep 17 00:00:00 2001 From: Ame Date: Tue, 14 Apr 2026 10:32:52 +0800 Subject: [PATCH 3/5] feat: calculateIndicator returns dataRange with OHLCV time span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the indicator calculator's type chain so data provenance flows from OHLCV bars all the way to the tool return value. This would have caught the FMP-returning-2022-data bug immediately — agent can now see "to: 2022-04-29" and know something is wrong. - Add TrackedValues type (values + DataSourceMeta) to carry metadata alongside number arrays through the calculation pipeline - Data-access functions (CLOSE/HIGH/LOW/OPEN/VOLUME) return TrackedValues - Stats/technical functions accept number[] | TrackedValues via toValues() - Calculator collects sources and returns { value, dataRange } - Tool layer builds meta from actual bar dates (no synthetic timestamps) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../analysis/indicator/calculator.spec.ts | 140 ++++++++++-------- src/domain/analysis/indicator/calculator.ts | 101 ++++++++----- .../indicator/functions/data-access.ts | 32 ++-- .../indicator/functions/statistics.ts | 62 ++++---- .../analysis/indicator/functions/technical.ts | 62 ++++---- src/domain/analysis/indicator/types.ts | 33 ++++- src/tool/analysis.ts | 18 ++- 7 files changed, 271 insertions(+), 177 deletions(-) diff --git a/src/domain/analysis/indicator/calculator.spec.ts b/src/domain/analysis/indicator/calculator.spec.ts index e74f40d9..d83f4d6c 100644 --- a/src/domain/analysis/indicator/calculator.spec.ts +++ b/src/domain/analysis/indicator/calculator.spec.ts @@ -2,11 +2,11 @@ * Indicator Calculator unit tests * * 覆盖:四则运算、运算符优先级、数据访问、统计函数、技术指标、 - * 数组索引、嵌套表达式、精度控制、错误处理。 + * 数组索引、嵌套表达式、精度控制、错误处理、数据溯源(dataRange)。 */ import { describe, it, expect } from 'vitest' import { IndicatorCalculator } from './calculator' -import type { IndicatorContext, OhlcvData } from './types' +import type { IndicatorContext, OhlcvData, TrackedValues } from './types' // Mock: 50 根日线,收盘价 100~149,volume 第 48 根为 null 测边界 const mockData: OhlcvData[] = Array.from({ length: 50 }, (_, i) => ({ @@ -20,12 +20,18 @@ const mockData: OhlcvData[] = Array.from({ length: 50 }, (_, i) => ({ })) const mockContext: IndicatorContext = { - getHistoricalData: async (_symbol: string, _interval: string) => { - return mockData - }, + getHistoricalData: async (_symbol: string, _interval: string) => ({ + data: mockData, + meta: { + symbol: _symbol, + from: mockData[0].date, + to: mockData[mockData.length - 1].date, + bars: mockData.length, + }, + }), } -function calc(formula: string, precision?: number) { +async function calc(formula: string, precision?: number) { const calculator = new IndicatorCalculator(mockContext) return calculator.calculate(formula, precision) } @@ -34,48 +40,47 @@ function calc(formula: string, precision?: number) { describe('arithmetic', () => { it('addition', async () => { - expect(await calc('2 + 3')).toBe(5) + expect((await calc('2 + 3')).value).toBe(5) }) it('subtraction', async () => { - expect(await calc('10 - 4')).toBe(6) + expect((await calc('10 - 4')).value).toBe(6) }) it('multiplication', async () => { - expect(await calc('3 * 7')).toBe(21) + expect((await calc('3 * 7')).value).toBe(21) }) it('division', async () => { - expect(await calc('15 / 4')).toBe(3.75) + expect((await calc('15 / 4')).value).toBe(3.75) }) it('operator precedence: * before +', async () => { - expect(await calc('2 + 3 * 4')).toBe(14) + expect((await calc('2 + 3 * 4')).value).toBe(14) }) it('operator precedence: / before -', async () => { - expect(await calc('10 - 6 / 2')).toBe(7) + expect((await calc('10 - 6 / 2')).value).toBe(7) }) it('parentheses override precedence', async () => { - expect(await calc('(2 + 3) * 4')).toBe(20) + expect((await calc('(2 + 3) * 4')).value).toBe(20) }) it('nested parentheses', async () => { - expect(await calc('((1 + 2) * (3 + 4))')).toBe(21) + expect((await calc('((1 + 2) * (3 + 4))')).value).toBe(21) }) it('negative numbers', async () => { - expect(await calc('-5 + 3')).toBe(-2) + expect((await calc('-5 + 3')).value).toBe(-2) }) it('decimal numbers', async () => { - expect(await calc('1.5 * 2.0')).toBe(3) + expect((await calc('1.5 * 2.0')).value).toBe(3) }) it('chained operations left to right', async () => { - // 10 - 3 - 2 = 5 (left-associative) - expect(await calc('10 - 3 - 2')).toBe(5) + expect((await calc('10 - 3 - 2')).value).toBe(5) }) it('division by zero throws', async () => { @@ -87,8 +92,8 @@ describe('arithmetic', () => { // mockData 返回全量 50 根:close 100..149, high 102..151, low 99..148, open 100..149 describe('data access', () => { - it('CLOSE returns all 50 bars', async () => { - const result = (await calc("CLOSE('AAPL', '1d')")) as number[] + it('CLOSE returns TrackedValues with 50 bars', async () => { + const result = (await calc("CLOSE('AAPL', '1d')")).value as number[] expect(Array.isArray(result)).toBe(true) expect(result.length).toBe(50) expect(result[0]).toBe(100) @@ -96,26 +101,25 @@ describe('data access', () => { }) it('HIGH returns correct values', async () => { - const result = (await calc("HIGH('AAPL', '1d')")) as number[] + const result = (await calc("HIGH('AAPL', '1d')")).value as number[] expect(result[0]).toBe(102) expect(result[49]).toBe(151) }) it('LOW returns correct values', async () => { - const result = (await calc("LOW('AAPL', '1d')")) as number[] + const result = (await calc("LOW('AAPL', '1d')")).value as number[] expect(result[0]).toBe(99) expect(result[49]).toBe(148) }) it('OPEN returns correct values', async () => { - const result = (await calc("OPEN('AAPL', '1d')")) as number[] + const result = (await calc("OPEN('AAPL', '1d')")).value as number[] expect(result[0]).toBe(100) expect(result[49]).toBe(149) }) it('VOLUME handles null as 0', async () => { - // mockData[48].volume = null, mockData[49].volume = 1490 - const result = (await calc("VOLUME('AAPL', '1d')")) as number[] + const result = (await calc("VOLUME('AAPL', '1d')")).value as number[] expect(result[48]).toBe(0) expect(result[49]).toBe(1490) }) @@ -125,15 +129,15 @@ describe('data access', () => { describe('array access', () => { it('positive index', async () => { - expect(await calc("CLOSE('AAPL', '1d')[0]")).toBe(100) + expect((await calc("CLOSE('AAPL', '1d')[0]")).value).toBe(100) }) it('negative index (-1 = last)', async () => { - expect(await calc("CLOSE('AAPL', '1d')[-1]")).toBe(149) + expect((await calc("CLOSE('AAPL', '1d')[-1]")).value).toBe(149) }) it('negative index (-2 = second to last)', async () => { - expect(await calc("CLOSE('AAPL', '1d')[-2]")).toBe(148) + expect((await calc("CLOSE('AAPL', '1d')[-2]")).value).toBe(148) }) it('out of bounds throws', async () => { @@ -146,42 +150,37 @@ describe('array access', () => { describe('statistics', () => { it('SMA', async () => { - // SMA(10) of 50 bars: average of last 10 = (140+...+149)/10 = 144.5 - expect(await calc("SMA(CLOSE('AAPL', '1d'), 10)")).toBe(144.5) + expect((await calc("SMA(CLOSE('AAPL', '1d'), 10)")).value).toBe(144.5) }) it('EMA', async () => { - const result = await calc("EMA(CLOSE('AAPL', '1d'), 10)") + const result = (await calc("EMA(CLOSE('AAPL', '1d'), 10)")).value as number expect(typeof result).toBe('number') expect(result).toBeGreaterThan(140) }) it('STDEV', async () => { - // stdev of 100..149 ≈ 14.43 - const result = await calc("STDEV(CLOSE('AAPL', '1d'))") + const result = (await calc("STDEV(CLOSE('AAPL', '1d'))")).value expect(result).toBeCloseTo(14.43, 1) }) it('MAX', async () => { - expect(await calc("MAX(CLOSE('AAPL', '1d'))")).toBe(149) + expect((await calc("MAX(CLOSE('AAPL', '1d'))")).value).toBe(149) }) it('MIN', async () => { - expect(await calc("MIN(CLOSE('AAPL', '1d'))")).toBe(100) + expect((await calc("MIN(CLOSE('AAPL', '1d'))")).value).toBe(100) }) it('SUM', async () => { - // 100+101+...+149 = 50 * (100+149)/2 = 6225 - expect(await calc("SUM(CLOSE('AAPL', '1d'))")).toBe(6225) + expect((await calc("SUM(CLOSE('AAPL', '1d'))")).value).toBe(6225) }) it('AVERAGE', async () => { - // (100+...+149)/50 = 124.5 - expect(await calc("AVERAGE(CLOSE('AAPL', '1d'))")).toBe(124.5) + expect((await calc("AVERAGE(CLOSE('AAPL', '1d'))")).value).toBe(124.5) }) it('SMA insufficient data throws', async () => { - // 50 bars but SMA(100) needs 100 await expect(calc("SMA(CLOSE('AAPL', '1d'), 100)")).rejects.toThrow('at least 100') }) }) @@ -190,15 +189,14 @@ describe('statistics', () => { describe('technical indicators', () => { it('RSI returns 0-100, trending up → high RSI', async () => { - const result = (await calc("RSI(CLOSE('AAPL', '1d'), 14)")) as number + const result = (await calc("RSI(CLOSE('AAPL', '1d'), 14)")).value as number expect(result).toBeGreaterThanOrEqual(0) expect(result).toBeLessThanOrEqual(100) - // 连续上涨,RSI 应接近 100 expect(result).toBeGreaterThan(90) }) it('BBANDS returns { upper, middle, lower }', async () => { - const result = (await calc("BBANDS(CLOSE('AAPL', '1d'), 20, 2)")) as Record + const result = (await calc("BBANDS(CLOSE('AAPL', '1d'), 20, 2)")).value as Record expect(result).toHaveProperty('upper') expect(result).toHaveProperty('middle') expect(result).toHaveProperty('lower') @@ -207,7 +205,7 @@ describe('technical indicators', () => { }) it('MACD returns { macd, signal, histogram }', async () => { - const result = (await calc("MACD(CLOSE('AAPL', '1d'), 12, 26, 9)")) as Record + const result = (await calc("MACD(CLOSE('AAPL', '1d'), 12, 26, 9)")).value as Record expect(result).toHaveProperty('macd') expect(result).toHaveProperty('signal') expect(result).toHaveProperty('histogram') @@ -215,7 +213,7 @@ describe('technical indicators', () => { }) it('ATR returns positive number', async () => { - const result = (await calc("ATR(HIGH('AAPL', '1d'), LOW('AAPL', '1d'), CLOSE('AAPL', '1d'), 14)")) as number + const result = (await calc("ATR(HIGH('AAPL', '1d'), LOW('AAPL', '1d'), CLOSE('AAPL', '1d'), 14)")).value as number expect(typeof result).toBe('number') expect(result).toBeGreaterThan(0) }) @@ -225,22 +223,19 @@ describe('technical indicators', () => { describe('complex expressions', () => { it('price deviation from MA (%)', async () => { - // latest close = 149, SMA(50) of 100..149 = average of last 50 = 124.5 - // (149 - 124.5) / 124.5 * 100 ≈ 19.68% - const result = await calc( + const result = (await calc( "(CLOSE('AAPL', '1d')[-1] - SMA(CLOSE('AAPL', '1d'), 50)) / SMA(CLOSE('AAPL', '1d'), 50) * 100", - ) + )).value expect(result).toBeCloseTo(19.68, 1) }) it('arithmetic on function results', async () => { - // MAX - MIN of all 50 closes = 149 - 100 = 49 - const result = await calc("MAX(CLOSE('AAPL', '1d')) - MIN(CLOSE('AAPL', '1d'))") + const result = (await calc("MAX(CLOSE('AAPL', '1d')) - MIN(CLOSE('AAPL', '1d'))")).value expect(result).toBe(49) }) it('double-quoted strings work', async () => { - const result = await calc('CLOSE("AAPL", "1d")') + const result = (await calc('CLOSE("AAPL", "1d")')).value expect(Array.isArray(result)).toBe(true) expect((result as number[]).length).toBe(50) }) @@ -250,28 +245,23 @@ describe('complex expressions', () => { describe('precision', () => { it('default precision = 4', async () => { - const result = (await calc('10 / 3')) as number - expect(result).toBe(3.3333) + expect((await calc('10 / 3')).value).toBe(3.3333) }) it('custom precision = 2', async () => { - const result = (await calc('10 / 3', 2)) as number - expect(result).toBe(3.33) + expect((await calc('10 / 3', 2)).value).toBe(3.33) }) it('precision = 0 rounds to integer', async () => { - const result = (await calc('10 / 3', 0)) as number - expect(result).toBe(3) + expect((await calc('10 / 3', 0)).value).toBe(3) }) - it('precision applies to arrays', async () => { - const result = (await calc("STDEV(CLOSE('AAPL', '1d'))", 0)) as number - expect(result).toBe(14) + it('precision applies to scalars from functions', async () => { + expect((await calc("STDEV(CLOSE('AAPL', '1d'))", 0)).value).toBe(14) }) it('precision applies to record values', async () => { - const result = (await calc("BBANDS(CLOSE('AAPL', '1d'), 20, 2)", 2)) as Record - // 所有值应只有 2 位小数 + const result = (await calc("BBANDS(CLOSE('AAPL', '1d'), 20, 2)", 2)).value as Record for (const v of Object.values(result)) { const decimals = v.toString().split('.')[1]?.length ?? 0 expect(decimals).toBeLessThanOrEqual(2) @@ -279,6 +269,30 @@ describe('precision', () => { }) }) +// ==================== dataRange 溯源 ==================== + +describe('dataRange', () => { + it('calculate returns dataRange with symbol metadata', async () => { + const { value, dataRange } = await calc("CLOSE('AAPL', '1d')[-1]") + expect(value).toBe(149) + expect(dataRange).toHaveProperty('AAPL') + expect(dataRange.AAPL.from).toBe(mockData[0].date) + expect(dataRange.AAPL.to).toBe(mockData[49].date) + expect(dataRange.AAPL.bars).toBe(50) + }) + + it('multiple symbols produce multiple dataRange entries', async () => { + // ATR uses HIGH, LOW, CLOSE — all same symbol, should produce one entry + const { dataRange } = await calc("ATR(HIGH('AAPL', '1d'), LOW('AAPL', '1d'), CLOSE('AAPL', '1d'), 14)") + expect(Object.keys(dataRange)).toEqual(['AAPL']) + }) + + it('pure arithmetic has empty dataRange', async () => { + const { dataRange } = await calc('2 + 3') + expect(Object.keys(dataRange).length).toBe(0) + }) +}) + // ==================== 错误处理 ==================== describe('errors', () => { diff --git a/src/domain/analysis/indicator/calculator.ts b/src/domain/analysis/indicator/calculator.ts index 2295883f..46e47286 100644 --- a/src/domain/analysis/indicator/calculator.ts +++ b/src/domain/analysis/indicator/calculator.ts @@ -16,18 +16,29 @@ import type { FunctionNode, BinaryOpNode, ArrayAccessNode, + DataSourceMeta, + TrackedValues, } from './types' +import { toValues } from './types' import * as DataAccess from './functions/data-access' import * as Statistics from './functions/statistics' import * as Technical from './functions/technical' +export interface CalculateOutput { + value: number | number[] | Record + dataRange: Record +} + export class IndicatorCalculator { + private dataSources: Record = {} + constructor(private context: IndicatorContext) {} async calculate( formula: string, precision: number = 4, - ): Promise> { + ): Promise { + this.dataSources = {} const ast = this.parse(formula) const result = await this.evaluate(ast) @@ -35,19 +46,29 @@ export class IndicatorCalculator { throw new Error(`Invalid formula: result cannot be a string. Got: "${result}"`) } - return this.applyPrecision(result, precision) + return { + value: this.applyPrecision(result, precision), + dataRange: this.dataSources, + } } private applyPrecision( - result: number | number[] | Record, + result: CalculationResult, precision: number, ): number | number[] | Record { if (typeof result === 'number') { return parseFloat(result.toFixed(precision)) } + // TrackedValues — apply precision to values array + if (!Array.isArray(result) && typeof result === 'object' && 'values' in result && 'source' in result) { + return (result as TrackedValues).values.map((v) => parseFloat(v.toFixed(precision))) + } if (Array.isArray(result)) { return result.map((v) => parseFloat(v.toFixed(precision))) } + if (typeof result === 'string') { + throw new Error(`Invalid formula: result cannot be a string. Got: "${result}"`) + } const rounded: Record = {} for (const [key, value] of Object.entries(result)) { rounded[key] = parseFloat(value.toFixed(precision)) @@ -233,47 +254,50 @@ export class IndicatorCalculator { } } + private collectSource(result: CalculationResult): void { + if (result && typeof result === 'object' && 'source' in result && 'values' in result) { + const tracked = result as TrackedValues + this.dataSources[tracked.source.symbol] = tracked.source + } + } + private async executeFunction(node: FunctionNode): Promise { const { name, args } = node const evaluatedArgs = await Promise.all(args.map((arg) => this.evaluate(arg))) - // Data access functions: FUNC('symbol', 'interval') - if (name === 'CLOSE') - return await DataAccess.CLOSE(evaluatedArgs[0] as string, evaluatedArgs[1] as string, this.context) - if (name === 'HIGH') - return await DataAccess.HIGH(evaluatedArgs[0] as string, evaluatedArgs[1] as string, this.context) - if (name === 'LOW') - return await DataAccess.LOW(evaluatedArgs[0] as string, evaluatedArgs[1] as string, this.context) - if (name === 'OPEN') - return await DataAccess.OPEN(evaluatedArgs[0] as string, evaluatedArgs[1] as string, this.context) - if (name === 'VOLUME') - return await DataAccess.VOLUME(evaluatedArgs[0] as string, evaluatedArgs[1] as string, this.context) - - // Statistics functions - if (name === 'SMA') return Statistics.SMA(evaluatedArgs[0] as number[], evaluatedArgs[1] as number) - if (name === 'EMA') return Statistics.EMA(evaluatedArgs[0] as number[], evaluatedArgs[1] as number) - if (name === 'STDEV') return Statistics.STDEV(evaluatedArgs[0] as number[]) - if (name === 'MAX') return Statistics.MAX(evaluatedArgs[0] as number[]) - if (name === 'MIN') return Statistics.MIN(evaluatedArgs[0] as number[]) - if (name === 'SUM') return Statistics.SUM(evaluatedArgs[0] as number[]) - if (name === 'AVERAGE') return Statistics.AVERAGE(evaluatedArgs[0] as number[]) - - // Technical indicator functions - if (name === 'RSI') return Technical.RSI(evaluatedArgs[0] as number[], evaluatedArgs[1] as number) + // Data access functions: FUNC('symbol', 'interval') → TrackedValues + if (name === 'CLOSE' || name === 'HIGH' || name === 'LOW' || name === 'OPEN' || name === 'VOLUME') { + const fn = DataAccess[name] + const result = await fn(evaluatedArgs[0] as string, evaluatedArgs[1] as string, this.context) + this.collectSource(result) + return result + } + + // Statistics functions — accept number[] | TrackedValues + if (name === 'SMA') return Statistics.SMA(evaluatedArgs[0] as number[] | TrackedValues, evaluatedArgs[1] as number) + if (name === 'EMA') return Statistics.EMA(evaluatedArgs[0] as number[] | TrackedValues, evaluatedArgs[1] as number) + if (name === 'STDEV') return Statistics.STDEV(evaluatedArgs[0] as number[] | TrackedValues) + if (name === 'MAX') return Statistics.MAX(evaluatedArgs[0] as number[] | TrackedValues) + if (name === 'MIN') return Statistics.MIN(evaluatedArgs[0] as number[] | TrackedValues) + if (name === 'SUM') return Statistics.SUM(evaluatedArgs[0] as number[] | TrackedValues) + if (name === 'AVERAGE') return Statistics.AVERAGE(evaluatedArgs[0] as number[] | TrackedValues) + + // Technical indicator functions — accept number[] | TrackedValues + if (name === 'RSI') return Technical.RSI(evaluatedArgs[0] as number[] | TrackedValues, evaluatedArgs[1] as number) if (name === 'BBANDS') - return Technical.BBANDS(evaluatedArgs[0] as number[], evaluatedArgs[1] as number, evaluatedArgs[2] as number) + return Technical.BBANDS(evaluatedArgs[0] as number[] | TrackedValues, evaluatedArgs[1] as number, evaluatedArgs[2] as number) if (name === 'MACD') return Technical.MACD( - evaluatedArgs[0] as number[], + evaluatedArgs[0] as number[] | TrackedValues, evaluatedArgs[1] as number, evaluatedArgs[2] as number, evaluatedArgs[3] as number, ) if (name === 'ATR') return Technical.ATR( - evaluatedArgs[0] as number[], - evaluatedArgs[1] as number[], - evaluatedArgs[2] as number[], + evaluatedArgs[0] as number[] | TrackedValues, + evaluatedArgs[1] as number[] | TrackedValues, + evaluatedArgs[2] as number[] | TrackedValues, evaluatedArgs[3] as number, ) @@ -285,7 +309,9 @@ export class IndicatorCalculator { const right = await this.evaluate(node.right) if (typeof left !== 'number' || typeof right !== 'number') { - throw new Error(`Binary operations require numbers, got ${typeof left} and ${typeof right}`) + const leftType = left && typeof left === 'object' && 'values' in left ? 'TrackedValues' : typeof left + const rightType = right && typeof right === 'object' && 'values' in right ? 'TrackedValues' : typeof right + throw new Error(`Binary operations require numbers, got ${leftType} and ${rightType}`) } switch (node.operator) { @@ -304,18 +330,21 @@ export class IndicatorCalculator { const array = await this.evaluate(node.array) const index = await this.evaluate(node.index) - if (!Array.isArray(array)) { + // Extract values from TrackedValues or use raw array + const values = toValues(array as number[] | TrackedValues) + + if (!Array.isArray(values)) { throw new Error(`Array access requires an array, got ${typeof array}`) } if (typeof index !== 'number') { throw new Error(`Array index must be a number, got ${typeof index}`) } - const actualIndex = index < 0 ? array.length + index : index - if (actualIndex < 0 || actualIndex >= array.length) { + const actualIndex = index < 0 ? values.length + index : index + if (actualIndex < 0 || actualIndex >= values.length) { throw new Error(`Array index out of bounds: ${index}`) } - return array[actualIndex] + return values[actualIndex] } } diff --git a/src/domain/analysis/indicator/functions/data-access.ts b/src/domain/analysis/indicator/functions/data-access.ts index 1026f909..25d85cf1 100644 --- a/src/domain/analysis/indicator/functions/data-access.ts +++ b/src/domain/analysis/indicator/functions/data-access.ts @@ -8,49 +8,49 @@ * 数据拉取量由 adapter 层按 interval 决定,公式层不关心。 */ -import type { IndicatorContext } from '../types' +import type { IndicatorContext, TrackedValues } from '../types' export async function CLOSE( symbol: string, interval: string, context: IndicatorContext, -): Promise { - const data = await context.getHistoricalData(symbol, interval) - return data.map((d) => d.close) +): Promise { + const { data, meta } = await context.getHistoricalData(symbol, interval) + return { values: data.map((d) => d.close), source: meta } } export async function HIGH( symbol: string, interval: string, context: IndicatorContext, -): Promise { - const data = await context.getHistoricalData(symbol, interval) - return data.map((d) => d.high) +): Promise { + const { data, meta } = await context.getHistoricalData(symbol, interval) + return { values: data.map((d) => d.high), source: meta } } export async function LOW( symbol: string, interval: string, context: IndicatorContext, -): Promise { - const data = await context.getHistoricalData(symbol, interval) - return data.map((d) => d.low) +): Promise { + const { data, meta } = await context.getHistoricalData(symbol, interval) + return { values: data.map((d) => d.low), source: meta } } export async function OPEN( symbol: string, interval: string, context: IndicatorContext, -): Promise { - const data = await context.getHistoricalData(symbol, interval) - return data.map((d) => d.open) +): Promise { + const { data, meta } = await context.getHistoricalData(symbol, interval) + return { values: data.map((d) => d.open), source: meta } } export async function VOLUME( symbol: string, interval: string, context: IndicatorContext, -): Promise { - const data = await context.getHistoricalData(symbol, interval) - return data.map((d) => d.volume ?? 0) +): Promise { + const { data, meta } = await context.getHistoricalData(symbol, interval) + return { values: data.map((d) => d.volume ?? 0), source: meta } } diff --git a/src/domain/analysis/indicator/functions/statistics.ts b/src/domain/analysis/indicator/functions/statistics.ts index 3e4a72eb..aefedf51 100644 --- a/src/domain/analysis/indicator/functions/statistics.ts +++ b/src/domain/analysis/indicator/functions/statistics.ts @@ -2,66 +2,78 @@ * Statistics functions — 纯数学计算 * * SMA, EMA, STDEV, MAX, MIN, SUM, AVERAGE + * 接受 number[] 或 TrackedValues(自动提取 values) */ +import { toValues, type TrackedValues } from '../types' + +type NumericInput = number[] | TrackedValues + /** Simple Moving Average */ -export function SMA(data: number[], period: number): number { - if (data.length < period) { - throw new Error(`SMA requires at least ${period} data points, got ${data.length}`) +export function SMA(data: NumericInput, period: number): number { + const v = toValues(data) + if (v.length < period) { + throw new Error(`SMA requires at least ${period} data points, got ${v.length}`) } - const slice = data.slice(-period) + const slice = v.slice(-period) const sum = slice.reduce((acc, val) => acc + val, 0) return sum / period } /** Exponential Moving Average */ -export function EMA(data: number[], period: number): number { - if (data.length < period) { - throw new Error(`EMA requires at least ${period} data points, got ${data.length}`) +export function EMA(data: NumericInput, period: number): number { + const v = toValues(data) + if (v.length < period) { + throw new Error(`EMA requires at least ${period} data points, got ${v.length}`) } const multiplier = 2 / (period + 1) - let ema = data.slice(0, period).reduce((acc, val) => acc + val, 0) / period - for (let i = period; i < data.length; i++) { - ema = (data[i] - ema) * multiplier + ema + let ema = v.slice(0, period).reduce((acc, val) => acc + val, 0) / period + for (let i = period; i < v.length; i++) { + ema = (v[i] - ema) * multiplier + ema } return ema } /** Standard Deviation */ -export function STDEV(data: number[]): number { - if (data.length === 0) { +export function STDEV(data: NumericInput): number { + const v = toValues(data) + if (v.length === 0) { throw new Error('STDEV requires at least 1 data point') } - const mean = data.reduce((acc, val) => acc + val, 0) / data.length - const variance = data.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / data.length + const mean = v.reduce((acc, val) => acc + val, 0) / v.length + const variance = v.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / v.length return Math.sqrt(variance) } /** Maximum value */ -export function MAX(data: number[]): number { - if (data.length === 0) { +export function MAX(data: NumericInput): number { + const v = toValues(data) + if (v.length === 0) { throw new Error('MAX requires at least 1 data point') } - return Math.max(...data) + return Math.max(...v) } /** Minimum value */ -export function MIN(data: number[]): number { - if (data.length === 0) { +export function MIN(data: NumericInput): number { + const v = toValues(data) + if (v.length === 0) { throw new Error('MIN requires at least 1 data point') } - return Math.min(...data) + return Math.min(...v) } /** Sum */ -export function SUM(data: number[]): number { - return data.reduce((acc, val) => acc + val, 0) +export function SUM(data: NumericInput): number { + const v = toValues(data) + return v.reduce((acc, val) => acc + val, 0) } /** Average */ -export function AVERAGE(data: number[]): number { - if (data.length === 0) { +export function AVERAGE(data: NumericInput): number { + const v = toValues(data) + if (v.length === 0) { throw new Error('AVERAGE requires at least 1 data point') } - return data.reduce((acc, val) => acc + val, 0) / data.length + return v.reduce((acc, val) => acc + val, 0) / v.length } diff --git a/src/domain/analysis/indicator/functions/technical.ts b/src/domain/analysis/indicator/functions/technical.ts index dbff346f..09a971ad 100644 --- a/src/domain/analysis/indicator/functions/technical.ts +++ b/src/domain/analysis/indicator/functions/technical.ts @@ -2,19 +2,24 @@ * Technical indicator functions — 纯数学计算 * * RSI, BBANDS, MACD, ATR + * 接受 number[] 或 TrackedValues(自动提取 values) */ +import { toValues, type TrackedValues } from '../types' import { EMA } from './statistics' +type NumericInput = number[] | TrackedValues + /** Relative Strength Index (RSI) */ -export function RSI(data: number[], period: number = 14): number { - if (data.length < period + 1) { - throw new Error(`RSI requires at least ${period + 1} data points, got ${data.length}`) +export function RSI(data: NumericInput, period: number = 14): number { + const v = toValues(data) + if (v.length < period + 1) { + throw new Error(`RSI requires at least ${period + 1} data points, got ${v.length}`) } const changes: number[] = [] - for (let i = 1; i < data.length; i++) { - changes.push(data[i] - data[i - 1]) + for (let i = 1; i < v.length; i++) { + changes.push(v[i] - v[i - 1]) } const gains = changes.map((c) => (c > 0 ? c : 0)) @@ -38,15 +43,16 @@ export function RSI(data: number[], period: number = 14): number { /** Bollinger Bands (BBANDS) */ export function BBANDS( - data: number[], + data: NumericInput, period: number = 20, stdDevMultiplier: number = 2, ): { upper: number; middle: number; lower: number } { - if (data.length < period) { - throw new Error(`BBANDS requires at least ${period} data points, got ${data.length}`) + const v = toValues(data) + if (v.length < period) { + throw new Error(`BBANDS requires at least ${period} data points, got ${v.length}`) } - const slice = data.slice(-period) + const slice = v.slice(-period) const middle = slice.reduce((acc, val) => acc + val, 0) / period const variance = slice.reduce((acc, val) => acc + Math.pow(val - middle, 2), 0) / period const stdDev = Math.sqrt(variance) @@ -60,24 +66,25 @@ export function BBANDS( /** MACD (Moving Average Convergence Divergence) */ export function MACD( - data: number[], + data: NumericInput, fastPeriod: number = 12, slowPeriod: number = 26, signalPeriod: number = 9, ): { macd: number; signal: number; histogram: number } { - if (data.length < slowPeriod + signalPeriod) { + const v = toValues(data) + if (v.length < slowPeriod + signalPeriod) { throw new Error( - `MACD requires at least ${slowPeriod + signalPeriod} data points, got ${data.length}`, + `MACD requires at least ${slowPeriod + signalPeriod} data points, got ${v.length}`, ) } - const fastEMA = EMA(data, fastPeriod) - const slowEMA = EMA(data, slowPeriod) + const fastEMA = EMA(v, fastPeriod) + const slowEMA = EMA(v, slowPeriod) const macdValue = fastEMA - slowEMA const macdHistory: number[] = [] - for (let i = slowPeriod; i <= data.length; i++) { - const slice = data.slice(0, i) + for (let i = slowPeriod; i <= v.length; i++) { + const slice = v.slice(0, i) const fast = EMA(slice, fastPeriod) const slow = EMA(slice, slowPeriod) macdHistory.push(fast - slow) @@ -95,25 +102,24 @@ export function MACD( /** Average True Range (ATR) */ export function ATR( - highs: number[], - lows: number[], - closes: number[], + highs: NumericInput, + lows: NumericInput, + closes: NumericInput, period: number = 14, ): number { - if (highs.length !== lows.length || lows.length !== closes.length || highs.length < period + 1) { + const h = toValues(highs) + const l = toValues(lows) + const c = toValues(closes) + if (h.length !== l.length || l.length !== c.length || h.length < period + 1) { throw new Error(`ATR requires at least ${period + 1} data points for all arrays`) } const trueRanges: number[] = [] - for (let i = 1; i < highs.length; i++) { - const high = highs[i] - const low = lows[i] - const prevClose = closes[i - 1] - + for (let i = 1; i < h.length; i++) { const tr = Math.max( - high - low, - Math.abs(high - prevClose), - Math.abs(low - prevClose), + h[i] - l[i], + Math.abs(h[i] - c[i - 1]), + Math.abs(l[i] - c[i - 1]), ) trueRanges.push(tr) } diff --git a/src/domain/analysis/indicator/types.ts b/src/domain/analysis/indicator/types.ts index ab0bc078..b409faae 100644 --- a/src/domain/analysis/indicator/types.ts +++ b/src/domain/analysis/indicator/types.ts @@ -1,12 +1,12 @@ /** * Indicator Calculator — 类型定义 * - * 通用 OHLCV 量化因子计算器,支持 equity / crypto / currency。 + * 通用 OHLCV 量化因子计算器,支持 equity / crypto / currency / commodity。 */ // ==================== Data ==================== -/** 通用 OHLCV 数据,equity/crypto/currency 共用 */ +/** 通用 OHLCV 数据,equity/crypto/currency/commodity 共用 */ export interface OhlcvData { date: string open: number @@ -17,6 +17,31 @@ export interface OhlcvData { [key: string]: unknown } +/** 数据来源元数据 — 从实际 bar 数据提取,不人造 */ +export interface DataSourceMeta { + symbol: string + from: string // 第一根 bar 的 date + to: string // 最后一根 bar 的 date + bars: number +} + +/** getHistoricalData 的返回值 — OHLCV + 元数据 */ +export interface HistoricalDataResult { + data: OhlcvData[] + meta: DataSourceMeta +} + +/** 带数据来源元数据的数值数组 — 从 data-access 函数一路冒泡 */ +export interface TrackedValues { + values: number[] + source: DataSourceMeta +} + +/** 从 number[] 或 TrackedValues 提取纯数组 */ +export function toValues(input: number[] | TrackedValues): number[] { + return Array.isArray(input) ? input : input.values +} + // ==================== Context ==================== /** 指标计算上下文 — 提供历史 OHLCV 数据获取能力 */ @@ -26,12 +51,12 @@ export interface IndicatorContext { * @param symbol - 资产 symbol,如 "AAPL"、"BTCUSD"、"EURUSD" * @param interval - K 线周期,如 "1d", "1w", "1h" */ - getHistoricalData: (symbol: string, interval: string) => Promise + getHistoricalData: (symbol: string, interval: string) => Promise } // ==================== AST ==================== -export type CalculationResult = number | number[] | string | Record +export type CalculationResult = number | number[] | string | Record | TrackedValues export type ASTNode = | NumberNode diff --git a/src/tool/analysis.ts b/src/tool/analysis.ts index 8c0d92fb..26c853c6 100644 --- a/src/tool/analysis.ts +++ b/src/tool/analysis.ts @@ -10,7 +10,7 @@ import { tool } from 'ai' import { z } from 'zod' import type { EquityClientLike, CryptoClientLike, CurrencyClientLike, CommodityClientLike } from '@/domain/market-data/client/types' import { IndicatorCalculator } from '@/domain/analysis/indicator/calculator' -import type { IndicatorContext, OhlcvData } from '@/domain/analysis/indicator/types' +import type { IndicatorContext, OhlcvData, HistoricalDataResult, DataSourceMeta } from '@/domain/analysis/indicator/types' /** 根据 interval 决定拉取的日历天数(约 1 倍冗余) */ function getCalendarDays(interval: string): number { @@ -44,7 +44,7 @@ function buildContext( commodityClient: CommodityClientLike, ): IndicatorContext { return { - getHistoricalData: async (symbol, interval) => { + getHistoricalData: async (symbol, interval): Promise => { const start_date = buildStartDate(interval) let raw: Array> @@ -64,13 +64,21 @@ function buildContext( } // Filter out bars with null OHLC (yfinance returns null for incomplete/missing data) - const results = raw.filter( + const data = raw.filter( (d): d is Record & OhlcvData => d.close != null && d.open != null && d.high != null && d.low != null, ) as OhlcvData[] - results.sort((a, b) => a.date.localeCompare(b.date)) - return results + data.sort((a, b) => a.date.localeCompare(b.date)) + + const meta: DataSourceMeta = { + symbol, + from: data.length > 0 ? data[0].date : '', + to: data.length > 0 ? data[data.length - 1].date : '', + bars: data.length, + } + + return { data, meta } }, } } From bc822e625d740e892b01de8c0192c4c995832170 Mon Sep 17 00:00:00 2001 From: Ame Date: Tue, 14 Apr 2026 10:45:55 +0800 Subject: [PATCH 4/5] test: add analysis tool e2e tests across equity/crypto/commodity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full pipeline tests: SDK client → OHLCV fetch → calculator → { value, dataRange }. Covers CLOSE, SMA, RSI, BBANDS for equity (AAPL), crypto (BTCUSD, ETHUSD), commodity canonical names (gold, crude_oil), and FMP commodity with year >= 2026 assertion to catch stale-data bugs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bbProviders/analysis.bbProvider.spec.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts diff --git a/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts new file mode 100644 index 00000000..4b02f6ff --- /dev/null +++ b/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts @@ -0,0 +1,158 @@ +/** + * Analysis tool e2e test — calculateIndicator full pipeline. + * + * Tests the complete chain: SDK client → OHLCV fetch → calculator → { value, dataRange }. + * Uses real provider APIs (yfinance free, FMP with key). + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import { getTestContext, hasCredential, type TestContext } from './setup.js' +import { getSDKExecutor } from '@/domain/market-data/client/typebb/executor.js' +import { buildRouteMap } from '@/domain/market-data/client/typebb/route-map.js' +import { SDKEquityClient } from '@/domain/market-data/client/typebb/equity-client.js' +import { SDKCryptoClient } from '@/domain/market-data/client/typebb/crypto-client.js' +import { SDKCurrencyClient } from '@/domain/market-data/client/typebb/currency-client.js' +import { SDKCommodityClient } from '@/domain/market-data/client/typebb/commodity-client.js' +import { createAnalysisTools } from '@/tool/analysis.js' + +let ctx: TestContext +let calculateIndicator: ReturnType['calculateIndicator'] + +beforeAll(async () => { + ctx = await getTestContext() + const executor = getSDKExecutor() + const routeMap = buildRouteMap() + const creds = ctx.credentials + + const equityClient = new SDKEquityClient(executor, 'equity', 'yfinance', creds, routeMap) + const cryptoClient = new SDKCryptoClient(executor, 'crypto', 'yfinance', creds, routeMap) + const currencyClient = new SDKCurrencyClient(executor, 'currency', 'yfinance', creds, routeMap) + const commodityClient = new SDKCommodityClient(executor, 'commodity', 'yfinance', creds, routeMap) + + const tools = createAnalysisTools(equityClient, cryptoClient, currencyClient, commodityClient) + calculateIndicator = tools.calculateIndicator +}) + +const run = (asset: string, formula: string, precision?: number) => + calculateIndicator.execute({ asset: asset as 'equity', formula, precision }, { toolCallId: 'test', messages: [] as any, abortSignal: undefined as any }) + +describe('analysis e2e — equity (yfinance)', () => { + it('CLOSE latest price', async () => { + const result = await run('equity', "CLOSE('AAPL', '1d')[-1]") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + expect(result.dataRange).toHaveProperty('AAPL') + expect(result.dataRange.AAPL.bars).toBeGreaterThan(100) + }) + + it('SMA(50)', async () => { + const result = await run('equity', "SMA(CLOSE('AAPL', '1d'), 50)") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + }) + + it('RSI(14)', async () => { + const result = await run('equity', "RSI(CLOSE('AAPL', '1d'), 14)") + expect(result.value).toBeGreaterThanOrEqual(0) + expect(result.value).toBeLessThanOrEqual(100) + }) + + it('dataRange.to is recent', async () => { + const result = await run('equity', "CLOSE('AAPL', '1d')[-1]") + const to = new Date(result.dataRange.AAPL.to) + const daysAgo = (Date.now() - to.getTime()) / (1000 * 60 * 60 * 24) + expect(daysAgo).toBeLessThan(7) // should be within last week + }) +}) + +describe('analysis e2e — crypto (yfinance)', () => { + it('CLOSE latest + dataRange', async () => { + const result = await run('crypto', "CLOSE('BTCUSD', '1d')[-1]") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + expect(result.dataRange).toHaveProperty('BTCUSD') + }) + + it('SMA(50)', async () => { + const result = await run('crypto', "SMA(CLOSE('BTCUSD', '1d'), 50)") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + }) + + it('RSI(14)', async () => { + const result = await run('crypto', "RSI(CLOSE('BTCUSD', '1d'), 14)") + expect(result.value).toBeGreaterThanOrEqual(0) + expect(result.value).toBeLessThanOrEqual(100) + }) + + it('BBANDS(20, 2)', async () => { + const result = await run('crypto', "BBANDS(CLOSE('BTCUSD', '1d'), 20, 2)") + const v = result.value as Record + expect(v).toHaveProperty('upper') + expect(v).toHaveProperty('middle') + expect(v).toHaveProperty('lower') + }) + + it('ETHUSD CLOSE latest', async () => { + const result = await run('crypto', "CLOSE('ETHUSD', '1d')[-1]") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + }) +}) + +describe('analysis e2e — commodity canonical names (yfinance)', () => { + it('gold — CLOSE latest', async () => { + const result = await run('commodity', "CLOSE('gold', '1d')[-1]") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + expect(result.dataRange).toHaveProperty('gold') + expect(result.dataRange.gold.bars).toBeGreaterThan(100) + }) + + it('gold — RSI(14)', async () => { + const result = await run('commodity', "RSI(CLOSE('gold', '1d'), 14)") + expect(result.value).toBeGreaterThanOrEqual(0) + expect(result.value).toBeLessThanOrEqual(100) + }) + + it('crude_oil — CLOSE latest', async () => { + const result = await run('commodity', "CLOSE('crude_oil', '1d')[-1]") + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + expect(result.dataRange).toHaveProperty('crude_oil') + }) + + it('dataRange.to is recent (not 2022)', async () => { + const result = await run('commodity', "CLOSE('gold', '1d')[-1]") + const to = new Date(result.dataRange.gold.to) + const year = to.getFullYear() + expect(year).toBeGreaterThanOrEqual(2026) // catches the FMP-2022-data bug + }) +}) + +describe('analysis e2e — commodity with FMP', () => { + beforeEach(({ skip }) => { if (!hasCredential(ctx.credentials, 'fmp')) skip('no fmp_api_key') }) + + it('gold via FMP canonical name', async () => { + // Rebuild clients with FMP as commodity provider + const executor = getSDKExecutor() + const routeMap = buildRouteMap() + const commodityClientFmp = new SDKCommodityClient(executor, 'commodity', 'fmp', ctx.credentials, routeMap) + const equityClient = new SDKEquityClient(executor, 'equity', 'yfinance', ctx.credentials, routeMap) + const cryptoClient = new SDKCryptoClient(executor, 'crypto', 'yfinance', ctx.credentials, routeMap) + const currencyClient = new SDKCurrencyClient(executor, 'currency', 'yfinance', ctx.credentials, routeMap) + + const tools = createAnalysisTools(equityClient, cryptoClient, currencyClient, commodityClientFmp) + const result = await tools.calculateIndicator.execute( + { asset: 'commodity', formula: "CLOSE('gold', '1d')[-1]" }, + { toolCallId: 'test', messages: [] as any, abortSignal: undefined as any }, + ) + + expect(typeof result.value).toBe('number') + expect(result.value).toBeGreaterThan(0) + expect(result.dataRange.gold.bars).toBeGreaterThan(100) + // Verify it's current data, not 2022 + const year = new Date(result.dataRange.gold.to).getFullYear() + expect(year).toBeGreaterThanOrEqual(2026) + }) +}) From 2f4f577d432c8986d516f1a9ebbad79683f11393 Mon Sep 17 00:00:00 2001 From: Ame Date: Tue, 14 Apr 2026 11:21:32 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20clarify=20tool=20description=20?= =?UTF-8?q?=E2=80=94=20SMA/RSI=20return=20scalar,=20no=20[-1]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alice was appending [-1] to SMA/RSI results, causing "Array access requires an array, got number" errors. The tool description grouped array access with stats/technical functions, making it easy to misread as "all results need [-1]". Restructured description: data-access functions (CLOSE etc) return arrays and support [-1]; stats/technical functions return scalars and must NOT use [-1]. Also noted commodity canonical names and the new { value, dataRange } return shape. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bbProviders/analysis.bbProvider.spec.ts | 6 ++-- src/tool/analysis.ts | 31 ++++++++++++------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts index 4b02f6ff..93d1941c 100644 --- a/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts +++ b/src/domain/market-data/__tests__/bbProviders/analysis.bbProvider.spec.ts @@ -33,8 +33,8 @@ beforeAll(async () => { calculateIndicator = tools.calculateIndicator }) -const run = (asset: string, formula: string, precision?: number) => - calculateIndicator.execute({ asset: asset as 'equity', formula, precision }, { toolCallId: 'test', messages: [] as any, abortSignal: undefined as any }) +const run = async (asset: string, formula: string, precision?: number) => + calculateIndicator.execute!({ asset: asset as 'equity', formula, precision }, { toolCallId: 'test', messages: [] as any, abortSignal: undefined as any }) as any describe('analysis e2e — equity (yfinance)', () => { it('CLOSE latest price', async () => { @@ -143,7 +143,7 @@ describe('analysis e2e — commodity with FMP', () => { const currencyClient = new SDKCurrencyClient(executor, 'currency', 'yfinance', ctx.credentials, routeMap) const tools = createAnalysisTools(equityClient, cryptoClient, currencyClient, commodityClientFmp) - const result = await tools.calculateIndicator.execute( + const result: any = await tools.calculateIndicator.execute!( { asset: 'commodity', formula: "CLOSE('gold', '1d')[-1]" }, { toolCallId: 'test', messages: [] as any, abortSignal: undefined as any }, ) diff --git a/src/tool/analysis.ts b/src/tool/analysis.ts index 26c853c6..410fd23b 100644 --- a/src/tool/analysis.ts +++ b/src/tool/analysis.ts @@ -91,22 +91,31 @@ export function createAnalysisTools( ) { return { calculateIndicator: tool({ - description: `Calculate technical indicators for any asset (equity, crypto, currency) using formula expressions. + description: `Calculate technical indicators for any asset using formula expressions. -Asset classes: "equity" for stocks, "crypto" for cryptocurrencies, "currency" for forex pairs, "commodity" for commodities (gold, oil, etc.). +Asset classes: "equity" for stocks, "crypto" for cryptocurrencies, "currency" for forex pairs, "commodity" for commodities (use canonical names: gold, crude_oil, copper, etc.). -Data access: CLOSE('AAPL', '1d'), HIGH, LOW, OPEN, VOLUME — args: symbol, interval (e.g. '1d', '1w', '1h'). -Statistics: SMA(data, period), EMA, STDEV, MAX, MIN, SUM, AVERAGE. -Technical: RSI(data, 14), BBANDS(data, 20, 2), MACD(data, 12, 26, 9), ATR(highs, lows, closes, 14). -Array access: CLOSE('AAPL', '1d')[-1] for latest price. Supports +, -, *, / operators. +Data access (returns array — use [-1] for latest value): + CLOSE('AAPL', '1d'), HIGH, LOW, OPEN, VOLUME — args: symbol, interval (e.g. '1d', '1w', '1h'). + CLOSE('AAPL', '1d')[-1] → latest close price as a single number. + +Statistics (returns a single number — do NOT use [-1]): + SMA(data, period), EMA, STDEV, MAX, MIN, SUM, AVERAGE. + +Technical (returns a single number or object — do NOT use [-1]): + RSI(data, 14) → number. BBANDS(data, 20, 2) → {upper, middle, lower}. + MACD(data, 12, 26, 9) → {macd, signal, histogram}. ATR(highs, lows, closes, 14) → number. + +Arithmetic: +, -, *, / operators between numbers. E.g. CLOSE(...)[-1] - SMA(..., 50). Examples: - asset="equity": SMA(CLOSE('AAPL', '1d'), 50) - asset="crypto": RSI(CLOSE('BTCUSD', '1d'), 14) - asset="currency": CLOSE('EURUSD', '1d')[-1] - asset="commodity": SMA(CLOSE('GC=F', '1d'), 20) (gold futures) + SMA(CLOSE('AAPL', '1d'), 50) → equity 50-day moving average + RSI(CLOSE('BTCUSD', '1d'), 14) → crypto RSI (single number, no [-1]) + CLOSE('EURUSD', '1d')[-1] → latest forex close (needs [-1]) + CLOSE('gold', '1d')[-1] → latest gold price (canonical name) -Use the corresponding search tool first to resolve the correct symbol.`, +Returns { value, dataRange } where dataRange shows the actual date span of the data used. +Use marketSearchForResearch to find the correct symbol first.`, inputSchema: z.object({ asset: z.enum(['equity', 'crypto', 'currency', 'commodity']).describe('Asset class'), formula: z.string().describe("Formula expression, e.g. SMA(CLOSE('AAPL', '1d'), 50)"),