From 985fde33ca8da366da8e819728186d8e4e4a066f Mon Sep 17 00:00:00 2001 From: xuilen Date: Fri, 11 Sep 2026 19:13:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=87=91=E8=9E=8D?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E=E8=AF=AD=E4=B9=89=E5=B0=81=E5=A5=97=20Financ?= =?UTF-8?q?ialFact=20=E5=B9=B6=E6=8E=A5=E5=85=A5=E8=A1=8C=E6=83=85/?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定义统一的 FinancialFact 封套, 使每个金融事实(行情/K线/财务指标/估值)都携带: - 规范化 instrumentId、metric、value、结构化 unit(currency/percent/basis-points/ratio/shares/scaled) - 原生 currency, 以及 asOf(数据自身时间戳) 与 retrievedAt(抓取时间) 分离, 绝不用本地 Date.now() 冒充 asOf - timing(live/delayed/eod/historical) 与 exchangeTimezone/marketSession 区分实时性 - 财务事实的 FiscalPeriod(quarter/fy/ttm/annual) 结构化解析 - raw/split-adjusted/dividend-adjusted 调整口径显式标记, 默认 unknown 而非猜测 - FX 换算 convertCurrency 保留原生 value/currency 与换算来源/汇率/汇率时点 - computeStale 按 timing 判定 stale, 供 UI/Agent 识别过期数据 将封套接入 quote/kline/financials/valuation 四个能力 manifest, 使 summary 能回答"何时/何种货币/何种口径"。缺省元数据一律显式标记 unknown, 绝不猜测。 新增 27 个单测(computeStale/convertCurrency/isPriceSeries/markUnknown/describeFact 及各 factory 映射), 全部通过。 --- packages/core/src/capability.ts | 7 + .../core/src/financial-fact-factories.test.ts | 230 ++++++++++++ packages/core/src/financial-fact-factories.ts | 321 +++++++++++++++++ packages/core/src/financial-fact.test.ts | 137 +++++++ packages/core/src/financial-fact.ts | 338 ++++++++++++++++++ packages/core/src/index.ts | 2 + .../manifests/company-valuation.ts | 10 +- .../capabilities/manifests/market-kline.ts | 28 +- .../capabilities/manifests/market-quote.ts | 48 ++- .../src/capabilities/manifests/phase-two.ts | 32 +- 10 files changed, 1136 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/financial-fact-factories.test.ts create mode 100644 packages/core/src/financial-fact-factories.ts create mode 100644 packages/core/src/financial-fact.test.ts create mode 100644 packages/core/src/financial-fact.ts diff --git a/packages/core/src/capability.ts b/packages/core/src/capability.ts index 937b756..5fd4c07 100644 --- a/packages/core/src/capability.ts +++ b/packages/core/src/capability.ts @@ -1,4 +1,5 @@ import type { TSchema } from '@sinclair/typebox'; +import type { FinancialFact } from './financial-fact.ts'; /** * Finance Capability domain — the single source of truth for every piece of @@ -47,6 +48,12 @@ export interface CapabilityResult { data: T; provenance: CapabilityProvenance; summary?: string; + /** + * Unified `FinancialFact` envelope for the result's key values (issue #24). + * Optional for backward compatibility; market-data capabilities SHOULD attach + * it so the Agent/UI can answer "what time, what currency, what basis". + */ + facts?: FinancialFact[]; } export type CapabilityRunStatus = 'success' | 'failed' | 'unavailable' | 'cancelled'; diff --git a/packages/core/src/financial-fact-factories.test.ts b/packages/core/src/financial-fact-factories.test.ts new file mode 100644 index 0000000..b3bf3dc --- /dev/null +++ b/packages/core/src/financial-fact-factories.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'bun:test'; +import type { CalcIndex, Kline, Quote } from './index.ts'; +import type { FinancialReport } from './market-data.ts'; +import { + calcIndexToFacts, + financialReportToFacts, + klineToFacts, + marketCurrencyOf, + quoteToFacts, +} from './financial-fact-factories.ts'; + +const RETRIEVED = 1_749_000_002; + +const quote: Quote = { + symbol: 'NVDA.US', + lastPrice: 224.12, + change: 4.36, + changePercent: 1.98, + volume: 123_456_789, + timestamp: 1_749_000_000, + high: 226.5, + low: 221.3, + open: 221.8, + prevClose: 219.76, +}; + +describe('marketCurrencyOf (structured, not free text)', () => { + it('maps the market code to its native currency', () => { + expect(marketCurrencyOf('NVDA.US')).toBe('USD'); + expect(marketCurrencyOf('0700.HK')).toBe('HKD'); + expect(marketCurrencyOf('600519.SH')).toBe('CNY'); + expect(marketCurrencyOf('000001.SZ')).toBe('CNY'); + expect(marketCurrencyOf('D05.SG')).toBe('SGD'); + }); + + it('returns undefined for unknown markets (never guesses)', () => { + expect(marketCurrencyOf('UNKNOWN')).toBeUndefined(); + expect(marketCurrencyOf('')).toBeUndefined(); + }); +}); + +describe('quoteToFacts (delayed quote + asOf/retrievedAt separation)', () => { + it('keeps asOf = data timestamp and retrievedAt = fetch time separate', () => { + const [lastPrice] = quoteToFacts(quote, { + currency: 'USD', + timing: 'delayed', + retrievedAt: RETRIEVED, + }); + expect(lastPrice.asOf).toBe(quote.timestamp); + expect(lastPrice.retrievedAt).toBe(RETRIEVED); + expect(lastPrice.asOf).not.toBe(lastPrice.retrievedAt); + }); + + it('structurally distinguishes a delayed quote', () => { + const [lastPrice] = quoteToFacts(quote, { currency: 'USD', timing: 'delayed', retrievedAt: RETRIEVED }); + expect(lastPrice.timing).toBe('delayed'); + expect(lastPrice.stale).toBe(false); // within delayed window at retrievedAt + }); + + it('tags spot price levels as raw and uses structured units', () => { + const facts = quoteToFacts(quote, { currency: 'USD', retrievedAt: RETRIEVED }); + const byMetric = new Map(facts.map((fact) => [fact.metric, fact])); + + expect(byMetric.get('lastPrice')?.adjustment).toBe('raw'); + expect(byMetric.get('lastPrice')?.unit).toEqual({ kind: 'currency' }); + expect(byMetric.get('lastPrice')?.currency).toBe('USD'); + expect(byMetric.get('changePercent')?.unit).toEqual({ kind: 'percent' }); + expect(byMetric.get('volume')?.unit).toEqual({ kind: 'shares' }); + }); + + it('marks currency unknown when no market mapping is available', () => { + const facts = quoteToFacts({ ...quote, symbol: 'UNKNOWN' }, { retrievedAt: RETRIEVED }); + const lastPrice = facts.find((fact) => fact.metric === 'lastPrice')!; + expect(lastPrice.currency).toBeUndefined(); + expect(lastPrice.unknown).toContain('currency'); + }); +}); + +describe('klineToFacts (adjustment never silently mixed)', () => { + const kline: Kline = { + symbol: 'NVDA.US', + timestamp: 1_749_000_000, + open: 220, + high: 225, + low: 219, + close: 224.12, + volume: 50_000_000, + }; + + it('carries an explicit adjustment on every price fact', () => { + const facts = klineToFacts(kline, { currency: 'USD', adjustment: 'split-and-dividend-adjusted' }); + const close = facts.find((fact) => fact.metric === 'close')!; + expect(close.adjustment).toBe('split-and-dividend-adjusted'); + expect(close.unit).toEqual({ kind: 'currency' }); + }); + + it('defaults to unknown adjustment rather than assuming raw', () => { + const facts = klineToFacts(kline, { currency: 'USD' }); + const close = facts.find((fact) => fact.metric === 'close')!; + expect(close.adjustment).toBe('unknown'); + }); + + it('does not tag volume (non-price) with an adjustment', () => { + const facts = klineToFacts(kline, { currency: 'USD', adjustment: 'raw' }); + const volume = facts.find((fact) => fact.metric === 'volume')!; + expect(volume.adjustment).toBeUndefined(); + expect(volume.unit).toEqual({ kind: 'shares' }); + }); +}); + +describe('financialReportToFacts (fiscal period + structured currency)', () => { + const report: FinancialReport = { + symbol: 'NVDA.US', + report: 'qf', + statements: { + BS: { + indicators: [ + { + title: '资产与负债', + currency: 'USD', + accounts: [ + { + field: 'TotalAssets', + name: '总资产(USD)', + percent: false, + values: [ + { fpEnd: 1_777_176_000, period: 'Q1 2027', year: 2027, value: 259_474_000_000 }, + { fpEnd: 1_769_317_200, period: 'Q4 2026', year: 2026, value: 206_803_000_000 }, + ], + }, + ], + }, + ], + }, + IS: { + indicators: [ + { + title: '盈利能力', + currency: 'USD', + accounts: [ + { + field: 'GrossMargin', + name: '毛利率', + percent: true, + values: [{ fpEnd: 1_777_176_000, period: 'Q1 2027', year: 2027, value: 73.6 }], + }, + ], + }, + ], + }, + }, + }; + + it('parses a fiscal period label into a structured period (quarter)', () => { + const facts = financialReportToFacts(report, { retrievedAt: RETRIEVED }); + const totalAssets = facts.find((fact) => fact.metric === 'TotalAssets' && fact.period?.quarter === 1)!; + expect(totalAssets.period).toEqual({ + kind: 'quarter', + year: 2027, + quarter: 1, + end: 1_777_176_000, + label: 'Q1 2027', + }); + }); + + it('uses asOf = fiscal period end and structured currency from the indicator', () => { + const facts = financialReportToFacts(report, { retrievedAt: RETRIEVED }); + const totalAssets = facts.find((fact) => fact.metric === 'TotalAssets')!; + expect(totalAssets.asOf).toBe(1_777_176_000); + expect(totalAssets.currency).toBe('USD'); + expect(totalAssets.unit).toEqual({ kind: 'currency' }); + expect(totalAssets.timing).toBe('historical'); // point-in-time, never stale + }); + + it('uses a percent unit for percent-flagged accounts (no currency)', () => { + const facts = financialReportToFacts(report, { retrievedAt: RETRIEVED }); + const margin = facts.find((fact) => fact.metric === 'GrossMargin')!; + expect(margin.unit).toEqual({ kind: 'percent' }); + expect(margin.currency).toBeUndefined(); + }); + + it('marks currency unknown when the indicator lacks a structured currency', () => { + const noCurrency: FinancialReport = { + symbol: 'UNKNOWN', + report: 'qf', + statements: { + BS: { + indicators: [ + { + title: '资产与负债', + accounts: [ + { + field: 'TotalAssets', + name: '总资产', + percent: false, + values: [{ fpEnd: 1_777_176_000, period: 'Q1 2027', year: 2027, value: 100 }], + }, + ], + }, + ], + }, + }, + }; + const facts = financialReportToFacts(noCurrency, { retrievedAt: RETRIEVED }); + expect(facts[0].currency).toBeUndefined(); + expect(facts[0].unknown).toContain('currency'); + }); +}); + +describe('calcIndexToFacts (valuation ratios)', () => { + const index: CalcIndex = { + symbol: 'NVDA.US', + pe: 34.12, + pb: 27.86, + dpsRate: 0.12, + totalMarketValue: 5_445_387_000_000, + turnoverRate: 0.46, + }; + + it('maps ratios, percents, and currency amounts to structured units', () => { + const facts = calcIndexToFacts(index, { currency: 'USD', retrievedAt: RETRIEVED }); + const byMetric = new Map(facts.map((fact) => [fact.metric, fact])); + expect(byMetric.get('pe')?.unit).toEqual({ kind: 'ratio' }); + expect(byMetric.get('pb')?.unit).toEqual({ kind: 'ratio' }); + expect(byMetric.get('dpsRate')?.unit).toEqual({ kind: 'percent' }); + expect(byMetric.get('totalMarketValue')?.unit).toEqual({ kind: 'currency' }); + expect(byMetric.get('totalMarketValue')?.currency).toBe('USD'); + expect(byMetric.get('turnoverRate')?.unit).toEqual({ kind: 'percent' }); + }); +}); diff --git a/packages/core/src/financial-fact-factories.ts b/packages/core/src/financial-fact-factories.ts new file mode 100644 index 0000000..30a4638 --- /dev/null +++ b/packages/core/src/financial-fact-factories.ts @@ -0,0 +1,321 @@ +/** + * Factories that map the provider-neutral market-data shapes (`Quote`, `Kline`, + * `FinancialReport`, `CalcIndex`) into the unified `FinancialFact` envelope. + * + * These are the ONE place that knows how each shape's timestamps, currencies, + * units, and periods map onto fact semantics, so the capability layer and the + * agent stay ignorant of those details. Every price-series fact is tagged with + * an explicit `adjustment` (never left to be implied as raw), every fundamental + * fact carries a structured `FiscalPeriod`, and anything the source did not + * tell us is recorded in `unknown` rather than guessed. + * + * Timestamps are epoch SECONDS, matching `Quote.timestamp` / `Kline.timestamp` + * / `FinancialReportValue.fpEnd`. `retrievedAt` is the fetch time — always kept + * separate from `asOf` (the data's own timestamp), never derived from + * `Date.now()` as a stand-in for the data. + */ + +import type { CalcIndex, Kline, Quote } from './index.ts'; +import type { + FinancialReport, + FinancialReportAccount, + FinancialReportValue, +} from './market-data.ts'; +import { + computeStale, + markUnknown, + type FactAdjustment, + type FactTiming, + type FactUnit, + type FinancialFact, + type FiscalPeriod, +} from './financial-fact.ts'; + +/** Options shared by every factory; all optional, all non-guessing. */ +export interface FactFactoryOptions { + provider?: string; + /** Epoch seconds the data was fetched. Defaults to now. */ + retrievedAt?: number; + /** Temporal status (`live`/`delayed`/`eod`/`historical`). Defaults to `unknown`. */ + timing?: FactTiming; + /** Native ISO currency. When omitted for a monetary fact, `currency` is unknown. */ + currency?: string; + /** Price-series adjustment; `unknown` when the source did not say. */ + adjustment?: FactAdjustment; + /** IANA exchange timezone, e.g. `America/New_York`. */ + exchangeTimezone?: string; + /** Market session, e.g. `regular` / `pre` / `post` / `closed`. */ + marketSession?: string; + /** Injectable clock (epoch ms) for `retrievedAt`/staleness. */ + now?: () => number; +} + +/** + * Deterministic market-code → native-currency lookup for `CODE.MARKET` symbols. + * This is a structured code table (not free-text parsing) and returns + * `undefined` for unknown markets so callers can mark the currency unknown + * rather than guess. + */ +const MARKET_CURRENCY: Record = { + US: 'USD', + HK: 'HKD', + SH: 'CNY', + SZ: 'CNY', + SG: 'SGD', +}; + +export function marketCurrencyOf(symbol: string): string | undefined { + const parts = (symbol ?? '').trim().toUpperCase().split('.'); + const market = parts[parts.length - 1] ?? ''; + return MARKET_CURRENCY[market]; +} + +// ── internal helpers ──────────────────────────────────────────────────────── + +function nowSeconds(now?: () => number): number { + return Math.floor((now?.() ?? Date.now()) / 1000); +} + +function resolveRetrievedAt(options: FactFactoryOptions): number { + return options.retrievedAt ?? nowSeconds(options.now); +} + +interface FactBase { + instrumentId: string; + currency?: string; + asOf?: number; + retrievedAt: number; + provider?: string; + timing: FactTiming; + adjustment?: FactAdjustment; + exchangeTimezone?: string; + marketSession?: string; + stale: boolean; +} + +function makeBase(options: FactFactoryOptions): Omit { + return { + currency: options.currency, + retrievedAt: resolveRetrievedAt(options), + provider: options.provider, + timing: options.timing ?? 'unknown', + adjustment: options.adjustment, + exchangeTimezone: options.exchangeTimezone, + marketSession: options.marketSession, + }; +} + +/** A monetary fact: structured `currency` unit, `currency` marked unknown when absent. */ +function moneyFact( + base: Omit & Pick, + metric: string, + value: number +): FinancialFact { + const fact: FinancialFact = { + ...base, + metric, + value, + unit: { kind: 'currency' }, + }; + return base.currency ? fact : markUnknown(fact, 'currency'); +} + +/** A non-monetary fact with an explicit structured unit. */ +function unitFact( + base: Omit & Pick, + metric: string, + value: number, + unit: FactUnit +): FinancialFact { + return { ...base, metric, value, unit }; +} + +// ── Quote ─────────────────────────────────────────────────────────────────── + +/** + * A real-time quote → facts for last/prev-close/open/high/low (spot, `raw`), + * change (currency delta), change-percent, and volume. `asOf` is + * `quote.timestamp`; `retrievedAt` is the fetch time. + */ +export function quoteToFacts(quote: Quote, options: FactFactoryOptions = {}): FinancialFact[] { + const shared = makeBase(options); + const base: FactBase = { + ...shared, + instrumentId: quote.symbol, + asOf: quote.timestamp, + adjustment: options.adjustment ?? 'raw', + stale: computeStale(options.timing ?? 'unknown', quote.timestamp, { + now: shared.retrievedAt, + }), + }; + + const facts: FinancialFact[] = [ + moneyFact(base, 'lastPrice', quote.lastPrice), + moneyFact(base, 'prevClose', quote.prevClose), + moneyFact(base, 'open', quote.open), + moneyFact(base, 'high', quote.high), + moneyFact(base, 'low', quote.low), + moneyFact(base, 'change', quote.change), + unitFact({ ...base, adjustment: undefined }, 'changePercent', quote.changePercent, { + kind: 'percent', + }), + unitFact({ ...base, adjustment: undefined }, 'volume', quote.volume, { kind: 'shares' }), + ]; + return facts; +} + +// ── Kline (historical bars) ───────────────────────────────────────────────── + +/** + * One historical bar → facts for OHLC (price series) and volume. The + * `adjustment` is explicit; it defaults to `unknown` (never silently `raw`). + */ +export function klineToFacts(kline: Kline, options: FactFactoryOptions = {}): FinancialFact[] { + const shared = makeBase(options); + const base: FactBase = { + ...shared, + instrumentId: kline.symbol, + asOf: kline.timestamp, + adjustment: options.adjustment ?? 'unknown', + stale: computeStale(options.timing ?? 'unknown', kline.timestamp, { + now: shared.retrievedAt, + }), + }; + + return [ + moneyFact(base, 'open', kline.open), + moneyFact(base, 'high', kline.high), + moneyFact(base, 'low', kline.low), + moneyFact(base, 'close', kline.close), + unitFact({ ...base, adjustment: undefined }, 'volume', kline.volume, { kind: 'shares' }), + ]; +} + +// ── Financial report (fundamental metrics) ────────────────────────────────── + +/** + * Financial statements → one fact per account value, with a structured + * `FiscalPeriod` (from the `period` label + `year`) and `asOf` = the fiscal + * period end (`fpEnd`). Currency comes from the indicator's structured + * `currency` field; percent-flagged accounts get a `percent` unit. + */ +export function financialReportToFacts( + report: FinancialReport, + options: FactFactoryOptions = {} +): FinancialFact[] { + // Fundamental fiscal metrics are point-in-time observations, not live data. + const shared = { ...makeBase(options), timing: options.timing ?? 'historical' }; + const facts: FinancialFact[] = []; + + for (const statement of Object.values(report.statements)) { + if (!statement) continue; + for (const indicator of statement.indicators) { + for (const account of indicator.accounts) { + facts.push(...accountToFacts(report.symbol, indicator.currency, account, shared)); + } + } + } + return facts; +} + +function accountToFacts( + symbol: string, + currency: string | undefined, + account: FinancialReportAccount, + shared: ReturnType +): FinancialFact[] { + const percent = account.percent === true; + return account.values.map((value) => + valueToFact(symbol, currency, account.field, percent, value, shared) + ); +} + +function valueToFact( + symbol: string, + currency: string | undefined, + metric: string, + percent: boolean, + value: FinancialReportValue, + shared: ReturnType +): FinancialFact { + const hasAsOf = value.fpEnd > 0; + const fact: FinancialFact = { + ...shared, + instrumentId: symbol, + metric, + value: value.value, + unit: percent ? { kind: 'percent' } : { kind: 'currency' }, + currency: percent ? undefined : currency, + asOf: hasAsOf ? value.fpEnd : undefined, + period: parseFiscalPeriod(value.period, value.year, value.fpEnd), + stale: false, // point-in-time fiscal facts never become stale + }; + + const unknown: string[] = []; + if (!percent && currency === undefined) unknown.push('currency'); + if (!hasAsOf) unknown.push('asOf'); + return unknown.length > 0 ? markUnknown(fact, ...unknown) : fact; +} + +/** `period` label (`Q1 2027`, `FY2026`, `TTM`, `2026`) → structured period. */ +function parseFiscalPeriod(label: string, year: number, fpEnd: number): FiscalPeriod { + const text = (label ?? '').trim(); + const end = fpEnd > 0 ? fpEnd : undefined; + + const quarter = /Q([1-4])\s*(\d{4})/i.exec(text); + if (quarter) { + return { + kind: 'quarter', + year: Number(quarter[2]), + quarter: Number(quarter[1]) as 1 | 2 | 3 | 4, + end, + label: text || undefined, + }; + } + if (/TTM/i.test(text)) return { kind: 'ttm', end, label: text || undefined }; + const fy = /FY\s*(\d{4})/i.exec(text); + if (fy) return { kind: 'fy', year: Number(fy[1]), end, label: text || undefined }; + if (/^\d{4}$/.test(text)) { + return { kind: 'annual', year: Number(text), end, label: text || undefined }; + } + return { + kind: 'unknown', + year: year > 0 ? year : undefined, + end, + label: text || undefined, + }; +} + +// ── Calc index (valuation ratios) ─────────────────────────────────────────── + +/** Calculated valuation indexes → structured ratio/percent/currency facts. */ +export function calcIndexToFacts(index: CalcIndex, options: FactFactoryOptions = {}): FinancialFact[] { + const shared = makeBase(options); + const base: FactBase = { + ...shared, + instrumentId: index.symbol, + asOf: undefined, + stale: false, + }; + + const facts: FinancialFact[] = []; + if (index.pe !== undefined) facts.push(unitFact(base, 'pe', index.pe, { kind: 'ratio' })); + if (index.pb !== undefined) facts.push(unitFact(base, 'pb', index.pb, { kind: 'ratio' })); + if (index.dpsRate !== undefined) facts.push(unitFact(base, 'dpsRate', index.dpsRate, { kind: 'percent' })); + if (index.totalMarketValue !== undefined) { + facts.push(moneyFact(base, 'totalMarketValue', index.totalMarketValue)); + } + if (index.turnoverRate !== undefined) { + facts.push(unitFact(base, 'turnoverRate', index.turnoverRate, { kind: 'percent' })); + } + if (index.ytdChangeRate !== undefined) { + facts.push(unitFact(base, 'ytdChangeRate', index.ytdChangeRate, { kind: 'percent' })); + } + if (index.volumeRatio !== undefined) { + facts.push(unitFact(base, 'volumeRatio', index.volumeRatio, { kind: 'ratio' })); + } + if (index.amplitude !== undefined) { + facts.push(unitFact(base, 'amplitude', index.amplitude, { kind: 'percent' })); + } + return facts; +} diff --git a/packages/core/src/financial-fact.test.ts b/packages/core/src/financial-fact.test.ts new file mode 100644 index 0000000..2c5d0f1 --- /dev/null +++ b/packages/core/src/financial-fact.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'bun:test'; +import { + computeStale, + convertCurrency, + describeFact, + factFreshnessLabel, + isPriceSeries, + markUnknown, + type FinancialFact, +} from './financial-fact.ts'; + +// Fixed epoch-second anchors so every assertion is deterministic. +const AS_OF = 1_749_000_000; // a "market" timestamp +const RETRIEVED = 1_749_000_002; // 2 seconds later + +function fact(overrides: Partial = {}): FinancialFact { + return { + instrumentId: 'NVDA.US', + metric: 'lastPrice', + value: 224.12, + unit: { kind: 'currency' }, + currency: 'USD', + asOf: AS_OF, + retrievedAt: RETRIEVED, + provider: 'longbridge', + timing: 'live', + adjustment: 'raw', + stale: false, + ...overrides, + }; +} + +describe('computeStale', () => { + it('flags live data older than 5 minutes as stale', () => { + const old = AS_OF - 400; // 6m40s old + expect(computeStale('live', old, { now: AS_OF })).toBe(true); + expect(computeStale('live', AS_OF - 200, { now: AS_OF })).toBe(false); + }); + + it('flags delayed data older than 15 minutes as stale (delayed ≠ live)', () => { + // 10 minutes old: fresh for live, stale for delayed. + const tenMinOld = AS_OF - 600; + expect(computeStale('delayed', tenMinOld, { now: AS_OF })).toBe(false); + expect(computeStale('delayed', AS_OF - 1000, { now: AS_OF })).toBe(true); + }); + + it('never flags eod or historical (point-in-time) facts as stale', () => { + const ancient = AS_OF - 100_000_000; + expect(computeStale('eod', ancient, { now: AS_OF })).toBe(false); + expect(computeStale('historical', ancient, { now: AS_OF })).toBe(false); + }); + + it('cannot classify a missing asOf as stale', () => { + expect(computeStale('live', undefined, { now: AS_OF })).toBe(false); + }); +}); + +describe('convertCurrency (cross-currency)', () => { + it('converts value and preserves native value/currency + conversion provenance', () => { + const usd = fact({ value: 100, currency: 'USD' }); + const hkd = convertCurrency(usd, 'HKD', 7.8, AS_OF, 'open-exchange-rates'); + + expect(hkd.value).toBeCloseTo(780); + expect(hkd.currency).toBe('HKD'); + // native preserved, not lost + expect(hkd.native).toEqual({ value: 100, currency: 'USD' }); + expect(hkd.fxConversion).toEqual({ + fromCurrency: 'USD', + toCurrency: 'HKD', + rate: 7.8, + rateAsOf: AS_OF, + source: 'open-exchange-rates', + }); + }); + + it('refuses to convert when there is no native currency', () => { + const noCurrency = fact({ currency: undefined }); + const out = convertCurrency(noCurrency, 'HKD', 7.8, AS_OF); + expect(out.value).toBe(noCurrency.value); + expect(out.unknown).toContain('currency'); + expect(out.fxConversion).toBeUndefined(); + }); + + it('refuses a non-positive rate and marks the conversion unknown', () => { + const out = convertCurrency(fact(), 'HKD', 0, AS_OF); + expect(out.value).toBe(224.12); + expect(out.unknown).toContain('fxConversion'); + }); +}); + +describe('isPriceSeries', () => { + it('is true only when an explicit adjustment is present', () => { + expect(isPriceSeries(fact({ adjustment: 'raw' }))).toBe(true); + expect(isPriceSeries(fact({ adjustment: 'unknown' }))).toBe(true); + expect(isPriceSeries(fact({ adjustment: undefined }))).toBe(false); + }); +}); + +describe('markUnknown', () => { + it('records fields explicitly instead of guessing', () => { + const out = markUnknown(fact(), 'currency', 'asOf'); + expect(out.unknown).toEqual(['currency', 'asOf']); + }); +}); + +describe('describeFact', () => { + it('answers what time / currency / basis for a live quote', () => { + const text = describeFact(fact()); + expect(text).toContain('NVDA.US lastPrice'); + expect(text).toContain('224.12'); + expect(text).toContain('USD'); + expect(text).toContain('live'); + expect(text).toContain('raw'); + expect(text).toContain('as of'); + expect(text).toContain('retrieved'); + }); + + it('surfaces a delayed price distinctly from a live one', () => { + const text = describeFact(fact({ timing: 'delayed', stale: true })); + expect(text).toContain('delayed'); + expect(text).toContain('stale'); + }); + + it('surfaces an FX conversion provenance', () => { + const converted = convertCurrency(fact(), 'HKD', 7.8, AS_OF); + const text = describeFact(converted); + expect(text).toContain('converted USD→HKD @ 7.8'); + }); +}); + +describe('factFreshnessLabel', () => { + it('derives a machine-readable label for the UI', () => { + expect(factFreshnessLabel(fact({ timing: 'delayed' }))).toBe('delayed'); + expect(factFreshnessLabel(fact({ timing: 'unknown', stale: true }))).toBe('stale'); + expect(factFreshnessLabel(fact({ timing: 'unknown', stale: false }))).toBe('unknown'); + }); +}); diff --git a/packages/core/src/financial-fact.ts b/packages/core/src/financial-fact.ts new file mode 100644 index 0000000..1869d7d --- /dev/null +++ b/packages/core/src/financial-fact.ts @@ -0,0 +1,338 @@ +/** + * Financial Fact Envelope — the single semantic wrapper every key financial + * number should carry before it enters an Agent, a report, or the UI. + * + * The most dangerous finance-copilot error is not "no data", it is treating + * numbers with *different* time, currency, unit, or adjustment semantics as the + * same fact: a real-time price vs. a 15-minute-delayed price, a previous-close + * vs. a fiscal-period-end value, USD vs. HKD, raw OHLC vs. split-adjusted OHLC. + * When these travel as bare `number`/`string`, downstream layers cannot tell + * them apart. + * + * This module defines: + * - `FinancialFact` — the envelope (instrument, metric, value, structured + * unit, native currency, `asOf` vs. `retrievedAt`, timing, adjustment, + * fiscal period, FX-conversion provenance, staleness, and explicit unknowns). + * - helpers to describe, convert, and classify facts so both the Agent (via a + * capability `summary`) and the UI (via `stale`/`timing`) can answer + * "what time, what currency, what basis is this number". + * + * Rules (issue #24): + * - `asOf` is the DATA's own timestamp — never `Date.now()`. + * - `retrievedAt` is when WE fetched it — kept separate from `asOf`. + * - currency / unit are structured (ISO code + `FactUnit`), never parsed from + * free text. + * - a currency conversion preserves the native value/currency and adds + * `FxConversion` provenance. + * - price series carry an explicit `adjustment`; raw vs. adjusted are never + * silently mixed. + * - anything the provider did not tell us is recorded in `unknown`, never + * guessed. + * + * Timestamps are epoch SECONDS unless noted otherwise, matching the existing + * core convention (`Quote.timestamp`, `Kline.timestamp`). + */ + +// ── Timing (data status) ──────────────────────────────────────────────────── + +/** + * The temporal status of a fact's value. + * + * - `live` — real-time value (or as close to it as the provider gives). + * - `delayed` — snapshot lagging real-time by a known/unknown delay. + * - `eod` — end-of-day value (final daily bar / close). + * - `historical` — a completed, point-in-time observation (an old bar, a past + * fiscal-period metric). + * - `unknown` — the provider did not tell us; callers MUST NOT guess. + */ +export type FactTiming = 'live' | 'delayed' | 'eod' | 'historical' | 'unknown'; + +// ── Adjustment (price series only) ────────────────────────────────────────── + +/** + * How a price series was adjusted for corporate actions. `undefined` means the + * value is NOT a price series (e.g. a fundamental metric), so adjustment does + * not apply. Price series always set this explicitly — `raw` is never implied + * — and use `unknown` when the provider did not say (never guessed). + */ +export type FactAdjustment = + | 'raw' + | 'split-adjusted' + | 'dividend-adjusted' + | 'split-and-dividend-adjusted' + | 'unknown'; + +// ── Structured unit ───────────────────────────────────────────────────────── + +/** + * A structured unit — never free text. `currency` is tracked separately on the + * fact; this records the *shape* of the number so percentages, basis points, + * ratios and scaled amounts are never guessed from a string. + */ +export type FactUnit = + | { kind: 'currency' } + | { kind: 'percent' } + | { kind: 'basis-points' } + | { kind: 'ratio' } + | { kind: 'shares' } + | { kind: 'count' } + | { kind: 'scaled'; magnitude: 'thousand' | 'million' | 'billion' } + | { kind: 'unknown' }; + +// ── Fiscal period ─────────────────────────────────────────────────────────── + +export type FiscalPeriodKind = 'fy' | 'ttm' | 'quarter' | 'semi-annual' | 'annual' | 'unknown'; + +/** Fiscal-period semantics for fundamental facts (income/balance/cash-flow). */ +export interface FiscalPeriod { + kind: FiscalPeriodKind; + /** Fiscal year, when known. */ + year?: number; + /** Calendar quarter, when `kind === 'quarter'`. */ + quarter?: 1 | 2 | 3 | 4; + /** Epoch seconds of the fiscal-period end — the metric's own "as of". */ + end?: number; + /** Provider display label, e.g. `Q1 2027`. */ + label?: string; +} + +// ── FX conversion provenance ──────────────────────────────────────────────── + +/** Metadata for a currency conversion applied to a fact's value. */ +export interface FxConversion { + /** Native (pre-conversion) currency. */ + fromCurrency: string; + /** Display (post-conversion) currency. */ + toCurrency: string; + rate: number; + /** Epoch seconds of the FX rate. */ + rateAsOf: number; + source?: string; +} + +// ── The envelope ──────────────────────────────────────────────────────────── + +export interface FinancialFact { + /** Canonical instrument id, `CODE.MARKET` (empty for market-level facts). */ + instrumentId: string; + /** Stable metric identity, e.g. `lastPrice`, `close`, `TotalAssets`. */ + metric: string; + /** The numeric value, already expressed in `unit` / `currency`. */ + value: number; + /** Structured unit — never free text. */ + unit: FactUnit; + /** Native ISO currency code (undefined for non-monetary facts). */ + currency?: string; + /** The DATA's own timestamp (epoch seconds). Never `Date.now()`. */ + asOf?: number; + /** Fiscal-period semantics, for fundamental facts. */ + period?: FiscalPeriod; + /** Epoch seconds when WE fetched the data. */ + retrievedAt: number; + provider?: string; + timing: FactTiming; + /** Present only for price series; `raw` is explicit, never implied. */ + adjustment?: FactAdjustment; + /** IANA exchange timezone, e.g. `America/New_York`. */ + exchangeTimezone?: string; + /** Market session, e.g. `regular`, `pre`, `post`, `closed`. */ + marketSession?: string; + /** + * Native (pre-conversion) value/currency, preserved when a currency + * conversion was applied. Absent when no conversion happened. + */ + native?: { value: number; currency: string }; + /** Conversion provenance, present only when a conversion happened. */ + fxConversion?: FxConversion; + /** Freshness classification relative to `asOf` + `timing`. */ + stale: boolean; + /** Fields the provider did not tell us — explicit, never guessed. */ + unknown?: string[]; +} + +// ── Staleness ─────────────────────────────────────────────────────────────── + +export interface StalenessOptions { + /** Epoch seconds to measure against. Defaults to `Date.now() / 1000`. */ + now?: number; + /** Max age (sec) for `live` data before it is stale. Default 5 min. */ + liveMaxAgeSec?: number; + /** Max age (sec) for `delayed` data before it is stale. Default 15 min. */ + delayedMaxAgeSec?: number; + /** Max age (sec) for `unknown`-timing data before it is stale. Default 1 day. */ + unknownMaxAgeSec?: number; +} + +/** + * Classify staleness from a fact's timing and `asOf`. + * + * `eod` and `historical` values are point-in-time observations — they never + * become stale (a historical close does not "expire"). `live` / `delayed` / + * `unknown` values become stale once they are older than their timing's max age. + * A missing `asOf` cannot be classified, so it is NOT stale (the caller should + * also mark `asOf` in `unknown`). + */ +export function computeStale( + timing: FactTiming, + asOf: number | undefined, + options: StalenessOptions = {} +): boolean { + if (asOf === undefined) return false; + if (timing === 'eod' || timing === 'historical') return false; + + const now = options.now ?? Math.floor(Date.now() / 1000); + const age = now - asOf; + if (age < 0) return false; // clock skew — never flag future data as stale + + switch (timing) { + case 'live': + return age > (options.liveMaxAgeSec ?? 300); + case 'delayed': + return age > (options.delayedMaxAgeSec ?? 900); + default: + return age > (options.unknownMaxAgeSec ?? 86_400); + } +} + +// ── Unknown marking ───────────────────────────────────────────────────────── + +/** Return a copy of `fact` with extra fields recorded as explicitly unknown. */ +export function markUnknown(fact: FinancialFact, ...fields: string[]): FinancialFact { + if (fields.length === 0) return fact; + const seen = new Set(fact.unknown ?? []); + for (const field of fields) seen.add(field); + return { ...fact, unknown: [...seen] }; +} + +// ── FX conversion ─────────────────────────────────────────────────────────── + +/** + * Apply a currency conversion, preserving the native value/currency and the + * conversion provenance. Conversion is refused (returned unchanged, with + * `currency` marked unknown) when there is no native currency to convert from. + */ +export function convertCurrency( + fact: FinancialFact, + toCurrency: string, + rate: number, + rateAsOf: number, + source?: string +): FinancialFact { + const target = (toCurrency ?? '').trim().toUpperCase(); + if (fact.currency === undefined || fact.currency === '' || target === '') { + return markUnknown(fact, 'currency'); + } + if (!Number.isFinite(rate) || rate <= 0) { + return markUnknown(fact, 'fxConversion'); + } + + const native = fact.native ?? { value: fact.value, currency: fact.currency }; + return { + ...fact, + value: fact.value * rate, + currency: target, + native, + fxConversion: { + fromCurrency: fact.currency, + toCurrency: target, + rate, + rateAsOf, + source, + }, + }; +} + +// ── Price series ──────────────────────────────────────────────────────────── + +/** True when the fact is a price-series value (carries an `adjustment`). */ +export function isPriceSeries(fact: FinancialFact): boolean { + return fact.adjustment !== undefined; +} + +// ── Formatting / description ──────────────────────────────────────────────── + +/** Human label for a structured unit (empty for unitless ratios/counts). */ +export function unitLabel(unit: FactUnit): string { + switch (unit.kind) { + case 'percent': + return '%'; + case 'basis-points': + return 'bp'; + case 'shares': + return 'shares'; + case 'scaled': + return { thousand: 'k', million: 'M', billion: 'B' }[unit.magnitude]; + default: + return ''; + } +} + +/** `epoch seconds` → `YYYY-MM-DD HH:mm:ss UTC` (deterministic). */ +function formatEpoch(seconds: number): string { + const date = new Date(seconds * 1000); + if (Number.isNaN(date.getTime())) return String(seconds); + const iso = date.toISOString(); // YYYY-MM-DDTHH:mm:ss.sssZ + return `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC`; +} + +function formatValue(fact: FinancialFact): string { + const raw = fact.value.toLocaleString('en-US', { maximumFractionDigits: 2 }); + const label = unitLabel(fact.unit); + return label === '' ? raw : `${raw}${label}`; +} + +function describePeriod(period: FiscalPeriod | undefined): string | undefined { + if (!period) return undefined; + if (period.label) return period.label; + if (period.kind === 'quarter' && period.year !== undefined && period.quarter !== undefined) { + return `Q${period.quarter} ${period.year}`; + } + if (period.year !== undefined) return `${period.kind.toUpperCase()} ${period.year}`; + return period.kind; +} + +/** + * One-line, human/agent-readable description of a fact that answers + * "what time, what currency, what basis is this number". + * + * Example: + * `NVDA.US lastPrice: 224.12 USD (live, raw, as of 2026-06-04 15:59:59 UTC, retrieved 2026-06-04 16:00:02 UTC)` + */ +export function describeFact(fact: FinancialFact): string { + const head = `${fact.instrumentId} ${fact.metric}: ${formatValue(fact)}`; + + const qualifiers: string[] = []; + if (fact.currency) qualifiers.push(fact.currency); + else if (fact.unit.kind === 'currency') qualifiers.push('currency unknown'); + if (fact.timing !== 'unknown') qualifiers.push(fact.timing); + if (fact.adjustment) qualifiers.push(fact.adjustment); + const period = describePeriod(fact.period); + if (period) qualifiers.push(period); + if (fact.marketSession) qualifiers.push(fact.marketSession); + if (fact.asOf !== undefined) qualifiers.push(`as of ${formatEpoch(fact.asOf)}`); + qualifiers.push(`retrieved ${formatEpoch(fact.retrievedAt)}`); + if (fact.fxConversion) { + qualifiers.push( + `converted ${fact.fxConversion.fromCurrency}→${fact.fxConversion.toCurrency} @ ${fact.fxConversion.rate}` + ); + } + if (fact.stale) qualifiers.push('stale'); + + return `${head} (${qualifiers.join(', ')})`; +} + +/** Describe a batch of facts, one per line (default 12). */ +export function factsToSummary(facts: FinancialFact[], max = 12): string { + return facts.slice(0, max).map(describeFact).join('\n'); +} + +/** + * A short machine-readable freshness label for the UI, derived from the same + * fields `DataFreshness` already renders. Returns `live`, `delayed`, `eod`, + * `historical`, `stale`, or `unknown`. + */ +export function factFreshnessLabel(fact: FinancialFact): string { + if (fact.timing !== 'unknown') return fact.timing; + if (fact.stale) return 'stale'; + return 'unknown'; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index beb62c5..94e41ff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -551,6 +551,8 @@ export * from './portfolio-risk.ts'; export * from './provider.ts'; export * from './account.ts'; export * from './market-data.ts'; +export * from './financial-fact.ts'; +export * from './financial-fact-factories.ts'; export * from './screening.ts'; export * from './strategy.ts'; export * from './research-diff.ts'; diff --git a/packages/shared/src/capabilities/manifests/company-valuation.ts b/packages/shared/src/capabilities/manifests/company-valuation.ts index a4dec68..7eb3178 100644 --- a/packages/shared/src/capabilities/manifests/company-valuation.ts +++ b/packages/shared/src/capabilities/manifests/company-valuation.ts @@ -1,6 +1,7 @@ import { Type } from '@sinclair/typebox'; import type { CalcIndex } from '@finagent/core'; import type { FinanceCapability } from '@finagent/core'; +import { calcIndexToFacts, marketCurrencyOf } from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; @@ -24,9 +25,16 @@ export function createCompanyValuationCapability( async execute(input, ctx) { const symbol = normalizeSymbol(input.symbol); const index = await fetchers.getCalcIndex(symbol); + const fetchedAt = (ctx?.now ?? Date.now)(); + const facts = calcIndexToFacts(index, { + provider: 'longbridge', + currency: marketCurrencyOf(symbol), + retrievedAt: Math.floor(fetchedAt / 1000), + }); return { data: index, - provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), stale: false }, + facts, + provenance: { provider: 'longbridge', fetchedAt, stale: false }, summary: formatCalcIndex(index), }; }, diff --git a/packages/shared/src/capabilities/manifests/market-kline.ts b/packages/shared/src/capabilities/manifests/market-kline.ts index 280c50d..d0d842b 100644 --- a/packages/shared/src/capabilities/manifests/market-kline.ts +++ b/packages/shared/src/capabilities/manifests/market-kline.ts @@ -1,6 +1,7 @@ import { Type } from '@sinclair/typebox'; -import type { Kline } from '@finagent/core'; +import type { FinancialFact, Kline } from '@finagent/core'; import type { FinanceCapability } from '@finagent/core'; +import { describeFact, klineToFacts, marketCurrencyOf } from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; @@ -43,22 +44,40 @@ export function createMarketKlineCapability( const period = input.period ?? '1d'; const limit = input.limit ?? 100; const klines = await fetchers.getKline({ symbol, period, limit }); + const fetchedAt = (ctx?.now ?? Date.now)(); + const timing = period === '1d' || period === '1w' ? 'eod' : 'historical'; + const facts = klines.map((kline) => + klineToFacts(kline, { + provider: 'longbridge', + currency: marketCurrencyOf(symbol), + timing, + adjustment: 'unknown', // CLI does not report adjustment — never assume raw + retrievedAt: Math.floor(fetchedAt / 1000), + }) + ); return { data: klines, + facts: facts.flat(), provenance: { provider: 'longbridge', - fetchedAt: (ctx?.now ?? Date.now)(), + fetchedAt, marketTime: klines[klines.length - 1]?.timestamp, stale: false, }, - summary: formatKline(symbol, period, klines), + summary: formatKline(symbol, period, klines, facts), }; }, }); } -function formatKline(symbol: string, period: string, klines: Kline[]) { +function formatKline( + symbol: string, + period: string, + klines: Kline[], + facts: FinancialFact[][] +) { const recent = klines.slice(-5); + const latestClose = facts[facts.length - 1]?.find((fact) => fact.metric === 'close'); return [ `${symbol} K-Line (${period})`, '-----------------', @@ -66,5 +85,6 @@ function formatKline(symbol: string, period: string, klines: Kline[]) { const date = new Date(kline.timestamp * 1000).toLocaleDateString(); return `${date}: O$${kline.open.toFixed(2)} H$${kline.high.toFixed(2)} L$${kline.low.toFixed(2)} C$${kline.close.toFixed(2)} V${kline.volume}`; }), + latestClose ? `Semantics: ${describeFact(latestClose)}` : '', ].join('\n'); } diff --git a/packages/shared/src/capabilities/manifests/market-quote.ts b/packages/shared/src/capabilities/manifests/market-quote.ts index 67b2f87..98db185 100644 --- a/packages/shared/src/capabilities/manifests/market-quote.ts +++ b/packages/shared/src/capabilities/manifests/market-quote.ts @@ -1,6 +1,7 @@ import { Type } from '@sinclair/typebox'; -import type { Quote } from '@finagent/core'; +import type { FinancialFact, Quote } from '@finagent/core'; import type { FinanceCapability } from '@finagent/core'; +import { describeFact, marketCurrencyOf, quoteToFacts } from '@finagent/core'; import { defineCapability } from '../define.ts'; import { normalizeSymbol } from '../validate.ts'; import type { CapabilityFetchers } from '../fetchers.ts'; @@ -27,33 +28,62 @@ export function createMarketQuoteCapability( async execute(input, ctx) { const symbol = normalizeSymbol(input.symbol); const quote = await fetchers.getQuote(symbol); + const fetchedAt = (ctx?.now ?? Date.now)(); + // Native currency is derived from the market code (structured, not free + // text). Quotes are real-time by contract; the `delayed`/`eod` variants + // are produced by callers that know a delay applies. + const facts = quoteToFacts(quote, { + provider: 'longbridge', + currency: marketCurrencyOf(symbol), + timing: 'live', + retrievedAt: Math.floor(fetchedAt / 1000), + }); return { data: quote, + facts, provenance: { provider: 'longbridge', - fetchedAt: (ctx?.now ?? Date.now)(), + fetchedAt, marketTime: quote.timestamp, stale: false, }, - summary: formatQuote(quote), + summary: formatQuote(quote, facts), }; }, }); } -function formatQuote(quote: Quote) { +function formatQuote(quote: Quote, facts: FinancialFact[]) { + const symbol = currencySymbol(facts.find((fact) => fact.metric === 'lastPrice')?.currency); const changeIcon = quote.change >= 0 ? '[up]' : '[down]'; const changeStr = quote.change >= 0 ? `+${quote.change.toFixed(2)} (+${quote.changePercent.toFixed(2)}%)` : `${quote.change.toFixed(2)} (${quote.changePercent.toFixed(2)}%)`; - const asOf = new Date(quote.timestamp * 1000).toLocaleString(); + + const lastPrice = facts.find((fact) => fact.metric === 'lastPrice'); return [ - `${changeIcon} ${quote.symbol}: $${quote.lastPrice.toFixed(2)}`, + `${changeIcon} ${quote.symbol}: ${symbol}${quote.lastPrice.toFixed(2)}`, `Change: ${changeStr}`, `Volume: ${quote.volume.toLocaleString()}`, - `High: $${quote.high.toFixed(2)} | Low: $${quote.low.toFixed(2)}`, - `Open: $${quote.open.toFixed(2)} | Prev Close: $${quote.prevClose.toFixed(2)}`, - `As of: ${asOf}`, + `High: ${symbol}${quote.high.toFixed(2)} | Low: ${symbol}${quote.low.toFixed(2)}`, + `Open: ${symbol}${quote.open.toFixed(2)} | Prev Close: ${symbol}${quote.prevClose.toFixed(2)}`, + lastPrice ? `Semantics: ${describeFact(lastPrice)}` : '', ].join('\n'); } + +/** Minimal structured currency-symbol lookup (falls back to the ISO code). */ +function currencySymbol(currency: string | undefined): string { + switch (currency) { + case 'USD': + return '$'; + case 'HKD': + return 'HK$'; + case 'CNY': + return '¥'; + case 'SGD': + return 'S$'; + default: + return currency ? `${currency} ` : ''; + } +} diff --git a/packages/shared/src/capabilities/manifests/phase-two.ts b/packages/shared/src/capabilities/manifests/phase-two.ts index 7e3adb8..d29aa47 100644 --- a/packages/shared/src/capabilities/manifests/phase-two.ts +++ b/packages/shared/src/capabilities/manifests/phase-two.ts @@ -1,5 +1,12 @@ import { Type } from '@sinclair/typebox'; -import type { AccountAssets, CashFlowRecord, FinanceCapability, Holding } from '@finagent/core'; +import type { + AccountAssets, + CashFlowRecord, + FinanceCapability, + FinancialFact, + Holding, +} from '@finagent/core'; +import { factsToSummary, financialReportToFacts } from '@finagent/core'; import type { CalendarEvent, CapitalFlow, @@ -186,10 +193,16 @@ export function createCompanyFinancialsCapability( input.kind ?? 'ALL', input.report ); + const fetchedAt = (ctx?.now ?? Date.now)(); + const facts = financialReportToFacts(report, { + provider: 'longbridge', + retrievedAt: Math.floor(fetchedAt / 1000), + }); return { data: report, - provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), stale: false }, - summary: `Financial statements for ${symbol}: ${formatStatementNames(report.statements)}.`, + facts, + provenance: { provider: 'longbridge', fetchedAt, stale: false }, + summary: formatFinancialsSummary(symbol, report, facts), }; }, }); @@ -475,3 +488,16 @@ function formatStatementNames( const names = Object.keys(statements); return names.length > 0 ? names.join(', ') : 'none'; } + +function formatFinancialsSummary( + symbol: string, + report: FinancialReport, + facts: FinancialFact[] +): string { + const names = formatStatementNames(report.statements); + const sample = factsToSummary(facts, 6); + return [ + `Financial statements for ${symbol}: ${names} (${facts.length} fact values).`, + sample, + ].join('\n'); +}