From 8b273a2c46aa303dba5a79d6743b3b5528c5c277 Mon Sep 17 00:00:00 2001 From: wangzhenjia Date: Tue, 22 Sep 2026 11:54:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(capabilities):=20kline/intraday/news=20?= =?UTF-8?q?=E7=9A=84=20provenance.marketTime=20=E4=BB=8E=E7=A7=92=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=AF=AB=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个 capability manifest 直接把 epoch **秒**的时间戳写进 `provenance.marketTime`, 而该字段在所有其它产出方都是 epoch **毫秒**: - `market-quote.ts:51` 写 `quote.timestamp * 1000` - longbridge adapter 的 `marketTimeMsFrom()`(`marketTimeMsFrom` 命名 + `* 1000`) - 同一对象里的 `fetchedAt` 也是 `Date.now()`(毫秒) 涉事三处(同一个根因:manifest 漏做秒→毫秒换算): ```ts // market-kline.ts:51 marketTime: klines[klines.length - 1]?.timestamp // market-intraday.ts:32 marketTime: data[data.length - 1]?.timestamp // research-news.ts:37 marketTime: news[0]?.timestamp ``` 这些时间戳确实是秒——**同文件**的格式化函数就是证据: - `market-kline.ts:66` `new Date(kline.timestamp * 1000)` - `market-intraday.ts:47` `new Date(item.timestamp * 1000)` - `research-news.ts:54` `new Date(item.timestamp * 1000)` ## 影响 `buildFinancialEvidence()`(`packages/shared/src/evidence/financial-evidence.ts:54`) 直接把 `provenance.marketTime` 当作证据信封的 `asOf`。于是 kline/intraday/news 三类证据的 `asOf` 比正确值小 1000 倍(如 1.71e9 而非 1.71e12),任何按日期渲染 或做新鲜度比较的地方都会落到 1970 年。research runner 也会把这个值原样带上 (`research/runner.ts:355`)。 ## 修复 三处统一改为 `latest.timestamp * 1000`,并在序列为空时保持 `undefined`。 范围:仅这三个 manifest 的 `marketTime` 赋值,不改 `data`、不改 summary 格式化, 也不动 phase-two 里未经证实的同类写法。无可见 UI 变化(数据正确后展示才正确)。 ## 测试 `packages/shared/src/capabilities/manifests.test.ts` 新增 4 个用例: kline / intraday / news 各自校验 `marketTime === 1710000000 * 1000`, 以及空序列时 `marketTime` 为 `undefined`。 回归证明(修复前): ``` bun test packages/shared/src/capabilities --isolate -> 37 pass / 3 fail ``` 修复后: ``` bun test packages/shared/src/capabilities --isolate -> 40 pass / 0 fail (Ran 40 tests across 8 files) bun run typecheck -> @finagent/core / i18n / shared / ui / electron 全部 exit 0 ``` 基线说明:干净 `origin/main`(`7c9b550`)上 `bun test packages/shared --isolate` 为 1069 pass / 5 fail(`ExperimentService.runExperiment` ×1、`ResearchService` ×3、 `langfuse backend` ×1)。这些失败在 main 上可复现,与本改动无关;本改动未引入 任何新的失败。 --- .../shared/src/capabilities/manifests.test.ts | 62 +++++++++++++++++++ .../capabilities/manifests/market-intraday.ts | 5 +- .../capabilities/manifests/market-kline.ts | 6 +- .../capabilities/manifests/research-news.ts | 5 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/capabilities/manifests.test.ts b/packages/shared/src/capabilities/manifests.test.ts index 20ceca51..dcc308cf 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 { createMarketIntradayCapability } from './manifests/market-intraday.ts'; +import { createResearchNewsCapability } from './manifests/research-news.ts'; import type { CapabilityFetchers } from './fetchers.ts'; const quote = { @@ -81,3 +84,62 @@ describe('market.quote manifest', () => { expect(cap.riskLevel).toBe('read'); }); }); + +// Kline / intraday / news timestamps are epoch SECONDS (each manifest's own +// formatter does `timestamp * 1000`), but `provenance.marketTime` is epoch MS +// everywhere else — `market.quote` writes `quote.timestamp * 1000` and the +// longbridge adapter's `marketTimeMsFrom()` multiplies by 1000. +const SECOND_TS = 1710000000; + +describe('provenance.marketTime is epoch milliseconds', () => { + it('market.kline converts the last bar timestamp from seconds', async () => { + const cap = createMarketKlineCapability( + fetchers({ + getKline: async () => [ + { symbol: 'AAPL.US', timestamp: SECOND_TS - 86400, open: 1, high: 1, low: 1, close: 1, volume: 1 }, + { symbol: 'AAPL.US', timestamp: SECOND_TS, open: 2, high: 2, low: 2, close: 2, volume: 2 }, + ], + }) + ); + const result = await cap.execute({ symbol: 'AAPL.US' }, { now: () => 12345 }); + expect(result.provenance.marketTime).toBe(SECOND_TS * 1000); + }); + + it('market.intraday converts the last tick timestamp from seconds', async () => { + const cap = createMarketIntradayCapability( + fetchers({ + getIntraday: async () => [ + { symbol: 'AAPL.US', timestamp: SECOND_TS - 60, price: 199, volume: 10 }, + { symbol: 'AAPL.US', timestamp: SECOND_TS, price: 200, volume: 20 }, + ], + }) + ); + const result = await cap.execute({ symbol: 'AAPL.US' }, { now: () => 12345 }); + expect(result.provenance.marketTime).toBe(SECOND_TS * 1000); + }); + + it('research.news converts the latest item timestamp from seconds', async () => { + const cap = createResearchNewsCapability( + fetchers({ + getNews: async () => [ + { + id: 'n-1', + title: 'Apple ships', + summary: 'A summary.', + url: 'https://example.com/n1', + timestamp: SECOND_TS, + symbols: ['AAPL.US'], + }, + ], + }) + ); + const result = await cap.execute({ symbol: 'AAPL.US' }, { now: () => 12345 }); + expect(result.provenance.marketTime).toBe(SECOND_TS * 1000); + }); + + it('leaves marketTime undefined when the series is empty', async () => { + const cap = createMarketKlineCapability(fetchers({ getKline: async () => [] })); + const result = await cap.execute({ symbol: 'AAPL.US' }, { now: () => 12345 }); + expect(result.provenance.marketTime).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/capabilities/manifests/market-intraday.ts b/packages/shared/src/capabilities/manifests/market-intraday.ts index 9af28d58..1170bba4 100644 --- a/packages/shared/src/capabilities/manifests/market-intraday.ts +++ b/packages/shared/src/capabilities/manifests/market-intraday.ts @@ -24,12 +24,15 @@ export function createMarketIntradayCapability( async execute(input, ctx) { const symbol = normalizeSymbol(input.symbol); const data = await fetchers.getIntraday(symbol); + // IntradayData.timestamp is epoch SECONDS (see formatIntraday below); + // provenance.marketTime is epoch MS everywhere else. + const latest = data[data.length - 1]; return { data, provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: data[data.length - 1]?.timestamp, + marketTime: latest === undefined ? undefined : latest.timestamp * 1000, stale: false, }, summary: formatIntraday(symbol, data), diff --git a/packages/shared/src/capabilities/manifests/market-kline.ts b/packages/shared/src/capabilities/manifests/market-kline.ts index 280c50d9..46a39d32 100644 --- a/packages/shared/src/capabilities/manifests/market-kline.ts +++ b/packages/shared/src/capabilities/manifests/market-kline.ts @@ -43,12 +43,16 @@ export function createMarketKlineCapability( const period = input.period ?? '1d'; const limit = input.limit ?? 100; const klines = await fetchers.getKline({ symbol, period, limit }); + // Kline.timestamp is epoch SECONDS (see formatKline below); every other + // producer writes provenance.marketTime in epoch MS (market.quote does + // `timestamp * 1000`, longbridge's marketTimeMsFrom() the same). + const latest = klines[klines.length - 1]; return { data: klines, provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: klines[klines.length - 1]?.timestamp, + marketTime: latest === undefined ? undefined : latest.timestamp * 1000, stale: false, }, summary: formatKline(symbol, period, klines), diff --git a/packages/shared/src/capabilities/manifests/research-news.ts b/packages/shared/src/capabilities/manifests/research-news.ts index f55eb188..44374df6 100644 --- a/packages/shared/src/capabilities/manifests/research-news.ts +++ b/packages/shared/src/capabilities/manifests/research-news.ts @@ -29,12 +29,15 @@ export function createResearchNewsCapability( // summaries, research data bundles, Copilot tool results) sees // neutralized text only. const news = sanitizeNewsItems(await fetchers.getNews(symbol)); + // NewsItem.timestamp is epoch SECONDS (see formatNews below); + // provenance.marketTime is epoch MS everywhere else. + const latest = news[0]; return { data: news, provenance: { provider: 'longbridge', fetchedAt: (ctx?.now ?? Date.now)(), - marketTime: news[0]?.timestamp, + marketTime: latest === undefined ? undefined : latest.timestamp * 1000, stale: false, }, summary: formatNews(symbol, news),