diff --git a/docs/provider-e2e.md b/docs/provider-e2e.md new file mode 100644 index 0000000..7ed820c --- /dev/null +++ b/docs/provider-e2e.md @@ -0,0 +1,41 @@ +# Live 双 Provider E2E + +该检查只验证真实 Provider,不使用 mock。它分别创建 Longbridge 和 Massive 适配器,经过 `ProviderRouter` 执行共同能力 `market.quote`、`market.kline`、`company.profile`,并检查规范化结果和 `ProviderProvenance` 的 `providerId`、`fetchedAt`、`stale` 等字段。 + +## 前置条件 + +1. 安装 Longbridge CLI,并完成登录:`longbridge auth login`。 +2. 设置 Massive API key。开发环境可使用 `MASSIVE_API_KEY`;兼容旧名称 `POLYGON_API_KEY`。 +3. 使用美股标的,默认是 `AAPL.US`。可通过 `FINAGENT_PROVIDER_E2E_SYMBOL` 覆盖。 + +密钥只通过进程环境变量传给适配器,不会写入报告或日志。Massive 返回的数据可能带有延迟/日终属性,报告会保留其 `delayed` 来源标记。 + +## 运行 + +默认命令不会联网,也不会执行真实请求: + +```sh +bun run test:provider-e2e +``` + +显式开启真实检查: + +```sh +# PowerShell +$env:FINAGENT_PROVIDER_E2E = "1" +$env:MASSIVE_API_KEY = "你的密钥" +bun run test:provider-e2e +``` + +可选地将脱敏 JSON 报告写入文件: + +```powershell +$env:FINAGENT_PROVIDER_E2E_OUTPUT = "artifacts/provider-e2e.json" +bun run test:provider-e2e +``` + +没有 Longbridge 登录、没有 Massive key、Provider 返回错误或规范化/来源校验失败时,命令以非零状态结束;不会把 `SKIPPED` 当作通过。普通 CI 只应运行 `bun run test:provider-e2e:guard`,真实 E2E 需要在具备两套凭据的受控环境中手动运行。 + +## 验收记录 + +提交真实验收结果时,应附上命令输出或脱敏 JSON,并记录 Bun 版本、操作系统/架构、执行时间和实际 Provider 状态。不要提交 API key、Longbridge 账户标识或供应商原始响应。 diff --git a/docs/provider-e2e.zh-CN.md b/docs/provider-e2e.zh-CN.md new file mode 100644 index 0000000..03e0167 --- /dev/null +++ b/docs/provider-e2e.zh-CN.md @@ -0,0 +1,61 @@ +# 真实双 Provider E2E + +该检查只访问真实 Provider,不使用 mock。它分别通过 `ProviderRouter` 调用 Longbridge 和 Massive 的共同能力:`market.quote`、`market.kline`、`company.profile`,并校验规范化结果及 `ProviderProvenance`。 + +## 前置条件 + +1. 安装 Longbridge CLI。Windows 可执行: + + ```powershell + winget install --id Longbridge.LongbridgeTerminal --location "D:\Tools\Longbridge" + ``` + +2. 打开新的 PowerShell 窗口,完成 Longbridge 登录: + + ```powershell + longbridge auth login + longbridge auth status --format json + ``` + + 返回的 `token.status` 必须是 `valid`。如果当前窗口仍找不到命令,可临时执行: + + ```powershell + $env:Path = "D:\Tools\Longbridge;$env:Path" + ``` + +3. 在 [Massive](https://massive.com/) 控制台创建 API Key,并设置环境变量: + + ```powershell + $env:MASSIVE_API_KEY = "你的 Massive API Key" + ``` + + 兼容旧变量名 `POLYGON_API_KEY`。密钥只存在于当前进程环境,不会写入报告。 + +## 运行 + +默认不会联网: + +```powershell +bun run test:provider-e2e +``` + +显式开启真实 E2E: + +```powershell +$env:FINAGENT_PROVIDER_E2E = "1" +$env:FINAGENT_PROVIDER_E2E_SYMBOL = "AAPL.US" +bun run test:provider-e2e +``` + +可选保存脱敏 JSON: + +```powershell +$env:FINAGENT_PROVIDER_E2E_OUTPUT = "artifacts/provider-e2e.json" +bun run test:provider-e2e +``` + +缺少任一凭据、Longbridge 未登录、Provider 返回错误,或规范化/来源校验失败时,命令以非零状态结束;不会将 `SKIPPED` 当作通过。普通 CI 使用 `bun run test:provider-e2e:guard`,真实 E2E 需要在具备两套凭据的受控环境中手动运行。 + +## 验收记录 + +提交真实验收结果时,应附上脱敏 JSON 或命令输出,并记录 Bun 版本、操作系统/架构、执行时间和两个 Provider 的实际状态。不得提交 API Key、Longbridge 账户标识或供应商原始响应。 diff --git a/package.json b/package.json index 4ff9240..8eeb217 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "test:ui": "bun test packages/ui", "test:e2e": "cd apps/electron && bun run test:e2e", "test:e2e:visible": "cd apps/electron && FINAGENT_E2E_VISIBLE=1 bun run test:e2e", + "test:provider-e2e": "bun run scripts/provider-e2e.ts", + "test:provider-e2e:guard": "bun test scripts/provider-e2e.test.ts", "test:package-smoke": "cd apps/electron && bun run test:package-smoke", "test:release": "bun run release:check", "release:check": "bun run scripts/release-check.mjs", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9e479bb..80784a2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,6 +47,30 @@ export interface Kline { volume: number; } +/** Provider-neutral query options for historical OHLCV bars. */ +export interface KlineQueryOptions { + symbol: string; + period?: '1m' | '5m' | '15m' | '1h' | '1d' | '1w'; + start?: number; + end?: number; + limit?: number; +} + +/** Provider-neutral query options for finance-calendar events. */ +export interface CalendarEventsQueryOptions { + eventType: 'financial' | 'report' | 'dividend' | 'ipo' | 'macrodata' | 'closed'; + symbols?: string[]; + start?: string; + end?: string; + count?: number; +} + +/** Provider-neutral query options for account cash-flow records. */ +export interface CashFlowQueryOptions { + start?: string; + end?: string; +} + export interface IntradayData { symbol: string; timestamp: number; diff --git a/packages/i18n/src/locales/en-US/connections.ts b/packages/i18n/src/locales/en-US/connections.ts index 4eccbc0..617e780 100644 --- a/packages/i18n/src/locales/en-US/connections.ts +++ b/packages/i18n/src/locales/en-US/connections.ts @@ -38,7 +38,7 @@ export const connections = { reconnect: 'Reconnect', installSetup: 'Install / Setup', byokNote: - 'Your own key governs usage. Free tiers may return end-of-day data (5 calls/min) and require attribution ("Powered by Polygon.io").', + 'Your own key governs usage. Massive may return delayed or end-of-day data and may require attribution ("Powered by Polygon.io").', dismissError: 'Dismiss error', dismiss: 'Dismiss', portfolioReady: 'Portfolio ✓', diff --git a/packages/i18n/src/locales/zh-CN/connections.ts b/packages/i18n/src/locales/zh-CN/connections.ts index 8af3c18..378fafd 100644 --- a/packages/i18n/src/locales/zh-CN/connections.ts +++ b/packages/i18n/src/locales/zh-CN/connections.ts @@ -39,7 +39,7 @@ export const connections = { reconnect: '重新连接', installSetup: '安装 / 设置', byokNote: - '你自己的密钥决定使用情况。免费套餐可能返回当日结束后的数据(每分钟 5 次调用)并要求注明来源(“Powered by Polygon.io”)。', + '你自己的密钥决定使用情况。Massive 可能返回延迟或日终数据,并可能要求注明来源(“Powered by Polygon.io”)。', dismissError: '关闭错误', dismiss: '关闭', portfolioReady: '投资组合 ✓', diff --git a/packages/shared/src/agent/market-data-service.ts b/packages/shared/src/agent/market-data-service.ts index 3de4512..f8c7cc9 100644 --- a/packages/shared/src/agent/market-data-service.ts +++ b/packages/shared/src/agent/market-data-service.ts @@ -1,17 +1,18 @@ import type { AccountAssets, + CalendarEventsQueryOptions, CalcIndex, + CashFlowQueryOptions, CashFlowRecord, Holding, IntradayData, Kline, + KlineQueryOptions, MarketStatus, NewsItem, PortfolioSnapshot, Quote, StaticInfo, -} from '@finagent/core'; -import type { CalendarEvent, CapitalFlow, Depth, @@ -21,7 +22,7 @@ import type { InstitutionRating, MarketTemperature, TradeTick, -} from '@finagent/longbridge-tools'; +} from '@finagent/core'; import { getAccountPositions, getAssets, @@ -44,9 +45,6 @@ import { getQuote, getStaticInfo, getTrades, - type GetCalendarEventsOptions, - type GetCashFlowOptions, - type GetKlineOptions, type LongBridgeStatus, } from '@finagent/longbridge-tools'; @@ -62,7 +60,7 @@ export interface MarketDataServiceOptions { export interface MarketDataFetchers { getQuote: (symbol: string) => Promise; - getKline: (options: GetKlineOptions) => Promise; + getKline: (options: KlineQueryOptions) => Promise; getIntraday: (symbol: string) => Promise; getPortfolio: () => Promise; getLongBridgeStatus: () => Promise; @@ -82,10 +80,10 @@ export interface MarketDataFetchers { getInstitutionRating: (symbol: string) => Promise; getDividends: (symbol: string) => Promise; getEpsForecasts: (symbol: string) => Promise; - getCalendarEvents: (options: GetCalendarEventsOptions) => Promise; + getCalendarEvents: (options: CalendarEventsQueryOptions) => Promise; getAccountPositions: () => Promise; getAssets: (currency?: string) => Promise; - getCashFlow: (options?: GetCashFlowOptions) => Promise; + getCashFlow: (options?: CashFlowQueryOptions) => Promise; } interface CacheEntry { @@ -142,7 +140,7 @@ export class MarketDataService { return this.cached(`quote:${symbol}`, this.quoteTTL, () => this.fetchers.getQuote(symbol)); } - getKline(options: GetKlineOptions) { + getKline(options: KlineQueryOptions) { const key = `kline:${options.symbol}:${options.period ?? '1d'}:${options.limit ?? 100}`; return this.cached(key, this.klineTTL, () => this.fetchers.getKline(options)); } @@ -222,7 +220,7 @@ export class MarketDataService { ); } - getCalendarEvents(options: GetCalendarEventsOptions) { + getCalendarEvents(options: CalendarEventsQueryOptions) { const key = `calendar:${options.eventType}:${(options.symbols ?? []).join(',')}:${options.start ?? ''}:${options.end ?? ''}:${options.count ?? 100}`; return this.cached(key, this.referenceTTL, () => this.fetchers.getCalendarEvents(options)); } @@ -237,7 +235,7 @@ export class MarketDataService { ); } - getCashFlow(options: GetCashFlowOptions = {}) { + getCashFlow(options: CashFlowQueryOptions = {}) { const key = `cash-flow:${options.start ?? ''}:${options.end ?? ''}`; return this.cached(key, this.portfolioTTL, () => this.fetchers.getCashFlow(options)); } diff --git a/packages/shared/src/capabilities/fetchers.ts b/packages/shared/src/capabilities/fetchers.ts index d4eee25..ee1b9e5 100644 --- a/packages/shared/src/capabilities/fetchers.ts +++ b/packages/shared/src/capabilities/fetchers.ts @@ -1,6 +1,8 @@ import type { AccountAssets, + CalendarEventsQueryOptions, CalcIndex, + CashFlowQueryOptions, CalendarEvent, CapitalFlow, CashFlowRecord, @@ -12,6 +14,7 @@ import type { InstitutionRating, IntradayData, Kline, + KlineQueryOptions, MarketStatus, MarketTemperature, NewsItem, @@ -41,10 +44,8 @@ import { getQuote, getStaticInfo, getTrades, - type GetCalendarEventsOptions, - type GetCashFlowOptions, - type GetKlineOptions, } from '@finagent/longbridge-tools'; +import type { CapabilityProvenance, ProviderResult } from '@finagent/core'; /** * Provider fetchers consumed by the capability manifests. Production uses the @@ -54,7 +55,7 @@ import { */ export interface CapabilityFetchers { getQuote: (symbol: string) => Promise; - getKline: (options: GetKlineOptions) => Promise; + getKline: (options: KlineQueryOptions) => Promise; getIntraday: (symbol: string) => Promise; getMarketStatus: () => Promise; getStaticInfo: (symbol: string) => Promise; @@ -73,10 +74,54 @@ export interface CapabilityFetchers { getInstitutionRating: (symbol: string) => Promise; getDividends: (symbol: string) => Promise; getEpsForecasts: (symbol: string) => Promise; - getCalendarEvents: (options: GetCalendarEventsOptions) => Promise; + getCalendarEvents: (options: CalendarEventsQueryOptions) => Promise; getAccountPositions: () => Promise; getAssets: (currency?: string) => Promise; - getCashFlow: (options?: GetCashFlowOptions) => Promise; + getCashFlow: (options?: CashFlowQueryOptions) => Promise; + /** Optional structured gateway entry point used to preserve provenance. */ + execute?: (capabilityId: string, input: unknown, signal?: AbortSignal) => Promise>; +} + +export interface ResolvedCapabilityFetch { + data: T; + provenance: CapabilityProvenance; +} + +/** Resolve through the provider gateway when configured, preserving its provenance. */ +export async function resolveCapabilityFetch( + fetchers: CapabilityFetchers, + capabilityId: string, + input: unknown, + fallback: () => Promise, + now: () => number, + marketTime?: number, + signal?: AbortSignal +): Promise> { + if (fetchers.execute) { + const result = await fetchers.execute(capabilityId, input, signal); + if (!result.ok) { + const error = Object.assign(new Error(result.error.message), { + code: result.error.code, + retryable: result.error.retryable, + }); + throw error; + } + return { + data: result.data, + provenance: { + provider: result.provenance.providerId, + providerId: result.provenance.providerId, + fetchedAt: result.provenance.fetchedAt, + marketTime: result.provenance.marketTime ?? marketTime, + delayed: result.provenance.delayed, + stale: result.provenance.stale, + }, + }; + } + return { + data: await fallback(), + provenance: { provider: 'longbridge', fetchedAt: now(), marketTime, stale: false }, + }; } export const defaultCapabilityFetchers: CapabilityFetchers = { diff --git a/packages/shared/src/capabilities/manifests.test.ts b/packages/shared/src/capabilities/manifests.test.ts index 20ceca5..748d29f 100644 --- a/packages/shared/src/capabilities/manifests.test.ts +++ b/packages/shared/src/capabilities/manifests.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; import { createMarketQuoteCapability } from './manifests/market-quote.ts'; +import { createMarketKlineCapability } from './manifests/market-kline.ts'; +import { createCompanyProfileCapability } from './manifests/company-profile.ts'; +import { createCompanyFinancialsCapability } from './manifests/phase-two.ts'; import type { CapabilityFetchers } from './fetchers.ts'; const quote = { @@ -80,4 +83,65 @@ describe('market.quote manifest', () => { expect(cap.category).toBe('market'); expect(cap.riskLevel).toBe('read'); }); + + it('preserves the answering provider provenance from the gateway', async () => { + const routed = fetchers({ + getQuote: async () => { + throw new Error('legacy fetcher should not be called'); + }, + execute: async () => ({ + ok: true as const, + data: quote as T, + provenance: { + providerId: 'massive', + providerName: 'Massive', + fetchedAt: 20000, + marketTime: 19000, + delayed: true, + stale: false, + }, + }), + }); + + const result = await createMarketQuoteCapability(routed).execute( + { symbol: 'AAPL.US' }, + { now: () => 99999 } + ); + expect(result.provenance).toEqual({ + provider: 'massive', + providerId: 'massive', + fetchedAt: 20000, + marketTime: 19000, + delayed: true, + stale: false, + }); + }); +}); + +describe('provider-routed history and fundamentals manifests', () => { + it('uses gateway provenance for K-line history and company profile', async () => { + const klines = [{ symbol: 'AAPL.US', timestamp: 18000, open: 1, high: 2, low: 0.5, close: 1.5, volume: 10 }]; + const info = { symbol: 'AAPL.US', name: 'Apple' }; + const financials = { symbol: 'AAPL.US', report: 'qf', statements: {} }; + const routed = fetchers({ + execute: async (capabilityId: string) => ({ + ok: true as const, + data: (capabilityId === 'market.kline' ? klines : capabilityId === 'company.financials' ? financials : info) as T, + provenance: { + providerId: 'massive', + providerName: 'Massive', + fetchedAt: 22000, + stale: false, + }, + }), + }); + + const history = await createMarketKlineCapability(routed).execute({ symbol: 'AAPL.US', limit: 1 }); + const profile = await createCompanyProfileCapability(routed).execute({ symbol: 'AAPL.US' }); + const fundamentals = await createCompanyFinancialsCapability(routed).execute({ symbol: 'AAPL.US' }); + expect(history.provenance.provider).toBe('massive'); + expect(history.provenance.marketTime).toBe(18000); + expect(profile.provenance.provider).toBe('massive'); + expect(fundamentals.provenance.provider).toBe('massive'); + }); }); diff --git a/packages/shared/src/capabilities/manifests/company-profile.ts b/packages/shared/src/capabilities/manifests/company-profile.ts index 277d420..ae5019b 100644 --- a/packages/shared/src/capabilities/manifests/company-profile.ts +++ b/packages/shared/src/capabilities/manifests/company-profile.ts @@ -4,7 +4,7 @@ import type { FinanceCapability } from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; -import { defaultCapabilityFetchers } from '../fetchers.ts'; +import { defaultCapabilityFetchers, resolveCapabilityFetch } from '../fetchers.ts'; export function createCompanyProfileCapability( fetchers: CapabilityFetchers = defaultCapabilityFetchers @@ -23,11 +23,19 @@ export function createCompanyProfileCapability( }), async execute(input, ctx) { const symbol = normalizeSymbol(input.symbol); - const info = await fetchers.getStaticInfo(symbol); + const resolved = await resolveCapabilityFetch( + fetchers, + 'company.profile', + { symbol }, + () => fetchers.getStaticInfo(symbol), + ctx?.now ?? Date.now, + undefined, + ctx?.signal + ); return { - data: info, - provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), stale: false }, - summary: formatStaticInfo(info), + data: resolved.data, + provenance: resolved.provenance, + summary: formatStaticInfo(resolved.data), }; }, }); diff --git a/packages/shared/src/capabilities/manifests/market-kline.ts b/packages/shared/src/capabilities/manifests/market-kline.ts index 280c50d..076de71 100644 --- a/packages/shared/src/capabilities/manifests/market-kline.ts +++ b/packages/shared/src/capabilities/manifests/market-kline.ts @@ -4,7 +4,7 @@ import type { FinanceCapability } from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; -import { defaultCapabilityFetchers } from '../fetchers.ts'; +import { defaultCapabilityFetchers, resolveCapabilityFetch } from '../fetchers.ts'; const KlinePeriod = Type.Union([ Type.Literal('1m'), @@ -42,15 +42,19 @@ export function createMarketKlineCapability( const symbol = normalizeSymbol(input.symbol); const period = input.period ?? '1d'; const limit = input.limit ?? 100; - const klines = await fetchers.getKline({ symbol, period, limit }); + const resolved = await resolveCapabilityFetch( + fetchers, + 'market.kline', + { symbol, period, limit }, + () => fetchers.getKline({ symbol, period, limit }), + ctx?.now ?? Date.now, + undefined, + ctx?.signal + ); + const klines = resolved.data; return { data: klines, - provenance: { - provider: 'longbridge', - fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: klines[klines.length - 1]?.timestamp, - stale: false, - }, + provenance: { ...resolved.provenance, marketTime: resolved.provenance.marketTime ?? klines[klines.length - 1]?.timestamp }, summary: formatKline(symbol, period, klines), }; }, diff --git a/packages/shared/src/capabilities/manifests/market-quote.ts b/packages/shared/src/capabilities/manifests/market-quote.ts index 67b2f87..a6223f2 100644 --- a/packages/shared/src/capabilities/manifests/market-quote.ts +++ b/packages/shared/src/capabilities/manifests/market-quote.ts @@ -4,7 +4,7 @@ import type { FinanceCapability } from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; -import { defaultCapabilityFetchers } from '../fetchers.ts'; +import { defaultCapabilityFetchers, resolveCapabilityFetch } from '../fetchers.ts'; export function createMarketQuoteCapability( fetchers: CapabilityFetchers = defaultCapabilityFetchers @@ -26,16 +26,19 @@ export function createMarketQuoteCapability( }), async execute(input, ctx) { const symbol = normalizeSymbol(input.symbol); - const quote = await fetchers.getQuote(symbol); + const resolved = await resolveCapabilityFetch( + fetchers, + 'market.quote', + { symbol }, + () => fetchers.getQuote(symbol), + ctx?.now ?? Date.now, + undefined, + ctx?.signal + ); return { - data: quote, - provenance: { - provider: 'longbridge', - fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: quote.timestamp, - stale: false, - }, - summary: formatQuote(quote), + data: resolved.data, + provenance: { ...resolved.provenance, marketTime: resolved.provenance.marketTime ?? resolved.data.timestamp }, + summary: formatQuote(resolved.data), }; }, }); diff --git a/packages/shared/src/capabilities/manifests/phase-two.ts b/packages/shared/src/capabilities/manifests/phase-two.ts index 7e3adb8..9b7dfa7 100644 --- a/packages/shared/src/capabilities/manifests/phase-two.ts +++ b/packages/shared/src/capabilities/manifests/phase-two.ts @@ -1,20 +1,23 @@ import { Type } from '@sinclair/typebox'; -import type { AccountAssets, CashFlowRecord, FinanceCapability, Holding } from '@finagent/core'; import type { + AccountAssets, CalendarEvent, CapitalFlow, + CashFlowRecord, Depth, DividendRecord, EpsForecast, + FinanceCapability, FinancialReport, + Holding, InstitutionRating, MarketTemperature, TradeTick, -} from '@finagent/longbridge-tools'; +} from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; -import { defaultCapabilityFetchers } from '../fetchers.ts'; +import { defaultCapabilityFetchers, resolveCapabilityFetch } from '../fetchers.ts'; // ── market.depth ──────────────────────────────────────────────────────────── @@ -181,14 +184,19 @@ export function createCompanyFinancialsCapability( }), async execute(input, ctx) { const symbol = normalizeSymbol(input.symbol); - const report = await fetchers.getFinancialReport( - symbol, - input.kind ?? 'ALL', - input.report + const resolved = await resolveCapabilityFetch( + fetchers, + 'company.financials', + { symbol, kind: input.kind ?? 'ALL', report: input.report }, + () => fetchers.getFinancialReport(symbol, input.kind ?? 'ALL', input.report), + ctx?.now ?? Date.now, + undefined, + ctx?.signal ); + const report = resolved.data; return { data: report, - provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), stale: false }, + provenance: resolved.provenance, summary: `Financial statements for ${symbol}: ${formatStatementNames(report.statements)}.`, }; }, diff --git a/packages/shared/src/providers/massive/adapter.ts b/packages/shared/src/providers/massive/adapter.ts index c1106de..33966fb 100644 --- a/packages/shared/src/providers/massive/adapter.ts +++ b/packages/shared/src/providers/massive/adapter.ts @@ -26,12 +26,12 @@ import { TtlCache } from './cache.ts' * the `apiKey` query param; legacy Polygon used a `Bearer` header. We try the * documented query-param form first and fall back to the Bearer header on a * 401, per the decision doc's risk note. - * - Freshness: the free "Stocks Basic" tier is end-of-day, so every result is - * marked `delayed: true`. A small TTL cache respects the 5 calls/min cap. + * - Freshness: Massive responses are treated as delayed, so every result is + * marked `delayed: true`. A small TTL cache helps respect provider limits. * - * LICENSING: the free tier is "Individual use" only; a commercial ship requires - * a Massive Business plan, and "Powered by Polygon.io" display attribution may - * be required. See `./index.ts` and report to the Connections UI owner. + * LICENSING: Massive usage and attribution requirements depend on the + * provider plan and applicable Market Data Terms of Service. Surface any + * required attribution in the Connections UI. */ const PROVIDER_ID = 'massive' @@ -224,8 +224,22 @@ export class MassiveFinancialDataProvider implements FinancialDataProvider { switch (capabilityId) { case 'market.quote': { const path = `/v2/snapshot/locale/us/markets/stocks/tickers/${encodeURIComponent(ticker)}` - const payload = await getJson(path, apiKey, signal, endpoint) - const quote = mapQuote(symbol, payload) + let quote: Quote + try { + const payload = await getJson(path, apiKey, signal, endpoint) + quote = mapQuote(symbol, payload) + } catch (error) { + // Snapshot quotes may be unavailable for some Massive accounts. The + // daily aggregate endpoint provides a canonical delayed quote for + // the gateway. + if ( + !(error instanceof ProviderHttpError) || + !['AUTH_EXPIRED', 'ACCESS_DENIED'].includes(error.providerError.code) + ) { + throw error + } + quote = await fetchAggregateQuote(ticker, symbol, apiKey, signal, endpoint) + } return { ok: true, data: quote, provenance: provenance(quote.timestamp) } } case 'market.kline': { @@ -270,6 +284,38 @@ async function getJson( } } +async function fetchAggregateQuote( + ticker: string, + symbol: string, + apiKey: string, + signal?: AbortSignal, + endpoint?: string +): Promise { + const { from, to } = klineRange(5) + const path = `/v2/aggs/ticker/${encodeURIComponent(ticker)}/range/1/day/${from}/${to}` + const payload = await getJson(buildAggsUrl(path, 5), apiKey, signal, endpoint) + const bars = mapKlines(symbol, payload) + const latest = bars[bars.length - 1] + if (!latest) { + throw new ProviderHttpError({ code: 'NOT_FOUND', message: 'No data found for this symbol.' }) + } + const previous = bars[bars.length - 2] + const prevClose = previous?.close ?? latest.open + const change = latest.close - prevClose + return { + symbol, + lastPrice: latest.close, + change, + changePercent: prevClose === 0 ? 0 : (change / prevClose) * 100, + volume: latest.volume, + timestamp: latest.timestamp, + high: latest.high, + low: latest.low, + open: latest.open, + prevClose, + } +} + /** * Try the documented `apiKey` query-param form first, then the legacy `Bearer` * header, across the canonical and legacy hosts. Returns the first non-401 @@ -469,9 +515,9 @@ function mapKlines(symbol: string, payload: unknown): Kline[] { } /** - * Maps ticker details into the core `StaticInfo` subset the free tier can - * actually fill: `symbol`, `name`, `exchange` (primary_exchange), and - * `currency` (currency_name). Left empty on purpose: + * Maps ticker details into the core `StaticInfo` subset Massive can fill: + * `symbol`, `name`, `exchange` (primary_exchange), and `currency` + * (currency_name). Left empty on purpose: * - `description`/`sic_description`/`homepage_url`/`market` have no field on * `StaticInfo` (core is not extended — see decision doc), so they are * dropped rather than smuggled onto the neutral shape. diff --git a/packages/shared/src/providers/massive/cache.ts b/packages/shared/src/providers/massive/cache.ts index 23cf6ff..1bde6b0 100644 --- a/packages/shared/src/providers/massive/cache.ts +++ b/packages/shared/src/providers/massive/cache.ts @@ -1,9 +1,9 @@ /** * Tiny in-memory TTL cache for provider responses. * - * Massive (Polygon.io) free tier is limited to 5 API calls/minute, so parsed - * responses are cached to keep the router smoke test and repeated UI renders - * under the cap. Entries are keyed by the caller (capability + input); this + * Massive (Polygon.io) rate limits depend on the API plan, so parsed responses + * are cached to keep router smoke tests and repeated UI renders within normal + * request budgets. Entries are keyed by the caller (capability + input); this * class only owns expiry and bounded eviction. */ diff --git a/packages/shared/src/providers/massive/index.ts b/packages/shared/src/providers/massive/index.ts index 592ac20..510b477 100644 --- a/packages/shared/src/providers/massive/index.ts +++ b/packages/shared/src/providers/massive/index.ts @@ -5,11 +5,9 @@ * `FinancialDataProvider` serving US quotes, daily klines, and ticker profiles. * No broker interface. * - * LICENSING / ATTRIBUTION: this adapter targets Massive's free "Stocks Basic" - * tier for development only (end-of-day data, 5 calls/min, "Individual use"). - * A commercial ship requires a Massive Business plan, and display attribution - * ("Powered by Polygon.io") may be required per the Market Data Terms of - * Service. Surface this copy in the Connections UI — do not silently omit it. + * LICENSING / ATTRIBUTION: Massive usage and display-attribution requirements + * depend on the provider plan and applicable Market Data Terms of Service. + * Surface any required attribution in the Connections UI. */ export { MassiveFinancialDataProvider } from './adapter.ts' export type { MassiveConfig } from './adapter.ts' diff --git a/packages/shared/src/providers/massive/massive.test.ts b/packages/shared/src/providers/massive/massive.test.ts index a8927b4..1ca63a7 100644 --- a/packages/shared/src/providers/massive/massive.test.ts +++ b/packages/shared/src/providers/massive/massive.test.ts @@ -119,7 +119,7 @@ describe('MassiveFinancialDataProvider', () => { expect(JSON.stringify(await provider.status())).not.toContain('canary-secret-123') }) - it('falls back to the previous close when the free tier omits OHLC', async () => { + it('falls back to the previous close when the snapshot omits OHLC', async () => { installFetch(() => jsonResponse({ ticker: { @@ -142,6 +142,38 @@ describe('MassiveFinancialDataProvider', () => { expect(result.data.change).toBeCloseTo(-0.02, 5) }) + it('falls back to daily aggregates when snapshot quotes are not entitled', async () => { + const calls = installFetch((url) => { + if (url.includes('/snapshot/')) return jsonResponse({ status: 'NOT_AUTHORIZED' }, 403) + return jsonResponse({ + ticker: 'AAPL', + results: [ + { c: 122, h: 123, l: 121, o: 121.5, t: 1699559040000, v: 1000 }, + { c: 120, h: 121, l: 119, o: 119.5, t: 1699472640000, v: 900 }, + ], + }) + }) + const provider = makeProvider('key') + const result = await provider.execute('market.quote', { symbol: 'AAPL.US' }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.data).toEqual({ + symbol: 'AAPL.US', + lastPrice: 122, + change: 2, + changePercent: (2 / 120) * 100, + volume: 1000, + timestamp: 1699559040, + high: 123, + low: 121, + open: 121.5, + prevClose: 120, + }) + expect(calls).toHaveLength(2) + expect(calls[1].url).toContain('/v2/aggs/ticker/AAPL/range/1/day/') + }) + it('maps daily aggregates to ascending Kline[]', async () => { installFetch(() => jsonResponse({ diff --git a/scripts/provider-e2e.test.ts b/scripts/provider-e2e.test.ts new file mode 100644 index 0000000..56f6f26 --- /dev/null +++ b/scripts/provider-e2e.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'bun:test' + +describe('provider E2E runner guard', () => { + function cleanEnv(overrides: Record): Record { + const env = { ...process.env, ...overrides } as Record + delete env.MASSIVE_API_KEY + delete env.POLYGON_API_KEY + return Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined)) as Record + } + + it('requires explicit opt-in before live requests', async () => { + const proc = Bun.spawn([process.execPath, 'run', 'scripts/provider-e2e.ts'], { + cwd: process.cwd(), + env: cleanEnv({ FINAGENT_PROVIDER_E2E: '0' }), + stdout: 'pipe', + stderr: 'pipe', + }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + expect(exitCode).toBe(0) + expect(stdout).toContain('未运行') + }) + + it('fails clearly when live mode has no Massive credential', async () => { + const proc = Bun.spawn([process.execPath, 'run', 'scripts/provider-e2e.ts'], { + cwd: process.cwd(), + env: cleanEnv({ FINAGENT_PROVIDER_E2E: '1' }), + stdout: 'pipe', + stderr: 'pipe', + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + expect(exitCode).toBe(2) + expect(stderr).toContain('缺少 MASSIVE_API_KEY') + }) +}) diff --git a/scripts/provider-e2e.ts b/scripts/provider-e2e.ts new file mode 100644 index 0000000..c314345 --- /dev/null +++ b/scripts/provider-e2e.ts @@ -0,0 +1,189 @@ +import { writeFile } from 'node:fs/promises' +import { LongbridgeFinancialDataProvider } from '../packages/shared/src/providers/longbridge/index.ts' +import { MassiveFinancialDataProvider } from '../packages/shared/src/providers/massive/index.ts' +import { ProviderRouter } from '../packages/shared/src/providers/router.ts' +import type { Kline, ProviderProvenance, ProviderResult, Quote, StaticInfo } from '../packages/core/src/index.ts' + +const ENABLED = process.env.FINAGENT_PROVIDER_E2E === '1' +const SYMBOL = (process.env.FINAGENT_PROVIDER_E2E_SYMBOL ?? 'AAPL.US').trim().toUpperCase() +const OUTPUT = process.env.FINAGENT_PROVIDER_E2E_OUTPUT?.trim() +const CAPABILITIES = ['market.quote', 'market.kline', 'company.profile'] as const + +type Capability = (typeof CAPABILITIES)[number] +type Snapshot = { + providerId: string + providerName: string + status: string + capabilities: Record + provenance: Record + latencyMs: Record +} + +function log(message: string): void { + process.stdout.write(`${message}\n`) +} + +function fail(message: string): never { + process.stderr.write(`Provider E2E failed: ${message}\n`) + process.exit(2) +} + +function assertProvenance( + result: ProviderResult, + providerId: string, + capability: Capability +): asserts result is Extract, { ok: true }> { + if (!result.ok) fail(`${providerId} ${capability}: ${result.error.code} (${result.error.message})`) + const provenance = result.provenance + if ( + provenance.providerId !== providerId || + typeof provenance.providerName !== 'string' || + !Number.isFinite(provenance.fetchedAt) || + typeof provenance.stale !== 'boolean' + ) { + fail(`${providerId} ${capability}: invalid provider provenance`) + } +} + +function assertCanonical(capability: Capability, data: unknown): void { + if (capability === 'market.quote') { + const quote = data as Quote + const fields: Array = [ + 'symbol', + 'lastPrice', + 'change', + 'changePercent', + 'volume', + 'timestamp', + 'high', + 'low', + 'open', + 'prevClose', + ] + if ( + typeof quote !== 'object' || + quote === null || + fields.some((field) => field === 'symbol' ? typeof quote[field] !== 'string' : !Number.isFinite(quote[field])) + ) { + fail(`${capability}: response is not canonical Quote`) + } + return + } + if (capability === 'market.kline') { + const klines = data as Kline[] + if (!Array.isArray(klines) || klines.length === 0) fail(`${capability}: provider returned no bars`) + for (const bar of klines) { + if ( + typeof bar.symbol !== 'string' || + !Number.isFinite(bar.timestamp) || + !Number.isFinite(bar.open) || + !Number.isFinite(bar.high) || + !Number.isFinite(bar.low) || + !Number.isFinite(bar.close) || + !Number.isFinite(bar.volume) + ) { + fail(`${capability}: response contains a non-canonical bar`) + } + } + return + } + const profile = data as StaticInfo + if (typeof profile !== 'object' || profile === null || typeof profile.symbol !== 'string' || typeof profile.name !== 'string') { + fail(`${capability}: response is not canonical StaticInfo`) + } +} + +function comparable(capability: Capability, data: unknown): unknown { + if (capability === 'market.quote') { + const quote = data as Quote + return { + symbol: quote.symbol, + lastPrice: quote.lastPrice, + change: quote.change, + changePercent: quote.changePercent, + volume: quote.volume, + timestamp: quote.timestamp, + high: quote.high, + low: quote.low, + open: quote.open, + prevClose: quote.prevClose, + } + } + if (capability === 'market.kline') { + const klines = data as Kline[] + return { count: klines.length, first: klines[0], last: klines[klines.length - 1] } + } + const profile = data as StaticInfo + return { symbol: profile.symbol, name: profile.name, exchange: profile.exchange, currency: profile.currency } +} + +async function executeProvider( + provider: LongbridgeFinancialDataProvider | MassiveFinancialDataProvider +): Promise { + const health = await provider.status() + if (health.status !== 'connected' && health.status !== 'permission-limited') { + fail(`${provider.id}: provider status is ${health.status}${health.message ? ` (${health.message})` : ''}`) + } + const router = new ProviderRouter() + router.register(provider) + router.setRouting({ primary: provider.id }) + const inputs: Record = { + 'market.quote': { symbol: SYMBOL }, + 'market.kline': { symbol: SYMBOL, period: '1d', limit: 5 }, + 'company.profile': { symbol: SYMBOL }, + } + const capabilities = {} as Snapshot['capabilities'] + const provenance = {} as Snapshot['provenance'] + const latencyMs = {} as Snapshot['latencyMs'] + for (const capability of CAPABILITIES) { + const started = performance.now() + const result = await router.execute(capability, inputs[capability]) + latencyMs[capability] = Math.round(performance.now() - started) + assertProvenance(result, provider.id, capability) + assertCanonical(capability, result.data) + capabilities[capability] = comparable(capability, result.data) + provenance[capability] = result.provenance + } + return { providerId: provider.id, providerName: provider.name, status: health.status, capabilities, provenance, latencyMs } +} + +function compare(left: Snapshot, right: Snapshot): Record { + return Object.fromEntries(CAPABILITIES.map((capability) => [capability, { + left: left.capabilities[capability], + right: right.capabilities[capability], + }])) as Record +} + +async function main(): Promise { + if (!ENABLED) { + log('Provider E2E 未运行。设置 FINAGENT_PROVIDER_E2E=1 后才会访问真实 Provider。') + return + } + if (!SYMBOL) fail('FINAGENT_PROVIDER_E2E_SYMBOL 不能为空') + const apiKey = (process.env.MASSIVE_API_KEY ?? process.env.POLYGON_API_KEY)?.trim() + if (!apiKey) fail('缺少 MASSIVE_API_KEY(或兼容的 POLYGON_API_KEY)') + + const longbridge = new LongbridgeFinancialDataProvider() + const massive = new MassiveFinancialDataProvider({ getApiKey: async () => apiKey }) + const [longbridgeSnapshot, massiveSnapshot] = await Promise.all([ + executeProvider(longbridge), + executeProvider(massive), + ]) + const report = { + generatedAt: new Date().toISOString(), + symbol: SYMBOL, + capabilities: CAPABILITIES, + providers: [longbridgeSnapshot, massiveSnapshot], + comparison: compare(longbridgeSnapshot, massiveSnapshot), + } + const serialized = JSON.stringify(report, null, 2) + if (OUTPUT) { + await writeFile(OUTPUT, `${serialized}\n`, 'utf8') + log(`Provider E2E 通过;脱敏报告已写入 ${OUTPUT}`) + } else { + log(serialized) + log('Provider E2E 通过;输出仅包含规范化字段和来源元数据。') + } +} + +main().catch((error) => fail(error instanceof Error ? error.message : 'unknown failure'))