From 9f7dc32425a277ef39a19d2a7e85cce0178bbfab Mon Sep 17 00:00:00 2001 From: "Wen.Vale" <2336420857@qq.com> Date: Wed, 16 Sep 2026 11:24:37 +0800 Subject: [PATCH] fix: preserve routed quote provenance --- packages/core/src/capability.ts | 5 + packages/core/src/financial-evidence.ts | 2 + packages/shared/src/agent/demo-market-data.ts | 33 ++- packages/shared/src/capabilities/define.ts | 77 ++++- packages/shared/src/capabilities/fetchers.ts | 15 + packages/shared/src/capabilities/index.ts | 6 +- .../capabilities/manifests/market-quote.ts | 16 +- .../shared/src/evidence/financial-evidence.ts | 4 +- .../quote-provenance-acceptance.test.ts | 265 ++++++++++++++++++ .../shared/src/providers/router-fetchers.ts | 38 ++- 10 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 packages/shared/src/kernel/quote-provenance-acceptance.test.ts diff --git a/packages/core/src/capability.ts b/packages/core/src/capability.ts index e78a06b..492d532 100644 --- a/packages/core/src/capability.ts +++ b/packages/core/src/capability.ts @@ -1,5 +1,6 @@ import type { TSchema } from '@sinclair/typebox'; import type { FinancialEvidenceMetadata } from './financial-evidence.ts'; +import type { ProviderFailoverStep } from './provider.ts'; /** * Finance Capability domain — the single source of truth for every piece of @@ -34,6 +35,8 @@ export interface CapabilityProvenance { provider: string; /** Provider id of the ACTUAL answering adapter (fallback-aware). */ providerId?: string; + /** Human-readable name of the actual answering adapter, when available. */ + providerName?: string; /** Canonical instrument id when the capability ran against a resolved listing. */ instrumentId?: string; /** Epoch ms at which the data was fetched. */ @@ -44,6 +47,8 @@ export interface CapabilityProvenance { delayed?: boolean; /** True when the data may be outdated relative to the market. */ stale: boolean; + /** Failed provider attempts before this result was produced. */ + failoverTrail?: ProviderFailoverStep[]; } export interface CapabilityResult { diff --git a/packages/core/src/financial-evidence.ts b/packages/core/src/financial-evidence.ts index 7c3713a..8d09cc8 100644 --- a/packages/core/src/financial-evidence.ts +++ b/packages/core/src/financial-evidence.ts @@ -54,6 +54,8 @@ export interface FinancialEvidenceEnvelope { retrievedAt: number; asOf?: number; stale: boolean; + /** True when the provider reported a delayed value rather than live data. */ + delayed?: boolean; cacheHit: boolean; fallback?: { from: string; to: string; reason?: string }; reconciliation?: { providers: string[]; method: string }; diff --git a/packages/shared/src/agent/demo-market-data.ts b/packages/shared/src/agent/demo-market-data.ts index 53eb75d..35bbcd9 100644 --- a/packages/shared/src/agent/demo-market-data.ts +++ b/packages/shared/src/agent/demo-market-data.ts @@ -1,5 +1,8 @@ import type { Holding, Kline, PortfolioAccount, PortfolioSnapshot, Quote } from '@finagent/core'; import type { MarketDataFetchers } from './market-data-service.ts'; +import type { CapabilityFetchResult } from '../capabilities/fetchers.ts'; + +type QuoteResultFetcher = (symbol: string, signal?: AbortSignal) => Promise>; /** * Built-in sample data for the offline demo path (#31 E2E, README offline @@ -57,6 +60,20 @@ function demoQuoteFor(symbol: string): Quote { }; } +function demoQuoteResult(symbol: string): CapabilityFetchResult { + const data = demoQuoteFor(symbol); + return { + data, + provenance: { + providerId: 'demo', + providerName: 'Built-in demo data', + fetchedAt: Date.now(), + marketTime: data.timestamp * 1000, + stale: false, + }, + }; +} + /** Deterministic daily close series ending at the demo quote's last price. */ function demoKlinesFor(symbol: string, limit?: number): Kline[] { const count = Math.max(2, Math.min(limit ?? 30, 400)); @@ -122,7 +139,10 @@ function demoPortfolioSnapshot(): PortfolioSnapshot { * demo dataset answers instead. Only the surfaces typed blocks demo are * wrapped; everything else keeps failing honestly. */ -export function withDemoDataFallback>(fetchers: F): F { +export function withDemoDataFallback & { getQuoteResult?: QuoteResultFetcher }>( + fetchers: F +): F { + const quoteResult = fetchers.getQuoteResult; return { ...fetchers, getQuote: async (symbol) => { @@ -136,6 +156,17 @@ export function withDemoDataFallback>(fetc } return demoQuoteFor(symbol); }, + ...(quoteResult + ? { + getQuoteResult: async (symbol: string, signal?: AbortSignal) => { + try { + return await quoteResult(symbol, signal); + } catch { + return demoQuoteResult(symbol); + } + }, + } + : {}), getKline: async (options) => { const real = fetchers.getKline; if (real) { diff --git a/packages/shared/src/capabilities/define.ts b/packages/shared/src/capabilities/define.ts index c08a729..a555288 100644 --- a/packages/shared/src/capabilities/define.ts +++ b/packages/shared/src/capabilities/define.ts @@ -8,6 +8,7 @@ import { type CapabilityResult, type CapabilityRiskLevel, type FinanceCapability, + type ProviderProvenance, } from '@finagent/core'; import { validateInput } from './validate.ts'; @@ -21,7 +22,11 @@ export interface CapabilityDefinition { auth: CapabilityAuth; toolName: string; inputSchema: TSchema; - execute(input: TInput, ctx?: CapabilityExecutionContext): Promise>; + execute( + input: TInput, + ctx?: CapabilityExecutionContext, + reportProvider?: (provenance: ProviderProvenance) => void + ): Promise>; } /** @@ -45,7 +50,13 @@ export function defineCapability( inputSchema: def.inputSchema, async execute(input, ctx) { const validated = validateInput(def.inputSchema, input); - return attachInstrumentIdToProvenance(await def.execute(validated, ctx)); + let providerProvenance: ProviderProvenance | undefined; + const result = await def.execute(validated, ctx, (provenance) => { + providerProvenance = cloneProviderProvenance(provenance); + }); + return attachInstrumentIdToProvenance( + attachProviderProvenance(result, providerProvenance) + ); }, }; } @@ -59,3 +70,65 @@ function attachInstrumentIdToProvenance(result: CapabilityResult): Capabil provenance: { ...result.provenance, instrumentId }, }; } + +function attachProviderProvenance( + result: CapabilityResult, + providerProvenance?: ProviderProvenance +): CapabilityResult { + if (!providerProvenance) return result; + + const failoverTrail = providerProvenance.failoverTrail?.map((step) => ({ ...step })); + const routingLineage = failoverTrail?.map((step) => ({ + kind: 'fallback' as const, + description: `Provider routing skipped ${step.providerId} after ${step.attempts} attempt${step.attempts === 1 ? '' : 's'} (${step.code}; ${step.kind}).`, + })) ?? []; + const firstFailure = failoverTrail?.[0]; + const fallback = !providerProvenance.stale + && firstFailure + && firstFailure.providerId !== providerProvenance.providerId + ? { + from: firstFailure.providerId, + to: providerProvenance.providerId, + reason: failoverTrail.map((step) => step.code).join(' → '), + } + : undefined; + const evidence = routingLineage.length > 0 || fallback + ? { + ...result.evidence, + ...(result.evidence?.fallback || !fallback ? {} : { fallback }), + ...(routingLineage.length > 0 + ? { lineage: [...routingLineage, ...(result.evidence?.lineage ?? [])] } + : {}), + } + : result.evidence; + + return { + ...result, + provenance: { + ...result.provenance, + provider: providerProvenance.providerId, + providerId: providerProvenance.providerId, + providerName: providerProvenance.providerName, + ...(providerProvenance.instrumentId ? { instrumentId: providerProvenance.instrumentId } : {}), + fetchedAt: providerProvenance.fetchedAt, + ...(providerProvenance.marketTime !== undefined + ? { marketTime: providerProvenance.marketTime } + : {}), + ...(providerProvenance.delayed !== undefined + ? { delayed: providerProvenance.delayed } + : {}), + stale: providerProvenance.stale, + ...(failoverTrail ? { failoverTrail } : {}), + }, + ...(evidence ? { evidence } : {}), + }; +} + +function cloneProviderProvenance(provenance: ProviderProvenance): ProviderProvenance { + return { + ...provenance, + ...(provenance.failoverTrail + ? { failoverTrail: provenance.failoverTrail.map((step) => ({ ...step })) } + : {}), + }; +} diff --git a/packages/shared/src/capabilities/fetchers.ts b/packages/shared/src/capabilities/fetchers.ts index d4eee25..ca6c097 100644 --- a/packages/shared/src/capabilities/fetchers.ts +++ b/packages/shared/src/capabilities/fetchers.ts @@ -16,6 +16,7 @@ import type { MarketTemperature, NewsItem, PortfolioSnapshot, + ProviderProvenance, Quote, StaticInfo, TradeTick, @@ -46,14 +47,28 @@ import { type GetKlineOptions, } from '@finagent/longbridge-tools'; +/** + * Opt-in enriched fetch result for capability paths that need the router's + * actual-provider provenance in addition to the normalized payload. + */ +export interface CapabilityFetchResult { + data: T; + provenance: ProviderProvenance; +} + /** * Provider fetchers consumed by the capability manifests. Production uses the * raw Longbridge fetchers; tests and the local backend can substitute a * `MarketDataService` (which satisfies this shape structurally) to inject * cached or stubbed data. + * + * Raw methods deliberately remain payload-only for renderer and legacy + * consumers. A capability can opt into a matching `*Result` method when it + * needs source provenance for a traceable product flow. */ export interface CapabilityFetchers { getQuote: (symbol: string) => Promise; + getQuoteResult?: (symbol: string, signal?: AbortSignal) => Promise>; getKline: (options: GetKlineOptions) => Promise; getIntraday: (symbol: string) => Promise; getMarketStatus: () => Promise; diff --git a/packages/shared/src/capabilities/index.ts b/packages/shared/src/capabilities/index.ts index eb3261f..eb8ae65 100644 --- a/packages/shared/src/capabilities/index.ts +++ b/packages/shared/src/capabilities/index.ts @@ -37,7 +37,11 @@ export { export { computeSkillReadiness } from './readiness.ts'; export { createCapabilityTools, type CapabilityTool } from './pi-tools.ts'; export { createCapabilityError, validateInput, normalizeSymbol } from './validate.ts'; -export { defaultCapabilityFetchers, type CapabilityFetchers } from './fetchers.ts'; +export { + defaultCapabilityFetchers, + type CapabilityFetchers, + type CapabilityFetchResult, +} from './fetchers.ts'; export { createPhaseOneCapabilities, phaseOneCapabilities } from './manifests/index.ts'; /** All twenty capabilities built from the default (real) Longbridge fetchers. */ diff --git a/packages/shared/src/capabilities/manifests/market-quote.ts b/packages/shared/src/capabilities/manifests/market-quote.ts index 67b2f87..092b9fc 100644 --- a/packages/shared/src/capabilities/manifests/market-quote.ts +++ b/packages/shared/src/capabilities/manifests/market-quote.ts @@ -1,6 +1,8 @@ import { Type } from '@sinclair/typebox'; -import type { Quote } from '@finagent/core'; -import type { FinanceCapability } from '@finagent/core'; +import type { + FinanceCapability, + Quote, +} from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; @@ -24,15 +26,19 @@ export function createMarketQuoteCapability( examples: ['AAPL.US', '0700.HK'], }), }), - async execute(input, ctx) { + async execute(input, ctx, reportProvider) { const symbol = normalizeSymbol(input.symbol); - const quote = await fetchers.getQuote(symbol); + const fetched = fetchers.getQuoteResult + ? await fetchers.getQuoteResult(symbol, ctx?.signal) + : undefined; + const quote = fetched?.data ?? await fetchers.getQuote(symbol); + if (fetched) reportProvider?.(fetched.provenance); return { data: quote, provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: quote.timestamp, + marketTime: quote.timestamp * 1000, stale: false, }, summary: formatQuote(quote), diff --git a/packages/shared/src/evidence/financial-evidence.ts b/packages/shared/src/evidence/financial-evidence.ts index 4829ee2..12abd21 100644 --- a/packages/shared/src/evidence/financial-evidence.ts +++ b/packages/shared/src/evidence/financial-evidence.ts @@ -10,6 +10,7 @@ import type { import { FINANCIAL_EVIDENCE_SCHEMA_VERSION, FINANCIAL_NORMALIZATION_VERSION, + readInstrumentId, } from '@finagent/core'; const SECRET_KEY = /(authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|password|cookie|secret|credential)/i; @@ -51,7 +52,7 @@ export function buildFinancialEvidence(input: BuildFinancialEvidenceInput): Fina const provider = stringValue(provenance.providerId) ?? stringValue(provenance.provider) ?? 'unknown'; const retrievedAt = numberValue(provenance.fetchedAt) ?? toolCall.completedAt ?? toolCall.startedAt; const asOf = numberValue(provenance.marketTime) ?? inferAsOf(result.data); - const instrumentId = canonicalInstrument(toolCall.args.symbol ?? inferSymbol(result.data)); + const instrumentId = readInstrumentId(provenance) ?? canonicalInstrument(toolCall.args.symbol ?? inferSymbol(result.data)); const values = collectValues(result.data, result.evidence); const snapshot = redact(result.data); const resultHash = hashJson(snapshot); @@ -82,6 +83,7 @@ export function buildFinancialEvidence(input: BuildFinancialEvidenceInput): Fina retrievedAt, ...(asOf !== undefined ? { asOf } : {}), stale: provenance.stale === true, + ...(provenance.delayed === true ? { delayed: true } : {}), cacheHit: result.evidence?.cacheHit === true, ...(result.evidence?.fallback ? { fallback: result.evidence.fallback } : {}), ...(result.evidence?.reconciliation ? { reconciliation: result.evidence.reconciliation } : {}), diff --git a/packages/shared/src/kernel/quote-provenance-acceptance.test.ts b/packages/shared/src/kernel/quote-provenance-acceptance.test.ts new file mode 100644 index 0000000..b675afd --- /dev/null +++ b/packages/shared/src/kernel/quote-provenance-acceptance.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { + AgentEvent, + AgentEventPayload, + AgentRunInput, + AgentRuntime, + ApiResult, + CapabilityId, + FinancialDataProvider, + ProviderHealth, + ProviderResult, + RuntimeSession, + ToolDefinition, +} from '@finagent/core'; +import { createCapabilityTools } from '../capabilities/pi-tools.ts'; +import { createMarketQuoteCapability } from '../capabilities/manifests/market-quote.ts'; +import { JsonFileStore } from '../storage/json-file-store.ts'; +import { MessageRepository } from '../storage/message-repository.ts'; +import { RunRepository } from '../storage/run-repository.ts'; +import { SessionRepository } from '../storage/session-repository.ts'; +import { createRouterFetchers } from '../providers/router-fetchers.ts'; +import { ProviderRouter } from '../providers/router.ts'; +import { RunManager } from './run-manager.ts'; +import { SessionManager } from './session-manager.ts'; + +const FETCHED_AT = 1_710_000_000_123; +const MARKET_TIME = 1_710_000_000_456; +const quote = { + symbol: 'AAPL.US', + instrumentId: 'XNAS:AAPL', + lastPrice: 200, + change: 3, + changePercent: 1.5, + volume: 1234, + // Quote timestamps are epoch seconds; ProviderProvenance.marketTime below + // deliberately uses the canonical epoch-millisecond contract. + timestamp: 1_710_000_000, + high: 203, + low: 198, + open: 199, + prevClose: 197, +}; + +let dir = ''; +let clock = 10_000; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'finagent-quote-provenance-')); + clock = 10_000; +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +type Handler = ( + capabilityId: CapabilityId, + input: unknown, + signal?: AbortSignal +) => Promise>; + +class FakeFinancialDataProvider implements FinancialDataProvider { + kind = 'financial-data' as const; + + constructor( + readonly id: string, + readonly name: string, + private readonly handler: Handler + ) {} + + async status(): Promise { + return { status: 'connected', lastCheck: clock }; + } + + capabilities(): CapabilityId[] { + return ['market.quote']; + } + + markets() { + return [{ id: 'US', name: 'United States' }]; + } + + async execute( + capabilityId: CapabilityId, + input: unknown, + signal?: AbortSignal + ): Promise> { + return (await this.handler(capabilityId, input, signal)) as ProviderResult; + } +} + +class ScriptedRuntime implements AgentRuntime { + constructor(private readonly script: (input: AgentRunInput) => AsyncIterable) {} + + async getTools(): Promise> { + return { ok: true, data: [] }; + } + + async ensureSession(_session: { id: string; title?: string; sessionPath?: string }): Promise { + return { sessionId: _session.id, status: 'active' }; + } + + async *run(input: AgentRunInput): AsyncIterable { + yield* this.script(input); + } + + async cancel(_input: { sessionId: string; runId: string }): Promise {} + + async dispose(): Promise {} +} + +function event( + sessionId: string, + runId: string, + type: AgentEvent['type'], + payload?: AgentEventPayload, + sequence = 1 +): AgentEvent { + return { + id: 'evt-' + type + '-' + sequence, + sessionId, + runId, + type, + timestamp: clock, + sequence, + ...(payload === undefined ? {} : { payload }), + } as unknown as AgentEvent; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error('Timed out waiting for run to settle.'); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + +function makeQuoteTool() { + const router = new ProviderRouter(); + router.register(new FakeFinancialDataProvider('longbridge', 'Longbridge', async () => ({ + ok: false, + error: { code: 'TIMEOUT', message: 'primary unavailable' }, + }))); + router.register(new FakeFinancialDataProvider('massive', 'Massive', async () => ({ + ok: true, + data: quote, + provenance: { + providerId: 'massive', + providerName: 'Massive', + instrumentId: 'XNAS:AAPL', + fetchedAt: FETCHED_AT, + marketTime: MARKET_TIME, + delayed: true, + stale: false, + }, + }))); + router.setRouting({ primary: 'longbridge', fallback: 'massive' }); + + const capability = createMarketQuoteCapability(createRouterFetchers(router)); + return createCapabilityTools([capability])[0]; +} + +describe('quote provenance acceptance slice (#101)', () => { + it('keeps the actual fallback source through the agent tool and evidence reload', async () => { + const tool = makeQuoteTool(); + const toolResult = await tool.execute('quote-1', { symbol: 'aapl.us' }, new AbortController().signal); + + expect(toolResult.details).toEqual(quote); + expect(toolResult.provenance).toMatchObject({ + provider: 'massive', + providerId: 'massive', + providerName: 'Massive', + instrumentId: 'XNAS:AAPL', + fetchedAt: FETCHED_AT, + marketTime: MARKET_TIME, + delayed: true, + stale: false, + failoverTrail: [ + expect.objectContaining({ + providerId: 'longbridge', + code: 'TIMEOUT', + kind: 'timeout', + attempts: 1, + }), + ], + }); + expect(toolResult.evidence).toMatchObject({ + fallback: { from: 'longbridge', to: 'massive', reason: 'TIMEOUT' }, + }); + + const structuredResult = { + data: toolResult.details, + provenance: toolResult.provenance, + evidence: toolResult.evidence, + }; + const runtime = new ScriptedRuntime(async function* (input) { + yield event(input.sessionId, input.runId, 'tool_started', { + toolCall: { + id: 'quote-1', + toolName: 'get_quote', + args: { symbol: 'AAPL.US' }, + startedAt: clock, + status: 'running', + }, + }); + yield event(input.sessionId, input.runId, 'tool_completed', { + toolCall: { + id: 'quote-1', + toolName: 'get_quote', + args: { symbol: 'AAPL.US' }, + startedAt: clock, + completedAt: clock, + status: 'success', + result: structuredResult, + }, + }); + yield event(input.sessionId, input.runId, 'message_completed', { + answer: 'Apple is $200.00; source: Massive.', + }); + yield event(input.sessionId, input.runId, 'run_completed', { + answer: 'Apple is $200.00; source: Massive.', + toolCalls: [], + }); + }); + const store = new JsonFileStore(dir); + const sessions = new SessionManager({ + sessions: new SessionRepository(store), + messages: new MessageRepository(store), + runs: new RunRepository(store), + piSessionDir: join(dir, 'pi-sessions'), + now: () => clock, + }); + const runs = new RunManager({ + sessions, + runs: new RunRepository(store), + runtime, + now: () => clock, + }); + const session = await sessions.createSession('Quote provenance'); + + await runs.startRun(session.id, "What is Apple's current price and where did it come from?"); + await waitFor(() => !runs.isRunning()); + + const messages = await sessions.listMessages(session.id); + const evidence = messages[1].financialEvidence?.[0]; + expect(evidence).toMatchObject({ + provider: 'massive', + instrumentId: 'XNAS:AAPL', + retrievedAt: FETCHED_AT, + asOf: MARKET_TIME, + delayed: true, + stale: false, + fallback: { from: 'longbridge', to: 'massive', reason: 'TIMEOUT' }, + }); + expect(evidence?.lineage.some((step) => step.kind === 'fallback')).toBe(true); + + // A fresh repository instance proves that citation-facing provenance is + // persisted as part of the assistant message, not recomputed on reload. + const reloaded = await new MessageRepository(store).list(session.id); + expect(reloaded[1].financialEvidence).toEqual(messages[1].financialEvidence); + }); +}); diff --git a/packages/shared/src/providers/router-fetchers.ts b/packages/shared/src/providers/router-fetchers.ts index 9481a1e..5e5b81e 100644 --- a/packages/shared/src/providers/router-fetchers.ts +++ b/packages/shared/src/providers/router-fetchers.ts @@ -13,6 +13,7 @@ import { type PortfolioSnapshot, type ProviderError, type ProviderResult, + type ProviderProvenance, type Quote, type StaticInfo, } from '@finagent/core'; @@ -33,6 +34,7 @@ import type { GetKlineOptions, } from '@finagent/longbridge-tools'; import { attachResolvedInstrument, type InstrumentQueryResolver } from './instrument.ts'; +import type { CapabilityFetchResult } from '../capabilities/fetchers.ts'; /** * Normalized failure thrown by the router-backed fetchers. Carries the stable @@ -57,11 +59,13 @@ export class ProviderFetchError extends Error { /** * Fetcher surface produced by `createRouterFetchers`. Structurally the * post-migration `CapabilityFetchers` contract (portfolio methods carry the - * neutral `@finagent/core` account shapes); the router erases routing from - * every consumer. + * neutral `@finagent/core` account shapes). Raw methods erase routing for + * data-only consumers; `getQuoteResult` is the explicit provenance-aware + * companion used by the traceable quote capability. */ export interface RouterCapabilityFetchers { getQuote: (symbol: string) => Promise; + getQuoteResult: (symbol: string, signal?: AbortSignal) => Promise>; getKline: (options: GetKlineOptions) => Promise; getIntraday: (symbol: string) => Promise; getMarketStatus: () => Promise; @@ -87,18 +91,36 @@ export interface RouterCapabilityFetchers { getCashFlow: (options?: GetCashFlowOptions) => Promise; } -async function fetch( +async function fetchResult( router: FinancialProviderRouter, capabilityId: string, - input: unknown -): Promise { - const result: ProviderResult = await router.execute(capabilityId, input); + input: unknown, + signal?: AbortSignal +): Promise> { + const result: ProviderResult = await router.execute(capabilityId, input, signal); if (result.ok) { - return result.data; + return { data: result.data, provenance: cloneProvenance(result.provenance) }; } throw new ProviderFetchError(result.error); } +async function fetch( + router: FinancialProviderRouter, + capabilityId: string, + input: unknown +): Promise { + return (await fetchResult(router, capabilityId, input)).data; +} + +function cloneProvenance(provenance: ProviderProvenance): ProviderProvenance { + return { + ...provenance, + ...(provenance.failoverTrail + ? { failoverTrail: provenance.failoverTrail.map((step) => ({ ...step })) } + : {}), + }; +} + export interface RouterFetcherOptions { /** Resolve user/ticker input to a canonical instrument before adapters run. */ resolve?: InstrumentQueryResolver; @@ -139,6 +161,8 @@ export function createRouterFetchers( const resolve = options.resolve; return { getQuote: (symbol) => fetch(router, 'market.quote', bindSymbolInput(symbol, {}, resolve)), + getQuoteResult: (symbol, signal) => + fetchResult(router, 'market.quote', bindSymbolInput(symbol, {}, resolve), signal), getKline: (options) => fetch(router, 'market.kline', bindSymbolInput(options.symbol, { ...options }, resolve)), getIntraday: (symbol) => fetch(router, 'market.intraday', bindSymbolInput(symbol, {}, resolve)),