diff --git a/.gitignore b/.gitignore index efeef59b..d6495e28 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ docs/TradingView级别像素对齐技术.md # Agents .agents/ +.worktrees/ # Next.js examples examples/next-app/.next/ diff --git a/AGENTS.md b/AGENTS.md index e7b97424..34d2ee23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,12 +177,9 @@ Never guess at Effect patterns - check the guide first. ## Comment Style -- Language: body in Chinese; technical terms keep English (Signal, batch, computed, Action, etc.) -- No Markdown symbols: no backticks, bold, or arrows. Use natural language for code references. -- Prefer JSDoc tags: `@remarks` (detailed explanation), `@param`, `@returns`, `@typeParam`, `@example`. -- Inline comments: `/** brief */` or `// brief`, no tags needed. -- **Say what it is, not what to do with it** -- **Never invent Chinese jargon** -- **表述直接,不说倒装话**:说"合并后写入 X",不说"写入 X 前先在此合并"。注释要一眼看完就能理解,不要兜圈子 -- **One sentence if possible** -- **分支注释写在 `if` 上一行**,不要写到分支内部 +- 每个文件必须有头部注释,说明文件用途。 +- 每个函数必须有注释,说明其职责、参数和返回值;简单函数可使用简短注释。 +- 关键代码必须有注释,说明实现意图、业务规则或不直观的处理逻辑。 +- 每个测试用例必须有中文注释,说明验证的行为和场景。 +- 注释正文使用中文,技术术语保留英文。 +- 注释必须简单明了,直接说明代码是什么或为什么这样实现,尽量使用一句话,避免冗长和重复代码本身的含义。 diff --git a/docs/superpowers/plans/2026-07-28-instance-market-session-registry.md b/docs/superpowers/plans/2026-07-28-instance-market-session-registry.md new file mode 100644 index 00000000..54bcd20d --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-instance-market-session-registry.md @@ -0,0 +1,80 @@ +# Instance Market Session Registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add strict chart-instance market session resolution and normalize gotdx symbols into the unified market model. + +**Architecture:** `SymbolSpec.market` is required. Each Chart owns a `MarketSessionRegistry`; time-share activation resolves its session before changing chart state. gotdx search converts private market/category metadata into `CN` or `HK`; core never examines fetcher params. + +**Tech Stack:** TypeScript, Vitest, Effect, pnpm workspace + +--- + +### Task 1: Market Session Registry + +**Files:** +- Create: `packages/core/src/engine/market/marketSessionRegistry.ts` +- Create: `packages/core/src/engine/market/__tests__/marketSessionRegistry.test.ts` +- Modify: `packages/core/src/index.ts` + +- [ ] Write tests proving built-ins resolve, unknown IDs throw, invalid configs throw, and two registries are isolated. +- [ ] Run the focused test and confirm failure because the registry module is missing. +- [ ] Implement `MarketSessionRegistry`, built-in CN/HK/US entries, validation, and `getRequired`. +- [ ] Export the registry and market session types from core. +- [ ] Run the focused test and confirm it passes. + +### Task 2: Strict Unified Symbol Market + +**Files:** +- Modify: `packages/core/src/controllers/types.ts` +- Modify: all repository `SymbolSpec` and `SymbolInfo` construction sites reported by type-check. +- Test: `packages/core/src/engine/market/__tests__/marketSessionRegistry.test.ts` + +- [ ] Add compile/runtime tests showing blank market is rejected. +- [ ] Make `market` required on `SymbolSpec` and `SymbolInfo`. +- [ ] Update fixtures and explicit inline/custom symbol construction with a deliberate market; do not infer from exchange or code. +- [ ] Run `pnpm type-check` and resolve only missing unified-market construction errors. + +### Task 3: Chart Instance Integration + +**Files:** +- Modify: `packages/core/src/controllers/types.ts` (`ChartMountOptions`) +- Modify: `packages/core/src/controllers/createChartController.ts` +- Modify: `packages/core/src/engine/chart.ts` +- Test: `packages/core/src/engine/modes/__tests__/timeShareMode.test.ts` or a focused Chart test + +- [ ] Write failing tests proving HK symbols select `HK_MARKET_SESSION`, unknown markets throw before fetch, and two Chart registries do not share overrides. +- [ ] Add `marketSessions` to mount options and instantiate one registry per Chart. +- [ ] In `Chart.setSymbols`, validate market and resolve/apply the time-share session before `setActiveMode` and data loading. +- [ ] Run focused tests and confirm pass. + +### Task 4: gotdx Market Normalization + +**Files:** +- Modify: `packages/core/src/data/gotdx.ts` +- Modify: `packages/core/src/data/types.ts` +- Test: `packages/core/src/data/__tests__/gotdx.test.ts` + +- [ ] Write failing tests for main-market to `CN`, HK category entries to `HK`, and unsupported metadata rejection. +- [ ] Extend `SearchResult` with required unified `market`. +- [ ] Normalize the raw gotdx response inside `searchGotdx`; preserve private params unchanged. +- [ ] Run gotdx tests and confirm pass. + +### Task 5: UI Search Propagation + +**Files:** +- Modify: `packages/vue/src/composables/useSymbolSearch.ts` +- Modify: Vue symbol conversion sites in `packages/vue/src/components/KLineChart.vue` +- Test: `packages/vue/src/composables/__tests__/useSymbolSearch.test.ts` + +- [ ] Write failing tests proving `market` survives catalog/search selection into `SymbolSpec`. +- [ ] Propagate the already-normalized field without deriving it from exchange or params. +- [ ] Run Vue focused tests and confirm pass. + +### Task 6: Verification + +- [ ] Run `pnpm --filter @363045841yyt/klinechart-core test`. +- [ ] Run `pnpm --filter @363045841yyt/klinechart test`. +- [ ] Run `pnpm type-check`. +- [ ] Run `pnpm test:packages` if focused suites and type-check pass. +- [ ] Inspect diffs in both repositories and report any unrelated existing changes without modifying them. diff --git a/docs/superpowers/plans/2026-07-28-symbol-chip-fetch-error-title.md b/docs/superpowers/plans/2026-07-28-symbol-chip-fetch-error-title.md new file mode 100644 index 00000000..2a6d000a --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-symbol-chip-fetch-error-title.md @@ -0,0 +1,268 @@ +# 品种 Chip 拉取错误 Title 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 主品种 K 线 Effect 显式失败时,品种 chip 悬停通过原生 `title` 显示失败原因。 + +**Architecture:** `DataBuffer` 持有 `lastError` 只读信号;失败写入、成功/换品种/dispose 清空。`ChartDataManager`/`Chart`/`ChartController` 暴露 `dataError`。Vue 订阅后把文案传到 `SymbolSelector` 的 `title`。 + +**Tech Stack:** TypeScript、Effect、vitest、Vue 3 SFC + +**Spec:** `docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md` + +--- + +### Task 1: DataBuffer.lastError + +**Files:** +- Modify: `packages/core/src/data/dataBufferTypes.ts` +- Modify: `packages/core/src/data/dataBuffer.ts` +- Test: `packages/core/src/data/__tests__/dataBuffer.test.ts` + +- [ ] **Step 1: 写失败测试** + +在 `dataBuffer.test.ts` 增加(`defaultSpec` 补 `market: 'CN'` 若测试需要): + +```ts +it('records lastError when fetch fails after retries', async () => { + const fetcher: DataFetcher = async () => { + throw new Error('[gotdx] stock/kline-by-date failed: 500') + } + buffer.setFetcher(fetcher) + buffer.setSymbol({ ...defaultSpec, market: 'CN' }) + + await vi.waitFor(() => expect(buffer.loading()).toBe(false), { timeout: 10_000 }) + expect(buffer.lastError()).toBe('[gotdx] stock/kline-by-date failed: 500') +}) + +it('clears lastError on successful fetch', async () => { + let fail = true + const fetcher: DataFetcher = async () => { + if (fail) throw new Error('offline') + return [makeKLine(Date.now())] + } + buffer.setFetcher(fetcher) + buffer.setSymbol({ ...defaultSpec, market: 'CN' }) + await vi.waitFor(() => expect(buffer.lastError()).toBe('offline'), { timeout: 10_000 }) + + fail = false + buffer.setSymbol({ ...defaultSpec, market: 'CN', symbol: 'sh.600001' }) + await vi.waitFor(() => { + expect(buffer.loading()).toBe(false) + expect(buffer.data().data.length).toBe(1) + }) + expect(buffer.lastError()).toBeNull() +}) + +it('does not set lastError for successful empty data', async () => { + buffer.setFetcher(async () => []) + buffer.setSymbol({ ...defaultSpec, market: 'CN' }) + await vi.waitFor(() => expect(buffer.loading()).toBe(false)) + expect(buffer.lastError()).toBeNull() +}) + +it('clears lastError on setInlineData', async () => { + buffer.setFetcher(async () => { + throw new Error('boom') + }) + buffer.setSymbol({ ...defaultSpec, market: 'CN' }) + await vi.waitFor(() => expect(buffer.lastError()).toBe('boom'), { timeout: 10_000 }) + buffer.setInlineData([makeKLine(Date.now())]) + expect(buffer.lastError()).toBeNull() +}) +``` + +- [ ] **Step 2: 跑测试确认 RED** + +```bash +pnpm exec vitest run src/data/__tests__/dataBuffer.test.ts +``` + +Expected: FAIL — `lastError` 不存在 + +- [ ] **Step 3: 最小实现** + +`dataBufferTypes.ts` 的 `DataBufferLike` / `KLineBuffer` 增加: + +```ts +readonly lastError: ReadonlySignal +``` + +`dataBuffer.ts`: + +```ts +private _lastError = createSignal(null) + +get lastError(): ReadonlySignal { + return this._lastError +} +``` + +- `setSymbol` / `setInlineData` / `dispose`:`this._lastError.set(null)` +- `_fetchAndMerge` 成功 merge 后:`this._lastError.set(null)` +- `.catch(err)`:若 `requestVersion === this._requestVersion`, + `this._lastError.set(err instanceof Error && err.message ? err.message : err ? String(err) : '加载失败')` + +注意:`FetchScheduler.run` 的 catch 目前丢弃 err;需把 `run` 的 reject 原因传到外层 catch,或在 task 内 try/catch 写入 lastError。优先在 `_fetchAndMerge` 的 task 内 try/catch: + +```ts +this._scheduler + .run(async () => { + try { + const incoming = await fetchEffect() + if (disposed() || requestVersion !== this._requestVersion) return + this._lastError.set(null) + // merge ... + } catch (err) { + if (disposed() || requestVersion !== this._requestVersion) return + const message = + err instanceof Error && err.message.trim() + ? err.message + : err != null && String(err).trim() + ? String(err) + : '加载失败' + this._lastError.set(message) + this._inflightBoundary = null + this._pendingRequestStartTs = null + } + }) +``` + +- [ ] **Step 4: 跑测试确认 GREEN** + +```bash +pnpm exec vitest run src/data/__tests__/dataBuffer.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/data/dataBuffer.ts packages/core/src/data/dataBufferTypes.ts packages/core/src/data/__tests__/dataBuffer.test.ts +git commit -m "feat(core): record DataBuffer lastError on fetch failure" +``` + +--- + +### Task 2: Chart / Controller 暴露 dataError + +**Files:** +- Modify: `packages/core/src/engine/data/chartDataManager.ts` +- Modify: `packages/core/src/engine/chart.ts` +- Modify: `packages/core/src/controllers/types.ts` +- Modify: `packages/core/src/controllers/createChartController.ts` +- Test: `packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts` 或新增小测试 + +- [ ] **Step 1: 写失败测试** + +在 incrementalLoad 或新建测试中:失败 fetcher 后 `manager`/`chart` 的 `dataError.peek()` 等于错误 message;成功后为 null。 + +- [ ] **Step 2: RED** + +- [ ] **Step 3: 实现** + +`ChartDataManager`: + +```ts +get dataError(): ReadonlySignal { + const buf = this.getActiveDataBuffer() + return (buf?.lastError ?? createSignal(null)) as ReadonlySignal +} +``` + +注意:active buffer 切换时,若直接返回 buffer 信号引用会变。更稳妥:在 `dataState` 增加 `error: string | null`,在 `publishBufferSnapshot` / loading/data 事件时同步 `buf.lastError.peek()`;或 ChartDataManager 维护桥接 signal,在 bindActiveBuffer 时订阅 `buf.lastError`。 + +推荐桥接(与 loading 镜像一致): + +- `bindActiveBuffer` 额外 `buf.lastError.subscribe` → 写入 `_dataState.actions.setError(...)` +- 或独立 `_errorSignal` 在 chartDataManager 内 + +最小路径:`Chart.dataError` 每次 `get` 返回 active buffer 的 `lastError`;Vue 在 `dataLoading` 订阅回调里 `peek()` 一次即可。但 `ChartController` 需要稳定 `ReadonlySignal`。 + +稳定方案: + +```ts +// chartDataManager +private _dataError = createSignal(null) + +private syncDataErrorFromBuffer(buf: KLineBuffer | TimeShareBuffer | null): void { + const err = + buf && 'lastError' in buf + ? ((buf as KLineBuffer).lastError?.peek() ?? null) + : null + this._dataError.set(err) +} + +// 在 publishBufferSnapshot / bind / handle loading|data 后调用 sync +get dataError(): ReadonlySignal { + return this._dataError +} +``` + +`Chart`:`get dataError() { return this.dataManager.dataError }` +`ChartController`:`readonly dataError: ReadonlySignal` +`createChartController`:`dataError: chart.dataError` + +- [ ] **Step 4: GREEN + commit** + +```bash +git commit -m "feat(core): expose chart dataError signal" +``` + +--- + +### Task 3: Vue chip title 接线 + +**Files:** +- Modify: `packages/vue/src/components/SymbolSelector.vue` +- Modify: `packages/vue/src/components/TopToolbar.vue` +- Modify: `packages/vue/src/components/KLineChart.vue` +- Test: 新增 `packages/vue/src/components/__tests__/SymbolSelector.errorTitle.test.ts`(若 vue 包已有 component test 模式);否则用纯函数/小测验证 title 计算,或 mount SymbolSelector + +- [ ] **Step 1: SymbolSelector 失败测试** + +```ts +// title 计算:error && errorMessage ? errorMessage : displayText +it('uses errorMessage as title when error is true', () => { + // mount SymbolSelector with error=true, errorMessage='offline', symbol display + // expect button title === 'offline' +}) +``` + +- [ ] **Step 2: RED → 实现 props `errorMessage?: string`,`:title="error && errorMessage ? errorMessage : displayText"`** + +- [ ] **Step 3: TopToolbar 增加 `symbolErrorMessage?: string` 传给 SymbolSelector** + +- [ ] **Step 4: KLineChart 订阅 `ctrl.dataError`,维护 `symbolErrorMessage`,传给 TopToolbar** + +在现有 `unsubscribeDataLoading` 旁: + +```ts +symbolErrorMessage.value = ctrl.dataError.peek() +const unsubscribeDataError = ctrl.dataError.subscribe(() => { + symbolErrorMessage.value = ctrl.dataError.peek() +}) +``` + +destroy 时 unsub。 + +- [ ] **Step 5: GREEN + commit** + +```bash +git commit -m "feat(vue): show fetch error reason on symbol chip title" +``` + +--- + +### Task 4: 回归验证 + +- [ ] `pnpm --filter @363045841yyt/klinechart-core test` +- [ ] `pnpm --filter @363045841yyt/klinechart-core build` +- [ ] 相关 vue 测试(若有) +- [ ] 提交中文设计/计划文档(若尚未提交) + +```bash +git add docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md docs/superpowers/plans/2026-07-28-symbol-chip-fetch-error-title.md +git commit -m "docs: 中文 chip 错误 title 设计与实现计划" +``` diff --git a/docs/superpowers/specs/2026-07-28-instance-market-session-registry-design.md b/docs/superpowers/specs/2026-07-28-instance-market-session-registry-design.md new file mode 100644 index 00000000..0263c4ee --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-instance-market-session-registry-design.md @@ -0,0 +1,125 @@ +# Instance Market Session Registry Design + +## Goal + +Make market identity part of the chart's unified symbol model and resolve time-share trading sessions through a chart-instance registry. Data-source-specific fields remain inside fetchers. Missing or unsupported market metadata must fail explicitly; the chart never guesses or falls back to A-share rules. + +## Scope + +- Add required `market: string` to the unified `SymbolSpec` model. +- Add a market-session registry owned by each Chart instance. +- Resolve the active time-share session from `SymbolSpec.market` before loading data. +- Add built-in CN, HK, and US session definitions to each instance unless the caller supplies replacements. +- Adapt only the gotdx fetcher/search boundary to produce unified market IDs. +- Do not infer market from symbol code, exchange, returned bars, or existing chart state. +- Do not add market normalization to other fetchers in this change. Their output must already provide a valid unified market or fail at the normalization boundary. + +## Unified Model + +```ts +export interface SymbolSpec { + symbol: string + market: string + exchange?: string + period?: string + adjust?: string + source?: string + params?: DataSourceParams + startDate?: string + endDate?: string + incremental?: boolean +} +``` + +`market` is a chart-domain identifier. `exchange` is display or venue metadata. `params` is private fetcher input. Neither `exchange` nor `params` may control chart behavior. + +`SymbolInfo` and search results that can become a `SymbolSpec` also carry the normalized `market` value. Conversion into `SymbolSpec` validates it before calling `setSymbols`. + +## Instance Registry + +Each Chart creates its own `MarketSessionRegistry`. There is no mutable global registry. + +```ts +interface MarketSessionRegistry { + register(market: string, config: MarketSessionConfig): void + getRequired(market: string): MarketSessionConfig +} +``` + +The registry validates non-empty market IDs and valid session configurations. `getRequired` throws a descriptive error for unknown IDs. + +Each instance starts with CN, HK, and US definitions copied into its own registry. Instance registration may add or replace definitions without affecting another chart. + +Chart creation options expose instance configuration: + +```ts +type ChartMountOptions = { + marketSessions?: Readonly> + // existing options +} +``` + +Caller entries override built-ins only for that Chart instance. + +## Time-Share Flow + +`Chart.setSymbols` validates the primary symbol before changing mode or loading data: + +1. Reject a missing or blank `SymbolSpec.market`. +2. For `period === 'timeshare'`, resolve the session with `registry.getRequired(spec.market)`. +3. Apply the resolved config to `TimeShareMode`. +4. Activate time-share mode and load the buffer. + +Unknown markets throw before the request starts. The previous chart mode and session remain unchanged when validation fails. + +K-line rendering does not consume session configuration, but all symbols still require `market` to preserve one complete data model. + +## gotdx Normalization + +gotdx search responses are normalized at the gotdx fetcher boundary: + +- Main-market `params.market` values 0, 1, and 2 map to `market: 'CN'`. +- Extended `exchange: 'HK'` entries with supported gotdx Hong Kong categories map to `market: 'HK'`. +- Unsupported or contradictory gotdx metadata throws a descriptive normalization error. + +The chart never reads gotdx `params.market`, `params.category`, or `params.kind`. + +The gotdx time-share request continues to route privately: + +- `params.category` uses `/api/ex/history-tick`. +- `params.market` uses `/api/stock/history-tick`. + +Those parameters affect only network routing and are not chart market identity. + +## Other Fetchers + +No other fetcher receives source-specific mapping in this change. Any path that converts another fetcher's search result or configuration into `SymbolSpec` must require an already-normalized `market`; otherwise it throws before `setSymbols`. + +This prevents silent partial support while keeping the implementation scope limited to gotdx. + +## Errors + +Failures are explicit and deterministic: + +- Missing market: `SymbolSpec.market is required for `. +- Unknown registry key: `Market session is not registered: `. +- gotdx cannot normalize metadata: include symbol and relevant private params. +- Invalid custom session: reject during registry registration. + +There is no CN default and no inference from `exchange` inside core. + +## Testing + +Use TDD for each behavior: + +- Registry instances are isolated. +- Built-in CN, HK, and US sessions resolve correctly. +- Missing and unknown markets throw before fetching. +- HK time-share selects 330 one-minute slots and Hong Kong axis endpoints. +- Switching HK to CN replaces the active session correctly. +- gotdx main-market search normalizes to CN. +- gotdx Hong Kong search normalizes to HK. +- gotdx unsupported metadata throws. +- Existing gotdx category/market request routing remains covered. + +Run focused core tests first, then the core package suite and root type-check relevant to changed public types. diff --git a/docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md b/docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md new file mode 100644 index 00000000..c1eb0e65 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-symbol-chip-fetch-error-title-design.md @@ -0,0 +1,105 @@ +# 品种 Chip 拉取错误 Title 设计 + +## 目标 + +主品种 K 线拉取因 Effect 显式失败时,Vue 品种 chip 悬停须通过原生 `title` 显示失败原因。用户无需打开控制台即可理解警告图标含义。 + +## 范围 + +- 仅主品种 K 线拉取。 +- 传播 Effect 显式失败:网络错误、HTTP/fetcher 的 `FETCH_FAILED`、超时、缺少 source,以及重试后仍 reject 的拉取 Promise。 +- 成功但返回空数组 `[]` 仍为非致命 warning,不写入 chip 错误原因。 +- 仅使用浏览器原生 `title`;不做自定义 tooltip 组件。 +- 本次不做:TimeShareBuffer、对比品种 chip、搜索结果错误、自定义气泡 UI。 + +## 问题 + +现状: + +1. `DataBuffer` 捕获拉取失败后只清理 inflight 状态。 +2. Vue 的 `symbolStatus` 在 loading 结束且无数据时变为 `'error'`。 +3. `SymbolSelector` 在 `error === true` 时显示警告图标。 +4. chip 的 `title` 始终是品种展示名,从不显示失败原因。 + +因此警告图标没有面向用户的解释。 + +## 设计 + +### Core:由 buffer 持有 lastError + +`DataBuffer` 内部维护可写错误信号,对外暴露只读: + +```ts +readonly lastError: ReadonlySignal +``` + +规则: + +- Effect 在重试/超时后仍显式失败时,将 `lastError` 设为可读的错误 message。 +- 优先使用 `Error.message`;否则使用 `String(error)`。 +- 任意一次拉取成功 merge(含空 `[]`)时,将 `lastError` 清为 `null`。 +- 在 `setSymbol`、`setInlineData`、`dispose` 时将 `lastError` 清为 `null`。 +- 过期请求的失败不得覆盖当前请求的错误,也不得清除更新请求的成功状态。 + +`KLineBuffer` / `DataBufferLike` 同步暴露同一只读信号,避免消费者向下转型。 + +### Core:Chart 对外表面 + +`ChartDataManager` 与 `Chart` 暴露: + +```ts +readonly dataError: ReadonlySignal +``` + +读取当前主 K 线 buffer 的 `lastError`。无活动 K 线 buffer 时为 `null`。 + +不走全局 EventBus。错误状态留在 data buffer 生命周期内。 + +### Vue:chip title + +`KLineChart` 订阅 `ctrl.dataError`(或等价 controller 暴露),维护本地 `symbolErrorMessage: string | null`。 + +传递链路: + +1. `KLineChart` → `TopToolbar` 的 `symbolErrorMessage` +2. `TopToolbar` → `SymbolSelector` 的 `errorMessage` + +`SymbolSelector` chip title: + +- 若 `error && errorMessage`:使用 `errorMessage` +- 否则:保持现有 `displayText` + +警告图标仍由现有布尔 `error` 控制。本改动不新增第二套视觉状态机,只为悬停提供原因文案。 + +`symbolStatus === 'error'` 仍可由 loading 结束且无数据推断,用于图标可见性。title 原因在存在 Effect 显式失败时必须来自 `lastError` / `dataError`,不得写死通用文案。 + +若因空数据显示图标但 `lastError` 为 null(成功空拉取),title 可仍为品种展示名。空数据不是 Effect 显式失败。 + +## 文案质量 + +UI 层不编造营销文案。直接透出 Effect/fetcher 边界已有的失败 message,例如: + +- `[gotdx] stock/kline-by-date failed: 500 Internal Server Error` +- `[DataBuffer] source is required for symbol "..."` +- Effect timeout 产生的超时文案 + +规范化后 message 为空时,回退为 `加载失败`。 + +## 测试 + +- DataBuffer 单元测试: + - 失败拉取将 `lastError` 设为错误 message + - 成功拉取清空 `lastError` + - `setSymbol` / `setInlineData` 清空 `lastError` + - 成功空 `[]` 不设置 `lastError` + - 过期 reject 不覆盖更新成功请求 +- Vue 接线测试或组件级断言: + - 提供 error message 时 chip `title` 等于该文案 + - 非错误时 chip `title` 仍为展示名 + +## 非目标 + +- 自定义样式 tooltip +- 本地化/重写每条 fetcher message +- 对比或分时错误 chip +- 将空数据策略改回硬失败 diff --git a/packages/ai-runtime/src/__tests__/executeTool.test.ts b/packages/ai-runtime/src/__tests__/executeTool.test.ts index 16e1b0c6..8a627e2c 100644 --- a/packages/ai-runtime/src/__tests__/executeTool.test.ts +++ b/packages/ai-runtime/src/__tests__/executeTool.test.ts @@ -16,6 +16,7 @@ function createMockChart(overrides?: Partial): ChartController viewport: stubSignal({} as any), data: stubSignal([]), dataLoading: stubSignal(false), + dataError: stubSignal(null), symbols: stubSignal([]), theme: stubSignal('light'), settings: stubSignal({} as any), diff --git a/packages/angular/src/__tests__/_mockController.ts b/packages/angular/src/__tests__/_mockController.ts index 3f6c4c81..ad4b1ebd 100644 --- a/packages/angular/src/__tests__/_mockController.ts +++ b/packages/angular/src/__tests__/_mockController.ts @@ -93,6 +93,7 @@ export function createMockChartController( paneRatios, paneLayout, dataLoading: createSignal(false), + dataError: createSignal(null), symbols: createSignal([] as ReadonlyArray), comparisonColors: createSignal>(new Map()), comparisonLoading: createSignal(false), diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index a2fb71c4..3d45bb92 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -154,6 +154,7 @@ export class KLineChartComponent implements AfterViewInit, OnChanges, OnDestroy @Input() data: ReadonlyArray = [] @Input() symbols: ReadonlyArray | undefined = undefined @Input() dataFetcher: DataFetcher | undefined = undefined + @Input() marketSessions: ChartMountOptions['marketSessions'] = undefined @Input() theme: 'light' | 'dark' | undefined = undefined @Input() settings: Partial | undefined = undefined @Input() initialZoomLevel: number | undefined = undefined @@ -198,6 +199,7 @@ export class KLineChartComponent implements AfterViewInit, OnChanges, OnDestroy data: this.data, symbols: this.symbols, dataFetcher: this.dataFetcher, + marketSessions: this.marketSessions, settings: this.settings, initialZoomLevel: this.initialZoomLevel, zoomLevels: this.zoomLevels, diff --git a/packages/core/src/controllers/createChartController.ts b/packages/core/src/controllers/createChartController.ts index 35604dc9..32580c4d 100644 --- a/packages/core/src/controllers/createChartController.ts +++ b/packages/core/src/controllers/createChartController.ts @@ -352,7 +352,7 @@ export async function createChartController(opts: ChartMountOptions): Promise chart.indicators().map(mapIndicatorInstance)) @@ -908,6 +909,7 @@ export async function createChartController(opts: ChartMountOptions): Promise> // Pre-existing DOM elements (skip buildDom when provided) canvasLayer?: HTMLElement @@ -297,6 +304,8 @@ export interface ChartController extends DrawingChartAdapter { readonly viewport: ReadonlySignal readonly data: ReadonlySignal> readonly dataLoading: ReadonlySignal + /** 主品种最近一次显式拉取失败原因;成功或重置后为 null */ + readonly dataError: ReadonlySignal readonly symbols: ReadonlySignal> readonly theme: ReadonlySignal<'light' | 'dark'> /** 用户偏好 settings(kernel.settings resolved 快照) */ diff --git a/packages/core/src/data/__tests__/dataBuffer.test.ts b/packages/core/src/data/__tests__/dataBuffer.test.ts index 54bef993..da04a48a 100644 --- a/packages/core/src/data/__tests__/dataBuffer.test.ts +++ b/packages/core/src/data/__tests__/dataBuffer.test.ts @@ -583,4 +583,51 @@ describe('DataBuffer', () => { expect(buffer.getDayKeys()![i]).toBe(expectedDayKey(allData[i]!.timestamp)) } }) + + it('records lastError when fetch fails after retries', async () => { + const fetcher: DataFetcher = async () => { + throw new Error('[gotdx] stock/kline-by-date failed: 500') + } + buffer.setFetcher(fetcher) + buffer.setSymbol(defaultSpec) + + await vi.waitFor(() => expect(buffer.loading()).toBe(false), { timeout: 10_000 }) + expect(buffer.lastError()).toBe('[gotdx] stock/kline-by-date failed: 500') + }) + + it('clears lastError on successful fetch', async () => { + let fail = true + const fetcher: DataFetcher = async () => { + if (fail) throw new Error('offline') + return [makeKLine(Date.now())] + } + buffer.setFetcher(fetcher) + buffer.setSymbol(defaultSpec) + await vi.waitFor(() => expect(buffer.lastError()).toBe('offline'), { timeout: 10_000 }) + + fail = false + buffer.setSymbol({ ...defaultSpec, symbol: 'sh.600001' }) + await vi.waitFor(() => { + expect(buffer.loading()).toBe(false) + expect(buffer.data().data.length).toBe(1) + }) + expect(buffer.lastError()).toBeNull() + }) + + it('sets lastError to 暂无K线数据 for successful empty data', async () => { + buffer.setFetcher(async () => []) + buffer.setSymbol(defaultSpec) + await vi.waitFor(() => expect(buffer.loading()).toBe(false)) + expect(buffer.lastError()).toBe('暂无K线数据') + }) + + it('clears lastError on setInlineData', async () => { + buffer.setFetcher(async () => { + throw new Error('boom') + }) + buffer.setSymbol(defaultSpec) + await vi.waitFor(() => expect(buffer.lastError()).toBe('boom'), { timeout: 10_000 }) + buffer.setInlineData([makeKLine(Date.now())]) + expect(buffer.lastError()).toBeNull() + }) }) diff --git a/packages/core/src/data/__tests__/fetcherRegistry.test.ts b/packages/core/src/data/__tests__/fetcherRegistry.test.ts index 297818ef..034aa59f 100644 --- a/packages/core/src/data/__tests__/fetcherRegistry.test.ts +++ b/packages/core/src/data/__tests__/fetcherRegistry.test.ts @@ -276,6 +276,7 @@ describe('search fetcher registry and router', () => { [ { symbol: '600519', + market: 'CN', description: '贵州茅台', exchange: 'SH', source: 'gotdx', @@ -292,6 +293,7 @@ describe('search fetcher registry and router', () => { [ { symbol: '600519', + market: 'CN', description: '贵州茅台', exchange: 'SH', source: 'gotdx', @@ -299,6 +301,7 @@ describe('search fetcher registry and router', () => { }, { symbol: '00700', + market: 'HK', description: '腾讯控股', exchange: 'HK', source: 'gotdx', @@ -311,6 +314,7 @@ describe('search fetcher registry and router', () => { await expect(routerSearchFetchers({ query: '股', limit: 10 })).resolves.toEqual([ { symbol: '600519', + market: 'CN', description: '贵州茅台', exchange: 'SH', source: 'gotdx', @@ -318,6 +322,7 @@ describe('search fetcher registry and router', () => { }, { symbol: '00700', + market: 'HK', description: '腾讯控股', exchange: 'HK', source: 'gotdx', @@ -326,6 +331,34 @@ describe('search fetcher registry and router', () => { ]) }) + it('keeps otherwise identical search results from different unified markets', async () => { + @DataFetcher({ name: 'multi-market', displayName: 'Multi Market', capabilities: ['search'] }) + class MultiMarketFetcher { + static fetcher = fetchFn + static searcher: SearchFetcherFn = async () => [ + { + symbol: '000001', + market: 'CN', + description: 'CN symbol', + exchange: 'X', + source: 'normalized', + }, + { + symbol: '000001', + market: 'HK', + description: 'HK symbol', + exchange: 'X', + source: 'normalized', + }, + ] + } + void MultiMarketFetcher + + const results = await routerSearchFetchers({ query: '000001' }) + + expect(results.map((item) => item.market)).toEqual(['CN', 'HK']) + }) + it('returns successful results when another searcher fails', async () => { @DataFetcher({ name: 'failed', displayName: 'Failed', capabilities: ['search'] }) class FailedFetcher { diff --git a/packages/core/src/data/__tests__/gotdx.test.ts b/packages/core/src/data/__tests__/gotdx.test.ts index edd8fd4a..2ea39b58 100644 --- a/packages/core/src/data/__tests__/gotdx.test.ts +++ b/packages/core/src/data/__tests__/gotdx.test.ts @@ -44,6 +44,7 @@ describe('gotdx fetcher', () => { symbol: '600519', description: '贵州茅台', exchange: 'SH', + market: 'CN', source: 'gotdx', params: { market: 1 }, }, @@ -57,6 +58,103 @@ describe('gotdx fetcher', () => { ) }) + it('normalizes Hong Kong extended symbols to the unified HK market', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + symbol: '01810', + description: '小米集团-W', + exchange: 'HK', + source: 'gotdx', + params: { category: 31, kind: 'ex' }, + }, + ]), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect(definition?.searcher?.('gotdx', { query: '01810' })).resolves.toEqual([ + expect.objectContaining({ symbol: '01810', market: 'HK' }), + ]) + }) + + it('normalizes mainland fund extended symbols to the unified CN market', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + symbol: '003760', + description: '国泰中证500A', + exchange: 'FUND', + source: 'gotdx', + params: { category: 33, kind: 'ex' }, + }, + ]), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect(definition?.searcher?.('gotdx', { query: '国泰中' })).resolves.toEqual([ + expect.objectContaining({ + symbol: '003760', + market: 'CN', + exchange: 'FUND', + params: { category: 33, kind: 'ex' }, + }), + ]) + }) + + it('returns all search rows and leaves unsupported markets empty', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + symbol: '01810', + description: '小米集团-W', + exchange: 'HK', + source: 'gotdx', + params: { category: 31, kind: 'ex' }, + }, + { + symbol: 'IF2608', + description: '沪深300期货', + exchange: 'FUTURES', + source: 'gotdx', + params: { category: 47, kind: 'ex' }, + }, + ]), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect(definition?.searcher?.('gotdx', { query: '01810' })).resolves.toEqual([ + expect.objectContaining({ symbol: '01810', market: 'HK' }), + expect.objectContaining({ symbol: 'IF2608', market: '', exchange: 'FUTURES' }), + ]) + }) + + it('keeps unmapped gotdx search rows with empty market instead of failing search', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + { + symbol: 'IF2608', + description: '沪深300期货', + exchange: 'FUTURES', + source: 'gotdx', + params: { category: 47, kind: 'ex' }, + }, + { + symbol: 'CBA07501', + description: '同业存单总指数', + exchange: 'EX-0', + source: 'gotdx', + params: { category: 0, kind: 'ex' }, + }, + ]), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect(definition?.searcher?.('gotdx', { query: '同业存单' })).resolves.toEqual([ + expect.objectContaining({ symbol: 'IF2608', market: '' }), + expect.objectContaining({ symbol: 'CBA07501', market: '', exchange: 'EX-0' }), + ]) + }) + it('uses params.market for stock requests', async () => { fetchMock.mockResolvedValue(jsonResponse([])) const definition = getRegisteredFetcher('gotdx') @@ -173,6 +271,147 @@ describe('gotdx fetcher', () => { ) }) + it('maps extended-market timeshare without unverified metrics', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + preClose: 18.5, + data: [{ timestamp: '2026-07-24T09:30:00+08:00', Price: 18.6, Avg: 18.55 }], + }), + ) + const definition = getRegisteredFetcher('gotdx') + + const result = await definition?.timeShareFetcher?.('gotdx', { + symbol: '01810', + exchange: 'HK', + params: { category: 31, kind: 'ex' }, + date: 20260724, + }) + + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://127.0.0.1:8080/api/ex/history-tick') + expect(JSON.parse(String(init?.body))).toMatchObject({ + category: 31, + code: '01810', + date: 20260724, + }) + expect(result).toEqual({ + preClose: 18.5, + data: [ + { + timestamp: new Date('2026-07-24T09:30:00+08:00').getTime(), + price: 18.6, + average: 18.55, + }, + ], + }) + }) + + it('maps stock volume and index amount without synthesizing the missing metric', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + preClose: 8.3, + data: [{ timestamp: '2026-07-30T09:30:00+08:00', Price: 8.7, Avg: 8.7, Volume: 7101 }], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + preClose: 3828.47, + data: [ + { + timestamp: '2026-07-30T09:30:00+08:00', + Price: 3812.11, + Avg: 3812.11, + Amount: 6_972_838_100, + }, + ], + }), + ) + const definition = getRegisteredFetcher('gotdx') + + await expect( + definition?.timeShareFetcher?.('gotdx', { + symbol: '601360', + params: { market: 1 }, + date: 20260730, + }), + ).resolves.toEqual({ + preClose: 8.3, + data: [ + { + timestamp: new Date('2026-07-30T09:30:00+08:00').getTime(), + price: 8.7, + average: 8.7, + volume: 7101, + }, + ], + }) + await expect( + definition?.timeShareFetcher?.('gotdx', { + symbol: '000001', + params: { market: 1 }, + date: 20260730, + }), + ).resolves.toEqual({ + preClose: 3828.47, + data: [ + { + timestamp: new Date('2026-07-30T09:30:00+08:00').getTime(), + price: 3812.11, + average: 3812.11, + amount: 6_972_838_100, + }, + ], + }) + }) + + it('preserves the API error message for unavailable extended-market timeshare', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: '该日期暂无历史分时数据' }, 422)) + const definition = getRegisteredFetcher('gotdx') + + await expect( + definition?.timeShareFetcher?.('gotdx', { + symbol: '00700', + exchange: 'HK', + params: { category: 31, kind: 'ex' }, + date: 20250908, + }), + ).rejects.toThrow('该日期暂无历史分时数据') + }) + + it('routes A-share timeshare by params.market to stock/history-tick', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + preClose: 8.3, + data: [{ timestamp: '2026-07-27T09:30:00+08:00', Price: 8.5, Avg: 8.5, Vol: 100 }], + }), + ) + const definition = getRegisteredFetcher('gotdx') + + await definition?.timeShareFetcher?.('gotdx', { + symbol: '000001', + params: { market: 0 }, + date: 20260727, + }) + + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://127.0.0.1:8080/api/stock/history-tick') + expect(JSON.parse(String(init?.body))).toMatchObject({ market: 0, code: '000001', date: 20260727 }) + }) + + it('rejects timeshare without params.market or params.category', async () => { + const definition = getRegisteredFetcher('gotdx') + + await expect( + definition?.timeShareFetcher?.('gotdx', { + symbol: '01810', + exchange: 'HK', + date: 20260724, + }), + ).rejects.toThrow(/params\.market or params\.category/) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('rejects the legacy array history-tick protocol', async () => { fetchMock.mockResolvedValue( jsonResponse([{ timestamp: '2026-07-27T09:30:00+08:00', Price: 8.5, Avg: 8.5, Vol: 100 }]), diff --git a/packages/core/src/data/__tests__/timeShareBuffer.test.ts b/packages/core/src/data/__tests__/timeShareBuffer.test.ts index db6e96a4..c6f5c3ec 100644 --- a/packages/core/src/data/__tests__/timeShareBuffer.test.ts +++ b/packages/core/src/data/__tests__/timeShareBuffer.test.ts @@ -86,4 +86,27 @@ describe('TimeShareBuffer', () => { expect(fetcher.mock.calls[0]?.[1].params).toEqual({ category: 71 }) buf.dispose() }) + + it('publishes the fetcher error and clears it after a successful reload', async () => { + const buf = new TimeShareBuffer() + // fetchTimeShare 有重试:首轮 load 需连续失败耗尽重试才落 lastError + const fetcher = vi + .fn() + .mockRejectedValueOnce(new Error('该日期暂无历史分时数据')) + .mockRejectedValueOnce(new Error('该日期暂无历史分时数据')) + .mockRejectedValueOnce(new Error('该日期暂无历史分时数据')) + .mockResolvedValue({ data: [point(10)], preClose: 9.5 }) + buf.setFetcher(fetcher) + + buf.load({ symbol: '00700', period: 'timeshare', source: 'gotdx' }) + await vi.waitFor(() => expect(buf.lastError()).toBe('该日期暂无历史分时数据'), { + timeout: 5_000, + }) + + buf.load({ symbol: '00700', period: 'timeshare', source: 'gotdx' }) + expect(buf.lastError()).toBeNull() + await vi.waitFor(() => expect(buf.getRawData()).toEqual([point(10)])) + expect(buf.lastError()).toBeNull() + buf.dispose() + }) }) diff --git a/packages/core/src/data/dataBuffer.effects.ts b/packages/core/src/data/dataBuffer.effects.ts index 30fcde31..29742821 100644 --- a/packages/core/src/data/dataBuffer.effects.ts +++ b/packages/core/src/data/dataBuffer.effects.ts @@ -2,7 +2,6 @@ import { Context, Effect, pipe, Schedule } from 'effect' import type { Effect as EffectType } from 'effect/Effect' import type { KLineData, SymbolSpec } from '../controllers/types' -import { KLineChartError } from '../errors' import type { TimeShareFetchResult } from './types' // ── KLine fetch service tag ── @@ -72,7 +71,7 @@ const retrySchedule = pipe( Schedule.compose(Schedule.recurs(FETCH_MAX_RETRIES)), ) -// ── KLine fetch Effect (retry + timeout + empty-data check) ── +// ── KLine fetch Effect (retry + timeout) ── export const fetchKLine = ( spec: SymbolSpec, @@ -83,20 +82,15 @@ export const fetchKLine = ( Effect.gen(function* () { const { fetch } = yield* KLineFetchService // 获取 Service 实例 const data = yield* pipe(fetch(spec, startTs, endTs), Effect.timeout(REQUEST_TIMEOUT)) + // 部分无数据品种返回 [] if (data.length === 0) { - return yield* Effect.fail( - new KLineChartError( - 'FETCH_FAILED', - `[DataBuffer] empty data for ${spec.symbol} ${formatDate(startTs)}~${formatDate(endTs)}`, - ), + yield* Effect.logWarning( + `[DataBuffer] empty data for ${spec.symbol} ${formatDate(startTs)}~${formatDate(endTs)}`, ) } return data }), - Effect.retry(retrySchedule), // 上个 Error 时触发 - Effect.tapError((err) => - Effect.logError(`[DataBuffer] fetch failed: ${(err as Error).message}`), - ), + Effect.retry(retrySchedule), ) // ── TimeShare fetch Effect (retry + timeout) ── diff --git a/packages/core/src/data/dataBuffer.ts b/packages/core/src/data/dataBuffer.ts index a8430943..de1d9b3b 100644 --- a/packages/core/src/data/dataBuffer.ts +++ b/packages/core/src/data/dataBuffer.ts @@ -2,7 +2,11 @@ import { Effect, pipe } from 'effect' import type { Effect as EffectType } from 'effect/Effect' import type { DataFetcher, KLineData, SymbolSpec } from '../controllers/types' -import type { ReadonlySignal } from '../foundation/reactivity/signal' +import { + createSignal, + type ReadonlySignal, + type WritableSignal, +} from '../foundation/reactivity/signal' import { fetchKLine, @@ -16,6 +20,12 @@ import { FetchScheduler } from './fetchScheduler' import { KLineDataStore } from './kLineDataStore' import { TimeKeyIndex } from './timeKeyIndex' +function errorMessage(err: unknown): string { + if (err instanceof Error && err.message.trim()) return err.message + if (err != null && String(err).trim()) return String(err) + return '加载失败' +} + export class DataBuffer implements KLineBuffer { private _store = new KLineDataStore() private _scheduler = new FetchScheduler() @@ -31,6 +41,7 @@ export class DataBuffer implements KLineBuffer { private _pendingRequestStartTs: number | null = null private _requestVersion = 0 private _disposed = false + private _lastError: WritableSignal = createSignal(null) constructor() {} @@ -42,6 +53,10 @@ export class DataBuffer implements KLineBuffer { return this._scheduler.loading } + get lastError(): ReadonlySignal { + return this._lastError + } + get currentSpec(): SymbolSpec | null { return this._currentSpec } @@ -82,6 +97,7 @@ export class DataBuffer implements KLineBuffer { this._keyIndex.reset() this._inflightBoundary = null this._pendingRequestStartTs = null + this._lastError.set(null) if (initialStartTs !== undefined) { this._loadInitialRange(initialStartTs, Date.now()) } else { @@ -118,6 +134,7 @@ export class DataBuffer implements KLineBuffer { this._scheduler.reset() this._inflightBoundary = null this._pendingRequestStartTs = null + this._lastError.set(null) this._keyIndex.recompute(this._store.getRawData()) } @@ -134,6 +151,7 @@ export class DataBuffer implements KLineBuffer { this._keyIndex.reset() this._inflightBoundary = null this._pendingRequestStartTs = null + this._lastError.set(null) } // ── Private ── @@ -207,23 +225,30 @@ export class DataBuffer implements KLineBuffer { this._scheduler .run(async () => { - const incoming = await fetchEffect() - if (disposed() || requestVersion !== this._requestVersion) return - - const result = this._store.merge(incoming) - this._keyIndex.recompute(this._store.getRawData()) - - this._inflightBoundary = null - const pending = this._pendingRequestStartTs - this._pendingRequestStartTs = null - if (result.advancedEarliest && pending !== null) { - this.ensureRange(pending, this._store.loadedWindow!.earliestTs) + try { + const incoming = await fetchEffect() + if (disposed() || requestVersion !== this._requestVersion) return + + // 成功空数组:接口无 K 线,记可读原因供 chip 展示 + this._lastError.set(incoming.length === 0 ? '暂无K线数据' : null) + const result = this._store.merge(incoming) + this._keyIndex.recompute(this._store.getRawData()) + + this._inflightBoundary = null + const pending = this._pendingRequestStartTs + this._pendingRequestStartTs = null + if (result.advancedEarliest && pending !== null) { + this.ensureRange(pending, this._store.loadedWindow!.earliestTs) + } + } catch (err) { + if (disposed() || requestVersion !== this._requestVersion) return + this._lastError.set(errorMessage(err)) + this._inflightBoundary = null + this._pendingRequestStartTs = null } }) .catch(() => { - if (requestVersion !== this._requestVersion) return - this._inflightBoundary = null - this._pendingRequestStartTs = null + // task 内已处理失败;此处仅吞掉 scheduler 链上的 residual reject }) } } diff --git a/packages/core/src/data/dataBufferTypes.ts b/packages/core/src/data/dataBufferTypes.ts index 9093ad7b..88e5ba18 100644 --- a/packages/core/src/data/dataBufferTypes.ts +++ b/packages/core/src/data/dataBufferTypes.ts @@ -19,6 +19,8 @@ export interface DataChange { export interface DataBufferLike { readonly data: ReadonlySignal readonly loading: ReadonlySignal + /** 最近一次显式拉取失败的可读原因;成功或重置后为 null */ + readonly lastError: ReadonlySignal readonly loadedWindow: DataWindow | null getRawData(): unknown[] setInlineData(data: unknown[]): void diff --git a/packages/core/src/data/gotdx.ts b/packages/core/src/data/gotdx.ts index 97b52299..0c759983 100644 --- a/packages/core/src/data/gotdx.ts +++ b/packages/core/src/data/gotdx.ts @@ -1,4 +1,4 @@ -import type { KLineData } from '../controllers/types' +import type { KLineData, TimeShareData } from '../controllers/types' import { KLineChartError } from '../errors' import { getFetcherBaseUrl } from './fetcherBaseUrl' @@ -57,33 +57,7 @@ function getShanghaiDateYYYYMMDD(): number { return +y * 10000 + +m * 100 + +d } -async function fetchGotdxHistoryTick( - _source: string, - config: TimeShareFetchConfig, -): Promise { - // 分时只认搜索/目录带来的 params.market,不按代码前缀猜市场 - if (typeof config.params?.market !== 'number') { - throw new KLineChartError( - 'FETCH_FAILED', - `[gotdx] history-tick requires params.market for ${config.symbol}`, - ) - } - const body = { - date: config.date ?? getShanghaiDateYYYYMMDD(), - market: config.params.market, - code: config.symbol, - } - const res = await fetch(`${getBaseUrl()}/api/stock/history-tick`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!res.ok) - throw new KLineChartError( - 'FETCH_FAILED', - `[gotdx] history-tick failed: ${res.status} ${res.statusText}`, - ) - const payload: unknown = await res.json() +function parseHistoryTickPayload(payload: unknown): TimeShareFetchResult { if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { throw new KLineChartError( 'FETCH_FAILED', @@ -109,16 +83,94 @@ async function fetchGotdxHistoryTick( return { preClose, - data: (list as Array<{ timestamp: string; Price: number; Avg: number; Vol: number }>).map( - (item) => ({ + data: (list as Array<{ + timestamp: string + Price: number + Avg: number + Volume?: unknown + Amount?: unknown + }>).map((item) => { + const point: TimeShareData = { timestamp: new Date(item.timestamp).getTime(), price: item.Price, average: item.Avg, - volume: item.Vol, - amount: item.Price * item.Vol, - }), - ), + } + if (typeof item.Volume === 'number' && Number.isFinite(item.Volume) && item.Volume >= 0) { + point.volume = item.Volume + } + if (typeof item.Amount === 'number' && Number.isFinite(item.Amount) && item.Amount >= 0) { + point.amount = item.Amount + } + return point + }), + } +} + +async function historyTickHttpError( + res: Response, + fallback: string, +): Promise { + try { + const body = (await res.json()) as { error?: unknown } + if (typeof body?.error === 'string' && body.error.trim()) { + return new KLineChartError('FETCH_FAILED', body.error.trim()) + } + } catch { + // 非 JSON 体时用 HTTP 状态文案 } + return new KLineChartError('FETCH_FAILED', fallback) +} + +async function fetchGotdxHistoryTick( + _source: string, + config: TimeShareFetchConfig, +): Promise { + // 分时只认搜索/目录带来的 params:category 走扩展,market 走 A 股;不按代码前缀猜 + const date = config.date ?? getShanghaiDateYYYYMMDD() + const explicitCategory = config.params?.category + if (typeof explicitCategory === 'number') { + const body = { + date, + category: explicitCategory, + code: config.symbol, + } + const res = await fetch(`${getBaseUrl()}/api/ex/history-tick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + throw await historyTickHttpError( + res, + `[gotdx] ex/history-tick failed: ${res.status} ${res.statusText}`, + ) + } + return parseHistoryTickPayload(await res.json()) + } + + if (typeof config.params?.market !== 'number') { + throw new KLineChartError( + 'FETCH_FAILED', + `[gotdx] history-tick requires params.market or params.category for ${config.symbol}`, + ) + } + const body = { + date, + market: config.params.market, + code: config.symbol, + } + const res = await fetch(`${getBaseUrl()}/api/stock/history-tick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + throw await historyTickHttpError( + res, + `[gotdx] history-tick failed: ${res.status} ${res.statusText}`, + ) + } + return parseHistoryTickPayload(await res.json()) } async function searchGotdx( @@ -137,7 +189,45 @@ async function searchGotdx( `[gotdx] symbol search failed: ${res.status} ${res.statusText}`, ) } - return (await res.json()) as ReadonlyArray + const raw = (await res.json()) as ReadonlyArray> + // 搜索只负责列出结果;无法归一化的品种 market 置空,选中时由图表校验提示 + return raw.map((item) => { + try { + return { ...item, market: normalizeGotdxMarket(item) } + } catch { + return { ...item, market: '' } + } + }) +} + +/** 扩展市场 exchange → 统一 market;仅含已有 session 的映射 */ +const GOTDX_EX_EXCHANGE_TO_MARKET: Readonly> = { + CN: 'CN', + FUND: 'CN', + MONEY: 'CN', + MONEY_FUND: 'CN', + HK: 'HK', + US: 'US', +} + +function normalizeGotdxMarket(item: Omit): string { + const sourceMarket = item.params?.market + if ( + typeof sourceMarket === 'number' && + (sourceMarket === 0 || sourceMarket === 1 || sourceMarket === 2) + ) { + return 'CN' + } + + if (typeof item.params?.category === 'number' && item.params.kind === 'ex') { + const market = GOTDX_EX_EXCHANGE_TO_MARKET[item.exchange] + if (market) return market + } + + throw new KLineChartError( + 'FETCH_FAILED', + `[gotdx] cannot normalize market for ${item.symbol}: exchange=${item.exchange} params=${JSON.stringify(item.params ?? {})}`, + ) } interface SecurityBar { diff --git a/packages/core/src/data/router.ts b/packages/core/src/data/router.ts index 7ad32b91..58e3d66f 100644 --- a/packages/core/src/data/router.ts +++ b/packages/core/src/data/router.ts @@ -51,7 +51,7 @@ function searchResultKey(result: SearchResult): string { const params = Object.entries(result.params ?? {}).sort(([left], [right]) => left.localeCompare(right), ) - return JSON.stringify([result.source, result.exchange, result.symbol, params]) + return JSON.stringify([result.source, result.market, result.exchange, result.symbol, params]) } export async function routerSearchFetchers( diff --git a/packages/core/src/data/timeShareBuffer.ts b/packages/core/src/data/timeShareBuffer.ts index 528690be..c36470f5 100644 --- a/packages/core/src/data/timeShareBuffer.ts +++ b/packages/core/src/data/timeShareBuffer.ts @@ -1,4 +1,4 @@ -import { Effect, Fiber, pipe } from 'effect' +import { Effect, pipe } from 'effect' import type { Effect as EffectType } from 'effect/Effect' import type { SymbolSpec } from '../controllers/types' @@ -10,6 +10,12 @@ import type { DataBufferLike, DataWindow, DataChange } from './dataBufferTypes' import { routerTimeShareFetcher } from './router' import type { TimeShareFetcherFn, TimeShareFetchResult } from './types' +function errorMessage(err: unknown): string { + if (err instanceof Error && err.message.trim()) return err.message + if (err != null && String(err).trim()) return String(err) + return '加载失败' +} + export class TimeShareBuffer implements DataBufferLike { // 当前持有的分时数据数组(内部可变副本) private _data: TimeShareData[] = [] @@ -17,16 +23,15 @@ export class TimeShareBuffer implements DataBufferLike { private _dataSignal: WritableSignal = createSignal({ data: [], prependedCount: 0 }) // 是否正在加载中,外部 UI 绑定用 private _loadingSignal: WritableSignal = createSignal(false) - // 可选的自定义 fetcher,优先级大于默认 fectcher + private _lastError: WritableSignal = createSignal(null) + // 可选的自定义 fetcher,优先级大于默认 fetcher private _fetcher: TimeShareFetcherFn | null = null // 指定查询的历史日期(0 = 当天) private _queryDate = 0 // 昨收价(分时涨跌基准);未设置时为 null private _preClose: number | null = null - // 请求序号,每次 load() 递增 + // 请求序号,每次 load() 递增;过期结果丢弃 private _requestSeq = 0 - // 当前运行的 fetch Fiber 句柄,用于随时中断旧请求 - private _fetchFiber: Fiber.RuntimeFiber | null = null // 实例是否已销毁,阻止后续任何操作 private _disposed = false @@ -38,6 +43,10 @@ export class TimeShareBuffer implements DataBufferLike { return this._loadingSignal } + get lastError(): ReadonlySignal { + return this._lastError + } + get loadedWindow(): DataWindow | null { if (this._data.length === 0) return null return { @@ -79,15 +88,11 @@ export class TimeShareBuffer implements DataBufferLike { load(spec: SymbolSpec): void { if (this._disposed) return - if (this._fetchFiber) { - Fiber.interrupt(this._fetchFiber) - this._fetchFiber = null - } - const requestSeq = ++this._requestSeq - // 新请求开始即清空旧点与昨收,避免历史日期切换时短暂显示另一天数据 + // 新请求开始即清空旧点、昨收与错误,避免历史日期切换时短暂显示另一天数据 this._data = [] this._preClose = null + this._lastError.set(null) this._dataSignal.set({ data: [], prependedCount: 0 }) this._loadingSignal.set(true) @@ -98,49 +103,54 @@ export class TimeShareBuffer implements DataBufferLike { ) => EffectType } = { fetch: (s, date) => - Effect.tryPromise(() => { - const fetcher = this._fetcher ?? routerTimeShareFetcher - return fetcher(s.source ?? 'gotdx', { - symbol: s.symbol, - exchange: s.exchange, - params: s.params, - date, - }).then((result) => { - if (Array.isArray(result)) { - return { data: result, preClose: null } - } - return result as TimeShareFetchResult - }) + Effect.tryPromise({ + try: () => { + const fetcher = this._fetcher ?? routerTimeShareFetcher + return fetcher(s.source ?? 'gotdx', { + symbol: s.symbol, + exchange: s.exchange, + params: s.params, + date, + }).then((result) => { + if (Array.isArray(result)) { + return { data: result, preClose: null } + } + return result as TimeShareFetchResult + }) + }, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), } const effect = pipe( fetchTimeShare(spec, this._queryDate || undefined), Effect.provideService(TimeShareFetchService, timeShareService), - Effect.tap((result) => - Effect.sync(() => { - if (this._disposed) return - this._queryDate = 0 - this._data = [...result.data] - this.setPreClose(result.preClose) - this._dataSignal.set({ data: [...result.data], prependedCount: 0 }) - }), - ), - Effect.ensuring( - Effect.sync(() => { - if (requestSeq === this._requestSeq) { - this._loadingSignal.set(false) - } - }), - ), ) - this._fetchFiber = Effect.runFork(effect) + void Effect.runPromise(effect) + .then((result) => { + if (this._disposed || requestSeq !== this._requestSeq) return + this._queryDate = 0 + this._data = [...result.data] + this.setPreClose(result.preClose) + this._lastError.set(null) + this._dataSignal.set({ data: [...result.data], prependedCount: 0 }) + }) + .catch((err) => { + if (this._disposed || requestSeq !== this._requestSeq) return + this._lastError.set(errorMessage(err)) + }) + .finally(() => { + if (requestSeq === this._requestSeq) { + this._loadingSignal.set(false) + } + }) } setInlineData(data: unknown[]): void { if (this._disposed) return this._data = data as TimeShareData[] + this._lastError.set(null) this._dataSignal.set({ data: [...(data as TimeShareData[])], prependedCount: 0 }) } @@ -148,12 +158,9 @@ export class TimeShareBuffer implements DataBufferLike { dispose(): void { this._disposed = true this._requestSeq++ - if (this._fetchFiber) { - Fiber.interrupt(this._fetchFiber) - this._fetchFiber = null - } this._data = [] this._preClose = null + this._lastError.set(null) this._loadingSignal.set(false) } } diff --git a/packages/core/src/data/types.ts b/packages/core/src/data/types.ts index 3f6dea3d..00e5be91 100644 --- a/packages/core/src/data/types.ts +++ b/packages/core/src/data/types.ts @@ -46,6 +46,7 @@ export interface SearchResult { symbol: string description: string exchange: string + market: string source: string params?: DataSourceParams } diff --git a/packages/core/src/engine/__tests__/chart.marketValidation.test.ts b/packages/core/src/engine/__tests__/chart.marketValidation.test.ts new file mode 100644 index 00000000..e22d5f48 --- /dev/null +++ b/packages/core/src/engine/__tests__/chart.marketValidation.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' + +import { Chart } from '../chart' +import { MarketSessionRegistry } from '../market/marketSessionRegistry' +import { HK_MARKET_SESSION } from '../../foundation/utils/sessionTimeLabels' + +function chartHarness() { + const timeShareMode = { setMarketSession: vi.fn() } + return Object.assign(Object.create(Chart.prototype), { + marketSessions: new MarketSessionRegistry(), + _timeShareMode: timeShareMode, + _kLineMode: {}, + setActiveMode: vi.fn(), + dataManager: { + symbols: { + peek: () => [{ symbol: '01810', market: 'HK', period: 'daily' }], + }, + addComparisonSymbol: vi.fn(), + resetToFetcher: vi.fn(), + applyCustomData: vi.fn(), + setCurrentPeriod: vi.fn(), + setTimeShareQueryDate: vi.fn(), + }, + }) as Chart +} + +describe('Chart market validation boundaries', () => { + it('rejects an unknown comparison market before data manager mutation', () => { + const chart = chartHarness() + + expect(() => + Chart.prototype.addComparisonSymbol.call(chart, { + symbol: 'IF2608', + market: 'FUTURES', + period: 'daily', + }), + ).toThrow('Market session is not registered: FUTURES') + }) + + it('rejects an unknown reset target before fetching', () => { + const chart = chartHarness() + + expect(() => + Chart.prototype.resetToFetcher.call(chart, { + symbol: 'IF2608', + market: 'FUTURES', + period: 'daily', + }), + ).toThrow('Market session is not registered: FUTURES') + }) + + it('configures HK session when setCurrentPeriod enters timeshare', () => { + const chart = chartHarness() + + Chart.prototype.setCurrentPeriod.call(chart, 'timeshare') + + expect((chart as any)._timeShareMode.setMarketSession).toHaveBeenCalledWith(HK_MARKET_SESSION) + }) + + it('configures HK session when switching to a historical timeshare date', () => { + const chart = chartHarness() + + Chart.prototype.switchToTimeShareForDate.call(chart, 20260728) + + expect((chart as any)._timeShareMode.setMarketSession).toHaveBeenCalledWith(HK_MARKET_SESSION) + }) + + it('configures HK session when resetting to a timeshare fetcher', () => { + const chart = chartHarness() + + Chart.prototype.resetToFetcher.call(chart, { + symbol: '01810', + market: 'HK', + period: 'timeshare', + }) + + expect((chart as any)._timeShareMode.setMarketSession).toHaveBeenCalledWith(HK_MARKET_SESSION) + }) + + it('rejects unknown custom-data market before applying data', () => { + const chart = chartHarness() + + expect(() => + Chart.prototype.applyCustomData.call(chart, { + symbol: 'IF2608', + market: 'FUTURES', + period: 'timeshare', + data: [], + }), + ).toThrow('Market session is not registered: FUTURES') + }) + + it('configures HK session when applying custom timeshare data', () => { + const chart = chartHarness() + + Chart.prototype.applyCustomData.call(chart, { + symbol: '01810', + market: 'HK', + period: 'timeshare', + data: [], + }) + + expect((chart as any)._timeShareMode.setMarketSession).toHaveBeenCalledWith(HK_MARKET_SESSION) + }) +}) diff --git a/packages/core/src/engine/chart.ts b/packages/core/src/engine/chart.ts index 5e6a6001..1eaffe7a 100644 --- a/packages/core/src/engine/chart.ts +++ b/packages/core/src/engine/chart.ts @@ -59,6 +59,8 @@ import { ChartPaneLayout } from './layout/chartPaneLayout' import { UpdateLevel, type VisibleRange } from './layout/pane' import { KLineMode } from './modes/kLineMode' import { TimeShareMode } from './modes/timeShareMode' +import { MarketSessionRegistry } from './market/marketSessionRegistry' +import { resolveSymbolMarketSession } from './market/resolveSymbolMarketSession' import { PaneRenderer } from './paneRenderer' import { ChartRenderer, mergeUpdateLevel } from './render/chartRenderer' import { ChartStateKernel } from './state/chartStateKernel' @@ -145,6 +147,7 @@ export class Chart { private _activeMode: ChartModeHandler private _kLineMode = new KLineMode() private _timeShareMode = new TimeShareMode() + private readonly marketSessions: MarketSessionRegistry /** 进入分时模式时保存的快照,退出时恢复(包含 zoom/scale/indicators) */ private _savedTimeShareState: { @@ -228,11 +231,16 @@ export class Chart { constructor( dom: ChartDom, opt: ChartOptions, - runtime?: { rendererHost?: RendererHost; initialSettings?: Partial }, + runtime?: { + rendererHost?: RendererHost + initialSettings?: Partial + marketSessions?: Readonly> + }, ) { this.dom = dom const { kWidth: _kWidth, kGap: _kGap, ...restOpt } = opt this._activeMode = this._kLineMode + this.marketSessions = new MarketSessionRegistry(runtime?.marketSessions) this.pluginHost = createPluginHost() this.rendererPluginManager = new RendererPluginManager() this.rendererHost = runtime?.rendererHost ?? createDefaultRendererHostSync() @@ -1327,6 +1335,11 @@ export class Chart { return this.dataManager.loading } + /** 主品种最近一次显式拉取失败原因 */ + get dataError(): ReadonlySignal { + return this.dataManager.dataError + } + /** 符号信号 */ get symbols(): ReadonlySignal> { return this.dataManager.symbols @@ -1427,9 +1440,14 @@ export class Chart { } setSymbols(specs: ReadonlyArray): void { + const sessions = specs.map((spec) => resolveSymbolMarketSession(spec, this.marketSessions)) + const primaryPeriod = specs[0]?.period + if (primaryPeriod === 'timeshare') { + this._timeShareMode.setMarketSession(sessions[0]!) + } + // 品种/周期切换时重置最新 K 线时间戳,确保新数据触发预警 this._lastAlertTimestamp = null - const primaryPeriod = specs[0]?.period if (primaryPeriod) { // ⚠️ setActiveMode 必须在 dataManager.setSymbols 之前调用, // 以确保 kWidth/kGap(从 zoom level 恢复)先写入 _optionsSignal, @@ -1445,6 +1463,7 @@ export class Chart { } addComparisonSymbol(spec: SymbolSpec): void { + resolveSymbolMarketSession(spec, this.marketSessions) this.dataManager.addComparisonSymbol(spec) } @@ -1460,22 +1479,43 @@ export class Chart { this.dataManager.setCurrentSymbol(symbol) } + private configureCurrentTimeShareSession(): void { + const primary = this.dataManager.symbols.peek()[0] + if (!primary) return + this._timeShareMode.setMarketSession(resolveSymbolMarketSession(primary, this.marketSessions)) + } + + private configureModeForSpec(spec: SymbolSpec): void { + const session = resolveSymbolMarketSession(spec, this.marketSessions) + const isTimeShare = spec.period === 'timeshare' + if (isTimeShare) this._timeShareMode.setMarketSession(session) + this.setActiveMode(isTimeShare ? this._timeShareMode : this._kLineMode) + } + setCurrentPeriod(period: string): void { + if (period === 'timeshare') this.configureCurrentTimeShareSession() this.setActiveMode(period === 'timeshare' ? this._timeShareMode : this._kLineMode) this.dataManager.setCurrentPeriod(period) } switchToTimeShareForDate(dateYYYYMMDD: number): void { + this.configureCurrentTimeShareSession() this.dataManager.setTimeShareQueryDate(dateYYYYMMDD) this.setActiveMode(this._timeShareMode) this.dataManager.setCurrentPeriod('timeshare') } applyCustomData(source: CustomDataSource): void { + this.configureModeForSpec({ + symbol: source.symbol ?? '', + market: source.market, + period: source.period ?? 'daily', + }) this.dataManager.applyCustomData(source) } resetToFetcher(spec: SymbolSpec): void { + this.configureModeForSpec(spec) this.dataManager.resetToFetcher(spec) } diff --git a/packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts b/packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts index d24df160..851abbad 100644 --- a/packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts +++ b/packages/core/src/engine/data/__tests__/chartDataManager.incrementalLoad.test.ts @@ -105,6 +105,7 @@ describe('ChartDataManager incremental load', () => { } const spec: SymbolSpec = { symbol: 'sh.600000', + market: 'CN', period: 'daily', adjust: 'none', source: 'mock', @@ -146,4 +147,77 @@ describe('ChartDataManager incremental load', () => { expect(hint!.style.background).toContain('--klc-color-selection-fill') expect(fetchCount).toBe(2) }) + + it('does not reuse primary data across unified markets', async () => { + let fetchCount = 0 + const fetcher: DataFetcher = async () => { + fetchCount++ + return [makeKLine(Date.now())] + } + const dataState = createDataState() + const symbols$ = createSignal>([]) + const dataManagerState = createDataManagerState() + const container = document.querySelector('#container')! + const scrollContent = document.querySelector('#scroll-content')! + manager = new ChartDataManager( + createDependencies( + { container, scrollContent }, + (symbols) => { + symbols$.set(symbols) + dataState.actions.setSymbols(symbols) + }, + symbols$, + ), + dataState, + dataManagerState, + ) + manager.setDataFetcher(fetcher) + + manager.setSymbols([ + { symbol: '000001', market: 'CN', period: 'daily', source: 'mock' }, + ]) + await vi.waitFor(() => expect(manager!.dataBuffer.loading.peek()).toBe(false)) + + manager.setSymbols([ + { symbol: '000001', market: 'HK', period: 'daily', source: 'mock' }, + ]) + await vi.waitFor(() => expect(manager!.dataBuffer.loading.peek()).toBe(false)) + + expect(fetchCount).toBe(2) + }) + + it( + 'mirrors active buffer lastError onto dataError', + async () => { + const fetcher: DataFetcher = async () => { + throw new Error('[gotdx] stock/kline-by-date failed: 500') + } + const dataState = createDataState() + const symbols$ = createSignal>([]) + const dataManagerState = createDataManagerState() + const container = document.querySelector('#container')! + const scrollContent = document.querySelector('#scroll-content')! + manager = new ChartDataManager( + createDependencies( + { container, scrollContent }, + (symbols) => { + symbols$.set(symbols) + dataState.actions.setSymbols(symbols) + }, + symbols$, + ), + dataState, + dataManagerState, + ) + manager.setDataFetcher(fetcher) + manager.setSymbols([{ symbol: '158017', market: 'CN', period: 'daily', source: 'gotdx' }]) + + await vi.waitFor( + () => + expect(manager!.dataError.peek()).toBe('[gotdx] stock/kline-by-date failed: 500'), + { timeout: 10_000 }, + ) + }, + 15_000, + ) }) diff --git a/packages/core/src/engine/data/__tests__/comparisonManager.test.ts b/packages/core/src/engine/data/__tests__/comparisonManager.test.ts index d36288cc..294de09a 100644 --- a/packages/core/src/engine/data/__tests__/comparisonManager.test.ts +++ b/packages/core/src/engine/data/__tests__/comparisonManager.test.ts @@ -51,6 +51,13 @@ function createHarness() { } describe('ComparisonManager runtime projection', () => { + it('uses unified market identity to separate otherwise identical symbols', () => { + const cn = comparisonBufferKey({ symbol: '000001', market: 'CN', period: 'daily' }) + const hk = comparisonBufferKey({ symbol: '000001', market: 'HK', period: 'daily' }) + + expect(cn).not.toBe(hk) + }) + it('reads specs from the injected kernel reader without a local shadow', () => { const harness = createHarness() harness.setSpecs([{ symbol: 'A', period: 'daily' }]) diff --git a/packages/core/src/engine/data/chartDataManager.ts b/packages/core/src/engine/data/chartDataManager.ts index ad41c140..6758f37d 100644 --- a/packages/core/src/engine/data/chartDataManager.ts +++ b/packages/core/src/engine/data/chartDataManager.ts @@ -44,9 +44,9 @@ const BUF_PRIMARY = 'main' const BUF_COMPARISON = 'cmp' const BUF_TIMESHARE = 'ts' -function bufKey(type: string, symbol: string, period?: string): string { - if (type === BUF_TIMESHARE) return `ts:${symbol}` - return `${type}:${symbol}:${period ?? 'daily'}` +function bufKey(type: string, market: string, symbol: string, period?: string): string { + if (type === BUF_TIMESHARE) return `ts:${market}:${symbol}` + return `${type}:${market}:${symbol}:${period ?? 'daily'}` } export class ChartDataManager { @@ -65,7 +65,9 @@ export class ChartDataManager { private _dmState: DataManagerStateModule private _dataUnsub: (() => void) | null = null private _loadingUnsub: (() => void) | null = null + private _errorUnsub: (() => void) | null = null private _lastDataChange: DataChange | null = null + private _dataError = createSignal(null) private _batchScheduler = new FetchBatchScheduler() private _scrollCompensator: ScrollCompensator @@ -124,6 +126,7 @@ export class ChartDataManager { data: [], loading: false, }) + this._dataError.set(null) return } @@ -133,6 +136,10 @@ export class ChartDataManager { this._loadingUnsub = buf.loading.subscribe(() => { this.handleBufferLoadingEvent(key) }) + this._errorUnsub = buf.lastError.subscribe(() => { + if (this._dataState.readonly.activeBufferKey.peek() !== key) return + this._dataError.set(buf.lastError.peek()) + }) // 初始同步:key/data/loading 同批;subscribe 不回放当前值 const { dataChanged, prependedCount, prevDataLength } = this.publishBufferSnapshot( @@ -140,6 +147,7 @@ export class ChartDataManager { buf, true, ) + this._dataError.set(buf.lastError.peek()) if (dataChanged) { this.onBufferDataChanged(key, prevDataLength, prependedCount) } @@ -151,8 +159,10 @@ export class ChartDataManager { private unbindActiveBuffer(): void { this._dataUnsub?.() this._loadingUnsub?.() + this._errorUnsub?.() this._dataUnsub = null this._loadingUnsub = null + this._errorUnsub = null this._lastDataChange = null } @@ -223,8 +233,8 @@ export class ChartDataManager { : null } - private getPrimaryDataBuffer(symbol: string, period: string): KLineBuffer { - const key = bufKey(BUF_PRIMARY, symbol, period) + private getPrimaryDataBuffer(spec: SymbolSpec): KLineBuffer { + const key = bufKey(BUF_PRIMARY, spec.market, spec.symbol, spec.period) let buf = this._klineBuffers.get(key) if (!buf) { buf = this._createKLineBuffer() @@ -398,6 +408,11 @@ export class ChartDataManager { return this._dataState.readonly.loading } + /** 主品种最近一次显式拉取失败原因 */ + get dataError(): ReadonlySignal { + return this._dataError + } + get symbols(): ReadonlySignal> { return this._dataState.readonly.symbols } @@ -490,7 +505,7 @@ export class ChartDataManager { get dataBuffer(): KLineBuffer { const buf = this.getActiveDataBuffer() if (buf) return buf - const key = bufKey(BUF_PRIMARY, '', 'daily') + const key = bufKey(BUF_PRIMARY, '', '', 'daily') let fallback = this._klineBuffers.get(key) if (!fallback) { fallback = this._createKLineBuffer() @@ -621,7 +636,7 @@ export class ChartDataManager { this.deps.setSymbols([ primary, ...this.deps.comparison.readonly.specs.peek(), - { symbol, period: 'daily' }, + { symbol, market: primary.market, period: 'daily' }, ]) } this._comparisonManager.setData(symbol, data) @@ -643,7 +658,8 @@ export class ChartDataManager { // ── Symbol / Period ── setCurrentSymbol(symbol: string): void { - const current = this._dmState.readonly.currentSpec.peek() ?? { symbol } + const current = this._dmState.readonly.currentSpec.peek() + if (!current) return this._dmState.actions.setCurrentSpec({ ...current, symbol }) const specs = this._dataState.readonly.symbols.peek() if (specs.length > 0) { @@ -691,7 +707,7 @@ export class ChartDataManager { tsBuf.setQueryDate(date) const spec = this._dmState.readonly.currentSpec.peek() if (spec) { - const key = bufKey(BUF_TIMESHARE, spec.symbol) + const key = bufKey(BUF_TIMESHARE, spec.market, spec.symbol) this._tsBuffers.set(key, tsBuf) this.activateBuffer(key) tsBuf.load(spec) @@ -701,10 +717,7 @@ export class ChartDataManager { setCurrentPeriod(period: string): void { const current = this._dmState.readonly.currentSpec.peek() - if (!current) { - this._dmState.actions.setCurrentSpec({ symbol: '', period }) - return - } + if (!current) return const next = { ...current, period } this.setSymbols([next, ...this.deps.comparison.readonly.specs.peek()]) } @@ -727,13 +740,17 @@ export class ChartDataManager { if (!this._dmState.readonly.preCustomSpec.peek()) { this._dmState.actions.setPreCustomSpec({ ...(this._dmState.readonly.currentSpec.peek() ?? - this._dataState.readonly.symbols.peek()[0] ?? { symbol: '' }), + this._dataState.readonly.symbols.peek()[0] ?? { + symbol: source.symbol ?? '', + market: source.market, + }), }) } // 每次都切到 custom 品种,注册到目录,填入数据 const spec: SymbolSpec = { symbol: source.symbol ?? '', + market: source.market, period: ChartDataManager.normalizePeriod(source.period), incremental: false, source: source.source ?? 'custom', @@ -745,6 +762,7 @@ export class ChartDataManager { this.registerSymbols([ { symbol: symbolCode, + market: source.market, description: source.description ?? symbolCode, exchange: source.exchange ?? '', source: source.source ?? 'custom', @@ -813,7 +831,7 @@ export class ChartDataManager { this._dmState.actions.setRangeInitialized(false) // Get or create timeshare buffer - const tsKey = bufKey(BUF_TIMESHARE, primary.symbol) + const tsKey = bufKey(BUF_TIMESHARE, primary.market, primary.symbol) let tsBuf = this._tsBuffers.get(tsKey) if (!tsBuf) { tsBuf = new TimeShareBuffer() @@ -838,8 +856,8 @@ export class ChartDataManager { private loadKLineSymbols(specs: ReadonlyArray): void { const spec = specs[0]! - const buf = this.getPrimaryDataBuffer(spec.symbol, spec.period!) - this.activateBuffer(bufKey(BUF_PRIMARY, spec.symbol, spec.period!)) + const buf = this.getPrimaryDataBuffer(spec) + this.activateBuffer(bufKey(BUF_PRIMARY, spec.market, spec.symbol, spec.period)) if (!this._dataFetcher) { buf.setCurrentSpec(spec) return diff --git a/packages/core/src/engine/data/symbolIdentity.ts b/packages/core/src/engine/data/symbolIdentity.ts index 89dde4c4..20b446d6 100644 --- a/packages/core/src/engine/data/symbolIdentity.ts +++ b/packages/core/src/engine/data/symbolIdentity.ts @@ -1,6 +1,6 @@ import type { DataSourceParams, SymbolSpec } from '../../controllers/types' -type SymbolIdentity = Pick & { +type SymbolIdentity = Pick & { params?: DataSourceParams } @@ -8,5 +8,11 @@ export function symbolSpecIdentityKey(spec: SymbolIdentity): string { const params = Object.entries(spec.params ?? {}).sort(([left], [right]) => left.localeCompare(right), ) - return JSON.stringify([spec.source ?? '', spec.exchange ?? '', spec.symbol, params]) + return JSON.stringify([ + spec.source ?? '', + spec.market, + spec.exchange ?? '', + spec.symbol, + params, + ]) } diff --git a/packages/core/src/engine/market/__tests__/marketSessionRegistry.test.ts b/packages/core/src/engine/market/__tests__/marketSessionRegistry.test.ts new file mode 100644 index 00000000..e2967731 --- /dev/null +++ b/packages/core/src/engine/market/__tests__/marketSessionRegistry.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { HK_MARKET_SESSION } from '../../../foundation/utils/sessionTimeLabels' +import { MarketSessionRegistry } from '../marketSessionRegistry' + +describe('MarketSessionRegistry', () => { + it('provides built-in market sessions per instance', () => { + const registry = new MarketSessionRegistry() + + expect(registry.getRequired('HK')).toEqual(HK_MARKET_SESSION) + }) + + it('throws for an unknown market without falling back', () => { + const registry = new MarketSessionRegistry() + + expect(() => registry.getRequired('UNKNOWN')).toThrow( + 'Market session is not registered: UNKNOWN', + ) + }) + + it('keeps overrides isolated between chart instances', () => { + const first = new MarketSessionRegistry() + const second = new MarketSessionRegistry() + const custom = { + timeZone: 'Asia/Hong_Kong', + sessions: [{ open: 10 * 60, close: 12 * 60 }], + slotMinutes: 1, + } + + first.register('HK', custom) + + expect(first.getRequired('HK')).toEqual(custom) + expect(second.getRequired('HK')).toEqual(HK_MARKET_SESSION) + }) + + it('rejects blank market ids and invalid sessions', () => { + const registry = new MarketSessionRegistry() + + expect(() => registry.register(' ', HK_MARKET_SESSION)).toThrow('Market id is required') + expect(() => + registry.register('BROKEN', { + timeZone: '', + sessions: [], + }), + ).toThrow('Invalid market session: BROKEN') + }) +}) diff --git a/packages/core/src/engine/market/__tests__/resolveSymbolMarketSession.test.ts b/packages/core/src/engine/market/__tests__/resolveSymbolMarketSession.test.ts new file mode 100644 index 00000000..0fe81279 --- /dev/null +++ b/packages/core/src/engine/market/__tests__/resolveSymbolMarketSession.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { HK_MARKET_SESSION, resolveMarketSessionSlots } from '../../../foundation/utils/sessionTimeLabels' +import type { SymbolSpec } from '../../../controllers/types' +import { MarketSessionRegistry } from '../marketSessionRegistry' +import { resolveSymbolMarketSession } from '../resolveSymbolMarketSession' + +describe('resolveSymbolMarketSession', () => { + it('resolves HK to its 330-slot trading session', () => { + const spec: SymbolSpec = { symbol: '01810', market: 'HK', period: 'timeshare' } + + const session = resolveSymbolMarketSession(spec, new MarketSessionRegistry()) + + expect(session).toEqual(HK_MARKET_SESSION) + expect(resolveMarketSessionSlots(session)).toBe(330) + }) + + it('rejects missing market without fallback', () => { + const spec = { symbol: '01810', period: 'timeshare' } as SymbolSpec + + expect(() => resolveSymbolMarketSession(spec, new MarketSessionRegistry())).toThrow( + 'SymbolSpec.market is required for 01810', + ) + }) + + it('rejects an unregistered market', () => { + const spec: SymbolSpec = { symbol: 'IF2608', market: 'FUTURES', period: 'timeshare' } + + expect(() => resolveSymbolMarketSession(spec, new MarketSessionRegistry())).toThrow( + 'Market session is not registered: FUTURES', + ) + }) +}) diff --git a/packages/core/src/engine/market/marketSessionRegistry.ts b/packages/core/src/engine/market/marketSessionRegistry.ts new file mode 100644 index 00000000..4f6e0148 --- /dev/null +++ b/packages/core/src/engine/market/marketSessionRegistry.ts @@ -0,0 +1,46 @@ +import { + ASHARE_MARKET_SESSION, + HK_MARKET_SESSION, + US_MARKET_SESSION, + type MarketSessionConfig, +} from '../../foundation/utils/sessionTimeLabels' + +const BUILTIN_MARKET_SESSIONS: Readonly> = { + CN: ASHARE_MARKET_SESSION, + HK: HK_MARKET_SESSION, + US: US_MARKET_SESSION, +} + +function isValidSession(config: MarketSessionConfig): boolean { + if (!config.timeZone.trim() || config.sessions.length === 0) return false + if (config.slotMinutes !== undefined && config.slotMinutes <= 0) return false + return config.sessions.every( + ({ open, close }) => + Number.isFinite(open) && Number.isFinite(close) && open >= 0 && close > open, + ) +} + +export class MarketSessionRegistry { + private readonly sessions = new Map( + Object.entries(BUILTIN_MARKET_SESSIONS), + ) + + constructor(overrides?: Readonly>) { + for (const [market, config] of Object.entries(overrides ?? {})) { + this.register(market, config) + } + } + + register(market: string, config: MarketSessionConfig): void { + const id = market.trim() + if (!id) throw new Error('Market id is required') + if (!isValidSession(config)) throw new Error(`Invalid market session: ${id}`) + this.sessions.set(id, config) + } + + getRequired(market: string): MarketSessionConfig { + const config = this.sessions.get(market) + if (!config) throw new Error(`Market session is not registered: ${market}`) + return config + } +} diff --git a/packages/core/src/engine/market/resolveSymbolMarketSession.ts b/packages/core/src/engine/market/resolveSymbolMarketSession.ts new file mode 100644 index 00000000..1334092b --- /dev/null +++ b/packages/core/src/engine/market/resolveSymbolMarketSession.ts @@ -0,0 +1,12 @@ +import type { SymbolSpec } from '../../controllers/types' +import type { MarketSessionConfig } from '../../foundation/utils/sessionTimeLabels' +import type { MarketSessionRegistry } from './marketSessionRegistry' + +export function resolveSymbolMarketSession( + spec: SymbolSpec, + registry: MarketSessionRegistry, +): MarketSessionConfig { + const market = spec.market?.trim() + if (!market) throw new Error(`SymbolSpec.market is required for ${spec.symbol}`) + return registry.getRequired(market) +} diff --git a/packages/core/src/engine/modes/__tests__/timeShareMath.test.ts b/packages/core/src/engine/modes/__tests__/timeShareMath.test.ts index 772507f7..bbf7c981 100644 --- a/packages/core/src/engine/modes/__tests__/timeShareMath.test.ts +++ b/packages/core/src/engine/modes/__tests__/timeShareMath.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveTimeShareSlotTimestamp, + resolveTimestampSessionSlot, timeShareSlotCenterX, } from '../../../foundation/utils/timeShareAxisLabels' import { @@ -129,6 +130,19 @@ describe('computeTimeShareXLayout', () => { expect(layout!.centers[0]).toBeCloseTo(1, 6) expect(layout!.centers[239]).toBeCloseTo(479, 6) }) + + it('uses supplied session slots instead of compacting across lunch', () => { + const layout = computeTimeShareXLayout({ + arrivedCount: 4, + sessionSlots: 240, + totalWidth: 480, + dpr: 1, + slotIndices: [0, 119, 121, 239], + }) + + expect(layout).not.toBeNull() + expect(layout!.centers).toEqual([1, 239, 243, 479]) + }) }) describe('computeTimeSharePaneLayout', () => { @@ -163,6 +177,16 @@ describe('computeTimeShareTimeLabelIndices', () => { }) describe('timeShare slot time/x helpers', () => { + it('maps gotdx closing timestamps without compacting the lunch break', () => { + const time = (hour: number, minute: number) => Date.UTC(2026, 6, 28, hour - 8, minute) + + expect(resolveTimestampSessionSlot(time(9, 30))).toBe(0) + expect(resolveTimestampSessionSlot(time(11, 30))).toBe(119) + expect(resolveTimestampSessionSlot(time(13, 1))).toBe(121) + expect(resolveTimestampSessionSlot(time(15, 0))).toBe(239) + expect(resolveTimestampSessionSlot(1e100)).toBeNull() + }) + it('maps A-share slots across lunch break', () => { // 2026-07-21 local const day = new Date(2026, 6, 21, 10, 0, 0, 0).getTime() diff --git a/packages/core/src/engine/modes/timeShareMath.ts b/packages/core/src/engine/modes/timeShareMath.ts index 4e6560b8..bd5d03c8 100644 --- a/packages/core/src/engine/modes/timeShareMath.ts +++ b/packages/core/src/engine/modes/timeShareMath.ts @@ -88,6 +88,7 @@ export type TimeShareXLayoutInput = { sessionSlots: number totalWidth: number dpr: number + slotIndices?: ReadonlyArray } export type TimeShareXLayout = { @@ -102,15 +103,16 @@ export type TimeShareXLayout = { * 分时 X 布局:step = totalWidth / sessionSlots,已到达点按 slot 索引落位,未到时段留白。 */ export function computeTimeShareXLayout(input: TimeShareXLayoutInput): TimeShareXLayout | null { - const { arrivedCount, sessionSlots, totalWidth, dpr } = input + const { arrivedCount, sessionSlots, totalWidth, dpr, slotIndices } = input if (arrivedCount <= 0 || sessionSlots <= 0 || totalWidth <= 0 || !(dpr > 0)) return null const step = totalWidth / sessionSlots const centers: number[] = new Array(arrivedCount) const lefts: number[] = new Array(arrivedCount) for (let i = 0; i < arrivedCount; i++) { - centers[i] = Math.round((i + 0.5) * step * dpr) / dpr - lefts[i] = Math.round(i * step * dpr) / dpr + const slotIndex = slotIndices?.[i] ?? i + centers[i] = Math.round((slotIndex + 0.5) * step * dpr) / dpr + lefts[i] = Math.round(slotIndex * step * dpr) / dpr } const logicalBarWidth = Math.max(step * 0.6, 1 / dpr) diff --git a/packages/core/src/engine/render/chartRenderer.ts b/packages/core/src/engine/render/chartRenderer.ts index a4437dc7..9a9edec3 100644 --- a/packages/core/src/engine/render/chartRenderer.ts +++ b/packages/core/src/engine/render/chartRenderer.ts @@ -31,8 +31,11 @@ import { ChartIndicatorManager } from '../indicators/chartIndicatorManager' import { UpdateLevel } from '../layout/pane' import type { VisibleRange } from '../layout/pane' import { MarkerManager, type CustomMarkerEntity, type MarkerManagerDeps } from '../marker/registry' -import { ASHARE_MARKET_SESSION } from '../../foundation/utils/timeShareAxisLabels' -import { resolveMarketSessionSlots } from '../../foundation/utils/timeShareAxisLabels' +import { + ASHARE_MARKET_SESSION, + resolveMarketSessionSlots, + resolveTimestampSessionSlot, +} from '../../foundation/utils/timeShareAxisLabels' import { computeTimeShareXLayout } from '../modes/timeShareMath' import type { ChartModeHandler } from '../modes/types' import { PaneRenderer } from '../paneRenderer' @@ -603,6 +606,9 @@ export class ChartRenderer { sessionSlots: resolveMarketSessionSlots(marketSession), totalWidth: vp.plotWidth, dpr: vp.dpr, + slotIndices: internalData.slice(range.start, range.end).map( + (item, index) => resolveTimestampSessionSlot(item.timestamp, marketSession) ?? index, + ), }) if (layout) { const halfBarPx = Math.floor((layout.barWidth * vp.dpr) / 2) @@ -932,6 +938,11 @@ export class ChartRenderer { if (xAxisCtx && this.timeAxisLayer) { const opt = this.deps.getOption() const dataManager = this.deps.getDataManager() + const activeMode = this.deps.getActiveMode() + const marketSession = + 'marketSession' in activeMode + ? (activeMode as { marketSession: typeof ASHARE_MARKET_SESSION }).marketSession + : undefined this.timeAxisCtx = { ctx: xAxisCtx, pane: { @@ -961,6 +972,7 @@ export class ChartRenderer { priceRange: { maxPrice: 0, minPrice: 0 }, }, period: dataManager.currentPeriod, + marketSession, data: renderData, range, scrollLeft: vp.scrollLeft, diff --git a/packages/core/src/engine/renderers/Indicator/__tests__/mainIndicatorLegendContext.test.ts b/packages/core/src/engine/renderers/Indicator/__tests__/mainIndicatorLegendContext.test.ts index 35aff4ec..92a166a9 100644 --- a/packages/core/src/engine/renderers/Indicator/__tests__/mainIndicatorLegendContext.test.ts +++ b/packages/core/src/engine/renderers/Indicator/__tests__/mainIndicatorLegendContext.test.ts @@ -9,6 +9,44 @@ function point(timestamp: number, price: number): TimeShareData { } describe('buildLegendTemplateContext timeshare baseline', () => { + it('formats timeshare volume as hands', () => { + const context = { + data: [{ ...point(1, 10), volume: 12_345 }], + period: 'timeshare', + range: { start: 0, end: 1 }, + crosshairIndex: 0, + paneWidth: 800, + theme: 'light', + isAsiaMarket: true, + settings: { preClose: 9 }, + } as unknown as RenderContext + + const result = buildLegendTemplateContext({ context, host: null, yPaddingPx: 0 }) + + expect(result?.timeshare?.volumeText).toBe('1.23万手') + }) + + // 验证仅有成交额的分时不伪造成交量。 + it('keeps amount-only timeshare metrics separate from volume', () => { + const context = { + data: [{ timestamp: 1, price: 3812.11, average: 3812.11, amount: 6_972_838_100 }], + period: 'timeshare', + range: { start: 0, end: 1 }, + crosshairIndex: 0, + paneWidth: 800, + theme: 'light', + isAsiaMarket: true, + settings: { preClose: 3828.47 }, + } as unknown as RenderContext + + const result = buildLegendTemplateContext({ context, host: null, yPaddingPx: 0 }) + + expect(result?.timeshare?.volume).toBeNull() + expect(result?.timeshare?.volumeText).toBeNull() + expect(result?.timeshare?.amount).toBe(6_972_838_100) + expect(result?.timeshare?.amountText).toBe('69.73亿') + }) + it.each([undefined, -1])( 'does not derive changes from the first price when preClose is %s', (preClose) => { diff --git a/packages/core/src/engine/renderers/Indicator/mainIndicatorLegend.ts b/packages/core/src/engine/renderers/Indicator/mainIndicatorLegend.ts index 754de5f4..d2974ae5 100644 --- a/packages/core/src/engine/renderers/Indicator/mainIndicatorLegend.ts +++ b/packages/core/src/engine/renderers/Indicator/mainIndicatorLegend.ts @@ -158,18 +158,22 @@ function paintLegendOnCanvas(overlayCtx: CanvasRenderingContext2D, legend: Legen overlayCtx.fillText(`${pctSign}${ts.changePercent.toFixed(2)}%`, x, y) x += measureTextWidth(overlayCtx, `${pctSign}${ts.changePercent.toFixed(2)}%`) + gap - overlayCtx.fillStyle = colors.textTertiary - overlayCtx.fillText('成交量 ', x, y) - x += measureTextWidth(overlayCtx, '成交量 ') - overlayCtx.fillStyle = colors.textPrimary - overlayCtx.fillText(ts.volumeText, x, y) - x += measureTextWidth(overlayCtx, ts.volumeText) + gap + if (ts.volumeText) { + overlayCtx.fillStyle = colors.textTertiary + overlayCtx.fillText('成交量 ', x, y) + x += measureTextWidth(overlayCtx, '成交量 ') + overlayCtx.fillStyle = colors.textPrimary + overlayCtx.fillText(ts.volumeText, x, y) + x += measureTextWidth(overlayCtx, ts.volumeText) + gap + } - overlayCtx.fillStyle = colors.textTertiary - overlayCtx.fillText('成交额 ', x, y) - x += measureTextWidth(overlayCtx, '成交额 ') - overlayCtx.fillStyle = colors.textPrimary - overlayCtx.fillText(ts.amountText, x, y) + if (ts.amountText) { + overlayCtx.fillStyle = colors.textTertiary + overlayCtx.fillText('成交额 ', x, y) + x += measureTextWidth(overlayCtx, '成交额 ') + overlayCtx.fillStyle = colors.textPrimary + overlayCtx.fillText(ts.amountText, x, y) + } rowIndex++ } else { { @@ -188,11 +192,13 @@ function paintLegendOnCanvas(overlayCtx: CanvasRenderingContext2D, legend: Legen overlayCtx.fillText(ts.average.toFixed(2), x, y) x += measureTextWidth(overlayCtx, ts.average.toFixed(2)) + gap - overlayCtx.fillStyle = colors.textTertiary - overlayCtx.fillText('成交量 ', x, y) - x += measureTextWidth(overlayCtx, '成交量 ') - overlayCtx.fillStyle = colors.textPrimary - overlayCtx.fillText(ts.volumeText, x, y) + if (ts.volumeText) { + overlayCtx.fillStyle = colors.textTertiary + overlayCtx.fillText('成交量 ', x, y) + x += measureTextWidth(overlayCtx, '成交量 ') + overlayCtx.fillStyle = colors.textPrimary + overlayCtx.fillText(ts.volumeText, x, y) + } rowIndex++ } { @@ -214,11 +220,13 @@ function paintLegendOnCanvas(overlayCtx: CanvasRenderingContext2D, legend: Legen overlayCtx.fillText(`${pctSign}${ts.changePercent.toFixed(2)}%`, x, y) x += measureTextWidth(overlayCtx, `${pctSign}${ts.changePercent.toFixed(2)}%`) + gap - overlayCtx.fillStyle = colors.textTertiary - overlayCtx.fillText('成交额 ', x, y) - x += measureTextWidth(overlayCtx, '成交额 ') - overlayCtx.fillStyle = colors.textPrimary - overlayCtx.fillText(ts.amountText, x, y) + if (ts.amountText) { + overlayCtx.fillStyle = colors.textTertiary + overlayCtx.fillText('成交额 ', x, y) + x += measureTextWidth(overlayCtx, '成交额 ') + overlayCtx.fillStyle = colors.textPrimary + overlayCtx.fillText(ts.amountText, x, y) + } rowIndex++ } } diff --git a/packages/core/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts b/packages/core/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts index 7fc1d652..e29b8893 100644 --- a/packages/core/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts +++ b/packages/core/src/engine/renderers/Indicator/mainIndicatorLegendContext.ts @@ -29,10 +29,11 @@ export interface LegendTimeshareRow { average: number changeAmount: number changePercent: number - volume: number - volumeText: string - amount: number - amountText: string + volume: number | null + /** 带手数单位的成交量文本。 */ + volumeText: string | null + amount: number | null + amountText: string | null changeColor: string } @@ -135,15 +136,19 @@ export function buildLegendTemplateContext( if (item && preClose !== null) { const changeAmount = item.price - preClose const changePercent = (changeAmount / preClose) * 100 + const volume = + typeof item.volume === 'number' && Number.isFinite(item.volume) ? item.volume : null + const amount = + typeof item.amount === 'number' && Number.isFinite(item.amount) ? item.amount : null timeshare = { price: item.price, average: item.average, changeAmount, changePercent, - volume: item.volume, - volumeText: formatVolumeShort(item.volume), - amount: item.amount, - amountText: formatAmountShort(item.amount), + volume, + volumeText: volume === null ? null : `${formatVolumeShort(volume)}手`, + amount, + amountText: amount === null ? null : formatAmountShort(amount), changeColor: changeAmount >= 0 ? colors.candleUpBody : colors.candleDownBody, } } diff --git a/packages/core/src/engine/renderers/__tests__/timeAxis.marketSession.test.ts b/packages/core/src/engine/renderers/__tests__/timeAxis.marketSession.test.ts new file mode 100644 index 00000000..d30eae77 --- /dev/null +++ b/packages/core/src/engine/renderers/__tests__/timeAxis.marketSession.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { RenderContext } from '../../../foundation/plugin/types' +import { HK_MARKET_SESSION } from '../../../foundation/utils/sessionTimeLabels' +import { createTimeAxisRendererPlugin } from '../timeAxis' + +describe('time axis market session', () => { + it('uses the active HK session from render context', () => { + const fillText = vi.fn() + const ctx = { + setTransform: vi.fn(), + scale: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillText, + } as unknown as CanvasRenderingContext2D + const context = { + ctx, + data: [{ timestamp: new Date('2026-07-28T09:30:00+08:00').getTime() }], + range: { start: 0, end: 1 }, + scrollLeft: 0, + kWidth: 1, + kGap: 0, + dpr: 1, + paneWidth: 330, + kLineCenters: [0.5], + period: 'timeshare', + marketSession: HK_MARKET_SESSION, + theme: 'light', + } as unknown as RenderContext + + createTimeAxisRendererPlugin({ height: 24 }).draw(context) + + const labels = fillText.mock.calls.map(([text]) => text) + expect(labels).toContain('16:00') + expect(labels).not.toContain('15:00') + }) +}) diff --git a/packages/core/src/engine/renderers/__tests__/timeShare.renderer.test.ts b/packages/core/src/engine/renderers/__tests__/timeShare.renderer.test.ts new file mode 100644 index 00000000..2efba114 --- /dev/null +++ b/packages/core/src/engine/renderers/__tests__/timeShare.renderer.test.ts @@ -0,0 +1,104 @@ +// @ts-nocheck - Test file with intentional type relaxations for mocking +import { describe, it, expect, vi } from 'vitest' + +import { createTimeShareRendererPlugin } from '../timeShare' + +import type { RenderContext } from '@/plugin' +import type { TimeShareData } from '@/types/price' + +function createMockCanvasContext() { + const strokeLineWidths: number[] = [] + let lineWidth = 0 + + const ctx = { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(() => { + strokeLineWidths.push(lineWidth) + }), + fill: vi.fn(), + closePath: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + translate: vi.fn(), + setLineDash: vi.fn(), + createLinearGradient: vi.fn(() => ({ + addColorStop: vi.fn(), + })), + fillRect: vi.fn(), + strokeStyle: '', + fillStyle: '', + lineJoin: '', + lineCap: '', + get lineWidth() { + return lineWidth + }, + set lineWidth(v: number) { + lineWidth = v + }, + strokeLineWidths, + } + + return ctx as unknown as CanvasRenderingContext2D & { strokeLineWidths: number[] } +} + +function createTsData(n = 4): TimeShareData[] { + return Array.from({ length: n }, (_, i) => ({ + timestamp: 1_700_000_000_000 + i * 60_000, + price: 10 + i * 0.1, + average: 10 + i * 0.05, + volume: 100 + i, + amount: 1000 + i, + })) +} + +function createContext(ctx: CanvasRenderingContext2D, data: TimeShareData[]): RenderContext { + const n = data.length + return { + ctx, + data, + range: { start: 0, end: n }, + dpr: 1, + scrollLeft: 0, + paneWidth: 800, + period: 'timeshare', + theme: 'light', + isAsiaMarket: true, + settings: { preClose: 10 }, + kLineCenters: Array.from({ length: n }, (_, i) => i * 10 + 5), + kBarRects: Array.from({ length: n }, (_, i) => ({ x: i * 10, width: 4 })), + pane: { + height: 400, + top: 0, + yAxis: { + priceToY: (price: number) => 200 - (price - 10) * 50, + }, + }, + } as unknown as RenderContext +} + +describe('timeShare renderer line width', () => { + it('draws price and average lines at 1px logical width', () => { + const ctx = createMockCanvasContext() + const plugin = createTimeShareRendererPlugin() + plugin.draw(createContext(ctx, createTsData())) + + // stroke 顺序:昨收虚线 → 现价折线 → 均价折线 + expect(ctx.strokeLineWidths).toEqual([1, 1, 1]) + }) + + // 验证上游未提供成交量时不预留量柱区域,也不绘制量柱。 + it('uses the full pane for price when timeshare data has no volume', () => { + const ctx = createMockCanvasContext() + const plugin = createTimeShareRendererPlugin() + const amountOnly = createTsData().map(({ volume: _volume, ...item }) => item) + + plugin.draw(createContext(ctx, amountOnly)) + + expect(ctx.fillRect).not.toHaveBeenCalled() + expect(ctx.moveTo).toHaveBeenCalledWith(5, 200) + }) +}) diff --git a/packages/core/src/engine/renderers/timeAxis.ts b/packages/core/src/engine/renderers/timeAxis.ts index 2160c414..1edae19b 100644 --- a/packages/core/src/engine/renderers/timeAxis.ts +++ b/packages/core/src/engine/renderers/timeAxis.ts @@ -69,6 +69,7 @@ export function createTimeAxisRendererPlugin(options: { drawTopBorder: false, drawBottomBorder: false, period: context.period, + marketSession: context.marketSession, monthKeys: context.monthKeys, dayKeys: context.dayKeys, }, diff --git a/packages/core/src/engine/renderers/timeShare.ts b/packages/core/src/engine/renderers/timeShare.ts index e7cac916..e2af49ae 100644 --- a/packages/core/src/engine/renderers/timeShare.ts +++ b/packages/core/src/engine/renderers/timeShare.ts @@ -45,7 +45,10 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost { if (preClose === null) return const paneHeight = pane.height - const layout = computeTimeSharePaneLayout(paneHeight, VOLUME_RATIO) + const hasVolume = tsData.some( + (item) => typeof item.volume === 'number' && Number.isFinite(item.volume) && item.volume > 0, + ) + const layout = computeTimeSharePaneLayout(paneHeight, hasVolume ? VOLUME_RATIO : 0) const { volumeAreaHeight, priceAreaHeight } = layout const { start, end } = range @@ -68,8 +71,11 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost { xPositions.push(x) yPrices.push(scaleYToPriceArea(pane.yAxis.priceToY(item.price))) yAvgs.push(scaleYToPriceArea(pane.yAxis.priceToY(item.average))) - volumes.push(item.volume ?? 0) - maxVolume = Math.max(maxVolume, item.volume ?? 0) + if (hasVolume) { + const volume = item.volume ?? 0 + volumes.push(volume) + maxVolume = Math.max(maxVolume, volume) + } } if (xPositions.length < 2) return @@ -97,26 +103,28 @@ export function createTimeShareRendererPlugin(): RendererPluginWithHost { colors.timeShareAreaDown, ) - drawSegmentLine(ctx, xPositions, yPrices, dpr, colors.timeSharePriceLine, 2) + drawSegmentLine(ctx, xPositions, yPrices, dpr, colors.timeSharePriceLine, 1) - drawSegmentLine(ctx, xPositions, yAvgs, dpr, colors.timeShareAvgLine, 1.5) + drawSegmentLine(ctx, xPositions, yAvgs, dpr, colors.timeShareAvgLine, 1) ctx.restore() - drawVolumeBars( - ctx, - kBarRects, - volumes, - maxVolume, - volumeAreaHeight, - paneHeight, - preClose, - dpr, - colors.volumeUp, - colors.volumeDown, - colors.volumeNeutral, - tsData, - start, - ) + if (hasVolume) { + drawVolumeBars( + ctx, + kBarRects, + volumes, + maxVolume, + volumeAreaHeight, + paneHeight, + preClose, + dpr, + colors.volumeUp, + colors.volumeDown, + colors.volumeNeutral, + tsData, + start, + ) + } ctx.restore() }, diff --git a/packages/core/src/features/semantic/__tests__/controller.test.ts b/packages/core/src/features/semantic/__tests__/controller.test.ts index 3043e338..a9aa8483 100644 --- a/packages/core/src/features/semantic/__tests__/controller.test.ts +++ b/packages/core/src/features/semantic/__tests__/controller.test.ts @@ -8,6 +8,7 @@ function createConfig(indicators: SemanticChartConfig['indicators']): SemanticCh version: '1.0.0', data: { source: 'baostock', + market: 'CN', symbol: '600000', exchange: 'SH', startDate: '2025-01-01', @@ -87,6 +88,7 @@ describe('SemanticChartController', () => { expect(chart.setSymbols).toHaveBeenCalledWith([ { symbol: '600000', + market: 'CN', exchange: 'SH', period: 'daily', adjust: 'qfq', diff --git a/packages/core/src/features/semantic/__tests__/validator.market.test.ts b/packages/core/src/features/semantic/__tests__/validator.market.test.ts new file mode 100644 index 00000000..cbd4b991 --- /dev/null +++ b/packages/core/src/features/semantic/__tests__/validator.market.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { SemanticConfigValidator } from '../validator' + +const validConfig = { + version: '1.0.0', + data: { + source: 'baostock', + market: 'CN', + symbol: '600000', + exchange: 'SH', + startDate: '2025-01-01', + endDate: '2025-01-02', + period: 'daily', + adjust: 'qfq', + }, +} + +describe('SemanticConfigValidator market', () => { + it('rejects config without unified market metadata', async () => { + const validator = new SemanticConfigValidator() + const config = structuredClone(validConfig) as { data: Record } + delete config.data.market + + const result = await validator.validate(config) + + expect(result.valid).toBe(false) + expect(result.errors?.join(' ')).toContain('market') + }) + + it('accepts explicit unified market metadata', async () => { + const validator = new SemanticConfigValidator() + + await expect(validator.validate(validConfig)).resolves.toEqual({ valid: true }) + }) +}) diff --git a/packages/core/src/features/semantic/controller.ts b/packages/core/src/features/semantic/controller.ts index c6935862..ec6c4410 100644 --- a/packages/core/src/features/semantic/controller.ts +++ b/packages/core/src/features/semantic/controller.ts @@ -115,6 +115,7 @@ export class SemanticChartController { this.chart.setSymbols([ { symbol: config.data.symbol, + market: config.data.market, exchange: config.data.exchange, period: config.data.period, adjust: config.data.adjust, diff --git a/packages/core/src/features/semantic/schema.json b/packages/core/src/features/semantic/schema.json index 425158da..ce8aeeea 100644 --- a/packages/core/src/features/semantic/schema.json +++ b/packages/core/src/features/semantic/schema.json @@ -21,10 +21,11 @@ "$defs": { "DataConfig": { "type": "object", - "required": ["source", "symbol", "startDate", "endDate", "period", "adjust"], + "required": ["source", "market", "symbol", "startDate", "endDate", "period", "adjust"], "additionalProperties": false, "properties": { "source": { "type": "string", "enum": ["baostock", "dongcai"] }, + "market": { "type": "string", "minLength": 1 }, "symbol": { "type": "string", "pattern": "^[0-9]{6}$" }, "exchange": { "type": "string", "enum": ["SH", "SZ", "BJ"] }, "startDate": { "type": "string", "format": "date" }, diff --git a/packages/core/src/features/semantic/types.ts b/packages/core/src/features/semantic/types.ts index 2018361f..08ba7e5d 100644 --- a/packages/core/src/features/semantic/types.ts +++ b/packages/core/src/features/semantic/types.ts @@ -23,9 +23,11 @@ export type AdjustType = 'qfq' | 'hfq' | 'splits' | 'none' export interface DataConfig { source: 'baostock' | 'dongcai' + /** 图表统一市场标识,必须已由调用方归一化 */ + market: string /** 股票代码(6位数字,不含前缀) */ symbol: string - /** 交易所(可选,默认根据代码自动识别) */ + /** 交易所展示标识 */ exchange?: string /** 开始日期 YYYY-MM-DD */ startDate: string diff --git a/packages/core/src/foundation/plugin/types.ts b/packages/core/src/foundation/plugin/types.ts index 126bde4f..8470f083 100644 --- a/packages/core/src/foundation/plugin/types.ts +++ b/packages/core/src/foundation/plugin/types.ts @@ -285,6 +285,8 @@ export interface RenderContext { data: unknown[] /** K线级别,如 'daily'、'5min'、'15min' */ period: string + /** 当前图表实例解析后的市场交易时段 */ + marketSession?: import('../utils/sessionTimeLabels').MarketSessionConfig comparisonData?: ReadonlyMap> comparisonSymbols?: ReadonlyArray comparisonColors?: ReadonlyMap diff --git a/packages/core/src/foundation/types/price.ts b/packages/core/src/foundation/types/price.ts index eb661958..972ee0fe 100644 --- a/packages/core/src/foundation/types/price.ts +++ b/packages/core/src/foundation/types/price.ts @@ -42,8 +42,10 @@ export interface TimeShareData { timestamp: number price: number average: number - volume: number - amount: number + /** 分时成交量,单位手;上游未提供时缺失。 */ + volume?: number + /** 分时成交额,单位元;上游未提供时缺失。 */ + amount?: number } export function isTimeShareData(data: unknown[]): data is TimeShareData[] { diff --git a/packages/core/src/foundation/utils/sessionTimeLabels.ts b/packages/core/src/foundation/utils/sessionTimeLabels.ts index 8676df05..4aadd022 100644 --- a/packages/core/src/foundation/utils/sessionTimeLabels.ts +++ b/packages/core/src/foundation/utils/sessionTimeLabels.ts @@ -132,6 +132,32 @@ export function resolveMarketSessionSlots( return Math.max(0, Math.floor(minutes / step)) } +/** 将实际交易时间映射到 session 槽位,收盘边界归入前一槽。 */ +export function resolveTimestampSessionSlot( + timestamp: number, + config: MarketSessionConfig = ASHARE_MARKET_SESSION, +): number | null { + if (!Number.isFinite(timestamp)) return null + + if (Number.isNaN(new Date(timestamp).getTime())) return null + + const wall = getWallClockInTimeZone(timestamp, config.timeZone) + const minuteOfDay = wall.hour * 60 + wall.minute + const step = config.slotMinutes && config.slotMinutes > 0 ? config.slotMinutes : 1 + let offset = 0 + + for (const range of config.sessions) { + const slotCount = Math.floor((range.close - range.open) / step) + if (slotCount <= 0) continue + if (minuteOfDay >= range.open && minuteOfDay < range.close) { + return offset + Math.floor((minuteOfDay - range.open) / step) + } + if (minuteOfDay === range.close) return offset + slotCount - 1 + offset += slotCount + } + return null +} + /** * 将交易所墙钟 minuteOfDay 转为 UTC 毫秒。 * baseTimestamp 用于确定「哪一天」(按 timeZone 日历日)。 diff --git a/packages/core/src/foundation/utils/timeShareAxisLabels.ts b/packages/core/src/foundation/utils/timeShareAxisLabels.ts index 23fe064c..5fade6c6 100644 --- a/packages/core/src/foundation/utils/timeShareAxisLabels.ts +++ b/packages/core/src/foundation/utils/timeShareAxisLabels.ts @@ -4,6 +4,7 @@ import { computeSessionTimeLabels, countSessionSlots, minuteOfDayToTimestamp, + resolveTimestampSessionSlot, resolveMarketSessionSlots, sessionSlotCenterX, type MarketSessionConfig, @@ -83,6 +84,7 @@ export { computeSessionTimeLabels, countSessionSlots, minuteOfDayToTimestamp, + resolveTimestampSessionSlot, resolveMarketSessionSlots, sessionSlotCenterX, } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5749fac..b2c7702e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,12 @@ export * from './features/alerts' export * from './features/replay' export * from './features/chartTypes' export * from './features/indicators' +export * from './engine/market/marketSessionRegistry' +export * from './engine/market/resolveSymbolMarketSession' +export type { + MarketSessionConfig, + OpenTimeRange, +} from './foundation/utils/sessionTimeLabels' // ── Batch 5: Component data models ──────────────────────────────────────── export * from './components/volumeProfile' diff --git a/packages/react/src/__tests__/_mockController.ts b/packages/react/src/__tests__/_mockController.ts index f0775677..82373136 100644 --- a/packages/react/src/__tests__/_mockController.ts +++ b/packages/react/src/__tests__/_mockController.ts @@ -54,6 +54,7 @@ export function createMockChartController( viewport, data, dataLoading: createSignal(false), + dataError: createSignal(null), symbols: createSignal([] as ReadonlyArray), theme, settings, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 457c990e..4c59af64 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -181,6 +181,7 @@ export interface KLineChartProps { data: ChartMountOptions['data'] symbols?: ChartMountOptions['symbols'] dataFetcher?: ChartMountOptions['dataFetcher'] + marketSessions?: ChartMountOptions['marketSessions'] settings?: Partial initialZoomLevel?: number theme?: 'light' | 'dark' @@ -210,7 +211,18 @@ export interface KLineChartHandle { } export const KLineChart = forwardRef(function KLineChart( - { data, symbols, dataFetcher, settings, initialZoomLevel, theme, zoomLevels, className, style }, + { + data, + symbols, + dataFetcher, + marketSessions, + settings, + initialZoomLevel, + theme, + zoomLevels, + className, + style, + }, ref: ForwardedRef, ) { const divRef = useRef(null) @@ -225,6 +237,7 @@ export const KLineChart = forwardRef(function data, symbols, dataFetcher, + marketSessions, settings, initialZoomLevel, zoomLevels, diff --git a/packages/vue/preview/App.vue b/packages/vue/preview/App.vue index 56924f0e..e8bc1258 100644 --- a/packages/vue/preview/App.vue +++ b/packages/vue/preview/App.vue @@ -62,6 +62,7 @@ 现价 {{ timeshare.price.toFixed(2) }} 涨幅 {{ timeshare.changePercent.toFixed(2) }}% + 成交量 {{ timeshare.volumeText }} @@ -629,6 +630,7 @@ if (useCustomData.value) { customData.value = { symbol: 'CUSTOM.DEMO', + market: 'CN', period: 'daily', data: DEMO_MAIN_DATA, comparisons: { diff --git a/packages/vue/src/__tests__/_mockController.ts b/packages/vue/src/__tests__/_mockController.ts index 8d625363..f8585e2d 100644 --- a/packages/vue/src/__tests__/_mockController.ts +++ b/packages/vue/src/__tests__/_mockController.ts @@ -100,6 +100,7 @@ export function createMockChartController( viewport, data, dataLoading: createSignal(false), + dataError: createSignal(null), symbols: createSignal([] as ReadonlyArray), theme, settings, diff --git a/packages/vue/src/components/KLineChart.vue b/packages/vue/src/components/KLineChart.vue index 531165c2..6cdbf0f8 100644 --- a/packages/vue/src/components/KLineChart.vue +++ b/packages/vue/src/components/KLineChart.vue @@ -9,6 +9,7 @@ :k-line-adjust="kLineAdjust" :symbol-loading="symbolStatus === 'loading'" :symbol-error="symbolStatus === 'error'" + :symbol-error-message="symbolErrorMessage || undefined" :overlay-symbols="overlaySymbols" :overlay-symbol-items="overlaySymbolItems" :comparison-colors="comparisonColorsMap" @@ -252,10 +253,10 @@ routerSearchFetchers, getRegisteredFetchers, type ChartController, + type ChartMountOptions, type InteractionSnapshot, type LegendTemplateContext, type SymbolSpec, - type SymbolInfo, type CustomDataSource, } from '@363045841yyt/klinechart-core/controllers' import { @@ -314,6 +315,9 @@ import MarkerTooltip from './MarkerTooltip.vue' /** 数据获取函数(可选)。默认使用内置 routerDataFetcher,亦可由使用者注入覆盖。 */ dataFetcher?: DataFetcher + /** 当前图表实例的市场交易时段覆盖 */ + marketSessions?: ChartMountOptions['marketSessions'] + yPaddingPx?: number minKWidth?: number maxKWidth?: number @@ -425,69 +429,13 @@ import MarkerTooltip from './MarkerTooltip.vue' // ── Symbol / Comparison State ── - // Default symbol catalog — registered into the controller on mount so the - // dropdown picker shows a meaningful list out of the box. Consumers can - // replace/extend via ctrl.registerSymbols() after mount. - const DEFAULT_SYMBOLS: SymbolInfo[] = [ - // TradingView global - { symbol: 'XAUUSD', description: '现货黄金', exchange: 'OANDA', source: 'tradingview' }, - { - symbol: 'BTCUSDT', - description: 'Bitcoin / Tether', - exchange: 'BINANCE', - source: 'tradingview', - }, - { - symbol: 'ETHUSDT', - description: 'Ethereum / Tether', - exchange: 'BINANCE', - source: 'tradingview', - }, - { symbol: 'EURUSD', description: '欧元/美元', exchange: 'OANDA', source: 'tradingview' }, - { symbol: 'SPX', description: '标普 500 指数', exchange: 'SP', source: 'tradingview' }, - { symbol: 'AAPL', description: 'Apple Inc.', exchange: 'NASDAQ', source: 'tradingview' }, - { symbol: 'TSLA', description: 'Tesla, Inc.', exchange: 'NASDAQ', source: 'tradingview' }, - { symbol: '1810', description: '小米集团', exchange: 'HKEX', source: 'tradingview' }, - // gotdx A 股:必须带 params.market,与搜索目录一致,禁止按代码猜市场 - { - symbol: '600519', - description: '贵州茅台', - exchange: 'SH', - source: 'gotdx', - params: { market: 1 }, - }, - { - symbol: '601360', - description: '三六零', - exchange: 'SH', - source: 'gotdx', - params: { market: 1 }, - }, - { - symbol: '000858', - description: '五 粮 液', - exchange: 'SZ', - source: 'gotdx', - params: { market: 0 }, - }, - { - symbol: '000001', - description: '平安银行', - exchange: 'SZ', - source: 'gotdx', - params: { market: 0 }, - }, - // Mock - { symbol: 'MOCK-100', description: 'Mock 100 条', exchange: 'MOCK', source: 'mock-100' }, - { symbol: 'MOCK-10000', description: 'Mock 10000 条', exchange: 'MOCK', source: 'mock-10000' }, - ] - const kLineLevel = ref(props.semanticConfig?.data?.period ?? 'daily') const previousKLineLevel = ref('daily') const kLineAdjust = ref(props.semanticConfig?.data?.adjust ?? 'none') const isIntraday = computed(() => kLineLevel.value.includes('min')) const currentSymbol = ref('选择商品') const currentSymbolItem = ref(null) + const symbolErrorMessage = ref(null) const overlaySymbols = ref([]) const overlaySymbolItems = ref([]) const symbolPool = ref([]) @@ -514,15 +462,28 @@ import MarkerTooltip from './MarkerTooltip.vue' syncSymbolsToController() } + function formatUnsupportedSymbolMessage(item: SymbolItem, error: unknown): string { + const detail = error instanceof Error ? error.message : String(error) + if (!item.market?.trim() || /market is required|Market session is not registered/i.test(detail)) { + return `暂不支持该品种(${item.exchange || item.symbol})` + } + return detail || '切换品种失败' + } + function onSymbolChange(item: SymbolItem) { symbolStatus.value = 'loading' const ctrl = controller.value if (!ctrl) return - ctrl.setDataFetcher(effectiveDataFetcher.value) - ctrl.registerSymbols([item]) - const current = ctrl.symbols.peek() ?? [] - const comparisonSpecs = current.slice(1) - ctrl.setSymbols([toSymbolSpec(item), ...comparisonSpecs]) + try { + ctrl.setDataFetcher(effectiveDataFetcher.value) + ctrl.registerSymbols([item]) + const current = ctrl.symbols.peek() ?? [] + const comparisonSpecs = current.slice(1) + ctrl.setSymbols([toSymbolSpec(item), ...comparisonSpecs]) + } catch (error) { + symbolStatus.value = 'error' + symbolErrorMessage.value = formatUnsupportedSymbolMessage(item, error) + } } function onAddOverlaySymbol(item: SymbolItem) { @@ -531,9 +492,14 @@ import MarkerTooltip from './MarkerTooltip.vue' const current = ctrl.symbols.peek() const currentKeys = current.map(symbolIdentityKey) if (currentKeys.includes(symbolIdentityKey(item))) return - ctrl.registerSymbols([item]) - forcePercentAxis() - ctrl.addComparisonSymbol(toSymbolSpec(item)) + try { + ctrl.registerSymbols([item]) + forcePercentAxis() + ctrl.addComparisonSymbol(toSymbolSpec(item)) + } catch (error) { + symbolStatus.value = 'error' + symbolErrorMessage.value = formatUnsupportedSymbolMessage(item, error) + } } function onRemoveOverlaySymbol(identity: string) { @@ -543,6 +509,7 @@ import MarkerTooltip from './MarkerTooltip.vue' function toSymbolSpec(item: SymbolItem): SymbolSpec { return { symbol: item.symbol, + market: item.market, exchange: item.exchange, period: kLineLevel.value, source: item.source, @@ -1381,6 +1348,7 @@ import MarkerTooltip from './MarkerTooltip.vue' const ctrl = createChartController({ container, data: [], + marketSessions: props.marketSessions, canvasLayer, rightAxisLayer, leftAxisLayer, @@ -1462,6 +1430,11 @@ import MarkerTooltip from './MarkerTooltip.vue' } }) + symbolErrorMessage.value = ctrl.dataError.peek() + const unsubscribeDataError = ctrl.dataError.subscribe(() => { + symbolErrorMessage.value = ctrl.dataError.peek() + }) + const unsubscribeTheme = ctrl.theme.subscribe(() => { const newTheme = ctrl.theme.peek() chartTheme.value = newTheme @@ -1492,6 +1465,7 @@ import MarkerTooltip from './MarkerTooltip.vue' const unsubscribeSymbolCatalog = ctrl.symbolCatalog.subscribe(() => { symbolPool.value = ctrl.symbolCatalog.peek().map((info) => ({ symbol: info.symbol, + market: info.market, description: info.description ?? info.symbol, exchange: info.exchange ?? '', source: info.source ?? '', @@ -1502,6 +1476,7 @@ import MarkerTooltip from './MarkerTooltip.vue' // 不依赖 registerSymbols 在 subscribe 之前还是之后调用。 symbolPool.value = ctrl.symbolCatalog.peek().map((info) => ({ symbol: info.symbol, + market: info.market, description: info.description ?? info.symbol, exchange: info.exchange ?? '', source: info.source ?? '', @@ -1523,6 +1498,7 @@ import MarkerTooltip from './MarkerTooltip.vue' currentSymbol.value = primary.symbol currentSymbolItem.value = { symbol: primary.symbol, + market: primary.market, description: primaryInfo?.description ?? primary.symbol, exchange: primary.exchange ?? '', source: primary.source ?? '', @@ -1542,6 +1518,7 @@ import MarkerTooltip from './MarkerTooltip.vue' ) return { symbol: s.symbol, + market: s.market, description: info?.description ?? s.symbol, exchange: s.exchange ?? '', source: s.source ?? '', @@ -1554,6 +1531,7 @@ import MarkerTooltip from './MarkerTooltip.vue' unsubscribeViewport() unsubscribeData() unsubscribeDataLoading() + unsubscribeDataError() unsubscribePaneRatios() unsubscribePaneLayout() unsubscribeTheme() @@ -1669,10 +1647,7 @@ import MarkerTooltip from './MarkerTooltip.vue' // 4) 直接订阅 kernel 的 tooltip 信号,绕过 VNode _setupTooltipSub() - // Seed the default symbol catalog — subscribe 已建立, set 会触发回调刷新 dropdown - ctrl.registerSymbols(DEFAULT_SYMBOLS) - - // 3.5) 在任何 draw 之前注册主图指标(BOLL/MA 等) + // 在任何 draw 之前注册主图指标(BOLL/MA 等) // initIndicatorsFromConfig 是同步的,读 props.semanticConfig 即可注册, // 确保 scheduler 首次 applyResults 时 BOLL 已在 registry 里 initIndicatorsFromConfig(props.semanticConfig) diff --git a/packages/vue/src/components/SymbolSelector.vue b/packages/vue/src/components/SymbolSelector.vue index 621b2b37..b8b3dc28 100644 --- a/packages/vue/src/components/SymbolSelector.vue +++ b/packages/vue/src/components/SymbolSelector.vue @@ -11,7 +11,15 @@ > {{ displayText }}