Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,3 +600,5 @@ export * from './trace-projection.ts';
export * from './instrument.ts';
export * from './instrument-catalog.ts';
export * from './financial-evidence.ts';
export * from './reconciliation.ts';
export * from './reconciliation-provider.ts';
6 changes: 6 additions & 0 deletions packages/core/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ export interface FinancialProviderRouter {
input: unknown,
signal?: AbortSignal
): Promise<ProviderResult<T>>;
/** Collect all provider results for reconciliation and audit flows. */
executeAll<T>(
capabilityId: CapabilityId,
input: unknown,
signal?: AbortSignal
): Promise<ProviderResult<T>[]>;
}

/** The global router instance id — never re-create per request. */
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/reconciliation-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ProviderResult } from './provider.ts';
import type { FinancialFactCandidate, ReconciliationPolicy, ReconciliationResult } from './reconciliation.ts';
import { reconcileFinancialFacts } from './reconciliation.ts';

export interface ReconciledProviderResults<T> {
results: ProviderResult<T>[];
reconciliation: ReconciliationResult;
}

/** Reconcile provider results while preserving failed and successful evidence. */
export function reconcileProviderResults<T>(
results: ProviderResult<T>[],
toCandidate: (data: T, providerId: string, providerName: string) => FinancialFactCandidate,
policy: ReconciliationPolicy
): ReconciledProviderResults<T> {
const candidates = results
.filter((result): result is Extract<ProviderResult<T>, { ok: true }> => result.ok)
.map((result) => toCandidate(result.data, result.provenance.providerId, result.provenance.providerName));
return { results: [...results], reconciliation: reconcileFinancialFacts(candidates, policy) };
}
44 changes: 44 additions & 0 deletions packages/core/src/reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test';
import { reconcileFinancialFacts } from './reconciliation';

const base = { period: '2025Q4', currency: 'USD', unit: 'USD', adjustment: 'reported' };

describe('reconcileFinancialFacts', () => {
it('detects material conflict without averaging', () => {
const r = reconcileFinancialFacts(
[{ ...base, provider: 'a', value: 100 }, { ...base, provider: 'b', value: 120 }],
{ version: '1', relativeTolerance: 0.03 }
);
expect(r.state).toBe('material-conflict');
expect(r.selected?.value).toBe(100);
});

it('rejects mismatched semantics', () => {
expect(
reconcileFinancialFacts(
[{ ...base, provider: 'a', value: 1 }, { ...base, provider: 'b', value: 1, period: '2024Q4' }],
{ version: '1' }
).state
).toBe('incomparable');
});

it('accepts tolerance', () => {
expect(
reconcileFinancialFacts(
[{ ...base, provider: 'a', value: 100 }, { ...base, provider: 'b', value: 101 }],
{ version: '1', absoluteTolerance: 2 }
).state
).toBe('within-tolerance');
});

it('uses explicit provider priority without averaging', () => {
const r = reconcileFinancialFacts(
[{ ...base, provider: 'fallback', value: 100 }, { ...base, provider: 'primary', value: 130 }],
{ version: '1', providerPriority: ['primary', 'fallback'], relativeTolerance: 0.03 }
);
expect(r.state).toBe('material-conflict');
expect(r.selected?.provider).toBe('primary');
expect(r.selected?.value).toBe(130);
expect(r.candidates.map((candidate) => candidate.value)).toEqual([100, 130]);
});
});
17 changes: 17 additions & 0 deletions packages/core/src/reconciliation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Deterministic, provider-neutral reconciliation of financial facts. */
export type ReconciliationState = 'agreement'|'within-tolerance'|'material-conflict'|'incomparable'|'insufficient-sources';
export interface FinancialFactCandidate { provider:string; value:number; asOf?:number; period?:string; currency?:string; unit?:string; adjustment?:string; }
export interface ReconciliationPolicy { version:string; absoluteTolerance?:number; relativeTolerance?:number; providerPriority?:string[]; }
export interface ReconciliationResult { state:ReconciliationState; candidates:FinancialFactCandidate[]; selected?:FinancialFactCandidate; discrepancy?:number; reason:string; policyVersion:string; }
export function reconcileFinancialFacts(candidates:FinancialFactCandidate[], policy:ReconciliationPolicy):ReconciliationResult {
const base={candidates:[...candidates],policyVersion:policy.version};
if(candidates.length<2)return {...base,state:'insufficient-sources',reason:'At least two provider candidates are required.'};
const first=candidates[0];
const comparable=candidates.every(c=>c.period===first.period&&c.currency===first.currency&&c.unit===first.unit&&c.adjustment===first.adjustment);
if(!comparable)return {...base,state:'incomparable',reason:'Candidates use different period, currency, unit, or adjustment semantics.'};
const values=candidates.map(c=>c.value), min=Math.min(...values), max=Math.max(...values), discrepancy=max-min, scale=Math.max(...values.map(v=>Math.abs(v)),1);
const within=(policy.absoluteTolerance!=null&&discrepancy<=policy.absoluteTolerance)||(policy.relativeTolerance!=null&&discrepancy/scale<=policy.relativeTolerance);
const selected=(policy.providerPriority??[]).map(p=>candidates.find(c=>c.provider===p)).find(Boolean)??first;
const state=discrepancy===0?'agreement':within?'within-tolerance':'material-conflict';
return {...base,state,discrepancy,selected,reason:state==='agreement'?'Candidates agree exactly.':state==='within-tolerance'?'Difference is within policy tolerance.':'Material disagreement; candidates are preserved and never averaged.'};
}
22 changes: 22 additions & 0 deletions packages/shared/src/providers/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,28 @@ describe('ProviderRouter.execute', () => {
expect(second.ok && second.provenance.providerId).toBe('fallback');
});

it('collects every supporting provider for reconciliation', async () => {
const router = new ProviderRouter();
router.register(
new FakeFinancialDataProvider('primary', 'Primary', ['company.financials'], async () =>
success('primary', 'Primary', { value: 100 })
)
);
router.register(
new FakeFinancialDataProvider('fallback', 'Fallback', ['company.financials'], async () =>
success('fallback', 'Fallback', { value: 130 })
)
);
router.setRouting({ primary: 'primary', fallback: 'fallback' });

const results = await router.executeAll<{ value: number }>('company.financials', {});
expect(results).toHaveLength(2);
expect(results.filter((result) => result.ok).map((result) => result.ok && result.provenance.providerId)).toEqual([
'primary',
'fallback',
]);
});

it('returns the primary result on success', async () => {
const router = new ProviderRouter();
let fallbackCalls = 0;
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/providers/reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test';
import type { ProviderResult } from '@finagent/core';
import { reconcileProviderResults } from '@finagent/core';

const policy = { version: 'financial-facts.v1', relativeTolerance: 0.03 };
const result = (providerId: string, value: number): ProviderResult<{ value: number }> => ({
ok: true,
data: { value },
provenance: { providerId, providerName: providerId, fetchedAt: 1_700_000_000, stale: false },
});

describe('provider reconciliation fixtures', () => {
it('classifies exact agreement', () => {
const r = reconcileProviderResults([result('longbridge', 100), result('massive', 100)], (d, p) => ({ provider: p, value: d.value }), policy);
expect(r.reconciliation.state).toBe('agreement');
});
it('classifies a material disagreement and keeps both candidates', () => {
const r = reconcileProviderResults([result('longbridge', 100), result('massive', 130)], (d, p) => ({ provider: p, value: d.value }), policy);
expect(r.reconciliation.state).toBe('material-conflict');
expect(r.reconciliation.candidates).toHaveLength(2);
});
it('reports insufficient sources when one provider fails', () => {
const failed: ProviderResult<{ value: number }> = { ok: false, error: { code: 'TIMEOUT', message: 'timeout' } };
const r = reconcileProviderResults([result('longbridge', 100), failed], (d, p) => ({ provider: p, value: d.value }), policy);
expect(r.reconciliation.state).toBe('insufficient-sources');
expect(r.results).toHaveLength(2);
});
});
7 changes: 7 additions & 0 deletions packages/shared/src/providers/router-fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export interface RouterCapabilityFetchers {
kind?: 'IS' | 'BS' | 'CF' | 'ALL',
report?: string
) => Promise<FinancialReport>;
getFinancialReportCandidates: (
symbol: string,
kind?: 'IS' | 'BS' | 'CF' | 'ALL',
report?: string
) => Promise<ProviderResult<FinancialReport>[]>;
getInstitutionRating: (symbol: string) => Promise<InstitutionRating>;
getDividends: (symbol: string) => Promise<DividendRecord[]>;
getEpsForecasts: (symbol: string) => Promise<EpsForecast[]>;
Expand Down Expand Up @@ -181,6 +186,8 @@ export function createRouterFetchers(
getMarketTemperature: (market) => fetch(router, 'market.sentiment', { market }),
getFinancialReport: (symbol, kind, report) =>
fetch(router, 'company.financials', bindSymbolInput(symbol, { kind, report }, resolve)),
getFinancialReportCandidates: (symbol, kind, report) =>
router.executeAll<FinancialReport>('company.financials', bindSymbolInput(symbol, { kind, report }, resolve)),
getInstitutionRating: (symbol) =>
fetch(router, 'company.ratings', bindSymbolInput(symbol, {}, resolve)),
getDividends: (symbol) =>
Expand Down
32 changes: 32 additions & 0 deletions packages/shared/src/providers/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,38 @@ export class ProviderRouter implements FinancialProviderRouter {
return { ok: false, error: unsupported(capabilityId) };
}

async executeAll<T>(
capabilityId: CapabilityId,
input: unknown,
signal?: AbortSignal
): Promise<ProviderResult<T>[]> {
if (signal?.aborted) return [{ ok: false, error: ABORTED }];
const capabilityOverride = this.capabilityRouting.get(capabilityId);
const routing = capabilityOverride
? capabilityOverride
: this.resolveRouting
? await this.resolveRouting()
: this.routing;
const order = [routing.primary, routing.fallback].filter(
(id): id is string => typeof id === 'string' && id.length > 0
);
for (const provider of this.registry.list()) {
if (!order.includes(provider.id) && supports(provider, capabilityId)) order.push(provider.id);
}
const results: ProviderResult<T>[] = [];
for (const id of order) {
const provider = this.get(id);
if (!provider || !supports(provider, capabilityId)) continue;
if (this.isEnabled && !(await this.isEnabled(id))) continue;
const outcome = await runWithRetry<T>(
() => this.invokeWithTimeout<T>(provider, capabilityId, input, signal),
this.retryOptions
);
results.push(outcome.result);
}
return results;
}

// ── internals ──────────────────────────────────────────────────────────

private attachTrail(provenance: ProviderProvenance, trail: ProviderFailoverStep[]): ProviderProvenance {
Expand Down